From ad95e84655c11bdb6e20dccee48b046549595631 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Wed, 12 Aug 2026 10:47:45 +0000 Subject: [PATCH 1/8] plugin: enforce index-first search with plugin hooks Installing the plugin now makes the index the way agents search code, instead of only recommending it: a PreToolUse hook denies the Grep tool, and a Node hook denies standalone grep/rg/git-grep Bash commands, each with a reason redirecting the agent to the search/sql tools. The deny is self-correcting - Claude Code feeds the reason back to the agent, so a reflexive grep becomes a search call on the next step. grep as a pipe filter on other command output stays allowed; filtering logs or test output is a job the index does not do. A SessionStart hook announces the policy up front so agents reach for the index first rather than learning it from a denial. The Bash hook uses Node (already the package's requirement) rather than jq, which is not guaranteed on user machines. --- hooks/deny-grep.mjs | 27 +++++++++++++++++++++++++++ hooks/hooks.json | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 hooks/deny-grep.mjs create mode 100644 hooks/hooks.json diff --git a/hooks/deny-grep.mjs b/hooks/deny-grep.mjs new file mode 100644 index 0000000..85723ce --- /dev/null +++ b/hooks/deny-grep.mjs @@ -0,0 +1,27 @@ +#!/usr/bin/env node +// PreToolUse hook (Bash matcher): deny standalone grep/rg/git-grep commands +// and redirect the agent to the code-context search/sql tools. Pipelines that +// merely filter other command output through grep are allowed - filtering +// logs or test output is a job the index does not do. +let data = ""; +process.stdin.on("data", (c) => (data += c)); +process.stdin.on("end", () => { + let command = ""; + try { + command = JSON.parse(data)?.tool_input?.command ?? ""; + } catch { + // Unparseable payload: allow (emit nothing) rather than break the tool call. + } + if (/^\s*(rg|grep|git\s+grep)\s/.test(command)) { + console.log( + JSON.stringify({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: + "code-context: standalone grep/rg for code search is disabled. Use the search MCP tool to find or understand code, or sql for counts and rankings across the repo. Piping other command output through grep is still allowed.", + }, + }), + ); + } +}); diff --git a/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 0000000..c5c568b --- /dev/null +++ b/hooks/hooks.json @@ -0,0 +1,34 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "echo '{\"hookSpecificOutput\":{\"hookEventName\":\"SessionStart\",\"additionalContext\":\"code-context is active: code search goes through its MCP tools. Use search to find or understand code and sql for counts/rankings; the Grep tool and standalone grep/rg commands are disabled (grep as a pipe filter on other command output still works).\"}}'" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Grep", + "hooks": [ + { + "type": "command", + "command": "echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"code-context: Grep is disabled here. Use the search MCP tool to find or understand code, or sql for counts and rankings across the repo.\"}}'" + } + ] + }, + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/deny-grep.mjs\"" + } + ] + } + ] + } +} From 820e4758f6e372ee7e0ad3f7b5ab4538c8daa06f Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Wed, 12 Aug 2026 11:58:51 +0000 Subject: [PATCH 2/8] hooks: gate grep denial on index readiness; sql is the search surface Grep (tool or standalone grep/rg/git-grep in Bash) is now denied only once the repo's index fully covers the code: manifest present, vectors ready, nothing truncated by the file cap. Until then grep passes through - agents are never forced onto an index that does not exist yet or can only rank by keyword. The hook reads the codecontext.json manifest directly (CX_INDEX_DIR override honored, walking up from cwd otherwise). The redirects now point at sql with the search table functions - the search tool (still registered by older server builds) is denied with a hybrid_search/bm25_search example, and sql statements calling vector_search are denied in favor of hybrid_search. One script serves SessionStart and both PreToolUse matchers, and the session-start note adapts to index state. --- hooks/deny-grep.mjs | 106 ++++++++++++++++++++++++++++++++++++++++---- hooks/hooks.json | 13 +----- 2 files changed, 99 insertions(+), 20 deletions(-) diff --git a/hooks/deny-grep.mjs b/hooks/deny-grep.mjs index 85723ce..545c5c3 100644 --- a/hooks/deny-grep.mjs +++ b/hooks/deny-grep.mjs @@ -1,27 +1,115 @@ #!/usr/bin/env node -// PreToolUse hook (Bash matcher): deny standalone grep/rg/git-grep commands -// and redirect the agent to the code-context search/sql tools. Pipelines that -// merely filter other command output through grep are allowed - filtering -// logs or test output is a job the index does not do. +// code-context enforcement hook, gated on index readiness. +// +// Grep (the tool, or standalone grep/rg/git-grep in Bash) is denied with a +// redirect to the search/sql tools ONLY once the repo's index fully covers +// the code: manifest present, vectors "ready", nothing truncated by the file +// cap. Until then grep passes through - agents are never forced onto an +// index that doesn't exist yet or can't answer semantically. Pipelines that +// merely filter other command output through grep are always allowed. +// +// One script serves three hook bindings (SessionStart, PreToolUse/Grep, +// PreToolUse/Bash), dispatching on the payload. +import { readFileSync, existsSync } from "node:fs"; +import { join, dirname } from "node:path"; + +const INDEX_FORMAT_VERSION = 2; + +/** Walk up from cwd to the index manifest (CX_INDEX_DIR overrides, matching + * the cx CLI). Returns "ready" | "partial" | "building" | "none". */ +function indexState(cwd) { + let manifestFile; + if (process.env.CX_INDEX_DIR) { + manifestFile = join(process.env.CX_INDEX_DIR, "codecontext.json"); + } else { + for (let dir = cwd || process.cwd(); ; dir = dirname(dir)) { + const candidate = join(dir, ".infino", "codecontext.json"); + if (existsSync(candidate)) { + manifestFile = candidate; + break; + } + if (dirname(dir) === dir) return "none"; + } + } + try { + const m = JSON.parse(readFileSync(manifestFile, "utf8")); + if (m.version !== INDEX_FORMAT_VERSION) return "none"; + if (m.truncatedFiles) return "partial"; + return m.vectors === "ready" ? "ready" : "building"; + } catch { + return "none"; + } +} + +const GREP_LAUNCH = /^\s*(rg|grep|git\s+grep)\s/; + let data = ""; process.stdin.on("data", (c) => (data += c)); process.stdin.on("end", () => { - let command = ""; + let input = {}; try { - command = JSON.parse(data)?.tool_input?.command ?? ""; + input = JSON.parse(data); } catch { - // Unparseable payload: allow (emit nothing) rather than break the tool call. + return; // unparseable payload: do nothing rather than break the call + } + + if (input.hook_event_name === "SessionStart") { + const note = + indexState(input.cwd) === "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)." + : "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 first call builds the index). grep stays enabled until the index fully covers the repo."; + console.log( + JSON.stringify({ + hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: note }, + }), + ); + return; + } + + // Policy: sql-with-TVFs is the only search surface. The search tool (still + // registered by older server builds) and raw vector_search are both denied + // with a redirect; bm25_search/hybrid_search are the sanctioned TVFs. + const toolName = input.tool_name ?? ""; + if (/code[-_]context.*__search$/.test(toolName)) { + console.log( + JSON.stringify({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: + '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) before vectors are ready. Rank + aggregate composes via GROUP BY.', + }, + }), + ); + return; } - if (/^\s*(rg|grep|git\s+grep)\s/.test(command)) { + if (/code[-_]context.*__sql$/.test(toolName) && /\bvector_search\s*\(/i.test(input.tool_input?.query ?? "")) { console.log( JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: - "code-context: standalone grep/rg for code search is disabled. Use the search MCP tool to find or understand code, or sql for counts and rankings across the repo. Piping other command output through grep is still allowed.", + "code-context: vector_search is not exposed - use hybrid_search('chunks','content','','embedding', {{q}}, k) with the embed map (keyword + semantic fused), or bm25_search before vectors are ready.", }, }), ); + return; } + + const isGrep = + toolName === "Grep" || + (toolName === "Bash" && GREP_LAUNCH.test(input.tool_input?.command ?? "")); + if (!isGrep || indexState(input.cwd) !== "ready") return; + + console.log( + JSON.stringify({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: + "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.", + }, + }), + ); }); diff --git a/hooks/hooks.json b/hooks/hooks.json index c5c568b..36c5e1c 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -5,23 +5,14 @@ "hooks": [ { "type": "command", - "command": "echo '{\"hookSpecificOutput\":{\"hookEventName\":\"SessionStart\",\"additionalContext\":\"code-context is active: code search goes through its MCP tools. Use search to find or understand code and sql for counts/rankings; the Grep tool and standalone grep/rg commands are disabled (grep as a pipe filter on other command output still works).\"}}'" + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/deny-grep.mjs\"" } ] } ], "PreToolUse": [ { - "matcher": "Grep", - "hooks": [ - { - "type": "command", - "command": "echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"code-context: Grep is disabled here. Use the search MCP tool to find or understand code, or sql for counts and rankings across the repo.\"}}'" - } - ] - }, - { - "matcher": "Bash", + "matcher": "Grep|Bash|mcp__.*code[-_]context.*__(sql|search)", "hooks": [ { "type": "command", From a4065a005880167b4a46662e081859489064018a Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Wed, 12 Aug 2026 11:59:32 +0000 Subject: [PATCH 3/8] mcp: sql with search TVFs is the only search surface; index eagerly Drop the search tool: sql is how agents search. Ranked retrieval is a table-valued function inside the query - hybrid_search for the fused keyword + semantic pass (bm25_search while vectors are backfilling) - so finding, understanding, counting, and ranking code are all one read-only SELECT. The instructions and the sql description now lead with the retrieval query shape (SELECT path, start_line, end_line, symbol, content FROM hybrid_search(...)) instead of presenting sql as an analytics side-tool, and vector_search is rejected with a redirect to hybrid_search - raw vector ranking loses the keyword arm for no benefit and agents kept reaching for it. Build the index at server startup instead of on the first query: the server starts with the session, so the staged build begins immediately and keyword search is typically live (vectors backfilling) before the agent asks anything. Queries still ensure the index inline as before; CX_AUTO_INDEX=0 disables both. Agent-observed failure modes this closes: choosing vector_search over hybrid_search, LIKE table scans instead of ranked TVFs, and first-query latency absorbed by an inline index build. --- src/mcp/server.ts | 185 +++++++++++++++++----------------------------- 1 file changed, 68 insertions(+), 117 deletions(-) diff --git a/src/mcp/server.ts b/src/mcp/server.ts index ad733d4..636189f 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -19,11 +19,11 @@ 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 } from "../core/searcher.js"; +import { newSession, receiptEnabled, sqlEntry, formatReceipt, recordUsage } from "../core/usage.js"; import { indexRepoStaged, syncRepo, @@ -168,112 +168,55 @@ 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 is not exposed - hybrid_search is the " + + "meaning-aware path. 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 +225,21 @@ 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 is not exposed - " + + "hybrid_search is the meaning-aware path. 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() @@ -314,6 +258,13 @@ export async function serveMcp(rootPath?: string): Promise { }, }, async ({ query, embed, path }) => { + if (/\bvector_search\s*\(/i.test(query)) { + return fail( + "vector_search is not exposed - use hybrid_search('" + TABLE + "','content',''," + + "'embedding', {{q}}, k) with the embed map (keyword + semantic fused), or bm25_search " + + "while vectors are still backfilling.", + ); + } let ctx: RepoCtx; try { ctx = repoFor(path); From 8adc8712ebef99f529bbe9b4e59badead49a50ee Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Wed, 12 Aug 2026 21:38:36 +0000 Subject: [PATCH 4/8] rewrite the query surface around sql TVFs only; drop search everywhere The search code path is gone, not deprecated: core search() and its SearchHit/SearchResult shapes, the cx search CLI command, CX_SEARCH_K, the search usage-receipt variant (searchEntry, wholeFileTokens, the per-hit ledger rendering), and every doc that described a three-tool surface. One retrieval door remains - read-only SQL whose table-valued search functions do the ranking: hybrid_search for the fused keyword + semantic pass, bm25_search for the keyword-only window while vectors backfill, GROUP BY over either for counts and rankings. Tests are ported, not deleted: every retrieval assertion now goes through runSql with the TVFs (exact-identifier bm25, meaning-aware hybrid, hybrid-over-synced-rows, staged-readiness and crash-recovery probes), truncation asserts partialIndex() directly, and the usage suite covers the sql receipt shape. The bench recall lane ranks through hybrid_search SQL; its vector-only lane keeps the raw engine call since it exists to isolate the embedder, not to model the agent surface. Verified: tsc --noEmit clean, vitest 100/100 across 13 files. --- AGENTS.md | 37 +++++----- README.md | 73 ++++++++++--------- bench/recall.mjs | 13 +++- llms.txt | 38 +++++----- src/cli.ts | 28 +++----- src/commands/query-cmds.ts | 60 +++------------- src/core/config.ts | 3 - src/core/searcher.ts | 106 +++------------------------- src/core/usage.ts | 60 ++-------------- src/mcp/ensure.ts | 2 +- src/mcp/server.ts | 16 ++--- test/auto-index.integration.test.ts | 12 ++-- test/integration.test.ts | 46 +++++++----- test/streaming.test.ts | 40 +++++++---- test/sync.test.ts | 14 ++-- test/truncation.test.ts | 16 ++--- test/usage.test.ts | 74 ++++--------------- 17 files changed, 227 insertions(+), 411 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5b02689..34d1db3 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,17 +25,19 @@ 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`). - `test/`: vitest suites. `bench/`: the benchmark harness. `docs/`: docs. @@ -53,11 +56,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 - vector_search stays unexposed for the same reason. +- 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..804cb28 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. +The CLI and MCP server embed `{{name}}` placeholders server-side, so agents +never handle raw vectors. (`vector_search` is deliberately not exposed: raw +vector ranking drops the keyword arm for no benefit.) ### 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,7 +197,7 @@ 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 @@ -252,7 +261,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). @@ -267,19 +276,18 @@ no restart, no per-repo config. | 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 | -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 +302,16 @@ 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 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/llms.txt b/llms.txt index 8aef780..a1c1731 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 @@ -26,19 +27,18 @@ logs, docs, and agent memory. 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`. +- 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 as table-valued relations - ranked + retrieval and GROUP BY aggregation in one query; vector_search is not + exposed) 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`. ## Evidence diff --git a/src/cli.ts b/src/cli.ts index e51eb45..34f70a0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -6,17 +6,17 @@ 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 { sqlCmd, statusCmd, usageCmd } from "./commands/query-cmds.js"; 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") .addHelpText( @@ -24,12 +24,13 @@ program ` 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`, ); program @@ -43,15 +44,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") @@ -87,7 +79,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/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 e76bf13..f9ed022 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -58,6 +58,3 @@ export const EMBED_MAX_CHARS = Number(process.env.CX_EMBED_MAX_CHARS ?? 8000); * and at local-repo scale (tens of thousands of chunks) still milliseconds. */ export const N_CENT = 1; -/** 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/searcher.ts b/src/core/searcher.ts index 0b8f9b7..3e2fc39 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; @@ -50,87 +46,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 +97,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 636189f..16799b1 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1,18 +1,18 @@ // 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"; 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/integration.test.ts b/test/integration.test.ts index fc54725..dd095e1 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 } from "../src/core/searcher.js"; import type { IndexHandle } from "../src/core/context.js"; import type { Embedder } from "../src/core/embedder.js"; @@ -82,27 +82,37 @@ 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("bm25_search needs no embedding, so it answers while vectors backfill", async () => { + const rows = await runSql( + handle, + 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..6a80709 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,14 @@ 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); }); 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)", () => { From 4892c54d26d62dc9574b80904ce698abecf4ea86 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Wed, 12 Aug 2026 22:05:55 +0000 Subject: [PATCH 5/8] sql: all engine TVFs are legitimate inside a statement Drop the vector_search rejection added two commits ago - the policy is that sql is the only door, not that some table functions are forbidden inside it. vector_search('chunks','embedding', {{q}}, k) is meaning-only ranking and occasionally the right relation; the tool description keeps steering toward hybrid_search (which retains the keyword arm) without refusing the query. The plugin hook likewise stops inspecting sql statements; it still denies the legacy search tool and gates grep. --- AGENTS.md | 2 +- README.md | 4 ++-- hooks/deny-grep.mjs | 16 +--------------- hooks/hooks.json | 2 +- llms.txt | 6 +++--- src/mcp/server.ts | 19 +++++++------------ 6 files changed, 15 insertions(+), 34 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 34d1db3..78c3628 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,7 +59,7 @@ before opening a PR. - 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 - vector_search stays unexposed for the same reason. + 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. diff --git a/README.md b/README.md index 804cb28..abff8e5 100644 --- a/README.md +++ b/README.md @@ -145,9 +145,9 @@ FROM bm25_search('chunks', 'content', 'vector index quantization', 300) GROUP BY path ORDER BY lines DESC LIMIT 15 ``` +`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. (`vector_search` is deliberately not exposed: raw -vector ranking drops the keyword arm for no benefit.) +never handle raw vectors. ### Staged readiness diff --git a/hooks/deny-grep.mjs b/hooks/deny-grep.mjs index 545c5c3..3403af8 100644 --- a/hooks/deny-grep.mjs +++ b/hooks/deny-grep.mjs @@ -67,8 +67,7 @@ process.stdin.on("end", () => { } // Policy: sql-with-TVFs is the only search surface. The search tool (still - // registered by older server builds) and raw vector_search are both denied - // with a redirect; bm25_search/hybrid_search are the sanctioned TVFs. + // registered by older server builds) is denied with a redirect to sql. const toolName = input.tool_name ?? ""; if (/code[-_]context.*__search$/.test(toolName)) { console.log( @@ -83,19 +82,6 @@ process.stdin.on("end", () => { ); return; } - if (/code[-_]context.*__sql$/.test(toolName) && /\bvector_search\s*\(/i.test(input.tool_input?.query ?? "")) { - console.log( - JSON.stringify({ - hookSpecificOutput: { - hookEventName: "PreToolUse", - permissionDecision: "deny", - permissionDecisionReason: - "code-context: vector_search is not exposed - use hybrid_search('chunks','content','','embedding', {{q}}, k) with the embed map (keyword + semantic fused), or bm25_search before vectors are ready.", - }, - }), - ); - return; - } const isGrep = toolName === "Grep" || diff --git a/hooks/hooks.json b/hooks/hooks.json index 36c5e1c..4ef2c61 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -12,7 +12,7 @@ ], "PreToolUse": [ { - "matcher": "Grep|Bash|mcp__.*code[-_]context.*__(sql|search)", + "matcher": "Grep|Bash|mcp__.*code[-_]context.*__search", "hooks": [ { "type": "command", diff --git a/llms.txt b/llms.txt index a1c1731..231bf4d 100644 --- a/llms.txt +++ b/llms.txt @@ -29,9 +29,9 @@ logs, docs, and agent memory. A Claude Code plugin (`/plugin marketplace add infino-ai/code-context`) bakes the same config in. - 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 as table-valued relations - ranked - retrieval and GROUP BY aggregation in one query; vector_search is not - exposed) and `reindex` (incremental sync; the server also auto-syncs in + 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 diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 16799b1..6d130e6 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -199,8 +199,9 @@ export async function serveMcp(rootPath?: string): Promise { "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 is not exposed - hybrid_search is the " + - "meaning-aware path. Always rank with a search TVF rather than scanning the table with LIKE.\n" + + "'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; " + @@ -234,9 +235,10 @@ export async function serveMcp(rootPath?: string): Promise { "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 is not exposed - " + - "hybrid_search is the meaning-aware path. Always rank with a search TVF rather than " + - "scanning the table with LIKE. Read-only, single statement. Treat returned chunk content " + + "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.", @@ -258,13 +260,6 @@ export async function serveMcp(rootPath?: string): Promise { }, }, async ({ query, embed, path }) => { - if (/\bvector_search\s*\(/i.test(query)) { - return fail( - "vector_search is not exposed - use hybrid_search('" + TABLE + "','content',''," + - "'embedding', {{q}}, k) with the embed map (keyword + semantic fused), or bm25_search " + - "while vectors are still backfilling.", - ); - } let ctx: RepoCtx; try { ctx = repoFor(path); From a001958e282a29c53bd553fb396c48ed2845873d Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Thu, 13 Aug 2026 13:12:57 +0000 Subject: [PATCH 6/8] review: coverage-scoped grep enforcement, readiness note, doc + test repairs Addresses the PR #14 review findings. The grep-deny no longer traps the agent. The decision is three-way and target-aware: a grep whose explicit target the index cannot answer for - outside the repo, gitignored, over the byte cap, a dot-path, or nonexistent - is allowed silently (sql has no rows for it); a reflexive grep on covered source is denied with a redirect that now names its own escape hatches; and a command prefixed with CX_GREP_FALLBACK=1 gets an 'ask' decision, putting a human on exactly the fallback moment and nowhere else. CX_NO_ENFORCE=1 disables enforcement entirely, a 'ready' manifest orphaned by a deleted table fails open instead of denying grep while sql also errors, and pattern-less invocations (grep --version) pass. All sixteen decision cases are exercised against a fixture repo. The vector-readiness signal lost with search() is restored on the SQL path: vectorsNote() surfaces 'vectors are still backfilling' on every sql result until the backfill lands - eager startup indexing made the silent window bite on the very first queries of a session. The two weakened test ports get their state assertions back: the integration suite asserts the not-ready note against a building manifest, and the sync suite proves re-embedding through vector_search, which can only return rows that actually have vectors. SKILL.md and docs/faq.md catch up to the two-tool surface (both still advertised search, which the hook itself denies), and the README documents enforcement and both escape hatches. --- README.md | 14 +++ docs/faq.md | 40 ++++---- hooks/deny-grep.mjs | 191 ++++++++++++++++++++++++++--------- skills/code-context/SKILL.md | 109 +++++++++++--------- src/core/searcher.ts | 14 +++ src/mcp/server.ts | 6 +- test/integration.test.ts | 9 +- test/sync.test.ts | 9 ++ 8 files changed, 269 insertions(+), 123 deletions(-) diff --git a/README.md b/README.md index abff8e5..f9cbc74 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,18 @@ miss code-context and fall back to grep/read. It's a small, always-loaded set 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 +truncated), the Grep tool and standalone `grep`/`rg` commands are denied with +a redirect to `sql`. 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, an oversized file, 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. + **For a team,** commit a project-scoped `.mcp.json` at the repo root so everyone gets it (after the one-time project-server approval): @@ -283,6 +295,8 @@ no restart, no per-repo config. | `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 Claude Code plugin's grep-enforcement hooks entirely | +| `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 `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. diff --git a/docs/faq.md b/docs/faq.md index 6a42a0e..9b92091 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? @@ -32,11 +32,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 +63,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 index 3403af8..937636a 100644 --- a/hooks/deny-grep.mjs +++ b/hooks/deny-grep.mjs @@ -1,47 +1,133 @@ #!/usr/bin/env node -// code-context enforcement hook, gated on index readiness. +// code-context enforcement hook: steer code search to the sql tool without +// trapping the agent. // -// Grep (the tool, or standalone grep/rg/git-grep in Bash) is denied with a -// redirect to the search/sql tools ONLY once the repo's index fully covers -// the code: manifest present, vectors "ready", nothing truncated by the file -// cap. Until then grep passes through - agents are never forced onto an -// index that doesn't exist yet or can't answer semantically. Pipelines that -// merely filter other command output through grep are always allowed. +// 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); +// - every explicit grep target is something the index covers - a target +// outside the repo, gitignored, over the byte cap, a dot-path, or +// nonexistent is auto-allowed, because sql could not answer it anyway; +// - 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 the hook entirely. Every ambiguity fails open (allow). // -// One script serves three hook bindings (SessionStart, PreToolUse/Grep, -// PreToolUse/Bash), dispatching on the payload. -import { readFileSync, existsSync } from "node:fs"; -import { join, dirname } from "node:path"; +// 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, statSync, existsSync } from "node:fs"; +import { join, dirname, resolve, relative, isAbsolute, sep } from "node:path"; +import { spawnSync } from "node:child_process"; const INDEX_FORMAT_VERSION = 2; +/** Mirror of the indexer's default byte cap (CX_MAX_FILE_BYTES). */ +const MAX_FILE_BYTES = Number(process.env.CX_MAX_FILE_BYTES ?? 1024 * 1024); -/** Walk up from cwd to the index manifest (CX_INDEX_DIR overrides, matching - * the cx CLI). Returns "ready" | "partial" | "building" | "none". */ -function indexState(cwd) { +/** A grep-like launch: optional fallback marker, then rg | grep | git grep. */ +const GREP_LAUNCH = /^\s*(CX_GREP_FALLBACK=1\s+)?(rg|grep|git\s+grep)\s/; + +/** 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) { let manifestFile; + let root = cwd || process.cwd(); if (process.env.CX_INDEX_DIR) { manifestFile = join(process.env.CX_INDEX_DIR, "codecontext.json"); } else { - for (let dir = cwd || process.cwd(); ; dir = dirname(dir)) { + for (let dir = root; ; dir = dirname(dir)) { const candidate = join(dir, ".infino", "codecontext.json"); if (existsSync(candidate)) { manifestFile = candidate; + root = dir; break; } - if (dirname(dir) === dir) return "none"; + if (dirname(dir) === dir) return { state: "none", root }; } } try { const m = JSON.parse(readFileSync(manifestFile, "utf8")); - if (m.version !== INDEX_FORMAT_VERSION) return "none"; - if (m.truncatedFiles) return "partial"; - return m.vectors === "ready" ? "ready" : "building"; + if (m.version !== INDEX_FORMAT_VERSION) return { state: "none", root }; + if (m.truncatedFiles) return { state: "partial", root }; + if (m.vectors !== "ready") return { state: "building", root }; + // A "ready" manifest orphaned by a deleted/moved table would deny grep + // while sql also errors - losing both paths. Confirm the table exists. + const indexDir = dirname(manifestFile); + const hasTable = readdirSync(indexDir).some((n) => n.startsWith(`${m.table}-`)); + return { state: hasTable ? "ready" : "none", root }; + } catch { + return { state: "none", root }; + } +} + +/** Explicit path targets of a grep-like command, heuristically: bare tokens + * after the first (the pattern). isSearch is false for pattern-less + * invocations like `grep --version`. Misparses fail open downstream. */ +function grepTargets(command) { + const toks = command.trim().split(/\s+/); + let i = 0; + if (/^CX_GREP_FALLBACK=1$/.test(toks[i])) i++; + i += toks[i] === "git" ? 2 : 1; + const targets = []; + let sawPattern = false; + for (; i < toks.length; i++) { + const t = toks[i]; + if (t === "--") continue; + if (t.startsWith("-")) continue; + if (!sawPattern) { + sawPattern = true; + continue; + } + targets.push(t.replace(/^['"]|['"]$/g, "")); + } + return { targets, isSearch: sawPattern }; +} + +/** True when the index cannot cover this target, so grep must be allowed: + * outside the repo, a dot-path, nonexistent, over the byte cap, or + * gitignored. Inconclusive signals count as covered (the policy applies). */ +function uncoveredTarget(target, cwd, root) { + const p = isAbsolute(target) ? target : resolve(cwd || root, target); + const rel = relative(root, p); + if (rel.startsWith("..") || isAbsolute(rel)) return true; + if (rel.split(sep).some((part) => part.startsWith(".") && part !== ".")) return true; + let st; + try { + st = statSync(p); + } catch { + return true; + } + if (st.isFile() && st.size > MAX_FILE_BYTES) return true; + try { + const r = spawnSync("git", ["-C", root, "check-ignore", "-q", p], { timeout: 3000 }); + if (r.status === 0) return true; } catch { - return "none"; + // git unavailable - skip this signal } + return false; } -const GREP_LAUNCH = /^\s*(rg|grep|git\s+grep)\s/; +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)); @@ -55,9 +141,11 @@ process.stdin.on("end", () => { if (input.hook_event_name === "SessionStart") { const note = - indexState(input.cwd) === "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)." - : "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 first call builds the index). grep stays enabled until the index fully covers the repo."; + 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 }, @@ -66,36 +154,41 @@ process.stdin.on("end", () => { return; } - // Policy: sql-with-TVFs is the only search surface. The search tool (still - // registered by older server builds) is denied with a redirect to sql. const toolName = input.tool_name ?? ""; + + // Legacy `search` tool (pre-0.2 servers): sql is the search surface. if (/code[-_]context.*__search$/.test(toolName)) { - console.log( - JSON.stringify({ - hookSpecificOutput: { - hookEventName: "PreToolUse", - permissionDecision: "deny", - permissionDecisionReason: - '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) before vectors are ready. Rank + aggregate composes via GROUP BY.', - }, - }), + 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; } - const isGrep = - toolName === "Grep" || - (toolName === "Bash" && GREP_LAUNCH.test(input.tool_input?.command ?? "")); - if (!isGrep || indexState(input.cwd) !== "ready") return; + if (process.env.CX_NO_ENFORCE === "1") return; - console.log( - JSON.stringify({ - hookSpecificOutput: { - hookEventName: "PreToolUse", - permissionDecision: "deny", - permissionDecisionReason: - "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 (toolName === "Grep") { + const info = indexInfo(input.cwd); + if (info.state !== "ready") return; + const target = input.tool_input?.path; + if (target && uncoveredTarget(target, input.cwd, info.root)) return; + decision("deny", DENY_REASON); + return; + } + + if (toolName === "Bash") { + const command = input.tool_input?.command ?? ""; + const m = GREP_LAUNCH.exec(command); + if (!m) return; + const info = indexInfo(input.cwd); + if (info.state !== "ready") return; + const { targets, isSearch } = grepTargets(command); + if (!isSearch) return; // pattern-less: grep --version etc. + if (targets.some((t) => uncoveredTarget(t, input.cwd, info.root))) return; + if (m[1]) { + decision("ask", ASK_REASON); + return; + } + decision("deny", DENY_REASON); + } }); 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/core/searcher.ts b/src/core/searcher.ts index 3e2fc39..5d1348b 100644 --- a/src/core/searcher.ts +++ b/src/core/searcher.ts @@ -37,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( diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 6d130e6..9d71afe 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -22,7 +22,7 @@ import { connect } from "@infino-ai/infino"; 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 { runSql, jsonify, partialIndex } from "../core/searcher.js"; +import { runSql, jsonify, partialIndex, vectorsNote } from "../core/searcher.js"; import { newSession, receiptEnabled, sqlEntry, formatReceipt, recordUsage } from "../core/usage.js"; import { indexRepoStaged, @@ -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()); @@ -279,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); @@ -287,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/integration.test.ts b/test/integration.test.ts index dd095e1..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 } 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"; @@ -106,9 +106,12 @@ describe("ranked retrieval via sql TVFs", () => { expect(String(rows[0].path)).toMatch(/auth|README/); }); - it("bm25_search needs no embedding, so it answers while vectors backfill", async () => { + 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( - handle, + building, fakeEmbedder, "SELECT path FROM bm25_search('chunks','content','commit log', 5)", ); diff --git a/test/sync.test.ts b/test/sync.test.ts index 6a80709..6895dbc 100644 --- a/test/sync.test.ts +++ b/test/sync.test.ts @@ -130,6 +130,15 @@ describe("syncRepo", () => { { 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 () => { From 65b809370f812e9d71b7818f0df673d2e33a4c3f Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Thu, 13 Aug 2026 23:31:14 +0000 Subject: [PATCH 7/8] hooks: decide coverage from the index's own record, not a model of it The grep-deny's coverage check re-derived the indexer's skip rules and got them wrong in both directions: it denied greps on files the index never holds (lockfiles, .csv, LICENSE, vendor/, symlinks - sql answers none of them, so the agent lost both paths), and it allowed indexed dotfiles. Now the indexer records how many chunks each file produced (filestate entries gain an optional count, stamped by both the build and the sync, 0 included so a sync does not re-hash) and the hook covers exactly the files with rows - verified equal to SELECT DISTINCT path FROM chunks on four fixtures built by the real indexer, every file, directory and glob form. A wholly count-less (pre-upgrade) state fails open until a rebuild stamps it. The manifest records the repo root so a CX_INDEX_DIR-relocated index still scopes targets, trusted only when it agrees with where the index was found (a copied repo's stale root would otherwise turn enforcement off silently). The command reader now holds against the shapes agents actually type: operands end at an unquoted pipe or comment (rg pat src | head was allowed - downstream words read as uncoverable targets), heredoc bodies are data, shell control words and group openers are stepped over (if/while/for/{/(/!, backgrounding &), launchers match by basename (/usr/bin/rg), git's global flags are walked (-C also moves the scope), wrapper value-flags are per-wrapper (env -i ate the launcher), unquoted trailing group-closers are syntax rather than targets, and a glob's star-runs collapse before compiling (a 24-star token wedged the regex past the hook timeout). Everything unparseable still fails open. 61 -> 73 enforcement cases, including ground-truth agreement driven through the real indexer on both write paths. --- hooks/deny-grep.mjs | 680 ++++++++++++++++++++++++++++++----- src/core/filestate.ts | 24 +- src/core/indexer.ts | 36 +- src/core/manifest.ts | 6 + test/enforcement.test.ts | 741 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 1395 insertions(+), 92 deletions(-) create mode 100644 test/enforcement.test.ts diff --git a/hooks/deny-grep.mjs b/hooks/deny-grep.mjs index 937636a..26a60e8 100644 --- a/hooks/deny-grep.mjs +++ b/hooks/deny-grep.mjs @@ -1,4 +1,7 @@ #!/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. // @@ -7,107 +10,593 @@ // - 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); -// - every explicit grep target is something the index covers - a target -// outside the repo, gitignored, over the byte cap, a dot-path, or -// nonexistent is auto-allowed, because sql could not answer it anyway; +// - 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 the hook entirely. Every ambiguity fails open (allow). +// 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, statSync, existsSync } from "node:fs"; -import { join, dirname, resolve, relative, isAbsolute, sep } from "node:path"; -import { spawnSync } from "node:child_process"; +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; -/** Mirror of the indexer's default byte cap (CX_MAX_FILE_BYTES). */ -const MAX_FILE_BYTES = Number(process.env.CX_MAX_FILE_BYTES ?? 1024 * 1024); -/** A grep-like launch: optional fallback marker, then rg | grep | git grep. */ -const GREP_LAUNCH = /^\s*(CX_GREP_FALLBACK=1\s+)?(rg|grep|git\s+grep)\s/; +/** 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) { - let manifestFile; - let root = cwd || process.cwd(); - if (process.env.CX_INDEX_DIR) { - manifestFile = join(process.env.CX_INDEX_DIR, "codecontext.json"); - } else { - for (let dir = root; ; dir = dirname(dir)) { - const candidate = join(dir, ".infino", "codecontext.json"); - if (existsSync(candidate)) { - manifestFile = candidate; - root = dir; - break; - } - if (dirname(dir) === dir) return { state: "none", root }; - } - } + 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(manifestFile, "utf8")); - if (m.version !== INDEX_FORMAT_VERSION) return { state: "none", root }; - if (m.truncatedFiles) return { state: "partial", root }; - if (m.vectors !== "ready") return { state: "building", root }; + 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 indexDir = dirname(manifestFile); const hasTable = readdirSync(indexDir).some((n) => n.startsWith(`${m.table}-`)); - return { state: hasTable ? "ready" : "none", root }; + 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 { state: "none", root }; + 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}/`)); } -/** Explicit path targets of a grep-like command, heuristically: bare tokens - * after the first (the pattern). isSearch is false for pattern-less - * invocations like `grep --version`. Misparses fail open downstream. */ -function grepTargets(command) { - const toks = command.trim().split(/\s+/); +/** 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; - if (/^CX_GREP_FALLBACK=1$/.test(toks[i])) i++; - i += toks[i] === "git" ? 2 : 1; + 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 t = toks[i]; - if (t === "--") continue; - if (t.startsWith("-")) continue; + 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; } - targets.push(t.replace(/^['"]|['"]$/g, "")); + // 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 { targets, isSearch: sawPattern }; -} - -/** True when the index cannot cover this target, so grep must be allowed: - * outside the repo, a dot-path, nonexistent, over the byte cap, or - * gitignored. Inconclusive signals count as covered (the policy applies). */ -function uncoveredTarget(target, cwd, root) { - const p = isAbsolute(target) ? target : resolve(cwd || root, target); - const rel = relative(root, p); - if (rel.startsWith("..") || isAbsolute(rel)) return true; - if (rel.split(sep).some((part) => part.startsWith(".") && part !== ".")) return true; - let st; - try { - st = statSync(p); - } catch { - return true; - } - if (st.isFile() && st.size > MAX_FILE_BYTES) return true; - try { - const r = spawnSync("git", ["-C", root, "check-ignore", "-q", p], { timeout: 3000 }); - if (r.status === 0) return true; - } catch { - // git unavailable - skip this signal - } - return false; + return { fallback, targets, isSearch: sawPattern, chdir }; } const decision = (permissionDecision, permissionDecisionReason) => @@ -139,6 +628,8 @@ process.stdin.on("end", () => { 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" @@ -154,6 +645,10 @@ process.stdin.on("end", () => { 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. @@ -165,30 +660,53 @@ process.stdin.on("end", () => { return; } - if (process.env.CX_NO_ENFORCE === "1") return; - if (toolName === "Grep") { const info = indexInfo(input.cwd); if (info.state !== "ready") return; - const target = input.tool_input?.path; - if (target && uncoveredTarget(target, input.cwd, info.root)) 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 m = GREP_LAUNCH.exec(command); - if (!m) return; + 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 { targets, isSearch } = grepTargets(command); - if (!isSearch) return; // pattern-less: grep --version etc. - if (targets.some((t) => uncoveredTarget(t, input.cwd, info.root))) return; - if (m[1]) { - decision("ask", ASK_REASON); + 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; } - decision("deny", DENY_REASON); } }); 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/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"); + }); +}); From 088ab81b33ac5ef4454df0e7f68be944da0df7be Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Thu, 13 Aug 2026 23:31:14 +0000 Subject: [PATCH 8/8] cli: cx install wires the enforcement hooks into a hand-configured client The plugin ships the hooks, but a hand-wired MCP server (npx, claude mcp add-json) has no hook surface at all - the tools arrive with no steering, agents keep grepping, and restarting changes nothing. 0.1.0 solved this with cx install and the command dropped out in 0.1.4; this brings it back for the two-tool surface: copy hooks/deny-grep.mjs to ~/.claude/hooks and merge SessionStart + PreToolUse entries into ~/.claude/settings.json, embedding the absolute node running the install because the client process often has no node on PATH and a hook that cannot find node fails silently. The settings file belongs to the user, so the edit is careful: ownership is per hook command keyed on the resolved script path (a foreign hook sharing our entry survives; a wrapper naming our basename is never touched), the rewrite is a same-directory temp file + rename that follows symlinks (dotfile-managed settings keep working) and writes hardlinks in place (a rename would quietly unlink the other name), uninstall asks sibling settings files before deleting the shared script and is a byte-identical round trip, a non-absolute HOME fails loudly instead of writing into the cwd, a project-scoped .claude target is refused (machine-specific absolute paths must not land in a version-controlled file; --force overrides), and malformed settings are refused rather than overwritten. Claude Code only, and says so - other MCP clients expose no hook surface to configure. package.json ships hooks/ in the tarball; cx --version reads package.json so it cannot drift again. 39 install cases; docs cover the plugin-vs-install split, both escape hatches, and the FAQ answer for making an agent actually use the index. --- AGENTS.md | 4 +- README.md | 46 ++- docs/faq.md | 23 ++ llms.txt | 23 +- package.json | 4 +- src/cli.ts | 25 +- src/commands/install-cmd.ts | 491 +++++++++++++++++++++++++++++++ test/install.test.ts | 572 ++++++++++++++++++++++++++++++++++++ 8 files changed, 1172 insertions(+), 16 deletions(-) create mode 100644 src/commands/install-cmd.ts create mode 100644 test/install.test.ts diff --git a/AGENTS.md b/AGENTS.md index 78c3628..abb7566 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +39,9 @@ the honest limits in [docs/tradeoffs.md](docs/tradeoffs.md). `indexer` (build + staged readiness + incremental sync), `searcher` (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 diff --git a/README.md b/README.md index f9cbc74..51ae0ea 100644 --- a/README.md +++ b/README.md @@ -205,15 +205,19 @@ 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 -truncated), the Grep tool and standalone `grep`/`rg` commands are denied with -a redirect to `sql`. 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, an oversized file, 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. +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): @@ -283,6 +287,27 @@ 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 | @@ -295,7 +320,7 @@ no restart, no per-repo config. | `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 Claude Code plugin's grep-enforcement hooks entirely | +| `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 `sql` result carries a **usage receipt** - a terse, local line showing @@ -320,6 +345,7 @@ 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 `sql` query diff --git a/docs/faq.md b/docs/faq.md index 9b92091..17b2c95 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -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 diff --git a/llms.txt b/llms.txt index 231bf4d..ea1aa47 100644 --- a/llms.txt +++ b/llms.txt @@ -26,7 +26,25 @@ 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. + 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 - @@ -38,7 +56,8 @@ logs, docs, and agent memory. `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 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/src/cli.ts b/src/cli.ts index 34f70a0..87e3d61 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,10 +4,19 @@ // // 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 { 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 @@ -18,7 +27,7 @@ program "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", ` @@ -30,7 +39,8 @@ Examples: 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 (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 @@ -58,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") 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/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/ */ + } +}