Commit Graph
171 Commits
Author SHA1 Message Date
Martin Vogel bd11088fe3 Merge pull request #1585 from DeusData/fix/installer-scope
fix(cli): stop relocating a binary a package manager owns (#1566)
2026-08-13 13:21:31 +02:00
Martin Vogel 5e5dcd8ebc Merge pull request #1423 from PR9000/fix/freebsd-cli-self-path
fix(cli): add native FreeBSD self-executable path resolution
2026-08-13 12:33:38 +02:00
Martin Vogel 786c5864c7 Merge pull request #1421 from PR9000/fix/freebsd-activation-transaction
fix: guard activation_finish_absent_publish behind its call-site conditions
2026-08-13 12:32:53 +02:00
Martin Vogel 0e89ef87ba fix(cli): stop relocating a binary a package manager owns (#1566)
`install`/`update` bundle two separable jobs: place the binary (and put its
directory on PATH), and configure the agents. For someone who installed through
mise, Homebrew or nix, the first job is not redundant — it is harmful. It drops
a SECOND copy into ~/.local/bin that shadows the managed one depending on PATH
order, and appends to a shell rc file no package manager asked us to touch:

    Would install binary -> /Users/user/.local/bin/codebase-memory-mcp
    Added /Users/user/.local/bin to PATH in /Users/user/.zshrc

They wanted the agent configs refreshed and nothing else. Reported by @SC-CTS.

`--skip-config` (binary, no configs) has existed for a long time; this adds its
missing mirror. Per the user's decision:

- **Infer by default.** A recognised manager path means the binary is not ours;
  we configure the agents and leave the binary and PATH alone.
- **`--skip-binary` / `--force-binary`** override the inference either way.
- **`update` REFUSES** on a foreign binary and names the command that will work
  (`mise upgrade`, `brew upgrade`), rather than reporting success while changing
  nothing — the dishonest-success pattern this project keeps having to fix.
- **PATH is only touched when WE placed the binary**, which is a bug in its own
  right regardless of the rest.

Detection is POSITIVE evidence — the path must look like a known manager's — and
the first implementation got this wrong in the tempting way: "anything outside
our install dir". The test suite failed immediately and was right to. A test
binary runs from build/, and an ordinary `install --dir=/opt/cbm` also lives
outside the default; both would be misread as foreign, and `update` would then
refuse to update an installation we own. The asymmetry settles it: a false
positive costs a working update, while a false negative merely leaves today's
behaviour for an unrecognised manager, which --skip-binary covers explicitly.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-13 11:56:17 +02:00
Martin Vogel 4c793c4ded fix(windows): accept the owner Windows actually stamps on what we create
An elevated install created its own staging file and then refused it:

    error: failed to stage install candidate: activation transaction I/O
    failed: owner-not-current-user

We compared the object's owner against TokenUser. Windows stamps newly created
objects with TokenOwner, and for a member of the Administrators group that
defaults to BUILTIN\Administrators ("System objects: Default owner for objects
created by members of the Administrators group" — default on Server, common on
hardened clients). So on any elevated account the check rejected a file written
seconds earlier by the same process. Reported by @spam0115 (#1580); @gotspatel
hit the same wall from the other side (#1582) — a fresh install whose MCP server
exits during initialize, from `C:\Users\Administrator`.

The fix reads TokenOwner and accepts that SID too. This is stricter-or-equal,
never looser: TokenOwner is the ONE SID this process stamps on its own
creations, so the check still refuses anything a different account owns. It does
NOT accept "whatever an administrator owns" — that would be the loose version,
and the neighbouring activation_windows_owner_is_trusted already covers the
pre-existing-directory case where a broader rule is appropriate. On a
non-elevated account TokenOwner == TokenUser and nothing changes at all.

Windows-only code, so it is unverifiable on the macOS host: this needs the
Windows leg to compile and exercise, which is the next step before merge.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-13 10:04:55 +02:00
Martin Vogel 1e6825f912 fix(cli): install into an existing opencode.jsonc instead of a second file (#1560)
OpenCode reads either opencode.json or opencode.jsonc. We always wrote the
.json name, so a user whose real config is opencode.jsonc got a SECOND config
file that OpenCode ignores: the MCP server never appeared, while the installer
reported success. Silent, and indistinguishable from "the tool is broken".

The install target is now whichever file already exists, .jsonc first since it
is the one we used to miss. With neither present, .json is created exactly as
before, so fresh installs are unchanged. Other clients in this file (pochi,
kilo) already resolve .jsonc paths — OpenCode was the gap, not the exception.

Reported by @iandol in discussion #1560, who noticed their config was .jsonc
while the installer named .json. Verified by revert: the test fails without
the fix and passes with it.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-12 23:12:27 +02:00
Martin Vogel 63f26f4ce1 fix(cli): forward CBM_CACHE_DIR to the Codex-spawned server (#1562)
Codex sanitizes the environment of stdio MCP subprocesses and passes through
only the names listed in `env_vars`. Our generated block had `command` and
`args` and nothing else, so a user with a custom CBM_CACHE_DIR got a
Codex-spawned server reading the DEFAULT cache while their account daemon used
the configured one. The two disagree during initialization, the connection
closes, and Codex exposes no cbm tools at all — a total failure that looks like
a broken MCP server rather than a missing environment variable.

The name is listed unconditionally rather than only when the variable happens
to be set during install: `env_vars` names variables to forward IF PRESENT, so
listing it costs nothing when unset, and it keeps working for someone who sets
CBM_CACHE_DIR after installing — a case install-time detection would silently
miss, producing exactly this bug again with no way to tell.

Reported by @anton-matosov, who verified the corrected block end-to-end before
filing.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-12 13:23:27 +02:00
Martin Vogel 2bb94372a4 fix(cli): quote the skill description so strict YAML readers accept it (#1554)
The installed SKILL.md carried an unquoted `description` whose value contains
"Triggers on: ". In YAML a colon-space inside an unquoted scalar is a
nested-mapping indicator, so strict readers — js-yaml's `load`, the frontmatter
parser in `npx skills` — reject the whole document. The skill does not load
wrongly; it does not load at all, and nothing in the install output says why.

Quoting the scalar fixes it without touching a word of the text.

The test checks the RULE rather than this string: for every skill, any
frontmatter value containing ": " must be quoted. Pinning the current wording
would let the next skill someone adds reintroduce exactly this bug. Verified by
reverting the fix — the test goes red, and green again with it restored.

Reported by @xezzon.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-12 13:11:56 +02:00
42e320a6cb docs(pi): name the loader mechanism behind the default-export requirement
Two contributors independently sent the same fix for #1550 while ours was
already in CI (#1553 by @musichen, who also reported the bug, and #1564 by
@henri-edh). Rather than close both as duplicates, this takes the part of
theirs that ours was missing.

@henri-edh's comment names the actual mechanism: Pi loads an extension via
jiti.import(path, { default: true }) and rejects anything that is not a
function, so a named-only export hands the loader the module namespace object
and the install fails with "Extension does not export a valid factory
function". Ours said "Pi calls the default export" — true, but it does not tell
the next reader why a named export cannot simply be added alongside, which is
exactly the change someone would try. Their wording is now ours.

The assertion is tightened the same way, from `export default function` to
`export default function (pi)`, so a default export with the wrong shape cannot
satisfy it.

Co-Authored-By: musichen <musichen@users.noreply.github.com>
Co-Authored-By: henri-edh <henri-edh@users.noreply.github.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-12 12:52:46 +02:00
Martin Vogel ff853ac14b fix(cli): stop blaming sessions for a failure that has none (#1537)
A reporter ran `uninstall`, then could not install again: every attempt said
"active CBM sessions and operations could not be stopped safely". A reboot
proved no such session existed, and the remedy line v0.10.2 added made it worse
— it told them to run `codebase-memory-mcp daemon status`, a binary they had
just uninstalled.

Both halves were wrong for their case:

The guard funneled two unrelated outcomes into one message. A cohort that is
BUSY means real sessions hold it and closing something is the fix. A reservation
that FAILED — lock I/O, leftover coordination state, permissions — means nothing
is running at all, and telling that reader to close sessions sends them after
processes that do not exist. Same misattribution class as #1416 and #1535: a
non-session failure wearing a session costume. They are now separate messages,
and the failure one says plainly that nothing needs to be closed.

The remedy also has to survive the situation it prints in. Install, and any
retry after uninstall, runs with no cbm on PATH; advice that starts with
`codebase-memory-mcp ...` is unusable exactly when it is needed. The busy
message now leads with the actionable step (close the editor/agent holding it)
and mentions the command only as an aid where a binary still exists; the
failure message asks for a cache listing, which needs no cbm at all.

A recorded transaction refusal still outranks both generic messages, so the
#1416 attribution is unchanged.

This does NOT yet explain why their reservation fails with nothing running —
that needs their environment, which is being requested on the issue. What it
does is stop sending them after the wrong thing while we find out.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-12 11:52:38 +02:00
Martin Vogel a154d18a2e fix(cli): stop breaking commands people already type (#1544, #1550)
Two regressions we shipped ourselves, both with the same shape: a change that
was correct in the abstract broke a concrete thing users had working.

**`update --ui` became a hard error (#1544).** v0.10.2 removed the ui/standard
chooser and deleted the flags that drove it, so a command in scripts, aliases
and muscle memory started failing with "unknown update option" where v0.10.1
had printed the install-script handoff. Retiring a CHOICE is fine; retiring the
WORDS is a break users get nothing for. Both flags are accepted again, do
nothing, and print one note explaining that every build now includes the UI.
A genuinely unknown flag is still rejected — the point is compatibility, not
silence. Reported by @ehendrix23.

**The generated Pi extension could not load (#1550).** Since #1392 the
extension has been generated from the tool registry, emitting
`export function register(pi)`. Pi loads an extension by calling its DEFAULT
export as a factory, so the file failed to load — and a Pi extension that fails
to load takes every `pi` command with it, `pi doctor` included, not just cbm's
tools. It now default-exports the factory. Reported by @musichen.

The existing generator test passed throughout: it asserted WHICH tools the file
lists, never that the file Pi loads is loadable. The new test pins the export
shape, which is the property that actually matters to the consumer.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-12 11:52:38 +02:00
Martin Vogel 8eabe191d2 fix(install,daemon): unbreak npx clients, group-writable homes, and legacy updaters
Five field reports in the 24 hours after v0.10.0 all pointed at the same thing:
gates that were right in principle refused real, ordinary setups, and then
failed to say why. Per the consolidated strictness decision, each gate keeps the
protection that matters and drops the part that was refusing legitimate users —
and every refusal now names what it refused and how to proceed.

**Daemon image gate: npx and every ephemeral install path (#1539, #1383).**
The admission check treated "the peer's image hashes differently" and "the
peer's image cannot be examined at all" as one failure. The second is what
`npx codebase-memory-mcp` always produces (ephemeral cache path,
unfingerprintable), so every npx-invoked client was rejected — and, because the
client never reported it, agents saw a transport that closed mid-handshake with
zero bytes on stdout. Reported by @wassolles with the admission path already
read and the fix space mapped.

An unverifiable image is now admitted: the rendezvous HELLO immediately above it
has already proven semantic version, build fingerprint, and protocol/store/
feature ABI, and the image check was trading that real proof for an unavailable
one. It logs daemon.client_image_unverifiable_admitted so the weaker check is
never invisible. A fingerprint MISMATCH — the tamper case the gate exists for —
still rejects hard. Separate test seams keep the two modes testable apart.

**Client bootstrap failures are no longer silent (#1539).**
An MCP client that cannot reach the daemon now emits a JSON-RPC error on stdout
naming the reason, plus the same text on stderr. Previously the reason sat in
bootstrap_result.message and the process exited having written nothing at all.

**POSIX activation: group-writable ancestors (#1535, discussion #1526).**
activation_directory_secure required no group or other write bit on the install
directory AND every ancestor. WSL2 ships ~ and ~/.local at 0775, as do several
distro skeletons and any site using a shared primary group, so install.sh failed
for a large fraction of Linux users — reporting a policy refusal as "activation
transaction I/O failed", which sent reporters after disk errors and filesystem
types. Root-caused by @AmirF194 in a clean ubuntu container; @shochdoerfer and
@iandol confirmed independently.

World-writable ancestors are still refused (any local user could swap a path
component mid-transaction). Group-writable ancestors are now warned about and
admitted. The LEAF directory stays strictly owner-private — that is where the
binary is published, and group write there would let another account replace the
executable between validation and exec. Refusals now name the directory, its
mode, and which rule refused.

**The obsolete ui/standard chooser (#1538, from discussion #1526).**
v0.10.0 consolidated to one archive per platform with the UI always embedded,
but `update` still offered a variant choice: "ui" could only 404, and "standard"
quietly WAS the UI build. Reported by @iandol upgrading 0.9.0 -> 0.10.0. The
chooser, its --standard/--ui flags, and the ui- URL plumbing are removed, along
with the CBM_VARIANT=ui remnant in the npm installer.

Already-released 0.9.x binaries cannot be fixed retroactively, so the release
workflow now publishes byte-identical ui-*-named alias assets — their updaters
work again with no user action. The aliases are uploaded AFTER the VirusTotal
gate: they are the same bytes as archives it already cleared, and uploading them
earlier would duplicate every object in the scan set and the provenance manifest.

**macOS install noise and attribution (#1537).**
install.sh silenced the "No such xattr: com.apple.quarantine" line, which is
what happens when a curl-downloaded archive carries no quarantine attribute —
harmless, and it became the title of a bug report about an unrelated failure.
The session-stop refusal now points at `daemon status` to list the client
processes actually holding the daemon, instead of asserting sessions exist and
leaving the reader to guess. Reported by @listepo.

**Riders.** hatchling is pinned in pkg/pypi (an unpinned backend resolved fresh
inside `python -m build` is what emitted Metadata-Version 2.5 and broke the
v0.10.1 publish); SECURITY.md's supported-versions table moves to 0.10.x.

Tests: separate seams for unverifiable vs mismatched peer images with a test per
outcome; activation refusal must name directory + mode + rule; a group-writable
ancestor must stage successfully. The update tests drop the flag that no longer
exists. Verified against each reporter's environment shape.

**Open security alerts (all three, OSSF Scorecard).**
- HIGH, binary artifact: an 8.8 MB compiled Go ELF wrapper had been committed at
  pkg/go/codebase-memory-mcp by accident. Removed, and both it and its .exe
  sibling are gitignored so `go build` in that directory cannot repeat it.
- HIGH, GHSA-2v37-7h3g-55p8: nanoid < 3.3.17 loops forever when a custom
  generator is called with size 0. It reaches us transitively (postcss -> vite),
  so it is pinned through the existing graph-ui overrides block rather than
  promoted to a direct dependency; the lockfile resolves 3.3.18.
- MEDIUM, unpinned pip command: the publish step installed build/twine by
  version only, leaving the whole transitive graph resolved at run time.
  pkg/pypi/requirements-publish.txt now hash-pins the complete toolchain (316
  hashes), generated on a linux/amd64 python:3.12 image so the wheels match what
  ubuntu-latest resolves, and the step runs pip with --require-hashes. Verified
  by installing from it in that same image.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-11 13:37:32 +02:00
Martin Vogel f59d24fd10 fix(mcp): make every tool reply client-usable — structuredContent, pipelining, config get (#1522)
Three regressions shipped in 0.10.0 share one failure shape: an empty result
with a success status, indistinguishable from "nothing found" for the LLM
clients that are cbm's primary consumers.

1) structuredContent {} on the whole tree-format surface (#1522 bug 1).
   #1488 replaced the duplicated payload with an EMPTY structuredContent
   object while every tool still declared a blanket permissive outputSchema.
   Spec-honoring clients (Claude Code among them) treat structuredContent as
   THE result when a schema is declared, so search_graph, trace_path,
   query_graph, get_architecture, search_code, and detect_changes all rendered
   as literally "{}" on their DEFAULT format, on every platform. The corrected
   contract: no tool declares an outputSchema (tool output is
   format-parameter-polymorphic — no static schema is truthful), JSON-object
   payloads keep their parsed structuredContent, errors keep
   structuredContent.error, and text-shaped payloads carry NO structuredContent
   key at all — which also preserves #1375's no-duplication win.

2) Frontend queue overflow killed the session (found by the #1522 sweep).
   Any 7+ requests pipelined in one stdin burst — an agent issuing parallel
   tool calls does exactly this — overflowed the 8-frame frontend queue, which
   failed the whole session: rc=1 with ZERO bytes of output, every buffered
   response lost. A full queue is now backpressure: the stdin reader blocks
   until the worker drains (bounded by the same stop/fail flags every teardown
   path already sets); only a single frame larger than the entire 12 MiB byte
   budget — which could never be admitted — remains a hard failure.

3) config get printed "" with exit 0 for every unset and every unknown key
   (#1522 bug 2). list printed stored-or-DEFAULT while get printed
   stored-or-EMPTY, and no subcommand validated key names, so a typo was
   indistinguishable from a correctly-read setting. One config-key table now
   drives help, list, get, set, and reset: get prints the stored value or the
   key's real default (the same fallback the runtime readers use), and unknown
   keys error with exit 1 on get, set, and reset alike.

Tests — each RED on the pre-fix tree and RED again on revert:
  * test_mcp.c: text results carry no structuredContent key; tools/list
    declares no outputSchema; the tool-table guard now binds all three
    branches (absent / parsed-object / error) for every registered tool.
  * test_daemon_frontend.c: the over-capacity contract flips from
    "session fails" to "backpressure without loss" — the held first request
    plus all 32 over-capacity frames are answered and the run closes cleanly.
  * test_cli.c: the config command contract — defaults, round-trip, reset,
    and unknown-key rejection on all three subcommands.
  * smoke-test.sh: Phase 3z rewritten to the corrected structuredContent
    contract, new Phase 3z1 (default-format replies usable in schema-honoring
    clients, no outputSchema advertised), 3z2 (24 pipelined calls all
    answered), 3z3 (config defaults + unknown-key rejection) — all asserted
    against the SHIPPED artifact, where #1488's smoke phase previously pinned
    the empty-object behavior as correct.

Verified end-to-end on the locally built production binary: all 15 tools
declare no schema; text tools return ABSENT structuredContent, object tools
populated, error envelopes intact; 7/24/64-deep pipelined bursts all answered
with rc=0; config get prints real defaults and exits 1 on unknown keys.
hook_augment (structuredContent.projects) and index_resilience
(structuredContent.status) consume object payloads and are unaffected.

Fixes #1522.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-11 01:12:59 +02:00
Martin Vogel 57f30e28e3 style(cli): clang-format the uninstall --dir target resolution
Ran the format gate before adding --dir rather than after, so the ternary
landed unformatted and lint/lint went red. Reformatted with the pinned Homebrew
LLVM clang-format; the diff is confined to that block and cli.c still compiles.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-09 19:56:56 +02:00
Martin Vogel a3ae04571a fix(cli): restore uninstall --dir so a custom install stays removable
The Windows long-path guard failed with:

  error: unknown uninstall option: --dir=C:\...\install_Ωμέγα\...\bin

Restoring cli.c from before the externalization dropped --dir from uninstall
while install kept it. That is worse than a test failure: anyone who installed
outside ~/.local/bin had no supported uninstall path at all — uninstall would
look in the default location, find nothing, and leave the real install in place.

Parsing mirrors cbm_cmd_install (--dir=PATH and --dir PATH, both rejecting an
empty value), and the resolved directory now feeds the target path with an
explicit truncation check instead of an unchecked snprintf into a fixed buffer.

Confirms the earlier cascade read: with the extended-path probes fixed,
daemon_stability, hook_augment, cli_non_ascii_arg and ui_drive_listing all went
green on their own. non_ascii_path was the only real failure, and it was hiding
two distinct product regressions behind one red guard.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-09 19:40:04 +02:00
Martin Vogel 1ba668cded fix(cli): keep the install/uninstall existence probes extended-path safe
test_non_ascii_path.py caught a real product regression I introduced:

  [FAIL] long-path existing target was overwritten despite --no
         (target existence probe is not extended-path safe)

Restoring cli.c from before the externalization also reverted three probes from
cbm_path_info_utf8() back to plain stat(). On Windows stat() resolves through
the ANSI code page, so an extended-length or non-ASCII target reports "absent" —
and a non-force `install --no` then overwrites the exact bytes the user asked to
keep. Silent data loss, not a cosmetic difference.

That fix rode along in the externalization commit but is orthogonal to it: the
wide-path helpers it depends on (win_utf8.h, compat_fs.c) were kept. Only their
call sites in cli.c were lost.

Two of main's five cbm_path_info_utf8 sites lived in asset code that is
correctly gone; the two restored here are the install target probe and the
uninstall binary probe.

Not yet explained: test_daemon_stability.py also went red in the same run
("daemon did not accept the UI configuration"). Its code path is identical to
main's apart from the intended capability predicate, and the guard that failed
before it completes an install into a long path it should have refused, so this
may be downstream state. Pushing this fix to find out rather than guessing.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-09 19:13:47 +02:00
Martin Vogel d58afe562d revert(release): re-embed runtime assets into the single shipped binary
Externalizing the integration templates (#1492/#1493) and the UI bundle
(#1501/#1503) was done to reduce the Microsoft `Wacatac.B!ml` surface. It did
not work: across dry runs the flagged artifact count stayed at ~3 and the
detections merely moved between artifacts.

Dissection of run 31286803592 shows there is no structural cause to fix. The
verdicts split across every axis at once — linux-amd64 (dynamic) flagged while
linux-amd64-portable (static) is clean, but linux-arm64 (dynamic) clean while
linux-arm64-portable (static) is flagged. The two macOS binaries have identical
segment structure and split clean/flagged. Siblings from one build landed in
different variant buckets (.B vs .C). Entropy is low everywhere
(code_vectors.bin 4.166, grammar tables 3.464 bits/byte, against 7.5-8.0 for
packed payloads), so the packed-payload hypothesis is excluded too.

So the complexity bought nothing, and installation goes back to being
self-contained: one binary that carries its own UI and agent integration
templates, with no adjacent data file that has to resolve before `install`
works. Only the UI-capable composition ships from now on, under the historical
unsuffixed archive name.

Removed: src/ui/asset_pack.{c,h}, asset_pack_stub.c, asset_manifest_stub.c,
scripts/pack-ui-assets.mjs, src/cli/integration_assets.{c,h},
assets/cbm-integrations.json, scripts/gen-integrations-hash.sh, the
--verify-runtime-assets probe (nothing adjacent left to verify), and the
composition gates A6/A7 whose property is now deliberately inverted.

Restored: scripts/embed-frontend.sh, src/ui/embedded_{assets.h,stub.c}, the
compiled-in hook/adapter template bodies, and the embed/EMBED_OBJS build path.

Kept from the reverted commits, re-applied by hand where a wholesale file
restore would have dropped them:
  - cbm_module_path_utf8() in both self-path sites. GetModuleFileNameA renders
    through the ANSI code page and mangles non-ASCII install paths.
  - the /__cbm/ui-readiness HMAC proof, secure_random and cbm_hmac_sha256, so
    `daemon start --open` still waits for a genuine CBM listener.
  - X-Content-Type-Options: nosniff on served assets.
  - the MinGW noexecstack gate, -lbcrypt, and the cppcheck/zip CI fixes.

Archives are now codebase-memory-mcp-<os>-<arch>[-portable] with exactly four
members (binary, LICENSE, installer, THIRD_PARTY_NOTICES.md). That restores the
names every static package manifest already points at — aur, chocolatey,
homebrew, scoop, winget and glama were all broken by the -ui- rename.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-09 13:06:42 +02:00
Martin Vogel 8018561cfe fix(release): externalize runtime assets and harden VT verification
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-08 17:35:05 +02:00
Martin Vogel a6f132bc85 harden: ship integration templates as a verified JSON asset, not embedded bytes
The product binary embedded nine complete shebang'd shell scripts, their
PowerShell/.cmd twins, and two node:child_process client-adapter modules as C
string literals, and wrote them into agent config dirs at 0755 on install. A
block of script-shaped bytes inside a large unsigned executable is exactly the
surface Microsoft Defender's ML scored Trojan:Script/Wacatac.B!ml — the
detection that has recurred across the last several release attempts, on the
Linux ELF and darwin-arm64 standard binaries while every UI and Windows variant
stayed clean.

This moves every integration template out of the binary into a single shipped
data file, assets/cbm-integrations.json (compact: one asset, because each
shipped file is itself scanned). The binary now carries only the templates'
identity — one embedded SHA-256, generated at build time from the JSON so it
can never drift — plus the code to load, verify, and render them.

Why a hash and not just a loose file: content compiled into the binary was
tamper-evident with the binary. Moving it to disk must not turn it into
unauthenticated code the installer blindly executes. install verifies the asset
against the embedded SHA-256 and fails closed on mismatch ("integration assets
missing or modified - reinstall from the release archive") before writing
anything. Verified end to end: a one-byte edit to the asset makes install
refuse.

Ownership, for safe upgrade/removal: the verified copy is published to
~/.cbm/assets/<version>/ — a sibling of the DB cache, never inside it, so
clearing the cache cannot strip a user's hooks. A deployed integration file is
ours to remove only if it matches a template rendered from that stored copy;
foreign files and user-edited files at the reserved paths are preserved. The
historical template bodies travel in the JSON's released[] arrays so files
written by older versions are still recognised and cleaned up on uninstall.

Removed from src/: cmm_gate/session/subagent script-prefix and suffix
constants, cbm_build_released_gate_script, the shell/PowerShell/.cmd bodies in
cli.c, and the JS/TS generators in client_adapter.c. Confirmed on the built
standard binary: 0 occurrences of '#!/usr/bin/env bash', '#!/bin/sh',
'node:child_process', and 'ExecutionPolicy' (was non-zero for each).

test_no_embedded_scripts_contract.sh makes the removal a build property, not a
reachability assumption: no production C source may contain a shebang or
node:child_process literal, and the built binary is scanned directly. It strips
comments first (a contract a comment can satisfy is a false guard), and is
revert-checked — reintroducing one shebang literal makes it fail naming the
file:line. check-binary-composition.sh (A6) enforces the same needles on every
release artifact post-strip.

This does not, by itself, prove the detection is cured: the clean UI binary is a
strict superset of the flagged standard one, so no single passage is a
deterministic trigger. It removes the most coherent malware-shaped composite
from every shipped binary, aligns with how gh/starship/zoxide externalise
integration material, and makes the templates maintainable as data rather than
escaped C literals. Causal confirmation still needs a VirusTotal ablation.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-08 02:58:20 +02:00
Martin Vogel 1b8beb266f harden(cli): stop shipping a policy-bypass invocation inside the binary
`cbm update` printed the Windows update command with an execution-policy
override baked in as a string literal. That put one of the most heavily
weighted malicious-script patterns there is inside every Windows artifact we
distribute — to save the user a single documented step.

The command now prints without it. Functionality is unchanged: the user still
gets a runnable command, and the failure it was pre-empting (PowerShell refusing
a file that arrived from the internet) is answered by Unblock-File, which the
message now names. The restricted-policy case is documented in the README's
Windows install section, which is the right home for that incantation — a
shipped binary is not.

Scope note, so this is not oversold: the 2026-08-07 dry-run detections are NOT
attributable to this string. It is `#ifdef _WIN32`, and every Windows artifact
scanned clean; the three flagged artifacts were Linux and macOS. This is margin
hygiene, not a fix for that regression — see the analysis in the PR.

Verification limit worth stating: the built-artifact check for this string is
vacuous on a non-Windows host, since the literal was Windows-only to begin with.
What is verified here is the source (no occurrence outside a comment) plus lint
and the cli suite; a real artifact check needs the Windows build leg in CI.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-07 20:49:46 +02:00
Martin Vogel d35589cf1b fix(cli): make --dry-run predict a refused hook-script rewrite (#1387)
The dry run claimed EVERY hook group as installable because gate_ok /
session_ok / subagent_ok were simply set to `true` when dry_run was on. When
the on-disk hook script is not ours - user-modified, or written by a manual
install pointing at another binary - the real install refuses to rewrite it,
so the preview promised what the run could not deliver. That is the second
half of #1387: the reporter had no way to see the loss coming, and said so.

- New read-only predicate cbm_text_owned_document_status() mirrors
  text_migrate_owned_document's decision WITHOUT writing: absent / already
  current / an exact released document => a write would proceed; anything else
  => it would be refused.
- The dry run uses it per script, so the three 'hooks:' lines now reflect what
  will actually happen, and each refused script is NAMED with the reason.
  Silence was the original defect: an unmentioned group reads as 'nothing to
  do', not 'this will be skipped'.

Test: cli_dry_run_predicts_refused_hook_script_issue1387 runs the real
dry-run path over an unowned gate script, capturing stdout, and asserts the
warning names the script. RED before, GREEN after, RED again on revert.
cli+agent_profiles+config_text_edit: 292 passed; lint-ci clean.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-06 16:41:27 +02:00
Martin Vogel 856ec69f84 Merge remote-tracking branch 'origin/main' into fix/1388-hook-conflict-notice
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>

# Conflicts:
#	tests/test_cli.c
2026-08-05 17:18:17 +02:00
Martin Vogel ea466487e8 test(hook): bind the conflict notice with a portable reproduce-first test
The end-to-end shell regression could not run on CI (recorded in the
previous commit), which left the fix without a gating test. Move the
notice DECISION out of main.c - which is not linked into the test
runner, and is why no C test could reach it - into src/cli/
hook_augment.c, which is. main.c now calls the shared API for both the
absent-daemon and build-conflict cases instead of carrying its own
copies of the strings, so the test binds the real production path
rather than a parallel one.

cli_hook_conflict_emits_stdout_notice_issue1388 asserts what the bug
actually broke: a build conflict yields a stdout systemMessage naming a
different build and pointing at 'daemon stop'; the absent-daemon notice
stays distinct and keeps pointing at 'daemon start' (which cannot heal
a conflict); and non-Claude dialects receive no bare stdout JSON, whose
channel it would corrupt.

RED-on-revert verified: restoring the pre-fix behaviour (conflict emits
nothing on stdout) fails this test alone, for the right reason
('conflict is NULL'). cli suite 258 passed, the local end-to-end shell
test still green against a seam-bearing binary, lint-ci clean.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-05 15:56:18 +02:00
Martin Vogel c9bbd880c7 Merge pull request #1449 from DeusData/fix/1416-activation-refusal-attribution
fix(cli): attribute activation refusals to the recorded safety check (#1416)
2026-08-05 03:28:32 +02:00
Martin Vogel 2044c5c657 fix(cli): attribute activation refusals to the recorded safety check (#1416)
Windows users hit 'active CBM sessions and operations could not be
stopped safely; no activation was committed' from install, uninstall
and doctor with ZERO CBM processes running - they rebooted, killed
phantom handles, and deleted runtime folders chasing sessions that did
not exist. The real failure was the activation transaction's Windows
ACL safety check refusing a directory carrying cross-account mutation
grants (commonly the stock Authenticated Users:(M) inheritance from a
drive root - reproduced on the Windows VM with a plain mkdir under C:\
and with an icacls-granted parent), and the refusal detail was recorded
internally but never shown.

cli_activation_diagnostic now prefixes the recorded refusal note - the
predicate, SID and path - plus one remediation line (remove the flagged
grant or use an owner-private directory). The sessions wording remains
for genuine stop/reservation failures, which record no note.

Adds a CBM_ENABLE_TEST_SEAMS setter for the refusal note so the
attribution is testable portably; the regression test asserts the note
reaches the diagnostic (and that the no-note path keeps the sessions
text). RED before the fix, GREEN after, RED again on revert. Verified
end-to-end on the Windows VM: uninstall/doctor against an ACL-tainted
tree now name the ACL check instead of sessions.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-04 21:35:24 +02:00
Martin Vogel 55c2994b08 fix(install): never remove existing hook entries on a refused script rewrite (#1387)
Installing 0.9.1-rc.1 over an existing setup deleted the user's working
Claude Code hook entries: when the on-disk hook script is not byte-owned
(a manual install embedding another binary path, or a user-modified
reminder), cbm_write_owned_hook_script_with_legacy correctly refuses the
rewrite (TEXT_UNOWNED) - but the failure branch then called
cbm_remove_claude_hooks / cbm_remove_session_hooks /
cbm_remove_claude_subagent_hooks, destroying the registrations for
scripts that still exist and work. The reporter lost their PreToolUse
Grep|Glob entry and all four SessionStart entries.

A refused script write now records the error and leaves settings.json
untouched; entry removal belongs to uninstall only (which keeps its own
removal path, cli.c:9622).

Regression test builds the reporter's state - unowned gate script with a
foreign BIN path, user-modified session reminder, 0.9.0-form settings
entries plus a foreign Bash hook - runs the full install, and asserts
every pre-existing entry and both script bodies survive byte-identically
while the failure is still reported. RED before this fix (entries
deleted), GREEN after, RED again on revert.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-04 17:54:20 +02:00
Martin Vogel e512c80e58 Merge pull request #1408 from dergachoff/fix/codex-agent-toml-transport
fix(install): declare a transport in generated Codex agent role TOMLs
2026-08-04 17:10:45 +02:00
Martin Vogel 66e9aec28d Merge pull request #1392 from DeusData/feat/generated-client-adapters
feat(install): generate pi and OpenCode extensions from the tool registry
2026-08-04 13:52:09 +02:00
Martin Vogel 80afcd6bbe fix: close the memory-error paths the clang-analyzer lane surfaced
The memory-diagnostics report's priority-4 lane (path-sensitive clang-analyzer,
memory checks only) run over all 111 production files. 21 findings triaged;
the real ones, all cold-path (none can explain #581's per-query residual):

LEAKS
- mcp get_architecture: scope_path leaked on the missing-store early return
  (REQUIRE_STORE frees only `project`); allocate after the gate.
- pass_definitions: cancellation mid-extraction leaked the pass-owned result
  cache including already-extracted entries; mirror the end-of-pass cleanup.
- store package-boundary scan: the row-scan abort path freed the node arrays
  but not the boundary accumulators or their duplicated package strings.
- cbm quarantine set: a duplicate path line leaked the replaced value (and a
  fresh key copy -- the table borrows key pointers); a partial strdup failure
  leaked the surviving half. Reuse the stored key for duplicates.
- pass_githistory: unchecked malloc/strdup -- an OOM dereferenced NULL and a
  failed strdup leaked the index cell. Allocate before claiming the slot.

NULL/UB
- cli config subcommand: NULL argv with nonzero argc slipped the guard (the
  inner `argv &&` shielded only the help comparison) into argv[0].
- store bfs_multi: a negative max_results broke out before any row was
  written, then freed fields of an unwritten negative-index slot. Clamp.
- pass_calls emit_http_async_edge: the service-pattern call sites pass a NULL
  target behind a hand-duplicated URL predicate; a drift between the copies
  turned target->id into a null deref. The callee is now total.
- sqlite_writer: both leaf-array OOM paths left leaf_count stale with a NULL
  array, walking pb_finalize_* into leaves[0]; consistent empty state routes
  them to the existing root=0 failure return.

HARDENED (invariants true but invisible to path-sensitive analysis)
- Leiden CSR + aggregate arrays, SCC adjacency: calloc + endpoint guards, so
  a future degree/collection miscount degrades benignly instead of UB.
- SCC cycle fill: the ncyc==0 no-slot invariant made local.

RECORDED FALSE POSITIVES (no code change)
- yaml sequence starts (loop bound == alloc bound), cypher agg arrays (same
  count both sides), mcp read_message ch (assigned by fgetc each iteration),
  pkgmap clean buffer, mcp csize (Tarjan: ncomp>=1 when nverts>=1), vendored
  verstable x2.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>

GATE + LANES (user decision: runner cost accepted)
- make lint-mem (local triage) and lint-mem-ci (gating: vendored-filtered,
  any remaining finding fails). The gate is green because every false
  positive above was restructured for provability -- calloc'd fill-cursor
  arrays, explicit Tarjan invariant, zeroed buffer tails, min-1-element
  allocations -- never suppressed.
- make diag: pinned newest-LLVM ASan/UBSan lane with straighter stacks.
- CI: lint-mem job (_lint.yml) and test-diag job (_test.yml), both on the
  pinned LLVM 22 apt toolchain. Cost disclosure: roughly +25-40 min and
  +25-60 min (ccache-warm) per push respectively.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-03 15:37:33 +02:00
Pedro Ramos 305b89bfa8 fix(cli): add native FreeBSD self-executable path resolution
cbm_detect_self_path had no branch for FreeBSD, falling through to the
generic #else that reads /proc/self/exe. In this environment /proc is a
native FreeBSD procfs mount left empty (only /compat/linux/proc is
populated, for Linux-ABI processes under the compat layer), so the
readlink always failed silently and the function fell back to the
expected install path instead of the actual running binary's path.

This broke install: with no valid self path, the activation transaction
could never confirm what to publish and aborted early with
"activation transaction I/O failed" before attempting any linkat/rename.

Add sysctl(CTL_KERN, KERN_PROC, KERN_PROC_PATHNAME, -1) for FreeBSD,
matching the pattern already used in the test suites, and include
<sys/sysctl.h> guarded by __FreeBSD__.

Signed-off-by: Pedro Ramos <131530838+pr9000@users.noreply.github.com>
2026-08-03 13:08:21 +01:00
Pedro Ramos a84ef75b5b fix: guard activation_finish_absent_publish behind its call-site conditions
Building on FreeBSD after syncing with upstream failed with
-Werror,-Wunused-function on activation_finish_absent_publish in
src/cli/activation_transaction.c.

The function is only invoked from three platform-specific branches:
Windows (_WIN32, MoveFileExW), macOS (__APPLE__, renameatx_np), and
modern Linux (__linux__ with SYS_renameat2, via syscall). On every other
POSIX target — including FreeBSD and other BSDs — control falls through
to the portable linkat(2) fallback, so the helper was defined but never
called, tripping -Wunused-function under -Werror.

Rather than excluding FreeBSD by name (which would still break on
OpenBSD, NetBSD, DragonFly, Solaris, or any Linux without
SYS_renameat2), wrap the function definition in the same #if condition
as its call sites. This keeps the guard mechanically tied to actual
usage instead of enumerating platforms, so it stays correct as new
targets are added.

Signed-off-by: Pedro Ramos <131530838+pr9000@users.noreply.github.com>
2026-08-03 11:50:57 +01:00
Ivan Dergachev 07885ef6a9 fix(install): declare a transport in generated Codex agent role TOMLs
Codex deserializes ~/.codex/agents/*.toml standalone: an
[mcp_servers.codebase-memory-mcp] table with only enabled_tools fails
with "invalid transport" and the whole role is dropped, so all three
generated roles have been dead on Codex since they first shipped in
v0.9.1-rc.1.

Render command = <installed binary> plus
args = ["--tool-profile=scout|analysis"] in direct Codex profiles,
mirroring the Kiro renderer, and keep enabled_tools. Recognize the
rc.1 transport-less rendering as a released shape so install migrates
those files in place and uninstall still removes them; pass the
installed binary path in the Codex uninstall set so exact-content
ownership keeps matching.

Fixes #1391

Signed-off-by: Ivan Dergachev <dergachoff@gmail.com>
2026-08-01 17:36:21 +04:00
Martin Vogel 029416fda6 Merge remote-tracking branch 'origin/main' into qa/call-reference-rebase
# Conflicts:
#	Makefile.cbm
#	internal/cbm/extract_defs.c
#	src/foundation/compat_fs.c
#	src/foundation/compat_fs.h
#	src/pipeline/pipeline.c
#	src/pipeline/pipeline_incremental.c
#	tests/test_main.c
#	tests/test_pipeline.c
#	tests/test_store_checkpoint.c
2026-08-01 01:29:20 +02:00
3a5790b075 feat(install): install generated pi and OpenCode extensions
Wires the registry-driven generators into install, plan and uninstall.

pi gets ~/.pi/agent/extensions/cbmem.ts, its only route to the graph -- pi has
no MCP client. OpenCode gets ~/.config/opencode/plugins/cbm-augment.ts, which
adds no tools (it already reaches all of them over MCP) and supplies only the
automatic graph lookup before a grep/glob that other clients get from their own
hook configuration. OpenCode has none; its plugin system is the only extension
point, verified against their plugin documentation.

Both go in as a MARKED BLOCK rather than a whole-file write. Those directories
are auto-loaded, so a user may legitimately keep their own module there, and an
install routine that clobbers it is destroying user content. Uninstall removes
only our block and leaves the rest of the file, matching every other uninstall
path here.

A generator returning NULL is a hard error rather than a skip. A silently absent
extension is precisely the failure that left the earlier proposal a no-op for six
weeks, so it must surface as an install error instead of a quiet success.

Note for reviewers: the generated body deliberately carries NO ownership
markers. cbm_text_upsert_managed_block adds them itself and rejects content that
already contains them -- an existing guard test (test_cli.c:6018) caught that
integration mistake before CI did, which is the test doing exactly its job.

Reimplements the ideas from two community PRs, both closed as superseded by this
work, with credit to their authors:

- #534 (@Tensorboyalive) proposed the pi extension. It embedded 380 lines of
  TypeScript as C string literals and registered 7 of the 15 registry tools;
  this generates from the registry so the surface cannot drift, and ships no .ts
  in the repository. Their marker-delimited editing, their uninstall test
  asserting the markers are GONE, and their defensive stdout parsing (take the
  last line that parses, not the first) are all carried forward here.
- #616 (@ZeR020) proposed the OpenCode plugin. Its payload omitted
  hook_event_name, which hook-augment requires, so it produced zero bytes; and
  its path template rejected the double quote but not the backslash, so a
  Windows home like C:\Users\urs\bin produced an invalid unicode escape and the
  auto-loaded plugin failed to parse. Both are closed by construction here and
  pinned by tests. Their hook-point research is what made the OpenCode side
  possible at all.

Tests: install writes both extensions and the dry run does not; uninstall
removes our block while preserving a user's own content in the same file.

Co-Authored-By: Tensorboyalive <Tensorboyalive@users.noreply.github.com>
Co-Authored-By: ZeR020 <ZeR020@users.noreply.github.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-07-31 22:02:19 +02:00
Martin Vogel 2a353b2a33 feat(cli): generate client extension modules from the tool registry
pi has no MCP client, and OpenCode has no declarative hook configuration --
verified against OpenCode's own plugin documentation, which states hooks are
available only through JavaScript/TypeScript plugin modules. For those two
clients a module is the only extension point.

We do not want to ship such a module as a repository asset. #534 proposed one
embedding 380 lines of TypeScript as C string literals and registering 7 of the
15 registry tools; every tool added afterwards would have been silently missing
for that client. Hand-maintained copies of the tool list have already produced
defects here (#1361, and the smoke-invariants count).

So the module is GENERATED from the live registry instead. cbm_client_adapter_pi
walks cbm_mcp_tool_count()/cbm_mcp_tool_name() and registers every tool, which
makes drift structurally impossible rather than merely discouraged: adding a
tool to TOOLS[] adds it to every generated adapter with no second edit. Nothing
in the repository is a .ts file.

Both emitters wrap their output in ownership markers so a caller can rewrite its
own block and leave a user-authored file alone.

Two defects from the prior proposals are closed by construction:

- Path escaping. #616's template rejected the double quote but not the
  backslash, so a Windows home like C:\Users\urs\bin produced an invalid
  unicode escape and the whole auto-loaded plugin failed to parse, which is
  worse than an absent plugin. cbm_client_adapter_escape_js escapes backslash,
  quote, newline and CR, and fails closed rather than emitting a truncated
  literal; generation aborts if the path cannot be escaped.
- The silent no-op. #616's payload omitted hook_event_name, which hook-augment
  requires and without which it accepts nothing, so the plugin emitted zero
  bytes for six weeks. The generated payload carries it and a test pins it.

Recorded honestly: the OpenCode emitter hooks tool.execute.after, whose ability
to modify a tool's output is NOT part of OpenCode's documented plugin contract
(only tool.execute.before's argument mutation is). If they change it the
augmentation stops with no error. That risk is accepted deliberately and the
emitter says so in a comment, so a future reader does not have to rediscover it.

This commit adds the generator and its tests only; wiring into the install and
uninstall routines follows separately so the two are reviewable apart.

Tests: every registry tool appears in the pi module (revert-checked by
simulating the 7-of-15 subset, which fails); Windows/quote/newline escaping and
its truncation boundary; the OpenCode payload carries hook_event_name and
registers no tools; NULL/empty binary paths generate nothing.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-07-31 20:48:39 +02:00
Martin Vogel 958a511345 docs+cli: finish the install-script update contract
The update handoff moved out of the product on every platform, but two loose
ends were left behind.

README was actively WRONG, not merely incomplete. It still documented in-place
updates for macOS/Linux, and it promised "The MCP server also checks for updates
on startup and notifies on the first tool call" -- a claim that died with the MCP
update thread and then the daemon's curl check. The section now states the one
contract that holds everywhere (`update` validates flags and prints the install
script command), why it works that way (on Windows a running image cannot
replace itself; on POSIX a deliberate choice, because an in-process updater is
structurally a downloader), and the affirmative property that replaced it: cbm
makes no network request of its own accord and the archives carry no download
URLs at all.

`uninstall` now REPORTS the install script it finds beside the binary, printing
the path and the `rm` command, and says "would leave" under --dry-run. It does
not delete it. install.sh places itself there so `update` has something to hand
off to, but that file may equally be the user's own copy, a symlink into a
checkout, or package-manager managed -- an uninstaller must not delete a file it
cannot prove it owns, and one printed line leaves the decision where it belongs.
Windows checks install.ps1 too and tolerates either path separator.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-07-30 00:05:32 +02:00
Martin Vogel 1f674c805e harden: remove capability that should never have shipped
Microsoft's ML flagged the rc.1 release binaries. The decisive evidence is that
the SAME sha256 went from 0/62 clean to Microsoft-detected in about an hour with
no byte change, so the verdict lives partly in scanner-side state and no code
change can promise a clean result. What code CAN do is stop shipping things that
have no business in a release artifact, which is worth doing on its own merits
and incidentally widens the classifier margin. Every claim below is verified
against a built binary by the new gate, not by reading source.

Executable stack (the worst of the findings). vendored/nomic/code_vectors_blob.S
is the only assembly in the build and carried no .note.GNU-stack. An unannotated
object makes ld assume the worst for the whole link, so EVERY Linux release we
have ever shipped had GNU_STACK RWE. Adds the note (cause) plus ELF-only
-Wl,-z,noexecstack (outcome); the gate fails the release if it returns.

Test seams are now opt-in, never opt-out. TEST_SEAMS=1 defines
CBM_ENABLE_TEST_SEAMS; without it the crash-orphan probe -- which forks a child
that ignores SIGTERM and loops forever, then writes its pid to a caller-supplied
path -- and the lease-ownership marker compile to trivial stubs, so call sites are
untouched and the binary holds no fork, no signal handler and no env-var string.
Opt-IN is the point: forgetting the flag yields a clean binary rather than a leaky
one. scripts/test.sh requests it in the leg that consumes it, and
tests/test_worker_watchdog.sh now asserts the capability up front instead of
dying later with an opaque "Killed: 9".

The daemon's background version check is gone. It spawned curl against
api.github.com/repos/.../releases/latest on the first eligible session of every
run to say "a newer version exists" -- a release URL and an outbound request in
every shipped binary, for something the install scripts already report. The
INJECTABLE SEAM survives: update_ops is still honoured, the fakes in
tests/test_daemon_application.c still cover notice/ownership/cancellation/replay,
and with no provider application_update_subscribe_locked returns early so no
generation ever starts. "No network request by default" is now structural.

Dead capability out of release builds. The tar.gz/zip extraction block
(gzip_decompress through cbm_extract_binary_from_zip, plus its cli.h
declarations) moves under CBM_CLI_ENABLE_TEST_API -- verified self-contained, zero
uses of any helper outside it, only callers the excluded updater and
tests/test_cli.c. Downloading an archive, decompressing it, picking an executable
out of it and marking it executable is the canonical dropper composite; it is now
absent rather than merely unreachable. SQLite is built with
-DSQLITE_OMIT_LOAD_EXTENSION (no caller of load_extension anywhere in src/ or
internal/), removing that API surface and part of the dlopen/dlsym surface.

Temp files and environment scanning (S2/S3). Predictable paths in mcp.c,
artifact.c and diagnostics.c are created privately and exclusively and written
through the returned descriptor; pass_envscan.c no longer descends symlinked
directories out of the project root, and its fixed 512-byte path buffers no
longer truncate into pointer arithmetic that could land outside the buffer.

Build-time entropy. mimalloc's version banner baked __DATE__/__TIME__ into every
binary, so two builds of identical source seconds apart could never share a hash
and no release could inherit a false-positive determination made about its
predecessor. Local patch removes it (marked to survive refreshes), -Wdate-time
makes any future use a build error, and -Wl,--no-insert-timestamp stops the PE
header carrying the link clock.

scripts/ci/check-binary-composition.sh is the proof that each removal stays
removed, wired into package-release.sh after strip so the local artifact-flow
smoke enforces exactly what CI does. It asserts absences plus a CANARY string, so
handing it a compressed, stubbed or empty file fails instead of passing
vacuously, and a missing tool is a hard error -- a skipped assertion must never
look like a satisfied one.

Two build-system traps found by that gate, both of which had silently defeated a
fix: the product binary is compiled in one shot from sources, so a flag flip did
not rebuild it (now tracked by a .build-config stamp that also removes the
binary, making it independent of mtime granularity); and prod_sqlite3.o /
prod_mimalloc.o depended on a single named source, so SQLITE_OMIT_LOAD_EXTENSION
and the mimalloc patch BOTH compiled to nothing on the first incremental build.
Source review would have called them done.

Deliberately NOT changed. Three seams stay in release artifacts because
scripts/smoke-test.sh runs against the real artifact and needs them:
CBM_TEST_CRASH_ON and CBM_TEST_HANG_ON inject the faults that prove supervisor
recovery, and CBM_TEST_WINDOWS_USER_PATH_RUN_ID is what keeps the PATH smoke from
writing the tester's actual PATH. The gate treats those as an allowlist, so a
NOVEL seam still fails. The true no-UI standard build is deferred rather than
rushed: src/ui/* is in PROD_SRCS and four files outside src/ui reference UI
symbols, including the daemon that serves the UI, so that assertion reports
instead of failing until the split lands -- a gate everyone knows is red teaches
people to ignore gates.

No grammar is removed. ObjectScript accounts for essentially all binary growth
since the last provably-clean release (+21.4MB rodata, +1.1MB text from two
four-line shims), which made it the obvious ablation candidate, but a dry run
performed twelve real Defender endpoint scans across standard/UI and amd64/arm64
with ObjectScript, the daemon and the expanded hooks all present and every scan
was clean. Nothing there is a deterministic trigger, so cutting a
community-contributed language would spend a real feature on unproven margin.
Lean is not a candidate either: at 99.6MB of source it is by far the largest
grammar, but it shipped in v0.9.0 which scanned 20/20 clean, so removing it would
produce a novel unscanned profile instead of restoring a known-good one.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-07-29 23:50:53 +02:00
Martin Vogel afd67c51b0 refactor(cli): install scripts own updating; the binary carries no URL
Completes the move started on Windows. `update` prints the install script's
command and exits 0 on every platform; the download/extract/chmod/exec sequence
is compiled out of release builds entirely, and the MCP background thread that
ran cbm_popen("curl ... api.github.com ...") on first tool call is gone.

The installers now place themselves beside the binary, which closes the last
gap: `update` referenced a raw.githubusercontent URL purely because an
install.sh-installed user had no local copy to point at. Two details make that
safe.

The source is the install script from the archive we JUST checksum-verified,
never "$0" -- which does not exist under `curl | bash`, and would pin the user
to the OLD installer forever. Since the script ships inside the verified
archive, it inherits that verification instead of needing its own.

And it is published by atomic rename, never written over the live path. Bash
reads a script incrementally by byte offset, so overwriting the file it is
executing continues reading NEW bytes at the OLD offset: silent, bizarre
corruption. cp to a temp name then mv -f swaps the directory entry while the
running shell keeps its original inode. Windows follows the same rule via
Copy-Item + Move-Item even though PowerShell parses up front.

Deliberately NOT done: spawning the new installer to delete and replace its
predecessor. Fetch remote code -> drop to disk -> spawn -> self-delete is a
textbook stager, and self-deletion is among the most heavily weighted
heuristics there is. It also gains nothing -- `"$DLBIN" install` already runs
the NEW release's install logic, because the binary owns the install.

Net: zero install or download URLs remain in the shipped binary, and the
allow-list entry is retired with it. Both scripts scan CLEAN (0/61) with the
self-placement code in them.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-07-29 17:04:50 +02:00
Martin Vogel 0b31a5dcdf fix(security): declare the printed install-script URL on the allow-list
The `update` handoff prints a curl one-liner when install.sh is not beside the
binary, and the security audit blocked the release on it:

  BLOCKED: src/cli/cli.c: URL not on allow-list: https://raw.githubusercontent.com/DeusData/

Two things were wrong. The URL was not declared, and it was split across string
literal continuations, so the audit extracted only the first fragment -- an
allow-list entry for the real URL could never have matched it. Hoisted to a
single CBM_INSTALL_SH_URL token so the declaration means what it says.

It is the same URL as the documented one-liner install, and it is printed for
the user to paste: the binary no longer downloads anything.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-07-29 10:38:18 +02:00
Martin Vogel 9ecabe0a6f refactor(cli): move self-update out of the product on every platform
Windows already handed updating to install.ps1, because a running .exe cannot
replace its own image. The remaining platforms now do the same, for a different
and better reason.

An in-process updater is structurally a downloader: it fetches a remote archive,
verifies it, extracts it, marks the result executable and runs it. That composite
lived in every shipped artifact -- along with the release URLs it fetched -- to
serve a command most users run a handful of times. The install scripts already
do all of it, are idempotent (so re-running one IS the update), and run while cbm
is not running.

`update` now prints the exact command for the running platform and exits 0:

  unix     bash "<dir>/install.sh"     (or the curl one-liner when not adjacent)
  windows  powershell -ExecutionPolicy Bypass -File "<dir>\install.ps1"

Both scripts ship inside their release archive, so the printed path resolves
next to the binary. Flags are still parsed and validated, so `update --dry-run
--ui` keeps rejecting typos rather than silently accepting them.

The updater itself is EXCLUDED from release builds rather than left to dead-code
elimination: build_update_url, download_verify_install, extract_and_install_binary,
checksum fetch, detect_os/detect_arch and the cli_download_* helpers now compile
only under CBM_CLI_ENABLE_TEST_API, which release builds do not define. The C
suite still covers the flow through the activation test seam. Verified against
the release binary: `releases/latest/download` is gone entirely.

Also removes the MCP background update check. It ran on the first tool call and
did:

  cbm_popen("curl -sf --max-time 5 -H 'Accept: application/vnd.github+json' "
            "'https://api.github.com/repos/.../releases/latest' 2>/dev/null")

An embedded shell command plus a startup network callout, in every session, to
tell users a newer version exists. It shelled out, depended on curl being
installed, and phoned home from every agent session. The install scripts report
versions; this did not need to be in the server.

Smoke follows the same collapse. Phases 6c and 14 asserted a Windows-specific
handoff and a POSIX in-process replacement; they now assert one platform-neutral
contract selected by UPDATE_SCRIPT, and 14a's byte-identity check compares the
driver against ITSELF before and after -- comparing against "$BINARY" passed on
Windows only because Windows has no signing step, while the POSIX fixture
ad-hoc re-signs its copy and so differed before `update` ever ran.

The Linux glibc guard is RELOCATED, not retired. The standard linux asset links
glibc 2.38+ and breaks Debian 11, RHEL 8 and Ubuntu 20.04, so the installer must
fetch the static "-portable" build. That constraint moved from the binary to
install.sh, and the smoke now guards it where the behaviour lives.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-07-29 10:22:19 +02:00
Martin Vogel 0a22fb14df fix(windows): absorb transient file locks when retiring a running image
Release run 30385988475 lost three Windows smoke jobs to one phenomenon on two
different surfaces. A Windows file that was just written or just executed can
refuse deletion and rename for a moment while a scanner reads it or while the OS
finishes reaping a child. Both conditions clear on their own within moments.

It reads as nondeterminism because it is timing: windows-2025 passed both amd64
variants while windows-latest -- the SAME image, windows-2025-vs2026 -- failed
both, and windows-11-arm failed too.

PRODUCT: uninstall abandoned a live installation.

With one binary per platform, uninstall must remove the very image it is
executing, and on Windows a running image can only be renamed, never
overwritten. That rename got a single attempt, so a transient sharing violation
surfaced to the user as

    error: failed to remove ...; completed configuration/index cleanup may remain

with the install left half-torn-down. install.ps1 already retries this exact
operation ten times for this exact reason; the C path now does the same, and
only for sharing/access/lock violations, so a file that is genuinely held still
fails closed instead of spinning.

Proven with a failure-injection seam rather than by racing a real scanner:
3 injected failures must still succeed, 64 must still fail AND leave the target
in place. Deterministic, so it does not need CI to learn whether it works.

HARNESS: the smoke killed itself silently.

Phases that install a binary then `rm -rf` the directory hit the same lock. Under
`set -euo pipefail` that returns non-zero and ends the run WITHOUT printing
anything -- the logs jump straight from "OK 13h" to job cleanup with no FAIL
line, which is what made this expensive to read.

None of those 16 removals is an assertion; they are fixture cleanup, and
_smoke.yml already states the principle ("a temp dir on an ephemeral runner must
never fail the job") that smoke-test.sh did not honour. They now go through
smoke_rmtree, which retries so the disk is actually reclaimed -- these fixtures
hold ~300 MB binaries and runners are disk-tight -- then warns and continues.

Verified: Windows VM full smoke ALL PASSED (previously died after 13h), macOS
full smoke ALL PASSED, activation_transaction + cli 259 passed on Windows, all
10 shell contracts pass, lint-ci clean.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-07-28 22:49:22 +02:00
Martin Vogel 2740208f40 fix(cli): report a failed install --dry-run plan check in the exit status
The Windows smoke guards that an invalid PATH seam must fail closed rather than
silently falling back to the live HKCU\Environment\Path. That guard went green
in a way that meant nothing: the seam still refused, and install still printed
"PATH configuration failed", but the process exited 0.

Windows release builds used to compile a separate managed-install path. With one
binary per platform they compile the shared cbm_cmd_install, which since 30b5f12
downgraded ANY failed dry-run plan check to CLI_OK. That commit was fixing a real
complaint -- the dry-run summary was being skipped entirely on Windows -- but it
bundled the exit status into the fix. Keeping the summary is right; reporting
success is not. --dry-run exists to answer "would this install work?", so a
caller that only sees the exit code could not tell a refused PATH probe from a
clean plan.

The summary and the triage note still print; only the status changes. A real
install already failed non-zero here, so the fail-closed behaviour itself was
never at risk -- just its observability.

The smoke also still encoded the launcher-era portable-vs-managed split, which
does not survive one-binary-per-platform:

  * 6b expected uninstall to be REFUSED. That refusal existed because a portable
    extracted bundle was a different artifact from the launcher-managed install
    it would have torn down. The extracted binary now IS the installed one.
  * 6c expected update to be REFUSED, contradicting the contract Phase 14a
    asserts against a real update: Windows hands off to install.ps1, exits 0 and
    prints the command. 6c now pins that same handoff.
  * 14a compared binaries with cmp, which is absent from the Windows MSYS shell,
    so it reported "different" for two copies of one file. Hashes instead, the
    way Phase 12 already verifies the release archive.
  * 14f seeded the MCP entry at the RETIRED binary. The old launcher-managed
    update rewrote that entry in-process; the handoff does not, so uninstall
    correctly declined to remove an entry owned by a different installation and
    the phase demanded the one thing it must never do. It now seeds the
    installed binary, which is what install.ps1 leaves a real user holding.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-07-28 19:42:37 +02:00
Martin Vogel a54ea95719 fix(windows): ship one binary — remove the launcher stub flagged as a dropper
Windows shipped a PAIR: a small permanent launcher (codebase-memory-mcp.exe)
plus the real product binary (codebase-memory-mcp.payload.exe). The launcher
existed for exactly one reason — a running .exe cannot replace its own image
on Windows, so an in-process self-update needs a second resident binary to do
the swap.

That stub is statically indistinguishable from a dropper: a small, unsigned,
zero-prevalence PE whose whole job is verify-and-execute another binary.
Defender's ML scored it Trojan:Win32/Wacatac.B!ml and blocked the v0.9.1-rc.1
release at the VirusTotal gate. It is not fixable in our code on x64 —
bcrypt-free, stripped, VERSIONINFO'd, minimal-resource and even
resource-FREE builds on CI's own MSYS2 CLANG64 toolchain were all flagged,
while the product binary scans clean on every platform.

So remove the stub and move self-update OUT of the process into install.ps1,
which runs while cbm is NOT running: Windows' image lock only blocks a
process from replacing ITSELF.  now prints the exact PowerShell
command (with the Unblock-File hint for Mark-of-the-Web); install.ps1 is
idempotent, so re-running it IS the update — it stops the daemon, renames the
running binary aside (the one mutation Windows permits on a running image),
publishes the new one, and sweeps retired copies.

Windows now matches Linux and macOS: ONE binary per platform.

  * packaging, install.ps1, npm and PyPI wrappers all carry a single binary
  * the launcher/payload ABI contract and ~2500 lines of stub state machinery
    are deleted
  * every daemon start, CLI call and hook fire loses a process spawn, a named
    pipe handshake and an stdio relay
  * test_windows_bundle_contract.sh is rewritten as an INVERTED contract: it
    now asserts no shipped surface can reintroduce a launcher/payload pair,
    and that install.ps1 retires the running binary before publishing

Verified: VirusTotal 0/67 on the packaged binary and 0/58 on install.ps1 (no
certificate involved); macOS and Linux full suites green; Windows guards all
green including the new update-handoff contract; npm 10/10; PyPI 3/3.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-07-28 17:03:01 +02:00
Martin Vogel e5c79418ca revert(install): restore the activation-transaction diagnostics to main
The refusal notes changed the error text the Windows launcher guards match
against, and test-windows-guards went red on this PR after they landed. They were
investigation instrumentation and they succeeded -- they turned 'staging
transaction open failed (status -3, os 0)' into ERROR_DISK_FULL and named the
exact site -- but a diagnostic that shifts a matched error string does not belong
in a release-blocking change.

Worth redoing deliberately on its own, with the guards' expectations updated in
the same commit.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-07-26 14:01:08 +02:00
Martin Vogel d2dee7b8ff diag(install): report the OS error from a failed staging copy
The copy loop's failure was reported as 'os 0' because the cleanup and close
calls after the loop clobber the thread's last error. Captures it at the failure
point, and separates a failed read from a failed write -- copying a payload that
another process still holds is a different problem from a full disk.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-07-26 09:45:54 +02:00
Martin Vogel 536d228631 diag(install): split the staging copy failure into named causes
The copy loop's five distinct failures -- short read/write, an empty source, a
failed fsync, a failed close, and a failed directory sync -- shared one branch
and one unnamed IO status. Exhaustive labelling identified this as the path the
Windows launcher guard's immediate reinstall takes; this names which of the five
actually fires.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-07-26 09:18:11 +02:00
Martin Vogel 6d95d4f8df diag(install): name every unlabelled staging IO refusal
Fourteen paths returned CBM_ACTIVATION_TRANSACTION_IO without setting a refusal
note, so the caller could only ever print 'status -3, os 0' -- an I/O error with
no cause. Identifying which one fires cost a ten-minute Windows guard run per
hypothesis, and two targeted guesses missed.

Labels every site so a single run names the path. Temporary labels; once the
guard's path is known they become descriptive.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-07-26 08:53:18 +02:00
Martin Vogel 15cbbc505a fix(install): name the target-directory refusal when preparing a transaction
activation_transaction_prepare refused an insecure target directory and
returned IO with no note, while destroy() on the way out clobbered
GetLastError -- so the caller printed 'staging transaction open failed (status
-3, os 0)': an I/O error with neither a cause nor an OS code.

That is the path the Windows launcher guard's immediate reinstall takes, and the
missing note is why each hypothesis about it cost a ten-minute guard run. A
target directory failing the exact-owner rule is a different bug from an
unreadable source, and now says so.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-07-26 08:30:05 +02:00
Martin Vogel e65e8ee42d fix(install): name which source refusal failed when staging
Three distinct refusals -- an insecure source directory, a failed snapshot, and
a missing source file -- collapsed into one boolean, and the frees on the way
out clobber GetLastError, so the caller reported 'staging transaction open
failed (status -3, os 0)': an I/O error with no cause and no refusal note.

The Windows launcher guard's immediate-reinstall step fails through exactly this
path, and with no cause reported each hypothesis cost a full ten-minute guard
run. Captures the OS error before the frees and names the predicate that
refused.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-07-26 07:58:55 +02:00
Martin Vogel 62821375c4 fix: harden daemon release paths and verification
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-07-24 16:08:42 +02:00