Commit Graph
2142 Commits
Author SHA1 Message Date
Martin Vogel bc4da94696 fix(ci): eliminate bare bash lookup in contracts
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-08 23:03:20 +02:00
Martin Vogel df2088a7ab fix(ci): pin bash for Windows fixture subprocesses
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-08 22:26:53 +02:00
Martin Vogel 079c6dcc7a fix(packages): make candidate probe test path portable
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-08 22:01:49 +02:00
Martin Vogel a1a5b7fdb4 fix(packages): eliminate wrapper lock sharing races
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-08 21:45:33 +02:00
Martin Vogel 7a62a085cf fix(packages): make wrapper locks Windows-safe
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-08 20:53:14 +02:00
Martin Vogel 54b5ba846b fix(lint): generate integration hash before cppcheck
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-08 19:18:47 +02:00
Martin Vogel 00ed56fb77 fix(build): restore Windows release compile
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-08 18:52:29 +02:00
Martin Vogel b32994868b fix(ci): audit authenticated loopback readiness
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-08 18:13:36 +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 2c50c7741e Merge pull request #1493 from DeusData/harden/av-surface-strings
harden: ship integration templates as a verified JSON asset, not embedded bytes
2026-08-08 07:32:06 +02:00
Martin Vogel 497c680aae fix(build): make the UI target depend on the generated integrations hash header
The -ui build failed on Windows with "cbm_integrations_hash.h file not found".
integration_assets.c includes that generated header, and the subagent added it
to PROJECT_HDRS so every single-invocation binary target picks it up — but
cbm-with-ui is a phony target with its own direct recipe that does NOT list
PROJECT_HDRS, so nothing generated the header before its one-shot compile. Only
the UI variant hit this; the standard binary, test-runner, test-repro-runner and
tsan targets all carry PROJECT_HDRS and built fine.

Added $(INTEGRATIONS_HASH_HDR) as an explicit prerequisite of cbm-with-ui.
Confirmed by dry-run: `make cbm-with-ui` now schedules the generator before the
compile, and the header regenerates from clean. Audited every target that
compiles integration_assets.c (via PROD_SRCS) — all now resolve the header, so
this closes the build gap rather than moving it.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-08 06:11:14 +02:00
Martin Vogel d15bd7241d fix(install): allow cbm-integrations.json in the install.ps1 archive namespace
The prior fix put cbm-integrations.json into the Windows archive, and install.ps1
promptly rejected it: its pre-extraction namespace guard permits only the exact
release entries in $WindowsArchiveNames and throws "archive contains an
unexpected root entry" on anything else. The asset is now a real release member,
so it belongs in that allow-list. Added in archive order (binary,
cbm-integrations.json, LICENSE, install.ps1, THIRD_PARTY_NOTICES.md), matching
package-release.sh and the windows-bundle contract's windows_archive_names.

The guard also REQUIRES each listed entry exactly once, so this both permits and
mandates the asset — an archive missing it now fails install.ps1 up front rather
than at first hook render. install.sh has no equivalent namespace allow-list (it
extracts the tarball directly), which is why the Unix smoke passed; no change
needed there.

install.ps1 stays pure ASCII (0 non-ASCII bytes); windows-bundle contract passes.
This was the Windows smoke advancing PAST the archive-staging fix (asset now
present) to the installer's own entry validation.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-08 05:49:55 +02:00
Martin Vogel 1c16786b4b fix(smoke): stage cbm-integrations.json in the Windows smoke path too
The Windows pr-smoke uses test-infrastructure/vm/vm-smoke.sh, the Windows analog
of smoke-local.sh, which had the same three staging gaps the Unix fix closed:
its required-sidecar check, fixture copy, and zip member list all omitted
cbm-integrations.json, so the served archive lacked the asset and install/
uninstall failed closed with "integration assets missing".

