Merge pull request #1493 from DeusData/harden/av-surface-strings

harden: ship integration templates as a verified JSON asset, not embedded bytes
This commit is contained in:
Martin Vogel authored and GitHub committed 2026-08-08 07:32:06 +02:00
commit 2c50c7741e
21 files changed
+1404 -453

No files matched your search

+24 -4
View File
@@ -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)
+1
View File
@@ -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}Line truncated
+19
View File
@@ -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
+19
View File
@@ -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/<version>/, 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
+10
View File
@@ -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/<version>/; 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 ==="
+38
View File
@@ -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 ────────────────────────────────────────────────
+54
View File
@@ -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 <asset.json> <out-header.h>" >&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"
+4 -2
View File
@@ -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"
]
}
+13 -6
View File
@@ -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
+9 -5
View File
@@ -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"
+8
View File
@@ -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" \
+3
View File
@@ -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"
+275 -318
View File
@@ -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 <home>/.cbm/assets/<version>/ (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);
+79 -105
View File
@@ -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;
+13 -6
View File
@@ -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
+415
View File
@@ -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 <home>/.cbm/assets/<version>/).
*/
#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 <stdio.h>
#include <stdlib.h>
#include <string.h>
#ifndef _WIN32
#include <unistd.h>
#endif
#ifdef __APPLE__
#include <mach-o/dyld.h>
#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/<binary> 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
+89
View File
@@ -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 <bindir>/../../assets (build/c/ builds and test-runner),
* else the stored copy under <home>/.cbm/assets/<version>/.
*
* The stored copy is the OWNERSHIP REFERENCE: `install` persists the verified
* bytes to <home>/.cbm/assets/<version>/ — 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 <stdbool.h>
#include <stddef.h>
#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
* <home>/.cbm/assets/<version>/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 */
+20 -5
View File
@@ -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
+160
View File
@@ -16,6 +16,7 @@
#include <cli/agent_profiles.h>
#include <cli/activation_transaction.h>
#include <cli/cli.h>
#include <cli/integration_assets.h>
#include <cli/progress_sink.h>
#include <daemon/bootstrap.h>
#include <daemon/runtime.h>
@@ -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 <home>/.cbm/assets/<version>/ —
* 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/<version>/");
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);
+143
View File
@@ -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"
+8 -2
View File
@@ -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.