Skip to content

Commit 6ee75ca

Browse files
committed
Add agentic file/shell tools with approval gating, loop safety, and tool-aware model recommendations
1 parent 3d18f16 commit 6ee75ca

16 files changed

Lines changed: 889 additions & 62 deletions

app/src/agent-tools.test.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { describe, it, expect, beforeEach } from "vitest";
2+
import * as fs from "node:fs";
3+
import * as os from "node:os";
4+
import * as path from "node:path";
5+
import { readFile, writeFile, listDir, searchFiles, executeTool, runCommand } from "./agent-tools";
6+
7+
describe("agent-tools", () => {
8+
let workspace: string;
9+
10+
beforeEach(() => {
11+
workspace = fs.mkdtempSync(path.join(os.tmpdir(), "agent-tools-test-"));
12+
});
13+
14+
describe("path traversal protection", () => {
15+
it("rejects a relative path that escapes the workspace via ..", () => {
16+
expect(() => readFile(workspace, "../../etc/passwd")).toThrow(/outside the workspace/);
17+
});
18+
19+
it("rejects an absolute path outside the workspace", () => {
20+
const outsideFile = path.join(os.tmpdir(), "not-in-workspace.txt");
21+
fs.writeFileSync(outsideFile, "secret");
22+
expect(() => readFile(workspace, outsideFile)).toThrow(/outside the workspace/);
23+
});
24+
25+
it("allows a path that resolves to exactly the workspace root", () => {
26+
expect(() => listDir(workspace, ".")).not.toThrow();
27+
});
28+
29+
it("allows a nested path within the workspace", () => {
30+
fs.mkdirSync(path.join(workspace, "sub"));
31+
fs.writeFileSync(path.join(workspace, "sub", "file.txt"), "hi");
32+
expect(readFile(workspace, "sub/file.txt")).toBe("hi");
33+
});
34+
});
35+
36+
describe("readFile", () => {
37+
it("reads a file's contents", () => {
38+
fs.writeFileSync(path.join(workspace, "a.txt"), "hello world");
39+
expect(readFile(workspace, "a.txt")).toBe("hello world");
40+
});
41+
42+
it("refuses to read a directory as a file", () => {
43+
fs.mkdirSync(path.join(workspace, "adir"));
44+
expect(() => readFile(workspace, "adir")).toThrow(/directory, not a file/);
45+
});
46+
47+
it("truncates very large files instead of returning them whole", () => {
48+
fs.writeFileSync(path.join(workspace, "big.txt"), "x".repeat(200_000));
49+
const result = readFile(workspace, "big.txt");
50+
expect(result.length).toBeLessThan(200_000);
51+
expect(result).toContain("truncated");
52+
});
53+
});
54+
55+
describe("writeFile", () => {
56+
it("creates a new file with the given content", () => {
57+
const result = writeFile(workspace, "new.txt", "content here");
58+
expect(result.bytesWritten).toBe(12);
59+
expect(fs.readFileSync(path.join(workspace, "new.txt"), "utf-8")).toBe("content here");
60+
});
61+
62+
it("creates parent directories as needed", () => {
63+
writeFile(workspace, "a/b/c.txt", "nested");
64+
expect(fs.readFileSync(path.join(workspace, "a", "b", "c.txt"), "utf-8")).toBe("nested");
65+
});
66+
67+
it("overwrites an existing file", () => {
68+
writeFile(workspace, "x.txt", "first");
69+
writeFile(workspace, "x.txt", "second");
70+
expect(fs.readFileSync(path.join(workspace, "x.txt"), "utf-8")).toBe("second");
71+
});
72+
});
73+
74+
describe("listDir", () => {
75+
it("lists files and marks directories with a trailing slash", () => {
76+
fs.writeFileSync(path.join(workspace, "file.txt"), "");
77+
fs.mkdirSync(path.join(workspace, "subdir"));
78+
const entries = listDir(workspace, ".");
79+
expect(entries).toContain("file.txt");
80+
expect(entries).toContain("subdir/");
81+
});
82+
});
83+
84+
describe("searchFiles", () => {
85+
it("finds matching lines with file and line number", () => {
86+
fs.writeFileSync(path.join(workspace, "code.txt"), "line one\nfindme here\nline three");
87+
const results = searchFiles(workspace, "findme");
88+
expect(results).toEqual([{ file: "code.txt", line: 2, text: "findme here" }]);
89+
});
90+
91+
it("skips ignored directories like node_modules", () => {
92+
fs.mkdirSync(path.join(workspace, "node_modules"));
93+
fs.writeFileSync(path.join(workspace, "node_modules", "lib.js"), "findme");
94+
const results = searchFiles(workspace, "findme");
95+
expect(results).toEqual([]);
96+
});
97+
});
98+
99+
describe("executeTool", () => {
100+
it("dispatches to the right tool by name", async () => {
101+
writeFile(workspace, "y.txt", "z");
102+
expect(await executeTool(workspace, "read_file", { path: "y.txt" })).toBe("z");
103+
});
104+
105+
it("throws for an unknown tool name", async () => {
106+
await expect(executeTool(workspace, "delete_everything", {})).rejects.toThrow(/Unknown tool/);
107+
});
108+
});
109+
110+
describe("run_command", () => {
111+
it("captures stdout and a zero exit code from a successful command", async () => {
112+
const output = await executeTool(workspace, "run_command", { command: "echo hello" });
113+
expect(output).toContain("Exit code: 0");
114+
expect(output).toContain("hello");
115+
});
116+
117+
it("captures a non-zero exit code", async () => {
118+
const output = await executeTool(workspace, "run_command", { command: "exit 3" });
119+
expect(output).toContain("Exit code: 3");
120+
});
121+
122+
it("runs in the specified cwd within the workspace", async () => {
123+
fs.mkdirSync(path.join(workspace, "sub"));
124+
fs.writeFileSync(path.join(workspace, "sub", "marker.txt"), "");
125+
const output = await executeTool(workspace, "run_command", { command: "ls", cwd: "sub" });
126+
expect(output).toContain("marker.txt");
127+
});
128+
129+
it("rejects a cwd that escapes the workspace", async () => {
130+
await expect(runCommand(workspace, "echo hi", "../../etc")).rejects.toThrow(/outside the workspace/);
131+
});
132+
});
133+
});

