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 /packages/core/src | |
| 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.
Diffstat (limited to 'packages/core/src')
| -rw-r--r-- | packages/core/src/tools/search-code.ts | 44 |
1 files changed, 33 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.", |
