diff options
| author | Adam Malczewski <[email protected]> | 2026-06-02 17:53:46 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-02 17:53:46 +0900 |
| commit | 09914c6ba15214d5ec05c106d5d11fd14a86f532 (patch) | |
| tree | 11f265a8d9e223f0b4b90ffadefc1ba0791569c1 | |
| parent | 8d70db66d3f0046cdef5fbce2ce5a86eab0959ef (diff) | |
| download | dispatch-09914c6ba15214d5ec05c106d5d11fd14a86f532.tar.gz dispatch-09914c6ba15214d5ec05c106d5d11fd14a86f532.zip | |
harden(search_code): defensive arg coercion, per-line truncation, rerun-safe pkg
Address findings from a second independent (Gemini) review covering the tool
and the packaging:
- Robustness (was: crash): non-string params from a model hallucination (e.g.
include_ext: ["ts","go"]) threw 'x.trim is not a function' and killed the
tool call. Add an asString() coercion for all string params (query, path,
include_ext, exclude_pattern, only); non-strings now no-op or return the
graceful 'query is required' error.
- Output bound: cap each rendered snippet line at 500 chars (MAX_LINE_CHARS,
mirrors read-file.ts) so a matched minified/generated line can't bloat the
payload. (Total output is already bounded by the universal truncator.)
- packaging/PKGBUILD: make the cs clone rerun-safe (rm -rf before clone) so
makepkg -e / repeat runs don't abort on 'destination path already exists';
add conflicts=('cs') to the code-search package for a clean pacman error vs.
the unrelated AUR 'cs' that also owns /usr/bin/cs (no provides — different
program).
Not changed (verified): path containment, the -- flag-injection guard, and the
deterministic pinned Docker build were all confirmed solid by the review.
Tests: +2 (wrong-type params don't crash; long-line truncation). Full suite
605 pass, biome + tsc green.
| -rw-r--r-- | packages/core/src/tools/search-code.ts | 44 | ||||
| -rw-r--r-- | packages/core/tests/tools/search-code.test.ts | 48 | ||||
| -rw-r--r-- | packaging/PKGBUILD | 11 |
3 files changed, 92 insertions, 11 deletions
diff --git a/packages/core/src/tools/search-code.ts b/packages/core/src/tools/search-code.ts index 67e1e1b..4350f6a 100644 --- a/packages/core/src/tools/search-code.ts +++ b/packages/core/src/tools/search-code.ts @@ -20,6 +20,10 @@ const MAX_CONTEXT = 20; const MIN_SNIPPET_LENGTH = 50; const MAX_SNIPPET_LENGTH = 2000; const TIMEOUT_MS = 30_000; +// Hard cap on any single rendered snippet line. Mirrors read-file.ts so a +// matched minified/generated line (e.g. a 2 MB bundle line) can't blow up the +// payload. The universal truncator bounds total output; this bounds per-line. +const MAX_LINE_CHARS = 500; /** Maps the `only` enum to the corresponding cs flag. */ const ONLY_FLAGS: Record<string, string> = { @@ -130,15 +134,15 @@ export function createSearchCodeTool(workingDirectory: string): ToolDefinition { ), }), execute: async (args: Record<string, unknown>): Promise<string> => { - const query = args.query as string; - if (typeof query !== "string" || query.trim() === "") { - return "Error: query is required."; + const query = typeof args.query === "string" ? args.query : ""; + if (query.trim() === "") { + return "Error: query is required (a non-empty string)."; } // Resolve and contain the optional search path within the workdir. // Canonicalize so a symlink-in-workdir pointing outside is detected, // matching the containment semantics of list_files / read_file. - const relPath = (args.path as string | undefined) ?? "."; + const relPath = asString(args.path) ?? "."; const absoluteWorkDir = await canonicalize(workingDirectory); const searchDir = await canonicalize(workingDirectory, relPath); if (searchDir !== absoluteWorkDir && !searchDir.startsWith(`${absoluteWorkDir}/`)) { @@ -250,11 +254,11 @@ function buildFlags(args: Record<string, unknown>, searchDir: string): string[] if (args.case_sensitive === true) flags.push("-c"); - const includeExt = args.include_ext as string | undefined; - if (includeExt?.trim()) flags.push("-i", includeExt.trim()); + const includeExt = asString(args.include_ext); + if (includeExt) flags.push("-i", includeExt); - const excludePattern = args.exclude_pattern as string | undefined; - if (excludePattern?.trim()) flags.push("-x", excludePattern.trim()); + const excludePattern = asString(args.exclude_pattern); + if (excludePattern) flags.push("-x", excludePattern); // Snippet mode selection. cs's default ("auto") emits a `lines[]` array for // code but a single `content` string for prose (.md/.html/…), which our @@ -283,7 +287,7 @@ function buildFlags(args: Record<string, unknown>, searchDir: string): string[] flags.push("-n", String(snippet)); } - const only = args.only as string | undefined; + const only = asString(args.only); if (only && ONLY_FLAGS[only]) flags.push(ONLY_FLAGS[only]); return flags; @@ -308,12 +312,12 @@ function formatResults(results: CsResult[], absoluteWorkDir: string): string { if (r.lines && r.lines.length > 0) { body = r.lines.map((l) => { const marker = l.match_positions && l.match_positions.length > 0 ? ">" : " "; - return ` ${marker} ${l.line_number}: ${l.content}`; + return ` ${marker} ${l.line_number}: ${truncateLine(l.content)}`; }); } else if (r.content && r.content.trim() !== "") { // Fallback for cs's "snippet"-mode shape (no per-line numbers): show // the snippet text itself so the result isn't a bare header. - body = r.content.split("\n").map((line) => ` ${line}`); + body = r.content.split("\n").map((line) => ` ${truncateLine(line)}`); } else { body = [" (match in file; no snippet available)"]; } @@ -330,6 +334,24 @@ function clamp(n: number, min: number, max: number): number { return Math.min(max, Math.max(min, n)); } +/** Cap an individual snippet line so a minified/generated line can't bloat output. */ +function truncateLine(line: string): string { + if (line.length <= MAX_LINE_CHARS) return line; + return `${line.slice(0, MAX_LINE_CHARS)}… [line truncated, ${line.length.toLocaleString()} chars]`; +} + +/** + * Coerce a tool argument to a trimmed string, or undefined. Guards against a + * model hallucinating a non-string (e.g. an array `["ts","go"]`) for a + * string-typed param: returning undefined makes the flag a no-op instead of + * throwing `x.trim is not a function` and crashing the tool call. + */ +function asString(v: unknown): string | undefined { + if (typeof v !== "string") return undefined; + const t = v.trim(); + return t === "" ? undefined : t; +} + function missingBinaryError(): string { return [ "Error: search_code requires the 'cs' (code spelunker) binary, which was not found.", diff --git a/packages/core/tests/tools/search-code.test.ts b/packages/core/tests/tools/search-code.test.ts index d43158a..00eee4c 100644 --- a/packages/core/tests/tools/search-code.test.ts +++ b/packages/core/tests/tools/search-code.test.ts @@ -63,6 +63,28 @@ describe("search_code tool", () => { expect(out).toContain("query is required"); }); + it("does not crash when params are the wrong type (model hallucination)", async () => { + const tool = createSearchCodeTool(workDir); + // A non-string query must be rejected gracefully, not throw. + const q = await tool.execute({ query: ["a", "b"] as unknown as string }); + expect(q).toMatch(/^Error:/); + expect(q).toContain("query is required"); + // A non-string include_ext (array) must not throw "x.trim is not a function". + const stubDir = await mkdtempP(join(tmpdir(), "dispatch-cs-stub-")); + try { + process.env.DISPATCH_CS_BIN = writeStub(stubDir, ECHO_ENV_STUB); + process.env.CS_STUB_OUTPUT = "null"; + const out = await tool.execute({ + query: "x", + include_ext: ["ts", "go"] as unknown as string, + exclude_pattern: { a: 1 } as unknown as string, + }); + expect(out).toBe("No matches found."); + } finally { + await rmP(stubDir, { recursive: true, force: true }); + } + }); + it("rejects a path outside the working directory", async () => { const tool = createSearchCodeTool(workDir); const out = await tool.execute({ query: "anything", path: "../../etc" }); @@ -181,6 +203,32 @@ describe("search_code tool", () => { } }); + it("truncates an excessively long snippet line", async () => { + const stubDir = await mkdtempP(join(tmpdir(), "dispatch-cs-stub-")); + try { + const longContent = `const x = "${"Z".repeat(5000)}";`; + const csJson = JSON.stringify([ + { + filename: "big.ts", + location: join(workDir, "big.ts"), + score: 1, + language: "TypeScript", + lines: [{ line_number: 1, content: longContent, match_positions: [[10, 14]] }], + }, + ]); + process.env.DISPATCH_CS_BIN = writeStub(stubDir, ECHO_ENV_STUB); + process.env.CS_STUB_OUTPUT = csJson; + const tool = createSearchCodeTool(workDir); + const out = await tool.execute({ query: "x" }); + expect(out).toContain("line truncated"); + // No single output line should approach the raw 5k length. + const longest = Math.max(...out.split("\n").map((l) => l.length)); + expect(longest).toBeLessThan(700); + } finally { + await rmP(stubDir, { recursive: true, force: true }); + } + }); + it("surfaces raw output when cs returns unparseable JSON", async () => { const stubDir = await mkdtempP(join(tmpdir(), "dispatch-cs-stub-")); try { diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD index 4fd96d2..8918a30 100644 --- a/packaging/PKGBUILD +++ b/packaging/PKGBUILD @@ -65,6 +65,13 @@ build() { # Clone the pinned cs commit, apply the Luau declaration patch, and build a # statically-linked binary. cs vendors its deps, so `go build -mod=vendor` # needs no network beyond the clone. Mirrors the Docker cs-builder stage. + # + # rm -rf first so a rerun (makepkg -e, or two invocations without -C) that + # reuses $srcdir doesn't abort on "destination path already exists". This + # PKGBUILD intentionally avoids source=() (the s6 service files share + # basenames and would collide in $srcdir), so we clone here rather than via + # makepkg's VCS source handling. + rm -rf "${srcdir}/cs-src" git clone https://github.com/boyter/cs.git "${srcdir}/cs-src" cd "${srcdir}/cs-src" git checkout "${_cs_commit}" @@ -237,6 +244,10 @@ package_dispatch-s6() { package_code-search() { pkgdesc='code spelunker (cs) — fast, relevance-ranked code search CLI (patched for Luau)' depends=() + # Both this package and the unrelated AUR `cs` (a colored ls) own /usr/bin/cs; + # declare the conflict so pacman reports it cleanly instead of a raw file + # collision. We do NOT `provides=('cs')` — this is a different program. + conflicts=('cs') url='https://github.com/boyter/cs' install -Dm755 "${srcdir}/cs" "${pkgdir}/usr/bin/cs" |
