flashlite 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.
- package/README.md +583 -2
- package/dist/adapters/anthropic.js +341 -0
- package/dist/adapters/openai.js +241 -0
- package/dist/approval.js +28 -0
- package/dist/boostworkflow.js +15 -0
- package/dist/boundedfs.js +214 -0
- package/dist/chatviewport.js +123 -0
- package/dist/checkpoint.js +100 -0
- package/dist/client.js +463 -0
- package/dist/clipboard.js +72 -0
- package/dist/commandpolicy.js +544 -0
- package/dist/commands.js +29 -0
- package/dist/compaction.js +66 -0
- package/dist/config.js +206 -0
- package/dist/contextbudget.js +201 -0
- package/dist/customcommands.js +105 -0
- package/dist/effort.js +79 -0
- package/dist/hooks.js +86 -0
- package/dist/images.js +84 -0
- package/dist/index.js +459 -0
- package/dist/lineinput.js +982 -0
- package/dist/loop.js +442 -0
- package/dist/markdown.js +426 -0
- package/dist/memory.js +55 -0
- package/dist/pathcomplete.js +92 -0
- package/dist/projectinstructions.js +77 -0
- package/dist/repl.js +1659 -0
- package/dist/researchweb.js +87 -0
- package/dist/sanitize.js +89 -0
- package/dist/sessions.js +209 -0
- package/dist/sse.js +80 -0
- package/dist/subagents.js +182 -0
- package/dist/systemprompt.js +35 -0
- package/dist/terminal.js +235 -0
- package/dist/todo.js +34 -0
- package/dist/token.js +17 -0
- package/dist/tokenprofiles.js +25 -0
- package/dist/toolcache.js +50 -0
- package/dist/toolpreview.js +253 -0
- package/dist/toolresultview.js +62 -0
- package/dist/tools.js +889 -0
- package/dist/types.js +3 -0
- package/dist/ui.js +1630 -0
- package/package.json +27 -4
package/dist/tools.js
ADDED
|
@@ -0,0 +1,889 @@
|
|
|
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
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
8
|
+
import { constants } from 'node:fs';
|
|
9
|
+
import { readPage, searchFile } from "./boundedfs.js";
|
|
10
|
+
import { absoluteCommandRisk, commandPolicy } from "./commandpolicy.js";
|
|
11
|
+
import { executeCommand, validateCommand, sanitizeCommandOutput, DEFAULT_COMMAND_TIMEOUT_MS, MAX_COMMAND_TIMEOUT_MS } from "./terminal.js";
|
|
12
|
+
import { parseTodos } from "./todo.js";
|
|
13
|
+
export const MAX_READ_BYTES = 256 * 1024;
|
|
14
|
+
/** Default page for read_file: ~8K tokens. The model can still ask for up to MAX_READ_BYTES. */
|
|
15
|
+
export const DEFAULT_READ_BYTES = 32 * 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_GREP_OUTPUT_BYTES = 64 * 1024;
|
|
21
|
+
export const DEFAULT_GREP_SCAN_BYTES = 16 * 1024 * 1024;
|
|
22
|
+
export const MAX_GREP_SCAN_BYTES = 100 * 1024 * 1024;
|
|
23
|
+
export const MAX_DIRECTORY_SCAN_ENTRIES = 100000;
|
|
24
|
+
export const MAX_BATCH_FILES = 20;
|
|
25
|
+
export const MAX_TREE_ENTRIES = 5000;
|
|
26
|
+
export const MAX_TREE_BYTES = 100 * 1024 * 1024;
|
|
27
|
+
const SKIP_DIRS = new Set(['.git', 'node_modules', '.venv', 'venv', '__pycache__', '.next', 'dist', 'build']);
|
|
28
|
+
const SKIP_FILES = new Set(['.DS_Store']);
|
|
29
|
+
const FILE_PATH_DESCRIPTION = 'relative path; absolute/~/.. only in unguarded auto';
|
|
30
|
+
export class ConfinementError extends Error {
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Confine a tool-supplied path to the working directory. Two checks, like the Swift
|
|
34
|
+
* FileToolbox: (1) the raw path must be relative (no absolute, ~, or drive-letter
|
|
35
|
+
* forms); (2) after resolution, both the lexical path and the realpath of its deepest
|
|
36
|
+
* existing ancestor must live under realpath(cwd), so symlinks can't escape.
|
|
37
|
+
* Returns the resolved absolute path to operate on.
|
|
38
|
+
*/
|
|
39
|
+
export async function confined(cwd, raw) {
|
|
40
|
+
if (typeof raw !== 'string' || raw.trim() === '') {
|
|
41
|
+
throw new ConfinementError('path must be a non-empty string');
|
|
42
|
+
}
|
|
43
|
+
if (raw.startsWith('~') || raw.startsWith('/') || /^[A-Za-z]:[\\/]/.test(raw)) {
|
|
44
|
+
throw new ConfinementError(`path must be relative to the working directory, got: ${raw}`);
|
|
45
|
+
}
|
|
46
|
+
if (raw.includes('\0'))
|
|
47
|
+
throw new ConfinementError('path contains a null byte');
|
|
48
|
+
// Anchor everything at the real cwd — on macOS tmpdir/realpath differ (/var vs /private/var).
|
|
49
|
+
const realCwd = await fs.realpath(cwd);
|
|
50
|
+
const resolved = path.resolve(realCwd, raw);
|
|
51
|
+
if (resolved !== realCwd && !resolved.startsWith(realCwd + path.sep)) {
|
|
52
|
+
throw new ConfinementError(`path escapes the working directory: ${raw}`);
|
|
53
|
+
}
|
|
54
|
+
// Symlink escape guard: realpath the deepest existing ancestor and re-check.
|
|
55
|
+
let probe = resolved;
|
|
56
|
+
try {
|
|
57
|
+
probe = await fs.realpath(resolved);
|
|
58
|
+
}
|
|
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
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
const parent = path.dirname(dir);
|
|
70
|
+
if (parent === dir) {
|
|
71
|
+
probe = resolved;
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
dir = parent;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (probe !== realCwd && !probe.startsWith(realCwd + path.sep)) {
|
|
79
|
+
throw new ConfinementError(`path resolves outside the working directory (symlink): ${raw}`);
|
|
80
|
+
}
|
|
81
|
+
// A dangling symlink has no realpath; never let creation follow it outside cwd.
|
|
82
|
+
let ancestor = resolved;
|
|
83
|
+
while (ancestor !== realCwd) {
|
|
84
|
+
const stat = await fs.lstat(ancestor).catch(() => null);
|
|
85
|
+
if (stat?.isSymbolicLink()) {
|
|
86
|
+
await fs.realpath(ancestor).catch(() => {
|
|
87
|
+
throw new ConfinementError('dangling symlinks are not supported');
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
ancestor = path.dirname(ancestor);
|
|
91
|
+
}
|
|
92
|
+
return resolved;
|
|
93
|
+
}
|
|
94
|
+
// A host-only capability scoped to one execution, never model-supplied arguments.
|
|
95
|
+
const absoluteAccess = new AsyncLocalStorage();
|
|
96
|
+
const execution = new AsyncLocalStorage();
|
|
97
|
+
function checkExecution() {
|
|
98
|
+
const options = execution.getStore();
|
|
99
|
+
options?.signal?.throwIfAborted();
|
|
100
|
+
if (options?.fileAuthorizationEnabled && !options.fileAuthorizationEnabled()) {
|
|
101
|
+
throw new Error('file authorization revoked — request approval again');
|
|
102
|
+
}
|
|
103
|
+
if (options?.absoluteAccess === true && !options.absoluteEnabled?.()) {
|
|
104
|
+
throw new Error('unguarded authorization revoked — request approval again');
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function readRedactions() {
|
|
108
|
+
return absoluteAccess.getStore() === true ? execution.getStore()?.redactValues ?? [] : [];
|
|
109
|
+
}
|
|
110
|
+
async function toolPath(cwd, raw, unrestricted = absoluteAccess.getStore() === true) {
|
|
111
|
+
if (!unrestricted)
|
|
112
|
+
return confined(cwd, raw);
|
|
113
|
+
if (typeof raw !== 'string' || raw.trim() === '')
|
|
114
|
+
throw new ConfinementError('path must be a non-empty string');
|
|
115
|
+
if (raw.includes('\0'))
|
|
116
|
+
throw new ConfinementError('path contains a null byte');
|
|
117
|
+
const expanded = raw === '~' ? os.homedir() : raw.startsWith('~/') ? path.join(os.homedir(), raw.slice(2)) : raw;
|
|
118
|
+
return path.resolve(await fs.realpath(cwd), expanded);
|
|
119
|
+
}
|
|
120
|
+
export const TOOL_DEFINITIONS = [
|
|
121
|
+
{
|
|
122
|
+
name: 'run_command',
|
|
123
|
+
description: 'Run a noninteractive shell command for tests/builds or work file tools cannot do. NOT sandboxed. Needs separate command authorization; guarded auto still asks for network, installs, sudo, external paths, destructive Git or opaque syntax. Prefer one focused command over multiple calls. No background jobs or interactive input. Output is capped; default timeout 60s.',
|
|
124
|
+
parameters: {
|
|
125
|
+
type: 'object',
|
|
126
|
+
properties: {
|
|
127
|
+
command: { type: 'string', description: 'shell command, up to 16 KiB' },
|
|
128
|
+
cwd: { type: 'string', description: 'existing directory (default .); external only in unguarded auto' },
|
|
129
|
+
timeout_ms: { type: 'integer', minimum: 1, maximum: MAX_COMMAND_TIMEOUT_MS, default: DEFAULT_COMMAND_TIMEOUT_MS },
|
|
130
|
+
},
|
|
131
|
+
required: ['command'],
|
|
132
|
+
},
|
|
133
|
+
requiresApproval: true,
|
|
134
|
+
approvalScope: 'always',
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
name: 'read_file',
|
|
138
|
+
description: 'Read UTF-8 text. Small files return plain text; paged reads return JSON {content, total_bytes, next_offset_bytes}; pass next_offset_bytes as offset_bytes to continue. Page default 32 KiB, max 256 KiB. Binary files are refused.',
|
|
139
|
+
parameters: {
|
|
140
|
+
type: 'object',
|
|
141
|
+
properties: {
|
|
142
|
+
path: { type: 'string', description: FILE_PATH_DESCRIPTION },
|
|
143
|
+
offset_bytes: { type: 'integer', minimum: 0, description: 'byte offset (default 0); use returned next_offset_bytes' },
|
|
144
|
+
max_bytes: { type: 'integer', minimum: 1, maximum: MAX_READ_BYTES, default: DEFAULT_READ_BYTES, description: 'page size in bytes (at least 4)' },
|
|
145
|
+
},
|
|
146
|
+
required: ['path'],
|
|
147
|
+
},
|
|
148
|
+
requiresApproval: false,
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
name: 'write_file',
|
|
152
|
+
description: 'Write a text file, creating parent directories; overwrites if it exists. Max 1 MB.',
|
|
153
|
+
parameters: {
|
|
154
|
+
type: 'object',
|
|
155
|
+
properties: {
|
|
156
|
+
path: { type: 'string', description: FILE_PATH_DESCRIPTION },
|
|
157
|
+
content: { type: 'string', description: 'full new file contents' },
|
|
158
|
+
},
|
|
159
|
+
required: ['path', 'content'],
|
|
160
|
+
},
|
|
161
|
+
requiresApproval: true,
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
name: 'edit_file',
|
|
165
|
+
description: 'Replace one exact, unique occurrence of old_string in a text file (1 MiB limit). Read the file first to copy the exact text.',
|
|
166
|
+
parameters: {
|
|
167
|
+
type: 'object',
|
|
168
|
+
properties: {
|
|
169
|
+
path: { type: 'string', description: FILE_PATH_DESCRIPTION },
|
|
170
|
+
old_string: { type: 'string', description: 'exact text to replace, unique in the file' },
|
|
171
|
+
new_string: { type: 'string', description: 'replacement text' },
|
|
172
|
+
},
|
|
173
|
+
required: ['path', 'old_string', 'new_string'],
|
|
174
|
+
},
|
|
175
|
+
requiresApproval: true,
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
name: 'list_dir',
|
|
179
|
+
description: 'List directory entries in lexical order (directories end in /), paged via next_offset.',
|
|
180
|
+
parameters: {
|
|
181
|
+
type: 'object',
|
|
182
|
+
properties: {
|
|
183
|
+
path: { type: 'string', description: FILE_PATH_DESCRIPTION },
|
|
184
|
+
offset: { type: 'integer', minimum: 0, maximum: MAX_DIRECTORY_SCAN_ENTRIES, default: 0, description: 'entries to skip; use returned next_offset' },
|
|
185
|
+
limit: { type: 'integer', minimum: 1, maximum: MAX_LIST_ENTRIES, default: MAX_LIST_ENTRIES },
|
|
186
|
+
},
|
|
187
|
+
required: ['path'],
|
|
188
|
+
},
|
|
189
|
+
requiresApproval: false,
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
name: 'grep',
|
|
193
|
+
description: 'Literal text search (not regex) over a file or directory. Returns file:line:text matches and byte offsets for cropped snippets. Skips symlinks and special files; normally skips dependency/build dirs. Partial results do not prove absence: continue a file via next_start_byte or narrow a directory search.',
|
|
194
|
+
parameters: {
|
|
195
|
+
type: 'object',
|
|
196
|
+
properties: {
|
|
197
|
+
pattern: { type: 'string', description: 'literal text to search for' },
|
|
198
|
+
path: { type: 'string', description: `search file or directory (default .); ${FILE_PATH_DESCRIPTION}` },
|
|
199
|
+
include: { type: 'string', description: 'only files whose path contains this string' },
|
|
200
|
+
start_byte: { type: 'integer', minimum: 0, default: 0, description: 'file-only zero-based byte search offset; retry returned next_start_byte with same file path and pattern' },
|
|
201
|
+
max_results: { type: 'integer', minimum: 1, maximum: MAX_GREP_RESULTS, default: MAX_GREP_RESULTS },
|
|
202
|
+
max_output_bytes: { type: 'integer', minimum: 1024, maximum: MAX_GREP_OUTPUT_BYTES, default: MAX_GREP_OUTPUT_BYTES, description: 'aggregate response byte budget including status/continuation metadata' },
|
|
203
|
+
max_scan_bytes: { type: 'integer', minimum: 1, maximum: MAX_GREP_SCAN_BYTES, default: DEFAULT_GREP_SCAN_BYTES, description: 'aggregate search bytes; increase or continue an exact file when partial' },
|
|
204
|
+
},
|
|
205
|
+
required: ['pattern'],
|
|
206
|
+
},
|
|
207
|
+
requiresApproval: false,
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
name: 'read_files',
|
|
211
|
+
description: 'Read up to 20 text files together. Returns JSON with content or an error for each path; 256 KB total content budget.',
|
|
212
|
+
parameters: {
|
|
213
|
+
type: 'object',
|
|
214
|
+
properties: { paths: { type: 'array', items: { type: 'string', description: FILE_PATH_DESCRIPTION }, minItems: 1, maxItems: MAX_BATCH_FILES } },
|
|
215
|
+
required: ['paths'],
|
|
216
|
+
},
|
|
217
|
+
requiresApproval: false,
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
name: 'file_info',
|
|
221
|
+
description: 'Inspect a file or directory without reading its content: type, size, permissions and modification time. Does not follow a final symlink.',
|
|
222
|
+
parameters: {
|
|
223
|
+
type: 'object', properties: { path: { type: 'string', description: FILE_PATH_DESCRIPTION } }, required: ['path'],
|
|
224
|
+
},
|
|
225
|
+
requiresApproval: false,
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
name: 'find_files',
|
|
229
|
+
description: 'Find files/directories recursively by literal path substring (not glob/regex). Skips symlinks, and outside unguarded auto .git/dependency/build dirs. Up to 500 results.',
|
|
230
|
+
parameters: {
|
|
231
|
+
type: 'object',
|
|
232
|
+
properties: {
|
|
233
|
+
path: { type: 'string', description: `search directory (default .); ${FILE_PATH_DESCRIPTION}` },
|
|
234
|
+
pattern: { type: 'string', description: 'literal path substring (default all)' },
|
|
235
|
+
type: { type: 'string', enum: ['file', 'directory', 'all'], description: 'default all' },
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
requiresApproval: false,
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
name: 'make_dir',
|
|
242
|
+
description: 'Create a directory and missing parent directories. Existing directories are unchanged.',
|
|
243
|
+
parameters: {
|
|
244
|
+
type: 'object', properties: { path: { type: 'string', description: FILE_PATH_DESCRIPTION } }, required: ['path'],
|
|
245
|
+
},
|
|
246
|
+
requiresApproval: true,
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
name: 'copy_path',
|
|
250
|
+
description: 'Copy a file (binary ok) or directory tree to a new path. Never overwrites; refuses symlinks, special files and trees over 5000 entries / 100 MiB, and outside unguarded auto .git data.',
|
|
251
|
+
parameters: {
|
|
252
|
+
type: 'object', properties: { source: { type: 'string', description: FILE_PATH_DESCRIPTION }, destination: { type: 'string', description: FILE_PATH_DESCRIPTION } }, required: ['source', 'destination'],
|
|
253
|
+
},
|
|
254
|
+
requiresApproval: true,
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
name: 'move_path',
|
|
258
|
+
description: 'Move or rename a file/directory; across the drive in unguarded auto. Never overwrites; same limits as copy_path.',
|
|
259
|
+
parameters: {
|
|
260
|
+
type: 'object', properties: { source: { type: 'string', description: FILE_PATH_DESCRIPTION }, destination: { type: 'string', description: FILE_PATH_DESCRIPTION } }, required: ['source', 'destination'],
|
|
261
|
+
},
|
|
262
|
+
requiresApproval: true,
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
name: 'remove_path',
|
|
266
|
+
description: 'Permanently remove a file/directory — this cannot be undone; use git (or read/copy first) to recover. Outside unguarded auto it cannot remove the working directory or its ancestors, .git internals, or symlink trees.',
|
|
267
|
+
parameters: {
|
|
268
|
+
type: 'object', properties: { path: { type: 'string', description: FILE_PATH_DESCRIPTION } }, required: ['path'],
|
|
269
|
+
},
|
|
270
|
+
requiresApproval: true,
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
name: 'todo_write',
|
|
274
|
+
description: 'Track the task as a todo list. Each item has a content string and a status of pending, in_progress, completed, or cancelled. Pass the full list each time; items are replaced, not merged.',
|
|
275
|
+
parameters: {
|
|
276
|
+
type: 'object',
|
|
277
|
+
properties: {
|
|
278
|
+
todos: {
|
|
279
|
+
type: 'array',
|
|
280
|
+
items: {
|
|
281
|
+
type: 'object',
|
|
282
|
+
properties: {
|
|
283
|
+
content: { type: 'string', description: 'the task description' },
|
|
284
|
+
status: { type: 'string', enum: ['pending', 'in_progress', 'completed', 'cancelled'] },
|
|
285
|
+
},
|
|
286
|
+
required: ['content'],
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
required: ['todos'],
|
|
291
|
+
},
|
|
292
|
+
requiresApproval: false,
|
|
293
|
+
},
|
|
294
|
+
];
|
|
295
|
+
const toolByName = new Map(TOOL_DEFINITIONS.map((t) => [t.name, t]));
|
|
296
|
+
function argString(args, key) {
|
|
297
|
+
const v = args[key];
|
|
298
|
+
return typeof v === 'string' ? v : '';
|
|
299
|
+
}
|
|
300
|
+
function requiredString(args, key, allowEmpty = false) {
|
|
301
|
+
const value = args[key];
|
|
302
|
+
if (typeof value !== 'string' || (!allowEmpty && !value))
|
|
303
|
+
throw new Error(`${key} must be a ${allowEmpty ? '' : 'non-empty '}string`);
|
|
304
|
+
return value;
|
|
305
|
+
}
|
|
306
|
+
function integerArg(args, key, fallback, min, max = Number.MAX_SAFE_INTEGER) {
|
|
307
|
+
const value = args[key] === undefined ? fallback : args[key];
|
|
308
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < min || value > max) {
|
|
309
|
+
throw new Error(`${key} must be an integer between ${min} and ${max}`);
|
|
310
|
+
}
|
|
311
|
+
return value;
|
|
312
|
+
}
|
|
313
|
+
async function readText(abs, limit = MAX_READ_BYTES) {
|
|
314
|
+
checkExecution();
|
|
315
|
+
const stat = await fs.stat(abs);
|
|
316
|
+
if (!stat.isFile())
|
|
317
|
+
throw new Error('not a regular file — use list_dir for directories');
|
|
318
|
+
if (stat.size > limit)
|
|
319
|
+
throw new Error(`file is ${stat.size} bytes (limit ${limit}) — use read_file with offset_bytes/max_bytes, or grep with this file path`);
|
|
320
|
+
const file = await fs.open(await fs.realpath(abs), constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW);
|
|
321
|
+
try {
|
|
322
|
+
if (!(await file.stat()).isFile())
|
|
323
|
+
throw new Error('not a regular file');
|
|
324
|
+
const buffer = Buffer.alloc(limit + 1);
|
|
325
|
+
let used = 0;
|
|
326
|
+
while (used < buffer.length) {
|
|
327
|
+
checkExecution();
|
|
328
|
+
const { bytesRead } = await file.read(buffer, used, buffer.length - used, null);
|
|
329
|
+
checkExecution();
|
|
330
|
+
if (!bytesRead)
|
|
331
|
+
break;
|
|
332
|
+
used += bytesRead;
|
|
333
|
+
}
|
|
334
|
+
if (used > limit)
|
|
335
|
+
throw new Error(`file exceeds ${limit} bytes — use grep to search it`);
|
|
336
|
+
const bytes = buffer.subarray(0, used);
|
|
337
|
+
if (bytes.includes(0))
|
|
338
|
+
throw new Error('binary file — use file_info or copy_path');
|
|
339
|
+
return bytes;
|
|
340
|
+
}
|
|
341
|
+
finally {
|
|
342
|
+
await file.close();
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
async function execReadFile(cwd, args) {
|
|
346
|
+
const abs = await toolPath(cwd, args.path);
|
|
347
|
+
const offset = integerArg(args, 'offset_bytes', 0, 0);
|
|
348
|
+
const limit = integerArg(args, 'max_bytes', DEFAULT_READ_BYTES, 1, MAX_READ_BYTES);
|
|
349
|
+
const page = await readPage(abs, offset, limit, checkExecution, readRedactions());
|
|
350
|
+
const whole = args.offset_bytes === undefined && args.max_bytes === undefined && page.next_offset_bytes === null;
|
|
351
|
+
return { toolCallId: '', content: whole ? page.content : JSON.stringify({ path: args.path, ...page }) };
|
|
352
|
+
}
|
|
353
|
+
async function writeText(abs, content) {
|
|
354
|
+
const file = await fs.open(abs, constants.O_WRONLY | constants.O_CREAT | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o666);
|
|
355
|
+
try {
|
|
356
|
+
if (!(await file.stat()).isFile())
|
|
357
|
+
throw new Error('writes require a regular file');
|
|
358
|
+
checkExecution(); // immediately before destructive commit
|
|
359
|
+
await file.truncate(0);
|
|
360
|
+
await file.writeFile(content, 'utf8');
|
|
361
|
+
}
|
|
362
|
+
finally {
|
|
363
|
+
await file.close();
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
async function execWriteFile(cwd, args) {
|
|
367
|
+
const abs = await mutablePath(cwd, args.path);
|
|
368
|
+
const content = requiredString(args, 'content', true);
|
|
369
|
+
if (Buffer.byteLength(content) > MAX_WRITE_BYTES) {
|
|
370
|
+
throw new Error(`content is ${Buffer.byteLength(content)} bytes (limit ${MAX_WRITE_BYTES})`);
|
|
371
|
+
}
|
|
372
|
+
checkExecution();
|
|
373
|
+
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
374
|
+
await writeText(abs, content);
|
|
375
|
+
return { toolCallId: '', content: `wrote ${Buffer.byteLength(content)} bytes to ${argString(args, 'path')}` };
|
|
376
|
+
}
|
|
377
|
+
async function execEditFile(cwd, args) {
|
|
378
|
+
const abs = await mutablePath(cwd, args.path);
|
|
379
|
+
const oldString = requiredString(args, 'old_string');
|
|
380
|
+
const newString = requiredString(args, 'new_string', true);
|
|
381
|
+
const original = (await readText(abs, MAX_WRITE_BYTES)).toString('utf8');
|
|
382
|
+
const first = original.indexOf(oldString);
|
|
383
|
+
if (first === -1)
|
|
384
|
+
throw new Error('old_string not found in the file — read it first for the exact text');
|
|
385
|
+
if (original.indexOf(oldString, first + 1) !== -1) {
|
|
386
|
+
throw new Error('old_string matches more than once — make it unique');
|
|
387
|
+
}
|
|
388
|
+
const edited = original.slice(0, first) + newString + original.slice(first + oldString.length);
|
|
389
|
+
if (Buffer.byteLength(edited) > MAX_WRITE_BYTES)
|
|
390
|
+
throw new Error(`edited file exceeds ${MAX_WRITE_BYTES} bytes`);
|
|
391
|
+
await writeText(abs, edited);
|
|
392
|
+
return { toolCallId: '', content: `edited ${argString(args, 'path')} (${original.length} -> ${edited.length} bytes)` };
|
|
393
|
+
}
|
|
394
|
+
async function execListDir(cwd, args) {
|
|
395
|
+
const abs = await toolPath(cwd, args.path);
|
|
396
|
+
const offset = integerArg(args, 'offset', 0, 0, MAX_DIRECTORY_SCAN_ENTRIES);
|
|
397
|
+
const limit = integerArg(args, 'limit', MAX_LIST_ENTRIES, 1, MAX_LIST_ENTRIES);
|
|
398
|
+
const entries = [];
|
|
399
|
+
let partial = false;
|
|
400
|
+
checkExecution();
|
|
401
|
+
const dir = await fs.opendir(abs).catch(() => { throw new Error(`no such directory: ${argString(args, 'path')}`); });
|
|
402
|
+
for await (const entry of dir) {
|
|
403
|
+
checkExecution();
|
|
404
|
+
if (entries.length >= MAX_DIRECTORY_SCAN_ENTRIES) {
|
|
405
|
+
partial = true;
|
|
406
|
+
break;
|
|
407
|
+
}
|
|
408
|
+
entries.push({ name: entry.name, directory: entry.isDirectory() });
|
|
409
|
+
}
|
|
410
|
+
// Compare raw names (not locale-dependent, and directory suffixes don't affect order).
|
|
411
|
+
entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
412
|
+
const page = entries.slice(offset, offset + limit);
|
|
413
|
+
let content = page.map((entry) => entry.name + (entry.directory ? '/' : '')).join('\n') || (offset ? '(end of directory)' : '(empty directory)');
|
|
414
|
+
const next = offset + page.length;
|
|
415
|
+
if (partial)
|
|
416
|
+
content += `\n… (partial listing: scan capped at ${MAX_DIRECTORY_SCAN_ENTRIES} entries; sorted scanned subset only, no exhaustive next_offset; inspect narrower subdirectories)`;
|
|
417
|
+
else if (next < entries.length)
|
|
418
|
+
content += `\n… (${entries.length - next} more entries; next_offset: ${next}; retry list_dir with same path and offset: ${next})`;
|
|
419
|
+
return { toolCallId: '', content };
|
|
420
|
+
}
|
|
421
|
+
function skipSearchEntry(name) {
|
|
422
|
+
return absoluteAccess.getStore() !== true && (SKIP_DIRS.has(name) || SKIP_FILES.has(name));
|
|
423
|
+
}
|
|
424
|
+
async function walk(base, include, onFile) {
|
|
425
|
+
const stack = [base];
|
|
426
|
+
let seen = 0, skipped = 0;
|
|
427
|
+
while (stack.length) {
|
|
428
|
+
checkExecution();
|
|
429
|
+
const current = stack.pop();
|
|
430
|
+
if (!(await fs.lstat(current)).isDirectory()) {
|
|
431
|
+
skipped++;
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
const dir = await fs.opendir(current).catch(() => null);
|
|
435
|
+
if (!dir) {
|
|
436
|
+
checkExecution();
|
|
437
|
+
skipped++;
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
for await (const e of dir) {
|
|
441
|
+
checkExecution();
|
|
442
|
+
if (++seen > MAX_GREP_FILES)
|
|
443
|
+
return { capped: true, skipped };
|
|
444
|
+
if (skipSearchEntry(e.name) || e.isSymbolicLink())
|
|
445
|
+
continue;
|
|
446
|
+
const abs = path.join(dir.path, e.name);
|
|
447
|
+
if (e.isDirectory())
|
|
448
|
+
stack.push(abs);
|
|
449
|
+
else if (e.isFile()) {
|
|
450
|
+
const rel = path.relative(base, abs);
|
|
451
|
+
if (include && !rel.includes(include))
|
|
452
|
+
continue;
|
|
453
|
+
if (await onFile(abs, rel))
|
|
454
|
+
return { capped: true, skipped };
|
|
455
|
+
}
|
|
456
|
+
else
|
|
457
|
+
skipped++;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return { capped: false, skipped };
|
|
461
|
+
}
|
|
462
|
+
async function execGrep(cwd, args) {
|
|
463
|
+
const pattern = requiredString(args, 'pattern');
|
|
464
|
+
if (Buffer.byteLength(pattern) > 4096 || /[\r\n]/.test(pattern))
|
|
465
|
+
throw new Error('pattern must be at most 4096 UTF-8 bytes with no newlines; narrow the literal pattern');
|
|
466
|
+
const include = argString(args, 'include');
|
|
467
|
+
const startAbs = await toolPath(cwd, args.path === undefined ? '.' : args.path);
|
|
468
|
+
const stat = await fs.lstat(startAbs);
|
|
469
|
+
if (!stat.isFile() && !stat.isDirectory())
|
|
470
|
+
throw new Error('grep needs a regular file or directory; symlinks/special files are not searched');
|
|
471
|
+
const startByte = integerArg(args, 'start_byte', 0, 0);
|
|
472
|
+
if (stat.isDirectory() && startByte !== 0)
|
|
473
|
+
throw new Error('start_byte only supports a regular file path; narrow path to a file first');
|
|
474
|
+
const resultLimit = integerArg(args, 'max_results', MAX_GREP_RESULTS, 1, MAX_GREP_RESULTS);
|
|
475
|
+
const outputLimit = integerArg(args, 'max_output_bytes', MAX_GREP_OUTPUT_BYTES, 1024, MAX_GREP_OUTPUT_BYTES);
|
|
476
|
+
const scanLimit = integerArg(args, 'max_scan_bytes', DEFAULT_GREP_SCAN_BYTES, 1, MAX_GREP_SCAN_BYTES);
|
|
477
|
+
const results = [];
|
|
478
|
+
let outputBytes = 0, scanned = 0, skipped = 0, partial = false, snippets = 0;
|
|
479
|
+
let nextByte = null, nextFile = '', reason = '';
|
|
480
|
+
// Reserve status space in the aggregate budget, never append unbounded metadata.
|
|
481
|
+
const statusReserve = 512;
|
|
482
|
+
const search = async (abs, rel) => {
|
|
483
|
+
checkExecution();
|
|
484
|
+
if (scanned >= scanLimit) {
|
|
485
|
+
partial = true;
|
|
486
|
+
reason = 'scan byte budget reached';
|
|
487
|
+
nextByte = 0;
|
|
488
|
+
nextFile = rel;
|
|
489
|
+
return true;
|
|
490
|
+
}
|
|
491
|
+
try {
|
|
492
|
+
const progress = await searchFile(abs, Buffer.from(pattern), stat.isFile() ? startByte : 0, scanLimit - scanned, checkExecution, readRedactions(), (match) => {
|
|
493
|
+
const position = match.snippet || match.line === null ? ` [byte_offset: ${match.byte}; snippet]` : '';
|
|
494
|
+
let row = `${rel}:${match.line ?? '?'}:${match.content}${position}`;
|
|
495
|
+
for (const value of readRedactions())
|
|
496
|
+
if (value)
|
|
497
|
+
row = row.replaceAll(value, '[REDACTED]');
|
|
498
|
+
const bytes = Buffer.byteLength(row) + (results.length ? 1 : 0);
|
|
499
|
+
if (results.length >= resultLimit || outputBytes + bytes > outputLimit - statusReserve)
|
|
500
|
+
return true;
|
|
501
|
+
results.push(row);
|
|
502
|
+
outputBytes += bytes;
|
|
503
|
+
if (match.snippet)
|
|
504
|
+
snippets++;
|
|
505
|
+
return false;
|
|
506
|
+
});
|
|
507
|
+
scanned += progress.bytesRead;
|
|
508
|
+
if (progress.reason) {
|
|
509
|
+
partial = true;
|
|
510
|
+
if (progress.nextByte !== null) {
|
|
511
|
+
nextByte = progress.nextByte;
|
|
512
|
+
nextFile = rel;
|
|
513
|
+
reason = progress.reason;
|
|
514
|
+
return true;
|
|
515
|
+
}
|
|
516
|
+
skipped++;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
catch (err) {
|
|
520
|
+
checkExecution(); // never swallow abort or live authorization revocation as a skip
|
|
521
|
+
if (stat.isFile())
|
|
522
|
+
throw err;
|
|
523
|
+
skipped++;
|
|
524
|
+
partial = true;
|
|
525
|
+
}
|
|
526
|
+
return false;
|
|
527
|
+
};
|
|
528
|
+
let traversal = { capped: false, skipped: 0 };
|
|
529
|
+
if (stat.isFile()) {
|
|
530
|
+
if (!include || argString(args, 'path').includes(include))
|
|
531
|
+
await search(startAbs, path.basename(startAbs));
|
|
532
|
+
}
|
|
533
|
+
else
|
|
534
|
+
traversal = await walk(startAbs, include, search);
|
|
535
|
+
skipped += traversal.skipped;
|
|
536
|
+
partial ||= traversal.capped || skipped > 0;
|
|
537
|
+
let content = results.join('\n') || (partial ? '(no matches in searched portion)' : '(no matches)');
|
|
538
|
+
const notes = [];
|
|
539
|
+
if (partial) {
|
|
540
|
+
notes.push(`partial search: ${reason || (traversal.capped ? `entries at ${MAX_GREP_FILES} capped` : 'files/directories skipped')}; skipped: ${skipped}; scanned_bytes: ${scanned}`);
|
|
541
|
+
if (nextByte !== null) {
|
|
542
|
+
notes.push(`next_start_byte: ${nextByte}`);
|
|
543
|
+
if (stat.isFile())
|
|
544
|
+
notes.push('retry same file path/pattern with start_byte: next_start_byte');
|
|
545
|
+
else {
|
|
546
|
+
const retryPath = sanitizeCommandOutput(JSON.stringify(path.join(typeof args.path === 'string' ? args.path : '.', nextFile)), readRedactions());
|
|
547
|
+
notes.push(Buffer.byteLength(retryPath) <= 128 ?
|
|
548
|
+
`continue file path ${retryPath} with start_byte: next_start_byte; directory traversal incomplete — no exhaustive cursor` :
|
|
549
|
+
'file path too long for bounded status; narrow path to a file; directory traversal incomplete — no exhaustive cursor');
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
notes.push('narrow path/include or raise max_scan_bytes/max_results/max_output_bytes within limits');
|
|
553
|
+
}
|
|
554
|
+
if (snippets)
|
|
555
|
+
notes.push(`truncated match snippets: ${snippets}; use read_file offset_bytes near byte_offset for full text`);
|
|
556
|
+
if (notes.length) {
|
|
557
|
+
// Sanitize BEFORE clipping so clipping cannot expose a fragment of a known key.
|
|
558
|
+
const status = sanitizeCommandOutput(`… (${notes.join('; ')})`, readRedactions());
|
|
559
|
+
const bytes = Buffer.from(status);
|
|
560
|
+
content += '\n' + (bytes.length <= statusReserve - 1 ? status : bytes.subarray(0, statusReserve - 4).toString('utf8') + '…');
|
|
561
|
+
}
|
|
562
|
+
return { toolCallId: '', content };
|
|
563
|
+
}
|
|
564
|
+
async function execReadFiles(cwd, args) {
|
|
565
|
+
if (!Array.isArray(args.paths) || !args.paths.length || args.paths.length > MAX_BATCH_FILES || args.paths.some((p) => typeof p !== 'string')) {
|
|
566
|
+
throw new Error(`paths must contain 1–${MAX_BATCH_FILES} strings`);
|
|
567
|
+
}
|
|
568
|
+
let remaining = MAX_READ_BYTES;
|
|
569
|
+
const results = [];
|
|
570
|
+
for (const raw of args.paths) {
|
|
571
|
+
checkExecution();
|
|
572
|
+
try {
|
|
573
|
+
const bytes = await readText(await toolPath(cwd, raw), remaining);
|
|
574
|
+
remaining -= bytes.length;
|
|
575
|
+
results.push({ path: raw, content: bytes.toString('utf8') });
|
|
576
|
+
}
|
|
577
|
+
catch (err) {
|
|
578
|
+
checkExecution();
|
|
579
|
+
results.push({ path: raw, error: err instanceof Error ? err.message : String(err) });
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
return { toolCallId: '', content: JSON.stringify(results) };
|
|
583
|
+
}
|
|
584
|
+
async function execFileInfo(cwd, args) {
|
|
585
|
+
const abs = await toolPath(cwd, args.path);
|
|
586
|
+
const stat = await fs.lstat(abs);
|
|
587
|
+
return { toolCallId: '', content: JSON.stringify({
|
|
588
|
+
path: args.path,
|
|
589
|
+
type: stat.isSymbolicLink() ? 'symlink' : stat.isDirectory() ? 'directory' : stat.isFile() ? 'file' : 'special',
|
|
590
|
+
size_bytes: stat.size,
|
|
591
|
+
permissions: (stat.mode & 0o777).toString(8).padStart(3, '0'),
|
|
592
|
+
modified_at: stat.mtime.toISOString(),
|
|
593
|
+
}) };
|
|
594
|
+
}
|
|
595
|
+
async function execFindFiles(cwd, args) {
|
|
596
|
+
const start = await toolPath(cwd, args.path ?? '.');
|
|
597
|
+
if (!(await fs.stat(start)).isDirectory())
|
|
598
|
+
throw new Error('find_files needs a directory');
|
|
599
|
+
const pattern = args.pattern === undefined ? '' : requiredString(args, 'pattern', true);
|
|
600
|
+
const type = args.type ?? 'all';
|
|
601
|
+
if (!['file', 'directory', 'all'].includes(type))
|
|
602
|
+
throw new Error('type must be file, directory, or all');
|
|
603
|
+
const results = [];
|
|
604
|
+
const stack = [start];
|
|
605
|
+
let seen = 0, capped = false;
|
|
606
|
+
while (stack.length && !capped) {
|
|
607
|
+
checkExecution();
|
|
608
|
+
const current = stack.pop();
|
|
609
|
+
if (!(await fs.lstat(current)).isDirectory())
|
|
610
|
+
throw new Error('search directory changed; retry find_files with a narrower path');
|
|
611
|
+
const dir = await fs.opendir(current);
|
|
612
|
+
for await (const entry of dir) {
|
|
613
|
+
checkExecution();
|
|
614
|
+
if (++seen > MAX_TREE_ENTRIES) {
|
|
615
|
+
capped = true;
|
|
616
|
+
break;
|
|
617
|
+
}
|
|
618
|
+
if (skipSearchEntry(entry.name) || entry.isSymbolicLink())
|
|
619
|
+
continue;
|
|
620
|
+
const abs = path.join(dir.path, entry.name);
|
|
621
|
+
const rel = path.relative(start, abs);
|
|
622
|
+
if (entry.isDirectory())
|
|
623
|
+
stack.push(abs);
|
|
624
|
+
if ((entry.isFile() || entry.isDirectory()) && rel.includes(pattern) &&
|
|
625
|
+
(type === 'all' || (type === 'file' ? entry.isFile() : entry.isDirectory()))) {
|
|
626
|
+
results.push(rel + (entry.isDirectory() ? '/' : ''));
|
|
627
|
+
if (results.length >= MAX_LIST_ENTRIES) {
|
|
628
|
+
capped = true;
|
|
629
|
+
break;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
let content = results.sort().join('\n') || (capped ? '(no matches in searched portion)' : '(no matches)');
|
|
635
|
+
if (capped)
|
|
636
|
+
content += `\n… (partial search: capped at ${MAX_LIST_ENTRIES} results / ${MAX_TREE_ENTRIES} entries; narrow path/pattern/type or use list_dir pagination; no exhaustive continuation cursor)`;
|
|
637
|
+
return { toolCallId: '', content };
|
|
638
|
+
}
|
|
639
|
+
async function mutablePath(cwd, raw) {
|
|
640
|
+
const abs = await toolPath(cwd, raw);
|
|
641
|
+
if (absoluteAccess.getStore() === true)
|
|
642
|
+
return abs;
|
|
643
|
+
const root = await fs.realpath(cwd);
|
|
644
|
+
if (abs === root)
|
|
645
|
+
throw new Error('cannot mutate the working directory itself');
|
|
646
|
+
let probe = abs;
|
|
647
|
+
while (probe !== root) {
|
|
648
|
+
if (path.basename(probe) === '.git')
|
|
649
|
+
throw new Error('cannot mutate .git internals');
|
|
650
|
+
const stat = await fs.lstat(probe).catch((err) => {
|
|
651
|
+
if (err.code === 'ENOENT')
|
|
652
|
+
return null;
|
|
653
|
+
throw err;
|
|
654
|
+
});
|
|
655
|
+
if (stat?.isSymbolicLink())
|
|
656
|
+
throw new Error('filesystem mutations do not support symlink paths');
|
|
657
|
+
probe = path.dirname(probe);
|
|
658
|
+
}
|
|
659
|
+
return abs;
|
|
660
|
+
}
|
|
661
|
+
async function requireMissing(abs) {
|
|
662
|
+
const exists = await fs.lstat(abs).catch((err) => {
|
|
663
|
+
if (err.code === 'ENOENT')
|
|
664
|
+
return null;
|
|
665
|
+
throw err;
|
|
666
|
+
});
|
|
667
|
+
if (exists)
|
|
668
|
+
throw new Error('destination already exists — choose a new path; overwriting is not supported');
|
|
669
|
+
}
|
|
670
|
+
async function inspectTree(abs) {
|
|
671
|
+
const entries = [];
|
|
672
|
+
let bytes = 0;
|
|
673
|
+
const stack = [{ abs, rel: '' }];
|
|
674
|
+
while (stack.length) {
|
|
675
|
+
checkExecution();
|
|
676
|
+
const item = stack.pop();
|
|
677
|
+
if (entries.length >= MAX_TREE_ENTRIES)
|
|
678
|
+
throw new Error(`tree exceeds ${MAX_TREE_ENTRIES} entries`);
|
|
679
|
+
if (absoluteAccess.getStore() !== true && item.rel.split(path.sep).some((name) => name === '.git')) {
|
|
680
|
+
throw new Error('tree contains protected .git internals');
|
|
681
|
+
}
|
|
682
|
+
const stat = await fs.lstat(item.abs);
|
|
683
|
+
if (!stat.isDirectory() && !stat.isFile())
|
|
684
|
+
throw new Error('tree contains a symlink or special file — operation refused');
|
|
685
|
+
bytes += stat.isFile() ? stat.size : 0;
|
|
686
|
+
if (bytes > MAX_TREE_BYTES)
|
|
687
|
+
throw new Error('tree exceeds the 100 MiB limit');
|
|
688
|
+
entries.push({ ...item, directory: stat.isDirectory(), mode: stat.mode & 0o777 });
|
|
689
|
+
if (stat.isDirectory()) {
|
|
690
|
+
const dir = await fs.opendir(item.abs);
|
|
691
|
+
for await (const entry of dir) {
|
|
692
|
+
checkExecution();
|
|
693
|
+
if (entries.length + stack.length >= MAX_TREE_ENTRIES)
|
|
694
|
+
throw new Error(`tree exceeds ${MAX_TREE_ENTRIES} entries`);
|
|
695
|
+
stack.push({ abs: path.join(item.abs, entry.name), rel: path.join(item.rel, entry.name) });
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
return entries;
|
|
700
|
+
}
|
|
701
|
+
async function execMakeDir(cwd, args) {
|
|
702
|
+
const abs = await mutablePath(cwd, args.path);
|
|
703
|
+
checkExecution();
|
|
704
|
+
await fs.mkdir(abs, { recursive: true });
|
|
705
|
+
return { toolCallId: '', content: `directory ready: ${args.path}` };
|
|
706
|
+
}
|
|
707
|
+
async function execTransfer(cwd, args, move) {
|
|
708
|
+
const source = await mutablePath(cwd, args.source);
|
|
709
|
+
const destination = await mutablePath(cwd, args.destination);
|
|
710
|
+
if (destination === source || destination.startsWith(source + path.sep))
|
|
711
|
+
throw new Error('destination must be outside the source tree');
|
|
712
|
+
await requireMissing(destination);
|
|
713
|
+
const entries = await inspectTree(source);
|
|
714
|
+
checkExecution();
|
|
715
|
+
await fs.mkdir(path.dirname(destination), { recursive: true });
|
|
716
|
+
if (move) {
|
|
717
|
+
checkExecution(); // immediately before committing the move
|
|
718
|
+
await fs.rename(source, destination);
|
|
719
|
+
}
|
|
720
|
+
else {
|
|
721
|
+
let created = false;
|
|
722
|
+
try {
|
|
723
|
+
for (const entry of entries) {
|
|
724
|
+
checkExecution();
|
|
725
|
+
const dest = path.join(destination, entry.rel);
|
|
726
|
+
// Recheck before each copy; never deliberately follow links from the source tree.
|
|
727
|
+
await mutablePath(cwd, path.relative(await fs.realpath(cwd), entry.abs));
|
|
728
|
+
const stat = await fs.lstat(entry.abs);
|
|
729
|
+
if (entry.directory ? !stat.isDirectory() : !stat.isFile())
|
|
730
|
+
throw new Error('tree contains a symlink or special file — operation refused');
|
|
731
|
+
checkExecution();
|
|
732
|
+
if (entry.directory)
|
|
733
|
+
await fs.mkdir(dest);
|
|
734
|
+
else
|
|
735
|
+
await fs.copyFile(entry.abs, dest, constants.COPYFILE_EXCL);
|
|
736
|
+
if (!entry.rel)
|
|
737
|
+
created = true;
|
|
738
|
+
checkExecution();
|
|
739
|
+
if (!entry.directory)
|
|
740
|
+
await fs.chmod(dest, entry.mode);
|
|
741
|
+
}
|
|
742
|
+
checkExecution(); // cancellation after the final copy still rolls back this destination
|
|
743
|
+
}
|
|
744
|
+
catch (err) {
|
|
745
|
+
if (created)
|
|
746
|
+
await fs.rm(destination, { recursive: true, force: true });
|
|
747
|
+
throw err;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
return { toolCallId: '', content: `${move ? 'moved' : 'copied'} ${args.source} → ${args.destination} (${entries.length} entries)` };
|
|
751
|
+
}
|
|
752
|
+
async function execRemovePath(cwd, args) {
|
|
753
|
+
const abs = await mutablePath(cwd, args.path);
|
|
754
|
+
await inspectTree(abs);
|
|
755
|
+
const projectRoot = await fs.realpath(cwd);
|
|
756
|
+
const relative = path.relative(projectRoot, abs);
|
|
757
|
+
// Deletion is permanent and cannot be undone — always refuse the working directory
|
|
758
|
+
// and its ancestors, even in unguarded auto where mutablePath does not block them.
|
|
759
|
+
if (abs === projectRoot || projectRoot.startsWith(abs + path.sep)) {
|
|
760
|
+
throw new Error('cannot permanently delete the working directory or one of its ancestors');
|
|
761
|
+
}
|
|
762
|
+
checkExecution(); // immediately before an irreversible removal
|
|
763
|
+
await fs.rm(abs, { recursive: true, force: false });
|
|
764
|
+
return { toolCallId: '', content: JSON.stringify({ removed: relative, permanent: true, hint: 'permanently deleted — use git or a copy to recover; there is no recovery store' }) };
|
|
765
|
+
}
|
|
766
|
+
export async function prepareCommand(cwd, args, redactValues = [], unrestricted = false) {
|
|
767
|
+
const command = validateCommand(args.command, args.timeout_ms, redactValues);
|
|
768
|
+
const rawCwd = args.cwd;
|
|
769
|
+
if (typeof rawCwd === 'string' && redactValues.some((value) => value && rawCwd.includes(value))) {
|
|
770
|
+
throw new Error('command cwd contains a protected credential');
|
|
771
|
+
}
|
|
772
|
+
const abs = await toolPath(cwd, args.cwd === undefined ? '.' : args.cwd, unrestricted);
|
|
773
|
+
if (!(await fs.stat(abs)).isDirectory())
|
|
774
|
+
throw new Error('command cwd must be an existing directory');
|
|
775
|
+
return { ...command, cwd: await fs.realpath(abs) };
|
|
776
|
+
}
|
|
777
|
+
const mutationTails = new Map();
|
|
778
|
+
/** Execute a tool. Commands require an approval snapshot; failures become error results. */
|
|
779
|
+
export async function executeTool(call, cwd, options = {}) {
|
|
780
|
+
const def = toolByName.get(call.name);
|
|
781
|
+
const result = () => execution.run(options, () => absoluteAccess.run(options.absoluteAccess === true, async () => {
|
|
782
|
+
if (!def)
|
|
783
|
+
throw new Error(`unknown tool: ${call.name}`);
|
|
784
|
+
options.signal?.throwIfAborted(); // also checked when serialized execution actually starts
|
|
785
|
+
if ((options.absoluteAccess === true || options.commandApproval?.kind === 'absolute') && !options.absoluteEnabled?.()) {
|
|
786
|
+
throw new Error('unguarded authorization revoked — request approval again');
|
|
787
|
+
}
|
|
788
|
+
if (call.argumentsInvalid)
|
|
789
|
+
throw new Error('invalid tool arguments');
|
|
790
|
+
switch (call.name) {
|
|
791
|
+
case 'run_command': {
|
|
792
|
+
const approved = options.commandApproval;
|
|
793
|
+
if (!approved)
|
|
794
|
+
throw new Error('terminal commands require separate explicit approval or guarded-auto authorization');
|
|
795
|
+
const current = await prepareCommand(cwd, call.arguments, options.redactValues, options.absoluteAccess === true);
|
|
796
|
+
if (current.command !== approved.command || current.cwd !== approved.cwd || current.timeoutMs !== approved.timeoutMs) {
|
|
797
|
+
throw new Error('terminal command changed after approval — request approval again');
|
|
798
|
+
}
|
|
799
|
+
if (approved.kind === 'absolute' && absoluteCommandRisk(current.command, current.cwd) && !approved.hugeRiskConfirmed) {
|
|
800
|
+
throw new Error('exceptional huge-risk command requires separate explicit confirmation');
|
|
801
|
+
}
|
|
802
|
+
if (approved.kind === 'auto') {
|
|
803
|
+
if (!options.autoEnabled?.())
|
|
804
|
+
throw new Error('auto authorization revoked — request approval again');
|
|
805
|
+
const decision = await commandPolicy(current.command, current.cwd);
|
|
806
|
+
if (!decision.auto || !options.autoEnabled?.())
|
|
807
|
+
throw new Error('auto authorization no longer eligible — request approval again');
|
|
808
|
+
}
|
|
809
|
+
if ((approved.kind === 'absolute' || options.absoluteAccess === true) && !options.absoluteEnabled?.()) {
|
|
810
|
+
throw new Error('unguarded authorization revoked — request approval again');
|
|
811
|
+
}
|
|
812
|
+
options.signal?.throwIfAborted();
|
|
813
|
+
options.onStart?.();
|
|
814
|
+
const command = await executeCommand({ ...current, signal: options.signal, redactValues: options.redactValues });
|
|
815
|
+
return { toolCallId: '', content: JSON.stringify(command), isError: command.status === 'completed' ? undefined : true };
|
|
816
|
+
}
|
|
817
|
+
case 'read_file':
|
|
818
|
+
return await execReadFile(cwd, call.arguments);
|
|
819
|
+
case 'write_file':
|
|
820
|
+
return await execWriteFile(cwd, call.arguments);
|
|
821
|
+
case 'edit_file':
|
|
822
|
+
return await execEditFile(cwd, call.arguments);
|
|
823
|
+
case 'list_dir':
|
|
824
|
+
return await execListDir(cwd, call.arguments);
|
|
825
|
+
case 'grep':
|
|
826
|
+
return await execGrep(cwd, call.arguments);
|
|
827
|
+
case 'read_files':
|
|
828
|
+
return await execReadFiles(cwd, call.arguments);
|
|
829
|
+
case 'file_info':
|
|
830
|
+
return await execFileInfo(cwd, call.arguments);
|
|
831
|
+
case 'find_files':
|
|
832
|
+
return await execFindFiles(cwd, call.arguments);
|
|
833
|
+
case 'make_dir':
|
|
834
|
+
return await execMakeDir(cwd, call.arguments);
|
|
835
|
+
case 'copy_path':
|
|
836
|
+
return await execTransfer(cwd, call.arguments, false);
|
|
837
|
+
case 'move_path':
|
|
838
|
+
return await execTransfer(cwd, call.arguments, true);
|
|
839
|
+
case 'remove_path':
|
|
840
|
+
return await execRemovePath(cwd, call.arguments);
|
|
841
|
+
case 'todo_write': {
|
|
842
|
+
const parsed = parseTodos(call.arguments);
|
|
843
|
+
if (parsed.error)
|
|
844
|
+
return { toolCallId: '', content: JSON.stringify({ error: parsed.error }), isError: true };
|
|
845
|
+
return { toolCallId: '', content: JSON.stringify({ status: 'ok', todos: parsed.todos }) };
|
|
846
|
+
}
|
|
847
|
+
default:
|
|
848
|
+
throw new Error(`unknown tool: ${call.name}`);
|
|
849
|
+
}
|
|
850
|
+
}));
|
|
851
|
+
try {
|
|
852
|
+
let r;
|
|
853
|
+
if (def?.requiresApproval) {
|
|
854
|
+
// Keep parallel batches from racing one another's mutations in the same project.
|
|
855
|
+
const key = await fs.realpath(cwd);
|
|
856
|
+
const task = (mutationTails.get(key) ?? Promise.resolve()).then(result);
|
|
857
|
+
const tail = task.then(() => { }, () => { });
|
|
858
|
+
mutationTails.set(key, tail);
|
|
859
|
+
try {
|
|
860
|
+
r = await task;
|
|
861
|
+
}
|
|
862
|
+
finally {
|
|
863
|
+
if (mutationTails.get(key) === tail)
|
|
864
|
+
mutationTails.delete(key);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
else {
|
|
868
|
+
r = await result();
|
|
869
|
+
}
|
|
870
|
+
r.toolCallId = call.id;
|
|
871
|
+
if (options.absoluteAccess === true) {
|
|
872
|
+
// Drive-wide reads must not put the known router credential into history.
|
|
873
|
+
for (const value of options.redactValues ?? [])
|
|
874
|
+
if (value)
|
|
875
|
+
r.content = r.content.replaceAll(value, '[REDACTED]');
|
|
876
|
+
}
|
|
877
|
+
if (call.name === 'grep' && Buffer.byteLength(r.content) > integerArg(call.arguments, 'max_output_bytes', MAX_GREP_OUTPUT_BYTES, 1024, MAX_GREP_OUTPUT_BYTES)) {
|
|
878
|
+
throw new Error('grep response exceeds max_output_bytes after redaction; narrow path/include or raise max_output_bytes within the 65536-byte limit');
|
|
879
|
+
}
|
|
880
|
+
return r;
|
|
881
|
+
}
|
|
882
|
+
catch (err) {
|
|
883
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
884
|
+
return { toolCallId: call.id, content: call.name === 'run_command' || options.absoluteAccess === true ? sanitizeCommandOutput(message, options.redactValues) : message, isError: true };
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
export function toolRequiresApproval(name) {
|
|
888
|
+
return toolByName.get(name)?.requiresApproval ?? false;
|
|
889
|
+
}
|