diff --git a/AGENTS.md b/AGENTS.md index 5b02689..abb7566 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,10 +8,11 @@ code. ## Project overview **code-context is local code search for AI coding agents: a CLI (`cx`) and an -MCP server over a ranked index that lives in plain files inside the repo.** It -fuses keyword (BM25) and semantic (vector) search into one ranked pass and -exposes read-only SQL over the index, so an agent answers questions about a -codebase without crawling files into the context window. The index is built +MCP server over a ranked index that lives in plain files inside the repo.** The +query surface is one door: read-only SQL whose table-valued search functions +(hybrid_search fusing keyword BM25 + semantic ranking, bm25_search keyword-only) +make finding, counting, and ranking code a single SELECT, so an agent answers +questions about a codebase without crawling files into the context window. The index is built and queried in-process with a local embedding model: no accounts, no API keys, no server. It is built on the [infino](https://github.com/infino-ai/infino) engine, which runs SQL, full-text, and vector search over one copy of the @@ -24,19 +25,23 @@ the honest limits in [docs/tradeoffs.md](docs/tradeoffs.md). ## Repo map - `src/cli.ts`: the `cx` / `code-context` command entry (commander). -- `src/mcp/server.ts`: the MCP server, three tools (`search`, `sql`, - `reindex`). Each takes an optional `path` (repo root) so one server serves - multiple repos in a session, defaulting to the startup root. +- `src/mcp/server.ts`: the MCP server, two tools (`sql`, `reindex`). Each + takes an optional `path` (repo root) so one server serves multiple repos + in a session, defaulting to the startup root. The index builds eagerly at + server startup. - `src/mcp/repos.ts`: the per-repo registry - resolves and validates a requested root, one engine connection per repo, LRU-capped. -- `src/mcp/ensure.ts`: auto-index on first query - a `search`/`sql` on a - never-indexed repo builds the index inline, then answers on the same call - (`CX_AUTO_INDEX=0` restores the strict "index it first" error). +- `src/mcp/ensure.ts`: auto-index safety net - a `sql` query that reaches a + never-indexed repo before the eager startup build builds the index inline, + then answers on the same call (`CX_AUTO_INDEX=0` restores the strict + "index it first" error). - `src/core/`: the engine-facing core. `chunker` (tree-sitter chunking), `indexer` (build + staged readiness + incremental sync), `searcher` - (hybrid search + SQL), `embedder` (local model), `filestate` (incremental + (SQL + embed-placeholder plumbing), `embedder` (local model), `filestate` (incremental sync state), `walker`, `manifest`, `config`, `context`, `output`. -- `src/commands/`: CLI command implementations (`index-cmd`, `query-cmds`). +- `src/commands/`: CLI command implementations (`index-cmd`, `query-cmds`, + and `install-cmd`, which wires the enforcement hooks into a non-plugin + client's settings). - `test/`: vitest suites. `bench/`: the benchmark harness. `docs/`: docs. ## Build, test, gates @@ -53,11 +58,13 @@ before opening a PR. ## Conventions - TypeScript, ES modules. Every source file carries an SPDX header. -- The MCP surface is deliberately three tools: one way to find (`search`), - one way to count (`sql`), one way to stay fresh (`reindex`). Adding - near-duplicate retrieval tools worsens an agent's tool selection; resist it. -- Search results carry chunk content plus `path:line` ranges so answers cite - code; keep that contract when touching `searcher` or the tool descriptions. +- The MCP surface is deliberately two tools: one way to query (`sql`, with + ranked retrieval as table-valued functions), one way to stay fresh + (`reindex`). Adding near-duplicate retrieval tools worsens an agent's tool + selection; resist it. +- Query results carry chunk content plus `path`/`start_line`/`end_line` so + answers cite code; keep that contract when touching `searcher` or the tool + descriptions. ## Boundaries diff --git a/README.md b/README.md index f44baa3..51ae0ea 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ Install the Claude Code plugin - nothing to paste into a config: /plugin install code-context@infino-ai ``` -It registers code-context's three tools with `alwaysLoad` already set, so the +It registers code-context's two tools with `alwaysLoad` already set, so the agent keeps them in view and reaches for the index directly instead of falling back to plain file search. @@ -75,10 +75,11 @@ servers - where clients defer tool definitions behind a tool-search step - the agent doesn't miss the index and fall back to plain file search. (Use *either* the plugin or this command, not both.) -Then just ask a question about the code. The first `search` or `sql` on an -unindexed repo builds the index inline and answers on the same call: keyword -search is live in seconds, and vectors backfill in the background. (Prefer to -kick it off yourself? The `reindex` tool does the same build on demand.) +Then just ask a question about the code. The server builds the index as it +starts, so keyword search is live within seconds of the session opening and +vectors backfill in the background - the first `sql` query typically finds the +index already waiting. (A query that beats the build still triggers it inline; +`reindex` syncs after big edits.) CI-tested on Linux x64 (glibc) and macOS arm64; linux-arm64, musl, and Windows-via-WSL are expected to work through the engine's prebuilt bindings @@ -118,19 +119,25 @@ One index and a deliberately small tool surface for agents: | Tool | What it does | When agents use it | |---|---|---| -| `search` | One ranked pass fusing exact keyword matching (BM25) with semantic similarity (reciprocal-rank fusion). Hits carry the chunk content, so answers come straight from results. | A strong default for finding and understanding code: how a subsystem works, code by meaning or exact term, context before a change, similar implementations - exact identifiers and paraphrases in the same call. | -| `sql` | Read-only SQL over the index, with the ranked search functions (`bm25_search`/`hybrid_search`) usable as table-valued relations. | Counts, rankings, aggregates over the whole repo in one query. | +| `sql` | Read-only SQL over the index, with ranked retrieval as table-valued relations: `hybrid_search` fuses exact keyword matching (BM25) with semantic similarity in one ranked pass; `bm25_search` is the keyword arm alone. Rows carry the chunk content, so answers come straight from results. | Everything: how a subsystem works, code by meaning or exact term, context before a change, and any count/ranking/aggregate over the whole repo - one query language for all of it. | | `reindex` | Incremental sync (the server also auto-syncs in the background). | After significant edits. | -Three tools is a deliberate design: one way to find, one way to count, one -way to stay fresh. Every additional near-duplicate retrieval tool worsens an -agent's tool selection, and hybrid search's keyword half already ranks -exact identifier terms highly, so a separate lexical tool has no job left. +Two tools is a deliberate design: one way to query, one way to stay fresh. +Every additional near-duplicate retrieval tool worsens an agent's tool +selection - and since hybrid ranking's keyword half already ranks exact +identifier terms highly, retrieval needs no second door beside SQL. -### The SQL move +### One surface, both jobs -Search-as-a-table composes with aggregation. Ranked by relevance, tallied by -SQL, one engine pass: +Finding code is a query: + +```sql +SELECT path, start_line, end_line, symbol, content +FROM hybrid_search('chunks', 'content', 'auth handling', 'embedding', {{q}}, 10) +``` + +and because search is a table, the same relation composes with aggregation - +ranked by relevance, tallied by SQL, one engine pass: ```sql SELECT path, SUM(end_line - start_line + 1) AS lines, COUNT(*) AS chunks @@ -138,9 +145,9 @@ FROM bm25_search('chunks', 'content', 'vector index quantization', 300) GROUP BY path ORDER BY lines DESC LIMIT 15 ``` -`hybrid_search(...)` and `vector_search(...)` work the same way. The CLI and -MCP server embed `{{name}}` placeholders server-side, so agents never handle -raw vectors. +`vector_search(...)` works the same way when you want meaning-only ranking. +The CLI and MCP server embed `{{name}}` placeholders server-side, so agents +never handle raw vectors. ### Staged readiness @@ -149,7 +156,9 @@ that takes under a second, so search works before any embedding model even exists on the machine. Vectors backfill in the background with a local model (downloaded once, no key; about two minutes for that same repo), and hybrid/semantic ranking unlocks automatically when they land. If the vector -stage fails, keyword search stays live and the index says so honestly. +stage fails, keyword search stays live and the index says so honestly. The +MCP server kicks this build off as it starts, so the index is typically live +before an agent's first query. The default model optimizes quality-per-minute. See [docs/embedder-eval.md](docs/embedder-eval.md) for how it was chosen. @@ -164,7 +173,7 @@ export and pass around. ## Setup for agents code-context is an MCP server over stdio, so any MCP client works. Register -it once and the tools (`search`, `sql`, `reindex`) become available to the +it once and the tools (`sql`, `reindex`) become available to the agent.
@@ -188,12 +197,28 @@ claude mcp add-json code-context -s user '{"command":"npx","args":["-y","@infino for the index directly. In sessions with many MCP servers Claude Code defers tool definitions behind a tool-search step; without `alwaysLoad` the agent can miss code-context and fall back to grep/read. It's a small, always-loaded set -(three tools). Omit it (or use the shorter `claude mcp add code-context -- npx +(two tools). Omit it (or use the shorter `claude mcp add code-context -- npx -y @infino-ai/code-context mcp`) if you'd rather leave the tools deferred. Use *either* the plugin or the `add-json` command, not both. They register the same `code-context` server, so running both just collides. +**Enforcement.** The plugin also ships hooks that make the index the default +way to search: once a repo's index fully covers it (vectors ready, nothing +over the file cap), the Grep tool and standalone `grep`/`rg`/`git grep` +commands are denied with a redirect to the `sql` search functions. Until the +index is fully built, grep is untouched - enforcement never pushes an agent +onto an index that can't answer yet. The deny is scoped, not absolute - grep +as a pipe filter on other command output always passes, and a grep targeting +something the index can't answer for (a gitignored file, a file over the byte +cap, a dot-path, a path outside the repo) is allowed silently. Two escape +hatches: prefix a command with `CX_GREP_FALLBACK=1` when an index search +genuinely came up short (the hook asks for approval instead of denying), and +`CX_NO_ENFORCE=1` in the environment disables enforcement entirely. Plain MCP +registration (`add-json`) gets the tools without the hooks; `cx install` +adds them (see below). Use *either* the plugin or `cx install`, not both - +both sets fire, harmlessly, but it's noise. + **For a team,** commit a project-scoped `.mcp.json` at the repo root so everyone gets it (after the one-time project-server approval): @@ -252,7 +277,7 @@ when the client's working directory is not the repo.
-Tools: `search`, `sql`, `reindex` (incremental sync: an unchanged repo is +Tools: `sql`, `reindex` (incremental sync: an unchanged repo is a fast no-op, and the server also auto-syncs in the background as queries arrive, so results track your edits without anyone asking). @@ -262,24 +287,46 @@ target a specific repo when a session spans more than one. One server instance serves them all, each with its own index in its own `.infino/` - no restart, no per-repo config. +**Enforcement without the plugin.** Enforcement - hooks that deny the Grep +tool and standalone `grep`/`rg`/`git grep` on a fully-indexed repo and +redirect to `sql`, with the full rules in the Claude Code block above - ships +inside the plugin. For every other way of reaching code-context - `npx`, +`claude mcp add-json`, Cursor, Windsurf - `cx install` wires the same hooks, +and there it's the only form that survives a client restart: + +``` +cx install # or: npx -y @infino-ai/code-context install +``` + +It copies the hook to `~/.claude/hooks/cx-deny-grep.mjs` and merges +`SessionStart` + `PreToolUse` entries into `~/.claude/settings.json`, leaving +any other hooks in the file alone. Re-running it is idempotent, `cx install +--uninstall` reverses it, and `--settings ` targets a different settings +file. The entries embed the absolute path of the `node` that ran the install, +because a client's process often has no `node` on `PATH` and a hook that +can't find node fails silently - which reads exactly like enforcement not +working. Start a new session for the client to pick the hooks up. Use the +plugin or this command, not both. + ## Configuration | Variable | Default | Purpose | |---|---|---| | `CX_INDEX_DIR` | `/.infino` | where the index lives | -| `CX_SEARCH_K` | 10 | default number of hits `search` returns (also settable per call and via the CLI `-k` flag) | -| `CX_MAX_FILES` / `CX_MAX_FILE_BYTES` | 20000 / 1MB | indexing caps (files over the file cap are left out; `search`/`sql` then flag the index as partial so an absence isn't read as proof) | +| `CX_MAX_FILES` / `CX_MAX_FILE_BYTES` | 20000 / 1MB | indexing caps (files over the file cap are left out; `sql` then flags the index as partial so an absence isn't read as proof) | | `CX_ROOT` | current directory | default repo root for the MCP server / CLI when not run from the repo (each tool call can override it with a `path` argument) | -| `CX_AUTO_INDEX` | on | `0` makes a query on an unindexed repo error instead of building the index inline on the first `search`/`sql` | +| `CX_AUTO_INDEX` | on | `0` makes a query on an unindexed repo error instead of building eagerly at server startup (or inline on the first `sql`) | | `CX_AUTO_SYNC` | on | `0` disables the MCP server's background staleness sync | | `CX_SYNC_INTERVAL_SECS` | 30 | auto-sync debounce between staleness checks | | `CX_NO_EMBED` | off | keyword-only mode for the MCP server (skip the vector stage) | | `CX_NO_RECEIPT` | off | `1` turns off usage accounting - the per-call receipt on results and the `cx usage` ledger | +| `CX_NO_ENFORCE` | off | `1` disables the grep-enforcement hooks entirely (however they were installed - the plugin or `cx install`) | +| `CX_GREP_FALLBACK` | - | prefix a `grep`/`rg` command with `CX_GREP_FALLBACK=1` to request an approved fallback grep after an index search came up short | -Every `search` / `sql` result carries a **usage receipt** - a terse, local line -showing the tokens it returned, the files it spanned, and a running session -total (e.g. `returned ~1.2k tokens | 4 chunks / 3 files | session ~8.4k over 7 -queries`). Every figure is a `~` estimate, computed in-process - nothing about +Every `sql` result carries a **usage receipt** - a terse, local line showing +the tokens it returned, the row count, and a running session total (e.g. +`returned ~1.2k tokens | 12 rows | invoked 7x this session (~8.4k tokens +total)`). Every figure is a `~` estimate, computed in-process - nothing about your queries or code leaves the machine. ## CLI @@ -294,17 +341,17 @@ npm install -g @infino-ai/code-context ``` cx index [path] sync the index (incremental; --full rebuilds, --watch follows edits) -cx search exact terms + meaning, one ranked pass (-k hits) cx sql read-only SQL; --embed q="text" fills {{q}} cx status what the index holds, how fresh, vector readiness cx usage ledger of queries run and what each returned (-n, --all, --clear, --json) cx mcp serve the MCP tools over stdio +cx install wire the enforcement hooks into the client (--uninstall reverses) ``` -`cx usage` reads the local ledger at `.infino/usage.jsonl` - every `search` / -`sql` (from the CLI or the MCP server) appends one line recording the query and -a compact summary of what came back (paths and line ranges for search, row -count for sql), plus the token figures from the receipt. It's a deterministic, +`cx usage` reads the local ledger at `.infino/usage.jsonl` - every `sql` query +(from the CLI or the MCP server) appends one line recording the statement and +a compact summary of what came back (row count and a preview of the rows), +plus the token figures from the receipt. It's a deterministic, model-independent view of what went through the index - no running server or agent needed to read it back. `CX_NO_RECEIPT=1` turns off both the inline receipt and this ledger. diff --git a/bench/recall.mjs b/bench/recall.mjs index ffcb2a2..26a712b 100644 --- a/bench/recall.mjs +++ b/bench/recall.mjs @@ -3,7 +3,7 @@ // Indexes THIS repo (code-context) to a temp dir with the default local // embedder, then runs a fixed set of paraphrase queries whose gold file is // known, and reports hit@5 / MRR@5 for vector-only ranking (isolates the -// embedder) and hybrid ranking (the `search` tool's real surface). +// embedder) and hybrid ranking (the sql tool's hybrid_search surface). // // It's deterministic (same model+dtype -> same vectors) and needs no network // beyond the one-time model download, which makes it a good regression signal. @@ -42,7 +42,7 @@ process.env.CX_INDEX_DIR = tmp; const { indexRepo } = await import(`${ROOT}/dist/core/indexer.js`); const { openForIndexing, openIndex } = await import(`${ROOT}/dist/core/context.js`); const { createEmbedder } = await import(`${ROOT}/dist/core/embedder.js`); -const { search } = await import(`${ROOT}/dist/core/searcher.js`); +const { runSql } = await import(`${ROOT}/dist/core/searcher.js`); const { TABLE, DEFAULT_CAPS } = await import(`${ROOT}/dist/core/config.js`); console.log(`indexing ${ROOT}`); @@ -69,7 +69,14 @@ for (const [q, gold] of GOLD) { const [vec] = await embedder.embed([q]); const vRank = rankOf(table.vectorSearch("embedding", vec, 15, { projection: ["path"] }), gold); if (vRank >= 1 && vRank <= 5) { vHit++; vRr += 1 / vRank; } - const hRank = rankOf((await search(handle, embedder, q, 15)).hits, gold); + const esc = q.replaceAll("'", "''"); + const hRows = await runSql( + handle, + embedder, + `SELECT path FROM hybrid_search('${TABLE}','content','${esc}','embedding', {{q}}, 15)`, + { q }, + ); + const hRank = rankOf(hRows, gold); if (hRank >= 1 && hRank <= 5) { hHit++; hRr += 1 / hRank; } else misses.push(`${gold} ("${q}")`); } diff --git a/docs/faq.md b/docs/faq.md index 6a42a0e..17b2c95 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -3,10 +3,10 @@ ### What is code-context? Local code search for AI coding agents: a CLI (`cx`) and an MCP server over a -ranked index that lives in plain files inside your repo. It fuses keyword -(BM25) and semantic search in one pass and exposes read-only SQL over the -index, so an agent answers questions about the codebase without reading it -file by file. +ranked index that lives in plain files inside your repo, queried through one +door - read-only SQL whose table-valued search functions fuse keyword (BM25) +and semantic ranking in one pass - so an agent answers questions about the +codebase without reading it file by file. ### When should an agent use it instead of grep? @@ -16,6 +16,29 @@ meaning when you do not know the identifier, and ranking or aggregating across the whole repo. For jumping to one known symbol or literal string, a plain grep is already cheap and there is no need for an index. +### How do I make an agent actually use the index instead of grep? + +Install the enforcement hooks. The Claude Code plugin ships them; on any other +client - `npx`, `claude mcp add-json`, Cursor, Windsurf - `cx install` writes +them, and there it is the only form that survives a client restart (use one or +the other, not both). It copies the hook to `~/.claude/hooks/cx-deny-grep.mjs` +and merges `SessionStart` + `PreToolUse` entries into +`~/.claude/settings.json` - idempotent, reversed by `cx install --uninstall`, +and pointed at another file with `--settings `. The entries embed the +absolute path of the `node` that ran the install, because a client's process +often has no `node` on `PATH` and a hook that cannot find node fails silently. + +With the hooks in place, once a repo's index fully covers it (vectors ready, +nothing over the file cap) the Grep tool and standalone `grep`/`rg`/`git grep` +are denied with a redirect to the `sql` search functions. Until the index is +fully built grep is untouched - enforcement never pushes an agent onto an +index that cannot answer yet. The deny is scoped, too: grep as a pipe filter +on other command output always passes, and a target the index cannot cover +(gitignored, over the byte cap, a dot-path, outside the repo) passes +silently. Prefix a command with +`CX_GREP_FALLBACK=1` to turn a deny into an approval prompt when an index +search genuinely came up short; `CX_NO_ENFORCE=1` disables enforcement. + ### Does my code leave the machine? No. There are no accounts, no API keys, and no server. The embedding model is @@ -32,11 +55,11 @@ reports that honestly rather than failing. ### Do I have to index before I can search? -No. The first `search` or `sql` on a repo that has never been indexed builds -the index inline and answers on that same call - keyword search is live in -seconds, vectors backfill behind it. Call `reindex` first if you'd rather -kick the build off explicitly, or set `CX_AUTO_INDEX=0` to make an unindexed -query return a "index it first" error instead of building. +No. The MCP server builds the index as it starts, so it is typically live +before the first query - and a `sql` query that beats the build still +triggers it inline and answers on the same call. Keyword search is live in +seconds, vectors backfill behind it. Set `CX_AUTO_INDEX=0` to disable both +and make an unindexed query return an "index it first" error instead. ### Can one server handle more than one repo? @@ -63,20 +86,20 @@ no-op. The MCP server also auto-syncs in the background as queries arrive. Indexing caps how many files it takes (`CX_MAX_FILES`, default 20,000); files past the cap are left out. When that happens the index is marked partial: -every `search` and `sql` result carries a `partial` note with how many files -were skipped and the cap in effect, so an agent treats a missing match as -"maybe not indexed" rather than "not in the repo." `cx status` shows the same, -and `cx search` prints a warning. Raise `CX_MAX_FILES` (CLI: `--max-files`) -and re-index for full coverage. +every `sql` result carries a `partial` note with how many files were skipped +and the cap in effect, so an agent treats a missing match as "maybe not +indexed" rather than "not in the repo." `cx status` shows the same. Raise +`CX_MAX_FILES` (CLI: `--max-files`) and re-index for full coverage. ### What tools does the MCP server expose? -Three, by design: `search` (hybrid keyword + semantic retrieval, one ranked -pass, hits carry chunk content with `path:line` ranges), `sql` (read-only -`SELECT`/`WITH` over the index, with the ranked search functions usable as -table-valued relations so search composes with `GROUP BY`), and `reindex` -(incremental sync). Every additional near-duplicate retrieval tool worsens an -agent's tool selection, so the surface is kept deliberately small. +Two, by design: `sql` (read-only `SELECT`/`WITH` over the index, with the +ranked search functions - `hybrid_search`, `bm25_search`, `vector_search` - +usable as table-valued relations, so one query finds code by meaning or exact +term AND composes with `GROUP BY` for counts and rankings; rows carry chunk +content with `path:line` ranges) and `reindex` (incremental sync). Every +additional near-duplicate retrieval tool worsens an agent's tool selection, +so retrieval lives inside SQL rather than beside it. ### How is SQL over code useful? diff --git a/hooks/deny-grep.mjs b/hooks/deny-grep.mjs new file mode 100644 index 0000000..26a60e8 --- /dev/null +++ b/hooks/deny-grep.mjs @@ -0,0 +1,712 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Infino Authors +// +// code-context enforcement hook: steer code search to the sql tool without +// trapping the agent. +// +// Grep (the tool, or a standalone grep/rg/git-grep Bash command) is denied +// with a redirect to the sql TVFs ONLY when all of these hold: +// - the repo's index is fully live: manifest present, vectors "ready", +// nothing truncated by the file cap, and the table actually on disk +// (a manifest orphaned by a deleted table fails open); +// - the index actually contains what the command searches. Coverage is +// never re-derived from the indexer's skip rules: any drift between the +// hook's model and the indexer's behaviour denies grep on a file sql +// cannot answer for either, which costs the agent both paths. The covered +// file set is read straight out of `.infino/filestate.json`, which every +// build and sync writes - keys whose entry recorded zero chunk rows are +// dropped, because that state fingerprints files it could not chunk +// (binary bytes behind an indexable extension, an empty `__init__.py`) and +// the table holds no rows for them; +// - the command does not carry the CX_GREP_FALLBACK=1 marker - with the +// marker the decision is "ask", the human-approved fallback for when an +// index search genuinely came up short. +// Pipe-filter grep (cargo test | grep FAILED) never matches; CX_NO_ENFORCE=1 +// disables enforcement entirely. Every ambiguity fails open (allow). +// +// The command is read as shell, not as text: heredoc bodies are removed before +// anything is parsed (a script being written must not be read as commands the +// agent is running), segments split on `&&`/`||`/`;`/newline but never on `|`, +// and a launcher's operands end at a `|` or a `#` comment - otherwise the words +// of a pipe filter (`| head -5`) read as targets nothing covers and the whole +// search falls open. Launchers are matched on their basename, behind wrappers +// (`command`, `timeout 5`, `xargs -a file`) and behind git's global flags. +// +// One script serves SessionStart and the PreToolUse matchers, dispatching on +// the payload. The legacy `search` tool (pre-0.2 servers) is denied with a +// redirect to sql. +import { readFileSync, readdirSync, existsSync } from "node:fs"; +import { join, dirname, resolve, relative, isAbsolute, basename, sep } from "node:path"; + +/** Index format the hook understands; anything else reads as absent. */ +const INDEX_FORMAT_VERSION = 2; + +/** The index directory and the two files read out of it (mirrors + * src/core/config.ts and src/core/filestate.ts). */ +const INDEX_DIR_NAME = ".infino"; +const MANIFEST_NAME = "codecontext.json"; +const FILESTATE_NAME = "filestate.json"; + +/** Commands that launch a code search. `git` counts only as `git grep`. */ +const GREP_LAUNCHERS = new Set(["rg", "grep"]); + +/** Wrappers that sit in front of the real command; each one used to hide a + * launcher from enforcement, so they are stepped over. */ +const COMMAND_WRAPPERS = new Set(["env", "sudo", "time", "nice", "command", "xargs", "nohup", "stdbuf"]); + +/** A wrapper flag that eats the next token as its value (`xargs -a list`, + * `nice -n 5`, `env -u VAR`). Without these the scan stops on the value and + * the launcher behind it escapes. */ +const WRAPPER_VALUE_FLAGS = { + env: new Set(["-u", "-S", "-C", "-P"]), + sudo: new Set(["-u", "-g", "-p", "-h", "-U", "-r", "-t", "-T", "-C", "-D", "-R"]), + xargs: new Set(["-a", "-d", "-E", "-I", "-i", "-L", "-l", "-n", "-P", "-s", "-S"]), + nice: new Set(["-n"]), + timeout: new Set(["-k", "-s"]), + stdbuf: new Set(["-i", "-o", "-e"]), + time: new Set([]), + command: new Set([]), + nohup: new Set([]), +}; + +/** Shell control words and group openers that sit in front of the command + * that actually runs (`if rg -q …; then`, `while rg …; do`, `{ rg …; }`, + * `! rg …`, `( rg … )`): each one read as the launcher and hid the search. */ +const CONTROL_PREFIXES = new Set([ + "if", "then", "else", "elif", "fi", + "do", "done", "while", "until", "for", + "!", "{", "}", "(", ")", +]); + +/** `timeout` takes a duration operand before the command it runs. Only this + * shape is stepped over; anything else leaves the scan where it is, which + * reads as "not a launcher" and allows. */ +const TIMEOUT_WRAPPER = "timeout"; +const TIMEOUT_DURATION = /^\d+(?:\.\d+)?[smhd]?$/; + +/** git's global options that take a separate value (`git -C dir grep ...`); + * every other global option is a lone flag (`--no-pager`). */ +const GIT_LAUNCHER = "git"; +const GIT_SUBCOMMAND = "grep"; +const GIT_VALUE_FLAGS = new Set(["-C", "-c", "--git-dir", "--work-tree", "--exec-path", "--namespace"]); + +/** `VAR=value`, the other prefix a launcher hides behind. */ +const ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/; + +/** The human-approved fallback marker, as a leading assignment. */ +const FALLBACK_MARKER = /^CX_GREP_FALLBACK=1$/; + +/** A target carrying any of these is a glob, matched against the indexed file + * set rather than resolved as a path. */ +const GLOB_CHARS = /[*?[{]/; + +/** An output redirection (`>`, `>>`, `2>`, `&>`), whose operand names a + * destination file rather than a search target. */ +const OUT_REDIRECT = /^(\d*|&)(>>?)$/; + +/** Anything that begins a redirection, joined to its operand or not. */ +const REDIRECT_START = /^(\d*|&)(>>?|<)/; + +/** A heredoc introducer at the scan position: `< p.split(sep).join("/"); + +/** True when a root-relative, "/"-separated path leads out of the repo. */ +const outsideRepo = (rel) => rel === ".." || rel.startsWith("../") || isAbsolute(rel); + +/** The command a token names: its basename, so an absolute path + * (`/usr/bin/rg`) counts as the launcher it runs. */ +const commandName = (text) => basename(toPosix(text ?? "")); + +/** Nearest ancestor of `from` holding an index manifest, or undefined. */ +function findIndexRoot(from) { + for (let dir = from; ; dir = dirname(dir)) { + if (existsSync(join(dir, INDEX_DIR_NAME, MANIFEST_NAME))) return dir; + if (dirname(dir) === dir) return undefined; + } +} + +/** Which tree the index describes. `manifest.root` is the only source when the + * index was not found by walking up from cwd (a custom CX_INDEX_DIR), where a + * cwd guess would read the repo's own files as "outside the repo" and allow + * everything. But it is only trusted while it agrees with where the index was + * found: a copied checkout (`cp -a repo repo2`), or an index built at a + * container path and read from the host, carries a manifest pointing at the + * original tree - trusting that would resolve every target outside the repo + * and silently stop enforcing while sql answers fine. */ +function repoRoot(manifestRoot, walkRoot, foundByWalk, from) { + if (!foundByWalk) return manifestRoot ?? walkRoot ?? from; + if (manifestRoot && resolve(manifestRoot) === resolve(walkRoot)) return manifestRoot; + return walkRoot; +} + +/** Locate the index for cwd (CX_INDEX_DIR override, else walk up to .infino) + * and classify it. Only "ready" enforces; everything else fails open. */ +function indexInfo(cwd) { + const from = cwd || process.cwd(); + const walkRoot = findIndexRoot(from); + const walkIndexDir = walkRoot ? join(walkRoot, INDEX_DIR_NAME) : undefined; + const indexDir = process.env.CX_INDEX_DIR ?? walkIndexDir; + const fallbackRoot = walkRoot ?? from; + if (!indexDir) return { state: "none", root: fallbackRoot }; + const foundByWalk = walkIndexDir !== undefined && resolve(indexDir) === resolve(walkIndexDir); + try { + const m = JSON.parse(readFileSync(join(indexDir, MANIFEST_NAME), "utf8")); + const manifestRoot = typeof m.root === "string" && m.root ? m.root : undefined; + const root = repoRoot(manifestRoot, walkRoot, foundByWalk, from); + if (m.version !== INDEX_FORMAT_VERSION) return { state: "none", root, indexDir }; + if (m.truncatedFiles) return { state: "partial", root, indexDir }; + if (m.vectors !== "ready") return { state: "building", root, indexDir }; + // A "ready" manifest orphaned by a deleted/moved table would deny grep + // while sql also errors - losing both paths. Confirm the table exists. + const hasTable = readdirSync(indexDir).some((n) => n.startsWith(`${m.table}-`)); + return { state: hasTable ? "ready" : "none", root, indexDir }; + } catch { + return { state: "none", root: fallbackRoot, indexDir }; + } +} + +/** Did this filestate entry put rows in the table? A recorded count of 0 means + * the indexer fingerprinted the file but chunked nothing out of it, so sql + * cannot answer for it and grep must stay allowed. An absent count is older + * state that never recorded one, which stays covered (back-compatible); any + * other shape is unreadable and fails open. */ +function entryHasChunks(entry) { + if (typeof entry !== "object" || entry === null) return false; + const n = entry.chunks; + if (n === undefined) return true; + return typeof n === "number" && n > 0; +} + +/** The covered file set: repo-root-relative, "/"-separated keys of + * `.infino/filestate.json` whose entry actually produced chunk rows. + * undefined when the state is missing, unparseable, or leaves nothing covered + * - each of which means the hook cannot tell what sql can answer, and so must + * fail open. */ +function indexedFiles(indexDir) { + try { + const state = JSON.parse(readFileSync(join(indexDir, FILESTATE_NAME), "utf8")); + if (state?.version !== 1 || typeof state.files !== "object" || state.files === null) return undefined; + const entries = Object.values(state.files); + // A state written before counts existed has no `chunks` field anywhere, so + // it cannot distinguish a chunk-less file from a covered one - treating it + // as covered denied greps sql could not answer. Fail open until a rebuild + // or the first sync stamps counts; a MIXED state (some entries stamped) + // keeps the back-compatible reading for the unstamped rest. + if (entries.length > 0 && entries.every((e) => typeof e !== "object" || e === null || e.chunks === undefined)) { + return undefined; + } + const keys = Object.keys(state.files).filter((k) => entryHasChunks(state.files[k])); + return keys.length > 0 ? keys : undefined; + } catch { + return undefined; + } +} + +/** Translate a glob into an anchored RegExp over "/"-separated paths: `*` and + * `?` stay inside one segment, `**` crosses separators, `{a,b}` alternates, + * `[...]` is a character class. undefined for a pattern that won't compile, + * which reads as "matches nothing" and so allows the grep. */ +function globRegExp(glob) { + let re = ""; + let braces = 0; + for (let i = 0; i < glob.length; i++) { + const c = glob[i]; + if (c === "*") { + if (glob[i + 1] === "*") { + // Collapse the whole run: `***…` compiles like `**`. One `.*` per pair + // stacked into `^.*.*.*…$`, which backtracks - 20 stars took seconds + // and 24 never answered, wedging the tool call until the hook timeout. + while (glob[i + 1] === "*") i++; + // `**/` also matches zero directories, so `**/*.ts` covers `a.ts`. + if (glob[i + 1] === "/") { + i++; + re += "(?:.*/)?"; + } else { + re += ".*"; + } + } else { + re += "[^/]*"; + } + } else if (c === "?") { + re += "[^/]"; + } else if (c === "{") { + braces++; + re += "(?:"; + } else if (c === "}" && braces > 0) { + braces--; + re += ")"; + } else if (c === "," && braces > 0) { + re += "|"; + } else if (c === "[") { + const end = glob.indexOf("]", i + 1); + if (end === -1) { + re += "\\["; + continue; + } + re += `[${glob.slice(i + 1, end).replace(/^[!^]/, "^")}]`; + i = end; + } else { + re += REGEX_META.test(c) ? `\\${c}` : c; + } + } + try { + return new RegExp(`^${re}$`); + } catch { + return undefined; + } +} + +/** Does the index hold at least one file this glob names? A pattern without a + * separator is matched against basenames too, which is how the Grep tool's + * `glob` input ("*.rs") is meant to read. */ +function coversGlob(pattern, base, root, keys, bareMatchesBasename) { + let scoped = toPosix(pattern); + const bare = !scoped.includes("/"); + if (!bare) { + // Rebase onto the repo root so the pattern lines up with the keys. resolve + // and relative are pure string math here - the glob chars pass through. + scoped = toPosix(relative(root, isAbsolute(pattern) ? pattern : resolve(base, pattern))); + if (outsideRepo(scoped)) return false; + } + const re = globRegExp(scoped); + if (!re) return false; + return keys.some((k) => re.test(k) || (bare && bareMatchesBasename && re.test(basename(k)))); +} + +/** Does the index contain this target? A path is covered when it is a covered + * file, or a directory with at least one covered file under it; a glob when at + * least one covered key matches. Everything else is NOT covered and grep is + * allowed, because sql could not answer it either: a path outside the repo, + * anything the indexer skipped (a lockfile, a `.csv`, `LICENSE`, `vendor/`, a + * symlink, a file over the byte cap, a binary), and anything it fingerprinted + * without chunking a single row out of. */ +function coversTarget(target, cwd, root, keys) { + const base = cwd || root; + // A bare operand glob (`*.ts`) is NOT matched against basenames: the shell + // expands it in the cwd before rg ever runs, so basename matching read + // `rg zzz *.ts` as repo-wide and denied a search whose real operands were + // uncovered. The Grep tool's `glob` input is recursive and keeps basenames. + if (GLOB_CHARS.test(target)) return coversGlob(target, base, root, keys, false); + const rel = toPosix(relative(root, isAbsolute(target) ? target : resolve(base, target))); + if (outsideRepo(rel)) return false; + if (rel === "") return true; // the repo root itself + return keys.some((k) => k === rel || k.startsWith(`${rel}/`)); +} + +/** Remove heredoc bodies from a command line. `cat > run.sh <<'SH' … SH` + * writes a file: its body is data, and reading the search line inside it as a + * command the agent runs denies the agent its own script (with an irrelevant + * "use sql" message). The redirection operator and delimiter go too - what + * stays is the command they belong to. Quotes are respected so a `<<` inside a + * string is not mistaken for one; a mistake here can only drop text, which + * fails open. */ +function stripHeredocs(command) { + let out = ""; + let quote = ""; + const pending = []; + let i = 0; + while (i < command.length) { + const c = command[i]; + if (quote) { + out += c; + if (c === quote) quote = ""; + else if (c === "\\" && quote === '"' && i + 1 < command.length) out += command[++i]; + i++; + continue; + } + if (c === '"' || c === "'") { + quote = c; + out += c; + i++; + continue; + } + if (c === "\\" && i + 1 < command.length) { + out += c + command[i + 1]; + i += 2; + continue; + } + if (c === "<" && command[i + 1] === "<" && command[i + 2] !== "<") { + const m = HEREDOC_START.exec(command.slice(i)); + if (m) { + pending.push({ word: m[2] ?? m[3] ?? m[4], dashed: m[1] === "-" }); + i += m[0].length; + continue; + } + } + if (c === "\n" && pending.length > 0) { + out += c; + i++; + // The bodies follow in the order their delimiters appeared, each ending + // on its own terminator line (`<<-` allows leading tabs on it). + while (pending.length > 0) { + const { word, dashed } = pending.shift(); + while (i < command.length) { + const nl = command.indexOf("\n", i); + const end = nl === -1 ? command.length : nl; + const line = command.slice(i, end); + i = nl === -1 ? command.length : nl + 1; + if ((dashed ? line.replace(HEREDOC_DASH_INDENT, "") : line) === word) break; + } + } + continue; + } + out += c; + i++; + } + return out; +} + +/** Split a command line into independently-launched segments on `&&`, `||`, + * `;` and newlines - and NEVER on `|`: a pipe filter (`cargo test | grep + * FAILED`) stays one segment whose launcher is `cargo`, which is exactly why + * it keeps working. Quotes are respected, so a separator inside a pattern + * stays literal. */ +function segments(command) { + const out = []; + let cur = ""; + let quote = ""; + for (let i = 0; i < command.length; i++) { + const c = command[i]; + if (quote) { + cur += c; + if (c === quote) quote = ""; + else if (c === "\\" && quote === '"' && i + 1 < command.length) cur += command[++i]; + continue; + } + if (c === '"' || c === "'") { + quote = c; + cur += c; + continue; + } + if (c === "\\" && i + 1 < command.length) { + cur += c + command[++i]; + continue; + } + if (c === ";" || c === "\n") { + out.push(cur); + cur = ""; + continue; + } + // `&&` and `||` split; so does a lone `&` (backgrounding: `rg pat src &` + // launched a search that was never inspected). `>&` (2>&1, >&2) is a + // redirection, not a control operator, and `|` is never a split. + if (c === "&") { + if (command[i + 1] === "&") { + out.push(cur); + cur = ""; + i++; + continue; + } + if (cur.endsWith(">")) { + cur += c; + continue; + } + out.push(cur); + cur = ""; + continue; + } + if (c === "|" && command[i + 1] === "|") { + out.push(cur); + cur = ""; + i++; + continue; + } + cur += c; + } + out.push(cur); + return out.filter((s) => s.trim() !== ""); +} + +/** Split a segment into `{ text, quoted }` tokens, keeping quoted phrases + * whole: the pattern of `grep -rn "let auth" src/` is one token, so `src/` + * reads as the target it is. Splitting on bare whitespace made `auth"` a bogus + * target that fell open - which is how multi-word patterns, the normal way + * agents grep, escaped. An unquoted `|` is emitted as its own token: glued to + * a neighbour (`src|head`) it would have made a target no key matches, and the + * search would have fallen open. `quoted` records that some of the token was + * quoted or escaped, i.e. that it is text rather than shell syntax: `grep + * "> TODO" notes.log` searches for a literal `>`, and reading that as a + * redirection would drop the pattern and turn an unindexed file into a + * repo-wide deny. */ +function tokenize(segment) { + const toks = []; + let cur = ""; + let started = false; + let quoted = false; + let quote = ""; + const push = () => { + if (started) toks.push({ text: cur, quoted }); + cur = ""; + started = false; + quoted = false; + }; + for (let i = 0; i < segment.length; i++) { + const c = segment[i]; + if (quote) { + if (c === quote) quote = ""; + else if (c === "\\" && quote === '"' && i + 1 < segment.length) cur += segment[++i]; + else cur += c; + continue; + } + if (c === "'" || c === '"') { + quote = c; + started = true; + quoted = true; + continue; + } + if (c === "\\" && i + 1 < segment.length) { + cur += segment[++i]; + started = true; + quoted = true; + continue; + } + if (/\s/.test(c)) { + push(); + continue; + } + if (c === PIPE) { + push(); + toks.push({ text: PIPE, quoted: false }); + continue; + } + cur += c; + started = true; + } + push(); + return toks; +} + +/** Step past a wrapper's own flags, and past the value any of them takes (never + * past a `|`, which is syntax and not anybody's operand). Returns the index of + * the last token consumed. */ +function skipWrapperFlags(toks, i, wrapper) { + const valueFlags = WRAPPER_VALUE_FLAGS[wrapper] ?? new Set(); + while (toks[i + 1]?.text.startsWith("-")) { + const flag = toks[++i].text; + const value = toks[i + 1]; + if (valueFlags.has(flag) && value && value.text !== PIPE && !value.text.startsWith("-")) i++; + } + return i; +} + +/** Read one segment as a grep launch, or undefined when it isn't one (a + * pipeline starting with another command included). `isSearch` is false for + * pattern-less invocations like `grep --version`; `targets` are the explicit + * path/glob operands, and `chdir` is git's `-C` directory, which moves the + * root those operands resolve against. Misparses fail open downstream. */ +function grepLaunch(segment) { + const toks = tokenize(segment); + let i = 0; + let fallback = false; + // Step over `VAR=1` prefixes and wrapper commands (with their own flags and + // operands) to reach the command that actually runs. + for (; i < toks.length; i++) { + const { text } = toks[i]; + if (ENV_ASSIGNMENT.test(text)) { + if (FALLBACK_MARKER.test(text)) fallback = true; + continue; + } + if (CONTROL_PREFIXES.has(text)) continue; + // A group opener glued to the command (`(rg`, `{rg`, `!rg`) is shell + // syntax, not part of the name: strip it and re-read the token. + const opened = text.replace(/^[({!]+/, ""); + if (opened !== text) { + if (opened === "") continue; + toks[i] = { ...toks[i], text: opened }; + i--; + continue; + } + const wrapper = commandName(text); + if (COMMAND_WRAPPERS.has(wrapper)) { + i = skipWrapperFlags(toks, i, wrapper); + continue; + } + if (wrapper === TIMEOUT_WRAPPER) { + i = skipWrapperFlags(toks, i, TIMEOUT_WRAPPER); + if (toks[i + 1] && TIMEOUT_DURATION.test(toks[i + 1].text)) i++; + continue; + } + break; + } + const name = commandName(toks[i]?.text); + let chdir; + if (name === GIT_LAUNCHER) { + // git's global options sit before the subcommand. `-C dir` also moves the + // search root, so it is carried out: `git -C . grep auth` searches this + // repo, `git -C ../other grep auth` does not. + let j = i + 1; + while (toks[j]?.text.startsWith("-")) { + const flag = toks[j].text; + const joined = flag.includes("="); + if (flag === "-C") chdir = toks[j + 1]?.text; + j += GIT_VALUE_FLAGS.has(flag) && !joined ? 2 : 1; + } + if (toks[j]?.text !== GIT_SUBCOMMAND) return undefined; + i = j + 1; + } else if (GREP_LAUNCHERS.has(name)) i += 1; + else return undefined; + + // First bare operand is the pattern; the rest are targets. A flag that eats + // a separate value shifts this by one, which yields a target no key matches + // - i.e. it fails open, never into a wrong deny. + const targets = []; + let sawPattern = false; + for (; i < toks.length; i++) { + const { text, quoted } = toks[i]; + // The operands end at a pipeline boundary or a comment: `| head -5` and + // `# note` are not paths this search reads. + if (!quoted && (text === PIPE || text.startsWith(COMMENT_START))) break; + if (!quoted && (text === "--" || text.startsWith("-"))) continue; + if (!quoted && REDIRECT_START.test(text)) { + // `rg foo > hits.txt` used to read `hits.txt` as a target that failed + // open. The destination is not a search target, so it is dropped; an + // input redirection (`grep foo < notes.log`) means the search reads a + // stream whose coverage the index cannot speak to, so it is allowed. + if (text.includes("<")) return undefined; + if (OUT_REDIRECT.test(text)) i++; // operator and operand are separate tokens + continue; + } + if (!sawPattern) { + sawPattern = true; + continue; + } + // An unquoted trailing `)` / `}` is group syntax (`( rg auth src )`, + // `{ rg auth src; }`), not part of a filename - a real parenthesised name + // arrives quoted. Left in place it made an uncovered "target" out of thin + // air and flipped the decision open. + const target = quoted ? text : text.replace(/[)}]+$/, ""); + if (target === "") continue; + targets.push(target); + } + return { fallback, targets, isSearch: sawPattern, chdir }; +} + +const decision = (permissionDecision, permissionDecisionReason) => + console.log( + JSON.stringify({ + hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision, permissionDecisionReason }, + }), + ); + +const DENY_REASON = + "code-context: the index fully covers this repo, so grep/rg for code search is disabled. " + + "Use the sql MCP tool - hybrid_search('chunks','content','','embedding', {{q}}, k) for " + + "ranked retrieval, GROUP BY over it for counts/rankings. Piping other command output through " + + "grep is still allowed. If an index search genuinely came up short, re-run this command " + + "prefixed with CX_GREP_FALLBACK=1 to request it; CX_NO_ENFORCE=1 in the environment disables " + + "enforcement entirely."; + +const ASK_REASON = + "code-context fallback: the agent signals an index search came up short and asks to grep " + + "directly. Allow this grep?"; + +let data = ""; +process.stdin.on("data", (c) => (data += c)); +process.stdin.on("end", () => { + let input = {}; + try { + input = JSON.parse(data); + } catch { + return; // unparseable payload: do nothing rather than break the call + } + + // SessionStart only describes the index; the note itself adapts to the kill + // switch, and every enforcing branch sits below it. + if (input.hook_event_name === "SessionStart") { + const note = + process.env.CX_NO_ENFORCE === "1" + ? "code-context is available: search the code with the sql MCP tool via table-valued functions - hybrid_search('chunks','content','','embedding', {{q}}, k) for ranked retrieval, bm25_search for keyword-only, GROUP BY for counts/rankings." + : indexInfo(input.cwd).state === "ready" + ? "code-context is active and the index fully covers this repo: all code search goes through the sql MCP tool via table-valued functions - hybrid_search('chunks','content','','embedding', {{q}}, k) for ranked retrieval (embed map {\"q\":...}), bm25_search for keyword-only, GROUP BY over either for counts/rankings. The Grep tool and standalone grep/rg commands are disabled (grep as a pipe filter on other command output still works; if an index search genuinely came up short, prefix the grep with CX_GREP_FALLBACK=1 to request it)." + : "code-context is available: search the code with the sql MCP tool via table-valued functions - hybrid_search('chunks','content','','embedding', {{q}}, k) for ranked retrieval, bm25_search for keyword-only, GROUP BY for counts/rankings (the index builds as the server starts). grep stays enabled until the index fully covers the repo."; + console.log( + JSON.stringify({ + hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: note }, + }), + ); + return; + } + + // The kill switch, above every enforcement branch - including the legacy + // `search` deny, which used to run in front of it. + if (process.env.CX_NO_ENFORCE === "1") return; + + const toolName = input.tool_name ?? ""; + + // Legacy `search` tool (pre-0.2 servers): sql is the search surface. + if (/code[-_]context.*__search$/.test(toolName)) { + decision( + "deny", + 'code-context: search goes through the sql tool. Ranked retrieval: SELECT path, start_line, end_line, symbol, content FROM hybrid_search(\'chunks\',\'content\',\'\',\'embedding\', {{q}}, 10) with embed {"q":""} - or bm25_search(\'chunks\',\'content\',\'\', 10) while vectors are backfilling. Rank + aggregate composes via GROUP BY.', + ); + return; + } + + if (toolName === "Grep") { + const info = indexInfo(input.cwd); + if (info.state !== "ready") return; + const keys = indexedFiles(info.indexDir); + if (!keys) return; + const path = input.tool_input?.path; + const glob = input.tool_input?.glob; + if (path && !coversTarget(path, input.cwd, info.root, keys)) return; + if (glob) { + // The tool's glob is relative to `path` when one is given. + const base = path + ? isAbsolute(path) + ? path + : resolve(input.cwd || info.root, path) + : input.cwd || info.root; + if (!coversGlob(glob, base, info.root, keys, true)) return; + } + decision("deny", DENY_REASON); + return; + } + + if (toolName === "Bash") { + const command = input.tool_input?.command ?? ""; + const launches = segments(stripHeredocs(command)) + .map(grepLaunch) + .filter((l) => l !== undefined && l.isSearch); + if (launches.length === 0) return; + const info = indexInfo(input.cwd); + if (info.state !== "ready") return; + const keys = indexedFiles(info.indexDir); + if (!keys) return; + for (const launch of launches) { + // `git -C dir` moves the directory relative targets resolve against, and + // with no target of its own it IS the scope of the search - so `git -C . + // grep auth` reads as this repo and `git -C ../other grep auth` as a tree + // the index does not speak for. Any other launch with no explicit target + // is a repo-wide search, which the index covers. + const chdir = launch.chdir + ? isAbsolute(launch.chdir) + ? launch.chdir + : resolve(input.cwd || info.root, launch.chdir) + : undefined; + const targets = launch.targets.length > 0 ? launch.targets : chdir ? [chdir] : []; + if (!targets.every((t) => coversTarget(t, chdir ?? input.cwd, info.root, keys))) continue; + if (launch.fallback) decision("ask", ASK_REASON); + else decision("deny", DENY_REASON); + return; + } + } +}); diff --git a/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 0000000..4ef2c61 --- /dev/null +++ b/hooks/hooks.json @@ -0,0 +1,25 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/deny-grep.mjs\"" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Grep|Bash|mcp__.*code[-_]context.*__search", + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/deny-grep.mjs\"" + } + ] + } + ] + } +} diff --git a/llms.txt b/llms.txt index 8aef780..ea1aa47 100644 --- a/llms.txt +++ b/llms.txt @@ -1,12 +1,13 @@ # code-context > Local code search for AI coding agents: a CLI and an MCP server over an -> index that lives in plain files inside the repo. Keyword (BM25) search is -> live seconds after indexing starts, semantic and hybrid search unlock as -> vectors backfill, and SQL composes ranked search with GROUP BY so "which -> files have the most code about X" is one query. No accounts, no API keys, -> no database server, no telemetry; embedding is a small local model and -> code never leaves the machine. +> index that lives in plain files inside the repo, queried through one door - +> read-only SQL with ranked search table functions. hybrid_search fuses +> keyword (BM25) and semantic ranking in one pass (bm25_search covers the +> window before vectors backfill), and the same relation composes with +> GROUP BY so "which files have the most code about X" is one query. No +> accounts, no API keys, no database server, no telemetry; embedding is a +> small local model and code never leaves the machine. code-context is built on infino (https://github.com/infino-ai/infino), an embedded retrieval engine; the same engine and index format also serve @@ -25,20 +26,38 @@ logs, docs, and agent memory. - Zero-install for Claude Code (recommended; `alwaysLoad` keeps the tools in view when many MCP servers are configured): `claude mcp add-json code-context -s user '{"command":"npx","args":["-y","@infino-ai/code-context","mcp"],"alwaysLoad":true}'`. - A Claude Code plugin (`/plugin marketplace add infino-ai/code-context`) bakes the same config in. -- MCP tools (stdio): `search` (exact terms AND meaning, one ranked pass, - hits carry chunk content with path:line ranges), `sql` (read-only - SELECT/WITH over `chunks(path, start_line, end_line, lang, content)`, - ranked search functions (bm25_search/hybrid_search) usable as table-valued - relations so search composes with GROUP BY), `reindex` (incremental sync; - the server also auto-syncs in the - background). A `search`/`sql` on a never-indexed repo builds the index - inline and answers on the same call (keyword live in seconds, vectors - backfilling); `CX_AUTO_INDEX=0` restores a strict "index it first" error. - Each tool takes an optional `path` (absolute repo root) so one server serves - multiple repos in a session; omit it for the startup root. -- CLI: `cx index` (incremental; `--full`, `--watch`), `cx search`, - `cx sql`, `cx status`, `cx mcp`. + A Claude Code plugin (`/plugin marketplace add infino-ai/code-context`) bakes the same config in + and ships the enforcement hooks below. +- Enforcement (make the index the way an agent searches): the Claude Code + plugin ships the hooks; on every other client - `npx`, `claude mcp + add-json`, Cursor, Windsurf - `cx install` writes them, and it is the only + form there that survives a client restart. It copies + `~/.claude/hooks/cx-deny-grep.mjs` and merges SessionStart + PreToolUse + entries into `~/.claude/settings.json`; idempotent, reversed by + `cx install --uninstall`, retargeted by `--settings `, and it embeds + the absolute node path it ran with because a client process often has no + node on PATH and a hook that cannot find node fails silently. Once a repo's + index fully covers it (vectors ready, nothing over the file cap) the Grep + tool and standalone grep/rg/git grep are denied with a redirect to the sql + search functions; grep as a pipe filter on other output always passes, a + target the index cannot cover (gitignored, over the byte cap, a dot-path, + outside the repo) passes silently, `CX_GREP_FALLBACK=1 ` turns the + deny into an approval prompt, and `CX_NO_ENFORCE=1` disables enforcement. + Until the index is fully built grep is untouched. Use the plugin or + `cx install`, not both. +- MCP tools (stdio): `sql` (the search surface: read-only SELECT/WITH over + `chunks(path, start_line, end_line, lang, symbol, content[, embedding])`, + with hybrid_search/bm25_search/vector_search as table-valued relations - + ranked retrieval and GROUP BY aggregation in one query) and `reindex` + (incremental sync; the server also auto-syncs in + the background). The server builds the index eagerly at startup, and a + query that beats the build still triggers it inline; `CX_AUTO_INDEX=0` + restores a strict "index it first" error. Each tool takes an optional + `path` (absolute repo root) so one server serves multiple repos in a + session; omit it for the startup root. +- CLI: `cx index` (incremental; `--full`, `--watch`), `cx sql`, + `cx status`, `cx usage`, `cx mcp`, `cx install` (`--uninstall`, + `--settings `). ## Evidence diff --git a/package.json b/package.json index b5fc75b..3ce716d 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,8 @@ }, "main": "dist/cli.js", "files": [ - "dist" + "dist", + "hooks" ], "engines": { "node": ">=20" @@ -46,6 +47,7 @@ }, "scripts": { "build": "tsc", + "prepare": "npm run build", "watch": "tsc --watch", "test": "vitest run", "prepack": "npm run build" diff --git a/skills/code-context/SKILL.md b/skills/code-context/SKILL.md index d8da38c..22f5d1b 100644 --- a/skills/code-context/SKILL.md +++ b/skills/code-context/SKILL.md @@ -1,68 +1,72 @@ --- name: code-context description: > - How to answer codebase questions with the code-context MCP tools (search, - sql, reindex): ranked hybrid keyword+semantic search, relevance-ranked SQL - aggregation over the index, and index lifecycle. Use when a question spans - many files ("how does X work", "where is Y handled"), when ranking or - counting code by topic across a repo, or when the code-context tools are - present but deferred and need loading before use. Not needed for jumping - to one known identifier - plain grep is fine there. + How to answer codebase questions with the code-context MCP tools (sql, + reindex): ranked retrieval and relevance-ranked aggregation in one + read-only SQL statement via search table functions, plus index lifecycle. + Use when a question spans many files ("how does X work", "where is Y + handled"), when ranking or counting code by topic across a repo, or when + the code-context tools are present but deferred and need loading before + use. --- -# code-context: ranked search over the repository +# code-context: ranked search over the repository, through SQL code-context maintains a local index of the repository (in `.infino/` at the -repo root) and exposes three MCP tools. The more a question spans the repo, -the more one ranked pass beats crawling files into context. +repo root) and exposes two MCP tools: `sql` and `reindex`. All retrieval goes +through `sql` — ranked search is a table-valued function inside the query, so +finding, counting, and ranking code are all one SELECT. The more a question +spans the repo, the more one ranked query beats crawling files into context. ## If the tools are deferred When the tool names appear in a deferred-tools listing but their schemas are -not loaded, load all three in ONE ToolSearch call before the first use, e.g. -query `+code-context search sql reindex` (or `select:` with the exact -listed names, comma-separated). Never load them one call at a time. +not loaded, load both in ONE ToolSearch call before the first use, e.g. +query `+code-context sql reindex` (or `select:` with the exact listed names, +comma-separated). Never load them one call at a time. -## Choosing the right tool +## Choosing the right query | Situation | Use | | --- | --- | -| One known identifier, literal string, or file | plain grep / file tools | -| "How does X work", "where is Y handled", concept without exact name | `search` | -| Counts, rankings, GROUP BY across the repo ("which files have the most code about X") | `sql` | +| "How does X work", "where is Y handled", concept without exact name | `hybrid_search` in FROM | +| One known identifier or literal string | `bm25_search` in FROM | +| Counts, rankings, GROUP BY across the repo ("which files have the most code about X") | either TVF + `GROUP BY` | | Working tree changed a lot mid-session | `reindex` (usually unnecessary - see lifecycle) | -## search - -- Pass terms, a phrase, or a plain-language description; one pass fuses BM25 - keyword matching with semantic similarity, so it works whether or not you - know the exact words. -- One good search beats several narrow ones - put both the identifiers you - know and the intent into a single query. -- Every hit carries `path`, `startLine`-`endLine`, and the chunk content with - a relevance score. Answer from the chunk content when it suffices, citing - the `path:line` ranges; open a file only for what the chunks don't show. -- If a hit is marked `truncated`, Read exactly its start-end range - (offset/limit), not the whole file. -- `k` (default 10, max 50) bounds hits; raise it for survey-style questions. -- Until the index's vector stage finishes, results say they are - keyword-ranked; they are still real, cited hits. - ## sql One read-only SELECT/WITH statement over the table `chunks(path, start_line, end_line, lang, symbol, content[, embedding])`. -Search functions are callable as table-valued relations, so one query can -rank AND aggregate: - -- `bm25_search('chunks','content','', k)` - keyword ranking, no - embedding needed. -- `hybrid_search('chunks','content','','embedding', {{q}}, k)` and - `vector_search('chunks','embedding', {{q}}, k)` - take a `{{name}}` - placeholder filled via the `embed` argument, e.g. `{"q": "query text"}`. +Search functions are table-valued relations, so ranking happens in the FROM +clause and everything above it is ordinary SQL. + +Finding code is a query: + +```sql +SELECT path, start_line, end_line, symbol, content +FROM hybrid_search('chunks','content','','embedding', {{q}}, 10) +``` + +with the `embed` argument filling `{{q}}`, e.g. `{"q": "where is auth +handled"}`. The functions: + +- `hybrid_search('chunks','content','','embedding', {{q}}, k)` - one + ranked pass fusing BM25 keyword matching with semantic similarity; works + whether or not you know the exact words. The default choice. +- `bm25_search('chunks','content','', k)` - keyword ranking alone, no + embedding needed; use it for exact identifiers and while vectors are still + backfilling. +- `vector_search('chunks','embedding', {{q}}, k)` - semantic ranking alone; + prefer `hybrid_search`, which keeps the keyword arm too. - `regexp_like(content, 'pattern')` works in WHERE. -The canonical move - "which files have the most code about X": +One good query beats several narrow ones - put both the identifiers you know +and the intent into a single terms string. `k` bounds the ranked rows; raise +it for survey-style questions. Never scan the table with `LIKE` when a TVF +can rank. + +The canonical aggregation - "which files have the most code about X": ```sql SELECT path, SUM(end_line - start_line + 1) AS lines, COUNT(*) AS chunks @@ -70,11 +74,18 @@ FROM bm25_search('chunks','content','', 300) GROUP BY path ORDER BY lines DESC LIMIT 15 ``` +Answer from the returned chunk content when it suffices, citing the +`path:start_line-end_line` ranges; open a file only for what the chunks +don't show, and Read exactly that range (offset/limit), not the whole file. + ## Index lifecycle (usually zero-touch) -- **First query in a never-indexed repo auto-builds the index** and answers - on the same call: it returns as soon as keyword search is live (seconds), - while vectors backfill in the background. Do not pre-emptively reindex. +- **The index builds as the server starts**, so it is typically live before + your first query; a query that beats the build still triggers it inline + and answers on the same call. Do not pre-emptively reindex. +- A result noting that **vectors are still backfilling** means + `hybrid_search`/`vector_search` rank keyword-only/partial for the moment; + `bm25_search` is unaffected. The note disappears when vectors are ready. - **Later queries auto-sync**: the server re-chunks only files that changed since the last index. An unchanged tree is a fast no-op. - Call `reindex` explicitly only after sweeping working-tree changes you @@ -88,9 +99,9 @@ GROUP BY path ORDER BY lines DESC LIMIT 15 - A result carrying a `partial` marker means the repo exceeded the index's file cap and some files were left out: treat a missing match as possibly-unindexed, not as proof the code doesn't exist. -- Search and sql results carry a one-line `usage` receipt (tokens returned, - chunks/files, session running total), computed locally. End your reply by - showing that line to the user verbatim. +- Results carry a one-line `usage` receipt (tokens returned, rows, session + running total), computed locally. End your reply by showing that line to + the user verbatim. ## Multi-repo sessions @@ -99,7 +110,7 @@ different repository than the one the server started in. ## Cost awareness -- `search`/`sql` calls are cheap (local, milliseconds). +- `sql` calls are cheap (local, milliseconds). - The first index of a repo and the vector backfill are the expensive part (CPU for the local embedding model, proportional to repo size). Avoid forcing `full: true` rebuilds unless the index is actually wrong, and diff --git a/src/cli.ts b/src/cli.ts index e51eb45..87e3d61 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,32 +4,43 @@ // // code-context / cx - local code search for AI coding agents. +import { readFileSync } from "node:fs"; import { Command } from "commander"; import { indexCmd } from "./commands/index-cmd.js"; -import { searchCmd, sqlCmd, statusCmd, usageCmd } from "./commands/query-cmds.js"; -import { DEFAULT_SEARCH_K } from "./core/config.js"; +import { installCmd } from "./commands/install-cmd.js"; +import { sqlCmd, statusCmd, usageCmd } from "./commands/query-cmds.js"; + +/** The package manifest, one level up from this module both in source + * (`src/cli.ts`) and in the shipped build (`dist/cli.js`). Reading the version + * from it at runtime is what keeps `cx --version` from drifting away from what + * npm published, which a hand-maintained literal here did. */ +const PACKAGE_JSON = new URL("../package.json", import.meta.url); +const { version } = JSON.parse(readFileSync(PACKAGE_JSON, "utf8")) as { version: string }; const program = new Command(); program .name("cx") .description( - "Local code search for AI coding agents - an index in plain files under .infino/.\n" + - "Keyword search seconds after `cx index`; semantic and hybrid search when vectors\n" + - "finish backfilling; SQL with relevance-ranked aggregation over the whole repo.", + "Local code search for AI coding agents - an index in plain files under .infino/,\n" + + "queried through one door: read-only SQL with ranked search table functions.\n" + + "hybrid_search fuses keyword + semantic ranking, bm25_search covers the window\n" + + "before vectors finish backfilling, and GROUP BY turns either into aggregation.", ) - .version("0.1.4") + .version(version) .addHelpText( "after", ` Examples: cx index index the current repo (keyword search is live in seconds) - cx search "parse_config" exact terms and meaning, one ranked pass - cx search "where is auth handled" works when you don't know the words + cx sql "SELECT path, start_line, end_line, symbol, content \\ + FROM hybrid_search('chunks','content','auth handling','embedding', {{q}}, 10)" \\ + --embed "q=where is auth handled" cx sql "SELECT path, SUM(end_line - start_line + 1) AS lines \\ FROM bm25_search('chunks','content','vector index', 300) \\ GROUP BY path ORDER BY lines DESC LIMIT 10" - cx mcp serve the MCP tools (search/sql/reindex) over stdio`, + cx mcp serve the MCP tools (sql/reindex) over stdio + cx install make the index the way Claude Code searches (hooks; --uninstall reverses)`, ); program @@ -43,15 +54,6 @@ program .option("--json", "machine-readable stats") .action(indexCmd); -program - .command("search") - .description("find code: exact terms and meaning in one ranked pass") - .argument("", "what you're looking for") - .option("-k ", "maximum hits", String(DEFAULT_SEARCH_K)) - .option("--json", "machine-readable output") - .option("-C, --path ", "repo root (default: current directory)") - .action(searchCmd); - program .command("sql") .description("read-only SQL over the index, including ranked search table functions") @@ -66,6 +68,17 @@ program .option("-C, --path ", "repo root (default: current directory)") .action(sqlCmd); +program + .command("install") + .description( + "wire index-first enforcement into Claude Code (hooks that steer code search to sql) - " + + "Claude Code only; other MCP clients have no hook surface to configure", + ) + .option("--uninstall", "remove the hooks this command installed") + .option("--settings ", "settings file to edit (default: ~/.claude/settings.json)") + .option("--force", "allow a project-scoped .claude settings file (its paths are machine-specific)") + .action(installCmd); + program .command("status") .description("show what the index holds and how fresh it is") @@ -87,7 +100,7 @@ program program .command("mcp") - .description("serve the MCP tools (search / sql / reindex) over stdio") + .description("serve the MCP tools (sql / reindex) over stdio") .option("-C, --path ", "repo root (default: current directory)") .action(async (opts: { path?: string }) => { const { serveMcp } = await import("./mcp/server.js"); diff --git a/src/commands/install-cmd.ts b/src/commands/install-cmd.ts new file mode 100644 index 0000000..f7b27d1 --- /dev/null +++ b/src/commands/install-cmd.ts @@ -0,0 +1,491 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Infino Authors +// +// `cx install` - wire index-first enforcement into Claude Code, for users who +// reach code-context as a plain MCP server (`npx`, `claude mcp add-json`) +// rather than through the Claude Code plugin. The plugin ships the same hooks +// in its own `hooks/` directory; this command is the path for a hand-wired +// server, and it is the only path that survives a client restart without +// per-session setup. +// +// Claude Code only. The hook schema written here and `~/.claude/settings.json` +// are Claude Code's; other MCP clients (Cursor, Windsurf, ...) run the same +// `sql` and `reindex` tools but expose no hook surface, so there is nothing +// for this command to configure there and it does not pretend otherwise. +// +// What it writes, all idempotent and reversible with --uninstall: +// ~/.claude/hooks/cx-deny-grep.mjs the hook itself, copied from the package +// ~/.claude/settings.json SessionStart + PreToolUse entries +// +// The hook command embeds `process.execPath` - the absolute node that is +// running this install - because the client's process often has no node on +// PATH (a hand-wired MCP entry with an absolute node path is the common case), +// and a hook that cannot find node fails silently, which reads exactly like +// "enforcement isn't working". The same reasoning makes every path absolute +// and makes an unresolvable home directory an error: a relative settings or +// script path resolves against whatever directory the client happens to be +// in, so it would be written successfully and never run. +// +// The settings file belongs to the user - their other hooks, their unrelated +// keys - so three rules hold everywhere below: ownership is decided per hook +// *command* (the resolved script path we copied in) and never per entry; the +// rewrite is a temp file plus rename beside the *real* file the path resolves +// to, so a crash cannot truncate it and a dotfile symlink survives it; and +// anything we cannot parse is refused rather than overwritten. + +import { + copyFileSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + readlinkSync, + realpathSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { basename, dirname, isAbsolute, join, resolve } from "node:path"; +import { homedir } from "node:os"; +import { fileURLToPath } from "node:url"; +import { bold, dim, green, yellow } from "../core/output.js"; + +/** Name the packaged hook is copied to, inside the settings directory. */ +const HOOK_BASENAME = "cx-deny-grep.mjs"; + +/** Subdirectory of the settings directory that holds hook scripts. */ +const HOOK_DIR = "hooks"; + +/** Claude Code's settings directory and its default settings file. */ +const CLAUDE_DIR = ".claude"; +const SETTINGS_BASENAME = "settings.json"; + +/** Tool names the PreToolUse hook inspects: the built-in search tools plus a + * legacy code-context `search` tool from a pre-2.0 server. */ +const PRE_TOOL_MATCHER = "Grep|Bash|mcp__.*code[-_]context.*__search"; + +/** The hook events this command owns. Every other event in the file, and + * every hook in these two that is not ours, is left exactly as found. */ +const OUR_EVENTS = ["SessionStart", "PreToolUse"] as const; + +/** Files beside the target a live hook command can hide in. Claude Code loads + * `settings.json` and `settings.local.json` from one directory and `--settings` + * takes any name at all, so the sibling scan reads every JSON file there + * rather than guessing at names: they share one hooks/ directory, and the + * script we would delete is the one the survivor runs. */ +const JSON_FILE = /\.json$/i; + +/** Trailing argument of a hook command, quoted or bare - where our entries + * carry the script path. */ +const QUOTED_TAIL = /"([^"]*)"\s*$/; +const BARE_TAIL = /(\S+)\s*$/; + +/** Indent for the settings file we write back, matching Claude Code's own. */ +const SETTINGS_INDENT = 2; + +/** Suffix of the temp file the atomic settings replace goes through. */ +const TMP_SUFFIX = ".cx-tmp"; + +export interface InstallCmdOptions { + /** Remove the hook and its settings entries instead of installing. */ + uninstall?: boolean; + /** Target a settings file other than ~/.claude/settings.json. */ + settings?: string; + /** Install into a project-scoped `.claude` directory anyway - only sensible + * for a settings file that stays on this machine (a gitignored + * settings.local.json); see the refusal in `installCmd`. */ + force?: boolean; +} + +interface HookCommand { + type: string; + command: string; +} + +interface HookEntry { + matcher?: string; + hooks: HookCommand[]; +} + +type Settings = Record & { hooks?: unknown }; + +/** A failure the user can act on. The CLI layer prints `error: ` and + * sets the exit code; nothing in here calls `process.exit`, so every branch + * stays reachable from a test. */ +export class InstallError extends Error { + constructor(message: string) { + super(message); + this.name = "InstallError"; + } +} + +/** `null`, `an array`, `a string`: enough for the user to see what they have. */ +function describeJson(value: unknown): string { + if (value === null) return "null"; + if (Array.isArray(value)) return "an array"; + return `a ${typeof value}`; +} + +/** The user's home directory, as an absolute path. `os.homedir()` answers "" + * under `env -i`, systemd units, and some CI runners; joining that yields a + * relative settings path that lands in the current working directory and a + * relative script path in the hook command. Both look like success and + * neither works, so this fails loudly instead. */ +function resolveHome(): string { + const home = homedir(); + if (!isAbsolute(home)) { + throw new InstallError( + `cannot resolve your home directory - os.homedir() returned ${JSON.stringify(home)}. ` + + `Set HOME to an absolute path, e.g. HOME=/home/you cx install, and re-run.`, + ); + } + return home; +} + +/** Parse the settings file, or `{}` when there is none. A file we cannot read + * as a JSON object is the user's to fix: Claude Code settings are plain JSON + * (no comments, no trailing commas), and overwriting a file we did not + * understand would cost them every setting in it, not just our hooks. */ +function readSettings(settingsPath: string): Settings { + if (!existsSync(settingsPath)) return {}; + const text = readFileSync(settingsPath, "utf8"); + if (text.trim() === "") return {}; + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (err) { + throw new InstallError( + `${settingsPath} is not valid JSON: ${(err as Error).message}. Claude Code settings are plain ` + + `JSON - no comments, no trailing commas. Fix the file (or move it aside) and re-run.`, + ); + } + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new InstallError( + `${settingsPath} holds ${describeJson(parsed)}, not a JSON object of settings. ` + + `Fix the file (or move it aside) and re-run.`, + ); + } + return parsed as Settings; +} + +/** The `hooks` block, validated far enough that our edits cannot fail halfway + * through: the block must be an object, and each event we touch must be an + * array of entries. Events we do not touch pass through unread. */ +function readHooks(settings: Settings, settingsPath: string): Record { + const raw = settings.hooks; + if (raw === undefined) return {}; + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { + throw new InstallError( + `"hooks" in ${settingsPath} is ${describeJson(raw)}, not an object of hook events. ` + + `Fix the file and re-run.`, + ); + } + const hooks = raw as Record; + for (const event of OUR_EVENTS) { + const entries: unknown = hooks[event]; + if (entries !== undefined && !Array.isArray(entries)) { + throw new InstallError( + `"hooks.${event}" in ${settingsPath} is ${describeJson(entries)}, not an array of hook ` + + `entries. Fix the file and re-run.`, + ); + } + } + return hooks; +} + +/** The trailing argument of a hook command, without its quotes. */ +function trailingArg(command: string): string { + const quoted = QUOTED_TAIL.exec(command); + if (quoted) return quoted[1]; + const bare = BARE_TAIL.exec(command); + return bare ? bare[1] : ""; +} + +/** Does this hook run the script we copied in? Keyed on the resolved path we + * wrote, not on the basename: a wrapper of the user's own that merely names + * `cx-deny-grep.mjs` on its command line is not ours to remove. */ +function runsOurHook(hook: unknown, hookPath: string): boolean { + const command = (hook as HookCommand | null)?.command; + return typeof command === "string" && trailingArg(command) === hookPath; +} + +/** One event's entries with our hook taken out. The filter runs at the inner + * level: an entry the user shares with us keeps its matcher and its own + * hooks, and only disappears when ours was the last hook in it. Entries that + * are not the shape we write pass through untouched. */ +function withoutOurHook(entries: HookEntry[], hookPath: string): HookEntry[] { + const kept: HookEntry[] = []; + for (const entry of entries) { + const inner = (entry as { hooks?: unknown } | null)?.hooks; + if (!Array.isArray(inner)) { + kept.push(entry); + continue; + } + const others = inner.filter((hook) => !runsOurHook(hook, hookPath)); + if (others.length === inner.length) kept.push(entry); + else if (others.length > 0) kept.push({ ...entry, hooks: others }); + } + return kept; +} + +/** Does this settings text still run `hookPath`? A file we cannot parse counts + * as a reference when it names our script at all: keeping a hook file nothing + * runs is harmless, deleting one that is still wired up breaks every tool call + * in that session with MODULE_NOT_FOUND. */ +function referencesHook(text: string, hookPath: string): boolean { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return text.includes(HOOK_BASENAME); + } + const hooks = (parsed as Settings | null)?.hooks; + if (hooks === null || typeof hooks !== "object") return false; + return Object.values(hooks as Record).some( + (entries) => + Array.isArray(entries) && + entries.some((entry) => { + const inner = (entry as { hooks?: unknown } | null)?.hooks; + return Array.isArray(inner) && inner.some((hook) => runsOurHook(hook, hookPath)); + }), + ); +} + +/** Settings files beside `settingsPath` that still run `hookPath`. Claude Code + * loads settings.json and settings.local.json from the same directory and the + * hook path we write is identical for both, so uninstalling one must not + * delete the script the other still points at. The file we are uninstalling is + * skipped by real path, so a link to it does not count as a second user. */ +function siblingsUsingHook(settingsDir: string, settingsPath: string, hookPath: string): string[] { + let names: string[]; + try { + names = readdirSync(settingsDir); + } catch { + return []; + } + const self = realTarget(settingsPath); + const found: string[] = []; + for (const name of names) { + const candidate = join(settingsDir, name); + if (!JSON_FILE.test(name) || realTarget(candidate) === self) continue; + let text: string; + try { + text = readFileSync(candidate, "utf8"); + } catch { + continue; + } + if (referencesHook(text, hookPath)) found.push(candidate); + } + return found; +} + +/** The file a path really names: the link target when it is a symlink, the + * path itself when it is not or does not exist yet. Dotfile managers make + * `~/.claude/settings.json` a symlink into a version-controlled directory, so + * every step that replaces or identifies the file has to follow the link - + * writing a new file at the link's own path would leave the tracked copy + * holding stale bytes, with the user's settings silently no longer applying. */ +function realTarget(path: string): string { + try { + return realpathSync(path); + } catch { + // realpath fails on a link whose target does not exist yet - a dotfiles + // checkout that has not happened. Follow the link by hand so the write + // creates the tracked file instead of replacing the link with a copy. + try { + return resolve(dirname(path), readlinkSync(path)); + } catch { + // Not a link, or nothing there at all: the path is its own target. + return path; + } + } +} + +/** Replace the settings file in one filesystem step. A plain write truncates + * first, so a crash - or a client reading while we write - can leave an empty + * settings.json, losing every setting the user has. The rename lands on the + * real file behind any symlink, and the temp file sits in that file's own + * directory so the rename cannot cross a filesystem boundary. */ +function writeSettings(settingsPath: string, settings: Settings): void { + const target = realTarget(settingsPath); + const dir = dirname(target); + mkdirSync(dir, { recursive: true }); + const json = JSON.stringify(settings, null, SETTINGS_INDENT) + "\n"; + // A hardlinked settings file must be written in place: the rename swaps in a + // new inode, quietly unlinking the file from its other name, so a dotfiles + // copy keeps the old bytes forever. realpath cannot see a hardlink, so this + // is decided on the link count, trading the atomic replace for keeping both + // names one file. + let links = 0; + try { + links = statSync(target).nlink; + } catch { + // No file yet - the rename path below creates it. + } + if (links > 1) { + writeFileSync(target, json); + return; + } + const tmp = join(dir, `${basename(target)}.${process.pid}${TMP_SUFFIX}`); + try { + writeFileSync(tmp, json); + renameSync(tmp, target); + } catch (err) { + rmSync(tmp, { force: true }); + throw err; + } +} + +/** Does this settings file belong to a project checkout rather than the user? + * A `.claude` directory anywhere but the home one is a repo's: that file is + * shareable and usually version-controlled. The home lookup runs only for a + * `.claude` target, so any other --settings path stays usable without one. + * The comparison is on real paths: a relocated or symlinked home (HOME as a + * link, macOS /tmp -> /private/tmp) spells the same directory two ways, and + * refusing the user's own settings file over spelling is a false alarm. */ +function isProjectScoped(settingsDir: string): boolean { + if (basename(settingsDir) !== CLAUDE_DIR) return false; + const homeClaude = join(resolveHome(), CLAUDE_DIR); + if (settingsDir === homeClaude) return false; + return realTarget(settingsDir) !== realTarget(homeClaude); +} + +/** The packaged hook source: `/hooks/deny-grep.mjs`, resolved + * from this module's location so it works from a global install, an npx + * cache, or a local checkout alike. */ +function packagedHook(): string { + // dist/commands/install-cmd.js -> package root is two levels up. + const here = dirname(fileURLToPath(import.meta.url)); + return resolve(here, "..", "..", HOOK_DIR, "deny-grep.mjs"); +} + +/** Remove our hooks from one settings file, then the script - but only once no + * sibling settings file in that directory still runs it. */ +function uninstall(settingsPath: string, settingsDir: string, hookPath: string): void { + if (!existsSync(settingsPath)) { + // Creating a settings file (and a .claude directory) to prove it holds + // none of our hooks would be a strange thing for a removal to do. + console.log(`${dim("nothing to remove")} - ${settingsPath} does not exist`); + return; + } + + const settings = readSettings(settingsPath); + const hooks = readHooks(settings, settingsPath); + const before = JSON.stringify(hooks); + for (const event of OUR_EVENTS) { + const rest = withoutOurHook(hooks[event] ?? [], hookPath); + if (rest.length > 0) hooks[event] = rest; + else delete hooks[event]; + } + const changed = JSON.stringify(hooks) !== before; + + if (changed) { + // Ours were the only entries: leave no empty "hooks" husk behind. + if (Object.keys(hooks).length > 0) settings.hooks = hooks; + else delete settings.hooks; + writeSettings(settingsPath, settings); + console.log(`${green("removed")} code-context enforcement from ${bold(settingsPath)}`); + console.log(dim(" start a new Claude Code session for the removal to take effect")); + } else { + console.log(`${dim("nothing to remove")} - no code-context hooks in ${settingsPath}`); + } + + if (existsSync(hookPath)) { + const stillUsed = siblingsUsingHook(settingsDir, settingsPath, hookPath); + if (stillUsed.length === 0) rmSync(hookPath, { force: true }); + else console.log(dim(` kept ${hookPath} - still run by ${stillUsed.join(", ")}`)); + } +} + +export function installCmd(opts: InstallCmdOptions): void { + const settingsPath = opts.settings + ? resolve(opts.settings) + : join(resolveHome(), CLAUDE_DIR, SETTINGS_BASENAME); + const settingsDir = dirname(settingsPath); + const hookPath = join(settingsDir, HOOK_DIR, HOOK_BASENAME); + // `resolve` and the home join both produce absolute paths; assert it before + // the path reaches a hook command, where a relative script would resolve + // against the client's working directory and quietly never run. + if (!isAbsolute(hookPath)) { + throw new InstallError( + `refusing to write the relative hook path ${hookPath} into a hook command - ` + + `pass an absolute path to --settings.`, + ); + } + + if (opts.uninstall) { + uninstall(settingsPath, settingsDir, hookPath); + return; + } + + // Everything we write is specific to this machine, so a settings file meant + // to be shared is the one place it must not go. Uninstall stays allowed + // above, so a --force install can always be undone. + if (isProjectScoped(settingsDir) && !opts.force) { + throw new InstallError( + [ + `${settingsPath} looks like a project-scoped Claude Code settings file, not your own.`, + `Its entries would carry absolute paths from this machine (${process.execPath}, plus a hook` + + ` file copied inside the project), so a teammate who checks the file out gets` + + ` MODULE_NOT_FOUND on every tool call instead of enforcement.`, + `To share enforcement with a team, use the code-context plugin for Claude Code - it resolves` + + ` the hook from the plugin root on each machine.`, + `To install it for yourself, run \`cx install\` with no --settings. Add --force only for a` + + ` settings file that stays on this machine, such as a gitignored settings.local.json.`, + ].join("\n"), + ); + } + + const source = packagedHook(); + if (!existsSync(source)) { + throw new InstallError( + `packaged hook not found at ${source} - this copy of the package is missing its hooks/ ` + + `directory. Reinstall it (npm i -g @infino-ai/code-context) and re-run.`, + ); + } + // Parse and validate before writing anything, so a settings file we refuse + // does not leave a freshly copied hook behind. + const settings = readSettings(settingsPath); + const hooks = readHooks(settings, settingsPath); + // A script already there was put there by an install that also wrote the + // settings entries running it, so it stays whatever happens below; one we + // copy in now is only ever reachable through the settings we are about to + // write, and has to go with them if that write fails. + const hookExisted = existsSync(hookPath); + mkdirSync(dirname(hookPath), { recursive: true }); + copyFileSync(source, hookPath); + + // Absolute node + absolute hook path: no PATH assumptions, no cwd assumptions. + const command = `"${process.execPath}" "${hookPath}"`; + hooks.SessionStart = [ + ...withoutOurHook(hooks.SessionStart ?? [], hookPath), + { hooks: [{ type: "command", command }] }, + ]; + hooks.PreToolUse = [ + ...withoutOurHook(hooks.PreToolUse ?? [], hookPath), + { matcher: PRE_TOOL_MATCHER, hooks: [{ type: "command", command }] }, + ]; + settings.hooks = hooks; + try { + writeSettings(settingsPath, settings); + } catch (err) { + if (!hookExisted) rmSync(hookPath, { force: true }); + throw err; + } + + console.log(`${bold("code-context")} enforcement installed for ${bold("Claude Code")}`); + console.log(` wrote ${settingsPath}`); + console.log(` hook ${hookPath}`); + console.log(` node ${process.execPath}`); + console.log(""); + console.log("Once a repo's index fully covers it (vectors ready, nothing over the file cap),"); + console.log("the Grep tool and standalone grep/rg are denied with a redirect to the sql tool."); + console.log(dim(" grep as a pipe filter on other output always passes")); + console.log(dim(" a target the index cannot cover (gitignored, oversized, outside the repo) passes")); + console.log(dim(" CX_GREP_FALLBACK=1 asks instead of denying; CX_NO_ENFORCE=1 disables")); + console.log(dim(" Claude Code only - Cursor, Windsurf and other MCP clients have no hook surface")); + console.log(""); + console.log(yellow("start a new Claude Code session to load the hooks")); + console.log(dim(" reverse with: cx install --uninstall")); +} diff --git a/src/commands/query-cmds.ts b/src/commands/query-cmds.ts index fe72707..b8ad333 100644 --- a/src/commands/query-cmds.ts +++ b/src/commands/query-cmds.ts @@ -1,15 +1,14 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Infino Authors // -// `cx search` / `cx sql` / `cx status` - the query commands. +// `cx sql` / `cx status` / `cx usage` - the query commands. import { openIndex, NoIndexError } from "../core/context.js"; import { indexDir, resolveRoot } from "../core/config.js"; import { createEmbedder, embedderInfo } from "../core/embedder.js"; -import { search, runSql, jsonify } from "../core/searcher.js"; +import { runSql, jsonify } from "../core/searcher.js"; import { receiptEnabled, - searchEntry, sqlEntry, formatReceipt, recordUsage, @@ -19,7 +18,7 @@ import { recordHookEvent, currentSessionStats, } from "../core/usage.js"; -import { bold, dim, cyan, yellow, green, table, fmtAge, fmtCount, fmtMs } from "../core/output.js"; +import { bold, dim, cyan, yellow, table, fmtAge, fmtCount, fmtMs } from "../core/output.js"; function die(err: unknown): never { const msg = err instanceof NoIndexError ? err.message : `error: ${(err as Error).message}`; @@ -27,39 +26,6 @@ function die(err: unknown): never { process.exit(1); } -export interface SearchCmdOptions { - k: string; - json?: boolean; - path?: string; -} - -export async function searchCmd(query: string, opts: SearchCmdOptions): Promise { - try { - const handle = openIndex(opts.path); - const result = await search(handle, createEmbedder(), query, Number(opts.k)); - if (receiptEnabled()) { - const entry = searchEntry(result, handle.root); - recordUsage(handle.dir, entry); - console.error(dim(formatReceipt(entry))); - } - if (opts.json) { - console.log(jsonify(result, true)); - return; - } - if (result.note) console.error(yellow(`note: ${result.note}`)); - if (result.partial) console.error(yellow(`warning: ${result.partial.note}`)); - result.hits.forEach((h, i) => { - console.log( - `${bold(String(i + 1) + ".")} ${cyan(h.path)}${dim(`:${h.startLine}-${h.endLine}`)} ${dim(`(${result.ranking} ${h.score.toFixed(3)})`)}`, - ); - console.log(` ${h.content.split("\n").slice(0, 5).join("\n ")}\n`); - }); - if (result.hits.length === 0) console.error(yellow("no hits")); - } catch (err) { - die(err); - } -} - export interface SqlCmdOptions { embed?: string[]; json?: boolean; @@ -111,7 +77,7 @@ export function statusCmd(opts: StatusCmdOptions): void { console.log( `code-context index: ${fmtCount(m.chunks)} chunks from ${fmtCount(m.files)} files, ` + `vectors ${m.vectors}, indexed ${fmtAge(m.indexedAt)}. ` + - `MCP tools: search (terms + meaning), sql (aggregation), reindex (after big edits).`, + `MCP tools: sql (ranked search TVFs + aggregation), reindex (after big edits).`, ); return; } @@ -187,7 +153,7 @@ export async function usageCmd(opts: UsageCmdOptions): Promise { return; } if (entries.length === 0 && (!session || session.prompts === 0)) { - console.error(yellow("no usage recorded yet - run `cx search`/`cx sql` here, or query via the MCP server")); + console.error(yellow("no usage recorded yet - run `cx sql` here, or query via the MCP server")); return; } @@ -210,19 +176,9 @@ export async function usageCmd(opts: UsageCmdOptions): Promise { const clock = new Date(e.ts).toLocaleTimeString("en-US", { hour12: false }); const tool = e.tool.padEnd(6); const q = cyan(`"${truncate(e.query, 52)}"`); - if (e.tool === "search") { - const hits = e.hits ?? []; - const files = new Set(hits.map((h) => h.path)).size; - console.log( - `${dim(clock)} ${bold(tool)} ${q} ${dim(`-> ${hits.length} hits / ${files} files | ~${fmtTokens(e.returnedTokens)} tok | ${e.ranking ?? "?"}`)}`, - ); - const locs = hits.slice(0, 5).map((h) => `${h.path}:${h.startLine}-${h.endLine}`); - if (locs.length) console.log(green(` ${locs.join(" ")}${hits.length > 5 ? dim(` (+${hits.length - 5} more)`) : ""}`)); - } else { - console.log( - `${dim(clock)} ${bold(tool)} ${q} ${dim(`-> ${e.rows ?? 0} rows | ~${fmtTokens(e.returnedTokens)} tok`)}`, - ); - } + console.log( + `${dim(clock)} ${bold(tool)} ${q} ${dim(`-> ${e.rows ?? 0} rows | ~${fmtTokens(e.returnedTokens)} tok`)}`, + ); } } diff --git a/src/core/config.ts b/src/core/config.ts index 810a4ab..d5e0cda 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -54,6 +54,3 @@ export const EMBED_BATCH = 32; * files) without changing retrieval. */ export const EMBED_MAX_CHARS = Number(process.env.CX_EMBED_MAX_CHARS ?? 8000); -/** Default number of search hits. Configurable per call (the `k` tool param / - * CLI `-k`) and via CX_SEARCH_K for config/CI-level defaults. */ -export const DEFAULT_SEARCH_K = Number(process.env.CX_SEARCH_K ?? 10); diff --git a/src/core/filestate.ts b/src/core/filestate.ts index 8a0528e..4c86b52 100644 --- a/src/core/filestate.ts +++ b/src/core/filestate.ts @@ -17,6 +17,17 @@ export interface FileEntry { size: number; mtimeMs: number; hash: string; + /** Rows this file contributed to the `chunks` table - `0` for a file that is + * fingerprinted but produced none (binary bytes behind an indexable + * extension, an empty `__init__.py`, a bare barrel `index.ts`). The state has + * to fingerprint those anyway, or every sync rediscovers them as "added"; + * the count is what keeps the state from reading as a superset of the + * queryable table. The enforcement hook needs exactly that distinction: it + * may only deny grep on a path sql can answer for, so a file with no rows + * must not count as covered. Optional, and absent means "unknown" - state + * written before the field existed still reads, and the hook treats those + * entries as covered. */ + chunks?: number; } export interface FileState { @@ -57,7 +68,10 @@ export interface RepoDiff { deleted: string[]; /** Candidate files whose size+mtime matched the stored entry (not hashed). */ unchanged: number; - /** The next state to persist after the sync applies. */ + /** The next state to persist after the sync applies. Entries carried over + * from the previous state keep their chunk count; `added`/`changed` entries + * are stamped by the caller, which is the only place the new count is + * knowable. */ next: FileState; } @@ -92,8 +106,12 @@ export function diffFiles( if (buf === undefined) continue; // racing delete - drops out of the index const hash = hashContent(buf); if (before && before.hash === hash) { - // touched but identical - refresh the stat fingerprint only - next.files[c.path] = { size: c.size, mtimeMs: c.mtimeMs, hash }; + // Touched but identical - refresh the stat fingerprint only, and carry + // the chunk count over: identical content chunks identically, and the + // caller never re-chunks this file to restamp it. + const refreshed: FileEntry = { size: c.size, mtimeMs: c.mtimeMs, hash }; + if (before.chunks !== undefined) refreshed.chunks = before.chunks; + next.files[c.path] = refreshed; unchanged++; continue; } diff --git a/src/core/indexer.ts b/src/core/indexer.ts index 986c284..ab67000 100644 --- a/src/core/indexer.ts +++ b/src/core/indexer.ts @@ -56,6 +56,7 @@ import { hashContent, readFileState, writeFileState, + type FileEntry, type FileState, } from "./filestate.js"; import type { Embedder } from "./embedder.js"; @@ -177,14 +178,20 @@ export async function indexRepoStaged(opts: IndexOptions): Promise { // while the indexed content doesn't. Reconcile the manifest when it drifts, // otherwise the "index is partial" marker goes stale. if (truncatedFiles !== (manifest.truncatedFiles ?? 0)) { - const reconciled: Manifest = { ...manifest }; + const reconciled: Manifest = { ...manifest, root }; if (truncatedFiles > 0) { reconciled.truncatedFiles = truncatedFiles; reconciled.maxFiles = caps.maxFiles; @@ -663,8 +670,15 @@ export async function syncRepo(opts: IndexOptions): Promise { delete diff.next.files[path]; continue; } + // The diff stamped size/mtime/hash; the row count is only knowable + // here, and is recorded as 0 for a file that yields none (dropping the + // entry instead would make every later sync re-hash the file). + const entry = diff.next.files[path]; + entry.chunks = 0; if (looksBinary(buf)) continue; - for (const c of await chunkFile(path, buf.toString("utf8"))) { + const fileChunks = await chunkFile(path, buf.toString("utf8")); + entry.chunks = fileChunks.length; + for (const c of fileChunks) { chunksAdded++; await chunkWriter.write(JSON.stringify(c) + "\n"); } @@ -709,6 +723,9 @@ export async function syncRepo(opts: IndexOptions): Promise { for (const r of langRows) languages[r.lang || "other"] = Number(r.n); const nextManifest: Manifest = { ...manifest, + // Re-stamped every sync so a moved checkout (or a manifest written before + // `root` existed) converges on the tree the sync actually walked. + root, files: Number(fileCount), chunks: Number(chunkCount), languages, @@ -808,10 +825,13 @@ function toTextRow(c: Chunk) { }; } -function toManifest(stats: IndexStats, embedder?: Manifest["embedder"]): Manifest { +/** `root` is recorded so readers can map the index back to its tree even when + * the index dir lives outside the repo (a custom CX_INDEX_DIR). */ +function toManifest(root: string, stats: IndexStats, embedder?: Manifest["embedder"]): Manifest { return { version: INDEX_FORMAT_VERSION, table: TABLE, + root, vectors: stats.vectors, ...(embedder ? { embedder } : {}), files: stats.files, diff --git a/src/core/manifest.ts b/src/core/manifest.ts index d4d69ae..78c3744 100644 --- a/src/core/manifest.ts +++ b/src/core/manifest.ts @@ -31,6 +31,12 @@ export interface Manifest { version: number; /** Table name the index lives in (always `chunks` today). */ table: string; + /** Absolute repo root this index was built from. Optional: manifests written + * before it existed omit it. It matters when the index does NOT sit in the + * repo (a custom CX_INDEX_DIR), where nothing else on disk ties the index + * back to its tree - tools that reason about repo-relative paths (the + * enforcement hook) would otherwise have to guess from a cwd. */ + root?: string; vectors: VectorState; embedder?: EmbedderInfo; files: number; diff --git a/src/core/searcher.ts b/src/core/searcher.ts index 0b8f9b7..5d1348b 100644 --- a/src/core/searcher.ts +++ b/src/core/searcher.ts @@ -1,19 +1,15 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Infino Authors // -// The two retrieval doors, shared by the CLI and the MCP server: +// The one retrieval door, shared by the CLI and the MCP server: // -// search - the finding door: one ranked pass fuses exact keyword matching -// (BM25) with semantic similarity (vectors, RRF) once vectors are -// ready; ranked keyword search until then. Hits carry chunk -// content, so answers come straight from results. -// sql - the power door: read-only SQL over the index, built on the search -// table functions (bm25_search / hybrid_search) composed with -// GROUP BY, with {{name}} placeholders embedded server-side for the -// vector functions. +// sql - read-only SQL over the index. Ranked retrieval is a table-valued +// function inside the query (hybrid_search for the fused keyword + +// semantic pass, bm25_search for keyword-only), so finding, counting, +// and ranking code are all one SELECT. {{name}} placeholders are +// embedded server-side for the vector functions. import type { IndexHandle } from "./context.js"; -import { TABLE, DEFAULT_SEARCH_K } from "./config.js"; import type { Embedder } from "./embedder.js"; import type { Manifest } from "./manifest.js"; @@ -26,8 +22,8 @@ export interface PartialIndex { } /** Build the partial-index marker from a manifest, or undefined when the whole - * tree was indexed. Shared by search (below) and the SQL path (server-side), - * so every query surfaces the same "results may be incomplete" signal. */ + * tree was indexed - every query surfaces the same "results may be incomplete" + * signal. */ export function partialIndex(manifest: Manifest): PartialIndex | undefined { if (!manifest.truncatedFiles) return undefined; const cap = manifest.maxFiles ?? 0; @@ -41,6 +37,20 @@ export function partialIndex(manifest: Manifest): PartialIndex | undefined { }; } +/** Agent-facing readiness note while the vector stage has not finished: + * hybrid_search / vector_search rank keyword-only or partial until the + * backfill lands, while bm25_search is unaffected. The old search() surfaced + * this on every call; the SQL surface must too, or an early query on an + * eagerly-building index silently reads as full hybrid recall. Undefined + * once vectors are ready. */ +export function vectorsNote(manifest: Manifest): string | undefined { + if (manifest.vectors === "ready") return undefined; + return ( + "vectors are still backfilling - hybrid_search/vector_search rank keyword-only or partial " + + "results right now; bm25_search is unaffected. This note disappears when vectors are ready." + ); +} + /** JSON.stringify that survives the engine's bigint row values. */ export function jsonify(value: unknown, pretty = false): string { return JSON.stringify( @@ -50,87 +60,6 @@ export function jsonify(value: unknown, pretty = false): string { ); } -// --- search ----------------------------------------------------------------- - -export interface SearchHit { - path: string; - startLine: number; - endLine: number; - lang: string; - score: number; - content: string; - /** Definition name(s) in this chunk (e.g. "parseConfig"), when known. */ - symbol?: string; - /** Set when content was capped - Read path:startLine-endLine for the rest. */ - truncated?: boolean; -} - -/** Per-hit content cap: enough to answer "how does X work" from the hit - * itself (a whole ~60-line chunk fits; only pathological chunks truncate). */ -const HIT_CONTENT_CAP = 4000; - -export interface SearchResult { - query: string; - /** "hybrid" once vectors are ready; "keyword" while they backfill. */ - ranking: "hybrid" | "keyword"; - hits: SearchHit[]; - note?: string; - /** Present when the index omitted files over the cap - results may be incomplete. */ - partial?: PartialIndex; -} - -const PROJECTION = ["path", "start_line", "end_line", "lang", "symbol", "content", "score"]; - -export async function search( - handle: IndexHandle, - embedder: Embedder, - query: string, - k = DEFAULT_SEARCH_K, -): Promise { - const table = handle.db.openTable(TABLE); - let rows: Array>; - let ranking: "hybrid" | "keyword"; - if (handle.manifest.vectors === "ready") { - const indexed = handle.manifest.embedder; - if (indexed && indexed.model !== embedder.model) { - throw new Error( - `query embedder (${embedder.model}) does not match the index embedder (${indexed.model}) - ` + - `set CX_EMBED_MODEL=${indexed.model} or re-run \`cx index\``, - ); - } - const [vector] = await embedder.embed([query]); - rows = table.hybridSearch("content", query, "embedding", vector, k, { projection: PROJECTION }); - ranking = "hybrid"; - } else { - rows = table.bm25Search("content", query, k, { projection: PROJECTION }); - ranking = "keyword"; - } - return { - query, - ranking, - hits: rows.map((r) => { - const full = String(r.content); - return { - path: String(r.path), - startLine: Number(r.start_line), - endLine: Number(r.end_line), - lang: String(r.lang ?? ""), - score: Number(r.score), - ...(r.symbol ? { symbol: String(r.symbol) } : {}), - content: full.slice(0, HIT_CONTENT_CAP), - ...(full.length > HIT_CONTENT_CAP ? { truncated: true } : {}), - }; - }), - ...(ranking === "keyword" && handle.manifest.vectors !== "ready" - ? { note: "vectors not ready yet - keyword-ranked only (re-run `cx index` or wait for the vector stage to finish)" } - : {}), - ...(() => { - const partial = partialIndex(handle.manifest); - return partial ? { partial } : {}; - })(), - }; -} - // --- sql -------------------------------------------------------------------- const PLACEHOLDER = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g; @@ -182,9 +111,8 @@ export async function runSql( sql: string, embeds?: Record, ): Promise>> { - // The mismatch guard applies to every path that embeds a query, not just - // `search` - a same-dimension model swap would otherwise return silently - // wrong vector_search/hybrid_search results through SQL. + // Embedder mismatch guard: a same-dimension model swap would otherwise + // return silently wrong hybrid_search results. if (PLACEHOLDER.test(sql)) { PLACEHOLDER.lastIndex = 0; const indexed = handle.manifest.embedder; diff --git a/src/core/usage.ts b/src/core/usage.ts index 4d0b67c..6b17c09 100644 --- a/src/core/usage.ts +++ b/src/core/usage.ts @@ -7,9 +7,9 @@ // estimate (we can't run the agent's tokenizer), and nothing leaves the // machine: the ledger is a plain JSONL file inside the repo's index dir. -import { appendFileSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; -import { join, resolve } from "node:path"; -import { jsonify, type SearchResult } from "./searcher.js"; +import { appendFileSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { jsonify } from "./searcher.js"; /** Rough tokens-per-char - the standard heuristic for English + code. Kept * deliberately simple: usage reports `~` figures, not a billed count. */ @@ -38,52 +38,14 @@ export const receiptEnabled = (): boolean => * it doesn't duplicate the repo. */ export interface UsageEntry { ts: string; - tool: "search" | "sql"; + tool: "sql"; query: string; returnedTokens: number; - /** search only: whole-file size of the distinct files the hits came from. */ - wholeFileTokens?: number | null; - ranking?: "hybrid" | "keyword"; - /** search only: the response, as the regions you'd jump to. */ - hits?: Array<{ path: string; startLine: number; endLine: number }>; - /** sql only. */ rows?: number; - /** sql only: a truncated preview of the returned rows (the answer itself). */ + /** A truncated preview of the returned rows (the answer itself). */ rowsPreview?: string; } -/** Best-effort sum of the on-disk size (as tokens) of the distinct files the - * hits came from - the "what reading them whole would cost" counterfactual. - * Conservative: a file we can't stat is skipped, never guessed, so the figure - * only ever understates the whole-file cost. null when nothing was stattable. */ -function wholeFileTokens(paths: string[], root: string): number | null { - let total = 0; - let counted = 0; - for (const p of paths) { - try { - total += Math.ceil(statSync(resolve(root, p)).size / CHARS_PER_TOKEN); - counted++; - } catch { - // unreadable/moved since indexing - drop it rather than mislead - } - } - return counted > 0 ? total : null; -} - -export function searchEntry(result: SearchResult, root: string): UsageEntry { - const hits = result.hits.map((h) => ({ path: h.path, startLine: h.startLine, endLine: h.endLine })); - const files = [...new Set(hits.map((h) => h.path))]; - return { - ts: new Date().toISOString(), - tool: "search", - query: result.query, - returnedTokens: result.hits.reduce((n, h) => n + estTokens(h.content), 0), - wholeFileTokens: wholeFileTokens(files, root), - ranking: result.ranking, - hits, - }; -} - const ROWS_PREVIEW_CAP = 2000; export function sqlEntry(query: string, rows: Array>): UsageEntry { @@ -111,17 +73,7 @@ const plural = (n: number, one: string, many: string): string => `${n} ${n === 1 * Mutates and appends the running total when a session is supplied. */ export function formatReceipt(entry: UsageEntry, session?: SessionUsage): string { const parts: string[] = []; - if (entry.tool === "search") { - const hits = entry.hits ?? []; - const files = new Set(hits.map((h) => h.path)).size; - // Just what was returned - no "vs whole file" counterfactual here: it's an - // estimate of a road not taken, not a measured saving, so we don't assert - // it after every response. The raw wholeFileTokens still lives in the entry - // for anyone who wants to reason about it from the ledger. - parts.push(`returned ~${fmtTokens(entry.returnedTokens)} tokens | ${plural(hits.length, "chunk", "chunks")} / ${plural(files, "file", "files")}`); - } else { - parts.push(`returned ~${fmtTokens(entry.returnedTokens)} tokens | ${plural(entry.rows ?? 0, "row", "rows")}`); - } + parts.push(`returned ~${fmtTokens(entry.returnedTokens)} tokens | ${plural(entry.rows ?? 0, "row", "rows")}`); if (session) { session.queries++; session.returnedTokens += entry.returnedTokens; diff --git a/src/mcp/ensure.ts b/src/mcp/ensure.ts index 60f8bb1..77ebe49 100644 --- a/src/mcp/ensure.ts +++ b/src/mcp/ensure.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Infino Authors // -// Auto-index on first query. A search/sql against a repo that has never been +// Auto-index safety net. A sql query against a repo that has never been // indexed builds the index inline and then answers on the same call, instead // of erroring with "index it first". Staged readiness makes this cheap: the // build resolves the moment keyword search is live (seconds), with vectors diff --git a/src/mcp/server.ts b/src/mcp/server.ts index ad733d4..9d71afe 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1,29 +1,29 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Infino Authors // -// The dedicated MCP server: three tools over one code index. +// The dedicated MCP server: two tools over one code index. // -// search - find code: exact terms AND meaning in one ranked pass -// sql - the power door: relevance-ranked aggregation over the search -// table functions (bm25_search / hybrid_search + GROUP BY) +// sql - the only search surface: ranked retrieval via the search +// table functions (hybrid_search / bm25_search) composed +// freely with GROUP BY, regexp_like, and plain SQL // reindex - sync from the working tree; replies the moment keyword // search is live and backfills vectors in-process // -// Three tools, deliberately: one way to find, one way to count, one way to -// stay fresh - every additional near-duplicate retrieval tool worsens the -// agent's tool selection. Results carry took_ms - server-side time for -// the call (query embedding included where one happens; no transport). +// Two tools, deliberately: one way to query, one way to stay fresh - +// every additional near-duplicate retrieval tool worsens the agent's +// tool selection. Results carry took_ms - server-side time for the +// call (query embedding included where one happens; no transport). import { existsSync } from "node:fs"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; import { connect } from "@infino-ai/infino"; -import { indexDir, resolveRoot, TABLE, DEFAULT_CAPS, DEFAULT_SEARCH_K } from "../core/config.js"; +import { indexDir, resolveRoot, TABLE, DEFAULT_CAPS } from "../core/config.js"; import { readManifest, type Manifest } from "../core/manifest.js"; import type { IndexHandle } from "../core/context.js"; -import { search, runSql, jsonify, partialIndex } from "../core/searcher.js"; -import { newSession, receiptEnabled, searchEntry, sqlEntry, formatReceipt, recordUsage } from "../core/usage.js"; +import { runSql, jsonify, partialIndex, vectorsNote } from "../core/searcher.js"; +import { newSession, receiptEnabled, sqlEntry, formatReceipt, recordUsage } from "../core/usage.js"; import { indexRepoStaged, syncRepo, @@ -64,7 +64,7 @@ export async function serveMcp(rootPath?: string): Promise { // keeps the stat walk off the hot path (~20ms to ~2s depending on repo size). const autoSyncEnabled = !["0", "false", "no"].includes((process.env.CX_AUTO_SYNC ?? "").toLowerCase()); const syncIntervalMs = Number(process.env.CX_SYNC_INTERVAL_SECS ?? 30) * 1000; - // A search/sql on a never-indexed repo builds the index inline, then answers + // A sql query on a never-indexed repo builds the index inline, then answers // on the same call (staged: keyword search live in seconds). Off restores the // strict "index it first" error. const autoIndexEnabled = !["0", "false", "no"].includes((process.env.CX_AUTO_INDEX ?? "").toLowerCase()); @@ -168,112 +168,56 @@ export async function serveMcp(rootPath?: string): Promise { (stats.vectors === "building" ? " and vectors are backfilling in the background" : ""), }); - const timed = (fn: () => T): { value: T; tookMs: number } => { - const t0 = performance.now(); - const value = fn(); - return { value, tookMs: Math.round((performance.now() - t0) * 1000) / 1000 }; - }; + // Build the index up front rather than on the first query. The server + // starts with the session, so kicking the staged build here means keyword + // search is typically live (and vectors backfilling) before the agent asks + // anything. Best-effort: queries still ensure the index inline. + if (autoIndexEnabled) { + setImmediate(() => { + try { + const ctx = repoFor(undefined); + if (!getHandle(ctx)) void buildIndex(ctx)?.catch(() => undefined); + else maybeAutoSync(ctx); + } catch { + // unresolvable root or similar - the first query will surface it + } + }); + } const server = new McpServer( { name: "code-context", version: "0.1.2" }, { instructions: - "code-context is a local ranked index over this repository - semantic + keyword search and " + - "SQL over the whole codebase. Reach for it whenever you need to understand or find code: " + - "understanding how a subsystem works, finding code by meaning or by exact term, gathering " + - "context before an edit, locating a bug or the code behind a behaviour, reviewing existing " + - "patterns, planning a refactor, understanding the architecture for feature work, or spotting " + - "similar/duplicate implementations. It is the primary tool for finding and understanding " + - "code here, for almost any question about this codebase. Three tools:\n" + - "- search - find code by meaning or terms across files and understand how something works, " + - "in one ranked pass.\n" + - "- sql - counts, rankings, and aggregates over the whole repo in one query, including " + - "relevance-ranked aggregation ('which files have the most code about X') that file tools " + - "cannot express at any budget.\n" + - "- reindex - sync the index after the working tree changes.\n" + - "Treat a hit's content as authoritative: when it answers the question, answer from it and " + - "cite path plus line range - you don't need to re-confirm with grep or by opening the file. " + - "Read a file only for a hit marked truncated (its cited range), or when the results genuinely " + - "don't cover the question. When one search isn't enough, refine the query and search again - " + - "the ranked hits are already the relevant regions.\n" + + "code-context is a local ranked index over this repository. All code search goes through " + + "the sql tool - ranked retrieval is a table-valued function inside your query, so finding, " + + "understanding, counting, and ranking code are all one read-only SELECT. It is the primary " + + "tool for almost any question about this codebase. Two tools:\n" + + "- sql - THE search surface. Table: chunks(path, start_line, end_line, lang, symbol, " + + "content[, embedding]). Find code: SELECT path, start_line, end_line, symbol, content FROM " + + "hybrid_search('chunks','content','','embedding', {{q}}, 10) with the embed map " + + '{"q":""} - keyword + semantic fused in one ranked pass. ' + + "bm25_search('chunks','content','', k) is the keyword arm (use it while vectors are " + + "still backfilling). Rank and aggregate compose: SELECT path, SUM(end_line - start_line + 1) " + + "AS lines FROM bm25_search(...) GROUP BY path ORDER BY lines DESC. regexp_like(content, " + + "'pattern') filters in WHERE. vector_search('chunks','embedding', {{q}}, k) ranks by " + + "meaning alone; prefer hybrid_search, which keeps the keyword arm too. Always rank with a " + + "search TVF rather than scanning the table with LIKE.\n" + + "- reindex - sync the index after the working tree changes (it also auto-syncs in the " + + "background).\n" + + "The index builds as the server starts, so it is typically ready before your first query; " + + "if a result notes vectors are still backfilling, ranking is keyword-only for the moment.\n" + + "Treat a returned chunk's content as authoritative: when it answers the question, answer " + + "from it and cite path plus line range - you don't need to re-confirm with grep or by " + + "opening the file. Read a file only for what the chunks don't show (the cited range via " + + "offset/limit), never whole files.\n" + "Every tool takes an optional 'path' (an absolute repo root): omit it for the default repo, " + - "or set it to target a specific one when you're working across more than one repo in a session.\n" + - "If a result carries a 'partial' marker, the repo exceeded the index's file cap and some files " + - "were left out: treat a missing match as possibly-unindexed, not proof it's absent.\n" + - "Each result carries a 'usage' receipt (tokens returned, chunks/files, and a session running " + - "total) - computed locally, nothing leaves the machine. Surface it to the user so they can see " + - "how much context the ranked results put into the conversation.", - }, - ); - - server.registerTool( - "search", - { - title: "Code search (exact terms + meaning)", - description: - "Semantic + keyword code search over the indexed repository - a strong default for finding " + - "and understanding code. Use it to: understand how a subsystem or feature works, find code " + - "by meaning when you don't know the exact name, locate the code behind a behaviour or bug, " + - "gather context before making a change, review existing implementations and patterns, find " + - "everything a refactor would touch, understand the architecture for feature work, or spot " + - "similar/duplicate code. One pass fuses exact keyword matching (BM25: identifiers, error " + - "strings, function names, stemmed and scored) with semantic similarity (renamed symbols, " + - "paraphrases, 'where is X handled'), so it works whether or not you know the words. Each hit " + - "carries path, line range, and the chunk content with a relevance score - treat it as " + - "authoritative and answer directly from it, citing path plus line range; you don't need to " + - "re-confirm a hit with grep or by opening the file. When one search isn't enough, refine the " + - "query and search again - the index has already ranked the relevant regions. Read a file only " + - "for a hit marked truncated (its cited start-end range via offset/limit), or when results " + - "genuinely don't cover the question. (Until the index's vector stage finishes, results are " + - "keyword-ranked and say so.) The result includes a 'usage' field - a one-line receipt " + - "(tokens returned, chunks/files, session total). After you " + - "answer, end your reply by showing that 'usage' line to the user verbatim.", - inputSchema: { - query: z.string().describe("What you're looking for - terms, a phrase, or a description."), - k: z.number().int().positive().max(50).default(DEFAULT_SEARCH_K).describe("Maximum hits."), - path: z - .string() - .optional() - .describe( - "Absolute path to the repository root to search. Defaults to the server's configured root; " + - "set it to target a specific repo when a session spans more than one.", - ), - }, - }, - async ({ query, k, path }) => { - let ctx: RepoCtx; - try { - ctx = repoFor(path); - } catch (err) { - return fail((err as Error).message); - } - let ensured: EnsureResult; - try { - ensured = await ensureIndexed(ctx, { autoIndexEnabled, getHandle, build: buildIndex }); - } catch (err) { - return fail(`indexing failed: ${(err as Error).message}`); - } - if ("needsIndex" in ensured) return noIndex(ctx.root); - const { handle, autoIndexed } = ensured; - if (!autoIndexed) maybeAutoSync(ctx); // a fresh build is already current - try { - const t0 = performance.now(); - const result = await search(handle, getEmbedder(), query, k); - let usage: string | undefined; - if (receiptOn) { - const entry = searchEntry(result, ctx.root); - recordUsage(ctx.dir, entry); - usage = formatReceipt(entry, session); - } - return ok({ - ...result, - ...(autoIndexed ? { auto_indexed: autoIndexNote(autoIndexed) } : {}), - took_ms: Math.round((performance.now() - t0) * 1000) / 1000, - ...(usage ? { usage } : {}), - }); - } catch (err) { - return fail(`search failed: ${(err as Error).message}`); - } + "or set it to target a specific one when you're working across more than one repo in a " + + "session.\n" + + "If a result carries a 'partial' marker, the repo exceeded the index's file cap and some " + + "files were left out: treat a missing match as possibly-unindexed, not proof it's absent.\n" + + "Each result carries a 'usage' receipt (tokens returned, rows, and a session running total) " + + "- computed locally, nothing leaves the machine. Surface it to the user so they can see " + + "how much context the results put into the conversation.", }, ); @@ -282,20 +226,22 @@ export async function serveMcp(rootPath?: string): Promise { { title: "SQL over the code index", description: - "Whole-repo analytical questions that file tools cannot express at any budget: counts, " + - "rankings, GROUP BY across the codebase in one query, " + - `on table ${TABLE}(path, start_line, end_line, lang, content[, embedding]). ` + - "Search functions are callable as table-valued relations, so one query can rank AND " + - "aggregate: bm25_search('" + TABLE + "','content','terms', k) needs no embedding; " + - "hybrid_search('" + TABLE + "','content','terms','embedding', {{q}}, k) and " + - "vector_search('" + TABLE + "','embedding', {{q}}, k) take a {{name}} placeholder with an " + - 'embed map: {"q":"query text"}. The canonical move - "which files have the most code about ' + - 'X": SELECT path, SUM(end_line - start_line + 1) AS lines FROM ' + - `bm25_search('${TABLE}','content','', 300) GROUP BY path ORDER BY lines DESC LIMIT 15. ` + - "Build queries on bm25_search/hybrid_search so results are ranked by relevance to the topic, " + - "not on a raw scan of the whole table. Read-only, single statement. The result includes a " + - "'usage' field - a one-line receipt (tokens returned, rows, session total). After " + - "you answer, end your reply by showing that 'usage' line to the user verbatim.", + "The way to search this codebase - ranked retrieval and whole-repo analytics in one " + + `read-only SQL statement over ${TABLE}(path, start_line, end_line, lang, symbol, ` + + "content[, embedding]). Search functions are table-valued relations. Find code: SELECT " + + "path, start_line, end_line, symbol, content FROM hybrid_search('" + TABLE + "','content'," + + "'','embedding', {{q}}, 10) with the embed map " + + '{"q":""} - keyword + semantic fused in one ranked pass; use ' + + "bm25_search('" + TABLE + "','content','', k) while vectors are still backfilling. " + + "Count/rank: SELECT path, SUM(end_line - start_line + 1) AS lines FROM bm25_search('" + + TABLE + "','content','', 300) GROUP BY path ORDER BY lines DESC LIMIT 15. " + + "regexp_like(content, 'pattern') works in WHERE. vector_search('" + TABLE + "','embedding', " + + "{{q}}, k) ranks by meaning alone; prefer hybrid_search, which keeps the keyword arm too. " + + "Always rank with a search TVF rather than scanning the table with LIKE. Read-only, single " + + "statement. Treat returned chunk content " + + "as authoritative - answer from it and cite path:start-end. The result includes a 'usage' " + + "field - a one-line receipt (tokens returned, rows, session total). After you answer, end " + + "your reply by showing that 'usage' line to the user verbatim.", inputSchema: { query: z .string() @@ -333,6 +279,7 @@ export async function serveMcp(rootPath?: string): Promise { const t0 = performance.now(); const rows = await runSql(handle, getEmbedder(), query, embed as Record | undefined); const partial = partialIndex(handle.manifest); + const note = vectorsNote(handle.manifest); let usage: string | undefined; if (receiptOn) { const entry = sqlEntry(query, rows); @@ -341,6 +288,7 @@ export async function serveMcp(rootPath?: string): Promise { } return ok({ rows, + ...(note ? { note } : {}), ...(partial ? { partial } : {}), ...(autoIndexed ? { auto_indexed: autoIndexNote(autoIndexed) } : {}), took_ms: Math.round((performance.now() - t0) * 1000) / 1000, diff --git a/test/auto-index.integration.test.ts b/test/auto-index.integration.test.ts index 4485cac..0aac468 100644 --- a/test/auto-index.integration.test.ts +++ b/test/auto-index.integration.test.ts @@ -12,7 +12,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { connect, type Connection } from "@infino-ai/infino"; import { indexRepoStaged, type IndexStats } from "../src/core/indexer.js"; import { readManifest } from "../src/core/manifest.js"; -import { search } from "../src/core/searcher.js"; +import { runSql } from "../src/core/searcher.js"; import type { IndexHandle } from "../src/core/context.js"; import type { Embedder } from "../src/core/embedder.js"; import type { RepoCtx } from "../src/mcp/repos.js"; @@ -76,9 +76,13 @@ describe("auto-index on first query (end to end)", () => { expect(existsSync(ctx.dir)).toBe(true); // The freshly built index actually answers a query. - const hits = await search(res.handle, fakeEmbedder, "verifySession token", 5); - expect(hits.hits.length).toBeGreaterThan(0); - expect(hits.hits[0].path).toContain("auth.ts"); + const rows = await runSql( + res.handle, + fakeEmbedder, + "SELECT path FROM bm25_search('chunks','content','verifySession token', 5)", + ); + expect(rows.length).toBeGreaterThan(0); + expect(String(rows[0].path)).toContain("auth.ts"); }); it("does not rebuild when the index already exists", async () => { diff --git a/test/enforcement.test.ts b/test/enforcement.test.ts new file mode 100644 index 0000000..63bf1a4 --- /dev/null +++ b/test/enforcement.test.ts @@ -0,0 +1,741 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Infino Authors +// +// The enforcement hook is the one place where being wrong costs the agent a +// capability: a deny it should not have issued leaves the target reachable by +// neither grep nor sql. So the cases below are mostly about what must stay +// ALLOWED - every file class the indexer skips (lockfiles, `.csv`, `LICENSE`, +// `vendor/`, symlinks, anything not yet synced), every file it fingerprinted +// without chunking a row out of, and every index state short of fully live. +// +// The other half is parsing: the hook reads a Bash command as shell, so a +// pipeline (`| head`), a trailing comment, a heredoc body, a wrapper +// (`timeout 5`, `command`, `xargs -a`), an absolute launcher path and git's +// global flags all have to land on the same decision the bare command would. +// +// The hook is driven the way the client drives it: a fresh node process with +// the payload on stdin, reading a real index directory. Silence on stdout is +// "allow" - the hook only speaks to deny or ask. +// +// Fixture indexes are hand-written (manifest + filestate + an empty table +// directory) rather than produced by the indexer: what the hook reads is those +// two files, and building them by hand keeps the suite in milliseconds. The +// real-indexer block at the bottom pins the agreement those fixtures assume. +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { connect } from "@infino-ai/infino"; +import { indexRepo, syncRepo } from "../src/core/indexer.js"; +import { readFileState } from "../src/core/filestate.js"; +import { readManifest } from "../src/core/manifest.js"; +import type { Embedder } from "../src/core/embedder.js"; + +/** The hook as published (and as `cx install` copies it). */ +const HOOK = new URL("../hooks/deny-grep.mjs", import.meta.url).pathname; + +/** Files the fixture records in filestate - the index's own account of what it + * holds. `.pre-commit-config.yaml` is there on purpose: real indexes do carry + * dotfiles, which the old dot-path rule wrongly waved through. */ +const INDEXED = ["src/a.rs", "src/auth.rs", "README.md", ".pre-commit-config.yaml"]; + +/** A filestate key as `writeIndex` writes it. A bare path records rows + * (`chunks: DEFAULT_FIXTURE_CHUNKS`); a tuple pins the count - `0` for a file + * the indexer fingerprinted but chunked nothing out of, `undefined` for state + * written before the field existed. */ +type FileFixture = string | [path: string, chunks: number | undefined]; + +/** Rows a fixture file records unless the case pins its own count. */ +const DEFAULT_FIXTURE_CHUNKS = 3; + +/** A Python package the indexer fingerprints whole but can chunk only one file + * of: the empty `__init__.py` and the NUL-carrying module yield no rows. */ +const PKG_EMPTY_INIT = "pkg/__init__.py"; +const PKG_BINARY = "pkg/blob.py"; +const PKG_SOURCE = "pkg/thing.py"; + +/** A package whose every file is chunk-less, i.e. a directory the table holds + * nothing at all for. */ +const HOLLOW_INIT = "hollow/__init__.py"; + +/** Files that exist in the tree but not in the index: the classes the indexer + * skips, plus one (`notes.txt`) that simply has not been synced yet. */ +const UNINDEXED = ["package-lock.json", "data.csv", "LICENSE", "vendor/lib.js", "notes.txt"]; + +/** A fully live index: vectors ready, nothing truncated. */ +const READY_MANIFEST = { + version: 2, + table: "chunks", + vectors: "ready", + files: INDEXED.length, + chunks: 9, + languages: { rust: 6, markdown: 3 }, + indexedAt: "2026-01-01T00:00:00.000Z", + indexMs: 12, +}; + +/** Stand-in for the engine's table directory next to the manifest. */ +const TABLE_DIR = "chunks-0123456789ab-0"; + +/** The developer's own shell must not decide a case, so the CX knobs the hook + * reads are cleared and set per invocation. */ +const BASE_ENV: NodeJS.ProcessEnv = { ...process.env }; +delete BASE_ENV.CX_NO_ENFORCE; +delete BASE_ENV.CX_INDEX_DIR; +delete BASE_ENV.CX_MAX_FILE_BYTES; + +interface IndexShape { + /** Manifest fields layered over READY_MANIFEST. */ + manifest?: Record; + /** filestate keys; null writes no filestate at all. */ + files?: FileFixture[] | null; + /** false leaves the manifest orphaned (no table on disk). */ + table?: boolean; +} + +const temps: string[] = []; + +function tempDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + temps.push(dir); + return dir; +} + +/** A tree carrying every file class, with a symlinked directory (the walker + * never descends into one, so nothing under it is ever indexed). */ +function buildTree(): string { + const repo = tempDir("cx-enforce-repo-"); + for (const rel of [...INDEXED, ...UNINDEXED]) { + mkdirSync(dirname(join(repo, rel)), { recursive: true }); + writeFileSync(join(repo, rel), "let auth = 1;\n"); + } + const outside = tempDir("cx-enforce-outside-"); + writeFileSync(join(outside, "x.rs"), "let auth = 2;\n"); + symlinkSync(outside, join(repo, "shared"), "dir"); + return repo; +} + +function writeIndex(indexDir: string, shape: IndexShape = {}): void { + const { manifest = {}, files = INDEXED, table = true } = shape; + mkdirSync(indexDir, { recursive: true }); + writeFileSync(join(indexDir, "codecontext.json"), JSON.stringify({ ...READY_MANIFEST, ...manifest })); + if (files) { + const entries = files.map((f) => { + const [path, chunks] = typeof f === "string" ? [f, DEFAULT_FIXTURE_CHUNKS] : f; + return [path, { size: 14, mtimeMs: 1, hash: "abc", ...(chunks === undefined ? {} : { chunks }) }]; + }); + writeFileSync(join(indexDir, "filestate.json"), JSON.stringify({ version: 1, files: Object.fromEntries(entries) })); + } + if (table) mkdirSync(join(indexDir, TABLE_DIR), { recursive: true }); +} + +interface HookResult { + decision: "allow" | "deny" | "ask"; + context?: string; +} + +/** One hook run. Empty stdout means the hook said nothing, which the client + * reads as allow. */ +function hook(payload: unknown, env: NodeJS.ProcessEnv = {}): HookResult { + const run = spawnSync(process.execPath, [HOOK], { + input: JSON.stringify(payload), + encoding: "utf8", + env: { ...BASE_ENV, ...env }, + }); + if (run.status !== 0) throw new Error(`hook exited ${run.status}: ${run.stderr}`); + const out = run.stdout.trim(); + if (out === "") return { decision: "allow" }; + const emitted = JSON.parse(out).hookSpecificOutput as { + permissionDecision?: HookResult["decision"]; + additionalContext?: string; + }; + return { decision: emitted.permissionDecision ?? "allow", context: emitted.additionalContext }; +} + +describe("grep enforcement", () => { + let repo: string; + let indexDir: string; + + beforeEach(() => { + repo = buildTree(); + indexDir = join(repo, ".infino"); + writeIndex(indexDir, { manifest: { root: repo } }); + }); + + afterEach(() => { + while (temps.length > 0) rmSync(temps.pop() as string, { recursive: true, force: true }); + }); + + const bash = (command: string, env: NodeJS.ProcessEnv = {}): string => + hook({ hook_event_name: "PreToolUse", tool_name: "Bash", cwd: repo, tool_input: { command } }, env).decision; + + const grepTool = (toolInput: Record, env: NodeJS.ProcessEnv = {}): string => + hook({ hook_event_name: "PreToolUse", tool_name: "Grep", cwd: repo, tool_input: toolInput }, env).decision; + + describe("coverage comes from the index, not from re-derived skip rules", () => { + it("allows a lockfile the chunker never indexes", () => { + expect(bash("grep -n commander package-lock.json")).toBe("allow"); + }); + + it("allows an extension outside the language allowlist", () => { + expect(bash("grep -n 1 data.csv")).toBe("allow"); + }); + + it("allows an extensionless file that is not a known basename", () => { + expect(bash("grep -n Apache LICENSE")).toBe("allow"); + }); + + it("allows a skipped directory even though it is not gitignored", () => { + expect(bash("grep -rn Foo vendor/")).toBe("allow"); + }); + + it("allows a symlinked directory the walker never descends into", () => { + expect(bash("grep -rn x shared/")).toBe("allow"); + }); + + it("allows a file that is on disk but absent from filestate", () => { + expect(bash("grep -n auth notes.txt")).toBe("allow"); + }); + + it("allows when any one of several targets is uncovered", () => { + expect(bash("grep -n auth src/a.rs data.csv")).toBe("allow"); + }); + + it("denies an indexed source file", () => { + expect(bash("grep -n auth src/a.rs")).toBe("deny"); + }); + + it("denies an indexed dotfile", () => { + expect(bash("grep -n repos .pre-commit-config.yaml")).toBe("deny"); + }); + + it("denies an indexed directory, by relative and absolute path alike", () => { + expect(bash("grep -rn auth src")).toBe("deny"); + expect(bash(`grep -rn auth ${join(repo, "src")}`)).toBe("deny"); + }); + + it("allows a path outside the repo", () => { + expect(bash("grep -n auth /etc/hosts")).toBe("allow"); + }); + }); + + // filestate fingerprints every readable candidate, including files it then + // chunks nothing out of - so its key set is a superset of the table's paths, + // and only the recorded row count separates the two. + describe("a fingerprinted file with no chunk rows is not covered", () => { + beforeEach(() => { + for (const rel of [PKG_EMPTY_INIT, PKG_BINARY, PKG_SOURCE, HOLLOW_INIT]) { + mkdirSync(dirname(join(repo, rel)), { recursive: true }); + writeFileSync(join(repo, rel), rel === PKG_SOURCE ? "def thing():\n return 1\n" : ""); + } + writeIndex(indexDir, { + manifest: { root: repo }, + files: [ + ...INDEXED, + [PKG_EMPTY_INIT, 0], + [PKG_BINARY, 0], + [PKG_SOURCE, 4], + [HOLLOW_INIT, 0], + ], + }); + }); + + it("allows an empty __init__.py and a NUL-carrying module, denies their real sibling", () => { + expect(bash(`rg -n main ${PKG_EMPTY_INIT}`)).toBe("allow"); + expect(bash(`rg -n main ${PKG_BINARY}`)).toBe("allow"); + expect(bash(`rg -n main ${PKG_SOURCE}`)).toBe("deny"); + }); + + it("covers a package directory only through the file that produced rows", () => { + expect(bash("rg -n main pkg/")).toBe("deny"); + expect(bash("rg -n main pkg")).toBe("deny"); + // Every key under this one is chunk-less: sql has nothing to answer with. + expect(bash("rg -n main hollow/")).toBe("allow"); + expect(bash("rg -n main hollow")).toBe("allow"); + }); + + it("applies the same rule to globs", () => { + expect(grepTool({ pattern: "main", glob: "*.py" })).toBe("deny"); + expect(grepTool({ pattern: "main", glob: "pkg/thing.py" })).toBe("deny"); + expect(grepTool({ pattern: "main", glob: "hollow/*.py" })).toBe("allow"); + expect(grepTool({ pattern: "main", glob: "**/__init__.py" })).toBe("allow"); + expect(grepTool({ pattern: "main", path: HOLLOW_INIT })).toBe("allow"); + }); + + it("covers a count-less entry only in a mixed (mid-upgrade) state", () => { + // A wholly pre-upgrade state fails open (see the round-2 case below); + // once any entry carries a count, the unstamped rest keeps the + // back-compatible covered reading until the next sync stamps it. + writeIndex(indexDir, { manifest: { root: repo }, files: [["src/a.rs", undefined], "README.md"] }); + expect(bash("grep -n auth src/a.rs")).toBe("deny"); + }); + + it("fails open when nothing in the state produced a row", () => { + writeIndex(indexDir, { manifest: { root: repo }, files: [["src/a.rs", 0], ["README.md", 0]] }); + expect(bash("grep -n auth src/a.rs")).toBe("allow"); + expect(bash("rg foo")).toBe("allow"); + }); + }); + + describe("command shapes", () => { + it("denies a repo-wide search with no explicit target", () => { + expect(bash("rg foo")).toBe("deny"); + }); + + it("denies a grep launched after another command in the same line", () => { + expect(bash("cd src && rg -n auth")).toBe("deny"); + expect(bash("cd src ; rg -n auth")).toBe("deny"); + expect(bash("test -d src || rg -n auth")).toBe("deny"); + }); + + it("denies a multi-word quoted pattern over an indexed directory", () => { + expect(bash('grep -rn "let auth" src/')).toBe("deny"); + expect(bash("grep -rn 'let auth' src/")).toBe("deny"); + }); + + it("denies through env-assignment and env/sudo/time/nice wrappers", () => { + expect(bash("env FOO=1 grep -n x src/a.rs")).toBe("deny"); + expect(bash("sudo grep -n x src/a.rs")).toBe("deny"); + expect(bash("time grep -n x src/a.rs")).toBe("deny"); + expect(bash("FOO=1 grep -n x src/a.rs")).toBe("deny"); + }); + + it("denies git grep", () => { + expect(bash("git grep -n auth src/a.rs")).toBe("deny"); + }); + + it("allows grep as a pipe filter, never splitting on |", () => { + expect(bash("cargo test 2>&1 | grep FAILED")).toBe("allow"); + expect(bash("ls | grep foo")).toBe("allow"); + expect(bash("rg --version | grep ripgrep")).toBe("allow"); + }); + + it("allows a pattern-less invocation", () => { + expect(bash("grep --version")).toBe("allow"); + }); + + it("reads a redirection as a destination, not as a target", () => { + expect(bash("rg foo > hits.txt")).toBe("deny"); + expect(bash("rg foo >hits.txt")).toBe("deny"); + expect(bash("grep -rn auth src/ 2>&1")).toBe("deny"); + expect(bash("grep -n 1 data.csv > hits.txt")).toBe("allow"); + // An input redirection searches a stream the index cannot speak for. + expect(bash("grep -n auth < notes.txt")).toBe("allow"); + }); + + it("treats a quoted token as pattern text, not as shell syntax", () => { + // Reading the `>` as a redirection would drop the pattern and make the + // unindexed file look like a repo-wide search. + expect(bash('grep -n "> TODO" notes.txt')).toBe("allow"); + expect(bash('grep -n "> TODO" src/a.rs')).toBe("deny"); + expect(bash('grep -n -- "-n" src/a.rs')).toBe("deny"); + }); + + it("asks when the fallback marker prefixes the grep", () => { + expect(bash("CX_GREP_FALLBACK=1 grep -n auth src/a.rs")).toBe("ask"); + expect(bash("cd src && CX_GREP_FALLBACK=1 rg -n auth")).toBe("ask"); + }); + }); + + // A search's own operands end at the pipeline: the words of `| head -5` are + // another command's, and reading them as targets nothing covers turned the + // most common grep idiom there is into an allow. + describe("a search whose output is piped onward", () => { + it("denies the search regardless of the filter behind the pipe", () => { + expect(bash("rg -n auth src | head -5")).toBe("deny"); + expect(bash("rg -n auth src/a.rs | head")).toBe("deny"); + expect(bash("rg -n auth | head -20")).toBe("deny"); + expect(bash("rg -n auth src | wc -l")).toBe("deny"); + expect(bash("grep -rn auth src | sort")).toBe("deny"); + expect(bash("rg -n auth src|head")).toBe("deny"); + expect(bash("rg --files-with-matches auth src | xargs cat")).toBe("deny"); + }); + + it("denies a search with a trailing comment", () => { + expect(bash("rg -n auth src # note")).toBe("deny"); + expect(bash("rg -n auth src #note")).toBe("deny"); + }); + + it("still allows a grep that filters another command's output", () => { + expect(bash("cargo test | grep FAILED")).toBe("allow"); + expect(bash("cargo test 2>&1 | grep -i error")).toBe("allow"); + expect(bash("ps aux | grep node")).toBe("allow"); + expect(bash("env | grep PATH")).toBe("allow"); + expect(bash("git log --oneline | grep fix")).toBe("allow"); + expect(bash("cat /etc/hosts | grep -v '#' | grep local")).toBe("allow"); + expect(bash("rg --version | grep ripgrep")).toBe("allow"); + }); + + it("keeps a quoted pipe inside the pattern literal", () => { + expect(bash('grep -rn "a|b" src/')).toBe("deny"); + expect(bash("rg -n '#include' src")).toBe("deny"); + expect(bash("grep -rn 'a|b' data.csv")).toBe("allow"); + }); + + it("asks when the fallback marker prefixes a piped search", () => { + expect(bash("CX_GREP_FALLBACK=1 rg -n auth src | head")).toBe("ask"); + }); + }); + + // A heredoc body is data: an agent writing a script or a doc that mentions a + // search must not be told to use sql instead. + describe("heredoc bodies", () => { + it("allows a script whose body merely contains a search line", () => { + expect(bash("cat > run.sh <<'SH'\nrg -n auth src\nSH")).toBe("allow"); + expect(bash("cat > run.sh < run.sh <<-SH\n\trg -n auth src\n\tSH")).toBe("allow"); + expect(bash('cat >> notes.md <<"MD"\ngrep -rn auth src is the old way\nMD')).toBe("allow"); + }); + + it("still reads the commands around the body", () => { + expect(bash("cat <<'EOF' > /dev/null\nx\nEOF\nrg -n auth src")).toBe("deny"); + expect(bash("rg -n auth src\ncat > run.sh <<'SH'\nls\nSH")).toBe("deny"); + }); + + it("allows a search line quoted into another command", () => { + expect(bash("echo 'rg -n auth src'")).toBe("allow"); + expect(bash("git commit -m 'prefer sql over rg -n auth src'")).toBe("allow"); + }); + }); + + // Each of these hid the launcher from the scan: a path, a wrapper that takes + // its own operand, or git's global flags in front of the subcommand. + describe("launcher shapes", () => { + it("denies through git's global flags", () => { + expect(bash("git -C . grep auth")).toBe("deny"); + expect(bash("git --no-pager grep auth")).toBe("deny"); + expect(bash("git -c core.pager=cat grep auth")).toBe("deny"); + expect(bash("git -C src grep auth")).toBe("deny"); + }); + + it("allows git grep aimed at another tree", () => { + expect(bash("git -C /etc grep auth")).toBe("allow"); + }); + + it("denies behind timeout, an absolute path, command, and xargs", () => { + expect(bash("timeout 5 rg -n auth src")).toBe("deny"); + expect(bash("timeout 30s rg -n auth src")).toBe("deny"); + expect(bash("/usr/bin/rg -n auth src")).toBe("deny"); + expect(bash("command grep -rn auth src")).toBe("deny"); + expect(bash("xargs -a /dev/null rg -n auth src")).toBe("deny"); + expect(bash("nice -n 5 rg -n auth src")).toBe("deny"); + }); + + it("leaves a launcher lookup alone", () => { + expect(bash("command -v rg")).toBe("allow"); + expect(bash("which grep")).toBe("allow"); + }); + }); + + describe("index state short of fully live", () => { + it("allows while vectors are still building", () => { + writeIndex(indexDir, { manifest: { root: repo, vectors: "building" } }); + expect(bash("grep -n auth src/a.rs")).toBe("allow"); + }); + + it("allows when the index is partial (files over the cap)", () => { + writeIndex(indexDir, { manifest: { root: repo, truncatedFiles: 3, maxFiles: 2 } }); + expect(bash("grep -n auth src/a.rs")).toBe("allow"); + }); + + it("allows when the manifest is orphaned by a missing table", () => { + rmSync(join(indexDir, TABLE_DIR), { recursive: true, force: true }); + expect(bash("grep -n auth src/a.rs")).toBe("allow"); + }); + + it("allows when filestate is missing", () => { + rmSync(join(indexDir, "filestate.json"), { force: true }); + expect(bash("grep -n auth src/a.rs")).toBe("allow"); + }); + + it("allows when filestate is unparseable or records no files", () => { + writeFileSync(join(indexDir, "filestate.json"), "{ not json"); + expect(bash("grep -n auth src/a.rs")).toBe("allow"); + writeIndex(indexDir, { manifest: { root: repo }, files: [] }); + expect(bash("grep -n auth src/a.rs")).toBe("allow"); + }); + + it("allows when there is no index at all", () => { + rmSync(indexDir, { recursive: true, force: true }); + expect(bash("grep -n auth src/a.rs")).toBe("allow"); + }); + + it("allows on an unparseable payload", () => { + const run = spawnSync(process.execPath, [HOOK], { input: "{ not json", encoding: "utf8", env: BASE_ENV }); + expect(run.status).toBe(0); + expect(run.stdout.trim()).toBe(""); + }); + }); + + describe("the CX_NO_ENFORCE kill switch", () => { + const off = { CX_NO_ENFORCE: "1" }; + + it("allows a grep that would otherwise be denied", () => { + expect(bash("grep -n auth src/a.rs", off)).toBe("allow"); + expect(grepTool({ pattern: "auth", path: "src" }, off)).toBe("allow"); + }); + + it("allows the legacy search tool, which used to be denied above it", () => { + const payload = { hook_event_name: "PreToolUse", tool_name: "mcp__code-context__search", cwd: repo, tool_input: { query: "auth" } }; + expect(hook(payload, off).decision).toBe("allow"); + expect(hook(payload).decision).toBe("deny"); + }); + + it("still describes the index at SessionStart", () => { + const note = hook({ hook_event_name: "SessionStart", cwd: repo }, off).context; + expect(note).toContain("hybrid_search"); + expect(note).not.toContain("are disabled"); + }); + }); + + describe("the Grep tool", () => { + it("denies an indexed path, and a repo-wide search with no path", () => { + expect(grepTool({ pattern: "auth", path: "src" })).toBe("deny"); + expect(grepTool({ pattern: "auth" })).toBe("deny"); + }); + + it("allows an uncovered path", () => { + expect(grepTool({ pattern: "Foo", path: "vendor" })).toBe("allow"); + expect(grepTool({ pattern: "1", path: "data.csv" })).toBe("allow"); + }); + + it("allows a glob that matches nothing indexed, denies one that matches", () => { + expect(grepTool({ pattern: "auth", glob: "*.py" })).toBe("allow"); + expect(grepTool({ pattern: "auth", glob: "src/**/*.go" })).toBe("allow"); + expect(grepTool({ pattern: "auth", glob: "*.rs" })).toBe("deny"); + expect(grepTool({ pattern: "auth", glob: "src/*.rs" })).toBe("deny"); + }); + }); + + describe("the repo root the manifest records", () => { + it("enforces on an index kept outside the repo (CX_INDEX_DIR)", () => { + const external = tempDir("cx-enforce-index-"); + const elsewhere = tempDir("cx-enforce-cwd-"); + rmSync(indexDir, { recursive: true, force: true }); + writeIndex(external, { manifest: { root: repo } }); + const env = { CX_INDEX_DIR: external }; + const payload = (command: string) => ({ + hook_event_name: "PreToolUse", + tool_name: "Bash", + cwd: elsewhere, + tool_input: { command }, + }); + // Without manifest.root the hook would call cwd the root, read the repo + // as a sibling "outside the repo", and allow both of these. + expect(hook(payload(`grep -n auth ${join(repo, "src", "a.rs")}`), env).decision).toBe("deny"); + expect(hook(payload(`grep -n auth ${join(repo, "data.csv")}`), env).decision).toBe("allow"); + }); + + it("falls back to the walked-up index directory when the manifest has no root", () => { + writeIndex(indexDir, {}); + expect(bash("grep -n auth src/a.rs")).toBe("deny"); + expect(bash("grep -n 1 data.csv")).toBe("allow"); + }); + + it("prefers the walked-up root over a stale one (a copied checkout)", () => { + // What `cp -a repo repo2` leaves behind: an index found inside repo2 + // whose manifest still names repo. Trusting that root resolves every + // target "outside the repo" and silently stops enforcing, while sql + // answers repo2's queries fine. + const copy = buildTree(); + const copyIndex = join(copy, ".infino"); + writeIndex(copyIndex, { manifest: { root: repo } }); + const at = (command: string) => + hook({ hook_event_name: "PreToolUse", tool_name: "Bash", cwd: copy, tool_input: { command } }).decision; + expect(at("grep -n auth src/a.rs")).toBe("deny"); + expect(at("grep -rn auth src")).toBe("deny"); + expect(at(`grep -n auth ${join(copy, "src", "a.rs")}`)).toBe("deny"); + expect(at("grep -n 1 data.csv")).toBe("allow"); + // The same stale root loses when CX_INDEX_DIR names the copy's own index. + const env = { CX_INDEX_DIR: copyIndex }; + expect( + hook({ hook_event_name: "PreToolUse", tool_name: "Bash", cwd: copy, tool_input: { command: "grep -n auth src/a.rs" } }, env) + .decision, + ).toBe("deny"); + }); + }); + + describe("round-2 hardening (verifier findings N1-N6)", () => { + it("answers a wildcard-flooded glob promptly instead of backtracking (N1)", () => { + // 40 stars used to compile to stacked `.*`s and wedge past the hook + // timeout; the vitest case timeout is the regression tripwire here. + expect(bash(`rg -n auth ${"*".repeat(40)}.ts`)).toBe("allow"); + expect(grepTool({ pattern: "auth", glob: `${"*".repeat(40)}.rs` })).toBe("deny"); + }); + + it("reads a bare operand glob the way the shell does, not against basenames (N2)", () => { + // The shell expands `*.rs` in the cwd before rg runs; no root-level file + // matches, so nothing indexed is searched. Basename matching read it as + // "src/a.rs, somewhere" and denied a search sql could not answer. + expect(bash("rg -n zzz *.rs")).toBe("allow"); + expect(bash("rg -n zzz src/*.rs")).toBe("deny"); + // The Grep tool's glob IS recursive; basename matching is right there. + expect(grepTool({ pattern: "zzz", glob: "*.rs" })).toBe("deny"); + }); + + it("sees through shell control words and group openers (N3)", () => { + expect(bash("if rg -q auth src/a.rs; then echo y; fi")).toBe("deny"); + expect(bash("while rg -q auth src/a.rs; do :; done")).toBe("deny"); + expect(bash("for f in 1; do rg -n auth src/a.rs; done")).toBe("deny"); + expect(bash("{ rg -n auth src/a.rs; }")).toBe("deny"); + expect(bash("( rg -n auth src/a.rs )")).toBe("deny"); + expect(bash("(rg -n auth src/a.rs)")).toBe("deny"); + expect(bash("! rg -q auth src/a.rs")).toBe("deny"); + }); + + it("treats backgrounding as a launch and redirections as text (N3)", () => { + expect(bash("rg -n auth src/a.rs &")).toBe("deny"); + expect(bash("rg -n auth src/a.rs & wait")).toBe("deny"); + // `>&` is a redirection, not a control operator - still one segment. + expect(bash("cargo build 2>&1 | grep error")).toBe("allow"); + expect(bash("echo done & wait")).toBe("allow"); + }); + + it("fails open on a wholly pre-upgrade filestate, covered on a mixed one (N5)", () => { + // No entry anywhere carries a count: the state cannot distinguish a + // chunk-less file, so nothing is denied until a rebuild stamps counts. + writeIndex(indexDir, { + manifest: { root: repo }, + files: INDEXED.map((f) => [f, undefined] as [string, undefined]), + }); + expect(bash("rg -n auth src/a.rs")).toBe("allow"); + // One stamped entry makes it a mixed (mid-upgrade) state: the unstamped + // rest keeps the back-compatible covered reading. + writeIndex(indexDir, { + manifest: { root: repo }, + files: [["src/a.rs", undefined], "src/auth.rs"], + }); + expect(bash("rg -n auth src/a.rs")).toBe("deny"); + }); + + it("steps over env -i and the other value-less wrapper flags (N6)", () => { + expect(bash("env -i rg -n auth src/a.rs")).toBe("deny"); + expect(bash("nohup rg -n auth src/a.rs")).toBe("deny"); + expect(bash("stdbuf -o0 rg -n auth src/a.rs")).toBe("deny"); + // xargs -I really does take a value; its launcher is still found. + expect(bash("xargs -I {} rg -n auth src/a.rs")).toBe("deny"); + // env's -u takes a value too - the launcher must not be eaten. + expect(bash("env -u PAGER rg -n auth src/a.rs")).toBe("deny"); + }); + }); + + describe("SessionStart context", () => { + it("announces enforcement when the index is fully live", () => { + const note = hook({ hook_event_name: "SessionStart", cwd: repo }).context; + expect(note).toContain("are disabled"); + expect(note).toContain("CX_GREP_FALLBACK=1"); + }); + + it("says grep stays enabled while the index is not live", () => { + writeIndex(indexDir, { manifest: { root: repo, vectors: "building" } }); + expect(hook({ hook_event_name: "SessionStart", cwd: repo }).context).toContain("grep stays enabled"); + }); + }); +}); + +// The hand-written fixtures above pin the hook's rules; this one pins the +// agreement they assume - that what the indexer records is what the hook reads. +// A real build over a tree the indexer partly skips is the case the old +// re-derived coverage got wrong, so it is worth the one real index a run costs. +const fakeEmbedder: Embedder = { + embed: async (texts) => texts.map((t) => new Array(16).fill(t.length / 100)), + dim: async () => 16, + provider: "fake", + model: "fake-16d", +}; + +/** A `.py` the walker takes and the chunker then refuses: an indexable + * extension over bytes with a NUL in them. */ +const NUL_MODULE = Buffer.from("print('x')\u0000binary tail\n", "latin1"); + +describe("against an index the indexer actually built", () => { + let root: string; + let dir: string; + let db: ReturnType; + + /** Paths the table actually holds rows for - the ground truth the hook's + * covered set has to equal. (Read as a GROUP BY: the binding cannot decode + * a bare DISTINCT projection.) */ + const tablePaths = (): string[] => + (db.querySql("SELECT path, COUNT(*) AS n FROM chunks GROUP BY path") as Array<{ path: string }>) + .map((r) => r.path) + .sort(); + + const bash = (command: string) => + hook({ hook_event_name: "PreToolUse", tool_name: "Bash", cwd: root, tool_input: { command } }).decision; + + /** Every path the index fingerprinted, split by what the hook decides for + * it. A denied path is one the hook claims sql can answer. */ + const coveredByHook = (): string[] => + Object.keys(readFileState(dir)?.files ?? {}) + .filter((p) => bash(`grep -n zzz ${p}`) === "deny") + .sort(); + + beforeAll(async () => { + root = mkdtempSync(join(tmpdir(), "cx-enforce-real-")); + mkdirSync(join(root, "src"), { recursive: true }); + mkdirSync(join(root, "pkg"), { recursive: true }); + writeFileSync(join(root, "src", "alpha.ts"), "export function alphaThing() { return 'quokka'; }\n"); + writeFileSync(join(root, "data.csv"), "a,b\n1,2\n"); + writeFileSync(join(root, "package-lock.json"), '{"name":"x","lockfileVersion":3}\n'); + // The two shapes filestate records but the table has no row for. + writeFileSync(join(root, "pkg", "__init__.py"), ""); + writeFileSync(join(root, "pkg", "blob.py"), NUL_MODULE); + writeFileSync(join(root, "pkg", "thing.py"), "def thing():\n return 'wombat'\n"); + dir = join(root, ".infino"); + db = connect(dir); + await indexRepo({ root, db, indexDirPath: dir, embedder: fakeEmbedder }); + }); + + afterAll(() => rmSync(root, { recursive: true, force: true })); + + it("records the repo root in the manifest", () => { + expect(readManifest(join(root, ".infino"))?.root).toBe(root); + }); + + it("denies what the build indexed and allows what it skipped", () => { + expect(bash("grep -n alphaThing src/alpha.ts")).toBe("deny"); + expect(bash("grep -n 1 data.csv")).toBe("allow"); + expect(bash("grep -n lockfileVersion package-lock.json")).toBe("allow"); + }); + + it("records a row count for every fingerprinted file, zero included", () => { + const files = readFileState(dir)?.files ?? {}; + expect(files["pkg/__init__.py"]?.chunks).toBe(0); + expect(files["pkg/blob.py"]?.chunks).toBe(0); + expect(files["pkg/thing.py"]?.chunks).toBeGreaterThan(0); + // Fingerprinted-but-rowless files stay in the state (dropping them would + // make every later sync re-hash them), which is why the count is the only + // thing separating the state from the table. + expect(Object.keys(files).sort()).not.toEqual(tablePaths()); + }); + + it("covers exactly the paths the table holds rows for", () => { + expect(coveredByHook()).toEqual(tablePaths()); + // Which is to say: the rowless siblings are grep-able, the real one is not. + expect(bash("grep -n thing pkg/thing.py")).toBe("deny"); + expect(bash("grep -n x pkg/__init__.py")).toBe("allow"); + expect(bash("grep -n x pkg/blob.py")).toBe("allow"); + expect(bash("grep -rn thing pkg")).toBe("deny"); + }); + + it("keeps the counts (and so the covered set) right across a sync", async () => { + writeFileSync(join(root, "pkg", "late.py"), "def late():\n return 'axolotl'\n"); + writeFileSync(join(root, "pkg", "late_empty.py"), ""); + writeFileSync(join(root, "pkg", "late_blob.py"), NUL_MODULE); + const outcome = await syncRepo({ root, db, indexDirPath: dir, embedder: fakeEmbedder }); + expect(outcome.action).toBe("synced"); + + const files = readFileState(dir)?.files ?? {}; + expect(files["pkg/late.py"]?.chunks).toBeGreaterThan(0); + expect(files["pkg/late_empty.py"]?.chunks).toBe(0); + expect(files["pkg/late_blob.py"]?.chunks).toBe(0); + // The full-build entries survive the sync with their counts intact. + expect(files["pkg/__init__.py"]?.chunks).toBe(0); + expect(coveredByHook()).toEqual(tablePaths()); + expect(bash("grep -n late pkg/late.py")).toBe("deny"); + expect(bash("grep -n x pkg/late_empty.py")).toBe("allow"); + expect(bash("grep -n x pkg/late_blob.py")).toBe("allow"); + }); +}); diff --git a/test/install.test.ts b/test/install.test.ts new file mode 100644 index 0000000..a81ba86 --- /dev/null +++ b/test/install.test.ts @@ -0,0 +1,572 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Infino Authors +// +// `cx install` edits a settings file the user shares with every other hook +// they own, so most of what matters here is what it must NOT do: stack a +// second copy of itself on reinstall, drop a foreign hook (including one that +// merely shares an entry with ours), lose an unrelated top-level key, truncate +// the file if it dies mid-write, or write machine-specific paths into a +// project's shareable settings file. --uninstall has the mirror obligations, +// plus leaving no empty "hooks" husk when ours were the only entries, and +// leaving the hook script in place while a sibling settings file still runs it. +// +// The load-bearing assertion is the hook command. It has to carry the +// absolute `process.execPath` and the absolute hook path: a bare "node" +// resolves against the client's PATH, and a client launched from a GUI often +// has none - the hook then fails silently, which is indistinguishable from +// enforcement that simply does not work. An unresolvable home directory is +// the same failure by another route, so it has to be an error. +// +// Every case points HOME and --settings at a temp directory, so the real +// ~/.claude is never touched. +import { execFileSync } from "node:child_process"; +import { + chmodSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, isAbsolute, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { InstallError, installCmd } from "../src/commands/install-cmd.js"; + +/** Where install puts the hook, relative to the settings file's directory. */ +const HOOK_REL_PATH = join("hooks", "cx-deny-grep.mjs"); + +/** The packaged hook install copies from - the source of truth for content. */ +const PACKAGED_HOOK = new URL("../hooks/deny-grep.mjs", import.meta.url); + +/** The PreToolUse matcher the entry must carry (mirrors hooks/hooks.json): + * the built-in search tools plus the legacy code-context `search` tool. */ +const PRE_TOOL_MATCHER = "Grep|Bash|mcp__.*code[-_]context.*__search"; + +/** `"" ""` - both halves absolute, quoted, nothing else. */ +const COMMAND_SHAPE = /^"([^"]+)" "([^"]+)"$/; + +interface HookCommand { + type: string; + command: string; +} + +interface HookEntry { + matcher?: string; + hooks: HookCommand[]; +} + +type Settings = Record & { hooks?: Record }; + +/** Hooks that belong to somebody else: one on an event we never touch, one + * sharing SessionStart with ours. Both must survive install and uninstall. */ +const FOREIGN_POST_TOOL: HookEntry = { + matcher: "Write|Edit", + hooks: [{ type: "command", command: "node /home/someone/.claude/hooks/prettier.mjs" }], +}; +const FOREIGN_SESSION_START: HookEntry = { + hooks: [{ type: "command", command: "/usr/local/bin/greet-me" }], +}; + +/** A hook of the user's that sits inside the same entry as ours - the entry is + * shared, so ownership has to be decided per hook and not per entry. */ +const AUDIT_HOOK: HookCommand = { + type: "command", + command: "node /home/someone/.claude/hooks/audit-log.mjs", +}; + +/** Name the dotfile-managed copy of the settings carries. Deliberately not + * `settings.json`: the temp file and the rename have to follow the link to + * wherever it points, not reuse the name of the link. */ +const TRACKED_SETTINGS = "claude-settings.json"; + +/** Modes for the settings directory: one the user cannot write - enough to + * fail the settings replace after the hook has already been copied in - and + * the one it is restored to so the sandbox can be removed. */ +const READ_ONLY_DIR = 0o500; +const WRITABLE_DIR = 0o700; + +/** A wrapper of the user's that merely names our script on its command line. + * Matching on the basename would delete it; matching on the resolved path we + * wrote does not. */ +const WRAPPER_HOOK: HookEntry = { + hooks: [ + { type: "command", command: "node /home/someone/my-wrapper.mjs --hook cx-deny-grep.mjs" }, + ], +}; + +describe("cx install", () => { + let dir: string; + let settingsPath: string; + let hookPath: string; + let realHome: string | undefined; + + const read = (path = settingsPath): Settings => + JSON.parse(readFileSync(path, "utf8")) as Settings; + const seed = (settings: Settings): void => seedText(JSON.stringify(settings, null, 2) + "\n"); + const seedText = (text: string): void => { + mkdirSync(dirname(settingsPath), { recursive: true }); + writeFileSync(settingsPath, text); + }; + /** `settings.json` as a symlink into a version-controlled directory - the + * shape every dotfile manager (stow, chezmoi, a hand-rolled Makefile) + * leaves behind. Returns the link target, which is the file that has to + * receive the merged settings. */ + const seedSymlinked = (settings: Settings): string => { + const tracked = join(dir, "dotfiles", TRACKED_SETTINGS); + mkdirSync(dirname(tracked), { recursive: true }); + writeFileSync(tracked, JSON.stringify(settings, null, 2) + "\n"); + mkdirSync(dirname(settingsPath), { recursive: true }); + symlinkSync(tracked, settingsPath); + return tracked; + }; + /** Run `body` with the settings directory read-only, so the settings replace + * fails where a real EACCES would: after the hook script is on disk. */ + const withUnwritableDir = (body: () => void): void => { + chmodSync(dirname(settingsPath), READ_ONLY_DIR); + try { + body(); + } finally { + chmodSync(dirname(settingsPath), WRITABLE_DIR); + } + }; + /** The hook command install writes, as a pre-existing entry would hold it. */ + const ourHook = (): HookCommand => ({ + type: "command", + command: `"${process.execPath}" "${hookPath}"`, + }); + const runsOurs = (entry: HookEntry): boolean => + entry.hooks.some((hook) => hook.command.endsWith(`"${hookPath}"`)); + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "cx-install-")); + // Point HOME at the sandbox: install refuses a .claude directory that is + // not the user's own, and every case here targets /.claude. + realHome = process.env.HOME; + process.env.HOME = dir; + settingsPath = join(dir, ".claude", "settings.json"); + hookPath = join(dir, ".claude", HOOK_REL_PATH); + }); + afterEach(() => { + if (realHome === undefined) delete process.env.HOME; + else process.env.HOME = realHome; + vi.restoreAllMocks(); + rmSync(dir, { recursive: true, force: true }); + }); + + describe("install", () => { + it("creates the settings file with both events and the packaged hook", () => { + expect(existsSync(settingsPath)).toBe(false); + installCmd({ settings: settingsPath }); + + const settings = read(); + expect(settings.hooks?.SessionStart).toHaveLength(1); + expect(settings.hooks?.PreToolUse).toHaveLength(1); + expect(settings.hooks?.PreToolUse?.[0].matcher).toBe(PRE_TOOL_MATCHER); + expect(settings.hooks?.SessionStart?.[0].matcher).toBeUndefined(); + + // The hook lands beside the settings file and is a byte copy of the + // packaged one - a stale or rewritten copy would enforce old rules. + expect(existsSync(hookPath)).toBe(true); + expect(readFileSync(hookPath)).toEqual(readFileSync(PACKAGED_HOOK)); + }); + + it("embeds the absolute node binary and the absolute hook path", () => { + installCmd({ settings: settingsPath }); + + const settings = read(); + const commands = [ + settings.hooks?.SessionStart?.[0].hooks[0], + settings.hooks?.PreToolUse?.[0].hooks[0], + ]; + for (const hook of commands) { + expect(hook?.type).toBe("command"); + const parts = COMMAND_SHAPE.exec(hook?.command ?? ""); + expect(parts).not.toBeNull(); + const [, node, script] = parts ?? []; + expect(node).toBe(process.execPath); + expect(script).toBe(hookPath); + expect(isAbsolute(node)).toBe(true); + expect(isAbsolute(script)).toBe(true); + // A bare interpreter name is the silent-failure mode we guard against. + expect(hook?.command).not.toMatch(/^"?node"?\s/); + } + }); + + it("defaults to the settings file in the home directory", () => { + installCmd({}); + + expect(existsSync(settingsPath)).toBe(true); + expect(read().hooks?.PreToolUse).toHaveLength(1); + expect(existsSync(hookPath)).toBe(true); + }); + + it("installs twice without stacking duplicate entries", () => { + installCmd({ settings: settingsPath }); + installCmd({ settings: settingsPath }); + + const settings = read(); + expect(settings.hooks?.SessionStart).toHaveLength(1); + expect(settings.hooks?.PreToolUse).toHaveLength(1); + }); + + it("keeps foreign hooks, on our events and on events we never touch", () => { + seed({ hooks: { PostToolUse: [FOREIGN_POST_TOOL], SessionStart: [FOREIGN_SESSION_START] } }); + installCmd({ settings: settingsPath }); + + const settings = read(); + expect(settings.hooks?.PostToolUse).toEqual([FOREIGN_POST_TOOL]); + expect(settings.hooks?.SessionStart).toContainEqual(FOREIGN_SESSION_START); + expect(settings.hooks?.SessionStart).toHaveLength(2); + expect(settings.hooks?.PreToolUse).toHaveLength(1); + }); + + it("keeps a foreign hook that shares an entry with ours", () => { + // The user added their audit hook next to ours, in one entry with their + // own matcher. Reinstalling replaces our hook, not their entry. + seed({ hooks: { PreToolUse: [{ matcher: "Bash", hooks: [AUDIT_HOOK, ourHook()] }] } }); + installCmd({ settings: settingsPath }); + + const entries = read().hooks?.PreToolUse ?? []; + expect(entries).toHaveLength(2); + const shared = entries.find((entry) => entry.matcher === "Bash"); + expect(shared?.hooks).toEqual([AUDIT_HOOK]); + const ours = entries.filter(runsOurs); + expect(ours).toHaveLength(1); + expect(ours[0].matcher).toBe(PRE_TOOL_MATCHER); + }); + + it("leaves a wrapper of the user's that merely names our script", () => { + seed({ hooks: { SessionStart: [WRAPPER_HOOK] } }); + installCmd({ settings: settingsPath }); + expect(read().hooks?.SessionStart).toContainEqual(WRAPPER_HOOK); + + installCmd({ uninstall: true, settings: settingsPath }); + expect(read().hooks?.SessionStart).toEqual([WRAPPER_HOOK]); + }); + + it("leaves unrelated top-level settings keys untouched", () => { + const permissions = { allow: ["Bash(git status)"], deny: [] }; + const env = { CX_MAX_FILE_BYTES: "2097152" }; + seed({ permissions, env, model: "opus" }); + installCmd({ settings: settingsPath }); + + const settings = read(); + expect(settings.permissions).toEqual(permissions); + expect(settings.env).toEqual(env); + expect(settings.model).toBe("opus"); + }); + + it("replaces the settings file atomically and leaves no temp file", () => { + seed({ model: "opus" }); + const before = statSync(settingsPath).ino; + installCmd({ settings: settingsPath }); + + // A rename swaps the inode; an in-place truncate-then-write keeps it and + // is exactly the window where a crash costs the user every setting. + expect(statSync(settingsPath).ino).not.toBe(before); + expect(readdirSync(dirname(settingsPath)).sort()).toEqual(["hooks", "settings.json"]); + + installCmd({ uninstall: true, settings: settingsPath }); + expect(readdirSync(dirname(settingsPath)).sort()).toEqual(["hooks", "settings.json"]); + }); + + it("writes through a symlinked settings file and leaves the link a link", () => { + const tracked = seedSymlinked({ model: "opus" }); + installCmd({ settings: settingsPath }); + + // Renaming onto the link replaces it with a regular file: the tracked + // copy keeps the old bytes and the user's settings silently stop + // applying, which is the same as losing them. + expect(lstatSync(settingsPath).isSymbolicLink()).toBe(true); + const settings = read(tracked); + expect(settings.hooks?.PreToolUse).toHaveLength(1); + expect(settings.model).toBe("opus"); + // The temp file belongs beside the real target, and must not survive it. + expect(readdirSync(dirname(tracked))).toEqual([TRACKED_SETTINGS]); + expect(readdirSync(dirname(settingsPath)).sort()).toEqual(["hooks", "settings.json"]); + }); + + it("creates the target of a settings symlink that does not point at a file yet", () => { + // The link is in place but the dotfiles repo is not checked out; writing + // a regular file at the link's own path would break the setup the moment + // it is. + const tracked = join(dir, "dotfiles", TRACKED_SETTINGS); + mkdirSync(dirname(settingsPath), { recursive: true }); + symlinkSync(tracked, settingsPath); + installCmd({ settings: settingsPath }); + + expect(lstatSync(settingsPath).isSymbolicLink()).toBe(true); + expect(read(tracked).hooks?.PreToolUse).toHaveLength(1); + expect(readdirSync(dirname(tracked))).toEqual([TRACKED_SETTINGS]); + }); + + it("removes the hook it just copied when the settings write fails", () => { + seed({ model: "opus" }); + mkdirSync(dirname(hookPath), { recursive: true }); + withUnwritableDir(() => { + expect(() => installCmd({ settings: settingsPath })).toThrow(); + }); + + // The script is copied before the settings that reference it, so an + // aborted write leaves a file nothing runs. + expect(existsSync(hookPath)).toBe(false); + expect(read()).toEqual({ model: "opus" }); + }); + + it("keeps a hook script from an earlier install when the write fails", () => { + installCmd({ settings: settingsPath }); + const copied = readFileSync(hookPath); + withUnwritableDir(() => { + expect(() => installCmd({ settings: settingsPath })).toThrow(); + }); + + // This one is still wired up by the settings written before; deleting it + // would break every tool call in the next session. + expect(readFileSync(hookPath)).toEqual(copied); + expect(readFileSync(settingsPath, "utf8")).toContain(hookPath); + }); + + it("reports Claude Code and the settings file it wrote", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + installCmd({ settings: settingsPath }); + + const out = log.mock.calls.map((args) => args.join(" ")).join("\n"); + expect(out).toContain("Claude Code"); + expect(out).toContain(settingsPath); + expect(out).toContain(hookPath); + }); + }); + + describe("target resolution", () => { + it("refuses to install when the home directory is not absolute", () => { + // os.homedir() answers "" under `env -i` and some CI runners; joining + // that writes .claude into the current working directory instead. + const cwdClaude = join(process.cwd(), ".claude"); + const existed = existsSync(cwdClaude); + + process.env.HOME = ""; + expect(() => installCmd({})).toThrow(InstallError); + expect(() => installCmd({})).toThrow(/home directory/i); + process.env.HOME = "relative/home"; + expect(() => installCmd({})).toThrow(/home directory/i); + + expect(existsSync(cwdClaude)).toBe(existed); + }); + + it("refuses a project-scoped .claude settings file", () => { + const project = join(dir, "repo", ".claude", "settings.json"); + + expect(() => installCmd({ settings: project })).toThrow(InstallError); + expect(() => installCmd({ settings: project })).toThrow(/project-scoped/); + // Nothing written, not even the directory: the shareable file would + // carry this machine's node path and a hook copy inside the repo. + expect(existsSync(dirname(project))).toBe(false); + }); + + it("accepts the home settings file reached through a symlinked HOME", () => { + // A relocated or symlinked home (/home/you -> /mnt/home/you, macOS + // /tmp -> /private/tmp) still names the user's own .claude directory, + // so scope has to be decided on resolved paths, not on the spelling. + const home = join(dir, "home"); + mkdirSync(join(home, ".claude"), { recursive: true }); + const linked = join(dir, "home-link"); + symlinkSync(home, linked); + process.env.HOME = linked; + const target = join(home, ".claude", "settings.json"); + + expect(() => installCmd({ settings: target })).not.toThrow(); + expect(read(target).hooks?.PreToolUse).toHaveLength(1); + }); + + it("installs into a project-scoped file under --force, and uninstalls without it", () => { + const project = join(dir, "repo", ".claude", "settings.json"); + installCmd({ settings: project, force: true }); + expect(read(project).hooks?.PreToolUse).toHaveLength(1); + + // Undoing a --force install must not need --force, or it would be a + // one-way door. + installCmd({ uninstall: true, settings: project }); + expect(readFileSync(project, "utf8")).not.toContain("cx-deny-grep"); + expect(existsSync(join(dirname(project), HOOK_REL_PATH))).toBe(false); + }); + }); + + describe("unreadable settings", () => { + /** A settings file we refuse must come back byte-identical, and no hook + * may be copied in on the way. */ + const expectRefusal = (text: string, message: RegExp): void => { + seedText(text); + expect(() => installCmd({ settings: settingsPath })).toThrow(message); + expect(() => installCmd({ uninstall: true, settings: settingsPath })).toThrow(message); + expect(readFileSync(settingsPath, "utf8")).toBe(text); + expect(existsSync(hookPath)).toBe(false); + }; + + it("refuses a settings file that is not valid JSON", () => { + expectRefusal('{\n // JSONC is not JSON\n "hooks": {}\n}\n', /not valid JSON/); + }); + + it("refuses a settings file that does not hold an object", () => { + expectRefusal("null\n", /holds null, not a JSON object/); + }); + + it("refuses a hooks block that is not an object of events", () => { + expectRefusal('{\n "hooks": []\n}\n', /"hooks".*is an array/); + }); + + it("refuses an event whose entries are not an array", () => { + expectRefusal('{\n "hooks": {\n "PreToolUse": "Grep"\n }\n}\n', /PreToolUse.*a string/); + }); + }); + + describe("uninstall", () => { + it("removes our entries and the copied hook file", () => { + installCmd({ settings: settingsPath }); + installCmd({ uninstall: true, settings: settingsPath }); + + expect(existsSync(hookPath)).toBe(false); + expect(readFileSync(settingsPath, "utf8")).not.toContain("cx-deny-grep"); + }); + + it("leaves no empty hooks husk when ours were the only entries", () => { + installCmd({ settings: settingsPath }); + installCmd({ uninstall: true, settings: settingsPath }); + + expect(read().hooks).toBeUndefined(); + }); + + it("keeps the hooks block and its foreign entries when others remain", () => { + seed({ hooks: { PostToolUse: [FOREIGN_POST_TOOL], SessionStart: [FOREIGN_SESSION_START] } }); + installCmd({ settings: settingsPath }); + installCmd({ uninstall: true, settings: settingsPath }); + + const settings = read(); + expect(settings.hooks?.PostToolUse).toEqual([FOREIGN_POST_TOOL]); + expect(settings.hooks?.SessionStart).toEqual([FOREIGN_SESSION_START]); + expect(settings.hooks?.PreToolUse).toBeUndefined(); + }); + + it("keeps the other hooks of an entry it shares with us", () => { + seed({ hooks: { PreToolUse: [{ matcher: "Bash", hooks: [AUDIT_HOOK, ourHook()] }] } }); + installCmd({ uninstall: true, settings: settingsPath }); + + const entries = read().hooks?.PreToolUse ?? []; + expect(entries).toEqual([{ matcher: "Bash", hooks: [AUDIT_HOOK] }]); + }); + + it("keeps the hook file while a sibling settings file still runs it", () => { + // settings.json and settings.local.json share one hooks/ directory, so + // the script is shared too: deleting it out from under the survivor + // turns every tool call into MODULE_NOT_FOUND. + const localPath = join(dir, ".claude", "settings.local.json"); + installCmd({ settings: settingsPath }); + installCmd({ settings: localPath }); + + installCmd({ uninstall: true, settings: settingsPath }); + expect(existsSync(hookPath)).toBe(true); + expect(readFileSync(localPath, "utf8")).toContain(hookPath); + + installCmd({ uninstall: true, settings: localPath }); + expect(existsSync(hookPath)).toBe(false); + }); + + it("keeps the hook file while a custom-named settings file still runs it", () => { + // --settings takes any path, so the survivor need not be named + // settings.local.json for its hook command to be live. + const custom = join(dirname(settingsPath), "my-settings.json"); + installCmd({ settings: settingsPath }); + installCmd({ settings: custom }); + + installCmd({ uninstall: true, settings: settingsPath }); + expect(existsSync(hookPath)).toBe(true); + expect(readFileSync(custom, "utf8")).toContain(hookPath); + + installCmd({ uninstall: true, settings: custom }); + expect(existsSync(hookPath)).toBe(false); + }); + + it("writes through a symlinked settings file and leaves the link a link", () => { + const tracked = seedSymlinked({ model: "opus" }); + installCmd({ settings: settingsPath }); + installCmd({ uninstall: true, settings: settingsPath }); + + expect(lstatSync(settingsPath).isSymbolicLink()).toBe(true); + const settings = read(tracked); + expect(settings.hooks).toBeUndefined(); + expect(settings.model).toBe("opus"); + expect(existsSync(hookPath)).toBe(false); + expect(readdirSync(dirname(tracked))).toEqual([TRACKED_SETTINGS]); + }); + + it("is a no-op on a settings file that was never installed into", () => { + const before: Settings = { + permissions: { allow: ["Read(**)"] }, + hooks: { PostToolUse: [FOREIGN_POST_TOOL] }, + }; + seed(before); + expect(() => installCmd({ uninstall: true, settings: settingsPath })).not.toThrow(); + + expect(read()).toEqual(before); + }); + + it("is a no-op when the settings file does not exist", () => { + installCmd({ uninstall: true, settings: settingsPath }); + + // A removal that conjures a settings file (and a .claude directory) to + // prove it holds no hooks has made the user's machine messier, not + // cleaner. + expect(existsSync(settingsPath)).toBe(false); + expect(existsSync(dirname(settingsPath))).toBe(false); + expect(existsSync(hookPath)).toBe(false); + }); + }); +}); + +/** The repo root plus the two ends of `cx --version`: the manifest that owns + * the version, and the built entry point that has to report it. */ +const REPO_ROOT = fileURLToPath(new URL("..", import.meta.url)); +const PACKAGE_JSON = new URL("../package.json", import.meta.url); +const CLI_BUILT = fileURLToPath(new URL("../dist/cli.js", import.meta.url)); +const TSC = fileURLToPath(new URL("../node_modules/typescript/bin/tsc", import.meta.url)); + +/** tsc on this project takes a couple of seconds; leave the build room. */ +const BUILD_TIMEOUT_MS = 120_000; + +describe("cx --version", () => { + it( + "reports the version from package.json", + () => { + buildCli(); + expect(existsSync(CLI_BUILT)).toBe(true); + const { version } = JSON.parse(readFileSync(PACKAGE_JSON, "utf8")) as { version: string }; + const printed = execFileSync(process.execPath, [CLI_BUILT, "--version"], { + encoding: "utf8", + }); + + // A literal in cli.ts drifted from the published version once already; + // reading package.json at runtime is what keeps the two in lockstep. + expect(printed.trim()).toBe(version); + }, + BUILD_TIMEOUT_MS, + ); +}); + +/** `--version` is only observable from the built CLI, so compile it here + * rather than trust whatever is in dist/ - a build left over from other work + * can be newer than the source and still print the wrong thing. A failing + * compile is not this case's business (`tsc --noEmit` is the gate for that) + * and tsc emits anyway, so the assertions decide. */ +function buildCli(): void { + try { + execFileSync(process.execPath, [TSC], { cwd: REPO_ROOT, stdio: "inherit" }); + } catch { + /* fall through: the version assertion reports whatever landed in dist/ */ + } +} diff --git a/test/integration.test.ts b/test/integration.test.ts index fc54725..e512ae0 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -7,7 +7,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { connect } from "@infino-ai/infino"; import { indexRepo, indexRepoStaged } from "../src/core/indexer.js"; import { readManifest } from "../src/core/manifest.js"; -import { runSql, search } from "../src/core/searcher.js"; +import { runSql, vectorsNote } from "../src/core/searcher.js"; import type { IndexHandle } from "../src/core/context.js"; import type { Embedder } from "../src/core/embedder.js"; @@ -82,27 +82,40 @@ describe("indexing", () => { }); }); -describe("search", () => { - it("finds exact identifiers through the keyword half", async () => { - const r = await search(handle, fakeEmbedder, "verifySession token", 5); - expect(r.hits.length).toBeGreaterThan(0); - expect(r.hits[0].path).toBe("src/auth.ts"); - expect(r.hits[0].startLine).toBeGreaterThan(0); - expect(r.hits[0].content).toContain("verifySession"); +describe("ranked retrieval via sql TVFs", () => { + it("finds exact identifiers through bm25_search", async () => { + const rows = await runSql( + handle, + fakeEmbedder, + "SELECT path, start_line, content FROM bm25_search('chunks','content','verifySession token', 5)", + ); + expect(rows.length).toBeGreaterThan(0); + expect(rows[0].path).toBe("src/auth.ts"); + expect(Number(rows[0].start_line)).toBeGreaterThan(0); + expect(String(rows[0].content)).toContain("verifySession"); }); - it("hybrid ranking once vectors are ready", async () => { - const r = await search(handle, fakeEmbedder, "session verification", 5); - expect(r.ranking).toBe("hybrid"); - expect(r.hits.length).toBeGreaterThan(0); - expect(r.hits[0].path).toMatch(/auth|README/); + it("ranks by meaning through hybrid_search once vectors are ready", async () => { + const rows = await runSql( + handle, + fakeEmbedder, + "SELECT path FROM hybrid_search('chunks','content','session verification','embedding', {{q}}, 5)", + { q: "session verification" }, + ); + expect(rows.length).toBeGreaterThan(0); + expect(String(rows[0].path)).toMatch(/auth|README/); }); - it("keyword ranking while vectors are not ready", async () => { - const noVec = { ...handle, manifest: { ...handle.manifest, vectors: "building" as const } }; - const r = await search(noVec, fakeEmbedder, "commit log", 5); - expect(r.ranking).toBe("keyword"); - expect(r.note).toMatch(/vectors not ready/); + it("surfaces the vectors-not-ready note while bm25_search still answers", async () => { + const building = { ...handle, manifest: { ...handle.manifest, vectors: "building" as const } }; + expect(vectorsNote(building.manifest)).toMatch(/vectors are still backfilling/); + expect(vectorsNote(handle.manifest)).toBeUndefined(); + const rows = await runSql( + building, + fakeEmbedder, + "SELECT path FROM bm25_search('chunks','content','commit log', 5)", + ); + expect(rows.length).toBeGreaterThan(0); }); }); diff --git a/test/streaming.test.ts b/test/streaming.test.ts index 641502b..731b47f 100644 --- a/test/streaming.test.ts +++ b/test/streaming.test.ts @@ -14,7 +14,7 @@ import { connect, type Connection } from "@infino-ai/infino"; import { APPEND_BATCH, EMBED_BATCH } from "../src/core/config.js"; import { indexRepo, indexRepoStaged, syncRepo, type SyncResult } from "../src/core/indexer.js"; import { readManifest } from "../src/core/manifest.js"; -import { search } from "../src/core/searcher.js"; +import { runSql } from "../src/core/searcher.js"; import { unpackRows, type Embedder } from "../src/core/embedder.js"; import type { IndexHandle } from "../src/core/context.js"; @@ -93,9 +93,13 @@ describe("streamed staged build", () => { expect(Number(n)).toBe(stats.chunks); const handle: IndexHandle = { root, dir, db, manifest: readManifest(dir)! }; - const r = await search(handle, float32Fake, "streamingfixture3 pipeline", 5); - expect(r.ranking).toBe("hybrid"); - expect(r.hits.length).toBeGreaterThan(0); + const rows = await runSql( + handle, + float32Fake, + "SELECT path FROM hybrid_search('chunks','content','streamingfixture3 pipeline','embedding', {{q}}, 5)", + { q: "streamingfixture3 pipeline" }, + ); + expect(rows.length).toBeGreaterThan(0); // Handoff files are gone once the vector stage settles. expect(spillNames(dir)).toEqual([]); @@ -127,9 +131,12 @@ describe("streamed staged build", () => { // Keyword search still answers from the stage-1 table. const handle: IndexHandle = { root, dir, db, manifest: readManifest(dir)! }; - const r = await search(handle, embedOnlyFake, "streamingfixture5", 3); - expect(r.ranking).toBe("keyword"); - expect(r.hits.length).toBeGreaterThan(0); + const rows = await runSql( + handle, + embedOnlyFake, + "SELECT path FROM bm25_search('chunks','content','streamingfixture5', 3)", + ); + expect(rows.length).toBeGreaterThan(0); expect(spillNames(dir)).toEqual([]); }); @@ -178,9 +185,13 @@ describe("streamed incremental sync", () => { expect(outcome.chunks).toBe(stats.chunks + outcome.chunksAdded); const handle: IndexHandle = { root, dir, db, manifest: readManifest(dir)! }; - const r = await search(handle, float32Fake, "syncwavefixture101", 3); - expect(r.ranking).toBe("hybrid"); - expect(r.hits.some((h) => h.path === "src/mod101.js")).toBe(true); + const rows = await runSql( + handle, + float32Fake, + "SELECT path FROM hybrid_search('chunks','content','syncwavefixture101','embedding', {{q}}, 3)", + { q: "syncwavefixture101" }, + ); + expect(rows.some((r) => r.path === "src/mod101.js")).toBe(true); }); }); @@ -244,8 +255,13 @@ describe("review-confirmed regressions", () => { const [{ n: after }] = db.querySql(`SELECT COUNT(*) AS n FROM chunks`) as [{ n: unknown }]; expect(Number(after)).toBe(Number(before)); const handle: IndexHandle = { root, dir, db, manifest: readManifest(dir)! }; - const r = await search(handle, float32Fake, "streamingfixture0 pipeline", 3); - expect(r.hits.some((h) => h.path === "src/mod0.js")).toBe(true); + const rows = await runSql( + handle, + float32Fake, + "SELECT path FROM hybrid_search('chunks','content','streamingfixture0 pipeline','embedding', {{q}}, 3)", + { q: "streamingfixture0 pipeline" }, + ); + expect(rows.some((r) => r.path === "src/mod0.js")).toBe(true); expect(spillNames(dir)).toEqual([]); // A later sync with a healthy embedder heals the same changeset. diff --git a/test/sync.test.ts b/test/sync.test.ts index 606b4b0..6895dbc 100644 --- a/test/sync.test.ts +++ b/test/sync.test.ts @@ -8,7 +8,7 @@ import { connect } from "@infino-ai/infino"; import { diffFiles, emptyFileState, hashContent, readFileState } from "../src/core/filestate.js"; import { indexRepo, syncRepo } from "../src/core/indexer.js"; import { readManifest } from "../src/core/manifest.js"; -import { search } from "../src/core/searcher.js"; +import { runSql } from "../src/core/searcher.js"; import type { IndexHandle } from "../src/core/context.js"; import type { Embedder } from "../src/core/embedder.js"; @@ -122,10 +122,23 @@ describe("syncRepo", () => { expect(count("axolotl")).toBeGreaterThan(0); expect(count("quokka")).toBe(0); expect(count("wombat")).toBe(0); - // hybrid search still works over synced rows (vectors were embedded) - const s = await search(handle, fakeEmbedder, "axolotl", 3); - expect(s.ranking).toBe("hybrid"); - expect(s.hits.some((h) => h.path === "src/gamma.ts")).toBe(true); + // hybrid retrieval still works over synced rows (vectors were embedded) + const rows = await runSql( + handle, + fakeEmbedder, + "SELECT path FROM hybrid_search('chunks','content','axolotl','embedding', {{q}}, 3)", + { q: "axolotl" }, + ); + expect(rows.some((r) => r.path === "src/gamma.ts")).toBe(true); + // Meaning-only ranking proves the synced rows were actually re-embedded: + // vector_search can only return rows that have vectors. + const vrows = await runSql( + handle, + fakeEmbedder, + "SELECT path FROM vector_search('chunks','embedding', {{q}}, 3)", + { q: "axolotl" }, + ); + expect(vrows.some((r) => r.path === "src/gamma.ts")).toBe(true); }); it("is idempotent when a file is re-added identically (no duplicate rows)", async () => { diff --git a/test/truncation.test.ts b/test/truncation.test.ts index a033f1f..32e7226 100644 --- a/test/truncation.test.ts +++ b/test/truncation.test.ts @@ -11,8 +11,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { connect, type Connection } from "@infino-ai/infino"; import { indexRepo, syncRepo } from "../src/core/indexer.js"; import { readManifest, type Manifest } from "../src/core/manifest.js"; -import { search, partialIndex } from "../src/core/searcher.js"; -import type { IndexHandle } from "../src/core/context.js"; +import { partialIndex } from "../src/core/searcher.js"; import type { Embedder } from "../src/core/embedder.js"; const fakeEmbedder: Embedder = { @@ -29,7 +28,6 @@ let db: Connection; const cap = (maxFiles: number) => ({ maxFiles, maxFileBytes: 1024 * 1024 }); const writeFile = (n: number) => writeFileSync(join(root, "src", `f${n}.ts`), `export const value${n} = "token${n}";\n`); -const handleFrom = (m: Manifest): IndexHandle => ({ root, dir, db, manifest: m }); beforeEach(() => { root = mkdtempSync(join(tmpdir(), "cx-trunc-")); @@ -71,10 +69,10 @@ describe("truncation end to end", () => { expect(m.truncatedFiles).toBe(2); expect(m.maxFiles).toBe(1); - const r = await search(handleFrom(m), fakeEmbedder, "token", 5); - expect(r.partial).toBeDefined(); - expect(r.partial!.filesSkipped).toBe(2); - expect(r.partial!.fileCap).toBe(1); + const p = partialIndex(m); + expect(p).toBeDefined(); + expect(p!.filesSkipped).toBe(2); + expect(p!.fileCap).toBe(1); }); it("omits truncation fields and the marker when the whole tree fits", async () => { @@ -85,7 +83,7 @@ describe("truncation end to end", () => { const m = readManifest(dir)!; expect(m.truncatedFiles).toBeUndefined(); expect(m.maxFiles).toBeUndefined(); - expect((await search(handleFrom(m), fakeEmbedder, "token", 5)).partial).toBeUndefined(); + expect(partialIndex(m)).toBeUndefined(); }); it("starts tracking truncation once a growing repo crosses the cap", async () => { @@ -140,6 +138,6 @@ describe("truncation end to end", () => { const m = readManifest(dir)!; expect(m.truncatedFiles).toBe(1); expect(m.maxFiles).toBe(1); - expect((await search(handleFrom(m), fakeEmbedder, "token", 5)).partial!.filesSkipped).toBe(1); + expect(partialIndex(m)!.filesSkipped).toBe(1); }); }); diff --git a/test/usage.test.ts b/test/usage.test.ts index 2d50e87..601a0ac 100644 --- a/test/usage.test.ts +++ b/test/usage.test.ts @@ -5,7 +5,6 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from import { estTokens, newSession, - searchEntry, sqlEntry, formatReceipt, recordUsage, @@ -15,19 +14,6 @@ import { recordHookEvent, currentSessionStats, } from "../src/core/usage.js"; -import type { SearchResult, SearchHit } from "../src/core/searcher.js"; - -const hit = (path: string, content: string): SearchHit => ({ - path, - startLine: 1, - endLine: 10, - lang: "ts", - score: 1, - content, -}); - -const result = (hits: SearchHit[]): SearchResult => ({ query: "q", ranking: "keyword", hits }); - describe("estTokens", () => { it("estimates ~chars/4", () => { expect(estTokens("")).toBe(0); @@ -36,46 +22,6 @@ describe("estTokens", () => { }); }); -describe("search receipt", () => { - let root: string; - beforeAll(() => { - root = mkdtempSync(join(tmpdir(), "cx-usage-")); - writeFileSync(join(root, "a.ts"), "x".repeat(4000)); // ~1k tokens on disk - writeFileSync(join(root, "b.ts"), "y".repeat(4000)); - }); - afterAll(() => rmSync(root, { recursive: true, force: true })); - - it("reports tokens returned and chunks/files, with no trailing markers", () => { - const line = formatReceipt(searchEntry(result([hit("a.ts", "z".repeat(400)), hit("a.ts", "z".repeat(400))]), root)); - expect(line).toBe("returned ~200 tokens | 2 chunks / 1 file"); - }); - - it("does not assert a whole-file counterfactual in the receipt", () => { - // The estimate still lives on the entry (for the ledger), but the receipt - // shown after every response makes no "vs reading them whole" claim. - const entry = searchEntry(result([hit("a.ts", "z".repeat(40)), hit("b.ts", "z".repeat(40))]), root); - expect(entry.wholeFileTokens).toBeGreaterThan(0); - const line = formatReceipt(entry); - expect(line).not.toMatch(/whole/); - expect(line).not.toMatch(/\bvs\b/); - }); - - it("accumulates the session invocation count and token total across calls", () => { - const session = newSession(); - formatReceipt(searchEntry(result([hit("a.ts", "z".repeat(400))]), root), session); // +100 - const line = formatReceipt(searchEntry(result([hit("a.ts", "z".repeat(400))]), root), session); // +100 - expect(session.queries).toBe(2); - expect(session.returnedTokens).toBe(200); - expect(line).toMatch(/invoked 2x this session \(~200 tokens total\)/); - }); - - it("counts the first call as invoked 1x", () => { - const session = newSession(); - const line = formatReceipt(searchEntry(result([hit("a.ts", "z".repeat(40))]), "/nope"), session); - expect(line).toMatch(/invoked 1x this session/); - }); -}); - describe("sql receipt", () => { it("reports row count and token estimate of the rows", () => { const line = formatReceipt(sqlEntry("SELECT 1", [{ path: "a.ts", lines: 12 }, { path: "b.ts", lines: 8 }])); @@ -89,6 +35,14 @@ describe("sql receipt", () => { expect(line).not.toMatch(/to read those files whole/); expect(line).toMatch(/invoked 1x this session/); }); + + it("accumulates the invocation count and token total across calls", () => { + const session = newSession(); + formatReceipt(sqlEntry("SELECT 1", [{ z: "z".repeat(396) }]), session); + const line = formatReceipt(sqlEntry("SELECT 1", [{ z: "z".repeat(396) }]), session); + expect(session.queries).toBe(2); + expect(line).toMatch(/invoked 2x this session/); + }); }); describe("the ledger", () => { @@ -97,17 +51,17 @@ describe("the ledger", () => { afterAll(() => rmSync(dir, { recursive: true, force: true })); it("round-trips entries oldest-first and captures the response summary", () => { - recordUsage(dir, searchEntry({ query: "auth", ranking: "hybrid", hits: [hit("a.ts", "zzzz")] }, dir)); + recordUsage(dir, sqlEntry("SELECT path FROM bm25_search('chunks','content','auth', 5)", [{ path: "a.ts" }])); recordUsage(dir, sqlEntry("SELECT count(*)", [{ n: 3 }])); const entries = readUsage(dir); - expect(entries.map((e) => e.tool)).toEqual(["search", "sql"]); - expect(entries[0].query).toBe("auth"); - expect(entries[0].hits?.[0]).toMatchObject({ path: "a.ts", startLine: 1, endLine: 10 }); + expect(entries.map((e) => e.tool)).toEqual(["sql", "sql"]); + expect(entries[0].query).toContain("bm25_search"); + expect(entries[0].rows).toBe(1); expect(entries[1].rows).toBe(1); }); it("skips torn / hand-edited lines instead of throwing", () => { - writeFileSync(usageLogPath(dir), '{"tool":"search"}\nnot json\n', { flag: "a" }); + writeFileSync(usageLogPath(dir), '{"tool":"sql"}\nnot json\n', { flag: "a" }); expect(() => readUsage(dir)).not.toThrow(); expect(readUsage(dir).length).toBeGreaterThanOrEqual(3); }); @@ -125,7 +79,7 @@ describe("prompt telemetry (hooks)", () => { afterEach(() => rmSync(dir, { recursive: true, force: true })); const submit = (sid: string) => recordHookEvent(dir, { hook_event_name: "UserPromptSubmit", session_id: sid }); - const cxCall = (sid: string) => recordHookEvent(dir, { hook_event_name: "PostToolUse", session_id: sid, tool_name: "mcp__code-context__search" }); + const cxCall = (sid: string) => recordHookEvent(dir, { hook_event_name: "PostToolUse", session_id: sid, tool_name: "mcp__code-context__sql" }); const otherCall = (sid: string) => recordHookEvent(dir, { hook_event_name: "PostToolUse", session_id: sid, tool_name: "Grep" }); it("counts prompts, cx calls, and prompts-that-used-cx (once per prompt)", () => {