summaryrefslogtreecommitdiffhomepage
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
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.
-rw-r--r--packages/core/src/tools/search-code.ts44
-rw-r--r--packages/core/tests/tools/search-code.test.ts48
-rw-r--r--packaging/PKGBUILD11
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"