subrouter-cli 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CLAUDE.md +51 -0
  2. package/README.md +397 -2
  3. package/config.example.json +9 -0
  4. package/package.json +21 -4
  5. package/scripts/sse-harness.ts +252 -0
  6. package/scripts/sub-wrapper.sh +11 -0
  7. package/src/adapters/anthropic.ts +311 -0
  8. package/src/adapters/openai.ts +227 -0
  9. package/src/approval.ts +21 -0
  10. package/src/chatviewport.ts +123 -0
  11. package/src/client.ts +398 -0
  12. package/src/clipboard.ts +62 -0
  13. package/src/commandpolicy.ts +272 -0
  14. package/src/commands.ts +18 -0
  15. package/src/config.ts +107 -0
  16. package/src/effort.ts +18 -0
  17. package/src/images.ts +77 -0
  18. package/src/index.ts +248 -0
  19. package/src/lineinput.ts +726 -0
  20. package/src/loop.ts +201 -0
  21. package/src/markdown.ts +244 -0
  22. package/src/repl.ts +700 -0
  23. package/src/sessions.ts +58 -0
  24. package/src/sse.ts +64 -0
  25. package/src/terminal.ts +228 -0
  26. package/src/token.ts +36 -0
  27. package/src/toolpreview.ts +54 -0
  28. package/src/tools.ts +790 -0
  29. package/src/types.ts +73 -0
  30. package/src/ui.ts +813 -0
  31. package/src/usage.ts +186 -0
  32. package/test/absolute-tools.test.ts +296 -0
  33. package/test/absolute-ui.test.ts +153 -0
  34. package/test/adapters.test.ts +205 -0
  35. package/test/anthropic.test.ts +246 -0
  36. package/test/auto-ui.test.ts +106 -0
  37. package/test/chatviewport.test.ts +49 -0
  38. package/test/client.test.ts +327 -0
  39. package/test/clipboard.test.ts +63 -0
  40. package/test/command-input.test.ts +183 -0
  41. package/test/command-tools.test.ts +141 -0
  42. package/test/commandpolicy.test.ts +252 -0
  43. package/test/disk-tools.test.ts +220 -0
  44. package/test/effort.test.ts +123 -0
  45. package/test/fixtures/openai-tools.sse +84 -0
  46. package/test/image-adapters.test.ts +78 -0
  47. package/test/image-ui.test.ts +151 -0
  48. package/test/images.test.ts +60 -0
  49. package/test/loop.test.ts +387 -0
  50. package/test/m5.test.ts +127 -0
  51. package/test/markdown.test.ts +201 -0
  52. package/test/repl-ui.test.ts +293 -0
  53. package/test/sse.test.ts +74 -0
  54. package/test/steering-ui.test.ts +182 -0
  55. package/test/steering.test.ts +68 -0
  56. package/test/terminal-ui.test.ts +144 -0
  57. package/test/terminal.test.ts +229 -0
  58. package/test/toolpreview.test.ts +51 -0
  59. package/test/tools.test.ts +227 -0
  60. package/test/ui.test.ts +635 -0
  61. package/test/usage-footer.test.ts +180 -0
  62. package/tsconfig.json +17 -0
