diff --git a/docs/concepts/code-search-for-coding-agents.md b/docs/concepts/code-search-for-coding-agents.md index 1e94107..f7443a4 100644 --- a/docs/concepts/code-search-for-coding-agents.md +++ b/docs/concepts/code-search-for-coding-agents.md @@ -33,13 +33,29 @@ files read one at a time. paraphrases, so "where is auth handled" works without knowing the exact identifier. -## Where crawling still wins +## Why grep is blocked anyway -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. +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. + +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/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/hook-local/check.cjs b/hook-local/check.cjs new file mode 100644 index 0000000..8093a23 --- /dev/null +++ b/hook-local/check.cjs @@ -0,0 +1,87 @@ +// 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) => ({ + hook_event_name: "PreToolUse", + tool_name: "Bash", + tool_input: { 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", + "gawk -f s.awk 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/", + "/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", + "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) { + 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):", 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)` + : `${bad} case(s) wrong`); diff --git a/hook-local/guard.cjs b/hook-local/guard.cjs new file mode 100644 index 0000000..1d845e3 --- /dev/null +++ b/hook-local/guard.cjs @@ -0,0 +1,154 @@ +"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 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 +// 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. + * + * 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"]); +/** `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; + // 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 + .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: 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 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) { + 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/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/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", diff --git a/src/core/guard.ts b/src/core/guard.ts new file mode 100644 index 0000000..55fed47 --- /dev/null +++ b/src/core/guard.ts @@ -0,0 +1,165 @@ +// 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 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 +// 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. + * + * 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"]); + +/** `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; + // 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 + .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: 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 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. */ +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..b4653a2 --- /dev/null +++ b/test/guard.test.ts @@ -0,0 +1,148 @@ +// 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 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 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(); + }); + + 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(); + }); +});