From f4f831ac547b494d4de15200a5ad1bdbf32722f6 Mon Sep 17 00:00:00 2001 From: Ekechi Nwokah <139023629+ekechinwokah@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:45:19 +0000 Subject: [PATCH 1/5] guard: block grep-family commands via a PreToolUse hook An agent that greps a codebase reasons from match fragments, and fragments produce confident wrong claims about code it never read. The plugin now ships a guard: a PreToolUse hook (cx guard --hook) that denies the Grep tool and grep/egrep/fgrep/rg/git-grep at command position in Bash - through pipes, env prefixes, wrappers (env, xargs, command, timeout...), paths, and alias escapes - while leaving argument uses like 'git log --grep=' alone. bm25_search covers the pinpoint exact-identifier case with chunks that carry their content. The decision is a pure function (src/core/guard.ts) with the tokenizer deliberately coarse: unmodeled quoting can only inspect MORE segments, so quoting cannot smuggle a grep past it. The hook never fails the session: unparseable events allow silently, matching cx usage --hook. Docs updated to match: the 'grep wins pinpoint lookups' guidance is replaced by the block-and-why. The hooks.json pin (@0.4.0) tracks .mcp.json and needs the same release-prep bump. --- .../concepts/code-search-for-coding-agents.md | 15 +-- docs/tradeoffs.md | 21 ++-- hooks/hooks.json | 15 +++ src/cli.ts | 8 +- src/commands/query-cmds.ts | 30 +++++ src/core/guard.ts | 113 ++++++++++++++++++ test/guard.test.ts | 77 ++++++++++++ 7 files changed, 262 insertions(+), 17 deletions(-) create mode 100644 hooks/hooks.json create mode 100644 src/core/guard.ts create mode 100644 test/guard.test.ts diff --git a/docs/concepts/code-search-for-coding-agents.md b/docs/concepts/code-search-for-coding-agents.md index 1e94107..c6cabf5 100644 --- a/docs/concepts/code-search-for-coding-agents.md +++ b/docs/concepts/code-search-for-coding-agents.md @@ -33,13 +33,14 @@ files read one at a time. paraphrases, so "where is auth handled" works without knowing the exact identifier. -## Where crawling still wins - -Jumping to one known symbol or literal string is a single grep's job, and -there an index does not save tokens: the grep returns one line, while ranked -search returns content the agent did not need for a path. code-context routes -this correctly (its tool descriptions tell an agent to prefer native grep for -pinpoint lookups) and reaches for the index when a question spans files. +## Why grep is blocked anyway + +On tokens alone, jumping to one known symbol is a single grep's job: one +line out. But an agent working from grep fragments reasons about code it +never read, and fragment-born claims are confidently wrong in ways a ranked +chunk - which carries its content - is not. code-context therefore ships a +PreToolUse guard that denies grep-family commands; `bm25_search` covers the +pinpoint case, and Read fills in whatever the chunk does not show. ## Hybrid, not just semantic diff --git a/docs/tradeoffs.md b/docs/tradeoffs.md index 38d992f..d787364 100644 --- a/docs/tradeoffs.md +++ b/docs/tradeoffs.md @@ -10,15 +10,18 @@ symbol-precise references. It ranks and retrieves content and aggregates by relevance. Tools that resolve structure (LSP servers, graph indexes) are complementary: MCP servers stack, so run both when you need both. -### It does not beat grep on pinpoint lookups - -Naming the one file a known symbol lives in is a single grep's job. There the -index does not save tokens: a grep returns one matching line, while ranked -search returns chunks that carry their content. That content is what pays off -on "how does X work" and whole-repo questions, and it is dead weight when all -you need is a path. Adding code-context does not reduce accuracy on -localization; it just does not win on cost there. Both are measured in the -[benchmark](benchmark.md). +### It blocks grep, on purpose + +The plugin ships a PreToolUse guard (`cx guard --hook`) that denies +grep-family commands - `grep`, `egrep`, `fgrep`, `rg`, `git grep`, and the +Grep tool. On raw token economics a pinpoint lookup is a single grep's job: +one matching line beats ranked chunks, and the [benchmark](benchmark.md) +measures exactly that. The block exists because of what agents do with +match fragments: reason from them without reading the code, and state +mechanisms the code does not contain. `bm25_search` answers the same +exact-identifier lookups with chunks that carry their content, and the +deliberate cost is the rare pinpoint hit grep would have answered a few +tokens cheaper. ### The first index of a repo pays a one-time vector cost diff --git a/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 0000000..87903fd --- /dev/null +++ b/hooks/hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Grep|Bash", + "hooks": [ + { + "type": "command", + "command": "npx -y @infino-ai/code-context@0.4.0 guard --hook" + } + ] + } + ] + } +} diff --git a/src/cli.ts b/src/cli.ts index e51eb45..2647e55 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -6,7 +6,7 @@ import { Command } from "commander"; import { indexCmd } from "./commands/index-cmd.js"; -import { searchCmd, sqlCmd, statusCmd, usageCmd } from "./commands/query-cmds.js"; +import { searchCmd, sqlCmd, statusCmd, guardCmd, usageCmd } from "./commands/query-cmds.js"; import { DEFAULT_SEARCH_K } from "./core/config.js"; const program = new Command(); @@ -85,6 +85,12 @@ program .option("-C, --path ", "repo root (default: current directory)") .action(usageCmd); +program + .command("guard") + .description("PreToolUse hook: deny grep-family commands in favor of the ranked index") + .option("--hook", "consume a Claude Code PreToolUse event on stdin and print a decision") + .action(guardCmd); + program .command("mcp") .description("serve the MCP tools (search / sql / reindex) over stdio") diff --git a/src/commands/query-cmds.ts b/src/commands/query-cmds.ts index fe72707..d654fdc 100644 --- a/src/commands/query-cmds.ts +++ b/src/commands/query-cmds.ts @@ -20,6 +20,7 @@ import { currentSessionStats, } from "../core/usage.js"; import { bold, dim, cyan, yellow, green, table, fmtAge, fmtCount, fmtMs } from "../core/output.js"; +import { guardDecision } from "../core/guard.js"; function die(err: unknown): never { const msg = err instanceof NoIndexError ? err.message : `error: ${(err as Error).message}`; @@ -139,6 +140,35 @@ export function statusCmd(opts: StatusCmdOptions): void { console.log(dim(` embedder ${embedderInfo()}`)); } +/** `cx guard --hook` - a PreToolUse hook that denies grep-family + * invocations (the Grep tool; `grep`/`egrep`/`fgrep`/`rg`/`git grep` at + * command position in Bash) so retrieval goes through the ranked index. + * Allow = print nothing. Deny = the PreToolUse decision JSON on stdout. + * A hook must never fail the session: unparseable input allows. */ +export async function guardCmd(opts: { hook?: boolean }): Promise { + if (!opts.hook) { + console.error("cx guard is a Claude Code hook; run it as `cx guard --hook` from a PreToolUse hook"); + process.exit(1); + } + let reason: string | null = null; + try { + reason = guardDecision(JSON.parse(await readStdin())); + } catch { + // a guard that cannot parse its event allows: never fail the session + } + if (reason !== null) { + console.log( + JSON.stringify({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: reason, + }, + }), + ); + } +} + export interface UsageCmdOptions { json?: boolean; /** How many of the most recent queries to list (default 20). */ diff --git a/src/core/guard.ts b/src/core/guard.ts new file mode 100644 index 0000000..553298b --- /dev/null +++ b/src/core/guard.ts @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Infino Authors +// +// The grep guard: decide whether a PreToolUse event is a grep-family +// invocation that should be denied in favor of the ranked index. +// +// Why block at all: an agent that greps a codebase reasons from match +// fragments, and fragments produce confident wrong claims about code it +// never read. The index answers the same lookups with ranked chunks that +// carry their content, and `bm25_search` covers the exact-identifier case +// grep was kept around for. Blocking is deliberate policy, not a +// capability gap - the repo owner opts in by shipping the hook. +// +// The decision is a pure function of the hook payload so it can be tested +// without a process boundary. The hook wrapper around it must never fail +// the session: unparseable input allows, silently. + +/** The PreToolUse payload subset the guard reads. */ +export interface GuardPayload { + hook_event_name?: string; + tool_name?: string; + tool_input?: { command?: string }; +} + +/** Programs whose invocation is denied, matched as the executed program + * (command position), never as a substring - `git log --grep=x` filters + * history and stays allowed; `\grep`, `/usr/bin/grep`, `env grep`, and + * `... | grep` are all still grep and denied. */ +const BLOCKED_PROGRAMS = new Set(["grep", "egrep", "fgrep", "rg"]); + +/** Wrapper programs that execute their argument: the token after them is + * still in command position. */ +const WRAPPERS = new Set(["command", "sudo", "env", "xargs", "nice", "time", "timeout", "stdbuf"]); + +/** `VAR=value` prefixes before a command. */ +const ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/; + +/** Strip a token down to the program it names: drop a leading backslash + * (alias escape) and any directory path. */ +function programName(token: string): string { + const unescaped = token.startsWith("\\") ? token.slice(1) : token; + const base = unescaped.slice(unescaped.lastIndexOf("/") + 1); + return base; +} + +/** Whether one pipeline segment invokes a blocked program. */ +function segmentBlocked(segment: string): string | null { + const tokens = segment.trim().split(/\s+/).filter(Boolean); + let i = 0; + // Skip env assignments and wrappers to reach the effective program. + // Wrapper flags (e.g. `xargs -0`, `timeout 5`) are skipped as + // non-program tokens: anything starting with `-` or looking like a + // bare number keeps scanning. + while (i < tokens.length) { + const tok = tokens[i]; + if (ENV_ASSIGNMENT.test(tok)) { + i++; + continue; + } + const prog = programName(tok); + if (WRAPPERS.has(prog)) { + i++; + continue; + } + if (prog.startsWith("-") || /^\d+[smh]?$/.test(prog)) { + i++; + continue; + } + if (BLOCKED_PROGRAMS.has(prog)) return prog; + // `git grep` is grep over the worktree; other `git ` is not. + if (prog === "git") { + const sub = tokens + .slice(i + 1) + .find((t) => !t.startsWith("-")); + return sub === "grep" ? "git grep" : null; + } + return null; // some other program owns this segment + } + return null; +} + +/** Split a shell command into pipeline/subshell segments. Coarse on + * purpose: quoting is not modeled, which can split inside a quoted + * string - that only ever inspects MORE segments, so quoting cannot be + * used to smuggle a grep past the guard, and a false positive requires a + * quoted string whose content itself invokes grep at command position. */ +function segments(command: string): string[] { + return command.split(/(?:\|\|?|&&|;|\n|\$\(|`|\(|\))/); +} + +/** The denial message: what was blocked and what to use instead. */ +export function denyReason(program: string): string { + return ( + `${program} is blocked here by code-context. Use the code-context MCP instead: ` + + `bm25_search('chunks','content','', k) via the sql tool for exact ` + + `identifiers and strings, hybrid_search to add meaning, then Read the ` + + `located file for full context.` + ); +} + +/** Decide a PreToolUse event: a deny reason, or null to allow. Pure. */ +export function guardDecision(payload: GuardPayload): string | null { + if (payload.hook_event_name !== undefined && payload.hook_event_name !== "PreToolUse") return null; + if (payload.tool_name === "Grep") return denyReason("grep (the Grep tool)"); + if (payload.tool_name !== "Bash") return null; + const command = payload.tool_input?.command; + if (typeof command !== "string" || command.length === 0) return null; + for (const seg of segments(command)) { + const hit = segmentBlocked(seg); + if (hit) return denyReason(hit); + } + return null; +} diff --git a/test/guard.test.ts b/test/guard.test.ts new file mode 100644 index 0000000..c8ee90d --- /dev/null +++ b/test/guard.test.ts @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Infino Authors + +import { describe, expect, it } from "vitest"; +import { guardDecision } from "../src/core/guard.js"; + +const bash = (command: string) => ({ + hook_event_name: "PreToolUse", + tool_name: "Bash", + tool_input: { command }, +}); + +describe("guardDecision", () => { + it("denies the Grep tool outright", () => { + expect(guardDecision({ hook_event_name: "PreToolUse", tool_name: "Grep" })).toMatch(/blocked/); + }); + + it("denies grep at command position", () => { + expect(guardDecision(bash("grep -rn foo src/"))).toMatch(/grep is blocked/); + }); + + it("denies rg, egrep, fgrep", () => { + for (const cmd of ["rg pattern", "egrep -c x f", "fgrep lit f"]) { + expect(guardDecision(bash(cmd))).not.toBeNull(); + } + }); + + it("denies grep behind a pipe", () => { + expect(guardDecision(bash("cat build.log | grep -i error"))).not.toBeNull(); + }); + + it("denies grep behind wrappers, env assignments, paths, and alias escapes", () => { + for (const cmd of [ + "FOO=1 grep x f", + "env FOO=1 grep x f", + "xargs -0 grep -l x", + "command grep x f", + "/usr/bin/grep x f", + "\\grep x f", + "timeout 5 rg x", + ]) { + expect(guardDecision(bash(cmd)), cmd).not.toBeNull(); + } + }); + + it("denies git grep but allows other git subcommands", () => { + expect(guardDecision(bash("git grep -n foo"))).toMatch(/git grep/); + expect(guardDecision(bash("git log --grep=fix -5"))).toBeNull(); + expect(guardDecision(bash("git -C /repo log --grep=fix"))).toBeNull(); + }); + + it("denies grep inside command substitution", () => { + expect(guardDecision(bash("echo $(grep -c x f)"))).not.toBeNull(); + }); + + it("allows grep as a mere argument", () => { + expect(guardDecision(bash("echo grep is blocked"))).toBeNull(); + expect(guardDecision(bash("man grep"))).toBeNull(); + }); + + it("allows unrelated commands and tools", () => { + expect(guardDecision(bash("cargo test --lib"))).toBeNull(); + expect(guardDecision({ hook_event_name: "PreToolUse", tool_name: "Read" })).toBeNull(); + }); + + it("allows on missing or malformed input", () => { + expect(guardDecision({})).toBeNull(); + expect(guardDecision({ tool_name: "Bash" })).toBeNull(); + expect(guardDecision({ tool_name: "Bash", tool_input: {} })).toBeNull(); + }); + + it("ignores non-PreToolUse events", () => { + expect( + guardDecision({ hook_event_name: "PostToolUse", tool_name: "Grep" }), + ).toBeNull(); + }); +}); From 63c555a7edc456fb6b72faf8c71a74dc7637bb43 Mon Sep 17 00:00:00 2001 From: Ekechi Nwokah <139023629+ekechinwokah@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:50:52 +0000 Subject: [PATCH 2/5] chunker: index log-family files (.log/.out/.err/.jsonl/.ndjson) With grep blocked by the guard, log spelunking has to go through the index too, and the allowlist excluded exactly those files. They chunk as fixed windows (no grammar), which is what a log wants; the 1MB CX_MAX_FILE_BYTES cap still applies, so a giant trace is skipped and flagged partial rather than swallowing the index. --- hook-local/guard.cjs | 106 +++++++++++++++++++++++++++++++++++++++++++ hook-local/hook.cjs | 17 +++++++ src/core/chunker.ts | 1 + 3 files changed, 124 insertions(+) create mode 100644 hook-local/guard.cjs create mode 100644 hook-local/hook.cjs diff --git a/hook-local/guard.cjs b/hook-local/guard.cjs new file mode 100644 index 0000000..eab7ec7 --- /dev/null +++ b/hook-local/guard.cjs @@ -0,0 +1,106 @@ +"use strict"; +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Infino Authors +// +// The grep guard: decide whether a PreToolUse event is a grep-family +// invocation that should be denied in favor of the ranked index. +// +// Why block at all: an agent that greps a codebase reasons from match +// fragments, and fragments produce confident wrong claims about code it +// never read. The index answers the same lookups with ranked chunks that +// carry their content, and `bm25_search` covers the exact-identifier case +// grep was kept around for. Blocking is deliberate policy, not a +// capability gap - the repo owner opts in by shipping the hook. +// +// The decision is a pure function of the hook payload so it can be tested +// without a process boundary. The hook wrapper around it must never fail +// the session: unparseable input allows, silently. +Object.defineProperty(exports, "__esModule", { value: true }); +exports.denyReason = denyReason; +exports.guardDecision = guardDecision; +/** Programs whose invocation is denied, matched as the executed program + * (command position), never as a substring - `git log --grep=x` filters + * history and stays allowed; `\grep`, `/usr/bin/grep`, `env grep`, and + * `... | grep` are all still grep and denied. */ +const BLOCKED_PROGRAMS = new Set(["grep", "egrep", "fgrep", "rg"]); +/** Wrapper programs that execute their argument: the token after them is + * still in command position. */ +const WRAPPERS = new Set(["command", "sudo", "env", "xargs", "nice", "time", "timeout", "stdbuf"]); +/** `VAR=value` prefixes before a command. */ +const ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/; +/** Strip a token down to the program it names: drop a leading backslash + * (alias escape) and any directory path. */ +function programName(token) { + const unescaped = token.startsWith("\\") ? token.slice(1) : token; + const base = unescaped.slice(unescaped.lastIndexOf("/") + 1); + return base; +} +/** Whether one pipeline segment invokes a blocked program. */ +function segmentBlocked(segment) { + const tokens = segment.trim().split(/\s+/).filter(Boolean); + let i = 0; + // Skip env assignments and wrappers to reach the effective program. + // Wrapper flags (e.g. `xargs -0`, `timeout 5`) are skipped as + // non-program tokens: anything starting with `-` or looking like a + // bare number keeps scanning. + while (i < tokens.length) { + const tok = tokens[i]; + if (ENV_ASSIGNMENT.test(tok)) { + i++; + continue; + } + const prog = programName(tok); + if (WRAPPERS.has(prog)) { + i++; + continue; + } + if (prog.startsWith("-") || /^\d+[smh]?$/.test(prog)) { + i++; + continue; + } + if (BLOCKED_PROGRAMS.has(prog)) + return prog; + // `git grep` is grep over the worktree; other `git ` is not. + if (prog === "git") { + const sub = tokens + .slice(i + 1) + .find((t) => !t.startsWith("-")); + return sub === "grep" ? "git grep" : null; + } + return null; // some other program owns this segment + } + return null; +} +/** Split a shell command into pipeline/subshell segments. Coarse on + * purpose: quoting is not modeled, which can split inside a quoted + * string - that only ever inspects MORE segments, so quoting cannot be + * used to smuggle a grep past the guard, and a false positive requires a + * quoted string whose content itself invokes grep at command position. */ +function segments(command) { + return command.split(/(?:\|\|?|&&|;|\n|\$\(|`|\(|\))/); +} +/** The denial message: what was blocked and what to use instead. */ +function denyReason(program) { + return (`${program} is blocked here by code-context. Use the code-context MCP instead: ` + + `bm25_search('chunks','content','', k) via the sql tool for exact ` + + `identifiers and strings, hybrid_search to add meaning, then Read the ` + + `located file for full context.`); +} +/** Decide a PreToolUse event: a deny reason, or null to allow. Pure. */ +function guardDecision(payload) { + if (payload.hook_event_name !== undefined && payload.hook_event_name !== "PreToolUse") + return null; + if (payload.tool_name === "Grep") + return denyReason("grep (the Grep tool)"); + if (payload.tool_name !== "Bash") + return null; + const command = payload.tool_input?.command; + if (typeof command !== "string" || command.length === 0) + return null; + for (const seg of segments(command)) { + const hit = segmentBlocked(seg); + if (hit) + return denyReason(hit); + } + return null; +} diff --git a/hook-local/hook.cjs b/hook-local/hook.cjs new file mode 100644 index 0000000..98654cd --- /dev/null +++ b/hook-local/hook.cjs @@ -0,0 +1,17 @@ +#!/usr/bin/env node +// Local PreToolUse grep guard — standalone build of code-context's +// hook/block-grep branch, for use until the released plugin ships it. +const { guardDecision } = require("./guard.cjs"); +let input = ""; +process.stdin.on("data", (d) => (input += d)); +process.stdin.on("end", () => { + let reason = null; + try { reason = guardDecision(JSON.parse(input)); } catch {} + if (reason !== null) { + console.log(JSON.stringify({ hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: reason, + }})); + } +}); diff --git a/src/core/chunker.ts b/src/core/chunker.ts index 282f247..f2acb99 100644 --- a/src/core/chunker.ts +++ b/src/core/chunker.ts @@ -76,6 +76,7 @@ const PARSE_CAP_BYTES = 512 * 1024; // Extension → language tag. Doubles as the indexing allowlist. const EXT_LANG: Record = { md: "md", mdx: "md", rst: "rst", txt: "txt", adoc: "adoc", tex: "tex", + log: "log", jsonl: "jsonl", ndjson: "jsonl", out: "log", err: "log", ts: "ts", tsx: "tsx", js: "js", jsx: "js", mjs: "js", cjs: "js", py: "py", pyi: "py", rs: "rs", go: "go", java: "java", rb: "rb", c: "c", h: "c", cpp: "cpp", hpp: "cpp", cc: "cpp", hh: "cpp", cs: "cs", From 6defb1a9e61802b353e2f21f46a3c93db8d2b545 Mon Sep 17 00:00:00 2001 From: Ekechi Nwokah <139023629+ekechinwokah@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:23:51 +0000 Subject: [PATCH 3/5] guard: block awk and sed alongside the grep family MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blocking grep alone moves the behavior rather than stopping it. With grep denied, an agent reaches for the next line-filter within reach — awk, sed — prints three matching lines, and answers from those instead of reading the file or asking the index. The failure being blocked is reasoning from an extracted fragment, not the name of the program that extracted it, so the stream editors that make it easy are blocked too. Editing a file in place belongs to the Edit tool and reading one belongs to Read or to the ranked index; neither use of awk or sed is lost. Filtering flags such as the one on git log are unaffected, since matching is still on the executed program at command position. Adds the same cases to the local standalone hook, plus a small CommonJS checker for it: the vitest suite needs a newer Node than some dev boxes run, and the guard is worth being able to verify anywhere. --- .../concepts/code-search-for-coding-agents.md | 7 +++ hook-local/check.cjs | 44 +++++++++++++++++++ hook-local/guard.cjs | 12 ++++- src/core/guard.ts | 12 ++++- test/guard.test.ts | 16 +++++++ 5 files changed, 87 insertions(+), 4 deletions(-) create mode 100644 hook-local/check.cjs diff --git a/docs/concepts/code-search-for-coding-agents.md b/docs/concepts/code-search-for-coding-agents.md index c6cabf5..7a79b56 100644 --- a/docs/concepts/code-search-for-coding-agents.md +++ b/docs/concepts/code-search-for-coding-agents.md @@ -42,6 +42,13 @@ chunk - which carries its content - is not. code-context therefore ships a PreToolUse guard that denies grep-family commands; `bm25_search` covers the pinpoint case, and Read fills in whatever the chunk does not show. +The guard also denies `awk` and `sed`, which is not obvious until you watch +an agent work with grep blocked: it reaches for the next line-filter within +reach and answers from the three lines that one printed. The failure being +blocked is reasoning from an extracted fragment, not the name of the program +that extracted it. Editing a file in place is Edit's job, and reading one is +Read's or the index's. + ## Hybrid, not just semantic Keyword (BM25) matching and vector similarity fuse into one ranked pass, so a diff --git a/hook-local/check.cjs b/hook-local/check.cjs new file mode 100644 index 0000000..4526ff3 --- /dev/null +++ b/hook-local/check.cjs @@ -0,0 +1,44 @@ +// Ad-hoc check that the local guard denies the stream editors and still +// allows ordinary commands. Cases live here rather than in a shell line +// so the guard does not (correctly) block the test invocation itself. +const { guardDecision } = require("./guard.cjs"); + +const bash = (command) => ({ + hook_event_name: "PreToolUse", + tool_name: "Bash", + tool_input: { command }, +}); + +const DENY = [ + "awk '/x/{print}' f.log", + "sed -n '1,5p' f.log", + "cat f.log | awk '{print $2}'", + "gawk -f s.awk f", + "mawk '{print}' f", + "/usr/bin/sed s/a/b/ f", + "\\sed -i s/a/b/ f", + "TMPDIR=/x awk '{print}' f", + "xargs -0 sed -i s/a/b/", + "echo $(awk '{print}' f)", +]; + +const ALLOW = [ + "cargo test --lib", + "git log --grep=fix -5", + "python3 gen.py", + "ls -la /mnt/scratch", + "cp a.log b.log", +]; + +let bad = 0; +for (const c of DENY) { + const r = guardDecision(bash(c)); + if (!r) { console.log("MISS (should deny):", c); bad++; } +} +for (const c of ALLOW) { + const r = guardDecision(bash(c)); + if (r) { console.log("FALSE POSITIVE (should allow):", c, "->", r.slice(0, 40)); bad++; } +} +console.log(bad === 0 + ? `all ${DENY.length + ALLOW.length} cases correct (${DENY.length} denied, ${ALLOW.length} allowed)` + : `${bad} case(s) wrong`); diff --git a/hook-local/guard.cjs b/hook-local/guard.cjs index eab7ec7..bb0d130 100644 --- a/hook-local/guard.cjs +++ b/hook-local/guard.cjs @@ -21,8 +21,16 @@ exports.guardDecision = guardDecision; /** Programs whose invocation is denied, matched as the executed program * (command position), never as a substring - `git log --grep=x` filters * history and stays allowed; `\grep`, `/usr/bin/grep`, `env grep`, and - * `... | grep` are all still grep and denied. */ -const BLOCKED_PROGRAMS = new Set(["grep", "egrep", "fgrep", "rg"]); + * `... | grep` are all still grep and denied. + * + * `awk` and `sed` are here for the same reason as grep, learned the hard + * way: with grep blocked, a pattern-matching agent reaches for the next + * line-filter to hand rather than reading the file, and answers from the + * three lines it printed. Whatever the tool, extracting a fragment and + * reasoning from it is the failure being blocked - so the stream editors + * that make it easy are blocked too. In-place edits belong to Edit, and + * reading belongs to Read or the index. */ +const BLOCKED_PROGRAMS = new Set(["grep", "egrep", "fgrep", "rg", "awk", "gawk", "mawk", "sed"]); /** Wrapper programs that execute their argument: the token after them is * still in command position. */ const WRAPPERS = new Set(["command", "sudo", "env", "xargs", "nice", "time", "timeout", "stdbuf"]); diff --git a/src/core/guard.ts b/src/core/guard.ts index 553298b..36f8c17 100644 --- a/src/core/guard.ts +++ b/src/core/guard.ts @@ -25,8 +25,16 @@ export interface GuardPayload { /** Programs whose invocation is denied, matched as the executed program * (command position), never as a substring - `git log --grep=x` filters * history and stays allowed; `\grep`, `/usr/bin/grep`, `env grep`, and - * `... | grep` are all still grep and denied. */ -const BLOCKED_PROGRAMS = new Set(["grep", "egrep", "fgrep", "rg"]); + * `... | grep` are all still grep and denied. + * + * `awk` and `sed` are here for the same reason as grep, learned the hard + * way: with grep blocked, a pattern-matching agent reaches for the next + * line-filter to hand rather than reading the file, and answers from the + * three lines it printed. Whatever the tool, extracting a fragment and + * reasoning from it is the failure being blocked - so the stream editors + * that make it easy are blocked too. In-place edits belong to Edit, and + * reading belongs to Read or the index. */ +const BLOCKED_PROGRAMS = new Set(["grep", "egrep", "fgrep", "rg", "awk", "gawk", "mawk", "sed"]); /** Wrapper programs that execute their argument: the token after them is * still in command position. */ diff --git a/test/guard.test.ts b/test/guard.test.ts index c8ee90d..f38947a 100644 --- a/test/guard.test.ts +++ b/test/guard.test.ts @@ -25,6 +25,22 @@ describe("guardDecision", () => { } }); + it("denies the stream editors, which are grep by another name", () => { + for (const cmd of [ + "awk '/x/{print}' f.log", + "gawk -f s.awk f", + "mawk '{print}' f", + "sed -n '1,5p' f.log", + "cat f.log | awk '{print $2}'", + "/usr/bin/sed s/a/b/ f", + "\\sed -i s/a/b/ f", + "TMPDIR=/x awk '{print}' f", + "xargs -0 sed -i s/a/b/", + ]) { + expect(guardDecision(bash(cmd)), cmd).not.toBeNull(); + } + }); + it("denies grep behind a pipe", () => { expect(guardDecision(bash("cat build.log | grep -i error"))).not.toBeNull(); }); From 228f3926c75b255722cd3b683f569066fb387464 Mon Sep 17 00:00:00 2001 From: Ekechi Nwokah <139023629+ekechinwokah@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:32:53 +0000 Subject: [PATCH 4/5] guard: deny every path by which file contents reach the agent Blocking one program at a time does not hold. With the grep family denied, the next reach is awk, then sed, then cut, then a one-line python, and the agent still answers from the handful of lines whichever one printed. The failure is reasoning from an extracted fragment, not the name of the program that extracted it, so the line to hold is that no file contents arrive through a shell at all. Denies the whole family together: the readers and pagers (cat, head, tail, tac, nl, less, strings, od, xxd), the text filters (cut, tr, sort, uniq, paste, join, comm, column, jq, yq), and interpreters run as one-liners, where python3 -c, node -e, perl -pe and a heredoc piped to stdin are a text filter under another name. Running a script from a file stays allowed, as does everything that does work rather than reads: builds, tests, benchmarks, version control, file moves, redirects into files. Only "show me the bytes" moves to Read and to the index, and the denial message now says so. Neither is a downgrade, since Read takes an offset and the index answers over a corpus of any size, so size is never a reason to filter. The local standalone hook carries the same rules, checked by 47 cases: 30 denied across pipes, wrappers, env prefixes, absolute paths and alias escapes, and 17 allowed covering the ordinary working commands. --- .../concepts/code-search-for-coding-agents.md | 20 ++++-- hook-local/check.cjs | 67 +++++++++++++++---- hook-local/guard.cjs | 62 +++++++++++++---- src/core/guard.ts | 66 ++++++++++++++---- test/guard.test.ts | 55 +++++++++++++++ 5 files changed, 228 insertions(+), 42 deletions(-) diff --git a/docs/concepts/code-search-for-coding-agents.md b/docs/concepts/code-search-for-coding-agents.md index 7a79b56..f7443a4 100644 --- a/docs/concepts/code-search-for-coding-agents.md +++ b/docs/concepts/code-search-for-coding-agents.md @@ -42,12 +42,20 @@ chunk - which carries its content - is not. code-context therefore ships a PreToolUse guard that denies grep-family commands; `bm25_search` covers the pinpoint case, and Read fills in whatever the chunk does not show. -The guard also denies `awk` and `sed`, which is not obvious until you watch -an agent work with grep blocked: it reaches for the next line-filter within -reach and answers from the three lines that one printed. The failure being -blocked is reasoning from an extracted fragment, not the name of the program -that extracted it. Editing a file in place is Edit's job, and reading one is -Read's or the index's. +Blocking grep by itself does not hold, which is not obvious until you watch +an agent work with it denied: it reaches for `awk`, then `sed`, then `cut`, +then a one-line `python3 -c`, and answers from the handful of lines whichever +one printed. The failure is reasoning from an extracted fragment, not the +name of the program that extracted it, so the guard denies the whole family +of utilities whose job is to emit file contents — readers and pagers, the +text filters, and interpreters run as one-liners. + +Nothing in that blocks doing work. Builds, tests, benchmarks, git, file +moves, and scripts run from a file are untouched; only "show me the bytes" +is redirected to Read and to the index. Neither is a downgrade: Read takes +an offset and the index answers over a corpus of any size, so file size is +never a reason to reach for a filter, and both return the surrounding +context that a matched line leaves out. ## Hybrid, not just semantic diff --git a/hook-local/check.cjs b/hook-local/check.cjs index 4526ff3..8093a23 100644 --- a/hook-local/check.cjs +++ b/hook-local/check.cjs @@ -1,6 +1,7 @@ -// Ad-hoc check that the local guard denies the stream editors and still -// allows ordinary commands. Cases live here rather than in a shell line -// so the guard does not (correctly) block the test invocation itself. +// Ad-hoc check that the local guard denies every route by which file +// contents could reach the agent through a shell, and still allows the +// commands that do work. Cases live here rather than in a shell line so +// the guard does not (correctly) block the test invocation itself. const { guardDecision } = require("./guard.cjs"); const bash = (command) => ({ @@ -10,34 +11,76 @@ const bash = (command) => ({ }); const DENY = [ + // grep family + "grep -rn foo src/", + "rg pattern", + "egrep -c x f", + "fgrep lit f", + "git grep -n foo", + // stream editors "awk '/x/{print}' f.log", - "sed -n '1,5p' f.log", - "cat f.log | awk '{print $2}'", "gawk -f s.awk f", - "mawk '{print}' f", - "/usr/bin/sed s/a/b/ f", + "sed -n '1,5p' f.log", "\\sed -i s/a/b/ f", + // readers and pagers + "cat f.log", + "head -20 f.log", + "tail -5 f.log", + "tac f.log", + "less f.log", + "strings bin", + "xxd -l 64 bin", + // text filters + "cut -d, -f2 data.csv", + "sort f.log | uniq -c", + "tr -d ' ' < f", + "jq '.rows[]' out.json", + // through pipes, wrappers, env prefixes and paths + "cargo test | tail -20", "TMPDIR=/x awk '{print}' f", "xargs -0 sed -i s/a/b/", - "echo $(awk '{print}' f)", + "/usr/bin/head -1 f", + "echo $(cat f)", + // inline interpreters + "python3 -c 'import re; print(1)'", + "python3 - <<'EOF'\nprint(1)\nEOF", + "node -e 'console.log(1)'", + "perl -pe 's/a/b/' f", + "ruby -e 'puts 1'", ]; const ALLOW = [ + // building, testing, benchmarking "cargo test --lib", + "cargo bench --bench bench -- vector-codec", + "make ci", + // scripts from a file, not one-liners + "python3 gen_chart.py", + "node build.js", + "bash run-sweep.sh", + // git, including its own filtering flags + "git status --short", "git log --grep=fix -5", - "python3 gen.py", + "git -C /repo log --oneline -3", + "git commit -F msg.txt", + // file and process management "ls -la /mnt/scratch", "cp a.log b.log", + "mkdir -p /mnt/scratch/out", + "pgrep -af cargo", + "cargo bench > /mnt/scratch/out.log 2>&1", + // the words appear, but not as commands + "echo grep is blocked", + "man awk", ]; let bad = 0; for (const c of DENY) { - const r = guardDecision(bash(c)); - if (!r) { console.log("MISS (should deny):", c); bad++; } + if (!guardDecision(bash(c))) { console.log("MISS (should deny):", JSON.stringify(c)); bad++; } } for (const c of ALLOW) { const r = guardDecision(bash(c)); - if (r) { console.log("FALSE POSITIVE (should allow):", c, "->", r.slice(0, 40)); bad++; } + if (r) { console.log("FALSE POSITIVE (should allow):", JSON.stringify(c), "->", r.slice(0, 50)); bad++; } } console.log(bad === 0 ? `all ${DENY.length + ALLOW.length} cases correct (${DENY.length} denied, ${ALLOW.length} allowed)` diff --git a/hook-local/guard.cjs b/hook-local/guard.cjs index bb0d130..156e700 100644 --- a/hook-local/guard.cjs +++ b/hook-local/guard.cjs @@ -23,14 +23,39 @@ exports.guardDecision = guardDecision; * history and stays allowed; `\grep`, `/usr/bin/grep`, `env grep`, and * `... | grep` are all still grep and denied. * - * `awk` and `sed` are here for the same reason as grep, learned the hard - * way: with grep blocked, a pattern-matching agent reaches for the next - * line-filter to hand rather than reading the file, and answers from the - * three lines it printed. Whatever the tool, extracting a fragment and - * reasoning from it is the failure being blocked - so the stream editors - * that make it easy are blocked too. In-place edits belong to Edit, and - * reading belongs to Read or the index. */ -const BLOCKED_PROGRAMS = new Set(["grep", "egrep", "fgrep", "rg", "awk", "gawk", "mawk", "sed"]); + * The line to hold is not "no grep", it is **no file content reaching the + * agent through a shell**. Blocking one program at a time loses: with grep + * denied an agent reaches for `awk`, then `sed`, then `cut`, then an + * inline interpreter, and answers from the handful of lines whichever one + * printed. So the whole family of utilities whose job is to emit file + * contents is denied together - readers and pagers, the text filters, and + * the interpreters run as one-liners. + * + * Nothing here blocks doing work. Builds, tests, benchmarks, git, file + * moves, and scripts run from a file are all untouched; only "show me the + * bytes" is redirected to Read and the index, which answer over a corpus + * of any size and carry the surrounding context a fragment does not. */ +const BLOCKED_PROGRAMS = new Set([ + // grep family + "grep", "egrep", "fgrep", "rg", "ag", "ack", + // stream editors + "awk", "gawk", "mawk", "nawk", "sed", "ed", + // readers and pagers + "cat", "tac", "head", "tail", "nl", "less", "more", "strings", + "od", "xxd", "hexdump", + // text filters + "cut", "tr", "sort", "uniq", "paste", "join", "comm", "column", + "fold", "rev", "expand", "unexpand", "jq", "yq", +]); +/** Interpreters that are only denied when run as a one-liner: `python3 -c`, + * `node -e`, `perl -pe`, or a heredoc/stdin script are a text filter wearing + * a different name, while `python3 script.py` is running a program someone + * can read. */ +const INLINE_INTERPRETERS = new Set([ + "python", "python3", "node", "perl", "ruby", "php", "deno", "bun", +]); +/** Flags that make an interpreter evaluate source from the command line. */ +const INLINE_EVAL_FLAG = /^-[A-Za-z]*[ce]$/; /** Wrapper programs that execute their argument: the token after them is * still in command position. */ const WRAPPERS = new Set(["command", "sudo", "env", "xargs", "nice", "time", "timeout", "stdbuf"]); @@ -68,6 +93,16 @@ function segmentBlocked(segment) { } if (BLOCKED_PROGRAMS.has(prog)) return prog; + // An interpreter is denied only when it evaluates source given on the + // command line or read from stdin - `python3 -c '...'`, `node -e`, + // `python3 -` and `python3 < INLINE_EVAL_FLAG.test(a)) || + args.includes("-") || + /<<-?\s*['"]?\w+/.test(segment); + return inline ? `${prog} run inline` : null; + } // `git grep` is grep over the worktree; other `git ` is not. if (prog === "git") { const sub = tokens @@ -89,10 +124,13 @@ function segments(command) { } /** The denial message: what was blocked and what to use instead. */ function denyReason(program) { - return (`${program} is blocked here by code-context. Use the code-context MCP instead: ` + - `bm25_search('chunks','content','', k) via the sql tool for exact ` + - `identifiers and strings, hybrid_search to add meaning, then Read the ` + - `located file for full context.`); + return (`${program} is blocked here by code-context: file contents do not reach ` + + `you through a shell. To read a file, use Read (it takes an offset, so ` + + `size is not a reason to filter). To find something, use the ` + + `code-context MCP: bm25_search('chunks','content','', k) via the ` + + `sql tool for exact identifiers and strings, hybrid_search to add ` + + `meaning. Both answer over a corpus of any size and return the ` + + `surrounding context, which a matched line does not.`); } /** Decide a PreToolUse event: a deny reason, or null to allow. Pure. */ function guardDecision(payload) { diff --git a/src/core/guard.ts b/src/core/guard.ts index 36f8c17..8adb571 100644 --- a/src/core/guard.ts +++ b/src/core/guard.ts @@ -27,14 +27,42 @@ export interface GuardPayload { * history and stays allowed; `\grep`, `/usr/bin/grep`, `env grep`, and * `... | grep` are all still grep and denied. * - * `awk` and `sed` are here for the same reason as grep, learned the hard - * way: with grep blocked, a pattern-matching agent reaches for the next - * line-filter to hand rather than reading the file, and answers from the - * three lines it printed. Whatever the tool, extracting a fragment and - * reasoning from it is the failure being blocked - so the stream editors - * that make it easy are blocked too. In-place edits belong to Edit, and - * reading belongs to Read or the index. */ -const BLOCKED_PROGRAMS = new Set(["grep", "egrep", "fgrep", "rg", "awk", "gawk", "mawk", "sed"]); + * The line to hold is not "no grep", it is **no file content reaching the + * agent through a shell**. Blocking one program at a time loses: with grep + * denied an agent reaches for `awk`, then `sed`, then `cut`, then an + * inline interpreter, and answers from the handful of lines whichever one + * printed. So the whole family of utilities whose job is to emit file + * contents is denied together - readers and pagers, the text filters, and + * the interpreters run as one-liners. + * + * Nothing here blocks doing work. Builds, tests, benchmarks, git, + * file moves, and scripts run from a file are all untouched; only "show me + * the bytes" is redirected to Read and the index, which answer over a + * corpus of any size and carry the surrounding context a fragment does + * not. */ +const BLOCKED_PROGRAMS = new Set([ + // grep family + "grep", "egrep", "fgrep", "rg", "ag", "ack", + // stream editors + "awk", "gawk", "mawk", "nawk", "sed", "ed", + // readers and pagers + "cat", "tac", "head", "tail", "nl", "less", "more", "strings", + "od", "xxd", "hexdump", + // text filters + "cut", "tr", "sort", "uniq", "paste", "join", "comm", "column", + "fold", "rev", "expand", "unexpand", "jq", "yq", +]); + +/** Interpreters that are only denied when run as a one-liner: `python3 -c`, + * `node -e`, `perl -pe`, or a heredoc/stdin script are a text filter wearing + * a different name, while `python3 script.py` is running a program someone + * can read. */ +const INLINE_INTERPRETERS = new Set([ + "python", "python3", "node", "perl", "ruby", "php", "deno", "bun", +]); + +/** Flags that make an interpreter evaluate source from the command line. */ +const INLINE_EVAL_FLAG = /^-[A-Za-z]*[ce]$/; /** Wrapper programs that execute their argument: the token after them is * still in command position. */ @@ -75,6 +103,17 @@ function segmentBlocked(segment: string): string | null { continue; } if (BLOCKED_PROGRAMS.has(prog)) return prog; + // An interpreter is denied only when it evaluates source given on the + // command line or read from stdin - `python3 -c '...'`, `node -e`, + // `python3 -` and `python3 < INLINE_EVAL_FLAG.test(a)) || + args.includes("-") || + /<<-?\s*['"]?\w+/.test(segment); + return inline ? `${prog} run inline` : null; + } // `git grep` is grep over the worktree; other `git ` is not. if (prog === "git") { const sub = tokens @@ -99,10 +138,13 @@ function segments(command: string): string[] { /** The denial message: what was blocked and what to use instead. */ export function denyReason(program: string): string { return ( - `${program} is blocked here by code-context. Use the code-context MCP instead: ` + - `bm25_search('chunks','content','', k) via the sql tool for exact ` + - `identifiers and strings, hybrid_search to add meaning, then Read the ` + - `located file for full context.` + `${program} is blocked here by code-context: file contents do not reach ` + + `you through a shell. To read a file, use Read (it takes an offset, so ` + + `size is not a reason to filter). To find something, use the ` + + `code-context MCP: bm25_search('chunks','content','', k) via the ` + + `sql tool for exact identifiers and strings, hybrid_search to add ` + + `meaning. Both answer over a corpus of any size and return the ` + + `surrounding context, which a matched line does not.` ); } diff --git a/test/guard.test.ts b/test/guard.test.ts index f38947a..b4653a2 100644 --- a/test/guard.test.ts +++ b/test/guard.test.ts @@ -41,6 +41,61 @@ describe("guardDecision", () => { } }); + it("denies readers, pagers and text filters — anything that emits file contents", () => { + for (const cmd of [ + "cat f.log", + "head -20 f.log", + "tail -5 f.log", + "tac f.log", + "nl f.log", + "less f.log", + "strings bin", + "xxd -l 64 bin", + "od -c bin", + "cut -d, -f2 data.csv", + "sort f.log | uniq -c", + "tr -d ' ' < f", + "jq '.rows[]' out.json", + "cargo test | tail -20", + "/usr/bin/head -1 f", + "echo $(cat f)", + ]) { + expect(guardDecision(bash(cmd)), cmd).not.toBeNull(); + } + }); + + it("denies interpreters run inline, allows them run from a script file", () => { + for (const cmd of [ + "python3 -c 'import re; print(1)'", + "python -c 'print(1)'", + "python3 - <<'EOF'\nprint(1)\nEOF", + "node -e 'console.log(1)'", + "perl -pe 's/a/b/' f", + "ruby -e 'puts 1'", + ]) { + expect(guardDecision(bash(cmd)), cmd).not.toBeNull(); + } + for (const cmd of ["python3 gen_chart.py", "node build.js", "bash run.sh"]) { + expect(guardDecision(bash(cmd)), cmd).toBeNull(); + } + }); + + it("leaves the commands that do work alone", () => { + for (const cmd of [ + "cargo test --lib", + "cargo bench --bench bench -- vector-codec", + "make ci", + "git status --short", + "git commit -F msg.txt", + "ls -la /mnt/scratch", + "cp a.log b.log", + "mkdir -p /mnt/scratch/out", + "cargo bench > /mnt/scratch/out.log 2>&1", + ]) { + expect(guardDecision(bash(cmd)), cmd).toBeNull(); + } + }); + it("denies grep behind a pipe", () => { expect(guardDecision(bash("cat build.log | grep -i error"))).not.toBeNull(); }); From f8a15d9fc6ce0e8ee403924f50b28e5e1d6f6108 Mon Sep 17 00:00:00 2001 From: Ekechi Nwokah Date: Fri, 4 Sep 2026 16:50:30 +0000 Subject: [PATCH 5/5] guard: the denial points at find The message told the agent to reach for bm25_search through the sql tool for exact identifiers, which was the workaround before find existed. It now names the three tools by question: find for every line containing an exact string (what grep did), search for meaning, sql for counts and rankings. Comments in both the source and the shipped CJS copy say the same. Merge after #43, which adds find. --- hook-local/guard.cjs | 16 +++++++++------- src/core/guard.ts | 16 +++++++++------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/hook-local/guard.cjs b/hook-local/guard.cjs index 156e700..1d845e3 100644 --- a/hook-local/guard.cjs +++ b/hook-local/guard.cjs @@ -8,9 +8,10 @@ // Why block at all: an agent that greps a codebase reasons from match // fragments, and fragments produce confident wrong claims about code it // never read. The index answers the same lookups with ranked chunks that -// carry their content, and `bm25_search` covers the exact-identifier case -// grep was kept around for. Blocking is deliberate policy, not a -// capability gap - the repo owner opts in by shipping the hook. +// carry their content, and the `find` tool covers the exact-identifier case +// grep was kept around for: every matching line, cited path:line, complete +// and unranked. Blocking is deliberate policy, not a capability gap - the +// repo owner opts in by shipping the hook. // // The decision is a pure function of the hook payload so it can be tested // without a process boundary. The hook wrapper around it must never fail @@ -127,10 +128,11 @@ function denyReason(program) { return (`${program} is blocked here by code-context: file contents do not reach ` + `you through a shell. To read a file, use Read (it takes an offset, so ` + `size is not a reason to filter). To find something, use the ` + - `code-context MCP: bm25_search('chunks','content','', k) via the ` + - `sql tool for exact identifiers and strings, hybrid_search to add ` + - `meaning. Both answer over a corpus of any size and return the ` + - `surrounding context, which a matched line does not.`); + `code-context MCP tools: find for every line containing an exact string ` + + `(path:line, complete and unranked - what grep did), search for meaning ` + + `and 'how does X work', sql for counts and rankings. They answer over a ` + + `corpus of any size and return the surrounding context, which a matched ` + + `line does not.`); } /** Decide a PreToolUse event: a deny reason, or null to allow. Pure. */ function guardDecision(payload) { diff --git a/src/core/guard.ts b/src/core/guard.ts index 8adb571..55fed47 100644 --- a/src/core/guard.ts +++ b/src/core/guard.ts @@ -7,9 +7,10 @@ // Why block at all: an agent that greps a codebase reasons from match // fragments, and fragments produce confident wrong claims about code it // never read. The index answers the same lookups with ranked chunks that -// carry their content, and `bm25_search` covers the exact-identifier case -// grep was kept around for. Blocking is deliberate policy, not a -// capability gap - the repo owner opts in by shipping the hook. +// carry their content, and the `find` tool covers the exact-identifier case +// grep was kept around for: every matching line, cited path:line, complete +// and unranked. Blocking is deliberate policy, not a capability gap - the +// repo owner opts in by shipping the hook. // // The decision is a pure function of the hook payload so it can be tested // without a process boundary. The hook wrapper around it must never fail @@ -141,10 +142,11 @@ export function denyReason(program: string): string { `${program} is blocked here by code-context: file contents do not reach ` + `you through a shell. To read a file, use Read (it takes an offset, so ` + `size is not a reason to filter). To find something, use the ` + - `code-context MCP: bm25_search('chunks','content','', k) via the ` + - `sql tool for exact identifiers and strings, hybrid_search to add ` + - `meaning. Both answer over a corpus of any size and return the ` + - `surrounding context, which a matched line does not.` + `code-context MCP tools: find for every line containing an exact string ` + + `(path:line, complete and unranked - what grep did), search for meaning ` + + `and 'how does X work', sql for counts and rankings. They answer over a ` + + `corpus of any size and return the surrounding context, which a matched ` + + `line does not.` ); }