From 5398084638691cba51eb4f7e6fdfb6c39ac59c48 Mon Sep 17 00:00:00 2001 From: crynta <3nnigma.dev@gmail.com> Date: Mon, 6 Jul 2026 16:33:06 +0200 Subject: [PATCH] fix(ai): filter sensitive search hits --- src/modules/ai/tools/search.test.ts | 122 ++++++++++++++++++++++++++++ src/modules/ai/tools/search.ts | 29 +++++-- 2 files changed, 146 insertions(+), 5 deletions(-) create mode 100644 src/modules/ai/tools/search.test.ts diff --git a/src/modules/ai/tools/search.test.ts b/src/modules/ai/tools/search.test.ts new file mode 100644 index 000000000..178fc1d47 --- /dev/null +++ b/src/modules/ai/tools/search.test.ts @@ -0,0 +1,122 @@ +import type { ToolExecutionOptions } from "ai"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ToolContext } from "./context"; + +const nativeMock = vi.hoisted(() => ({ + canonicalize: vi.fn(async (path: string) => path), + glob: vi.fn(), + grep: vi.fn(), +})); + +vi.mock("../lib/native", () => ({ + native: nativeMock, +})); + +import { buildSearchTools } from "./search"; + +const toolOptions: ToolExecutionOptions = { + toolCallId: "tool-call", + messages: [], +}; + +function makeContext(): ToolContext { + return { + getCwd: () => "/workspace", + getWorkspaceRoot: () => "/workspace", + getTerminalContext: () => null, + isActiveTerminalPrivate: () => false, + injectIntoActivePty: () => false, + openPreview: () => false, + spawnAgent: () => null, + readAgentOutput: () => null, + readCache: new Map(), + getSessionId: () => "session", + }; +} + +type GrepToolResult = { + hits: { path: string; rel: string; line: number; text: string }[]; +}; + +type GlobToolResult = { + hits: { path: string; rel: string }[]; +}; + +describe("AI search tools path safety", () => { + beforeEach(() => { + nativeMock.canonicalize.mockClear(); + nativeMock.glob.mockReset(); + nativeMock.grep.mockReset(); + }); + + it("filters grep hits that read_file would refuse", async () => { + nativeMock.grep.mockResolvedValue({ + hits: [ + { + path: "/workspace/src/app.ts", + rel: "workspace/src/app.ts", + line: 7, + text: "const tokenName = 'safe fixture';", + }, + { + path: "/workspace/secrets.json", + rel: "workspace/secrets.json", + line: 1, + text: '{"token":"secret"}', + }, + { + path: "/workspace/server.pem", + rel: "workspace/server.pem", + line: 1, + text: "BEGIN PRIVATE KEY", + }, + { + path: "/etc/passwd", + rel: "etc/passwd", + line: 1, + text: "root:x:0:0:root:/root:/bin/sh", + }, + ], + truncated: false, + files_scanned: 4, + }); + + const tools = buildSearchTools(makeContext()); + const execute = tools.grep.execute; + if (!execute) throw new Error("grep tool execute missing"); + const result = (await execute( + { pattern: "token|root|PRIVATE KEY", root: "/" }, + toolOptions, + )) as GrepToolResult; + + expect(result.hits.map((h) => h.path)).toEqual(["/workspace/src/app.ts"]); + expect(result.hits.map((h) => h.text)).toEqual([ + "const tokenName = 'safe fixture';", + ]); + }); + + it("filters glob hits that enumerate sensitive paths", async () => { + nativeMock.glob.mockResolvedValue({ + hits: [ + { path: "/home/me/project/src/index.ts", rel: "src/index.ts" }, + { path: "/home/me/project/id_rsa", rel: "id_rsa" }, + { path: "/home/me/project/.ssh/config", rel: ".ssh/config" }, + { path: "service-account-prod.json", rel: "service-account-prod.json" }, + { path: "/etc/passwd", rel: "../../etc/passwd" }, + ], + truncated: false, + }); + + const tools = buildSearchTools(makeContext()); + const execute = tools.glob.execute; + if (!execute) throw new Error("glob tool execute missing"); + const result = (await execute( + { pattern: "**/*", root: "/home/me/project" }, + toolOptions, + )) as GlobToolResult; + + expect(result.hits).toEqual([ + { path: "/home/me/project/src/index.ts", rel: "src/index.ts" }, + ]); + }); +}); diff --git a/src/modules/ai/tools/search.ts b/src/modules/ai/tools/search.ts index 637fc1fdc..c924b224d 100644 --- a/src/modules/ai/tools/search.ts +++ b/src/modules/ai/tools/search.ts @@ -1,7 +1,7 @@ import { tool } from "ai"; import { z } from "zod"; import { native } from "../lib/native"; -import { checkReadableCanonical } from "../lib/security"; +import { checkReadable, checkReadableCanonical } from "../lib/security"; import { resolvePath, type ToolContext } from "./context"; function resolveRoot( @@ -32,6 +32,16 @@ function clipLine(s: string): string { return `${s.slice(0, MAX_LINE_LEN)}…[+${s.length - MAX_LINE_LEN}]`; } +function pathForSafety(path: string, root: string): string { + if (path.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(path)) return path; + const sep = root.includes("\\") && !root.includes("/") ? "\\" : "/"; + return root.endsWith(sep) ? `${root}${path}` : `${root}${sep}${path}`; +} + +function isReadableSearchHit(path: string, root: string): boolean { + return checkReadable(pathForSafety(path, root)).ok; +} + export function buildSearchTools(ctx: ToolContext) { return { grep: tool({ @@ -67,7 +77,10 @@ export function buildSearchTools(ctx: ToolContext) { }) => { const r = resolveRoot(root, ctx); if (!r.ok) return { error: r.error }; - const safety = await checkReadableCanonical(r.path, native.canonicalize); + const safety = await checkReadableCanonical( + r.path, + native.canonicalize, + ); if (!safety.ok) return { error: safety.reason, root: r.path }; r.path = safety.canonical; const cap = Math.min(max_results ?? 30, 500); @@ -79,9 +92,12 @@ export function buildSearchTools(ctx: ToolContext) { caseInsensitive: case_insensitive, maxResults: cap, }); + const hits = res.hits.filter((h) => + isReadableSearchHit(h.path, r.path), + ); return { root: r.path, - hits: res.hits.map((h) => ({ + hits: hits.map((h) => ({ path: h.path, rel: h.rel, line: h.line, @@ -107,7 +123,10 @@ export function buildSearchTools(ctx: ToolContext) { execute: async ({ pattern, root, max_results }) => { const r = resolveRoot(root, ctx); if (!r.ok) return { error: r.error }; - const safety = await checkReadableCanonical(r.path, native.canonicalize); + const safety = await checkReadableCanonical( + r.path, + native.canonicalize, + ); if (!safety.ok) return { error: safety.reason, root: r.path }; r.path = safety.canonical; try { @@ -118,7 +137,7 @@ export function buildSearchTools(ctx: ToolContext) { }); return { root: r.path, - hits: res.hits, + hits: res.hits.filter((h) => isReadableSearchHit(h.path, r.path)), truncated: res.truncated, }; } catch (e) {