From a6f132bc85b6d010667db5ba0cf64fb537e551b2 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 8 Aug 2026 02:58:20 +0200 Subject: [PATCH 1/7] harden: ship integration templates as a verified JSON asset, not embedded bytes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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// — 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 --- Makefile.cbm | 22 +- assets/cbm-integrations.json | 1 + scripts/ci/check-binary-composition.sh | 38 ++ scripts/gen-integrations-hash.sh | 54 ++ scripts/package-release.sh | 19 +- scripts/test.sh | 3 + src/cli/cli.c | 549 ++++++++++----------- src/cli/client_adapter.c | 184 +++---- src/cli/client_adapter.h | 19 +- src/cli/integration_assets.c | 415 ++++++++++++++++ src/cli/integration_assets.h | 89 ++++ tests/test_cli.c | 160 ++++++ tests/test_no_embedded_scripts_contract.sh | 143 ++++++ tests/test_windows_bundle_contract.sh | 10 +- 14 files changed, 1288 insertions(+), 418 deletions(-) create mode 100644 assets/cbm-integrations.json create mode 100644 scripts/gen-integrations-hash.sh create mode 100644 src/cli/integration_assets.c create mode 100644 src/cli/integration_assets.h create mode 100644 tests/test_no_embedded_scripts_contract.sh diff --git a/Makefile.cbm b/Makefile.cbm index 97a481d9b7..56bd923798 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -52,7 +52,7 @@ CFLAGS_COMMON = -std=c11 -D_DEFAULT_SOURCE -D_GNU_SOURCE -Wall -Wextra -Werror \ $(GCC_ONLY_FLAGS) \ -Isrc -Ivendored -Ivendored/sqlite3 \ -Ivendored/mimalloc/include \ - -I$(CBM_DIR) -I$(TS_INCLUDE) + -I$(CBM_DIR) -I$(TS_INCLUDE) -I$(BUILD_DIR)/generated CXXFLAGS_COMMON = -std=c++14 -Wall -Wextra -Werror \ -Wno-unused-parameter \ @@ -355,7 +355,7 @@ GIT_SRCS = src/git/git_context.c # CLI module (new) CLI_SRCS = src/cli/cli.c src/cli/progress_sink.c src/cli/hook_augment.c src/cli/client_adapter.c \ - src/cli/agent_clients.c src/cli/agent_profiles.c \ + src/cli/agent_clients.c src/cli/agent_profiles.c src/cli/integration_assets.c \ src/cli/config_json_like.c src/cli/config_toml_edit.c src/cli/config_yaml_edit.c \ src/cli/config_text_edit.c src/cli/activation_transaction.c @@ -470,6 +470,17 @@ EXISTING_C_SRCS = $(EXTRACTION_SRCS) $(LSP_SRCS) $(TS_RUNTIME_SRC) \ PROJECT_HDRS = $(wildcard src/*.h src/*/*.h $(CBM_DIR)/*.h $(CBM_DIR)/lsp/*.h tests/*.h \ tests/repro/*.h) +# ── Integration-asset integrity constant (generated, never hand-edited) ── +# The binary embeds exactly ONE fact about the shipped integration templates: +# the SHA-256 of assets/cbm-integrations.json, generated at build time (rule +# below the BUILD_DIR definition; target lines expand immediately). Appended +# to PROJECT_HDRS so every single-invocation target (test-runner, +# test-repro-runner, test-runner-tsan, codebase-memory-mcp) rebuilds when the +# asset changes — a stale constant would make every install fail verification. +INTEGRATIONS_ASSET = assets/cbm-integrations.json +INTEGRATIONS_HASH_HDR = $(BUILD_DIR)/generated/cbm_integrations_hash.h +PROJECT_HDRS += $(INTEGRATIONS_HASH_HDR) + # ── Test sources ───────────────────────────────────────────────── TEST_FOUNDATION_SRCS = \ @@ -685,6 +696,11 @@ ALL_TEST_SRCS =$(TEST_FOUNDATION_SRCS) $(TEST_EXTRACTION_SRCS) $(TEST_STORE_SRCS BUILD_DIR = build/c +# Integration-asset hash header (variables defined next to PROJECT_HDRS; the +# rule lives here because target lines expand $(BUILD_DIR) immediately). +$(INTEGRATIONS_HASH_HDR): $(INTEGRATIONS_ASSET) scripts/gen-integrations-hash.sh | $(BUILD_DIR) + scripts/gen-integrations-hash.sh $(INTEGRATIONS_ASSET) $@ + # ── Object file compilation (grammars need relaxed warnings) ───── # Grammar + tree-sitter runtime: compiled without -Werror (upstream code has warnings) @@ -1097,7 +1113,7 @@ lint-cppcheck: --suppress=ctunullpointerOutOfMemory \ --suppress='nullPointer:internal/cbm/sqlite_writer.c' \ -Isrc -Ivendored -Ivendored/sqlite3 \ - -I$(CBM_DIR) -I$(TS_INCLUDE) \ + -I$(CBM_DIR) -I$(TS_INCLUDE) -I$(BUILD_DIR)/generated \ $(LINT_SRCS) # clang-format: formatting check (config in .clang-format, dry-run = no changes) diff --git a/assets/cbm-integrations.json b/assets/cbm-integrations.json new file mode 100644 index 0000000000..35caa19c27 --- /dev/null +++ b/assets/cbm-integrations.json @@ -0,0 +1 @@ +{"format":1,"product":"codebase-memory-mcp","templates":{"claude_gate":{"posix":{"text":"#!/usr/bin/env bash\n# codebase-memory-mcp search augmenter (Claude Code PreToolUse).\n# NOTE: the legacy filename is kept for zero-migration upgrades.\n# Despite the name this NEVER blocks a tool call - it only adds\n# graph context. Any failure is silent (exit 0, no output).\nBIN={{BIN}}\n[ -x \"$BIN\" ] || exit 0\n\"$BIN\" hook-augment 2>/dev/null\nexit 0\n","bin":"sh"},"windows":{"text":"@echo off\r\nsetlocal DisableDelayedExpansion\r\nREM PreToolUse search and read coverage adapter installed by codebase-memory-mcp.\r\nREM Fail-open: it never blocks or logs hook or prompt content.\r\nset \"BIN={{BIN}}\"\r\nif not exist \"%BIN%\" exit /b 0\r\n\"%BIN%\" hook-augment 2>NUL\r\nexit /b 0\r\n","bin":"cmd"},"released":[{"text":"#!/usr/bin/env bash\n# codebase-memory-mcp search augmenter (Claude Code PreToolUse).\n# NOTE: the legacy filename is kept for zero-migration upgrades.\n# Despite the name this NEVER blocks a tool call - it only adds\n# graph context. Any failure is silent (exit 0, no output).\nBIN=\"{{BIN}}\"\n[ -x \"$BIN\" ] || exit 0\n\"$BIN\" hook-augment 2>/dev/null\nexit 0\n","bin":"raw"}]},"claude_session":{"posix":{"text":"#!/usr/bin/env bash\n# SessionStart context adapter installed by codebase-memory-mcp.\n# Fail-open: it never blocks or logs hook/prompt content.\nBIN={{BIN}}\n[ -x \"$BIN\" ] || exit 0\n\"$BIN\" hook-augment 2>/dev/null\nexit 0\n","bin":"sh"},"windows":{"text":"@echo off\r\nsetlocal DisableDelayedExpansion\r\nREM SessionStart context adapter installed by codebase-memory-mcp.\r\nREM Fail-open: it never blocks or logs hook or prompt content.\r\nset \"BIN={{BIN}}\"\r\nif not exist \"%BIN%\" exit /b 0\r\n\"%BIN%\" hook-augment 2>NUL\r\nexit /b 0\r\n","bin":"cmd"},"released":[{"text":"#!/usr/bin/env bash\n# SessionStart hook: remind agent to use codebase-memory-mcp tools.\n# Installed by codebase-memory-mcp. Fires on startup/resume/clear/compact.\ncat << 'REMINDER'\nCRITICAL - Code Discovery Protocol:\n1. ALWAYS use codebase-memory-mcp tools FIRST for ANY code exploration:\n - search_graph(name_pattern/label/qn_pattern) to find functions/classes/routes\n - trace_path(function_name, mode=calls|data_flow|cross_service) for call chains\n - get_code_snippet(qualified_name) for exact symbol source (precise ranges)\n - query_graph(query) for complex Cypher patterns\n - get_architecture(aspects) for project structure\n - search_code(pattern) for text search (graph-augmented grep)\n2. Use Grep/Glob/Read freely for text, configs, non-code files, and\n always Read a file before editing it.\n3. If a project is not indexed yet, run index_repository FIRST.\nREMINDER\n"}]},"claude_subagent":{"posix":{"text":"#!/usr/bin/env bash\n# SubagentStart context adapter installed by codebase-memory-mcp.\n# Fail-open: it never blocks or logs hook/prompt content.\nBIN={{BIN}}\n[ -x \"$BIN\" ] || exit 0\n\"$BIN\" hook-augment 2>/dev/null\nexit 0\n","bin":"sh"},"windows":{"text":"@echo off\r\nsetlocal DisableDelayedExpansion\r\nREM SubagentStart context adapter installed by codebase-memory-mcp.\r\nREM Fail-open: it never blocks or logs hook or prompt content.\r\nset \"BIN={{BIN}}\"\r\nif not exist \"%BIN%\" exit /b 0\r\n\"%BIN%\" hook-augment 2>NUL\r\nexit /b 0\r\n","bin":"cmd"},"released":[{"text":"#!/usr/bin/env bash\n# SubagentStart hook: tell subagents to use codebase-memory-mcp tools.\n# Installed by codebase-memory-mcp. Fires when any subagent is spawned.\n# SubagentStart injects context via JSON additionalContext, not plain stdout.\ncat << 'REMINDER'\n{\"hookSpecificOutput\":{\"hookEventName\":\"SubagentStart\",\"additionalContext\":\"Code discovery: prefer codebase-memory-mcp tools (search_graph, trace_path, get_code_snippet, query_graph, get_architecture, search_code) over grep/file-read for navigating code. Use Grep/Glob/Read for text, configs, and non-code files.\"}}\nREMINDER\n"}]},"augment_session":{"posix":{"text":"#!/bin/sh\n# SessionStart adapter installed by codebase-memory-mcp.\nBIN={{BIN}}\n[ -x \"$BIN\" ] || exit 0\nexec \"$BIN\" hook-augment --event SessionStart 2>/dev/null\n","bin":"sh"},"windows":{"text":"# SessionStart adapter installed by codebase-memory-mcp.\n$bin = {{BIN}}\nif (-not (Test-Path -LiteralPath $bin -PathType Leaf)) { exit 0 }\n& $bin hook-augment --event SessionStart 2>$null\nexit 0\n","bin":"ps"}},"augment_coverage":{"posix":{"text":"#!/bin/sh\n# PostToolUse view adapter installed by codebase-memory-mcp.\nBIN={{BIN}}\n[ -x \"$BIN\" ] || exit 0\nexec \"$BIN\" hook-augment --dialect augment 2>/dev/null\n","bin":"sh"},"windows":{"text":"# PostToolUse view adapter installed by codebase-memory-mcp.\n$bin = {{BIN}}\nif (-not (Test-Path -LiteralPath $bin -PathType Leaf)) { exit 0 }\n& $bin hook-augment --dialect augment 2>$null\nexit 0\n","bin":"ps"}},"cline_context":{"posix":{"text":"#!/bin/sh\n# Cline {{EVENT}} context adapter installed by codebase-memory-mcp.\nBIN={{BIN}}\n[ -x \"$BIN\" ] || exit 0\nexec \"$BIN\" hook-augment --dialect cline --event {{EVENT}} 2>/dev/null\n","bin":"sh"},"windows":{"text":"# Cline {{EVENT}} context adapter installed by codebase-memory-mcp.\n$bin = {{BIN}}\nif (-not (Test-Path -LiteralPath $bin -PathType Leaf)) { exit 0 }\n& $bin hook-augment --dialect cline --event {{EVENT}} 2>$null\nexit 0\n","bin":"ps"}},"adapter_pi":{"all":{"text":"// Generated by codebase-memory-mcp for pi.\n// Regenerated on install/update from the MCP tool registry, so it cannot\n// drift from the tools the server actually serves. Edits inside this block\n// are overwritten; edit outside it, or remove the markers to take ownership.\nimport { spawn } from 'node:child_process';\n\nconst BIN = '{{BIN}}';\n\nasync function call(tool, args, signal) {\n return new Promise((resolve) => {\n const child = spawn(BIN, ['cli', tool, JSON.stringify(args ?? {})], {\n stdio: ['ignore', 'pipe', 'pipe'],\n env: { ...process.env, CBM_LOG_LEVEL: 'error' },\n });\n let out = '';\n const onAbort = () => { if (!child.killed) child.kill(); };\n signal?.addEventListener('abort', onAbort, { once: true });\n child.stdout.on('data', (d) => (out += d.toString()));\n child.on('error', (e) => {\n signal?.removeEventListener('abort', onAbort);\n resolve({ error: String(e && e.message ? e.message : e) });\n });\n child.on('close', () => {\n signal?.removeEventListener('abort', onAbort);\n const lines = out.split('\\n').map((l) => l.trim()).filter(Boolean);\n for (let i = lines.length - 1; i >= 0; i--) {\n try { return resolve(JSON.parse(lines[i])); } catch { /* keep scanning */ }\n }\n resolve({ error: 'no JSON response from codebase-memory-mcp' });\n });\n });\n}\n\nexport function register(pi) {\n{{TOOLS}}}\n","bin":"js"},"tool_line":" pi.registerTool({ name: '{{TOOL}}', run: (args, ctx) => call('{{TOOL}}', args, ctx?.signal) });\n"},"adapter_opencode":{"all":{"text":"// Generated by codebase-memory-mcp for OpenCode.\n// Regenerated on install/update from the MCP tool registry, so it cannot\n// drift from the tools the server actually serves. Edits inside this block\n// are overwritten; edit outside it, or remove the markers to take ownership.\n// OpenCode already reaches every tool over MCP; this adds only the\n// automatic graph lookup before a grep/glob, which other clients get\n// through their own hook configuration.\nimport { spawn } from 'node:child_process';\n\nconst BIN = '{{BIN}}';\n\nfunction augment(tool, args) {\n return new Promise((resolve) => {\n const child = spawn(BIN, ['hook-augment'], {\n stdio: ['pipe', 'pipe', 'ignore'],\n env: { ...process.env, CBM_LOG_LEVEL: 'error' },\n });\n let out = '';\n child.stdout.on('data', (d) => (out += d.toString()));\n child.on('error', () => resolve(''));\n child.on('close', () => resolve(out));\n child.stdin.end(JSON.stringify({\n hook_event_name: 'PreToolUse',\n tool_name: tool,\n tool_input: args ?? {},\n }));\n });\n}\n\nexport const CodebaseMemory = async () => ({\n 'tool.execute.after': async (input, output) => {\n const tool = input?.tool === 'grep' ? 'Grep' : input?.tool === 'glob' ? 'Glob' : null;\n if (!tool) return;\n const extra = await augment(tool, output?.args);\n if (extra && typeof output?.output === 'string') {\n output.output += '\\n' + extra;\n }\n },\n});\n","bin":"js"}}}} diff --git a/scripts/ci/check-binary-composition.sh b/scripts/ci/check-binary-composition.sh index c940793f72..1a0167067a 100755 --- a/scripts/ci/check-binary-composition.sh +++ b/scripts/ci/check-binary-composition.sh @@ -93,6 +93,21 @@ UI_HTTP_NEEDLES=( '[c]odebase-memory-mcp' ) +# The binary used to embed nine complete shebang'd shell scripts, their +# PowerShell/.cmd twins and two node:child_process client modules — script +# bodies written to disk at 0755, which is precisely the text surface +# Defender's ML scored Trojan:Script/Wacatac.B!ml. They now ship in +# cbm-integrations.json (the binary embeds only its SHA-256); these needles +# prove the removal stayed removed. Source-level enforcement lives in +# tests/test_no_embedded_scripts_contract.sh; this is the shipped-artifact +# half of the same property. +SCRIPT_NEEDLES=( + '#!/usr/bin/env bash' + '#!/bin/bash' + '#!/bin/sh' + 'node:child_process' +) + # Canary: proves the needle scan can actually see this file's strings. Without # it, handing the gate a gzip, a stub or a 0-byte file would pass every # absence assertion. 168+ occurrences in a real artifact, 0 in anything else. @@ -367,6 +382,29 @@ check_file() { printf 'INFO %-22s %s: %d/%d UI/HTTP needles present (no-UI split pending; set CBM_CHECK_UI_ABSENT=1 to enforce)\n' \ A5-no-ui-http "$token" "$ui_hits" "${#UI_HTTP_NEEDLES[@]}" fi + + # A6 — embedded integration-script text. Enforced on standard artifacts: + # every C-source template moved into cbm-integrations.json, so a hit means + # one crept back in. The ui artifact additionally embeds the RAW frontend + # bundle (scripts/embed-frontend.sh stores plain bytes), whose minified JS + # may legitimately contain e.g. "#!" sequences we do not control — so ui + # reports rather than fails until that is measured on a real ui build, + # mirroring A5's honest-INFO pattern. Flip CBM_CHECK_UI_SCRIPTS_ABSENT=1 + # to enforce there too. + if [ "$is_ui" -eq 1 ] && [ "${CBM_CHECK_UI_SCRIPTS_ABSENT:-0}" != "1" ]; then + script_hits=0 + for needle in "${SCRIPT_NEEDLES[@]}"; do + if LC_ALL=C grep -a -q -F -e "$needle" "$file"; then + script_hits=$((script_hits + 1)) + fi + done + printf 'INFO %-22s %s: %d/%d script needles present (raw frontend bundle; set CBM_CHECK_UI_SCRIPTS_ABSENT=1 to enforce)\n' \ + A6-no-embedded-scripts "$token" "$script_hits" "${#SCRIPT_NEEDLES[@]}" + else + for needle in "${SCRIPT_NEEDLES[@]}"; do + assert_absent "$file" "$token" A6-no-embedded-scripts "$needle" + done + fi } # ── Walk the targets ──────────────────────────────────────────────── diff --git a/scripts/gen-integrations-hash.sh b/scripts/gen-integrations-hash.sh new file mode 100644 index 0000000000..eadf043437 --- /dev/null +++ b/scripts/gen-integrations-hash.sh @@ -0,0 +1,54 @@ +#!/bin/sh +# gen-integrations-hash.sh — emit the build-time integrity constant for +# assets/cbm-integrations.json. +# +# The binary embeds exactly ONE fact about the integration templates: the +# SHA-256 of the shipped asset file. Verifying the file against this constant +# before use preserves the tamper-evidence the templates had while they were +# compiled in — moving them out of the binary (AV-surface reduction) must not +# turn them into unauthenticated code the installer happily writes to disk. +# +# Generated, never hand-maintained: a stale constant is exactly the silent +# drift this step exists to prevent, so the header is a build product with a +# real dependency on the JSON file. +set -eu + +if [ "$#" -ne 2 ]; then + echo "usage: gen-integrations-hash.sh " >&2 + exit 2 +fi +asset="$1" +out="$2" + +[ -f "$asset" ] || { echo "gen-integrations-hash: missing $asset" >&2; exit 1; } + +if command -v sha256sum >/dev/null 2>&1; then + hash=$(sha256sum "$asset" | cut -d' ' -f1) +elif command -v shasum >/dev/null 2>&1; then + hash=$(shasum -a 256 "$asset" | cut -d' ' -f1) +else + echo "gen-integrations-hash: neither sha256sum nor shasum available" >&2 + exit 1 +fi + +case "$hash" in +*[!0-9a-f]* | "") + echo "gen-integrations-hash: bad digest '$hash'" >&2 + exit 1 + ;; +esac +if [ "${#hash}" -ne 64 ]; then + echo "gen-integrations-hash: bad digest length for '$hash'" >&2 + exit 1 +fi + +mkdir -p "$(dirname "$out")" +tmp="$out.tmp.$$" +{ + echo "/* Generated by scripts/gen-integrations-hash.sh — do not edit. */" + echo "#ifndef CBM_INTEGRATIONS_HASH_H" + echo "#define CBM_INTEGRATIONS_HASH_H" + echo "#define CBM_INTEGRATIONS_SHA256 \"$hash\"" + echo "#endif" +} >"$tmp" +mv "$tmp" "$out" diff --git a/scripts/package-release.sh b/scripts/package-release.sh index 72b0907040..2a837a918c 100755 --- a/scripts/package-release.sh +++ b/scripts/package-release.sh @@ -37,9 +37,16 @@ Environment: BUILD_DIR build tree to archive from (default build/c). Archive contents (defined here, canonical) — ONE binary per platform: - unix: codebase-memory-mcp LICENSE install.sh THIRD_PARTY_NOTICES.md (.tar.gz) - windows: codebase-memory-mcp.exe LICENSE install.ps1 + unix: codebase-memory-mcp cbm-integrations.json LICENSE install.sh + THIRD_PARTY_NOTICES.md (.tar.gz) + windows: codebase-memory-mcp.exe cbm-integrations.json LICENSE install.ps1 THIRD_PARTY_NOTICES.md (.zip) + +cbm-integrations.json is the integration-template data file: the binary +embeds only its SHA-256 and refuses to install integrations without a +verified copy, so an archive without it produces a binary that cannot +install. It ships NEXT TO the binary — the resolution path install.sh / +install.ps1 rely on when they run `install` from the extracted archive. EOF } @@ -174,13 +181,13 @@ if [ "$GOOS" = "windows" ]; then # a workflow step so the local artifact-flow smoke enforces the same thing. bash scripts/ci/check-binary-composition.sh --variant="$VARIANT" \ "$PACK_DIR/codebase-memory-mcp.exe" || exit 2 - cp LICENSE install.ps1 "$PACK_DIR/" + cp LICENSE install.ps1 assets/cbm-integrations.json "$PACK_DIR/" scripts/gen-third-party-notices.sh "$PACK_DIR/THIRD_PARTY_NOTICES.md" ( cd "$PACK_DIR" rm -f "$OUT_DIR/$NAME.zip" zip -q "$OUT_DIR/$NAME.zip" \ - codebase-memory-mcp.exe LICENSE install.ps1 THIRD_PARTY_NOTICES.md + codebase-memory-mcp.exe cbm-integrations.json LICENSE install.ps1 THIRD_PARTY_NOTICES.md ) echo "=== package-release: $OUT_DIR/$NAME.zip ===" else @@ -189,9 +196,9 @@ else strip_release_binary "$BUILD_DIR/codebase-memory-mcp" || exit 2 bash scripts/ci/check-binary-composition.sh --variant="$VARIANT" \ "$BUILD_DIR/codebase-memory-mcp" || exit 2 - cp LICENSE install.sh "$BUILD_DIR/" + cp LICENSE install.sh assets/cbm-integrations.json "$BUILD_DIR/" scripts/gen-third-party-notices.sh "$BUILD_DIR/THIRD_PARTY_NOTICES.md" tar -czf "$OUT_DIR/$NAME.tar.gz" -C "$BUILD_DIR" \ - codebase-memory-mcp LICENSE install.sh THIRD_PARTY_NOTICES.md + codebase-memory-mcp cbm-integrations.json LICENSE install.sh THIRD_PARTY_NOTICES.md echo "=== package-release: $OUT_DIR/$NAME.tar.gz ===" fi diff --git a/scripts/test.sh b/scripts/test.sh index f7b813e2ba..46db34e5ea 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -233,6 +233,9 @@ bash "$ROOT/tests/test_venue_parity_contract.sh" echo "=== Step 0k: spawn console-window contract (#1427) ===" bash "$ROOT/tests/test_spawn_no_window_contract.sh" +echo "=== Step 0l: no embedded integration-script text (AV surface) ===" +bash "$ROOT/tests/test_no_embedded_scripts_contract.sh" + # Verify compiler supports target arch verify_compiler "$CC" diff --git a/src/cli/cli.c b/src/cli/cli.c index 81a4f2ac21..3e8d02beef 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -22,6 +22,7 @@ #include "foundation/log.h" #include "foundation/sha256.h" #include "cli/client_adapter.h" +#include "cli/integration_assets.h" #include "mcp/mcp.h" // cbm_mcp_tool_input_schema — CLI flag parser + per-tool --help #include "mcp/index_supervisor.h" @@ -4702,6 +4703,156 @@ static bool cbm_write_owned_hook_script(const char *path, const char *script) { return cbm_write_owned_hook_script_with_legacy(path, script, NULL, 0U); } +/* ── Integration-template rendering ───────────────────────────── */ +/* Script bodies are no longer compiled in (a block of shebang'd shell text + * inside an unsigned executable is exactly what AV classifiers score); they + * come from the hash-verified cbm-integrations.json via integration_assets.h + * and are materialized here by placeholder substitution. Byte-identity with + * the old embedded builders is the migration contract: ownership checks + * recognise previously written files only because the rendered bytes are + * exactly what those versions wrote. */ + +#ifdef _WIN32 +static int cbm_escape_batch_value(const char *value, char *escaped, size_t escaped_size) { + if (!value || !escaped || escaped_size == 0U) { + return CLI_ERR; + } + size_t out = 0U; + for (const unsigned char *cursor = (const unsigned char *)value; *cursor; cursor++) { + if (*cursor < 0x20U || *cursor == 0x7fU || *cursor == '"') { + return CLI_ERR; + } + size_t needed = (*cursor == '%' || *cursor == '^') ? 2U : 1U; + if (out + needed >= escaped_size) { + return CLI_ERR; + } + if (needed == 2U) { + escaped[out++] = (char)*cursor; + } + escaped[out++] = (char)*cursor; + } + escaped[out] = '\0'; + return CLI_OK; +} +#endif + +/* Encode the binary path exactly as the template's era/format requires. The + * modes mirror the historical builders one-to-one; "raw" is the pre-quoting + * released form, which never escaped and only refused the one character able + * to terminate its double-quoted context. */ +static int cbm_encode_template_binary(const char *bin_mode, const char *binary_path, char *out, + size_t out_size) { + if (!bin_mode || !binary_path || !out || out_size == 0U) { + return CLI_ERR; + } + if (strcmp(bin_mode, "sh") == 0) { + return cbm_shell_quote_word(binary_path, out, out_size); + } + if (strcmp(bin_mode, "ps") == 0) { + return cbm_powershell_quote_word(binary_path, out, out_size); + } +#ifdef _WIN32 + if (strcmp(bin_mode, "cmd") == 0) { + return cbm_escape_batch_value(binary_path, out, out_size); + } +#endif + if (strcmp(bin_mode, "raw") == 0) { + if (strchr(binary_path, '"')) { + return CLI_ERR; + } + int written = snprintf(out, out_size, "%s", binary_path); + return written > 0 && (size_t)written < out_size ? CLI_OK : CLI_ERR; + } + return CLI_ERR; +} + +static int cbm_template_append(char *out, size_t out_size, size_t *used, const char *chunk, + size_t chunk_length) { + if (chunk_length >= out_size - *used) { + return CLI_ERR; + } + memcpy(out + *used, chunk, chunk_length); + *used += chunk_length; + out[*used] = '\0'; + return CLI_OK; +} + +/* Materialize one template body: substitute {{BIN}} (encoded per bin_mode) + * and {{EVENT}}. An unresolved "{{" is an error, never emitted — a template + * placeholder must not survive into a file we write to disk. */ +static int cbm_render_integration_body(const cbm_integration_body_t *body, const char *binary_path, + const char *event, char *out, size_t out_size) { + static const char bin_token[] = "{{BIN}}"; + static const char event_token[] = "{{EVENT}}"; + if (!body || !body->text || !out || out_size == 0U) { + return CLI_ERR; + } + char encoded_binary[CLI_BUF_8K]; + encoded_binary[0] = '\0'; + if (body->bin_mode && cbm_encode_template_binary(body->bin_mode, binary_path, encoded_binary, + sizeof(encoded_binary)) != CLI_OK) { + return CLI_ERR; + } + size_t used = 0U; + out[0] = '\0'; + const char *cursor = body->text; + while (*cursor) { + const char *brace = strstr(cursor, "{{"); + if (!brace) { + return cbm_template_append(out, out_size, &used, cursor, strlen(cursor)); + } + if (cbm_template_append(out, out_size, &used, cursor, (size_t)(brace - cursor)) != CLI_OK) { + return CLI_ERR; + } + if (body->bin_mode && strncmp(brace, bin_token, SLEN(bin_token)) == 0) { + if (cbm_template_append(out, out_size, &used, encoded_binary, strlen(encoded_binary)) != + CLI_OK) { + return CLI_ERR; + } + cursor = brace + SLEN(bin_token); + } else if (event && strncmp(brace, event_token, SLEN(event_token)) == 0) { + if (cbm_template_append(out, out_size, &used, event, strlen(event)) != CLI_OK) { + return CLI_ERR; + } + cursor = brace + SLEN(event_token); + } else { + return CLI_ERR; + } + } + return CLI_OK; +} + +/* Render a template's current body for this platform. */ +static int cbm_render_integration_script(const char *template_id, const char *binary_path, + const char *event, char *script, size_t script_size) { + const cbm_integration_template_t *tpl = cbm_integration_template(template_id); + if (!tpl) { + return CLI_ERR; + } + return cbm_render_integration_body(&tpl->current, binary_path, event, script, script_size); +} + +/* Upper bound on historical bodies per template; the data file carries at + * most one released body per Claude hook today. */ +#define CMM_MAX_RELEASED_SCRIPTS 4U + +/* Render every historical body of a template into caller storage. A body + * whose era-quoting refuses this binary path is skipped — exactly how the + * old embedded builders degraded — while remaining bodies still count. */ +static size_t cbm_render_released_scripts(const cbm_integration_template_t *tpl, + const char *binary_path, char storage[][CLI_BUF_8K], + const char **out, size_t max_count) { + size_t count = 0U; + for (size_t i = 0U; tpl && i < tpl->released_count && count < max_count; i++) { + if (cbm_render_integration_body(&tpl->released[i], binary_path, NULL, storage[count], + CLI_BUF_8K) == CLI_OK) { + out[count] = storage[count]; + count++; + } + } + return count; +} + #ifdef _WIN32 #define AUGMENT_SESSION_SCRIPT "codebase-memory-session.ps1" #define AUGMENT_COVERAGE_SCRIPT "codebase-memory-coverage.ps1" @@ -4715,31 +4866,7 @@ static int cbm_build_augment_session_script(const char *binary_path, char *scrip if (!binary_path || !script || script_size == 0U) { return CLI_ERR; } - char quoted[CLI_BUF_8K]; -#ifdef _WIN32 - if (cbm_powershell_quote_word(binary_path, quoted, sizeof(quoted)) != CLI_OK) { - return CLI_ERR; - } - int written = snprintf(script, script_size, - "# SessionStart adapter installed by codebase-memory-mcp.\n" - "$bin = %s\n" - "if (-not (Test-Path -LiteralPath $bin -PathType Leaf)) { exit 0 }\n" - "& $bin hook-augment --event SessionStart 2>$null\n" - "exit 0\n", - quoted); -#else - if (cbm_shell_quote_word(binary_path, quoted, sizeof(quoted)) != CLI_OK) { - return CLI_ERR; - } - int written = snprintf(script, script_size, - "#!/bin/sh\n" - "# SessionStart adapter installed by codebase-memory-mcp.\n" - "BIN=%s\n" - "[ -x \"$BIN\" ] || exit 0\n" - "exec \"$BIN\" hook-augment --event SessionStart 2>/dev/null\n", - quoted); -#endif - return written > 0 && (size_t)written < script_size ? CLI_OK : CLI_ERR; + return cbm_render_integration_script("augment_session", binary_path, NULL, script, script_size); } static bool cbm_install_augment_session_script(const char *binary_path, const char *script_path) { @@ -4754,31 +4881,8 @@ static int cbm_build_augment_coverage_script(const char *binary_path, char *scri if (!binary_path || !script || script_size == 0U) { return CLI_ERR; } - char quoted[CLI_BUF_8K]; -#ifdef _WIN32 - if (cbm_powershell_quote_word(binary_path, quoted, sizeof(quoted)) != CLI_OK) { - return CLI_ERR; - } - int written = snprintf(script, script_size, - "# PostToolUse view adapter installed by codebase-memory-mcp.\n" - "$bin = %s\n" - "if (-not (Test-Path -LiteralPath $bin -PathType Leaf)) { exit 0 }\n" - "& $bin hook-augment --dialect augment 2>$null\n" - "exit 0\n", - quoted); -#else - if (cbm_shell_quote_word(binary_path, quoted, sizeof(quoted)) != CLI_OK) { - return CLI_ERR; - } - int written = snprintf(script, script_size, - "#!/bin/sh\n" - "# PostToolUse view adapter installed by codebase-memory-mcp.\n" - "BIN=%s\n" - "[ -x \"$BIN\" ] || exit 0\n" - "exec \"$BIN\" hook-augment --dialect augment 2>/dev/null\n", - quoted); -#endif - return written > 0 && (size_t)written < script_size ? CLI_OK : CLI_ERR; + return cbm_render_integration_script("augment_coverage", binary_path, NULL, script, + script_size); } static bool cbm_install_augment_coverage_script(const char *binary_path, const char *script_path) { @@ -4803,173 +4907,19 @@ static int cbm_cline_hook_path(const char *cline_root, const char *event, char * static int cbm_build_cline_context_script(const char *binary_path, const char *event, char *script, size_t script_size) { - char quoted[CLI_BUF_8K]; -#ifdef _WIN32 - if (cbm_powershell_quote_word(binary_path, quoted, sizeof(quoted)) != CLI_OK) { - return CLI_ERR; - } - int written = snprintf(script, script_size, - "# Cline %s context adapter installed by codebase-memory-mcp.\n" - "$bin = %s\n" - "if (-not (Test-Path -LiteralPath $bin -PathType Leaf)) { exit 0 }\n" - "& $bin hook-augment --dialect cline --event %s 2>$null\n" - "exit 0\n", - event, quoted, event); -#else - if (cbm_shell_quote_word(binary_path, quoted, sizeof(quoted)) != CLI_OK) { + if (!binary_path || !event || !script || script_size == 0U) { return CLI_ERR; } - int written = snprintf(script, script_size, - "#!/bin/sh\n" - "# Cline %s context adapter installed by codebase-memory-mcp.\n" - "BIN=%s\n" - "[ -x \"$BIN\" ] || exit 0\n" - "exec \"$BIN\" hook-augment --dialect cline --event %s 2>/dev/null\n", - event, quoted, event); -#endif - return written > 0 && (size_t)written < script_size ? CLI_OK : CLI_ERR; -} - -static const char cmm_gate_script_prefix[] = - "#!/usr/bin/env bash\n" - "# codebase-memory-mcp search augmenter (Claude Code PreToolUse).\n" - "# NOTE: the legacy filename is kept for zero-migration upgrades.\n" - "# Despite the name this NEVER blocks a tool call - it only adds\n" - "# graph context. Any failure is silent (exit 0, no output).\n" - "BIN="; - -static const char cmm_session_script_prefix[] = - "#!/usr/bin/env bash\n" - "# SessionStart context adapter installed by codebase-memory-mcp.\n" - "# Fail-open: it never blocks or logs hook/prompt content.\n" - "BIN="; - -static const char cmm_subagent_script_prefix[] = - "#!/usr/bin/env bash\n" - "# SubagentStart context adapter installed by codebase-memory-mcp.\n" - "# Fail-open: it never blocks or logs hook/prompt content.\n" - "BIN="; - -#ifndef _WIN32 -static const char cmm_hook_script_suffix[] = "\n" - "[ -x \"$BIN\" ] || exit 0\n" - "\"$BIN\" hook-augment 2>/dev/null\n" - "exit 0\n"; -#endif - -#ifdef _WIN32 -static int cbm_escape_batch_value(const char *value, char *escaped, size_t escaped_size) { - if (!value || !escaped || escaped_size == 0U) { - return CLI_ERR; - } - size_t out = 0U; - for (const unsigned char *cursor = (const unsigned char *)value; *cursor; cursor++) { - if (*cursor < 0x20U || *cursor == 0x7fU || *cursor == '"') { - return CLI_ERR; - } - size_t needed = (*cursor == '%' || *cursor == '^') ? 2U : 1U; - if (out + needed >= escaped_size) { - return CLI_ERR; - } - if (needed == 2U) { - escaped[out++] = (char)*cursor; - } - escaped[out++] = (char)*cursor; - } - escaped[out] = '\0'; - return CLI_OK; + return cbm_render_integration_script("cline_context", binary_path, event, script, script_size); } -#endif -static int cbm_build_current_hook_script(const char *prefix, const char *binary_path, char *script, - size_t script_size) { -#ifdef _WIN32 - char escaped_binary[CLI_BUF_8K]; - if (!prefix || !binary_path || !script || - cbm_escape_batch_value(binary_path, escaped_binary, sizeof(escaped_binary)) != CLI_OK) { - return CLI_ERR; - } - const char *description = NULL; - if (strcmp(prefix, cmm_gate_script_prefix) == 0) { - description = "PreToolUse search and read coverage adapter"; - } else if (strcmp(prefix, cmm_session_script_prefix) == 0) { - description = "SessionStart context adapter"; - } else if (strcmp(prefix, cmm_subagent_script_prefix) == 0) { - description = "SubagentStart context adapter"; - } else { - return CLI_ERR; - } - int written = snprintf(script, script_size, - "@echo off\r\n" - "setlocal DisableDelayedExpansion\r\n" - "REM %s installed by codebase-memory-mcp.\r\n" - "REM Fail-open: it never blocks or logs hook or prompt content.\r\n" - "set \"BIN=%s\"\r\n" - "if not exist \"%%BIN%%\" exit /b 0\r\n" - "\"%%BIN%%\" hook-augment 2>NUL\r\n" - "exit /b 0\r\n", - description, escaped_binary); -#else - char quoted_binary[CLI_BUF_8K]; - if (!prefix || !binary_path || !script || - cbm_shell_quote_word(binary_path, quoted_binary, sizeof(quoted_binary)) != CLI_OK) { - return CLI_ERR; - } - int written = - snprintf(script, script_size, "%s%s%s", prefix, quoted_binary, cmm_hook_script_suffix); -#endif - return written > 0 && (size_t)written < script_size ? CLI_OK : CLI_ERR; -} - -static const char cmm_released_session_script[] = - "#!/usr/bin/env bash\n" - "# SessionStart hook: remind agent to use codebase-memory-mcp tools.\n" - "# Installed by codebase-memory-mcp. Fires on startup/resume/clear/compact.\n" - "cat << 'REMINDER'\n" - "CRITICAL - Code Discovery Protocol:\n" - "1. ALWAYS use codebase-memory-mcp tools FIRST for ANY code exploration:\n" - " - search_graph(name_pattern/label/qn_pattern) to find functions/classes/routes\n" - " - trace_path(function_name, mode=calls|data_flow|cross_service) for call chains\n" - " - get_code_snippet(qualified_name) for exact symbol source (precise ranges)\n" - " - query_graph(query) for complex Cypher patterns\n" - " - get_architecture(aspects) for project structure\n" - " - search_code(pattern) for text search (graph-augmented grep)\n" - "2. Use Grep/Glob/Read freely for text, configs, non-code files, and\n" - " always Read a file before editing it.\n" - "3. If a project is not indexed yet, run index_repository FIRST.\n" - "REMINDER\n"; - -static const char cmm_released_subagent_script[] = - "#!/usr/bin/env bash\n" - "# SubagentStart hook: tell subagents to use codebase-memory-mcp tools.\n" - "# Installed by codebase-memory-mcp. Fires when any subagent is spawned.\n" - "# SubagentStart injects context via JSON additionalContext, not plain stdout.\n" - "cat << 'REMINDER'\n" - "{\"hookSpecificOutput\":{\"hookEventName\":\"SubagentStart\"," - "\"additionalContext\":\"Code discovery: prefer codebase-memory-mcp tools " - "(search_graph, trace_path, get_code_snippet, query_graph, get_architecture, " - "search_code) over grep/file-read for navigating code. Use Grep/Glob/Read for " - "text, configs, and non-code files.\"}}\n" - "REMINDER\n"; - -static int cbm_build_released_gate_script(const char *binary_path, char *script, - size_t script_size) { - if (!binary_path || !script || strchr(binary_path, '"')) { - return CLI_ERR; - } - int written = snprintf(script, script_size, - "#!/usr/bin/env bash\n" - "# codebase-memory-mcp search augmenter (Claude Code PreToolUse).\n" - "# NOTE: the legacy filename is kept for zero-migration upgrades.\n" - "# Despite the name this NEVER blocks a tool call - it only adds\n" - "# graph context. Any failure is silent (exit 0, no output).\n" - "BIN=\"%s\"\n" - "[ -x \"$BIN\" ] || exit 0\n" - "\"$BIN\" hook-augment 2>/dev/null\n" - "exit 0\n", - binary_path); - return written > 0 && (size_t)written < script_size ? CLI_OK : CLI_ERR; -} +/* Claude Code hook templates in cbm-integrations.json. Their "released" + * bodies are the exact historical scripts older versions wrote (including + * the pre-quoting gate form), so upgrade and uninstall keep recognising + * previously written files byte-for-byte. */ +#define CMM_HOOK_GATE_TEMPLATE "claude_gate" +#define CMM_SESSION_REMINDER_TEMPLATE "claude_session" +#define CMM_SUBAGENT_REMINDER_TEMPLATE "claude_subagent" static int cbm_remove_owned_hook_script(const char *path, const char *expected_current, const char *const *released_scripts, @@ -5032,17 +4982,16 @@ bool cbm_install_hook_gate_script(const char *home, const char *binary_path) { return false; } + const cbm_integration_template_t *tpl = cbm_integration_template(CMM_HOOK_GATE_TEMPLATE); char script[CLI_BUF_8K]; - if (cbm_build_current_hook_script(cmm_gate_script_prefix, binary_path, script, - sizeof(script)) != CLI_OK) { + if (!tpl || cbm_render_integration_body(&tpl->current, binary_path, NULL, script, + sizeof(script)) != CLI_OK) { return false; } - char released_script[CLI_BUF_8K]; - const char *const legacy[] = {released_script}; - size_t legacy_count = cbm_build_released_gate_script(binary_path, released_script, - sizeof(released_script)) == CLI_OK - ? 1U - : 0U; + char released_storage[CMM_MAX_RELEASED_SCRIPTS][CLI_BUF_8K]; + const char *legacy[CMM_MAX_RELEASED_SCRIPTS]; + size_t legacy_count = cbm_render_released_scripts(tpl, binary_path, released_storage, legacy, + CMM_MAX_RELEASED_SCRIPTS); #ifdef _WIN32 if (cbm_remove_owned_legacy_hook_script(hooks_dir, CMM_HOOK_GATE_SCRIPT_LEGACY, script, legacy, legacy_count) != CLI_OK) { @@ -5085,19 +5034,23 @@ static bool cbm_install_session_reminder_script(const char *home, const char *bi return false; } + const cbm_integration_template_t *tpl = cbm_integration_template(CMM_SESSION_REMINDER_TEMPLATE); char script[CLI_BUF_8K]; - if (cbm_build_current_hook_script(cmm_session_script_prefix, binary_path, script, - sizeof(script)) != CLI_OK) { + if (!tpl || cbm_render_integration_body(&tpl->current, binary_path, NULL, script, + sizeof(script)) != CLI_OK) { return false; } - const char *const legacy[] = {cmm_released_session_script}; + char released_storage[CMM_MAX_RELEASED_SCRIPTS][CLI_BUF_8K]; + const char *legacy[CMM_MAX_RELEASED_SCRIPTS]; + size_t legacy_count = cbm_render_released_scripts(tpl, binary_path, released_storage, legacy, + CMM_MAX_RELEASED_SCRIPTS); #ifdef _WIN32 if (cbm_remove_owned_legacy_hook_script(hooks_dir, CMM_SESSION_REMINDER_SCRIPT_LEGACY, script, - legacy, 1U) != CLI_OK) { + legacy, legacy_count) != CLI_OK) { return false; } #endif - return cbm_write_owned_hook_script_with_legacy(script_path, script, legacy, 1U); + return cbm_write_owned_hook_script_with_legacy(script_path, script, legacy, legacy_count); } static int cbm_upsert_session_hooks(const char *settings_path) { @@ -5279,19 +5232,24 @@ static bool cbm_install_subagent_reminder_script(const char *home, const char *b return false; } + const cbm_integration_template_t *tpl = + cbm_integration_template(CMM_SUBAGENT_REMINDER_TEMPLATE); char script[CLI_BUF_8K]; - if (cbm_build_current_hook_script(cmm_subagent_script_prefix, binary_path, script, - sizeof(script)) != CLI_OK) { + if (!tpl || cbm_render_integration_body(&tpl->current, binary_path, NULL, script, + sizeof(script)) != CLI_OK) { return false; } - const char *const legacy[] = {cmm_released_subagent_script}; + char released_storage[CMM_MAX_RELEASED_SCRIPTS][CLI_BUF_8K]; + const char *legacy[CMM_MAX_RELEASED_SCRIPTS]; + size_t legacy_count = cbm_render_released_scripts(tpl, binary_path, released_storage, legacy, + CMM_MAX_RELEASED_SCRIPTS); #ifdef _WIN32 if (cbm_remove_owned_legacy_hook_script(hooks_dir, CMM_SUBAGENT_REMINDER_SCRIPT_LEGACY, script, - legacy, 1U) != CLI_OK) { + legacy, legacy_count) != CLI_OK) { return false; } #endif - return cbm_write_owned_hook_script_with_legacy(script_path, script, legacy, 1U); + return cbm_write_owned_hook_script_with_legacy(script_path, script, legacy, legacy_count); } /* #1387 dry-run predicate: would writing this hook script succeed, or would @@ -5315,29 +5273,24 @@ static bool cbm_hook_script_write_would_succeed(const char *home, const char *bi if (written <= 0 || (size_t)written >= sizeof(script_path)) { return false; } - const char *prefix = cmm_gate_script_prefix; + const char *template_id = CMM_HOOK_GATE_TEMPLATE; if (strcmp(script_name, CMM_SESSION_REMINDER_SCRIPT) == 0) { - prefix = cmm_session_script_prefix; + template_id = CMM_SESSION_REMINDER_TEMPLATE; } else if (strcmp(script_name, CMM_SUBAGENT_REMINDER_SCRIPT) == 0) { - prefix = cmm_subagent_script_prefix; + template_id = CMM_SUBAGENT_REMINDER_TEMPLATE; } + const cbm_integration_template_t *tpl = cbm_integration_template(template_id); char script[CLI_BUF_8K]; - if (cbm_build_current_hook_script(prefix, binary_path, script, sizeof(script)) != CLI_OK) { + if (!tpl || cbm_render_integration_body(&tpl->current, binary_path, NULL, script, + sizeof(script)) != CLI_OK) { return false; } /* Released shapes are accepted by the real write, so they must be accepted * here too or the preview would warn about a script that upgrades fine. */ - char released[CLI_BUF_8K]; - const char *candidates[2]; - size_t candidate_count = 0U; - if (strcmp(script_name, CMM_HOOK_GATE_SCRIPT) == 0 && - cbm_build_released_gate_script(binary_path, released, sizeof(released)) == CLI_OK) { - candidates[candidate_count++] = released; - } else if (strcmp(script_name, CMM_SESSION_REMINDER_SCRIPT) == 0) { - candidates[candidate_count++] = cmm_released_session_script; - } else if (strcmp(script_name, CMM_SUBAGENT_REMINDER_SCRIPT) == 0) { - candidates[candidate_count++] = cmm_released_subagent_script; - } + char released_storage[CMM_MAX_RELEASED_SCRIPTS][CLI_BUF_8K]; + const char *candidates[CMM_MAX_RELEASED_SCRIPTS]; + size_t candidate_count = cbm_render_released_scripts(tpl, binary_path, released_storage, + candidates, CMM_MAX_RELEASED_SCRIPTS); return cbm_text_owned_document_status(script_path, script, candidates, candidate_count) == 0; } @@ -9438,6 +9391,20 @@ int cbm_cmd_install(int argc, char **argv) { return 0; } + /* Integration-assets gate: verify cbm-integrations.json against the + * embedded SHA-256 BEFORE the first mutation and persist the verified + * copy to /.cbm/assets// (the ownership reference for + * every later upgrade/uninstall decision). Missing or modified assets + * fail the whole install with one actionable message — never a partial + * install, and --dry-run must not promise what a real run would refuse. */ + { + char assets_error[CLI_BUF_1K]; + if (!cbm_integration_assets_install(home, dry_run, assets_error, sizeof(assets_error))) { + (void)fprintf(stderr, "error: %s\n", assets_error); + return CLI_TRUE; + } + } + printf("codebase-memory-mcp install %s\n\n", CBM_VERSION); char self_path[CLI_BUF_1K] = {0}; @@ -9722,83 +9689,61 @@ static void uninstall_claude_code(const char *home, bool dry_run) { record_agent_config_error(true, "Claude Code", "subagent_hook_uninstall", settings_path); } - char current_gate[CLI_BUF_8K]; - char current_session[CLI_BUF_8K]; - char current_subagent[CLI_BUF_8K]; - char released_gate[CLI_BUF_8K]; - const char *const gate_legacy[] = {released_gate}; - const char *const session_legacy[] = {cmm_released_session_script}; - const char *const subagent_legacy[] = {cmm_released_subagent_script}; - size_t gate_legacy_count = cbm_build_released_gate_script(installed_binary, released_gate, - sizeof(released_gate)) == CLI_OK - ? 1U - : 0U; + /* Ownership reference for the on-disk scripts: materialize current + + * released bodies from the verified asset store and compare exactly. + * When the assets are unavailable the render fails and every script + * is preserved with a recorded error — never guessed at. */ static const struct { const char *name; const char *legacy_name; - const char *prefix; + const char *template_id; } hook_types[] = { - {CMM_HOOK_GATE_SCRIPT, CMM_HOOK_GATE_SCRIPT_LEGACY, cmm_gate_script_prefix}, + {CMM_HOOK_GATE_SCRIPT, CMM_HOOK_GATE_SCRIPT_LEGACY, CMM_HOOK_GATE_TEMPLATE}, {CMM_SESSION_REMINDER_SCRIPT, CMM_SESSION_REMINDER_SCRIPT_LEGACY, - cmm_session_script_prefix}, + CMM_SESSION_REMINDER_TEMPLATE}, {CMM_SUBAGENT_REMINDER_SCRIPT, CMM_SUBAGENT_REMINDER_SCRIPT_LEGACY, - cmm_subagent_script_prefix}, - }; - struct { - const char *name; - const char *legacy_name; - const char *current; - const char *const *legacy; - size_t legacy_count; - bool current_valid; - } owned_scripts[] = { - {hook_types[0].name, hook_types[0].legacy_name, current_gate, gate_legacy, - gate_legacy_count, - cbm_build_current_hook_script(hook_types[0].prefix, installed_binary, current_gate, - sizeof(current_gate)) == CLI_OK}, - {hook_types[1].name, hook_types[1].legacy_name, current_session, session_legacy, 1U, - cbm_build_current_hook_script(hook_types[1].prefix, installed_binary, current_session, - sizeof(current_session)) == CLI_OK}, - {hook_types[2].name, hook_types[2].legacy_name, current_subagent, subagent_legacy, 1U, - cbm_build_current_hook_script(hook_types[2].prefix, installed_binary, current_subagent, - sizeof(current_subagent)) == CLI_OK}, + CMM_SUBAGENT_REMINDER_TEMPLATE}, }; char hooks_dir[CLI_BUF_1K]; int hooks_written = snprintf(hooks_dir, sizeof(hooks_dir), "%s/hooks", config_dir); bool hooks_dir_valid = hooks_written > 0 && (size_t)hooks_written < sizeof(hooks_dir); - for (size_t i = 0; i < sizeof(owned_scripts) / sizeof(owned_scripts[0]); i++) { + for (size_t i = 0; i < sizeof(hook_types) / sizeof(hook_types[0]); i++) { char script_path[CLI_BUF_1K]; - int script_written = hooks_dir_valid - ? snprintf(script_path, sizeof(script_path), "%s/%s", - hooks_dir, owned_scripts[i].name) - : CLI_ERR; + int script_written = hooks_dir_valid ? snprintf(script_path, sizeof(script_path), + "%s/%s", hooks_dir, hook_types[i].name) + : CLI_ERR; bool script_path_valid = script_written > 0 && (size_t)script_written < sizeof(script_path); - if (!owned_scripts[i].current_valid) { + const cbm_integration_template_t *tpl = + cbm_integration_template(hook_types[i].template_id); + char current[CLI_BUF_8K]; + if (!tpl || cbm_render_integration_body(&tpl->current, installed_binary, NULL, current, + sizeof(current)) != CLI_OK) { record_agent_config_error(true, "Claude Code", "hook_script_uninstall", - owned_scripts[i].name); + hook_types[i].name); continue; } + char released_storage[CMM_MAX_RELEASED_SCRIPTS][CLI_BUF_8K]; + const char *legacy[CMM_MAX_RELEASED_SCRIPTS]; + size_t legacy_count = cbm_render_released_scripts( + tpl, installed_binary, released_storage, legacy, CMM_MAX_RELEASED_SCRIPTS); if (!script_path_valid || - cbm_remove_owned_hook_script(script_path, owned_scripts[i].current, - owned_scripts[i].legacy, - owned_scripts[i].legacy_count) < CLI_OK) { + cbm_remove_owned_hook_script(script_path, current, legacy, legacy_count) < CLI_OK) { record_agent_config_error(true, "Claude Code", "hook_script_uninstall", - script_path_valid ? script_path : owned_scripts[i].name); + script_path_valid ? script_path : hook_types[i].name); } #ifdef _WIN32 if (!hooks_dir_valid || - cbm_remove_owned_legacy_hook_script( - hooks_dir, owned_scripts[i].legacy_name, owned_scripts[i].current, - owned_scripts[i].legacy, owned_scripts[i].legacy_count) != CLI_OK) { + cbm_remove_owned_legacy_hook_script(hooks_dir, hook_types[i].legacy_name, current, + legacy, legacy_count) != CLI_OK) { char legacy_path[CLI_BUF_1K]; int written = hooks_dir_valid ? snprintf(legacy_path, sizeof(legacy_path), "%s/%s", - hooks_dir, owned_scripts[i].legacy_name) + hooks_dir, hook_types[i].legacy_name) : CLI_ERR; record_agent_config_error(true, "Claude Code", "legacy_hook_script_uninstall", written > 0 && (size_t)written < sizeof(legacy_path) ? legacy_path - : owned_scripts[i].legacy_name); + : hook_types[i].legacy_name); } #endif } @@ -10919,6 +10864,18 @@ int cbm_cmd_uninstall(int argc, char **argv) { printf("codebase-memory-mcp uninstall\n\n"); + /* Load the ownership reference (the stored asset copy, or any other + * hash-verified candidate) up front. When none is available every + * template-dependent file removal below preserves its file and records + * this one actionable error, while settings/registration cleanup — which + * needs no templates — still proceeds. */ + { + char assets_error[CLI_BUF_1K]; + if (!cbm_integration_assets_require(home, assets_error, sizeof(assets_error))) { + (void)fprintf(stderr, "warning: %s\n", assets_error); + } + } + g_agent_uninstall_errors = 0; cbm_detected_agents_t agents = cbm_detect_agents(home); diff --git a/src/cli/client_adapter.c b/src/cli/client_adapter.c index c6828ea12e..64ad7e6d07 100644 --- a/src/cli/client_adapter.c +++ b/src/cli/client_adapter.c @@ -1,10 +1,12 @@ /* * client_adapter.c — Generate client extension modules from the MCP tool registry. * - * See client_adapter.h for why these are generated rather than shipped. + * See client_adapter.h for how generation is split between the verified + * template (module scaffolding) and the live registry (tool list). */ #include "cli/client_adapter.h" +#include "cli/integration_assets.h" #include "foundation/constants.h" #include "mcp/mcp.h" @@ -22,11 +24,10 @@ typedef struct { bool failed; } adapter_sb_t; -static void sb_append(adapter_sb_t *sb, const char *s) { +static void sb_append_len(adapter_sb_t *sb, const char *s, size_t add) { if (sb->failed || !s) { return; } - size_t add = strlen(s); if (sb->len + add + 1 > sb->cap) { size_t want = sb->cap ? sb->cap : CBM_SZ_2K; while (want < sb->len + add + 1) { @@ -49,6 +50,47 @@ static void sb_append(adapter_sb_t *sb, const char *s) { sb->buf[sb->len] = '\0'; } +static void sb_append(adapter_sb_t *sb, const char *s) { + if (!sb->failed && s) { + sb_append_len(sb, s, strlen(s)); + } +} + +/* Append template text, substituting each named placeholder with its value. + * An unresolved "{{" fails the build: a placeholder must never survive into + * a module a client will execute. */ +static void sb_append_template(adapter_sb_t *sb, const char *text, const char *const names[], + const char *const values[], size_t pair_count) { + if (sb->failed || !text) { + sb->failed = true; + return; + } + const char *cursor = text; + while (!sb->failed && *cursor) { + const char *brace = strstr(cursor, "{{"); + if (!brace) { + sb_append(sb, cursor); + return; + } + sb_append_len(sb, cursor, (size_t)(brace - cursor)); + size_t matched = 0U; + for (size_t i = 0U; i < pair_count; i++) { + size_t name_length = strlen(names[i]); + if (strncmp(brace + PAIR_LEN, names[i], name_length) == 0 && + strncmp(brace + PAIR_LEN + name_length, "}}", PAIR_LEN) == 0) { + sb_append(sb, values[i]); + matched = PAIR_LEN + name_length + PAIR_LEN; + break; + } + } + if (matched == 0U) { + sb->failed = true; + return; + } + cursor = brace + matched; + } +} + bool cbm_client_adapter_escape_js(const char *in, char *out, size_t out_sz) { if (!in || !out || out_sz == 0) { return false; @@ -91,20 +133,13 @@ bool cbm_client_adapter_escape_js(const char *in, char *out, size_t out_sz) { return true; } -/* Shared preamble: the ownership markers and a note telling whoever opens the - * file that edits inside the block are overwritten. */ -static void emit_header(adapter_sb_t *sb, const char *client) { - /* No markers here: cbm_text_upsert_managed_block adds them and REJECTS - * content that already carries them, so the body must stay marker-free. */ - sb_append(sb, "// Generated by codebase-memory-mcp for "); - sb_append(sb, client); - sb_append(sb, - ".\n" - "// Regenerated on install/update from the MCP tool registry, so it cannot\n" - "// drift from the tools the server actually serves. Edits inside this block\n" - "// are overwritten; edit outside it, or remove the markers to take ownership.\n"); -} - +/* The module scaffolding (spawn bridge, header note, plugin surface) comes + * from the hash-verified cbm-integrations.json — those child-process-spawning + * bytes must not live inside the binary (AV surface) — while the tool list is + * still generated from the live registry, so the adapter cannot drift from + * the tools the server actually serves. No markers in the template: the + * managed-block writer adds them and REJECTS content that already carries + * them, so the body must stay marker-free. */ char *cbm_client_adapter_pi(const char *binary_path) { if (!binary_path || !binary_path[0]) { return NULL; @@ -117,59 +152,34 @@ char *cbm_client_adapter_pi(const char *binary_path) { if (count <= 0) { return NULL; } + const cbm_integration_template_t *tpl = cbm_integration_template("adapter_pi"); + if (!tpl || !tpl->tool_line || !tpl->current.bin_mode || + strcmp(tpl->current.bin_mode, "js") != 0) { + return NULL; + } - adapter_sb_t sb = {0}; - emit_header(&sb, "pi"); - sb_append(&sb, "import { spawn } from 'node:child_process';\n\n"); - sb_append(&sb, "const BIN = '"); - sb_append(&sb, bin); - sb_append(&sb, "';\n\n"); - - /* One shared invoker: the binary prints JSON on stdout, so take the last - * line that parses rather than trusting the first — a stray log line on - * stdout must not break the bridge. */ - sb_append( - &sb, "async function call(tool, args, signal) {\n" - " return new Promise((resolve) => {\n" - " const child = spawn(BIN, ['cli', tool, JSON.stringify(args ?? {})], {\n" - " stdio: ['ignore', 'pipe', 'pipe'],\n" - " env: { ...process.env, CBM_LOG_LEVEL: 'error' },\n" - " });\n" - " let out = '';\n" - " const onAbort = () => { if (!child.killed) child.kill(); };\n" - " signal?.addEventListener('abort', onAbort, { once: true });\n" - " child.stdout.on('data', (d) => (out += d.toString()));\n" - " child.on('error', (e) => {\n" - " signal?.removeEventListener('abort', onAbort);\n" - " resolve({ error: String(e && e.message ? e.message : e) });\n" - " });\n" - " child.on('close', () => {\n" - " signal?.removeEventListener('abort', onAbort);\n" - " const lines = out.split('\\n').map((l) => l.trim()).filter(Boolean);\n" - " for (let i = lines.length - 1; i >= 0; i--) {\n" - " try { return resolve(JSON.parse(lines[i])); } catch { /* keep scanning */ }\n" - " }\n" - " resolve({ error: 'no JSON response from codebase-memory-mcp' });\n" - " });\n" - " });\n" - "}\n\n"); - - /* Register every tool the registry advertises — never a hand-picked subset, - * which is the drift this generator exists to prevent. */ - sb_append(&sb, "export function register(pi) {\n"); + /* Register every tool the registry advertises — never a hand-picked + * subset, which is the drift this generator exists to prevent. */ + adapter_sb_t tools = {0}; for (int i = 0; i < count; i++) { const char *name = cbm_mcp_tool_name(i); if (!name || !name[0]) { continue; } - sb_append(&sb, " pi.registerTool({ name: '"); - sb_append(&sb, name); - sb_append(&sb, "', run: (args, ctx) => call('"); - sb_append(&sb, name); - sb_append(&sb, "', args, ctx?.signal) });\n"); + const char *const tool_names[] = {"TOOL"}; + const char *const tool_values[] = {name}; + sb_append_template(&tools, tpl->tool_line, tool_names, tool_values, 1U); + } + if (tools.failed) { + free(tools.buf); + return NULL; } - sb_append(&sb, "}\n"); + adapter_sb_t sb = {0}; + const char *const names[] = {"BIN", "TOOLS"}; + const char *const values[] = {bin, tools.buf ? tools.buf : ""}; + sb_append_template(&sb, tpl->current.text, names, values, PAIR_LEN); + free(tools.buf); if (sb.failed) { free(sb.buf); return NULL; @@ -185,50 +195,14 @@ char *cbm_client_adapter_opencode(const char *binary_path) { if (!cbm_client_adapter_escape_js(binary_path, bin, sizeof(bin))) { return NULL; } - + const cbm_integration_template_t *tpl = cbm_integration_template("adapter_opencode"); + if (!tpl || !tpl->current.bin_mode || strcmp(tpl->current.bin_mode, "js") != 0) { + return NULL; + } adapter_sb_t sb = {0}; - emit_header(&sb, "OpenCode"); - sb_append(&sb, "// OpenCode already reaches every tool over MCP; this adds only the\n" - "// automatic graph lookup before a grep/glob, which other clients get\n" - "// through their own hook configuration.\n"); - sb_append(&sb, "import { spawn } from 'node:child_process';\n\n"); - sb_append(&sb, "const BIN = '"); - sb_append(&sb, bin); - sb_append(&sb, "';\n\n"); - - /* hook-augment requires hook_event_name and accepts Grep/Glob only under - * PreToolUse; omitting it makes the whole hook a silent no-op. */ - sb_append(&sb, "function augment(tool, args) {\n" - " return new Promise((resolve) => {\n" - " const child = spawn(BIN, ['hook-augment'], {\n" - " stdio: ['pipe', 'pipe', 'ignore'],\n" - " env: { ...process.env, CBM_LOG_LEVEL: 'error' },\n" - " });\n" - " let out = '';\n" - " child.stdout.on('data', (d) => (out += d.toString()));\n" - " child.on('error', () => resolve(''));\n" - " child.on('close', () => resolve(out));\n" - " child.stdin.end(JSON.stringify({\n" - " hook_event_name: 'PreToolUse',\n" - " tool_name: tool,\n" - " tool_input: args ?? {},\n" - " }));\n" - " });\n" - "}\n\n"); - - sb_append(&sb, - "export const CodebaseMemory = async () => ({\n" - " 'tool.execute.after': async (input, output) => {\n" - " const tool = input?.tool === 'grep' ? 'Grep' : input?.tool === 'glob' ? 'Glob' " - ": null;\n" - " if (!tool) return;\n" - " const extra = await augment(tool, output?.args);\n" - " if (extra && typeof output?.output === 'string') {\n" - " output.output += '\\n' + extra;\n" - " }\n" - " },\n" - "});\n"); - + const char *const names[] = {"BIN"}; + const char *const values[] = {bin}; + sb_append_template(&sb, tpl->current.text, names, values, 1U); if (sb.failed) { free(sb.buf); return NULL; diff --git a/src/cli/client_adapter.h b/src/cli/client_adapter.h index f0c812e62b..b885d870aa 100644 --- a/src/cli/client_adapter.h +++ b/src/cli/client_adapter.h @@ -3,13 +3,20 @@ * * Some clients have no MCP support (pi), and some have no declarative hook * configuration (OpenCode); their only extension point is a JavaScript/TypeScript - * module. We do not ship such a module as a repository asset — it would be a - * second, hand-maintained copy of the tool surface, and hand-maintained copies of - * the tool list have already produced defects here. + * module. Generation splits along what each source is authoritative for: * - * Instead the module is GENERATED at install time from the live registry - * (cbm_mcp_tool_count / cbm_mcp_tool_name), so it cannot drift: adding a tool to - * TOOLS[] adds it to every generated adapter with no second edit. + * - The module SCAFFOLDING (the node:child_process spawn bridge, header note, + * plugin surface) comes from the hash-verified cbm-integrations.json via + * integration_assets.h. It used to be compiled in as C string literals, but + * child-process-spawning script text inside an unsigned binary is exactly + * the surface AV classifiers score, so it ships as data and is verified + * against the binary's embedded SHA-256 before use. + * + * - The TOOL LIST is still generated at install time from the live registry + * (cbm_mcp_tool_count / cbm_mcp_tool_name), never hand-maintained in the + * asset file: hand-maintained copies of the tool surface have already + * produced defects here. Adding a tool to TOOLS[] adds it to every + * generated adapter with no second edit — that property is preserved. * * Generated text is returned heap-allocated; the caller frees it. Nothing here * touches the filesystem — writing is the caller's job, so dry-run and diff --git a/src/cli/integration_assets.c b/src/cli/integration_assets.c new file mode 100644 index 0000000000..b8e8e296e5 --- /dev/null +++ b/src/cli/integration_assets.c @@ -0,0 +1,415 @@ +/* + * integration_assets.c — Verified access to the shipped integration templates. + * + * See integration_assets.h for the architecture (why the bodies live in a + * data file, the integrity model, and the ownership-reference role of the + * stored copy under /.cbm/assets//). + */ +#include "cli/integration_assets.h" + +#include "foundation/compat.h" +#include "foundation/compat_fs.h" +#include "foundation/constants.h" +#include "foundation/platform.h" +#include "foundation/sha256.h" +#include "yyjson/yyjson.h" + +#include "cbm_integrations_hash.h" + +#include +#include +#include + +#ifndef _WIN32 +#include +#endif +#ifdef __APPLE__ +#include +#endif + +#ifndef CBM_VERSION +#define CBM_VERSION "dev" +#endif + +enum { + ASSETS_DIR_PERM = 0755, + /* The shipped file is ~8 KB; the cap only bounds a corrupted candidate. */ + ASSETS_MAX_BYTES = CBM_SZ_64K * CBM_SZ_16, +}; + +/* The one actionable failure message: every "cannot use the templates" path + * reports the same recovery, because the recovery is always the same. */ +static const char assets_error_message[] = + "integration assets missing or modified - reinstall from the release archive " + "(expected " CBM_INTEGRATIONS_ASSET_NAME " next to the binary, in $CBM_ASSETS_DIR, " + "or under ~/.cbm/assets/" CBM_VERSION ")"; + +typedef struct { + const char *id; + cbm_integration_template_t tpl; + cbm_integration_body_t *released_storage; +} asset_entry_t; + +/* Success-only cache: a failed resolve is re-attempted on every call so one + * bad candidate never poisons a later call that could succeed. */ +static struct { + bool ok; + char *bytes; + size_t length; + yyjson_doc *doc; + asset_entry_t *entries; + size_t entry_count; +} g_assets; + +static void assets_fill_error(char *err, size_t err_sz) { + if (err && err_sz > 0U) { + (void)snprintf(err, err_sz, "%s", assets_error_message); + } +} + +static bool assets_exe_dir(char *out, size_t out_sz) { + if (out_sz < CBM_SZ_2) { + return false; + } +#ifdef _WIN32 + DWORD length = GetModuleFileNameA(NULL, out, (DWORD)out_sz); + if (length == 0 || (size_t)length >= out_sz) { + return false; + } + cbm_normalize_path_sep(out); +#elif defined(__APPLE__) + uint32_t self_sz = (uint32_t)out_sz; + if (_NSGetExecutablePath(out, &self_sz) != 0) { + return false; + } +#else + ssize_t length = readlink("/proc/self/exe", out, out_sz - SKIP_ONE); + if (length <= 0 || (size_t)length >= out_sz - SKIP_ONE) { + return false; + } + out[length] = '\0'; +#endif + char *slash = strrchr(out, '/'); + if (!slash || slash == out) { + return false; + } + *slash = '\0'; + return true; +} + +static bool assets_stored_copy_path(const char *home, char *out, size_t out_sz) { + if (!home || !home[0]) { + return false; + } + int written = snprintf(out, out_sz, "%s/.cbm/assets/%s/%s", home, CBM_VERSION, + CBM_INTEGRATIONS_ASSET_NAME); + return written > 0 && (size_t)written < out_sz; +} + +/* Read a candidate fully. Returns false for missing/unreadable/oversized. */ +static bool assets_read_file(const char *path, char **out_bytes, size_t *out_length) { + *out_bytes = NULL; + *out_length = 0U; + FILE *file = cbm_fopen(path, "rb"); + if (!file) { + return false; + } + if (fseek(file, 0L, SEEK_END) != 0) { + (void)fclose(file); + return false; + } + long size = ftell(file); + if (size <= 0 || size > (long)ASSETS_MAX_BYTES || fseek(file, 0L, SEEK_SET) != 0) { + (void)fclose(file); + return false; + } + char *bytes = malloc((size_t)size + SKIP_ONE); + if (!bytes) { + (void)fclose(file); + return false; + } + size_t got = fread(bytes, 1U, (size_t)size, file); + (void)fclose(file); + if (got != (size_t)size) { + free(bytes); + return false; + } + bytes[got] = '\0'; + *out_bytes = bytes; + *out_length = got; + return true; +} + +static bool assets_bytes_verified(const char *bytes, size_t length) { + char hex[CBM_SHA256_HEX_LEN + SKIP_ONE]; + cbm_sha256_hex(bytes, length, hex); + return strcmp(hex, CBM_INTEGRATIONS_SHA256) == 0; +} + +static bool assets_parse_body(yyjson_val *value, cbm_integration_body_t *body) { + yyjson_val *text = yyjson_is_obj(value) ? yyjson_obj_get(value, "text") : NULL; + yyjson_val *bin = yyjson_is_obj(value) ? yyjson_obj_get(value, "bin") : NULL; + if (!text || !yyjson_is_str(text) || (bin && !yyjson_is_str(bin))) { + return false; + } + body->text = yyjson_get_str(text); + body->bin_mode = bin ? yyjson_get_str(bin) : NULL; + return true; +} + +/* Parse verified bytes into the template table. The variant picked here is + * the platform's ("windows" on _WIN32, else "posix", with "all" accepted for + * platform-independent templates such as the JS adapters). */ +static bool assets_adopt_bytes(char *bytes, size_t length) { + yyjson_doc *doc = yyjson_read(bytes, length, 0); + yyjson_val *root = doc ? yyjson_doc_get_root(doc) : NULL; + yyjson_val *format = root && yyjson_is_obj(root) ? yyjson_obj_get(root, "format") : NULL; + yyjson_val *templates = root && yyjson_is_obj(root) ? yyjson_obj_get(root, "templates") : NULL; + if (!format || !yyjson_is_int(format) || yyjson_get_int(format) != 1 || !templates || + !yyjson_is_obj(templates)) { + if (doc) { + yyjson_doc_free(doc); + } + return false; + } +#ifdef _WIN32 + static const char platform_variant[] = "windows"; +#else + static const char platform_variant[] = "posix"; +#endif + size_t template_count = yyjson_obj_size(templates); + asset_entry_t *entries = calloc(template_count ? template_count : 1U, sizeof(*entries)); + if (!entries) { + yyjson_doc_free(doc); + return false; + } + size_t entry_count = 0U; + size_t index; + size_t max; + yyjson_val *key; + yyjson_val *value; + bool parse_ok = true; + yyjson_obj_foreach(templates, index, max, key, value) { + if (!yyjson_is_str(key) || !yyjson_is_obj(value)) { + parse_ok = false; + break; + } + yyjson_val *variant = yyjson_obj_get(value, platform_variant); + if (!variant) { + variant = yyjson_obj_get(value, "all"); + } + if (!variant) { + /* Not offered for this platform: skip rather than reject, so a + * future platform-scoped template does not brick the others. */ + continue; + } + asset_entry_t *entry = &entries[entry_count]; + entry->id = yyjson_get_str(key); + if (!assets_parse_body(variant, &entry->tpl.current)) { + parse_ok = false; + break; + } + yyjson_val *tool_line = yyjson_obj_get(value, "tool_line"); + if (tool_line && !yyjson_is_str(tool_line)) { + parse_ok = false; + break; + } + entry->tpl.tool_line = tool_line ? yyjson_get_str(tool_line) : NULL; + yyjson_val *released = yyjson_obj_get(value, "released"); + if (released) { + if (!yyjson_is_arr(released)) { + parse_ok = false; + break; + } + size_t released_count = yyjson_arr_size(released); + if (released_count > 0U) { + entry->released_storage = calloc(released_count, sizeof(*entry->released_storage)); + if (!entry->released_storage) { + parse_ok = false; + break; + } + size_t released_index; + size_t released_max; + yyjson_val *released_value; + size_t stored = 0U; + yyjson_arr_foreach(released, released_index, released_max, released_value) { + if (!assets_parse_body(released_value, &entry->released_storage[stored])) { + parse_ok = false; + break; + } + stored++; + } + if (!parse_ok) { + entry_count++; + break; + } + entry->tpl.released = entry->released_storage; + entry->tpl.released_count = stored; + } + } + entry_count++; + } + if (!parse_ok) { + for (size_t i = 0U; i < entry_count; i++) { + free(entries[i].released_storage); + } + free(entries); + yyjson_doc_free(doc); + return false; + } + g_assets.bytes = bytes; + g_assets.length = length; + g_assets.doc = doc; + g_assets.entries = entries; + g_assets.entry_count = entry_count; + g_assets.ok = true; + return true; +} + +/* Try one candidate path: read + hash-verify + parse. On success the bytes + * are adopted into the cache (ownership transferred). */ +static bool assets_try_candidate(const char *path) { + char *bytes = NULL; + size_t length = 0U; + if (!assets_read_file(path, &bytes, &length)) { + return false; + } + if (!assets_bytes_verified(bytes, length) || !assets_adopt_bytes(bytes, length)) { + free(bytes); + return false; + } + return true; +} + +static bool assets_resolve(const char *home) { + if (g_assets.ok) { + return true; + } + /* CBM_ASSETS_DIR set = authoritative: an explicit override that is + * missing or modified FAILS; silently falling back would hide exactly + * the tampering the hash check exists to surface. */ + static const char env_missing[] = "\x1f" + "CBM_ASSETS_DIR_MISSING" + "\x1f"; + char env_buf[CBM_SZ_4K]; + char candidate[CBM_SZ_4K]; + const char *assets_dir = + cbm_safe_getenv("CBM_ASSETS_DIR", env_buf, sizeof(env_buf), env_missing); + if (!assets_dir) { + return false; /* present but unrepresentable: fail, do not fall back */ + } + if (strcmp(assets_dir, env_missing) != 0 && assets_dir[0]) { + int written = snprintf(candidate, sizeof(candidate), "%s/%s", assets_dir, + CBM_INTEGRATIONS_ASSET_NAME); + return written > 0 && (size_t)written < sizeof(candidate) && + assets_try_candidate(candidate); + } + char exe_dir[CBM_SZ_4K]; + if (assets_exe_dir(exe_dir, sizeof(exe_dir))) { + int written = + snprintf(candidate, sizeof(candidate), "%s/%s", exe_dir, CBM_INTEGRATIONS_ASSET_NAME); + if (written > 0 && (size_t)written < sizeof(candidate) && assets_try_candidate(candidate)) { + return true; + } + /* Source-tree layout: build/c/ with assets/ at the root. */ + written = snprintf(candidate, sizeof(candidate), "%s/../../assets/%s", exe_dir, + CBM_INTEGRATIONS_ASSET_NAME); + if (written > 0 && (size_t)written < sizeof(candidate) && assets_try_candidate(candidate)) { + return true; + } + } + return assets_stored_copy_path(home, candidate, sizeof(candidate)) && + assets_try_candidate(candidate); +} + +bool cbm_integration_assets_require(const char *home, char *err, size_t err_sz) { + if (assets_resolve(home)) { + return true; + } + assets_fill_error(err, err_sz); + return false; +} + +bool cbm_integration_assets_install(const char *home, bool dry_run, char *err, size_t err_sz) { + if (!home || !home[0] || !cbm_integration_assets_require(home, err, err_sz)) { + if (!home || !home[0]) { + assets_fill_error(err, err_sz); + } + return false; + } + if (dry_run) { + return true; + } + char stored[CBM_SZ_4K]; + char parent[CBM_SZ_4K]; + if (!assets_stored_copy_path(home, stored, sizeof(stored))) { + assets_fill_error(err, err_sz); + return false; + } + (void)snprintf(parent, sizeof(parent), "%s", stored); + char *slash = strrchr(parent, '/'); + if (!slash) { + assets_fill_error(err, err_sz); + return false; + } + *slash = '\0'; + if (!cbm_mkdir_p(parent, ASSETS_DIR_PERM)) { + if (err && err_sz > 0U) { + (void)snprintf(err, err_sz, "cannot create %s to store the verified %s copy", parent, + CBM_INTEGRATIONS_ASSET_NAME); + } + return false; + } + /* Publish by rename so a concurrent reader never sees a torn copy. */ + char temp_path[CBM_SZ_4K]; + int written = snprintf(temp_path, sizeof(temp_path), "%s.tmp", stored); + if (written <= 0 || (size_t)written >= sizeof(temp_path)) { + assets_fill_error(err, err_sz); + return false; + } + FILE *file = cbm_fopen(temp_path, "wb"); + bool wrote = file && fwrite(g_assets.bytes, 1U, g_assets.length, file) == g_assets.length; + if (file && fclose(file) != 0) { + wrote = false; + } + if (!wrote || cbm_rename_replace(temp_path, stored) != 0) { + (void)cbm_unlink(temp_path); + if (err && err_sz > 0U) { + (void)snprintf(err, err_sz, "cannot write the verified %s copy to %s", + CBM_INTEGRATIONS_ASSET_NAME, stored); + } + return false; + } + return true; +} + +const cbm_integration_template_t *cbm_integration_template(const char *id) { + if (!id || !assets_resolve(NULL)) { + return NULL; + } + for (size_t i = 0U; i < g_assets.entry_count; i++) { + if (strcmp(g_assets.entries[i].id, id) == 0) { + return &g_assets.entries[i].tpl; + } + } + return NULL; +} + +#ifdef CBM_CLI_ENABLE_TEST_API +void cbm_integration_assets_reset_for_testing(void) { + for (size_t i = 0U; i < g_assets.entry_count; i++) { + free(g_assets.entries[i].released_storage); + } + free(g_assets.entries); + g_assets.entries = NULL; + g_assets.entry_count = 0U; + if (g_assets.doc) { + yyjson_doc_free(g_assets.doc); + g_assets.doc = NULL; + } + free(g_assets.bytes); + g_assets.bytes = NULL; + g_assets.length = 0U; + g_assets.ok = false; +} +#endif diff --git a/src/cli/integration_assets.h b/src/cli/integration_assets.h new file mode 100644 index 0000000000..6fb8748c5c --- /dev/null +++ b/src/cli/integration_assets.h @@ -0,0 +1,89 @@ +/* + * integration_assets.h — Verified access to the shipped integration templates. + * + * The binary used to embed every integration artifact it installs — nine + * complete shebang'd shell scripts, their PowerShell/.cmd twins, and the + * generated JS/TS client modules — as C string literals, written to client + * config dirs at 0755. That block of script-shaped bytes inside an unsigned + * executable is exactly the surface AV classifiers score (Wacatac.B!ml), so + * the bodies now live in ONE shipped data file, assets/cbm-integrations.json, + * and the binary embeds exactly ONE constant about them: the file's SHA-256 + * (generated at build time into cbm_integrations_hash.h). + * + * Integrity model: every use verifies the file against that constant first. + * This preserves the tamper-evidence the templates had while compiled in; + * a verified copy is bit-identical to the shipped one by construction, so + * WHICH copy satisfied the check never matters — only availability does. + * + * Resolution order: $CBM_ASSETS_DIR (authoritative when set — a bad explicit + * override fails, it never falls back), else next to the running binary (the + * flat release-archive layout install.sh/install.ps1 execute from), else the + * source-tree layout /../../assets (build/c/ builds and test-runner), + * else the stored copy under /.cbm/assets//. + * + * The stored copy is the OWNERSHIP REFERENCE: `install` persists the verified + * bytes to /.cbm/assets// — a SIBLING of the disposable cache + * (~/.cache/codebase-memory-mcp), never inside it — so uninstall can decide + * "is this deployed file ours?" by materializing templates from it long after + * the release archive is gone. Deciding ownership by materialize-and-compare + * against these templates REPLACES the old exact-match against embedded + * literals; released[] carries the historical bodies older versions wrote, so + * upgrades and uninstalls keep recognising them. Foreign or user-modified + * files are preserved exactly as before. + * + * Single-threaded by design: only the CLI install/uninstall paths touch this, + * never the MCP server or hook-augment hot paths. + */ +#ifndef CBM_CLI_INTEGRATION_ASSETS_H +#define CBM_CLI_INTEGRATION_ASSETS_H + +#include +#include + +#define CBM_INTEGRATIONS_ASSET_NAME "cbm-integrations.json" + +/* One template body. `text` may contain {{BIN}} / {{EVENT}} / {{TOOLS}} / + * {{TOOL}} placeholders; `bin_mode` names how {{BIN}} must be encoded when + * substituted ("sh", "ps", "cmd", "raw", "js"), or NULL for a body that + * takes no binary path at all. */ +typedef struct { + const char *text; + const char *bin_mode; +} cbm_integration_body_t; + +/* A template resolved for THIS platform (posix/windows/all variant). The + * released list is the exact set of historical bodies previous versions + * materialized; it exists so ownership checks still recognise files written + * before this version — the ONE place old bodies may live. */ +typedef struct { + cbm_integration_body_t current; + const cbm_integration_body_t *released; + size_t released_count; + const char *tool_line; /* adapters only: per-tool registration line */ +} cbm_integration_template_t; + +/* Locate, read and hash-verify the asset file (cached after first success; + * failures are re-resolved on every call so a transient state never sticks). + * `home` may be NULL when no stored-copy candidate should be considered. + * Returns true on success; on failure fills `err` (when given) with the + * actionable message. */ +bool cbm_integration_assets_require(const char *home, char *err, size_t err_sz); + +/* Install-time step: verify (as above) and persist the verified bytes to + * /.cbm/assets//cbm-integrations.json. Verification failure or + * an unwritable store is an error — the caller must not partially install. + * dry_run verifies only and writes nothing. */ +bool cbm_integration_assets_install(const char *home, bool dry_run, char *err, size_t err_sz); + +/* Template lookup for this platform. Returns NULL when the assets are + * unavailable or the id/variant is unknown. The returned pointers stay valid + * for the process lifetime (or until the test-only reset). */ +const cbm_integration_template_t *cbm_integration_template(const char *id); + +#ifdef CBM_CLI_ENABLE_TEST_API +/* Drop the cached document so a test can steer resolution via CBM_ASSETS_DIR + * (set = authoritative) and observe verification failures deterministically. */ +void cbm_integration_assets_reset_for_testing(void); +#endif + +#endif /* CBM_CLI_INTEGRATION_ASSETS_H */ diff --git a/tests/test_cli.c b/tests/test_cli.c index f35dd9d4c7..789a6fc4e9 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -16,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -9635,6 +9636,162 @@ TEST(cli_upgrade_preserves_near_legacy_claude_hook_script) { PASS(); } +/* The script bodies now ship in cbm-integrations.json, guarded only by the + * SHA-256 the binary embeds. Verification failing CLOSED is the property + * that replaces "the bytes are inside the binary": a tampered or missing + * asset must refuse to materialize anything, with the one actionable + * message, and must never partially install. */ +TEST(cli_integration_assets_tampered_or_missing_fail_closed) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-assets-tamper-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + char assets_dir[512]; + char asset_path[640]; + char hooks_dir[512]; + char gate_path[640]; + snprintf(assets_dir, sizeof(assets_dir), "%s/assets", tmpdir); + snprintf(asset_path, sizeof(asset_path), "%s/cbm-integrations.json", assets_dir); + snprintf(hooks_dir, sizeof(hooks_dir), "%s/.claude/hooks", tmpdir); + snprintf(gate_path, sizeof(gate_path), "%s/cbm-code-discovery-gate", hooks_dir); + test_mkdirp(assets_dir); + test_mkdirp(hooks_dir); + /* Well-formed JSON with the wrong hash: exactly what an attacker editing + * the shipped file would produce. */ + write_test_file(asset_path, "{\"format\":1,\"templates\":{}}"); + + char *saved_assets = save_test_env("CBM_ASSETS_DIR"); + char *saved_claude = save_test_env("CLAUDE_CONFIG_DIR"); + cbm_setenv("CBM_ASSETS_DIR", assets_dir, 1); + cbm_unsetenv("CLAUDE_CONFIG_DIR"); + cbm_integration_assets_reset_for_testing(); + + char err[512]; + err[0] = '\0'; + bool tampered_refused = !cbm_integration_assets_require(tmpdir, err, sizeof(err)); + bool message_actionable = strstr(err, "reinstall from the release archive") != NULL; + bool install_gate_refused = !cbm_integration_assets_install(tmpdir, false, err, sizeof(err)); + bool gate_write_refused = !cbm_install_hook_gate_script(tmpdir, "/opt/codebase-memory-mcp"); + char *gate_file = read_test_file_alloc(gate_path); + bool nothing_materialized = gate_file == NULL; + free(gate_file); + + /* Missing is refused exactly like modified — CBM_ASSETS_DIR set means + * authoritative, never a silent fallback to another copy. */ + remove(asset_path); + cbm_integration_assets_reset_for_testing(); + bool missing_refused = !cbm_integration_assets_require(tmpdir, err, sizeof(err)); + + restore_test_env("CBM_ASSETS_DIR", saved_assets); + restore_test_env("CLAUDE_CONFIG_DIR", saved_claude); + cbm_integration_assets_reset_for_testing(); + test_rmdir_r(tmpdir); + if (!tampered_refused) + FAIL("a hash-mismatched asset file must fail verification"); + if (!message_actionable) + FAIL("the failure must carry the reinstall-from-archive message"); + if (!install_gate_refused) + FAIL("install must fail closed on unverifiable assets"); + if (!gate_write_refused || !nothing_materialized) + FAIL("no template may materialize from an unverified asset file"); + if (!missing_refused) + FAIL("a missing asset under an explicit CBM_ASSETS_DIR must refuse, not fall back"); + PASS(); +} + +#ifndef CBM_VERSION +#define CBM_VERSION "dev" +#endif + +/* `install` persists the verified bytes to /.cbm/assets// — + * a sibling of the disposable cache, never inside it. That stored copy is + * the OWNERSHIP REFERENCE uninstall materializes templates from after the + * release archive is gone, so it must exist, re-verify, and never be + * written by a dry run. */ +TEST(cli_integration_assets_install_stores_versioned_ownership_copy) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-assets-store-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + char stored_dir[512]; + char stored_path[640]; + snprintf(stored_dir, sizeof(stored_dir), "%s/.cbm/assets/%s", tmpdir, CBM_VERSION); + snprintf(stored_path, sizeof(stored_path), "%s/cbm-integrations.json", stored_dir); + + char *saved_assets = save_test_env("CBM_ASSETS_DIR"); + cbm_unsetenv("CBM_ASSETS_DIR"); + cbm_integration_assets_reset_for_testing(); + + char err[512]; + err[0] = '\0'; + bool dry_ok = cbm_integration_assets_install(tmpdir, true, err, sizeof(err)); + char *dry_file = read_test_file_alloc(stored_path); + bool dry_wrote_nothing = dry_file == NULL; + free(dry_file); + + bool install_ok = cbm_integration_assets_install(tmpdir, false, err, sizeof(err)); + char *stored = read_test_file_alloc(stored_path); + bool stored_exists = stored != NULL; + free(stored); + + /* The stored copy must itself pass verification when it is the ONLY + * candidate — that is what makes it usable as the ownership reference. */ + cbm_setenv("CBM_ASSETS_DIR", stored_dir, 1); + cbm_integration_assets_reset_for_testing(); + bool stored_verifies = cbm_integration_assets_require(NULL, err, sizeof(err)); + + restore_test_env("CBM_ASSETS_DIR", saved_assets); + cbm_integration_assets_reset_for_testing(); + test_rmdir_r(tmpdir); + if (!dry_ok || !dry_wrote_nothing) + FAIL("a dry-run install must verify but write no stored copy"); + if (!install_ok || !stored_exists) + FAIL("install must persist the verified asset copy under ~/.cbm/assets//"); + if (!stored_verifies) + FAIL("the stored copy must re-verify against the embedded hash"); + PASS(); +} + +/* Pin the CURRENT posix gate body byte-for-byte. Every already-deployed + * install holds files with exactly these bytes; a well-meaning edit to the + * template in cbm-integrations.json would silently turn them all foreign + * (preserved forever, never upgraded or uninstalled). Changing this fixture + * is only legitimate together with moving the old body into the template's + * released[] list. */ +TEST(cli_integration_current_gate_script_bytes_are_pinned) { +#ifdef _WIN32 + SKIP_PLATFORM("POSIX gate body contract"); +#endif + static const char expected_gate[] = + "#!/usr/bin/env bash\n" + "# codebase-memory-mcp search augmenter (Claude Code PreToolUse).\n" + "# NOTE: the legacy filename is kept for zero-migration upgrades.\n" + "# Despite the name this NEVER blocks a tool call - it only adds\n" + "# graph context. Any failure is silent (exit 0, no output).\n" + "BIN='/opt/codebase-memory-mcp'\n" + "[ -x \"$BIN\" ] || exit 0\n" + "\"$BIN\" hook-augment 2>/dev/null\n" + "exit 0\n"; + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-assets-bytes-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + char gate_path[640]; + snprintf(gate_path, sizeof(gate_path), "%s/.claude/hooks/cbm-code-discovery-gate", tmpdir); + + char *saved_claude = save_test_env("CLAUDE_CONFIG_DIR"); + cbm_unsetenv("CLAUDE_CONFIG_DIR"); + bool installed = cbm_install_hook_gate_script(tmpdir, "/opt/codebase-memory-mcp"); + char *gate = read_test_file_alloc(gate_path); + bool byte_identical = installed && gate && strcmp(gate, expected_gate) == 0; + free(gate); + restore_test_env("CLAUDE_CONFIG_DIR", saved_claude); + test_rmdir_r(tmpdir); + if (!byte_identical) + FAIL("the materialized gate script must be byte-identical to the shipped template"); + PASS(); +} + TEST(cli_hook_upsert_rejects_linked_settings) { char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-hook-links-XXXXXX"); @@ -12264,6 +12421,9 @@ SUITE(cli) { #ifndef _WIN32 RUN_TEST(cli_upgrade_migrates_released_claude_hook_scripts); RUN_TEST(cli_upgrade_preserves_near_legacy_claude_hook_script); + RUN_TEST(cli_integration_assets_tampered_or_missing_fail_closed); + RUN_TEST(cli_integration_assets_install_stores_versioned_ownership_copy); + RUN_TEST(cli_integration_current_gate_script_bytes_are_pinned); RUN_TEST(cli_hook_upsert_rejects_linked_settings); RUN_TEST(cli_claude_hook_script_collisions_are_not_registered); RUN_TEST(cli_codex_legacy_migration_rejects_linked_config); diff --git a/tests/test_no_embedded_scripts_contract.sh b/tests/test_no_embedded_scripts_contract.sh new file mode 100644 index 0000000000..0baf389bc3 --- /dev/null +++ b/tests/test_no_embedded_scripts_contract.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +# Contract: the product must not embed integration-script text. +# +# The binary used to carry nine complete shebang'd shell scripts, their +# PowerShell/.cmd twins and the node:child_process client modules as C string +# literals — written to client config dirs at 0755. A block of script-shaped +# bytes inside an unsigned executable is exactly the surface Microsoft +# Defender's ML scored as Trojan:Script/Wacatac.B!ml. Those bodies now live in +# assets/cbm-integrations.json (hash-verified; the binary embeds only the +# SHA-256), and this contract is the proof the removal STAYS removed: one +# revived string literal would silently hand the next release its +# malware-shaped surface back, and nothing else in CI would notice. +# +# Two independent legs: +# 1. SOURCE: no production C source under src/ may contain a shebang or a +# node:child_process literal. Absence at source level makes absence a +# BUILD property of every artifact (standard, ui, all platforms) — not a +# reachability assumption about any one binary. +# 2. ARTIFACT: when build/c/codebase-memory-mcp exists, its bytes are +# scanned directly. scripts/ci/check-binary-composition.sh (A6) runs the +# same needles on every RELEASE artifact post-strip, so a missing local +# build here skips only a redundant leg, never the property. +# +# Comment stripping is mandatory, not tidiness: a contract that a COMMENT can +# satisfy (or trip) is a false guard — see test_spawn_no_window_contract.sh, +# whose first version passed with its fix reverted because prose matched. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +python3 - "$ROOT" <<'PY' +import pathlib +import sys + +root = pathlib.Path(sys.argv[1]) + + +def strip_comments(text): + """Blank out C comments, preserving line count and column positions.""" + out = [] + i, n = 0, len(text) + while i < n: + two = text[i:i + 2] + if two == "/*": + j = text.find("*/", i + 2) + j = n if j < 0 else j + 2 + out.append("".join(c if c == "\n" else " " for c in text[i:j])) + i = j + elif two == "//": + j = text.find("\n", i) + j = n if j < 0 else j + out.append(" " * (j - i)) + i = j + else: + out.append(text[i]) + i += 1 + return "".join(out) + + +# A file may opt out only with a justified, at-the-case entry (O10): the +# exact repo-relative path plus WHY. Empty on purpose — the templates live in +# assets/cbm-integrations.json, and no production source needs script text. +ALLOWLIST = { + # "src/foo/bar.c": "why this file legitimately embeds script text", +} + +# "#!" alone covers every shebang, including one assembled from adjacent +# string literals ("#!" "/bin/sh"); node:child_process covers the JS bridge. +SOURCE_NEEDLES = ["#!", "node:child_process"] + +# Every FIRST-PARTY tree linked into the product binary. Vendored trees are +# deliberately out of scope (pinned upstream code we do not author; the +# artifact leg and the release gate still cover whatever they contribute). +SOURCE_GLOBS = [ + "src/**/*.c", + "internal/cbm/*.c", + "internal/cbm/lsp/*.c", + "internal/cbm/lsp/generated/*.c", +] + +failures = [] +scanned = 0 +sources = sorted(p for g in SOURCE_GLOBS for p in root.glob(g)) +for path in sources: + rel = path.relative_to(root).as_posix() + scanned += 1 + if rel in ALLOWLIST: + continue + text = strip_comments(path.read_text(encoding="utf-8", errors="replace")) + for needle in SOURCE_NEEDLES: + for idx, line in enumerate(text.splitlines(), start=1): + if needle in line: + failures.append(f"{rel}:{idx} contains '{needle}': {line.strip()[:80]}") + +# Anti-vacuity: the files that carried the embedded bodies must still be in +# scope — if they move, this contract must move with them, loudly. +for must_scan in ("src/cli/cli.c", "src/cli/client_adapter.c"): + if not (root / must_scan).is_file(): + failures.append(f"{must_scan} is gone — re-point this contract at the " + "file that now owns integration installs") + +if scanned == 0: + print("FAIL: scanned no production sources — this contract checks nothing") + sys.exit(1) + +if failures: + print(f"FAIL: {len(failures)} embedded script literal(s) in production sources:\n") + for f in failures: + print(f" - {f}") + print("\nIntegration bodies belong in assets/cbm-integrations.json (verified by " + "the embedded SHA-256), never in the binary. A justified exception needs " + "an ALLOWLIST entry in this file.") + sys.exit(1) + +print(f"OK: {scanned} production source file(s) free of script literals") +PY + +# ── Artifact leg ───────────────────────────────────────────────────── +BINARY="build/c/codebase-memory-mcp" +if [ ! -f "$BINARY" ]; then + # Not a silent green: name exactly why this leg may skip (O10). The source + # leg above already proved the build property, and the release gate + # (check-binary-composition.sh A6) scans every shipped artifact. + echo "skip: $BINARY not built here; artifact leg runs in the release gate (A6)" + exit 0 +fi + +fail=0 +for needle in '#!/usr/bin/env bash' '#!/bin/bash' '#!/bin/sh' 'node:child_process'; do + if LC_ALL=C grep -a -q -F -e "$needle" "$BINARY"; then + echo "FAIL: built binary contains '$needle' — an integration body is embedded again" + fail=1 + fi +done +# Anti-vacuity canary, same as the release gate: proves the scan reads a real +# artifact rather than a stub/truncated file where absence is meaningless. +if ! LC_ALL=C grep -a -q -F -e 'codebase-memory-mcp' "$BINARY"; then + echo "FAIL: canary string missing — $BINARY is not a readable product binary" + fail=1 +fi +[ "$fail" -eq 0 ] && echo "OK: built binary carries no shebang or node:child_process bytes" +exit "$fail" diff --git a/tests/test_windows_bundle_contract.sh b/tests/test_windows_bundle_contract.sh index 430b5ba60c..6942245d56 100644 --- a/tests/test_windows_bundle_contract.sh +++ b/tests/test_windows_bundle_contract.sh @@ -75,9 +75,15 @@ def yaml_run_blocks(text: str) -> list[str]: binary = "codebase-memory-mcp.exe" payload = "codebase-memory-mcp.payload.exe" -windows_archive_names = (binary, "LICENSE", "install.ps1", "THIRD_PARTY_NOTICES.md") +# cbm-integrations.json: the integration templates the binary verifies by its +# embedded SHA-256 before installing anything. install.ps1 runs `install` from +# the extract dir, so the file must sit NEXT TO the .exe or every install +# fails closed with "integration assets missing or modified". +windows_archive_names = ( + binary, "cbm-integrations.json", "LICENSE", "install.ps1", "THIRD_PARTY_NOTICES.md", +) -# ── 1. The archive is exactly four files, defined in ONE place ─────────────── +# ── 1. The archive is exactly five files, defined in ONE place ─────────────── # Every venue (release build, local artifact-flow smoke) produces archives # through scripts/package-release.sh, so the layout is asserted where it is # defined and cannot fork per venue. From 83df500364dcc308c01684ec3c642191877fdee4 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 8 Aug 2026 04:18:01 +0200 Subject: [PATCH 2/7] fix(build,ci): make the integrations-hash generator runnable and quiet a JSON-license false positive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Makefile.cbm | 2 +- scripts/gen-integrations-hash.sh | 0 scripts/license-policy.json | 5 +++-- 3 files changed, 4 insertions(+), 3 deletions(-) mode change 100644 => 100755 scripts/gen-integrations-hash.sh diff --git a/Makefile.cbm b/Makefile.cbm index 56bd923798..d421167143 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -699,7 +699,7 @@ BUILD_DIR = build/c # Integration-asset hash header (variables defined next to PROJECT_HDRS; the # rule lives here because target lines expand $(BUILD_DIR) immediately). $(INTEGRATIONS_HASH_HDR): $(INTEGRATIONS_ASSET) scripts/gen-integrations-hash.sh | $(BUILD_DIR) - scripts/gen-integrations-hash.sh $(INTEGRATIONS_ASSET) $@ + sh scripts/gen-integrations-hash.sh $(INTEGRATIONS_ASSET) $@ # ── Object file compilation (grammars need relaxed warnings) ───── diff --git a/scripts/gen-integrations-hash.sh b/scripts/gen-integrations-hash.sh old mode 100644 new mode 100755 diff --git a/scripts/license-policy.json b/scripts/license-policy.json index a4430c1898..a8490d6ec0 100644 --- a/scripts/license-policy.json +++ b/scripts/license-policy.json @@ -16,13 +16,14 @@ "LicenseRef-scancode-public-domain", "LicenseRef-scancode-public-domain-disclaimer" ], - "_ignored_paths_comment": "Path prefixes (relative to the staged scan tree) excluded from the gate. Use ONLY for documented false positives. Justifications: the license tooling itself (gate scripts + this policy file) necessarily names prohibited licenses; gen-third-party-notices.sh echoes license terminology; audit-license-provenance.py names licenses in its verdict maps; src/discover/discover.c contains a license-FILENAME classification list (LICENSE-MIT, LICENSE-APACHE, ...) for file discovery, which ScanCode reads as license references.", + "_ignored_paths_comment": "Path prefixes (relative to the staged scan tree) excluded from the gate. Use ONLY for documented false positives. Justifications: the license tooling itself (gate scripts + this policy file) necessarily names prohibited licenses; gen-third-party-notices.sh echoes license terminology; audit-license-provenance.py names licenses in its verdict maps; src/discover/discover.c contains a license-FILENAME classification list (LICENSE-MIT, LICENSE-APACHE, ...) for file discovery, which ScanCode reads as license references; package-release.sh lists the archive members 'cbm-integrations.json LICENSE ...' adjacently (an order the Windows single-binary contract locks), and ScanCode reads the '.json LICENSE' token adjacency as a reference to the SPDX 'JSON' license — a first-party MIT build script, not vendored code.", "ignored_paths": [ "tree/scripts/license-policy.json", "tree/scripts/license-gate-check.py", "tree/scripts/license-gate.sh", "tree/scripts/gen-third-party-notices.sh", "tree/src/discover/discover.c", - "tree/scripts/audit-license-provenance.py" + "tree/scripts/audit-license-provenance.py", + "tree/scripts/package-release.sh" ] } From 365841304c7deebb996361fd880e9982a2052f19 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 8 Aug 2026 05:08:25 +0200 Subject: [PATCH 3/7] fix(install,smoke): carry cbm-integrations.json wherever the binary goes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//, 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 --- install.ps1 | 18 ++++++++++++++++++ install.sh | 19 +++++++++++++++++++ scripts/build.sh | 10 ++++++++++ scripts/smoke-local.sh | 14 +++++++++----- scripts/smoke-test.sh | 8 ++++++++ 5 files changed, 64 insertions(+), 5 deletions(-) diff --git a/install.ps1 b/install.ps1 index 890dfd4f87..9545280cda 100644 --- a/install.ps1 +++ b/install.ps1 @@ -322,6 +322,24 @@ if (Test-Path -LiteralPath $DownloadedInstaller -PathType Leaf) { } } +# Place the integration-template asset beside the installed binary, mirroring +# install.sh. `install` above published a verified copy to ~/.cbm/assets, but a +# later install/uninstall run from $InstallDir resolves the asset next to the +# binary first; without this copy that lookup misses and the run fails closed +# with "integration assets missing". +$DownloadedAsset = Join-Path $TmpDir "cbm-integrations.json" +if (Test-Path -LiteralPath $DownloadedAsset -PathType Leaf) { + $AssetDest = Join-Path $InstallDir "cbm-integrations.json" + $AssetTmp = "$AssetDest.new" + try { + Copy-Item -LiteralPath $DownloadedAsset -Destination $AssetTmp -Force -ErrorAction Stop + Move-Item -LiteralPath $AssetTmp -Destination $AssetDest -Force -ErrorAction Stop + } catch { + Remove-Item -LiteralPath $AssetTmp -Force -ErrorAction SilentlyContinue + Write-Host "note: could not place cbm-integrations.json in $InstallDir (asset resolves from ~/.cbm/assets)" + } +} + # Verify try { $ver = & $Dest --version 2>&1 diff --git a/install.sh b/install.sh index e58a28294d..e0551055ed 100755 --- a/install.sh +++ b/install.sh @@ -294,6 +294,25 @@ if [ -f "$DL_INSTALLER" ]; then fi fi +# Place the integration-template asset beside the installed binary. `install` +# above already published a verified copy to ~/.cbm/assets//, but a +# later `install`/`uninstall` run from INSTALL_DIR resolves the asset next to +# the binary FIRST — and without this copy that lookup misses, so a re-run or an +# uninstall would fail closed with "integration assets missing" even though a +# valid install just completed. Atomic rename, best effort, same rationale as +# the installer copy above. +DL_ASSET="$DLDIR/cbm-integrations.json" +if [ -f "$DL_ASSET" ]; then + ASSET_TMP="$INSTALL_DIR/.cbm-integrations.json.$$" + if cp "$DL_ASSET" "$ASSET_TMP" 2>/dev/null && + mv -f "$ASSET_TMP" "$INSTALL_DIR/cbm-integrations.json" 2>/dev/null; then + : + else + rm -f "$ASSET_TMP" 2>/dev/null || true + echo "note: could not place cbm-integrations.json in $INSTALL_DIR (asset resolves from ~/.cbm/assets)" + fi +fi + # Verify VERSION=$("$DEST" --version 2>&1) || { echo "error: installed binary failed to run" >&2 diff --git a/scripts/build.sh b/scripts/build.sh index 575477a940..0b44dcbf49 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -156,4 +156,14 @@ else CFLAGS_EXTRA="$CFLAGS_EXTRA" "${EXTRA_MAKE_ARGS[@]+"${EXTRA_MAKE_ARGS[@]}"}" fi +# Stage the integration-template asset next to the binary so the dev/CI build +# mirrors the release archive layout. The binary resolves cbm-integrations.json +# next to itself, in $CBM_ASSETS_DIR, or under ~/.cbm/assets//; a plain +# build/c binary has none of those, so install/uninstall (which render the +# templates to verify hash and ownership) would fail closed exactly as they do +# for a user who deleted the file. Copying it here keeps `install` working +# straight out of a build tree — smoke, local dev, and the release packaging all +# then see the same adjacency. +cp "$ROOT/assets/cbm-integrations.json" "$BUILD_DIR/cbm-integrations.json" + echo "=== Build complete: ${BUILD_DIR}/codebase-memory-mcp ===" diff --git a/scripts/smoke-local.sh b/scripts/smoke-local.sh index ae617634a8..b97292560b 100755 --- a/scripts/smoke-local.sh +++ b/scripts/smoke-local.sh @@ -72,7 +72,7 @@ esac ARTIFACT_DIR="${CBM_SMOKE_ARTIFACT_DIR:-}" if [ -n "$ARTIFACT_DIR" ]; then ARTIFACT_DIR="$(cd "$ARTIFACT_DIR" && pwd)" - for required in LICENSE install.sh THIRD_PARTY_NOTICES.md; do + for required in cbm-integrations.json LICENSE install.sh THIRD_PARTY_NOTICES.md; do [ -s "$ARTIFACT_DIR/$required" ] || { echo "smoke-local: release artifact is missing $required" >&2 exit 2 @@ -123,16 +123,20 @@ mkdir -p "$FIXTURE_DIR" "$SMOKE_TEMP_DIR" "$SMOKE_HOME" "$SMOKE_XDG_CONFIG" \ "$SMOKE_APPDATA" "$SMOKE_LOCALAPPDATA" cp "$BINARY" "$FIXTURE_DIR/codebase-memory-mcp" if [ -n "$ARTIFACT_DIR" ]; then - cp "$ARTIFACT_DIR/LICENSE" "$ARTIFACT_DIR/install.sh" \ - "$ARTIFACT_DIR/THIRD_PARTY_NOTICES.md" "$FIXTURE_DIR/" + cp "$ARTIFACT_DIR/cbm-integrations.json" "$ARTIFACT_DIR/LICENSE" \ + "$ARTIFACT_DIR/install.sh" "$ARTIFACT_DIR/THIRD_PARTY_NOTICES.md" "$FIXTURE_DIR/" else - cp "$ROOT/LICENSE" "$ROOT/install.sh" "$FIXTURE_DIR/" + cp "$ROOT/assets/cbm-integrations.json" "$ROOT/LICENSE" "$ROOT/install.sh" "$FIXTURE_DIR/" "$ROOT/scripts/gen-third-party-notices.sh" "$FIXTURE_DIR/THIRD_PARTY_NOTICES.md" fi +# The archive must carry cbm-integrations.json — install verifies it against the +# binary's embedded SHA-256 and fails closed without it. Member set and ORDER +# mirror scripts/package-release.sh (the Windows single-binary contract locks +# that order); a fixture that omits it would smoke a release layout we never ship. EXPECTED_ARTIFACT="codebase-memory-mcp${SUFFIX}-${OS}-${ARCH}.tar.gz" tar -czf "$FIXTURE_DIR/$EXPECTED_ARTIFACT" -C "$FIXTURE_DIR" \ - codebase-memory-mcp LICENSE install.sh THIRD_PARTY_NOTICES.md + codebase-memory-mcp cbm-integrations.json LICENSE install.sh THIRD_PARTY_NOTICES.md if [ -n "$SUFFIX" ]; then cp "$FIXTURE_DIR/$EXPECTED_ARTIFACT" \ "$FIXTURE_DIR/codebase-memory-mcp-${OS}-${ARCH}.tar.gz" diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 344b009305..f0c680f942 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -3068,16 +3068,24 @@ if [ -n "${SMOKE_DOWNLOAD_URL:-}" ]; then fi UPDATE_HOME=$(smoke_mktemp_dir) mkdir -p "$UPDATE_HOME/.claude" "$UPDATE_HOME/.local/bin" + # This phase stages the binary by hand into a fresh HOME — it does NOT go + # through install.sh or `install`, so nothing populates ~/.cbm/assets. The + # integration asset ships next to the binary in every real layout (archive, + # build tree, installed dir), so stage it next to each staged copy here too; + # otherwise the uninstall this phase drives fails closed rendering templates. + BINARY_ASSET="$(dirname "$BINARY")/cbm-integrations.json" if [[ "$BINARY" == *.exe ]]; then cp "$BINARY" "$UPDATE_HOME/.local/bin/codebase-memory-mcp.exe" mkdir -p "$UPDATE_HOME/retired-install" cp "$BINARY" "$UPDATE_HOME/retired-install/codebase-memory-mcp.exe" + [ -f "$BINARY_ASSET" ] && cp "$BINARY_ASSET" "$UPDATE_HOME/.local/bin/cbm-integrations.json" else cp "$BINARY" "$UPDATE_HOME/.local/bin/codebase-memory-mcp" chmod 755 "$UPDATE_HOME/.local/bin/codebase-memory-mcp" mkdir -p "$UPDATE_HOME/retired-install" cp "$BINARY" "$UPDATE_HOME/retired-install/codebase-memory-mcp" chmod 755 "$UPDATE_HOME/retired-install/codebase-memory-mcp" + [ -f "$BINARY_ASSET" ] && cp "$BINARY_ASSET" "$UPDATE_HOME/.local/bin/cbm-integrations.json" if [ "$(uname -s)" = "Darwin" ]; then codesign --sign - --force "$UPDATE_HOME/.local/bin/codebase-memory-mcp" 2>/dev/null || true codesign --sign - --force "$UPDATE_HOME/retired-install/codebase-memory-mcp" \ From faf08a0743d7435073003a92fa5f6dbe1596fb82 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 8 Aug 2026 05:13:59 +0200 Subject: [PATCH 4/7] fix(ci): ignore the JSON-license false positive in smoke-local.sh too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- scripts/license-policy.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/license-policy.json b/scripts/license-policy.json index a8490d6ec0..5ae9faa15c 100644 --- a/scripts/license-policy.json +++ b/scripts/license-policy.json @@ -16,7 +16,7 @@ "LicenseRef-scancode-public-domain", "LicenseRef-scancode-public-domain-disclaimer" ], - "_ignored_paths_comment": "Path prefixes (relative to the staged scan tree) excluded from the gate. Use ONLY for documented false positives. Justifications: the license tooling itself (gate scripts + this policy file) necessarily names prohibited licenses; gen-third-party-notices.sh echoes license terminology; audit-license-provenance.py names licenses in its verdict maps; src/discover/discover.c contains a license-FILENAME classification list (LICENSE-MIT, LICENSE-APACHE, ...) for file discovery, which ScanCode reads as license references; package-release.sh lists the archive members 'cbm-integrations.json LICENSE ...' adjacently (an order the Windows single-binary contract locks), and ScanCode reads the '.json LICENSE' token adjacency as a reference to the SPDX 'JSON' license — a first-party MIT build script, not vendored code.", + "_ignored_paths_comment": "Path prefixes (relative to the staged scan tree) excluded from the gate. Use ONLY for documented false positives. Justifications: the license tooling itself (gate scripts + this policy file) necessarily names prohibited licenses; gen-third-party-notices.sh echoes license terminology; audit-license-provenance.py names licenses in its verdict maps; src/discover/discover.c contains a license-FILENAME classification list (LICENSE-MIT, LICENSE-APACHE, ...) for file discovery, which ScanCode reads as license references; package-release.sh and smoke-local.sh list the archive members 'cbm-integrations.json LICENSE ...' adjacently (the order the Windows single-binary contract locks, mirrored by the smoke fixture), and ScanCode reads the '.json LICENSE' token adjacency as a reference to the SPDX 'JSON' license — first-party MIT build scripts, not vendored code.", "ignored_paths": [ "tree/scripts/license-policy.json", "tree/scripts/license-gate-check.py", @@ -24,6 +24,7 @@ "tree/scripts/gen-third-party-notices.sh", "tree/src/discover/discover.c", "tree/scripts/audit-license-provenance.py", - "tree/scripts/package-release.sh" + "tree/scripts/package-release.sh", + "tree/scripts/smoke-local.sh" ] } From 1c16786b4bc2dbc1eccfe7e2645c1a63b9634f91 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 8 Aug 2026 05:31:53 +0200 Subject: [PATCH 5/7] fix(smoke): stage cbm-integrations.json in the Windows smoke path too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- test-infrastructure/vm/vm-smoke.sh | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/test-infrastructure/vm/vm-smoke.sh b/test-infrastructure/vm/vm-smoke.sh index b2dd2176eb..308c5c8639 100755 --- a/test-infrastructure/vm/vm-smoke.sh +++ b/test-infrastructure/vm/vm-smoke.sh @@ -59,7 +59,7 @@ ARTIFACT_DIR="${CBM_SMOKE_ARTIFACT_DIR:-}" if [ -n "$ARTIFACT_DIR" ]; then ARTIFACT_DIR="$(cd "$ARTIFACT_DIR" && pwd)" for required in codebase-memory-mcp.exe \ - LICENSE install.ps1 THIRD_PARTY_NOTICES.md; do + cbm-integrations.json LICENSE install.ps1 THIRD_PARTY_NOTICES.md; do [ -s "$ARTIFACT_DIR/$required" ] || { echo "vm-smoke: release artifact is missing $required" >&2; exit 2; } done @@ -146,22 +146,37 @@ MSYS2_ARG_CONV_EXCL='*' powershell.exe -NoProfile -ExecutionPolicy Bypass \ -SmokeRoot "$(cygpath -w "$SMOKE_DIR")" cp "$BINARY_SRC" "$SMOKE_DIR/codebase-memory-mcp.exe" cp "$SMOKE_DIR/codebase-memory-mcp.exe" "$FIXTURE_DIR/" +# The integration asset must sit next to the smoke binary in $SMOKE_DIR: phases +# that invoke it directly (Phase 14 stages it by hand and drives an uninstall) +# resolve the asset next to the binary, and without it those render-template +# operations fail closed. Its own copy lands in the served archive below. +if [ -n "$ARTIFACT_DIR" ]; then + ASSET_SRC="$ARTIFACT_DIR/cbm-integrations.json" +else + ASSET_SRC="$(dirname "$BINARY_SRC")/cbm-integrations.json" +fi +cp "$ASSET_SRC" "$SMOKE_DIR/cbm-integrations.json" # The install/update phases fetch these out of the served archive, so they must # be the artifact's own copies whenever one was supplied — regenerating them # here would smoke a sidecar the release never ships. if [ -n "$ARTIFACT_DIR" ]; then - cp "$ARTIFACT_DIR/LICENSE" "$ARTIFACT_DIR/install.ps1" \ - "$ARTIFACT_DIR/THIRD_PARTY_NOTICES.md" "$FIXTURE_DIR/" + cp "$ARTIFACT_DIR/cbm-integrations.json" "$ARTIFACT_DIR/LICENSE" \ + "$ARTIFACT_DIR/install.ps1" "$ARTIFACT_DIR/THIRD_PARTY_NOTICES.md" "$FIXTURE_DIR/" else - cp LICENSE install.ps1 "$FIXTURE_DIR/" + # build.sh stages cbm-integrations.json next to the binary; ship it in the + # archive so the installed binary can verify and render its templates. + cp "$(dirname "$BINARY_SRC")/cbm-integrations.json" LICENSE install.ps1 "$FIXTURE_DIR/" scripts/gen-third-party-notices.sh "$FIXTURE_DIR/THIRD_PARTY_NOTICES.md" fi +# Member set + ORDER mirror scripts/package-release.sh (Windows): the install +# verifies cbm-integrations.json against the binary's embedded SHA-256 and fails +# closed without it. EXPECTED_ARTIFACT="codebase-memory-mcp${SUFFIX}-windows-${SMOKE_ARCH}.zip" ( cd "$FIXTURE_DIR" zip -q "$EXPECTED_ARTIFACT" \ - codebase-memory-mcp.exe LICENSE install.ps1 THIRD_PARTY_NOTICES.md + codebase-memory-mcp.exe cbm-integrations.json LICENSE install.ps1 THIRD_PARTY_NOTICES.md if [ -n "$SUFFIX" ]; then cp "$EXPECTED_ARTIFACT" "codebase-memory-mcp-windows-${SMOKE_ARCH}.zip" fi From d15bd7241d137f19003fe5fd85afb3a068d00fa5 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 8 Aug 2026 05:49:55 +0200 Subject: [PATCH 6/7] fix(install): allow cbm-integrations.json in the install.ps1 archive namespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- install.ps1 | 1 + 1 file changed, 1 insertion(+) diff --git a/install.ps1 b/install.ps1 index 9545280cda..a65a1a342d 100644 --- a/install.ps1 +++ b/install.ps1 @@ -16,6 +16,7 @@ $InstallDir = "$env:LOCALAPPDATA\Programs\codebase-memory-mcp" $BinName = "codebase-memory-mcp.exe" $WindowsArchiveNames = @( $BinName, + "cbm-integrations.json", "LICENSE", "install.ps1", "THIRD_PARTY_NOTICES.md" From 497c680aae809906f6b876bb034048ba8fa13948 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 8 Aug 2026 06:11:14 +0200 Subject: [PATCH 7/7] fix(build): make the UI target depend on the generated integrations hash header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Makefile.cbm | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Makefile.cbm b/Makefile.cbm index d421167143..f7eef1d781 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -1057,7 +1057,11 @@ frontend: embed: frontend scripts/embed-frontend.sh graph-ui/dist $(BUILD_DIR)/embedded -cbm-with-ui: embed $(OBJS_VENDORED_PROD) +# $(INTEGRATIONS_HASH_HDR): generated header included by integration_assets.c. +# The standard target gets it via $(PROJECT_HDRS); the UI target compiles in one +# shot and must list it too, or the build fails with "cbm_integrations_hash.h +# file not found" (seen on the Windows UI build). +cbm-with-ui: embed $(INTEGRATIONS_HASH_HDR) $(OBJS_VENDORED_PROD) $(CC) $(CFLAGS_PROD) -o $(BUILD_DIR)/codebase-memory-mcp \ $(MAIN_SRC) $(PROD_SRCS_WITH_ASSETS) \ $(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) \