diff --git a/Makefile.cbm b/Makefile.cbm index 97a481d9b..f7eef1d78 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) + sh 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) @@ -1041,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) \ @@ -1097,7 +1117,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 000000000..35caa19c2 --- /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/install.ps1 b/install.ps1 index 890dfd4f8..a65a1a342 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" @@ -322,6 +323,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 e58a28294..e0551055e 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 575477a94..0b44dcbf4 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/ci/check-binary-composition.sh b/scripts/ci/check-binary-composition.sh index c940793f7..1a0167067 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 100755 index 000000000..eadf04343 --- /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/license-policy.json b/scripts/license-policy.json index a4430c189..5ae9faa15 100644 --- a/scripts/license-policy.json +++ b/scripts/license-policy.json @@ -16,13 +16,15 @@ "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 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", "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", + "tree/scripts/smoke-local.sh" ] } diff --git a/scripts/package-release.sh b/scripts/package-release.sh index 72b090704..2a837a918 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/smoke-local.sh b/scripts/smoke-local.sh index ae617634a..b97292560 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 344b00930..f0c680f94 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" \ diff --git a/scripts/test.sh b/scripts/test.sh index f7b813e2b..46db34e5e 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 81a4f2ac2..3e8d02bee 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,160 +4703,14 @@ 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); } -#ifdef _WIN32 -#define AUGMENT_SESSION_SCRIPT "codebase-memory-session.ps1" -#define AUGMENT_COVERAGE_SCRIPT "codebase-memory-coverage.ps1" -#else -#define AUGMENT_SESSION_SCRIPT "codebase-memory-session.sh" -#define AUGMENT_COVERAGE_SCRIPT "codebase-memory-coverage.sh" -#endif - -static int cbm_build_augment_session_script(const char *binary_path, char *script, - size_t script_size) { - 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; -} - -static bool cbm_install_augment_session_script(const char *binary_path, const char *script_path) { - char script[CLI_BUF_8K]; - return ensure_parent_dir(script_path) == CLI_OK && - cbm_build_augment_session_script(binary_path, script, sizeof(script)) == CLI_OK && - cbm_write_owned_hook_script(script_path, script); -} - -static int cbm_build_augment_coverage_script(const char *binary_path, char *script, - size_t script_size) { - 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; -} - -static bool cbm_install_augment_coverage_script(const char *binary_path, const char *script_path) { - char script[CLI_BUF_8K]; - return ensure_parent_dir(script_path) == CLI_OK && - cbm_build_augment_coverage_script(binary_path, script, sizeof(script)) == CLI_OK && - cbm_write_owned_hook_script(script_path, script); -} - -static const char *const cmm_cline_context_events[] = {"TaskStart", "TaskResume", - "UserPromptSubmit", "PreCompact"}; - -static int cbm_cline_hook_path(const char *cline_root, const char *event, char *path, - size_t path_size) { -#ifdef _WIN32 - int written = snprintf(path, path_size, "%s/hooks/%s.ps1", cline_root, event); -#else - int written = snprintf(path, path_size, "%s/hooks/%s", cline_root, event); -#endif - return written > 0 && (size_t)written < path_size ? CLI_OK : CLI_ERR; -} - -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) { - 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 +/* ── 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) { @@ -4881,96 +4736,191 @@ static int cbm_escape_batch_value(const char *value, char *escaped, size_t escap } #endif -static int cbm_build_current_hook_script(const char *prefix, const char *binary_path, char *script, - size_t script_size) { +/* 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 - 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; + if (strcmp(bin_mode, "cmd") == 0) { + return cbm_escape_batch_value(binary_path, out, out_size); } - 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; + 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 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, '"')) { +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; } - 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; + 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" +#else +#define AUGMENT_SESSION_SCRIPT "codebase-memory-session.sh" +#define AUGMENT_COVERAGE_SCRIPT "codebase-memory-coverage.sh" +#endif + +static int cbm_build_augment_session_script(const char *binary_path, char *script, + size_t script_size) { + if (!binary_path || !script || script_size == 0U) { + return 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) { + char script[CLI_BUF_8K]; + return ensure_parent_dir(script_path) == CLI_OK && + cbm_build_augment_session_script(binary_path, script, sizeof(script)) == CLI_OK && + cbm_write_owned_hook_script(script_path, script); +} + +static int cbm_build_augment_coverage_script(const char *binary_path, char *script, + size_t script_size) { + if (!binary_path || !script || script_size == 0U) { + return 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) { + char script[CLI_BUF_8K]; + return ensure_parent_dir(script_path) == CLI_OK && + cbm_build_augment_coverage_script(binary_path, script, sizeof(script)) == CLI_OK && + cbm_write_owned_hook_script(script_path, script); +} + +static const char *const cmm_cline_context_events[] = {"TaskStart", "TaskResume", + "UserPromptSubmit", "PreCompact"}; + +static int cbm_cline_hook_path(const char *cline_root, const char *event, char *path, + size_t path_size) { +#ifdef _WIN32 + int written = snprintf(path, path_size, "%s/hooks/%s.ps1", cline_root, event); +#else + int written = snprintf(path, path_size, "%s/hooks/%s", cline_root, event); +#endif + return written > 0 && (size_t)written < path_size ? CLI_OK : CLI_ERR; +} + +static int cbm_build_cline_context_script(const char *binary_path, const char *event, char *script, + size_t script_size) { + if (!binary_path || !event || !script || script_size == 0U) { + return CLI_ERR; + } + return cbm_render_integration_script("cline_context", binary_path, event, script, script_size); +} + +/* 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, size_t released_script_count) { @@ -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 c6828ea12..64ad7e6d0 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 f0c812e62..b885d870a 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 000000000..b8e8e296e --- /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 000000000..6fb8748c5 --- /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/test-infrastructure/vm/vm-smoke.sh b/test-infrastructure/vm/vm-smoke.sh index b2dd2176e..308c5c863 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 diff --git a/tests/test_cli.c b/tests/test_cli.c index f35dd9d4c..789a6fc4e 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 000000000..0baf389bc --- /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 430b5ba60..6942245d5 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.