fix(hooks): replace blocking Claude PreToolUse gate with non-blocking augmenter
The previous PreToolUse hook gated Grep/Glob/Read/Search with 'exit 2' on the first call per session, which broke Claude Code's read-before-edit invariant (issue #362) and could deny tool calls under upgrade/missing-binary failure modes. Replace it with a structurally non-blocking augmenter: - New 'codebase-memory-mcp hook-augment' subcommand reads the hook JSON from stdin and, for Grep/Glob, queries search_graph (in-process, no shell) and emits hookSpecificOutput.additionalContext. Every failure path (no project, short token, missing binary, slow query, timeout) exits 0 with no stdout — the hook physically cannot block a tool call. - 300 ms SIGALRM/_exit(0) in-process deadline; 5 s settings.json timeout backstop. Output is written exactly once at the very end, so a mid-work timeout yields a clean no-op (never partial JSON). - Matcher narrowed to 'Grep|Glob' (Read explicitly excluded) for Claude; Gemini matcher narrowed to 'google_search|grep_search' (excludes read_file) for the same reason. - The installed shim is a thin wrapper that delegates to the binary; legacy filename 'cbm-code-discovery-gate' is kept so existing settings.json entries upgrade with zero migration. Installer refuses to embed binary paths containing a double quote (shim injection defense). - Per-agent 'old matchers' lists let upsert/remove clean up historical matcher strings during upgrade. - Smoke tests (8d/8e/8l) updated to assert the new behavior and regress-test against re-introducing Read in the matcher or 'exit 2' in the shim. - Session reminder text updated: 'always Read a file before editing it' replaces the prior 'fall back to Read only for text content'. (cherry picked from commit f72c8e68c4d91e52911a569a967ad782ce5472b2)
This commit is contained in:
+1
-1
@@ -215,7 +215,7 @@ TRACES_SRCS = src/traces/traces.c
|
||||
WATCHER_SRCS = src/watcher/watcher.c
|
||||
|
||||
# CLI module (new)
|
||||
CLI_SRCS = src/cli/cli.c src/cli/progress_sink.c
|
||||
CLI_SRCS = src/cli/cli.c src/cli/progress_sink.c src/cli/hook_augment.c
|
||||
|
||||
# UI module (graph visualization)
|
||||
UI_SRCS = \
|
||||
|
||||
@@ -333,9 +333,9 @@ Restart your agent. Verify with `/mcp` — you should see `codebase-memory-mcp`
|
||||
|
||||
| Agent | MCP Config | Instructions | Hooks |
|
||||
|-------|-----------|-------------|-------|
|
||||
| Claude Code | `.claude/.mcp.json` | 4 Skills | PreToolUse (Grep/Glob/Read reminder) |
|
||||
| Claude Code | `.claude/.mcp.json` | 4 Skills | PreToolUse (Grep/Glob graph augment, non-blocking) |
|
||||
| Codex CLI | `.codex/config.toml` | `.codex/AGENTS.md` | — |
|
||||
| Gemini CLI | `.gemini/settings.json` | `.gemini/GEMINI.md` | BeforeTool (grep/read reminder) |
|
||||
| Gemini CLI | `.gemini/settings.json` | `.gemini/GEMINI.md` | BeforeTool (grep reminder) |
|
||||
| Zed | `settings.json` (JSONC) | — | — |
|
||||
| OpenCode | `opencode.json` | `AGENTS.md` | — |
|
||||
| Antigravity | `mcp_config.json` | `AGENTS.md` | — |
|
||||
@@ -345,7 +345,15 @@ Restart your agent. Verify with `/mcp` — you should see `codebase-memory-mcp`
|
||||
| OpenClaw | `openclaw.json` | — | — |
|
||||
| Kiro | `.kiro/settings/mcp.json` | — | — |
|
||||
|
||||
**Hooks** are advisory (exit code 0) — they remind agents to prefer MCP graph tools when they reach for grep/glob/read, without blocking the tool call.
|
||||
**Hooks are structurally non-blocking** (exit code 0, every failure path).
|
||||
For Claude Code, the `PreToolUse` hook intercepts `Grep`/`Glob` (never `Read` —
|
||||
gating `Read` breaks the read-before-edit invariant) and, when the search
|
||||
token matches indexed symbols, injects them as `additionalContext` via
|
||||
`search_graph` so the agent gets structured context alongside its normal
|
||||
search results. For Gemini CLI, `BeforeTool` prints a short reminder.
|
||||
The installed Claude shim file is named `cbm-code-discovery-gate` for
|
||||
backward compatibility with existing installs; despite the legacy name it
|
||||
never gates and never blocks.
|
||||
|
||||
## CLI Mode
|
||||
|
||||
|
||||
+29
-12
@@ -569,26 +569,38 @@ if ! path_match "$CMD" "$SELF_PATH"; then
|
||||
fi
|
||||
echo "OK 8c: Claude Code MCP (.claude/.mcp.json)"
|
||||
|
||||
# 8d: Claude Code hooks
|
||||
# 8d: Claude Code hooks — matcher must be exactly "Grep|Glob" (no Read, no Search).
|
||||
# Gating Read breaks Claude Code's read-before-edit invariant (issue #362), so
|
||||
# this assertion locks in the matcher to prevent regressions.
|
||||
if ! cat "$FAKE_HOME/.claude/settings.json" 2>/dev/null | python3 -c "
|
||||
import json, sys
|
||||
d = json.load(sys.stdin)
|
||||
hooks = d.get('hooks', {}).get('PreToolUse', [])
|
||||
found = any('Grep' in str(h.get('matcher', '')) for h in hooks)
|
||||
sys.exit(0 if found else 1)
|
||||
ok = any(h.get('matcher') == 'Grep|Glob' for h in hooks)
|
||||
bad = any('Read' in str(h.get('matcher', '')) for h in hooks)
|
||||
sys.exit(0 if (ok and not bad) else 1)
|
||||
" 2>/dev/null; then
|
||||
echo "FAIL 8d: PreToolUse hook not found in settings.json"
|
||||
echo "FAIL 8d: PreToolUse hook matcher is not exactly 'Grep|Glob' (or still contains Read)"
|
||||
exit 1
|
||||
fi
|
||||
echo "OK 8d: Claude Code PreToolUse hook"
|
||||
echo "OK 8d: Claude Code PreToolUse hook (matcher=Grep|Glob, Read excluded)"
|
||||
|
||||
# 8e: Claude Code gate script
|
||||
# 8e: Claude Code shim script — must be non-blocking augmenter, not a gate.
|
||||
if [ "$(uname -s)" != "MINGW64_NT" ] 2>/dev/null; then
|
||||
if [ ! -x "$FAKE_HOME/.claude/hooks/cbm-code-discovery-gate" ]; then
|
||||
echo "FAIL 8e: gate script not executable or missing"
|
||||
GATE_SCRIPT="$FAKE_HOME/.claude/hooks/cbm-code-discovery-gate"
|
||||
if [ ! -x "$GATE_SCRIPT" ]; then
|
||||
echo "FAIL 8e: shim script not executable or missing"
|
||||
exit 1
|
||||
fi
|
||||
echo "OK 8e: gate script installed and executable"
|
||||
if grep -q 'exit 2' "$GATE_SCRIPT"; then
|
||||
echo "FAIL 8e: shim contains 'exit 2' — must never block"
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q 'hook-augment' "$GATE_SCRIPT"; then
|
||||
echo "FAIL 8e: shim missing 'hook-augment' delegation"
|
||||
exit 1
|
||||
fi
|
||||
echo "OK 8e: shim installed, non-blocking, delegates to hook-augment"
|
||||
fi
|
||||
|
||||
# 8f-8h: Codex TOML
|
||||
@@ -626,12 +638,17 @@ if ! cat "$FAKE_HOME/.gemini/settings.json" 2>/dev/null | python3 -c "
|
||||
import json, sys
|
||||
d = json.load(sys.stdin)
|
||||
hooks = d.get('hooks', {}).get('BeforeTool', [])
|
||||
sys.exit(0 if len(hooks) > 0 else 1)
|
||||
# Matcher must be exactly 'google_search|grep_search' (no read_file). The
|
||||
# old matcher gated the agent's read tool — consistent with the Claude fix
|
||||
# we remove it here too.
|
||||
ok = any(h.get('matcher') == 'google_search|grep_search' for h in hooks)
|
||||
bad = any('read_file' in str(h.get('matcher', '')) for h in hooks)
|
||||
sys.exit(0 if (ok and not bad) else 1)
|
||||
" 2>/dev/null; then
|
||||
echo "FAIL 8l: Gemini BeforeTool hook missing"
|
||||
echo "FAIL 8l: Gemini BeforeTool hook matcher must be 'google_search|grep_search' (no read_file)"
|
||||
exit 1
|
||||
fi
|
||||
echo "OK 8l: Gemini BeforeTool hook"
|
||||
echo "OK 8l: Gemini BeforeTool hook (matcher=google_search|grep_search)"
|
||||
|
||||
# 8m: Gemini instructions
|
||||
if [ ! -f "$FAKE_HOME/.gemini/GEMINI.md" ]; then
|
||||
|
||||
+99
-47
@@ -1462,18 +1462,31 @@ int cbm_remove_antigravity_mcp(const char *config_path) {
|
||||
|
||||
/* ── Claude Code pre-tool hooks ───────────────────────────────── */
|
||||
|
||||
#define CMM_HOOK_MATCHER "Grep|Glob|Read|Search"
|
||||
/* Matcher intentionally excludes Read: gating Read breaks Claude Code's
|
||||
* read-before-edit invariant (issue #362). The hook is a non-blocking
|
||||
* augmenter, never a gate. */
|
||||
#define CMM_HOOK_MATCHER "Grep|Glob"
|
||||
#define CMM_HOOK_COMMAND "~/.claude/hooks/cbm-code-discovery-gate"
|
||||
/* Hard backstop in settings.json; the binary also self-bounds with an
|
||||
* in-process deadline well under this. */
|
||||
#define CMM_HOOK_TIMEOUT_SEC 5
|
||||
|
||||
/* Old matcher values from previous versions — recognized during upgrade so
|
||||
* upsert_hooks_json can remove them before inserting the current matcher. */
|
||||
static const char *cmm_old_matchers[] = {
|
||||
* upsert/remove can clean them up before inserting the current matcher.
|
||||
* Per-agent lists (no shared global): each caller passes its own. */
|
||||
static const char *const cmm_claude_old_matchers[] = {
|
||||
"Grep|Glob|Read|Search",
|
||||
"Grep|Glob|Read",
|
||||
NULL,
|
||||
};
|
||||
static const char *const cmm_gemini_old_matchers[] = {
|
||||
"google_search|read_file|grep_search",
|
||||
NULL,
|
||||
};
|
||||
|
||||
/* Check if a PreToolUse array entry matches our hook (current or old matcher). */
|
||||
static bool is_cmm_hook_entry(yyjson_mut_val *entry, const char *matcher_str) {
|
||||
/* Check if a hook array entry is ours (current matcher or a known old one). */
|
||||
static bool is_cmm_hook_entry(yyjson_mut_val *entry, const char *matcher_str,
|
||||
const char *const *old_matchers) {
|
||||
yyjson_mut_val *matcher = yyjson_mut_obj_get(entry, "matcher");
|
||||
if (!matcher || !yyjson_mut_is_str(matcher)) {
|
||||
return false;
|
||||
@@ -1486,8 +1499,8 @@ static bool is_cmm_hook_entry(yyjson_mut_val *entry, const char *matcher_str) {
|
||||
return true;
|
||||
}
|
||||
/* Also match old versions for backwards-compatible upgrade */
|
||||
for (int i = 0; cmm_old_matchers[i]; i++) {
|
||||
if (strcmp(val, cmm_old_matchers[i]) == 0) {
|
||||
for (int i = 0; old_matchers && old_matchers[i]; i++) {
|
||||
if (strcmp(val, old_matchers[i]) == 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1501,12 +1514,15 @@ typedef struct {
|
||||
const char *hook_event;
|
||||
const char *matcher_str;
|
||||
const char *command_str;
|
||||
const char *const *old_matchers; /* NULL-terminated; may be NULL */
|
||||
int timeout_sec; /* >0 adds "timeout" to the hook entry */
|
||||
} hooks_upsert_args_t;
|
||||
static int upsert_hooks_json(hooks_upsert_args_t args) {
|
||||
const char *settings_path = args.settings_path;
|
||||
const char *hook_event = args.hook_event;
|
||||
const char *matcher_str = args.matcher_str;
|
||||
const char *command_str = args.command_str;
|
||||
const char *const *old_matchers = args.old_matchers;
|
||||
if (!settings_path) {
|
||||
return CLI_ERR;
|
||||
}
|
||||
@@ -1549,7 +1565,7 @@ static int upsert_hooks_json(hooks_upsert_args_t args) {
|
||||
size_t max;
|
||||
yyjson_mut_val *item;
|
||||
yyjson_mut_arr_foreach(event_arr, idx, max, item) {
|
||||
if (is_cmm_hook_entry(item, matcher_str)) {
|
||||
if (is_cmm_hook_entry(item, matcher_str, old_matchers)) {
|
||||
yyjson_mut_arr_remove(event_arr, idx);
|
||||
break;
|
||||
}
|
||||
@@ -1563,6 +1579,9 @@ static int upsert_hooks_json(hooks_upsert_args_t args) {
|
||||
yyjson_mut_val *hook_obj = yyjson_mut_obj(mdoc);
|
||||
yyjson_mut_obj_add_str(mdoc, hook_obj, "type", "command");
|
||||
yyjson_mut_obj_add_str(mdoc, hook_obj, "command", command_str);
|
||||
if (args.timeout_sec > 0) {
|
||||
yyjson_mut_obj_add_int(mdoc, hook_obj, "timeout", args.timeout_sec);
|
||||
}
|
||||
yyjson_mut_arr_append(hooks_arr, hook_obj);
|
||||
yyjson_mut_obj_add_val(mdoc, entry, "hooks", hooks_arr);
|
||||
|
||||
@@ -1579,11 +1598,13 @@ typedef struct {
|
||||
const char *settings_path;
|
||||
const char *hook_event;
|
||||
const char *matcher_str;
|
||||
const char *const *old_matchers; /* NULL-terminated; may be NULL */
|
||||
} hooks_remove_args_t;
|
||||
static int remove_hooks_json(hooks_remove_args_t args) {
|
||||
const char *settings_path = args.settings_path;
|
||||
const char *hook_event = args.hook_event;
|
||||
const char *matcher_str = args.matcher_str;
|
||||
const char *const *old_matchers = args.old_matchers;
|
||||
if (!settings_path) {
|
||||
return CLI_ERR;
|
||||
}
|
||||
@@ -1618,7 +1639,7 @@ static int remove_hooks_json(hooks_remove_args_t args) {
|
||||
size_t max;
|
||||
yyjson_mut_val *item;
|
||||
yyjson_mut_arr_foreach(event_arr, idx, max, item) {
|
||||
if (is_cmm_hook_entry(item, matcher_str)) {
|
||||
if (is_cmm_hook_entry(item, matcher_str, old_matchers)) {
|
||||
yyjson_mut_arr_remove(event_arr, idx);
|
||||
break;
|
||||
}
|
||||
@@ -1630,20 +1651,40 @@ static int remove_hooks_json(hooks_remove_args_t args) {
|
||||
}
|
||||
|
||||
int cbm_upsert_claude_hooks(const char *settings_path) {
|
||||
return upsert_hooks_json(
|
||||
(hooks_upsert_args_t){settings_path, "PreToolUse", CMM_HOOK_MATCHER, CMM_HOOK_COMMAND});
|
||||
return upsert_hooks_json((hooks_upsert_args_t){
|
||||
.settings_path = settings_path,
|
||||
.hook_event = "PreToolUse",
|
||||
.matcher_str = CMM_HOOK_MATCHER,
|
||||
.command_str = CMM_HOOK_COMMAND,
|
||||
.old_matchers = cmm_claude_old_matchers,
|
||||
.timeout_sec = CMM_HOOK_TIMEOUT_SEC,
|
||||
});
|
||||
}
|
||||
|
||||
int cbm_remove_claude_hooks(const char *settings_path) {
|
||||
return remove_hooks_json((hooks_remove_args_t){settings_path, "PreToolUse", CMM_HOOK_MATCHER});
|
||||
return remove_hooks_json((hooks_remove_args_t){
|
||||
.settings_path = settings_path,
|
||||
.hook_event = "PreToolUse",
|
||||
.matcher_str = CMM_HOOK_MATCHER,
|
||||
.old_matchers = cmm_claude_old_matchers,
|
||||
});
|
||||
}
|
||||
|
||||
/* Install the code discovery gate script to ~/.claude/hooks/.
|
||||
* Blocks the first Grep/Glob/Read/Search call per session (exit 2 + stderr),
|
||||
* nudging Claude toward codebase-memory-mcp. All subsequent calls in the same
|
||||
* session pass through (gate file keyed on PPID). */
|
||||
static void cbm_install_hook_gate_script(const char *home) {
|
||||
if (!home) {
|
||||
/* Install the search-augmenter shim to ~/.claude/hooks/.
|
||||
* The shim is a thin wrapper that delegates to `<binary> hook-augment`,
|
||||
* which adds graph context to Grep/Glob calls. It NEVER blocks a tool call:
|
||||
* a missing/old/hung binary results in a silent exit 0 (issue #362/#288).
|
||||
* The legacy filename `cbm-code-discovery-gate` is retained so existing
|
||||
* settings.json entries and uninstall keep working with zero migration. */
|
||||
static void cbm_install_hook_gate_script(const char *home, const char *binary_path) {
|
||||
if (!home || !binary_path) {
|
||||
return;
|
||||
}
|
||||
/* Defensive: refuse to embed a binary path containing a double-quote, which
|
||||
* would break the BIN="..." shell quoting in the generated shim. In normal
|
||||
* installs this is unreachable (paths come from cbm_detect_self_path), but
|
||||
* fail-loud here beats silently emitting a malformed script. */
|
||||
if (strchr(binary_path, '"') != NULL) {
|
||||
return;
|
||||
}
|
||||
char hooks_dir[CLI_BUF_1K];
|
||||
@@ -1657,22 +1698,18 @@ static void cbm_install_hook_gate_script(const char *home) {
|
||||
if (!f) {
|
||||
return;
|
||||
}
|
||||
(void)fprintf(f, "#!/bin/bash\n"
|
||||
"# Gate hook: nudges Claude toward codebase-memory-mcp for code discovery.\n"
|
||||
"# First Grep/Glob/Read/Search per session -> block. Subsequent -> allow.\n"
|
||||
"# PPID = Claude Code process PID, unique per session.\n"
|
||||
"GATE=/tmp/cbm-code-discovery-gate-$PPID\n"
|
||||
"find /tmp -name 'cbm-code-discovery-gate-*' -mtime +1 -delete 2>/dev/null\n"
|
||||
"if [ -f \"$GATE\" ]; then\n"
|
||||
" exit 0\n"
|
||||
"fi\n"
|
||||
"touch \"$GATE\"\n"
|
||||
"echo 'BLOCKED: For code discovery, use codebase-memory-mcp tools first: "
|
||||
"search_graph(name_pattern) to find functions/classes, trace_path() for "
|
||||
"call chains, get_code_snippet(qualified_name) to read source. If the graph "
|
||||
"is not indexed yet, call index_repository first. Fall back to Grep/Glob/Read "
|
||||
"only for text content search. If you need Grep, retry.' >&2\n"
|
||||
"exit 2\n");
|
||||
(void)fprintf(
|
||||
f,
|
||||
"#!/bin/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);
|
||||
/* fchmod before close to avoid TOCTOU race (CodeQL cpp/toctou-race-condition) */
|
||||
#ifndef _WIN32
|
||||
fchmod(fileno(f), CLI_OCTAL_PERM);
|
||||
@@ -1710,11 +1747,12 @@ static void cbm_install_session_reminder_script(const char *home) {
|
||||
"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) to read source (NOT Read/cat)\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. Fall back to Grep/Glob/Read ONLY for text content, config values, non-code files.\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");
|
||||
#ifndef _WIN32
|
||||
@@ -1730,8 +1768,10 @@ static int cbm_upsert_session_hooks(const char *settings_path) {
|
||||
static const char *matchers[] = {"startup", "resume", "clear", "compact"};
|
||||
int rc = 0;
|
||||
for (int i = 0; i < NUM_DIRS; i++) {
|
||||
if (upsert_hooks_json((hooks_upsert_args_t){settings_path, "SessionStart", matchers[i],
|
||||
CMM_SESSION_COMMAND}) != 0) {
|
||||
if (upsert_hooks_json((hooks_upsert_args_t){.settings_path = settings_path,
|
||||
.hook_event = "SessionStart",
|
||||
.matcher_str = matchers[i],
|
||||
.command_str = CMM_SESSION_COMMAND}) != 0) {
|
||||
rc = CLI_ERR;
|
||||
}
|
||||
}
|
||||
@@ -1742,27 +1782,39 @@ static int cbm_remove_session_hooks(const char *settings_path) {
|
||||
static const char *matchers[] = {"startup", "resume", "clear", "compact"};
|
||||
int rc = 0;
|
||||
for (int i = 0; i < NUM_DIRS; i++) {
|
||||
if (remove_hooks_json((hooks_remove_args_t){settings_path, "SessionStart", matchers[i]}) !=
|
||||
0) {
|
||||
if (remove_hooks_json((hooks_remove_args_t){.settings_path = settings_path,
|
||||
.hook_event = "SessionStart",
|
||||
.matcher_str = matchers[i]}) != 0) {
|
||||
rc = CLI_ERR;
|
||||
}
|
||||
}
|
||||
return rc;
|
||||
}
|
||||
|
||||
#define GEMINI_HOOK_MATCHER "google_search|read_file|grep_search"
|
||||
/* Matcher excludes read_file for consistency with the Claude fix: the hook
|
||||
* is an advisory reminder, not a gate over the agent's file reads. */
|
||||
#define GEMINI_HOOK_MATCHER "google_search|grep_search"
|
||||
#define GEMINI_HOOK_COMMAND \
|
||||
"echo 'Reminder: prefer codebase-memory-mcp search_graph/trace_path/" \
|
||||
"get_code_snippet over grep/file search for code discovery.' >&2"
|
||||
|
||||
int cbm_upsert_gemini_hooks(const char *settings_path) {
|
||||
return upsert_hooks_json((hooks_upsert_args_t){settings_path, "BeforeTool", GEMINI_HOOK_MATCHER,
|
||||
GEMINI_HOOK_COMMAND});
|
||||
return upsert_hooks_json((hooks_upsert_args_t){
|
||||
.settings_path = settings_path,
|
||||
.hook_event = "BeforeTool",
|
||||
.matcher_str = GEMINI_HOOK_MATCHER,
|
||||
.command_str = GEMINI_HOOK_COMMAND,
|
||||
.old_matchers = cmm_gemini_old_matchers,
|
||||
});
|
||||
}
|
||||
|
||||
int cbm_remove_gemini_hooks(const char *settings_path) {
|
||||
return remove_hooks_json(
|
||||
(hooks_remove_args_t){settings_path, "BeforeTool", GEMINI_HOOK_MATCHER});
|
||||
return remove_hooks_json((hooks_remove_args_t){
|
||||
.settings_path = settings_path,
|
||||
.hook_event = "BeforeTool",
|
||||
.matcher_str = GEMINI_HOOK_MATCHER,
|
||||
.old_matchers = cmm_gemini_old_matchers,
|
||||
});
|
||||
}
|
||||
|
||||
/* ── PATH management ──────────────────────────────────────────── */
|
||||
@@ -2654,11 +2706,11 @@ static void install_claude_code_config(const char *home, const char *binary_path
|
||||
snprintf(settings_path, sizeof(settings_path), "%s/.claude/settings.json", home);
|
||||
if (!dry_run) {
|
||||
cbm_upsert_claude_hooks(settings_path);
|
||||
cbm_install_hook_gate_script(home);
|
||||
cbm_install_hook_gate_script(home, binary_path);
|
||||
cbm_install_session_reminder_script(home);
|
||||
cbm_upsert_session_hooks(settings_path);
|
||||
}
|
||||
printf(" hooks: PreToolUse (code discovery gate)\n");
|
||||
printf(" hooks: PreToolUse (Grep/Glob search-graph augmenter, non-blocking)\n");
|
||||
printf(" hooks: SessionStart (MCP usage reminder on startup/resume/clear/compact)\n");
|
||||
}
|
||||
|
||||
|
||||
@@ -260,4 +260,10 @@ int cbm_cmd_update(int argc, char **argv);
|
||||
/* config: get/set/list/reset runtime config values. */
|
||||
int cbm_cmd_config(int argc, char **argv);
|
||||
|
||||
/* hook-augment: stdin-driven Claude Code PreToolUse augmenter.
|
||||
* Reads the hook JSON from stdin and emits hookSpecificOutput.additionalContext
|
||||
* with search_graph hits for Grep/Glob calls. NEVER blocks: every failure
|
||||
* path returns 0 with no stdout output. */
|
||||
int cbm_cmd_hook_augment(void);
|
||||
|
||||
#endif /* CBM_CLI_H */
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
/*
|
||||
* hook_augment.c — `codebase-memory-mcp hook-augment`
|
||||
*
|
||||
* A non-blocking Claude Code PreToolUse augmenter. Reads the hook JSON from
|
||||
* stdin, and for Grep/Glob calls injects matching graph symbols as
|
||||
* `additionalContext` so the agent gets structured context alongside its
|
||||
* normal search results.
|
||||
*
|
||||
* Cardinal rule: this NEVER blocks a tool call. Every error, timeout, missing
|
||||
* project, or short/odd pattern path results in `exit 0` with NO stdout
|
||||
* output (a clean pass-through). This is what makes issue #362 structurally
|
||||
* impossible to recur — the hook cannot deny a tool.
|
||||
*
|
||||
* The underlying query is `search_graph` (pure SQLite, shell-free) — chosen
|
||||
* over `search_code` (which shells out to grep|xargs) so the hook stays cheap
|
||||
* enough to run before every Grep/Glob.
|
||||
*/
|
||||
|
||||
#include "cli/cli.h"
|
||||
#include "foundation/mem.h"
|
||||
#include "mcp/mcp.h"
|
||||
#include "pipeline/pipeline.h"
|
||||
#include "yyjson/yyjson.h"
|
||||
|
||||
#include <ctype.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#ifndef _WIN32
|
||||
#include <signal.h>
|
||||
#include <sys/time.h>
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
#define HA_STDIN_CAP (256 * 1024) /* hook payloads are tiny; cap defensively */
|
||||
#define HA_MIN_TOKEN 4 /* skip short/noisy patterns before any work */
|
||||
#define HA_MAX_TOKEN 96
|
||||
#define HA_RESULT_LIMIT 5
|
||||
#define HA_MAX_WALKUP 8 /* cwd may be a subdir of the indexed root */
|
||||
#define HA_DEADLINE_MS 300 /* hard in-process budget (see also: the */
|
||||
/* settings.json "timeout" backstop) */
|
||||
|
||||
/* ── Hard deadline ────────────────────────────────────────────────
|
||||
* A slow SQLite open or query must never stall the agent. When the timer
|
||||
* fires we _exit(0) immediately. Output is written exactly once at the very
|
||||
* end, so firing mid-work simply yields a clean no-op (no partial JSON). */
|
||||
#ifndef _WIN32
|
||||
static void ha_deadline_exit(int sig) {
|
||||
(void)sig;
|
||||
_exit(0);
|
||||
}
|
||||
|
||||
static void ha_arm_deadline(void) {
|
||||
struct sigaction sa;
|
||||
memset(&sa, 0, sizeof(sa));
|
||||
sa.sa_handler = ha_deadline_exit;
|
||||
sigaction(SIGALRM, &sa, NULL);
|
||||
|
||||
struct itimerval it;
|
||||
memset(&it, 0, sizeof(it));
|
||||
it.it_value.tv_sec = HA_DEADLINE_MS / 1000;
|
||||
it.it_value.tv_usec = (HA_DEADLINE_MS % 1000) * 1000;
|
||||
setitimer(ITIMER_REAL, &it, NULL);
|
||||
}
|
||||
#else
|
||||
static void ha_arm_deadline(void) { /* Windows: rely on settings.json timeout */ }
|
||||
#endif
|
||||
|
||||
/* ── stdin ────────────────────────────────────────────────────────── */
|
||||
|
||||
static char *ha_read_stdin(void) {
|
||||
char *buf = malloc(HA_STDIN_CAP + 1);
|
||||
if (!buf) {
|
||||
return NULL;
|
||||
}
|
||||
size_t total = 0;
|
||||
size_t n;
|
||||
while (total < HA_STDIN_CAP &&
|
||||
(n = fread(buf + total, 1, HA_STDIN_CAP - total, stdin)) > 0) {
|
||||
total += n;
|
||||
}
|
||||
buf[total] = '\0';
|
||||
return buf;
|
||||
}
|
||||
|
||||
/* ── pattern → token ──────────────────────────────────────────────
|
||||
* Extract the longest identifier-like run ([A-Za-z_][A-Za-z0-9_]*) of at
|
||||
* least HA_MIN_TOKEN chars. Pure-identifier output means it is always safe
|
||||
* to embed in a regex (name_pattern) with no escaping. Returns false when
|
||||
* the pattern has no usable token (path globs, short/regex-only patterns) —
|
||||
* the caller then no-ops, which keeps the common cheap case cheap. */
|
||||
static bool ha_extract_token(const char *pattern, char *out, size_t out_sz) {
|
||||
if (!pattern) {
|
||||
return false;
|
||||
}
|
||||
size_t best_start = 0;
|
||||
size_t best_len = 0;
|
||||
size_t i = 0;
|
||||
while (pattern[i]) {
|
||||
if (isalpha((unsigned char)pattern[i]) || pattern[i] == '_') {
|
||||
size_t start = i;
|
||||
while (pattern[i] &&
|
||||
(isalnum((unsigned char)pattern[i]) || pattern[i] == '_')) {
|
||||
i++;
|
||||
}
|
||||
size_t len = i - start;
|
||||
if (len > best_len) {
|
||||
best_len = len;
|
||||
best_start = start;
|
||||
}
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
if (best_len < HA_MIN_TOKEN) {
|
||||
return false;
|
||||
}
|
||||
if (best_len > HA_MAX_TOKEN) {
|
||||
best_len = HA_MAX_TOKEN;
|
||||
}
|
||||
if (best_len + 1 > out_sz) {
|
||||
best_len = out_sz - 1;
|
||||
}
|
||||
memcpy(out, pattern + best_start, best_len);
|
||||
out[best_len] = '\0';
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ── JSON helpers ─────────────────────────────────────────────────── */
|
||||
|
||||
static const char *ha_obj_str(yyjson_val *obj, const char *key) {
|
||||
yyjson_val *v = obj ? yyjson_obj_get(obj, key) : NULL;
|
||||
return (v && yyjson_is_str(v)) ? yyjson_get_str(v) : NULL;
|
||||
}
|
||||
|
||||
/* Build the search_graph args JSON: {"project":..,"name_pattern":".*tok.*",
|
||||
* "limit":N}. `token` is a pure identifier so regex embedding is safe. */
|
||||
static char *ha_build_args(const char *project, const char *token) {
|
||||
yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL);
|
||||
yyjson_mut_val *root = yyjson_mut_obj(doc);
|
||||
yyjson_mut_doc_set_root(doc, root);
|
||||
|
||||
char name_pattern[HA_MAX_TOKEN + 8];
|
||||
snprintf(name_pattern, sizeof(name_pattern), ".*%s.*", token);
|
||||
|
||||
yyjson_mut_obj_add_str(doc, root, "project", project);
|
||||
yyjson_mut_obj_add_str(doc, root, "name_pattern", name_pattern);
|
||||
yyjson_mut_obj_add_int(doc, root, "limit", HA_RESULT_LIMIT);
|
||||
|
||||
char *out = yyjson_mut_write(doc, 0, NULL);
|
||||
yyjson_mut_doc_free(doc);
|
||||
return out; /* caller frees */
|
||||
}
|
||||
|
||||
/* Parse the MCP envelope returned by cbm_mcp_handle_tool and, if it is a
|
||||
* successful search_graph result with >=1 hit, format a compact
|
||||
* additionalContext string. Returns malloc'd text or NULL.
|
||||
*
|
||||
* *is_error is set when the envelope is an MCP error (e.g. project not
|
||||
* indexed) so the caller can try a parent directory. */
|
||||
static char *ha_format_context(const char *envelope, const char *token,
|
||||
bool *is_error) {
|
||||
*is_error = false;
|
||||
yyjson_doc *edoc = yyjson_read(envelope, strlen(envelope), 0);
|
||||
if (!edoc) {
|
||||
return NULL;
|
||||
}
|
||||
yyjson_val *eroot = yyjson_doc_get_root(edoc);
|
||||
yyjson_val *err = yyjson_obj_get(eroot, "isError");
|
||||
if (err && yyjson_is_true(err)) {
|
||||
*is_error = true;
|
||||
yyjson_doc_free(edoc);
|
||||
return NULL;
|
||||
}
|
||||
yyjson_val *content = yyjson_obj_get(eroot, "content");
|
||||
yyjson_val *item0 = (content && yyjson_is_arr(content))
|
||||
? yyjson_arr_get(content, 0)
|
||||
: NULL;
|
||||
const char *inner = ha_obj_str(item0, "text");
|
||||
if (!inner) {
|
||||
yyjson_doc_free(edoc);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
yyjson_doc *idoc = yyjson_read(inner, strlen(inner), 0);
|
||||
if (!idoc) {
|
||||
yyjson_doc_free(edoc);
|
||||
return NULL;
|
||||
}
|
||||
yyjson_val *iroot = yyjson_doc_get_root(idoc);
|
||||
yyjson_val *results = yyjson_obj_get(iroot, "results");
|
||||
size_t nres = (results && yyjson_is_arr(results)) ? yyjson_arr_size(results) : 0;
|
||||
if (nres == 0) {
|
||||
yyjson_doc_free(idoc);
|
||||
yyjson_doc_free(edoc);
|
||||
return NULL; /* valid project, just no matching symbols */
|
||||
}
|
||||
|
||||
char *text = malloc(4096);
|
||||
if (!text) {
|
||||
yyjson_doc_free(idoc);
|
||||
yyjson_doc_free(edoc);
|
||||
return NULL;
|
||||
}
|
||||
int off = snprintf(text, 4096,
|
||||
"[codebase-memory] %zu graph symbol(s) match \"%s\" "
|
||||
"(structured context; your search results below are "
|
||||
"unaffected):",
|
||||
nres, token);
|
||||
size_t idx;
|
||||
size_t maxn;
|
||||
yyjson_val *r;
|
||||
yyjson_arr_foreach(results, idx, maxn, r) {
|
||||
if (off < 0 || off >= 3900) {
|
||||
break;
|
||||
}
|
||||
const char *qn = ha_obj_str(r, "qualified_name");
|
||||
const char *nm = ha_obj_str(r, "name");
|
||||
const char *fp = ha_obj_str(r, "file_path");
|
||||
const char *lb = ha_obj_str(r, "label");
|
||||
const char *disp = (qn && qn[0]) ? qn : (nm ? nm : "");
|
||||
off += snprintf(text + off, (size_t)(4096 - off), "\n- %s %s%s%s",
|
||||
disp, fp ? fp : "",
|
||||
(lb && lb[0]) ? " " : "", (lb && lb[0]) ? lb : "");
|
||||
}
|
||||
|
||||
yyjson_doc_free(idoc);
|
||||
yyjson_doc_free(edoc);
|
||||
return text;
|
||||
}
|
||||
|
||||
/* Emit the PreToolUse additionalContext payload to stdout (exactly once). */
|
||||
static void ha_emit(const char *text) {
|
||||
yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL);
|
||||
yyjson_mut_val *root = yyjson_mut_obj(doc);
|
||||
yyjson_mut_doc_set_root(doc, root);
|
||||
yyjson_mut_val *hso = yyjson_mut_obj(doc);
|
||||
yyjson_mut_obj_add_str(doc, hso, "hookEventName", "PreToolUse");
|
||||
yyjson_mut_obj_add_str(doc, hso, "additionalContext", text);
|
||||
yyjson_mut_obj_add_val(doc, root, "hookSpecificOutput", hso);
|
||||
|
||||
char *json = yyjson_mut_write(doc, 0, NULL);
|
||||
if (json) {
|
||||
fputs(json, stdout);
|
||||
free(json);
|
||||
}
|
||||
yyjson_mut_doc_free(doc);
|
||||
}
|
||||
|
||||
/* Walk up from `start`, deriving a project name at each level and querying
|
||||
* search_graph until an indexed project is found (or the walk is exhausted).
|
||||
* Stops at the first non-error result: a valid project with zero hits is a
|
||||
* legitimate "no match" and must NOT cause a parent-directory probe. */
|
||||
static char *ha_resolve_and_query(cbm_mcp_server_t *srv, const char *start,
|
||||
const char *token) {
|
||||
char dir[4096];
|
||||
snprintf(dir, sizeof(dir), "%s", start);
|
||||
|
||||
for (int level = 0; level < HA_MAX_WALKUP && dir[0] == '/'; level++) {
|
||||
char *project = cbm_project_name_from_path(dir);
|
||||
if (project) {
|
||||
char *args = ha_build_args(project, token);
|
||||
free(project);
|
||||
if (args) {
|
||||
char *res = cbm_mcp_handle_tool(srv, "search_graph", args);
|
||||
free(args);
|
||||
if (res) {
|
||||
bool is_error = false;
|
||||
char *ctx = ha_format_context(res, token, &is_error);
|
||||
free(res);
|
||||
if (ctx) {
|
||||
return ctx; /* hits → done */
|
||||
}
|
||||
if (!is_error) {
|
||||
return NULL; /* valid project, no hits → stop */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/* Not indexed at this level — climb to the parent. */
|
||||
char *slash = strrchr(dir, '/');
|
||||
if (!slash || slash == dir) {
|
||||
break;
|
||||
}
|
||||
*slash = '\0';
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
int cbm_cmd_hook_augment(void) {
|
||||
ha_arm_deadline();
|
||||
|
||||
char *input = ha_read_stdin();
|
||||
if (!input) {
|
||||
return 0;
|
||||
}
|
||||
yyjson_doc *doc = yyjson_read(input, strlen(input), 0);
|
||||
if (!doc) {
|
||||
free(input);
|
||||
return 0;
|
||||
}
|
||||
yyjson_val *root = yyjson_doc_get_root(doc);
|
||||
|
||||
const char *tool = ha_obj_str(root, "tool_name");
|
||||
if (!tool || (strcmp(tool, "Grep") != 0 && strcmp(tool, "Glob") != 0)) {
|
||||
yyjson_doc_free(doc);
|
||||
free(input);
|
||||
return 0;
|
||||
}
|
||||
|
||||
yyjson_val *tin = yyjson_obj_get(root, "tool_input");
|
||||
const char *pattern = ha_obj_str(tin, "pattern");
|
||||
char token[HA_MAX_TOKEN + 1];
|
||||
if (!ha_extract_token(pattern, token, sizeof(token))) {
|
||||
yyjson_doc_free(doc);
|
||||
free(input);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const char *cwd = ha_obj_str(root, "cwd");
|
||||
#ifndef _WIN32
|
||||
char cwdbuf[4096];
|
||||
if (!cwd || cwd[0] != '/') {
|
||||
if (!getcwd(cwdbuf, sizeof(cwdbuf))) {
|
||||
yyjson_doc_free(doc);
|
||||
free(input);
|
||||
return 0;
|
||||
}
|
||||
cwd = cwdbuf;
|
||||
}
|
||||
#else
|
||||
/* Windows: Claude Code passes cwd in the hook payload. The walk-up loop
|
||||
* below requires POSIX-style absolute paths ('/'-prefixed), so without a
|
||||
* usable cwd there is nothing to augment — fail open cleanly. */
|
||||
if (!cwd || cwd[0] != '/') {
|
||||
yyjson_doc_free(doc);
|
||||
free(input);
|
||||
return 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||||
if (!srv) {
|
||||
yyjson_doc_free(doc);
|
||||
free(input);
|
||||
return 0;
|
||||
}
|
||||
|
||||
char *ctx = ha_resolve_and_query(srv, cwd, token);
|
||||
if (ctx) {
|
||||
ha_emit(ctx);
|
||||
free(ctx);
|
||||
}
|
||||
|
||||
cbm_mcp_server_free(srv);
|
||||
yyjson_doc_free(doc);
|
||||
free(input);
|
||||
return 0;
|
||||
}
|
||||
@@ -281,6 +281,10 @@ static int handle_subcommand(int argc, char **argv) {
|
||||
cbm_mem_init(MAIN_RAM_FRACTION);
|
||||
return run_cli(argc - i - SKIP_ONE, argv + i + SKIP_ONE);
|
||||
}
|
||||
if (strcmp(argv[i], "hook-augment") == 0) {
|
||||
cbm_mem_init(MAIN_RAM_FRACTION);
|
||||
return cbm_cmd_hook_augment();
|
||||
}
|
||||
if (strcmp(argv[i], "install") == 0) {
|
||||
return cbm_cmd_install(argc - i - SKIP_ONE, argv + i + SKIP_ONE);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user