Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 22 additions & 6 deletions docs/concepts/code-search-for-coding-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
21 changes: 12 additions & 9 deletions docs/tradeoffs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
87 changes: 87 additions & 0 deletions hook-local/check.cjs
Original file line number Diff line number Diff line change
@@ -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`);
154 changes: 154 additions & 0 deletions hook-local/guard.cjs
Original file line number Diff line number Diff line change
@@ -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 <<EOF`. Running a script file is allowed.
if (INLINE_INTERPRETERS.has(prog)) {
const args = tokens.slice(i + 1);
const inline = args.some((a) => 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 <sub>` 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;
}
17 changes: 17 additions & 0 deletions hook-local/hook.cjs
Original file line number Diff line number Diff line change
@@ -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,
}}));
}
});
15 changes: 15 additions & 0 deletions hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Grep|Bash",
"hooks": [
{
"type": "command",
"command": "npx -y @infino-ai/code-context@0.4.0 guard --hook"
}
]
}
]
}
}
8 changes: 7 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -85,6 +85,12 @@ program
.option("-C, --path <dir>", "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")
Expand Down
Loading