Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions src/modules/ai/tools/search.test.ts
Original file line number Diff line number Diff line change
@@ -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" },
]);
});
});
29 changes: 24 additions & 5 deletions src/modules/ai/tools/search.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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);
Expand All @@ -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,
Expand All @@ -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 {
Expand All @@ -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) {
Expand Down
Loading