app/src/agent-tools.ts

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
import * as fs from "node:fs";
2+
import * as path from "node:path";
3+
import { exec } from "node:child_process";
4+
import { promisify } from "node:util";
5+
import type { ToolDefinition } from "./providers/types";
6+
7+
const execAsync = promisify(exec);
8+
9+
export const AGENT_TOOLS: ToolDefinition[] = [
10+
{
11+
name: "read_file",
12+
description: "Read the contents of a text file within the workspace.",
13+
parameters: {
14+
type: "object",
15+
properties: {
16+
path: { type: "string", description: "File path, relative to the workspace root." },
17+
},
18+
required: ["path"],
19+
},
20+
},
21+
{
22+
name: "write_file",
23+
description: "Create a file or overwrite it with the given content. Creates parent directories as needed.",
24+
parameters: {
25+
type: "object",
26+
properties: {
27+
path: { type: "string", description: "File path, relative to the workspace root." },
28+
content: { type: "string", description: "The full content to write to the file." },
29+
},
30+
required: ["path", "content"],
31+
},
32+
},
33+
{
34+
name: "list_dir",
35+
description: "List files and subdirectories at a path within the workspace.",
36+
parameters: {
37+
type: "object",
38+
properties: {
39+
path: { type: "string", description: 'Directory path, relative to the workspace root. Use "." for the root.' },
40+
},
41+
required: [],
42+
},
43+
},
44+
{
45+
name: "search_files",
46+
description: "Search for a text string across files in the workspace and return matching lines.",
47+
parameters: {
48+
type: "object",
49+
properties: {
50+
query: { type: "string", description: "The text to search for (plain substring match, case-sensitive)." },
51+
path: { type: "string", description: 'Subdirectory to scope the search to, relative to the workspace root. Defaults to "."' },
52+
},
53+
required: ["query"],
54+
},
55+
},
56+
{
57+
name: "run_command",
58+
description:
59+
"Execute a shell command in the workspace (or a subdirectory of it) and return its stdout/stderr/exit code. Use for builds, tests, git, npm, etc.",
60+
parameters: {
61+
type: "object",
62+
properties: {
63+
command: { type: "string", description: "The shell command to run." },
64+
cwd: { type: "string", description: 'Working directory for the command, relative to the workspace root. Defaults to "."' },
65+
},
66+
required: ["command"],
67+
},
68+
},
69+
];
70+
71+
const MAX_READ_CHARS = 100_000;
72+
const MAX_SEARCH_RESULTS = 50;
73+
const MAX_LIST_ENTRIES = 500;
74+
const MAX_COMMAND_OUTPUT_CHARS = 50_000;
75+
const COMMAND_TIMEOUT_MS = 60_000;
76+
const IGNORED_DIRS = new Set(["node_modules", ".git", "dist", "build", "out", "release", "__pycache__"]);
77+
78+
// Every tool call is confined to the chosen workspace directory — this
79+
// resolves the (possibly relative, possibly attacker-crafted via a prompt
80+
// injection in file content the model read) path and throws if it would
81+
// escape that directory via ../ or an absolute path elsewhere on disk.
82+
function resolveSafePath(workspaceRoot: string, relativePath: string): string {
83+
const root = path.resolve(workspaceRoot);
84+
const resolved = path.resolve(root, relativePath || ".");
85+
if (resolved !== root && !resolved.startsWith(root + path.sep)) {
86+
throw new Error(`Path "${relativePath}" is outside the workspace directory.`);
87+
}
88+
return resolved;
89+
}
90+
91+
export function readFile(workspaceRoot: string, relativePath: string): string {
92+
const target = resolveSafePath(workspaceRoot, relativePath);
93+
const stat = fs.statSync(target);
94+
if (stat.isDirectory()) throw new Error(`"${relativePath}" is a directory, not a file.`);
95+
const content = fs.readFileSync(target, "utf-8");
96+
return content.length > MAX_READ_CHARS
97+
? `${content.slice(0, MAX_READ_CHARS)}\n\n[truncated — file is ${content.length} characters]`
98+
: content;
99+
}
100+
101+
export function writeFile(workspaceRoot: string, relativePath: string, content: string): { bytesWritten: number } {
102+
const target = resolveSafePath(workspaceRoot, relativePath);
103+
fs.mkdirSync(path.dirname(target), { recursive: true });
104+
fs.writeFileSync(target, content);
105+
return { bytesWritten: Buffer.byteLength(content) };
106+
}
107+
108+
export function listDir(workspaceRoot: string, relativePath: string): string[] {
109+
const target = resolveSafePath(workspaceRoot, relativePath || ".");
110+
const entries = fs.readdirSync(target, { withFileTypes: true });
111+
return entries.slice(0, MAX_LIST_ENTRIES).map((e) => (e.isDirectory() ? `${e.name}/` : e.name));
112+
}
113+
114+
export interface SearchMatch {
115+
file: string;
116+
line: number;
117+
text: string;
118+
}
119+
120+
export function searchFiles(workspaceRoot: string, query: string, relativePath = "."): SearchMatch[] {
121+
const startDir = resolveSafePath(workspaceRoot, relativePath);
122+
const results: SearchMatch[] = [];
123+
124+
function walk(dir: string): void {
125+
if (results.length >= MAX_SEARCH_RESULTS) return;
126+
let entries: fs.Dirent[];
127+
try {
128+
entries = fs.readdirSync(dir, { withFileTypes: true });
129+
} catch {
130+
return;
131+
}
132+
for (const entry of entries) {
133+
if (results.length >= MAX_SEARCH_RESULTS) return;
134+
if (entry.name.startsWith(".") || IGNORED_DIRS.has(entry.name)) continue;
135+
const full = path.join(dir, entry.name);
136+
if (entry.isDirectory()) {
137+
walk(full);
138+
continue;
139+
}
140+
if (!entry.isFile()) continue;
141+
let text: string;
142+
try {
143+
text = fs.readFileSync(full, "utf-8");
144+
} catch {
145+
continue; // binary or unreadable — skip
146+
}
147+
const lines = text.split("\n");
148+
for (let i = 0; i < lines.length && results.length < MAX_SEARCH_RESULTS; i++) {
149+
if (lines[i].includes(query)) {
150+
results.push({
151+
file: path.relative(workspaceRoot, full).split(path.sep).join("/"),
152+
line: i + 1,
153+
text: lines[i].trim().slice(0, 200),
154+
});
155+
}
156+
}
157+
}
158+
}
159+
160+
walk(startDir);
161+
return results;
162+
}
163+
164+
function truncateOutput(text: string): string {
165+
return text.length > MAX_COMMAND_OUTPUT_CHARS
166+
? `${text.slice(0, MAX_COMMAND_OUTPUT_CHARS)}\n[truncated]`
167+
: text;
168+
}
169+
170+
function formatCommandResult(stdout: string, stderr: string, exitCode: number | null): string {
171+
const parts = [`Exit code: ${exitCode}`];
172+
if (stdout) parts.push(`--- stdout ---\n${truncateOutput(stdout)}`);
173+
if (stderr) parts.push(`--- stderr ---\n${truncateOutput(stderr)}`);
174+
return parts.join("\n\n");
175+
}
176+
177+
export async function runCommand(workspaceRoot: string, command: string, relativeCwd = "."): Promise<string> {
178+
const cwd = resolveSafePath(workspaceRoot, relativeCwd);
179+
try {
180+
const { stdout, stderr } = await execAsync(command, {
181+
cwd,
182+
timeout: COMMAND_TIMEOUT_MS,
183+
maxBuffer: 10 * 1024 * 1024,
184+
});
185+
return formatCommandResult(stdout, stderr, 0);
186+
} catch (err) {
187+
const e = err as { stdout?: string; stderr?: string; code?: number; killed?: boolean; message: string };
188+
if (e.killed) {
189+
return `Command timed out after ${COMMAND_TIMEOUT_MS / 1000}s.\n\n${formatCommandResult(e.stdout ?? "", e.stderr ?? "", e.code ?? null)}`;
190+
}
191+
return formatCommandResult(e.stdout ?? "", e.stderr ?? e.message, e.code ?? null);
192+
}
193+
}
194+
195+
export async function executeTool(workspaceRoot: string, name: string, args: Record<string, unknown>): Promise<unknown> {
196+
switch (name) {
197+
case "read_file":
198+
return readFile(workspaceRoot, String(args.path ?? ""));
199+
case "write_file":
200+
return writeFile(workspaceRoot, String(args.path ?? ""), String(args.content ?? ""));
201+
case "list_dir":
202+
return listDir(workspaceRoot, String(args.path ?? "."));
203+
case "search_files":
204+
return searchFiles(workspaceRoot, String(args.query ?? ""), args.path ? String(args.path) : ".");
205+
case "run_command":
206+
return runCommand(workspaceRoot, String(args.command ?? ""), args.cwd ? String(args.cwd) : ".");
207+
default:
208+
throw new Error(`Unknown tool: ${name}`);
209+
}
210+
}

0 commit comments

Comments
 (0)