mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
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:
21 files changed
+1404
-453
No files matched your search
+24
-4
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 ==="
|
||||
@@ -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 ────────────────────────────────────────────────
|
||||
|
||||
Executable
+54
@@ -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"
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 */
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user