package/src/tools.ts ADDED
@@ -0,0 +1,790 @@
1
+ // tools.ts — filesystem tools, path confinement, and separately approved commands.
2
+ // File operations stay inside cwd unless the host grants live absolute access.
3
+ // Commands are NOT sandboxed and require a separate approval snapshot.
4
+
5
+ import fs from 'node:fs/promises';
6
+ import path from 'node:path';
7
+ import os from 'node:os';
8
+ import { AsyncLocalStorage } from 'node:async_hooks';
9
+ import { randomUUID } from 'node:crypto';
10
+ import { constants } from 'node:fs';
11
+ import { commandPolicy } from './commandpolicy.ts';
12
+ import type { ToolCall, ToolDefinition } from './types.ts';
13
+ import { executeCommand, validateCommand, sanitizeCommandOutput, DEFAULT_COMMAND_TIMEOUT_MS, MAX_COMMAND_TIMEOUT_MS } from './terminal.ts';
14
+
15
+ export const MAX_READ_BYTES = 256 * 1024;
16
+ export const MAX_WRITE_BYTES = 1024 * 1024;
17
+ export const MAX_LIST_ENTRIES = 500;
18
+ export const MAX_GREP_RESULTS = 200;
19
+ export const MAX_GREP_FILES = 5000;
20
+ export const MAX_BATCH_FILES = 20;
21
+ export const MAX_TREE_ENTRIES = 5000;
22
+ export const MAX_TREE_BYTES = 100 * 1024 * 1024;
23
+ export const RECOVERY_DIR = '.sub-recovery';
24
+
25
+ const SKIP_DIRS = new Set(['.git', RECOVERY_DIR, 'node_modules', '.venv', 'venv', '__pycache__', '.next', 'dist', 'build']);
26
+ const SKIP_FILES = new Set(['.DS_Store']);
27
+ const FILE_PATH_DESCRIPTION = 'path relative to the working directory; in absolute mode also accepts absolute, ~/ and parent paths';
28
+
29
+ export class ConfinementError extends Error {}
30
+
31
+ /**
32
+ * Confine a tool-supplied path to the working directory. Two checks, like the Swift
33
+ * FileToolbox: (1) the raw path must be relative (no absolute, ~, or drive-letter
34
+ * forms); (2) after resolution, both the lexical path and the realpath of its deepest
35
+ * existing ancestor must live under realpath(cwd), so symlinks can't escape.
36
+ * Returns the resolved absolute path to operate on.
37
+ */
38
+ export async function confined(cwd: string, raw: unknown): Promise<string> {
39
+ if (typeof raw !== 'string' || raw.trim() === '') {
40
+ throw new ConfinementError('path must be a non-empty string');
41
+ }
42
+ if (raw.startsWith('~') || raw.startsWith('/') || /^[A-Za-z]:[\\/]/.test(raw)) {
43
+ throw new ConfinementError(`path must be relative to the working directory, got: ${raw}`);
44
+ }
45
+ if (raw.includes('\0')) throw new ConfinementError('path contains a null byte');
46
+ if (raw.split(/[\\/]/).includes(RECOVERY_DIR)) {
47
+ throw new ConfinementError('recovery storage is private — use list_removed / restore_file');
48
+ }
49
+ // Anchor everything at the real cwd — on macOS tmpdir/realpath differ (/var vs /private/var).
50
+ const realCwd = await fs.realpath(cwd);
51
+ const resolved = path.resolve(realCwd, raw);
52
+ if (resolved !== realCwd && !resolved.startsWith(realCwd + path.sep)) {
53
+ throw new ConfinementError(`path escapes the working directory: ${raw}`);
54
+ }
55
+ // Symlink escape guard: realpath the deepest existing ancestor and re-check.
56
+ let probe = resolved;
57
+ try {
58
+ probe = await fs.realpath(resolved);
59
+ } catch {
60
+ let dir = path.dirname(resolved);
61
+ while (true) {
62
+ try {
63
+ const realDir = await fs.realpath(dir);
64
+ const rest = resolved.slice(dir.length).split(path.sep).filter(Boolean);
65
+ probe = path.join(realDir, ...rest);
66
+ break;
67
+ } catch {
68
+ const parent = path.dirname(dir);
69
+ if (parent === dir) {
70
+ probe = resolved;
71
+ break;
72
+ }
73
+ dir = parent;
74
+ }
75
+ }
76
+ }
77
+ if (probe !== realCwd && !probe.startsWith(realCwd + path.sep)) {
78
+ throw new ConfinementError(`path resolves outside the working directory (symlink): ${raw}`);
79
+ }
80
+ if ([resolved, probe].some((p) => path.relative(realCwd, p).split(path.sep).includes(RECOVERY_DIR))) {
81
+ throw new ConfinementError('recovery storage is private — use list_removed / restore_file');
82
+ }
83
+ // A dangling symlink has no realpath; never let creation follow it outside cwd.
84
+ let ancestor = resolved;
85
+ while (ancestor !== realCwd) {
86
+ const stat = await fs.lstat(ancestor).catch(() => null);
87
+ if (stat?.isSymbolicLink()) {
88
+ await fs.realpath(ancestor).catch(() => {
89
+ throw new ConfinementError('dangling symlinks are not supported');
90
+ });
91
+ }
92
+ ancestor = path.dirname(ancestor);
93
+ }
94
+ return resolved;
95
+ }
96
+
97
+ // A host-only capability scoped to one execution, never model-supplied arguments.
98
+ const absoluteAccess = new AsyncLocalStorage<boolean>();
99
+
100
+ async function toolPath(cwd: string, raw: unknown, unrestricted = absoluteAccess.getStore() === true): Promise<string> {
101
+ if (!unrestricted) return confined(cwd, raw);
102
+ if (typeof raw !== 'string' || raw.trim() === '') throw new ConfinementError('path must be a non-empty string');
103
+ if (raw.includes('\0')) throw new ConfinementError('path contains a null byte');
104
+ const expanded = raw === '~' ? os.homedir() : raw.startsWith('~/') ? path.join(os.homedir(), raw.slice(2)) : raw;
105
+ return path.resolve(await fs.realpath(cwd), expanded);
106
+ }
107
+
108
+ export const TOOL_DEFINITIONS: ToolDefinition[] = [
109
+ {
110
+ name: 'run_command',
111
+ description: 'Run a noninteractive terminal command after explicit approval, guarded-auto authorization for recognized project tasks, or acknowledged absolute-mode authorization for arbitrary commands. Risky or unknown commands ask outside absolute mode; file approve-all does not authorize commands. Starts in the project by default; absolute mode permits an external cwd. NOT sandboxed: commands can access the wider machine and network. Use for project tests/builds; avoid destructive commands and prefer recoverable file tools. No persistent background jobs or interactive input. Output bounded at 64 KiB; default timeout 60s, maximum 5 minutes.',
112
+ parameters: {
113
+ type: 'object',
114
+ properties: {
115
+ command: { type: 'string', description: 'exact shell command; /bin/zsh -f -c, up to 16 KiB' },
116
+ cwd: { type: 'string', description: 'existing project directory (default .); in absolute mode also accepts absolute, ~/ and parent paths' },
117
+ timeout_ms: { type: 'integer', minimum: 1, maximum: MAX_COMMAND_TIMEOUT_MS, default: DEFAULT_COMMAND_TIMEOUT_MS },
118
+ },
119
+ required: ['command'],
120
+ },
121
+ requiresApproval: true,
122
+ approvalScope: 'always',
123
+ },
124
+ {
125
+ name: 'read_file',
126
+ description:
127
+ 'Read a text file from the working directory, or across the drive in absolute mode. Refuses files larger than 256 KB — use grep to search large files instead.',
128
+ parameters: {
129
+ type: 'object',
130
+ properties: { path: { type: 'string', description: FILE_PATH_DESCRIPTION } },
131
+ required: ['path'],
132
+ },
133
+ requiresApproval: false,
134
+ },
135
+ {
136
+ name: 'write_file',
137
+ description:
138
+ 'Write a text file (creating parent directories as needed), overwriting it if it exists. Refuses files larger than 1 MB.',
139
+ parameters: {
140
+ type: 'object',
141
+ properties: {
142
+ path: { type: 'string', description: FILE_PATH_DESCRIPTION },
143
+ content: { type: 'string', description: 'full new file contents' },
144
+ },
145
+ required: ['path', 'content'],
146
+ },
147
+ requiresApproval: true,
148
+ },
149
+ {
150
+ name: 'edit_file',
151
+ description:
152
+ 'Replace one exact string occurrence in a file. old_string must match exactly once, including whitespace — use read_file first to get the exact text.',
153
+ parameters: {
154
+ type: 'object',
155
+ properties: {
156
+ path: { type: 'string', description: FILE_PATH_DESCRIPTION },
157
+ old_string: { type: 'string', description: 'exact text to replace, unique in the file' },
158
+ new_string: { type: 'string', description: 'replacement text' },
159
+ },
160
+ required: ['path', 'old_string', 'new_string'],
161
+ },
162
+ requiresApproval: true,
163
+ },
164
+ {
165
+ name: 'list_dir',
166
+ description: 'List the entries of a directory (directories marked with /), up to 500 entries.',
167
+ parameters: {
168
+ type: 'object',
169
+ properties: { path: { type: 'string', description: FILE_PATH_DESCRIPTION } },
170
+ required: ['path'],
171
+ },
172
+ requiresApproval: false,
173
+ },
174
+ {
175
+ name: 'grep',
176
+ description:
177
+ 'Search files under a directory for a literal substring (not a regex). Skips .git, node_modules, and other build dirs. Returns file:line matches, up to 200.',
178
+ parameters: {
179
+ type: 'object',
180
+ properties: {
181
+ pattern: { type: 'string', description: 'literal text to search for' },
182
+ path: { type: 'string', description: `search directory (default .); ${FILE_PATH_DESCRIPTION}` },
183
+ include: { type: 'string', description: 'only files whose path contains this string' },
184
+ },
185
+ required: ['pattern'],
186
+ },
187
+ requiresApproval: false,
188
+ },
189
+ {
190
+ name: 'read_files',
191
+ description: 'Read up to 20 text files together. Returns JSON with content or an error for each path; 256 KB total content budget.',
192
+ parameters: {
193
+ type: 'object',
194
+ properties: { paths: { type: 'array', items: { type: 'string', description: FILE_PATH_DESCRIPTION }, minItems: 1, maxItems: MAX_BATCH_FILES } },
195
+ required: ['paths'],
196
+ },
197
+ requiresApproval: false,
198
+ },
199
+ {
200
+ name: 'file_info',
201
+ description: 'Inspect a file or directory without reading its content: type, size, permissions and modification time. Does not follow a final symlink.',
202
+ parameters: {
203
+ type: 'object', properties: { path: { type: 'string', description: FILE_PATH_DESCRIPTION } }, required: ['path'],
204
+ },
205
+ requiresApproval: false,
206
+ },
207
+ {
208
+ name: 'find_files',
209
+ description: 'Find files and directories recursively, using an optional literal path substring (not glob/regex). Skips symlinks, recovery, .git, dependencies and build directories. Up to 500 results / 5000 entries.',
210
+ parameters: {
211
+ type: 'object',
212
+ properties: {
213
+ path: { type: 'string', description: `search directory (default .); ${FILE_PATH_DESCRIPTION}` },
214
+ pattern: { type: 'string', description: 'literal path substring (default all)' },
215
+ type: { type: 'string', enum: ['file', 'directory', 'all'], description: 'default all' },
216
+ },
217
+ },
218
+ requiresApproval: false,
219
+ },
220
+ {
221
+ name: 'make_dir',
222
+ description: 'Create a directory and missing parent directories. Existing directories are unchanged.',
223
+ parameters: {
224
+ type: 'object', properties: { path: { type: 'string', description: FILE_PATH_DESCRIPTION } }, required: ['path'],
225
+ },
226
+ requiresApproval: true,
227
+ },
228
+ {
229
+ name: 'copy_path',
230
+ description: 'Copy a regular file (including binary assets) or directory tree to a new path. Never overwrites. Refuses symlinks, special files and trees over 5000 entries / 100 MiB. Outside absolute mode also refuses .git and recovery data.',
231
+ parameters: {
232
+ type: 'object', properties: { source: { type: 'string', description: FILE_PATH_DESCRIPTION }, destination: { type: 'string', description: FILE_PATH_DESCRIPTION } }, required: ['source', 'destination'],
233
+ },
234
+ requiresApproval: true,
235
+ },
236
+ {
237
+ name: 'move_path',
238
+ description: 'Move or rename a file/directory to a new path inside the working directory, or across the drive in absolute mode. Never overwrites. Same tree/symlink limits as copy_path.',
239
+ parameters: {
240
+ type: 'object', properties: { source: { type: 'string', description: FILE_PATH_DESCRIPTION }, destination: { type: 'string', description: FILE_PATH_DESCRIPTION } }, required: ['source', 'destination'],
241
+ },
242
+ requiresApproval: true,
243
+ },
244
+ {
245
+ name: 'remove_path',
246
+ description: 'Recoverably remove a file/directory by moving it into private project recovery storage. Returns a recovery ID for restore_file. No permanent deletion. Same tree limits as copy_path; outside absolute mode cannot remove the working directory.',
247
+ parameters: {
248
+ type: 'object', properties: { path: { type: 'string', description: FILE_PATH_DESCRIPTION } }, required: ['path'],
249
+ },
250
+ requiresApproval: true,
251
+ },
252
+ {
253
+ name: 'list_removed',
254
+ description: 'List up to 500 recoverably removed items: recovery ID, original path and removal time. No file content is read.',
255
+ parameters: { type: 'object', properties: {} },
256
+ requiresApproval: false,
257
+ },
258
+ {
259
+ name: 'restore_file',
260
+ description: 'Restore a removed file/directory by recovery ID, to its original path or an optional new destination. Never overwrites.',
261
+ parameters: {
262
+ type: 'object',
263
+ properties: { recovery_id: { type: 'string' }, destination: { type: 'string', description: `optional new destination; ${FILE_PATH_DESCRIPTION}` } },
264
+ required: ['recovery_id'],
265
+ },
266
+ requiresApproval: true,
267
+ },
268
+ ];
269
+
270
+ const toolByName = new Map(TOOL_DEFINITIONS.map((t) => [t.name, t]));
271
+
272
+ export interface ToolResult {
273
+ toolCallId: string;
274
+ content: string;
275
+ isError?: boolean;
276
+ }
277
+
278
+ function argString(args: Record<string, unknown>, key: string): string {
279
+ const v = args[key];
280
+ return typeof v === 'string' ? v : '';
281
+ }
282
+
283
+ function requiredString(args: Record<string, unknown>, key: string, allowEmpty = false): string {
284
+ const value = args[key];
285
+ if (typeof value !== 'string' || (!allowEmpty && !value)) throw new Error(`${key} must be a ${allowEmpty ? '' : 'non-empty '}string`);
286
+ return value;
287
+ }
288
+
289
+ async function readText(abs: string, limit = MAX_READ_BYTES): Promise<Buffer> {
290
+ const stat = await fs.stat(abs);
291
+ if (!stat.isFile()) throw new Error('not a regular file — use list_dir for directories');
292
+ if (stat.size > limit) throw new Error(`file is ${stat.size} bytes (limit ${limit}) — use grep to search it`);
293
+ const file = await fs.open(abs, constants.O_RDONLY | constants.O_NONBLOCK);
294
+ try {
295
+ if (!(await file.stat()).isFile()) throw new Error('not a regular file');
296
+ const buffer = Buffer.alloc(limit + 1);
297
+ let used = 0;
298
+ while (used < buffer.length) {
299
+ const { bytesRead } = await file.read(buffer, used, buffer.length - used, null);
300
+ if (!bytesRead) break;
301
+ used += bytesRead;
302
+ }
303
+ if (used > limit) throw new Error(`file exceeds ${limit} bytes — use grep to search it`);
304
+ const bytes = buffer.subarray(0, used);
305
+ if (bytes.includes(0)) throw new Error('binary file — use file_info or copy_path');
306
+ return bytes;
307
+ } finally {
308
+ await file.close();
309
+ }
310
+ }
311
+
312
+ async function execReadFile(cwd: string, args: Record<string, unknown>): Promise<ToolResult> {
313
+ const abs = await toolPath(cwd, args.path);
314
+ return { toolCallId: '', content: (await readText(abs)).toString('utf8') };
315
+ }
316
+
317
+ async function writeText(abs: string, content: string): Promise<void> {
318
+ const file = await fs.open(abs, constants.O_WRONLY | constants.O_CREAT | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o666);
319
+ try {
320
+ if (!(await file.stat()).isFile()) throw new Error('writes require a regular file');
321
+ await file.truncate(0);
322
+ await file.writeFile(content, 'utf8');
323
+ } finally {
324
+ await file.close();
325
+ }
326
+ }
327
+
328
+ async function execWriteFile(cwd: string, args: Record<string, unknown>): Promise<ToolResult> {
329
+ const abs = await mutablePath(cwd, args.path);
330
+ const content = requiredString(args, 'content', true);
331
+ if (Buffer.byteLength(content) > MAX_WRITE_BYTES) {
332
+ throw new Error(`content is ${Buffer.byteLength(content)} bytes (limit ${MAX_WRITE_BYTES})`);
333
+ }
334
+ await fs.mkdir(path.dirname(abs), { recursive: true });
335
+ await writeText(abs, content);
336
+ return { toolCallId: '', content: `wrote ${Buffer.byteLength(content)} bytes to ${argString(args, 'path')}` };
337
+ }
338
+
339
+ async function execEditFile(cwd: string, args: Record<string, unknown>): Promise<ToolResult> {
340
+ const abs = await mutablePath(cwd, args.path);
341
+ const oldString = requiredString(args, 'old_string');
342
+ const newString = requiredString(args, 'new_string', true);
343
+ const original = (await readText(abs, MAX_WRITE_BYTES)).toString('utf8');
344
+ const first = original.indexOf(oldString);
345
+ if (first === -1) throw new Error('old_string not found in the file — read it first for the exact text');
346
+ if (original.indexOf(oldString, first + oldString.length) !== -1) {
347
+ throw new Error('old_string matches more than once — make it unique');
348
+ }
349
+ const edited = original.slice(0, first) + newString + original.slice(first + oldString.length);
350
+ if (Buffer.byteLength(edited) > MAX_WRITE_BYTES) throw new Error(`edited file exceeds ${MAX_WRITE_BYTES} bytes`);
351
+ await writeText(abs, edited);
352
+ return { toolCallId: '', content: `edited ${argString(args, 'path')} (${original.length} -> ${edited.length} bytes)` };
353
+ }
354
+
355
+ async function execListDir(cwd: string, args: Record<string, unknown>): Promise<ToolResult> {
356
+ const abs = await toolPath(cwd, args.path);
357
+ const entries = await fs.readdir(abs, { withFileTypes: true }).catch(() => {
358
+ throw new Error(`no such directory: ${argString(args, 'path')}`);
359
+ });
360
+ const lines = entries
361
+ .slice(0, MAX_LIST_ENTRIES)
362
+ .map((e) => (e.isDirectory() ? e.name + '/' : e.name))
363
+ .sort();
364
+ let content = lines.join('\n') || '(empty directory)';
365
+ if (entries.length > MAX_LIST_ENTRIES) content += `\n… (${entries.length - MAX_LIST_ENTRIES} more entries)`;
366
+ return { toolCallId: '', content };
367
+ }
368
+
369
+ async function walk(base: string, dir: string, include: string, onFile: (abs: string, rel: string) => Promise<boolean>): Promise<boolean> {
370
+ // Returns true when the walker should stop early (caps reached).
371
+ const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
372
+ for (const e of entries) {
373
+ if (e.isDirectory()) {
374
+ if (SKIP_DIRS.has(e.name)) continue;
375
+ if (await walk(base, path.join(dir, e.name), include, onFile)) return true;
376
+ } else if (e.isFile()) {
377
+ if (SKIP_FILES.has(e.name)) continue;
378
+ const abs = path.join(dir, e.name);
379
+ const rel = path.relative(base, abs);
380
+ if (include && !rel.includes(include)) continue;
381
+ if (await onFile(abs, rel)) return true;
382
+ }
383
+ }
384
+ return false;
385
+ }
386
+
387
+ async function execGrep(cwd: string, args: Record<string, unknown>): Promise<ToolResult> {
388
+ const pattern = argString(args, 'pattern');
389
+ if (!pattern) throw new Error('grep needs a non-empty pattern');
390
+ const include = argString(args, 'include');
391
+ const startAbs = await toolPath(cwd, typeof args.path === 'string' && args.path ? args.path : '.');
392
+ const stat = await fs.stat(startAbs).catch(() => null);
393
+ if (!stat) throw new Error(`no such path: ${argString(args, 'path') || '.'}`);
394
+ if (!stat.isDirectory()) throw new Error(`${argString(args, 'path')} is not a directory — grep searches directories`);
395
+
396
+ const results: string[] = [];
397
+ let filesSeen = 0;
398
+ let capped = false;
399
+ await walk(startAbs, startAbs, include, async (abs, rel) => {
400
+ filesSeen++;
401
+ if (filesSeen > MAX_GREP_FILES) {
402
+ capped = true;
403
+ return true;
404
+ }
405
+ let text: string;
406
+ try {
407
+ text = (await readText(abs)).toString('utf8');
408
+ } catch {
409
+ return false;
410
+ }
411
+ const lines = text.split('\n');
412
+ for (let i = 0; i < lines.length; i++) {
413
+ if (lines[i].includes(pattern)) {
414
+ results.push(`${rel}:${i + 1}:${lines[i].trim()}`);
415
+ if (results.length >= MAX_GREP_RESULTS) {
416
+ capped = true;
417
+ return true;
418
+ }
419
+ }
420
+ }
421
+ return false;
422
+ });
423
+ let content = results.join('\n') || '(no matches)';
424
+ if (capped) content += `\n… (results capped at ${MAX_GREP_RESULTS}, files at ${MAX_GREP_FILES})`;
425
+ return { toolCallId: '', content };
426
+ }
427
+
428
+ async function execReadFiles(cwd: string, args: Record<string, unknown>): Promise<ToolResult> {
429
+ if (!Array.isArray(args.paths) || !args.paths.length || args.paths.length > MAX_BATCH_FILES || args.paths.some((p) => typeof p !== 'string')) {
430
+ throw new Error(`paths must contain 1–${MAX_BATCH_FILES} strings`);
431
+ }
432
+ let remaining = MAX_READ_BYTES;
433
+ const results: { path: string; content?: string; error?: string }[] = [];
434
+ for (const raw of args.paths as string[]) {
435
+ try {
436
+ const bytes = await readText(await toolPath(cwd, raw), remaining);
437
+ remaining -= bytes.length;
438
+ results.push({ path: raw, content: bytes.toString('utf8') });
439
+ } catch (err) {
440
+ results.push({ path: raw, error: err instanceof Error ? err.message : String(err) });
441
+ }
442
+ }
443
+ return { toolCallId: '', content: JSON.stringify(results) };
444
+ }
445
+
446
+ async function execFileInfo(cwd: string, args: Record<string, unknown>): Promise<ToolResult> {
447
+ const abs = await toolPath(cwd, args.path);
448
+ const stat = await fs.lstat(abs);
449
+ return { toolCallId: '', content: JSON.stringify({
450
+ path: args.path,
451
+ type: stat.isSymbolicLink() ? 'symlink' : stat.isDirectory() ? 'directory' : stat.isFile() ? 'file' : 'special',
452
+ size_bytes: stat.size,
453
+ permissions: (stat.mode & 0o777).toString(8).padStart(3, '0'),
454
+ modified_at: stat.mtime.toISOString(),
455
+ }) };
456
+ }
457
+
458
+ async function execFindFiles(cwd: string, args: Record<string, unknown>): Promise<ToolResult> {
459
+ const start = await toolPath(cwd, args.path ?? '.');
460
+ if (!(await fs.stat(start)).isDirectory()) throw new Error('find_files needs a directory');
461
+ const pattern = args.pattern === undefined ? '' : requiredString(args, 'pattern', true);
462
+ const type = args.type ?? 'all';
463
+ if (!['file', 'directory', 'all'].includes(type as string)) throw new Error('type must be file, directory, or all');
464
+ const results: string[] = [];
465
+ const stack = [start];
466
+ let seen = 0, capped = false;
467
+ while (stack.length && !capped) {
468
+ const dir = await fs.opendir(stack.pop()!);
469
+ for await (const entry of dir) {
470
+ if (++seen > MAX_TREE_ENTRIES) { capped = true; break; }
471
+ if (SKIP_DIRS.has(entry.name) || SKIP_FILES.has(entry.name) || entry.isSymbolicLink()) continue;
472
+ const abs = path.join(dir.path, entry.name);
473
+ const rel = path.relative(start, abs);
474
+ if (entry.isDirectory()) stack.push(abs);
475
+ if ((entry.isFile() || entry.isDirectory()) && rel.includes(pattern) &&
476
+ (type === 'all' || (type === 'file' ? entry.isFile() : entry.isDirectory()))) {
477
+ results.push(rel + (entry.isDirectory() ? '/' : ''));
478
+ if (results.length >= MAX_LIST_ENTRIES) { capped = true; break; }
479
+ }
480
+ }
481
+ }
482
+ let content = results.sort().join('\n') || '(no matches)';
483
+ if (capped) content += `\n… (capped at ${MAX_LIST_ENTRIES} results / ${MAX_TREE_ENTRIES} entries)`;
484
+ return { toolCallId: '', content };
485
+ }
486
+
487
+ async function mutablePath(cwd: string, raw: unknown): Promise<string> {
488
+ const abs = await toolPath(cwd, raw);
489
+ if (absoluteAccess.getStore() === true) return abs;
490
+ const root = await fs.realpath(cwd);
491
+ if (abs === root) throw new Error('cannot mutate the working directory itself');
492
+ let probe = abs;
493
+ while (probe !== root) {
494
+ if (path.basename(probe) === '.git') throw new Error('cannot mutate .git internals');
495
+ const stat = await fs.lstat(probe).catch((err: NodeJS.ErrnoException) => {
496
+ if (err.code === 'ENOENT') return null;
497
+ throw err;
498
+ });
499
+ if (stat?.isSymbolicLink()) throw new Error('filesystem mutations do not support symlink paths');
500
+ probe = path.dirname(probe);
501
+ }
502
+ return abs;
503
+ }
504
+
505
+ async function requireMissing(abs: string): Promise<void> {
506
+ const exists = await fs.lstat(abs).catch((err: NodeJS.ErrnoException) => {
507
+ if (err.code === 'ENOENT') return null;
508
+ throw err;
509
+ });
510
+ if (exists) throw new Error('destination already exists — choose a new path; overwriting is not supported');
511
+ }
512
+
513
+ interface TreeEntry { abs: string; rel: string; directory: boolean; mode: number; }
514
+
515
+ async function inspectTree(abs: string): Promise<TreeEntry[]> {
516
+ const entries: TreeEntry[] = [];
517
+ let bytes = 0;
518
+ const stack = [{ abs, rel: '' }];
519
+ while (stack.length) {
520
+ const item = stack.pop()!;
521
+ if (entries.length >= MAX_TREE_ENTRIES) throw new Error(`tree exceeds ${MAX_TREE_ENTRIES} entries`);
522
+ if (absoluteAccess.getStore() !== true && item.rel.split(path.sep).some((name) => name === '.git' || name === RECOVERY_DIR)) {
523
+ throw new Error('tree contains protected .git or recovery storage');
524
+ }
525
+ const stat = await fs.lstat(item.abs);
526
+ if (!stat.isDirectory() && !stat.isFile()) throw new Error('tree contains a symlink or special file — operation refused');
527
+ bytes += stat.isFile() ? stat.size : 0;
528
+ if (bytes > MAX_TREE_BYTES) throw new Error('tree exceeds the 100 MiB limit');
529
+ entries.push({ ...item, directory: stat.isDirectory(), mode: stat.mode & 0o777 });
530
+ if (stat.isDirectory()) {
531
+ const dir = await fs.opendir(item.abs);
532
+ for await (const entry of dir) {
533
+ if (entries.length + stack.length >= MAX_TREE_ENTRIES) throw new Error(`tree exceeds ${MAX_TREE_ENTRIES} entries`);
534
+ stack.push({ abs: path.join(item.abs, entry.name), rel: path.join(item.rel, entry.name) });
535
+ }
536
+ }
537
+ }
538
+ return entries;
539
+ }
540
+
541
+ async function execMakeDir(cwd: string, args: Record<string, unknown>): Promise<ToolResult> {
542
+ const abs = await mutablePath(cwd, args.path);
543
+ await fs.mkdir(abs, { recursive: true });
544
+ return { toolCallId: '', content: `directory ready: ${args.path}` };
545
+ }
546
+
547
+ async function execTransfer(cwd: string, args: Record<string, unknown>, move: boolean): Promise<ToolResult> {
548
+ const source = await mutablePath(cwd, args.source);
549
+ const destination = await mutablePath(cwd, args.destination);
550
+ if (destination === source || destination.startsWith(source + path.sep)) throw new Error('destination must be outside the source tree');
551
+ await requireMissing(destination);
552
+ const entries = await inspectTree(source);
553
+ await fs.mkdir(path.dirname(destination), { recursive: true });
554
+ if (move) {
555
+ await fs.rename(source, destination);
556
+ } else {
557
+ let created = false;
558
+ try {
559
+ for (const entry of entries) {
560
+ const dest = path.join(destination, entry.rel);
561
+ // Recheck before each copy; never deliberately follow links from the source tree.
562
+ await mutablePath(cwd, path.relative(await fs.realpath(cwd), entry.abs));
563
+ const stat = await fs.lstat(entry.abs);
564
+ if (entry.directory ? !stat.isDirectory() : !stat.isFile()) throw new Error('tree contains a symlink or special file — operation refused');
565
+ if (entry.directory) await fs.mkdir(dest);
566
+ else await fs.copyFile(entry.abs, dest, constants.COPYFILE_EXCL);
567
+ if (!entry.rel) created = true;
568
+ if (!entry.directory) await fs.chmod(dest, entry.mode);
569
+ }
570
+ } catch (err) {
571
+ if (created) await fs.rm(destination, { recursive: true, force: true });
572
+ throw err;
573
+ }
574
+ }
575
+ return { toolCallId: '', content: `${move ? 'moved' : 'copied'} ${args.source} → ${args.destination} (${entries.length} entries)` };
576
+ }
577
+
578
+ interface RecoveryRecord { path: string; removed_at: string; }
579
+ const RECOVERY_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
580
+
581
+ async function recoveryRoot(cwd: string, create = false): Promise<string> {
582
+ const root = path.join(await fs.realpath(cwd), RECOVERY_DIR);
583
+ if (create) await fs.mkdir(root, { mode: 0o700 }).catch((err: NodeJS.ErrnoException) => {
584
+ if (err.code !== 'EEXIST') throw err;
585
+ });
586
+ const stat = await fs.lstat(root);
587
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('recovery storage must be a real directory');
588
+ if (stat.mode & 0o077) throw new Error('recovery storage must be private (directory mode 0700)');
589
+ if (create) {
590
+ await fs.writeFile(path.join(root, '.gitignore'), '*\n', { flag: 'wx', mode: 0o600 }).catch((err: NodeJS.ErrnoException) => {
591
+ if (err.code !== 'EEXIST') throw err;
592
+ });
593
+ }
594
+ return root;
595
+ }
596
+
597
+ async function recoveryEntry(root: string, id: unknown): Promise<{ entry: string; record: RecoveryRecord }> {
598
+ if (typeof id !== 'string' || !RECOVERY_ID.test(id)) throw new Error('invalid recovery_id — use list_removed');
599
+ const entry = path.join(root, id);
600
+ if (!(await fs.lstat(entry)).isDirectory()) throw new Error('invalid recovery entry');
601
+ const file = await fs.open(path.join(entry, 'record.json'), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
602
+ let record: RecoveryRecord;
603
+ try {
604
+ const stat = await file.stat();
605
+ if (!stat.isFile() || stat.size > 4096) throw new Error('invalid recovery record');
606
+ const buffer = Buffer.alloc(4097);
607
+ const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
608
+ if (bytesRead > 4096) throw new Error('invalid recovery record');
609
+ const parsed: unknown = JSON.parse(buffer.subarray(0, bytesRead).toString('utf8'));
610
+ if (!parsed || typeof parsed !== 'object' || !('path' in parsed) || typeof parsed.path !== 'string' ||
611
+ !('removed_at' in parsed) || typeof parsed.removed_at !== 'string') throw new Error('invalid recovery record');
612
+ record = { path: parsed.path, removed_at: parsed.removed_at };
613
+ } finally {
614
+ await file.close();
615
+ }
616
+ await fs.lstat(path.join(entry, 'item'));
617
+ return { entry, record };
618
+ }
619
+
620
+ async function execRemovePath(cwd: string, args: Record<string, unknown>): Promise<ToolResult> {
621
+ const abs = await mutablePath(cwd, args.path);
622
+ await inspectTree(abs);
623
+ const root = await recoveryRoot(cwd, true);
624
+ const id = randomUUID();
625
+ const entry = path.join(root, id);
626
+ await fs.mkdir(entry, { mode: 0o700 });
627
+ const record = { path: path.relative(await fs.realpath(cwd), abs), removed_at: new Date().toISOString() };
628
+ try {
629
+ const json = JSON.stringify(record);
630
+ if (Buffer.byteLength(json) > 4096) throw new Error('original path is too long for a recovery record');
631
+ await fs.writeFile(path.join(entry, 'record.json'), json, { flag: 'wx', mode: 0o600 });
632
+ await fs.rename(abs, path.join(entry, 'item'));
633
+ } catch (err) {
634
+ await fs.unlink(path.join(entry, 'record.json')).catch(() => {});
635
+ await fs.rmdir(entry).catch(() => {});
636
+ throw err;
637
+ }
638
+ return { toolCallId: '', content: JSON.stringify({ removed: record.path, recovery_id: id, hint: 'use restore_file to recover; no automatic purge' }) };
639
+ }
640
+
641
+ async function execListRemoved(cwd: string): Promise<ToolResult> {
642
+ const root = await recoveryRoot(cwd).catch((err: NodeJS.ErrnoException) => {
643
+ if (err.code === 'ENOENT') return null;
644
+ throw err;
645
+ });
646
+ if (!root) return { toolCallId: '', content: JSON.stringify({ items: [], capped: false }) };
647
+ const records: { recovery_id: string; path: string; removed_at: string }[] = [];
648
+ const dir = await fs.opendir(root);
649
+ let seen = 0, capped = false;
650
+ for await (const entry of dir) {
651
+ if (++seen > MAX_TREE_ENTRIES || records.length >= MAX_LIST_ENTRIES) { capped = true; break; }
652
+ if (!entry.isDirectory() || !RECOVERY_ID.test(entry.name)) continue;
653
+ try {
654
+ const { record } = await recoveryEntry(root, entry.name);
655
+ records.push({ recovery_id: entry.name, ...record });
656
+ } catch { /* Incomplete or tampered records are not restorable listings. */ }
657
+ }
658
+ return { toolCallId: '', content: JSON.stringify({ items: records, capped }) };
659
+ }
660
+
661
+ async function execRestoreFile(cwd: string, args: Record<string, unknown>): Promise<ToolResult> {
662
+ const root = await recoveryRoot(cwd);
663
+ const { entry, record } = await recoveryEntry(root, args.recovery_id);
664
+ const destination = await mutablePath(cwd, args.destination ?? record.path);
665
+ await requireMissing(destination);
666
+ await inspectTree(path.join(entry, 'item'));
667
+ await fs.mkdir(path.dirname(destination), { recursive: true });
668
+ await fs.rename(path.join(entry, 'item'), destination);
669
+ await fs.unlink(path.join(entry, 'record.json')).catch(() => {});
670
+ await fs.rmdir(entry).catch(() => {});
671
+ return { toolCallId: '', content: `restored ${args.recovery_id} → ${path.relative(await fs.realpath(cwd), destination)}` };
672
+ }
673
+
674
+ export interface PreparedCommand { command: string; cwd: string; timeoutMs: number; }
675
+ export interface CommandApproval extends PreparedCommand { kind?: 'explicit' | 'auto' | 'absolute'; }
676
+
677
+ export async function prepareCommand(cwd: string, args: Record<string, unknown>, redactValues: string[] = [], unrestricted = false): Promise<PreparedCommand> {
678
+ const command = validateCommand(args.command, args.timeout_ms, redactValues);
679
+ const rawCwd = args.cwd;
680
+ if (typeof rawCwd === 'string' && redactValues.some((value) => value && rawCwd.includes(value))) {
681
+ throw new Error('command cwd contains a protected credential');
682
+ }
683
+ const abs = await toolPath(cwd, args.cwd === undefined ? '.' : args.cwd, unrestricted);
684
+ if (!(await fs.stat(abs)).isDirectory()) throw new Error('command cwd must be an existing directory');
685
+ return { ...command, cwd: await fs.realpath(abs) };
686
+ }
687
+
688
+ export interface ToolExecutionOptions {
689
+ signal?: AbortSignal;
690
+ redactValues?: string[];
691
+ commandApproval?: CommandApproval; // exact explicit, guarded-auto or absolute authorization
692
+ autoEnabled?: () => boolean; // checked when queued execution actually starts
693
+ absoluteAccess?: boolean; // host-only file/cwd capability, never a tool argument
694
+ absoluteEnabled?: () => boolean; // required live authorization at queued execution start
695
+ onStart?: () => void;
696
+ }
697
+
698
+ const mutationTails = new Map<string, Promise<void>>();
699
+
700
+ /** Execute a tool. Commands require an approval snapshot; failures become error results. */
701
+ export async function executeTool(call: ToolCall, cwd: string, options: ToolExecutionOptions = {}): Promise<ToolResult> {
702
+ const def = toolByName.get(call.name);
703
+ const result = (): Promise<ToolResult> => absoluteAccess.run(options.absoluteAccess === true, async () => {
704
+ if (!def) throw new Error(`unknown tool: ${call.name}`);
705
+ options.signal?.throwIfAborted(); // also checked when serialized execution actually starts
706
+ if ((options.absoluteAccess === true || options.commandApproval?.kind === 'absolute') && !options.absoluteEnabled?.()) {
707
+ throw new Error('absolute authorization revoked — request approval again');
708
+ }
709
+ if (call.argumentsInvalid) throw new Error('invalid tool arguments');
710
+ switch (call.name) {
711
+ case 'run_command': {
712
+ const approved = options.commandApproval;
713
+ if (!approved) throw new Error('terminal commands require separate explicit approval or guarded-auto authorization');
714
+ const current = await prepareCommand(cwd, call.arguments, options.redactValues, options.absoluteAccess === true);
715
+ if (current.command !== approved.command || current.cwd !== approved.cwd || current.timeoutMs !== approved.timeoutMs) {
716
+ throw new Error('terminal command changed after approval — request approval again');
717
+ }
718
+ if (approved.kind === 'auto') {
719
+ if (!options.autoEnabled?.()) throw new Error('auto authorization revoked — request approval again');
720
+ const decision = await commandPolicy(current.command, current.cwd);
721
+ if (!decision.auto || !options.autoEnabled?.()) throw new Error('auto authorization no longer eligible — request approval again');
722
+ }
723
+ if ((approved.kind === 'absolute' || options.absoluteAccess === true) && !options.absoluteEnabled?.()) {
724
+ throw new Error('absolute authorization revoked — request approval again');
725
+ }
726
+ options.signal?.throwIfAborted();
727
+ options.onStart?.();
728
+ const command = await executeCommand({ ...current, signal: options.signal, redactValues: options.redactValues });
729
+ return { toolCallId: '', content: JSON.stringify(command), isError: command.status === 'completed' ? undefined : true };
730
+ }
731
+ case 'read_file':
732
+ return await execReadFile(cwd, call.arguments);
733
+ case 'write_file':
734
+ return await execWriteFile(cwd, call.arguments);
735
+ case 'edit_file':
736
+ return await execEditFile(cwd, call.arguments);
737
+ case 'list_dir':
738
+ return await execListDir(cwd, call.arguments);
739
+ case 'grep':
740
+ return await execGrep(cwd, call.arguments);
741
+ case 'read_files':
742
+ return await execReadFiles(cwd, call.arguments);
743
+ case 'file_info':
744
+ return await execFileInfo(cwd, call.arguments);
745
+ case 'find_files':
746
+ return await execFindFiles(cwd, call.arguments);
747
+ case 'make_dir':
748
+ return await execMakeDir(cwd, call.arguments);
749
+ case 'copy_path':
750
+ return await execTransfer(cwd, call.arguments, false);
751
+ case 'move_path':
752
+ return await execTransfer(cwd, call.arguments, true);
753
+ case 'remove_path':
754
+ return await execRemovePath(cwd, call.arguments);
755
+ case 'list_removed':
756
+ return await execListRemoved(cwd);
757
+ case 'restore_file':
758
+ return await execRestoreFile(cwd, call.arguments);
759
+ default:
760
+ throw new Error(`unknown tool: ${call.name}`);
761
+ }
762
+ });
763
+ try {
764
+ let r: ToolResult;
765
+ if (def?.requiresApproval) {
766
+ // Keep parallel batches from racing one another's mutations in the same project.
767
+ const key = await fs.realpath(cwd);
768
+ const task = (mutationTails.get(key) ?? Promise.resolve()).then(result);
769
+ const tail = task.then(() => {}, () => {});
770
+ mutationTails.set(key, tail);
771
+ try { r = await task; }
772
+ finally { if (mutationTails.get(key) === tail) mutationTails.delete(key); }
773
+ } else {
774
+ r = await result();
775
+ }
776
+ r.toolCallId = call.id;
777
+ if (options.absoluteAccess === true) {
778
+ // Drive-wide reads must not put the known router credential into history.
779
+ for (const value of options.redactValues ?? []) if (value) r.content = r.content.replaceAll(value, '[REDACTED]');
780
+ }
781
+ return r;
782
+ } catch (err) {
783
+ const message = err instanceof Error ? err.message : String(err);
784
+ return { toolCallId: call.id, content: call.name === 'run_command' || options.absoluteAccess === true ? sanitizeCommandOutput(message, options.redactValues) : message, isError: true };
785
+ }
786
+ }
787
+
788
+ export function toolRequiresApproval(name: string): boolean {
789
+ return toolByName.get(name)?.requiresApproval ?? false;
790
+ }