summaryrefslogtreecommitdiffhomepage
path: root/packages/core/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-02 17:53:46 +0900
committerAdam Malczewski <[email protected]>2026-06-02 17:53:46 +0900
commit09914c6ba15214d5ec05c106d5d11fd14a86f532 (patch)
tree11f265a8d9e223f0b4b90ffadefc1ba0791569c1 /packages/core/src
parent8d70db66d3f0046cdef5fbce2ce5a86eab0959ef (diff)
downloaddispatch-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.ts44
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.",