Plus one Windows-specific spot: vm-smoke.sh copies the smoke binary into
$SMOKE_DIR and runs smoke-test.sh against it there, but the asset only reached
$FIXTURE_DIR — so Phase 14 (which hand-stages the binary and drives an
uninstall, resolving the asset next to the binary) missed it. Now staged next to
the binary in $SMOKE_DIR as well. build.sh already places it next to
build/c/*.exe, so the non-artifact source is $(dirname BINARY_SRC); artifact
mode takes the release copy.

Member set + order in the zip mirror scripts/package-release.sh (Windows).
vm-smoke.sh lives under test-infrastructure/, outside the license gate's scan
set, so the cbm-integrations.json/LICENSE adjacency raises no ScanCode finding
there. Syntax-checked; vm-worktree-manifest, smoke-fixture and windows-bundle
contracts pass. The Unix smoke already runs clean through all 16 phases; this
mirrors that staging onto the one path I cannot exercise locally.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-08 05:31:53 +02:00
Martin Vogel faf08a0743 fix(ci): ignore the JSON-license false positive in smoke-local.sh too
Round 2's smoke fix added the archive member list 'cbm-integrations.json LICENSE
install.sh ...' to scripts/smoke-local.sh (mirroring package-release.sh), which
reintroduced the exact ScanCode false positive that commit 83df500 quieted for
package-release.sh: the '.json LICENSE' token adjacency reads as the SPDX 'JSON'
license. Same class, same fix — smoke-local.sh is a first-party MIT build
script listing its own archive members, added to the license policy's
ignored_paths with the shared justification.

Swept every ScanCode-scanned file (src/pkg/scripts, the gate's own file set) for
the adjacency: the only three carrying it — package-release.sh, smoke-local.sh,
and license-policy.json itself — are all now in ignored_paths, so this closes it
rather than trading one flagged file for another.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-08 05:13:59 +02:00
Martin Vogel 365841304c fix(install,smoke): carry cbm-integrations.json wherever the binary goes
pr-smoke failed on all three platforms: the binary's install/uninstall render
templates from the integration asset, and every place that stages the binary
without the asset made those operations fail closed with "integration assets
missing". The asset must travel with the binary in EVERY layout, not just the
release archive. Five staging paths were missing it:

- scripts/build.sh — stage cbm-integrations.json next to the freshly built
  binary, so `build/c/codebase-memory-mcp install` works straight out of a
  build tree (dev, and the base for the smoke fixture).
- scripts/smoke-local.sh — include the asset in the fixture tarball and its
  required-sidecar check. Member set and ORDER now mirror package-release.sh
  exactly (binary, cbm-integrations.json, LICENSE, install.sh, notices); the
  fixture was smoking an archive layout we never actually ship.
- install.sh / install.ps1 — after installing the binary, copy the asset beside
  it in the install dir. `install` already publishes a verified copy to
  ~/.cbm/assets/<version>/, but a later install/uninstall run from the install
  dir resolves the asset NEXT TO THE BINARY first, so without the adjacent copy
  that lookup misses and a re-install or uninstall fails on a machine that just
  installed successfully. Best-effort atomic rename, same shape as the existing
  install.sh/ps1 sidecar copy. install.ps1 stays pure ASCII.
- scripts/smoke-test.sh Phase 14 — this phase hand-stages the binary into a
  fresh HOME without going through install, so nothing populates ~/.cbm/assets;
  stage the asset next to each staged copy so the uninstall it drives resolves.

Verified: scripts/smoke-local.sh on the standard binary now runs clean through
all 16 phases — zero "integration assets missing" / hook_script_uninstall /
agent-cleanup-failed lines (was 5). windows-bundle, smoke-fixture, exec-bit and
no-embedded-scripts contracts pass; install.ps1 has zero non-ASCII bytes.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-08 05:08:25 +02:00
Martin Vogel 83df500364 fix(build,ci): make the integrations-hash generator runnable and quiet a JSON-license false positive
Two CI failures on the integration-asset change, both build/gate plumbing, not
the architecture:

1. scripts/gen-integrations-hash.sh was committed 100644 while Makefile.cbm
   invoked it directly, so a fresh CI checkout died with "Permission denied"
   building the hash header — which failed EVERY test leg at step 0, since the
   contract step builds first. It passed locally only because the generated
   header was already cached, so make never re-ran the generator. Fixed both
   ways: the recipe now runs it via `sh` (mode-independent, cannot regress from
   a checkout mode), and the file is committed 100755 to match its siblings.
   Note: test_script_exec_bit_contract.sh scans shell call sites, not Makefile
   recipes, so it did not catch this — the `sh` prefix is the durable guard.

2. The license gate (ScanCode) flagged scripts/package-release.sh with the SPDX
   'JSON' license. The archive member lists place 'cbm-integrations.json' and
   'LICENSE' adjacently, and ScanCode reads the '.json LICENSE' token adjacency
   as a JSON-license reference. The order is not free to change — the Windows
   single-binary contract locks the exact member sequence — so this is a genuine
   false positive on a first-party MIT build script. Added it to the policy's
   ignored_paths with the same justification the six existing first-party
   entries carry (the gate scripts, provenance auditor, and discover.c all name
   licenses for legitimate reasons). No allow-listed SPDX id was added; the
   JSON license is NOT now permitted anywhere else.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-08 04:18:01 +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 6a1ef1e1b8 Merge pull request #1492 from DeusData/harden/av-surface-strings
harden(cli): stop shipping a policy-bypass invocation inside the binary
2026-08-07 23:18:57 +02:00
Martin Vogel c7782e0b47 test(windows-bundle): pin the install.ps1 hand-off, not the bypass flag
The contract asserted the literal "powershell -ExecutionPolicy Bypass -File"
appeared in cbm_cmd_update, so removing that string from the binary reddened
every test leg on every platform — the contracts run as step 0 of scripts/test.sh,
so one violation stops the matrix before a single suite executes. 13 checks, one
cause.

The contract's stated intent is that the Windows update command "must hand off to
install.ps1 and must NOT carry an in-process self-update path". The bypass flag
was never that property, only the literal form the command happened to take. The
assertion now pins the hand-off itself ("powershell -File" plus install.ps1),
which is what the comment always claimed to protect.

Adds the inverse assertion in the same place: the printed command must NOT
contain an execution-policy override at all. Without it this contract would
silently permit the pattern coming back, and the whole point of removing it was
that a shipped binary is the wrong place to carry a canonical loader signature.

Process note for myself: I ran the cli suite and lint before pushing, but not the
contracts — which is precisely the gap recorded as "scripts/test.sh runs them as
step zero of EVERY leg, so one violation reddens all 30 CI jobs". Ran the full
contract set this time.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-07 21:56:22 +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 74f540aa3a Merge pull request #1491 from DeusData/ci/vt-gate-names-engines
ci(vt-gate): name the engine that flagged, not just the count
2026-08-07 20:30:46 +02:00
Martin Vogel f3e79f976c ci(vt-gate): name the engine that flagged, not just the count
The gate reported "1 malicious, 0 suspicious / 59 engines" and stopped there —
withholding the one fact a detection is actionable on. A false-positive
submission goes to a SPECIFIC vendor, and triage begins with "known-noisy engine
or a new family?". Neither question is answerable from a count.

It cost exactly that in the 2026-08-07 dry run: three artifacts flagged
(linux-amd64, linux-amd64-portable, darwin-arm64), one engine each, and the logs
could not say by what. The vendor had to be recovered by hand from the web UI
afterwards — and the WDSI developer false-positive path is a web form with no
public API, so guessing the wrong vendor wastes a filing round trip.

The analysis response already carries per-engine results; the gate simply threw
them away. It now prints, under the BLOCKED line, each engine that returned
malicious or suspicious with its detection label and definition date:

  BLOCKED: codebase-memory-mcp-linux-amd64 flagged (1 malicious, ...)
    detected by: Microsoft = Trojan:Win32/Wacatac.B!ml (engine ..., defs ...)

Engines that did not flag are not listed, or the actionable line drowns in
sixty clean rows.

Deliberately unchanged: the gate's strictness. Zero tolerance still means any
detection on any artifact on any version blocks, and an analysis WITHOUT
per-engine results still blocks on the counts alone — the contract pins both, so
naming engines cannot become a route to tolerating one. A missing engine list
prints an explicit "<engine names unavailable>" rather than nothing, so the gap
is visible instead of reading as a detection-free block.

Verified by revert: against the old gate the new assertions fail with the
counts-only output quoted above.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-07 20:27:53 +02:00
Martin Vogel a8c6b3f579 Merge pull request #1490 from DeusData/ci/dry-run-skip-smoke
ci(dry-run): add skip_smoke for a direct build → VirusTotal pass
2026-08-07 19:15:35 +02:00
Martin Vogel 95b3ba9448 ci(dry-run): add skip_smoke for a direct build -> VirusTotal pass
Adding the VirusTotal step left one trip unavailable: build straight to the
scan. Smoke is in the VirusTotal job's needs, so the only way to reach the scan
was to run smoke first — and a smoke failure would withhold precisely the answer
such a run was dispatched to get.

The skips now cover the three questions worth asking separately:

  skip_builds      no artifacts at all             "do lint and tests pass?"
  skip_smoke       build + scan, no smoke          "does this diff scan red?"
  skip_virustotal  build + smoke + soak, no scan   "do the archives pass CI?"

skip_smoke is for the case where the scan verdict is the entire point: the last
release scanned clean, so the question is only whether the accumulated diff
turns it red. Everything between build and the scan is cost without information
for that question.

VirusTotal keeps smoke and soak in its needs, and that is deliberate: they order
the job last, they do not gate it. A SKIPPED smoke leaves the scan reachable
(result 'skipped', which is not 'failure'); a FAILED smoke still withholds it,
because a verdict on artifacts already known to be broken is not worth two hours
of polling. The release-gate-chain contract covers exactly this distinction —
an optional phase must not silently disable the phases after it — and passes.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-07 19:14:54 +02:00
Martin Vogel e16c4af1ed Merge pull request #1488 from DeusData/fix/mcp-payload-duplication
perf(mcp): stop repeating the payload in structuredContent (#1375)
2026-08-07 18:03:21 +02:00
Martin Vogel 399ea7c3b1 Merge pull request #1489 from DeusData/ci/dry-run-virustotal
ci(dry-run): scan the archive artifacts with VirusTotal as the final step
2026-08-07 18:01:07 +02:00
Martin Vogel b67401a03b ci(dry-run): scan the archive artifacts with VirusTotal as the final step
Preparation for the next dry-run cycle. The release gate scans every shipped
artifact with VirusTotal and blocks on any detection; the dry run did not, so
the one question most likely to stop a release was the one a dry run could not
answer. v0.9.1-rc found that out the expensive way.

The scan mirrors the release job. The only difference is where the archives come
from: the release pulls them off a published release, a dry run has none, so
they come from the build job's artifacts — the same files, produced by the same
canonical package-release.sh, extracted the same way and scanned as the same
bytes a user would download and run. Same zero-tolerance gate
(scripts/ci/check-virustotal.sh), so a dry run now fails where the release would.

It runs LAST, after smoke and soak. It is the slowest job — polling can take two
hours — and its verdict does not depend on our code at all: the same unchanged
bytes can come back clean one day and flagged the next. That must not delay the
feedback that IS about our code.

Two independent skips, because they answer different questions:

  skip_builds      no artifacts at all          "do lint and tests pass?"
  skip_virustotal  build + smoke + soak, no scan "do the archives pass CI?"

The second is the one asked for: during a debug cycle, when the question is
whether the archives survive smoke and soak, a two-hour scan burning API quota
is pure noise. skip_builds implies no scan, since there is nothing to scan.

Extraction lives in scripts/ci/extract-release-archives.sh rather than inline in
the YAML because the venue-parity contract requires it — a venue may provision,
plumb artifacts, or call a canonical leg script, and this is leg logic. The
contract caught the inline version.

Two defects found by testing the script instead of trusting it:

  * The empty-input guard did not guard. It counted files in the output
    directory, but the install scripts are copied in unconditionally, so the
    count was never zero and an input with no archives reported success — a
    clean VirusTotal run over nothing, the exact false green the check exists to
    prevent. It now counts binaries recovered from archives, and exits 1 on
    zero.
  * The script was committed 100644 while the workflow invokes it directly as a
    command, which is the "Permission denied" failure
    test_script_exec_bit_contract.sh was written for. That contract scans
    scripts/ but not .github/workflows/, so it did not catch it here.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-07 17:59:50 +02:00
Martin Vogel 17c2a513a6 Merge pull request #1486 from DeusData/fix/1375-oversized-mcp-response
fix(daemon): answer an oversized MCP reply with an error, not process death (#1375)
2026-08-07 17:18:03 +02:00
Martin Vogel f27f0a2a6c perf(mcp): stop repeating the payload in structuredContent (#1375)
Every non-JSON tool result shipped its payload twice: content[0].text, and an
identical copy as structuredContent {"text": <entire payload>}. Measured on a
20k-node query_graph, the reply was 2.05x the payload it carried. Half of every
large answer was redundant bytes — half the 10 MiB transport budget, and double
the tokens billed to every LLM caller on every call.

Measured, same query, same fixture:

  before   8,529,990 bytes   content.text 4,154,932   structuredContent.text SAME
  after    4,265,047 bytes   content.text 4,154,932   structuredContent {}

Exactly 50.0% smaller with content.text byte-identical: the payload is fully
delivered, only the second copy is gone. The 10 MiB ceiling now also admits
roughly twice the rows before #1375's limit applies.

Nothing is lost. structuredContent exists to carry STRUCTURE, and a string
re-wrapped in a one-key object has none — a client reading
structuredContent.text learned exactly what content[0].text already told it. The
empty object still satisfies the declared outputSchema, which is
{"type":"object","additionalProperties":true} and never required a text field.

Two cases are deliberately NOT changed:

  * JSON payloads. structuredContent stays the PARSED object. That is the
    spec's structured+serialized pattern rather than waste, and it is what our
    own hook-augment consumes (structuredContent.projects from list_projects).
  * Errors. structuredContent.error is kept: bounded, small, and the only
    machine-readable form of a failure a client gets.

Guarded at both levels, because the defect was invisible per-tool — each result
looked reasonable alone, and only measuring the wire showed half of it was
redundant:

  * tests/test_mcp.c enumerates the TOOL TABLE itself, so a new tool is covered
    the moment it is registered, with no test edit. A guard pinned to
    query_graph would not have caught search_graph, and would not catch whatever
    is added next. It fails if no tool produced a non-JSON payload, so it cannot
    report a green it never earned — which it did on the first attempt, catching
    that {} args make every tool error out and assert nothing.
  * scripts/smoke-test.sh Phase 3z asserts the same property on the SHIPPED
    binary. This is a wire-format contract: what a real client receives from the
    real artifact is not something a from-source test can prove.

Both revert-checked: restoring the duplication reddens the unit guard at the
strcmp, the plain-text guard, the search_graph expectation, and the smoke phase
(which names search_graph and get_architecture).

tests/test_mcp.c expectations that pinned the old duplicating shape are updated
rather than relaxed — the format changed on purpose.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-07 16:46:52 +02:00
Martin Vogel 28aaa3979e Merge pull request #1485 from DeusData/fix/1427-exec-no-shell-window
fix(windows): suppress the console window on the last spawn site (#1427)
2026-08-07 16:18:46 +02:00
Martin Vogel 5efa57492a fix(daemon): answer an oversized MCP reply with an error, not process death (#1375)
A single query_graph reply larger than one 10 MiB frame killed the MCP server:
exit code 1, no JSON-RPC error, nothing on stderr, no crash report. Under a host
that does not respawn the server, every tool from it was gone for the rest of
the session — the reporter lost their tooling mid-investigation to one dead-code
query and finished by driving the binary over stdio by hand.

Root cause is a conflation, not the limit itself. application_mcp_request
checked only response_length > UINT32_MAX and passed anything smaller on, so an
oversized reply reached the transport, which rejected the frame. The frontend
worker cannot tell that rejection from a dead socket, and for a dead socket it
deliberately _Exit()s the process — closing the kernel IPC handle is the only
portable way to cancel daemon session ownership from a thread blocked in stdio.
Correct for a broken transport; a fatal over-reaction to a large answer.

The substitution therefore has to happen in application_mcp_request, which is
the last layer still holding the request id. Replies that fit are returned
byte-identical; a reply that cannot be framed becomes a JSON-RPC -32603 naming
both numbers and what to do ("narrow the projection, add LIMIT, or paginate"),
because the failure it replaces was a silent exit and an opaque "internal error"
would be no improvement.

Reproduced first, on a locally generated 20k-node fixture rather than the
reporter's repo (external code stays unverified until reviewed):

  before   LIMIT 10000 -> ok, 8,529,990 bytes
           LIMIT 20000 -> SERVER DIED exit=1 stderr=''
  after    LIMIT 20000 -> JSON-RPC -32603, and a follow-up query on the SAME
           session succeeds — which is the property that actually matters

The decision and the substitution live in one function on purpose. The first
version of the test built the error object directly and PASSED with the size
check removed: it proved the error was well-formed while the server still died.
Testing application_framable_response instead binds the branch — verified by
removing the guard, which reddens it at ASSERT(replaced != big).

Not addressed here, deliberately: every reply still carries its payload twice
(content[0].text and structuredContent.text are verbatim duplicates, ~2.05x
measured), which is why the ceiling is reached at ~4 MB of real content. That is
a wire-format question affecting every tool and every caller's token bill, so it
belongs in its own change with a decision behind it, not smuggled into a crash
fix.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-07 15:28:08 +02:00
Martin Vogelandnoctrex cc1d60db4e fix(windows): suppress the console window on the last spawn site (#1427)
#1448 set CREATE_NO_WINDOW on the subprocess spawn and on cbm_popen_isolated,
but cbm_exec_no_shell (compat_fs.c) still passed a bare 0 for dwCreationFlags.
That is the helper behind git, codesign and open, so the most frequently hit
path kept flashing a console — and under a stdio MCP session with auto_watch
those windows steal focus while the user is typing. Reported by @noctrex on
#1448 after verifying the merged fix.

Also adds the contract nobody had. The flag has to be set per call site, the
four sites do not resemble each other, and reviewing "does this PR add the
flag" says nothing about the sites a PR does not touch — which is exactly how
the third one survived a fix applied twice:

  daemon/bootstrap.c   had it from the start   (DETACHED_PROCESS | ... | flag)
  subprocess.c         added by #1448          (flags variable)
  compat_fs.c :313     added by #1448          (inline literal)
  compat_fs.c :689     missed by #1448         (bare 0)

tests/test_spawn_no_window_contract.sh asserts the whole-tree property instead,
so a fifth spawn site cannot be added without it.

The contract strips C comments before matching, and that is load-bearing rather
than tidiness: the first version of it PASSED with the fix reverted, because the
explanatory comment above the fixed call contains the flag name and satisfied
the match. A contract that can be satisfied by prose about the contract is a
false guard. Verified in both directions afterwards — green with the fix, and
red naming compat_fs.c:696 with the fix reverted and that comment still in
place.

Co-Authored-By: noctrex <noctrex@users.noreply.github.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-07 14:58:22 +02:00
Martin Vogel 98f292ecb5 Merge pull request #1448 from mlandolfi90/fix/hide-daemon-child-windows
fix(windows): hide the console windows of spawned child processes
2026-08-07 14:50:43 +02:00
Martin Vogel 8042aa86fb Merge pull request #1472 from DeusData/feat/workspace-grants-and-manifest
feat: declare indexing roots from the CLI; size-safe compression; prune the cache from discovery
2026-08-07 06:13:11 +02:00
Martin Vogel 7865b8785f Merge pull request #1473 from DeusData/fix/dryrun-predicts-hook-refusal
fix(cli): make --dry-run predict a refused hook-script rewrite (#1387)
2026-08-07 05:49:13 +02:00
Martin Vogel 4f7db18b92 Merge pull request #1477 from DeusData/fix/linux-allocator-override
fix(mem): route ordinary malloc through mimalloc on Linux (#1360)
2026-08-07 05:47:23 +02:00
Martin Vogel ab43e7524d Merge pull request #1476 from DeusData/test/1367-empty-graph-regression
test(similarity): cover the empty-graph qsort null base (#1367)
2026-08-07 05:30:41 +02:00
Martin Vogel d585d829a6 Merge pull request #1475 from DeusData/fix/subprocess-posix-spawn
fix(subprocess): spawn instead of fork+exec on macOS
2026-08-07 05:26:12 +02:00
Martin Vogel bb4fca52c0 fix(discover): resolve the cache directory once per walk, not per directory
cbm_resolve_cache_dir() returns a pointer to a static thread-local buffer. The
cache prune called it for every directory the walk visited, so it rewrote a buffer
other code was entitled to still be holding — an aliasing bug, not a cosmetic
inefficiency.

The Windows guards job caught it as two REDs: a daemon cold-storm regression and
a following "index did not run" setup failure. Both are consistent with the cache
path being rewritten underneath a live reader during indexing.

Resolve once at the discovery entry point into a thread-local copy and compare
against that, so the prune costs one resolver call per walk rather than one per
directory, and holds no pointer into shared storage.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-07 04:41:50 +02:00
Martin Vogel 6ca9ea82cf Merge pull request #1474 from DeusData/diag/cohort-unheld-claim
diag(daemon): name the cohort path that admits a mismatched build
2026-08-07 04:34:06 +02:00
Martin Vogel 452372eec5 fix(mem): route ordinary malloc through mimalloc on Linux (#1360)
The comment above MIMALLOC_OVERRIDE_DEFINE claimed Unix "relies on
static-link-order override". That was never true. mimalloc emits strong
malloc/free definitions only when MI_MALLOC_OVERRIDE is set — the define its
source gates alloc-override.c on — and the build set it for MinGW only.
MI_OVERRIDE, which the build does set everywhere, is this project's own
prod/test marker that mimalloc never reads. With the override body compiled
out there were no strong symbols for link order to prefer, so ordinary malloc
went to libc.

Measured, not inferred. The shipped v0.9.1-rc.1 artifacts report 0/6
allocator-owned size classes on linux-arm64 glibc AND musl-static, and the
same A/B on ubuntu-arm64 here:

  main            warn  mem.allocator.not_owned  owned_classes=0/6
  with this fix   info  mem.allocator.owned      classes=all

Every purge/reclaim option cbm_mem_init sets was therefore inert on Linux,
applying only to the bound sqlite/tree-sitter populations — the same class of
defect as #581, where committed memory ratcheted for months because nothing
asserted the wiring on a real artifact.

macOS stays off deliberately and permanently: enabling the override there
compiles alloc-override.c's forwarding definitions, and under the two-level
namespace this binary's free becomes mi_free while system libraries keep
allocating from the system allocator, so the first pointer crossing that
boundary aborts with "mi_free: invalid pointer". ELF's flat namespace has no
such split, which is why Linux can have this and macOS cannot.

Three parts:

* Makefile.cbm switches MI_MALLOC_OVERRIDE and a new CBM_MEM_GLOBAL_OVERRIDE
  together in one place, for MinGW and Linux. The latter tells our own sources
  what to expect and goes into CFLAGS_PROD only, because MIMALLOC_CFLAGS_TEST
  builds mimalloc with -DMI_OVERRIDE=0 and no override define: a test binary
  has no global override BY CONSTRUCTION and must never be told to expect one.
  Deriving that expectation from the platform instead would make every Linux
  test run warn about a correctly configured build.

* mem.c splits the startup audit by what the build actually asked for. Where
  no override was requested, ordinary malloc reaching libc is the design, so
  it reports the measured ownership at INFO and names what IS bound. A warning
  that fires on every run of a correct build is not a tripwire, it is noise —
  it trains readers to ignore the one line that catches #581. The genuine
  warning still fires wherever an override was requested and did not take.

* smoke-test.sh Phase 1b pins the SHIPPED artifact's wiring per platform and
  fails in BOTH directions: Windows/Linux must own all classes, macOS must
  not. This cannot live in a unit test — a from-source test build never has
  the override — which is exactly why the defect survived so long.

Verified on ubuntu-arm64: prod owns all classes; the test build reports
bound-populations-only at INFO (0/6) and the mem suite stays green; smoke
Phase 1b passes, and fails with the expected message when the same tree is
built with the override forced off. On macOS: prod reports
bound_populations_only at INFO, smoke Phase 1b passes, mem suite 51/51.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-06 18:33:34 +02:00
Martin Vogel 6a343a7bce test(similarity): cover the empty-graph qsort null base (#1367)
The production guard for #1367 — collect_fp_entries only sorting when
count > 1 — is on main, but nothing exercises it. A fix without a test is a
fix that can be silently undone, and this one is unusually easy to undo:
removing the guard produces no visible symptom on most lanes.

Adds the missing case: a graph with zero Function/Method nodes, where the
entry array is never allocated and the null base would reach qsort.

The test comment records WHERE the test binds, because that turned out to be
the interesting part. Removing the guard again:

  macOS                          25 passed  (Apple libc does not mark qsort
                                             nonnull, so there is no UB to see)
  Linux ASan/UBSan, recovering   25 passed  (prints "null pointer passed as
                                             argument 1", then keeps going)
  Linux UBSan, trapping          exit 1     (process aborts — the RED)

So the assertions here are the behavioural half (empty graph → rc 0, no
SIMILAR_TO edges), and the UB half is caught by the trap-UBSan leg alone.
Both were measured on ubuntu-arm64 in each direction. Spelling that out in
the test keeps a future reader from checking it on a recovering lane, seeing
green, and concluding the null-base guard is still there when it is not.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-06 18:33:13 +02:00
Martin Vogel 91f622315b fix(test): free the fixture project name before reindexing
Three language fixtures assign cbm_project_name_from_path into lp->project each
time they open an indexed store. Teardown frees the last one, so a fixture that
indexes more than once dropped every earlier heap name — 68 allocations across the
run, which the macOS leak lane reports at process exit.

Pre-existing rather than introduced here: none of these files or fqn.c are in this
branch's diff, and the leak lane passed on the previous PR. Adding a suite shifted
what the leak job sees, which is how it surfaced. Fixing it here rather than
recording it, since a gate that only stays green by composition accident is not a
gate.

Freeing before reassigning also makes the ownership obvious at the point it
matters, instead of relying on the reader noticing the teardown three functions
away.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-06 18:15:48 +02:00
Martin Vogel 80d52797f6 feat(foundation): per-project request manifest with content-keyed approval
A repository may ship .cbmpathwhitelist listing outside roots it would like
indexed alongside it. The file REQUESTS; it never GRANTS. Both attackers in this
project's threat model can write a file inside a repository — a malicious indexed
repo, and an agent with write access to the project — so a repo-local file cannot
be authoritative without handing them the boundary.

Approval is recorded user-level and keyed to a SHA-256 of the manifest bytes, so
editing the file lapses approval and it must be granted again. Without the hash a
repo approved once could widen itself forever on a later pull. This is direnv's
model, and it is the only shape that keeps a checked-in file's ergonomics without
making the file a permission slip. The hash covers the raw bytes rather than the
parsed entries: a reordering or comment change is still a change the approver has
not seen.

`allow-root --approve-manifest <project>` is the human action that grants.
Approval refuses outright if any requested entry would not stand on its own as an
indexing root, so approving cannot become a route around the breadth policy, and
entries are re-classified at use time as well so a stored approval cannot outrank
a credential list that has grown since.

Entries containing control characters are rejected — the same shape as the
newline splitting that let a crafted indexed path inject an extra entry into the
scoped file list.

Deliberately NOT wired into the root boundary. A project's manifest authorizes
outside roots FOR THAT PROJECT, so the question is "may project P pull in tree T",
not "may T be indexed standalone", and the boundary check only ever sees one path
with no project context to ask that with. A first draft did wire it there by
passing the candidate as its own project root, which read a manifest that by
definition was not the one requesting it and so authorized nothing — caught by
exercising it end to end. The consuming half belongs where project context exists,
in discovery walking a project's approved extra roots;
cbm_workspace_manifest_allows is the query it will use.

Five tests, including the one the design rests on: approval lapses when the
manifest content changes.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-06 18:00:08 +02:00
Martin Vogel 579a910886 fix(subprocess): spawn instead of fork+exec on macOS
fork() duplicates the parent's address-space bookkeeping. Under an
ASan-instrumented parent that includes an enormous shadow mapping, and past a
footprint threshold the child is jetsam-killed BEFORE exec replaces the image.
The reaped corpse then looks like the launched tool dying, so a `git init` in a
fixture "fails" only once enough suites have run ahead of it — a spawn defect
that reads as an unrelated assertion. tests/test_daemon_runtime.c already
switched to posix_spawn for this exact reason; this moves the shared
subprocess layer over as well.

posix_spawn never copies the parent address space, so the parent's footprint
stops being a variable. Every guarantee of the fork path is carried over:

  - own process group        SETPGROUP + setpgroup(0)   (kill-tree contract)
  - default signal handling  SETSIGDEF + SETSIGMASK
  - std{in,out,err} wiring   adddup2
  - all other fds closed     CLOEXEC_DEFAULT, Apple's equivalent of the
                             child's close-everything loop (the three dup2'd
                             fds survive, since dup2 clears close-on-exec)
  - PATH lookup              posix_spawnp keeps execvp semantics

One deliberate difference is reconciled rather than adopted: posix_spawn
reports an unusable binary to the PARENT, where fork+exec instead yields a
child that exits 127. cbm_subprocess_run's contract treats spawn_failed as
"the spawn mechanism failed", not "the tool was missing", so exec-class errors
fall back to fork+exec and macOS keeps classifying a bogus binary exactly as
Linux does. That path forks a child that exits immediately, so it does not
reintroduce the hazard.

The existing suite binds each guarantee, verified by breaking them:
dropping CLOEXEC_DEFAULT leaks the sentinel descriptor and reddens
subprocess_posix_child_closes_unrelated_descriptors; dropping SETPGROUP makes
the kill-tree turn on the runner's own group and reddens the suite wholesale.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-06 17:25:24 +02:00
Martin Vogel 455e9e5b62 diag(daemon): name the cohort path that admits a mismatched build
A forced client/daemon build mismatch raises a cohort conflict locally but is
ADMITTED on every CI leg, which made the #1388 conflict regression untestable
there. Reading the code explains how that is possible: identity comparison
happens ONLY in the BUSY branch, i.e. when another process already holds the
cohort lifetime lock. When the EX acquire succeeds - no live holder - the
participant claims the cohort and NO comparison runs at all, so a mismatched
build joins.

That is correct behaviour when nothing is running. What is missing is the
ability to tell the two apart after the fact: "was the lifetime lock held?" is
exactly the question that separates the local run from the CI run, and no log
line answered it.

This is deliberately observability only - no admission behaviour changes. The
next CI run that exercises the path will say which branch it took, and that
decides whether the CI daemon simply is not holding the lease (an environment
fact) or the lease is being released early (a real bug).

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-06 16:50:38 +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 bf0dccf1f9 feat(cli): add allow-root to declare an indexing root
The grant store was readable but nothing could write to it, so only
CBM_ALLOWED_ROOT could declare a root. `allow-root <path>` records one,
`--list` shows what is recorded, and `--approve-sensitive` is required for a
home or credential directory. Roots that are refused outright — volume and
drive roots, paths too broad to index as a unit — cannot be granted at all.

Enrollment is a command a person types and deliberately nothing else. That is
the property the whole store exists for: neither an indexed repository nor a
tool caller can widen its own boundary. A confirmation delivered through the
MCP surface would be answered by the same agent that may have been influenced,
so it would not be a human decision.

The path is canonicalized before recording, because the policy is defined over
resolved paths and a grant stored as a symlink would not match the resolved
path the indexer later presents.

Classified stateless in the daemon bootstrap: it writes one line of user-level
config and reads nothing from a project, so enrolling a root must not depend on
daemon state. Without that classification the argument fell through to MCP
server mode and exited silently on EOF.

Verified end to end: /etc refused as too broad, $HOME refused pending the flag,
a project directory recorded, a granted root indexes, and a non-granted root is
refused naming the exact command that would allow it.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-06 16:18:32 +02:00
Martin Vogel 7814dacf15 fix: size the compress path with size_t and prune the cache from the walk
Two loose ends from the bounds work.

The decompression side already took size_t so a >2 GiB capacity could not wrap
through int, but compression still took int for both the source length and the
destination capacity, and the artifact export cast a size_t database size down
to reach it. A database past 2 GiB would have handed the encoder a negative
length. Both lengths and the bound helper are size_t now, and the function
returns int64_t like its decompressing counterpart.

The discovery walk now prunes the cache directory by absolute path. A custom
CBM_CACHE_DIR may sit inside a repository — tests do it routinely — and walking
into it pulls every other project's graph database into this project's file
list. This is the narrow form of a concern that was briefly implemented as
refusing any root that contained the cache; refusing a whole root was too blunt,
and not walking the cache is what the concern actually asks for.

The test fails without the prune: a .go file planted under the cache is
otherwise discovered, and "cache" is not in the built-in skip list, so the
assertion is not vacuous.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-06 16:06:43 +02:00
Martin Vogel 0d6f26a170 Merge pull request #1464 from DeusData/fix/bounds-and-workspace-roots
fix: tighten buffer, recursion and indexing-root bounds
2026-08-06 04:44:25 +02:00