@wangzhizhi/remi 0.0.1-alpha

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 (55) hide show
  1. package/README.md +9 -0
  2. package/dist/doctor.js +108 -0
  3. package/dist/git.js +41 -0
  4. package/dist/help.js +27 -0
  5. package/dist/i18n.js +422 -0
  6. package/dist/index.js +97 -0
  7. package/dist/initPrompt.js +17 -0
  8. package/dist/model.js +116 -0
  9. package/dist/modelSelection.js +34 -0
  10. package/dist/permissionDisplay.js +46 -0
  11. package/dist/permissions.js +206 -0
  12. package/dist/repl.js +346 -0
  13. package/dist/resume.js +3 -0
  14. package/dist/setup.js +62 -0
  15. package/dist/statusline.js +59 -0
  16. package/dist/style.js +48 -0
  17. package/dist/syntaxTheme.js +39 -0
  18. package/dist/tui/RemiApp.js +1756 -0
  19. package/dist/tui/commands.js +427 -0
  20. package/dist/tui/index.js +42 -0
  21. package/dist/tui/renderers/Header.js +28 -0
  22. package/dist/tui/renderers/MessageList.js +1176 -0
  23. package/dist/tui/renderers/PromptBox.js +118 -0
  24. package/dist/tui/renderers/StatusLine.js +124 -0
  25. package/dist/tui/renderers/WorkingIndicator.js +70 -0
  26. package/dist/tui/slashCommandHighlight.js +8 -0
  27. package/dist/tui/theme.js +13 -0
  28. package/dist/tui/types.js +1 -0
  29. package/dist/usage.js +66 -0
  30. package/dist/version.js +5 -0
  31. package/node_modules/@remi/compact/dist/index.js +389 -0
  32. package/node_modules/@remi/compact/package.json +8 -0
  33. package/node_modules/@remi/config/dist/index.js +426 -0
  34. package/node_modules/@remi/config/package.json +8 -0
  35. package/node_modules/@remi/core/dist/contextBuilder.js +344 -0
  36. package/node_modules/@remi/core/dist/directoryOverview.js +359 -0
  37. package/node_modules/@remi/core/dist/index.js +2843 -0
  38. package/node_modules/@remi/core/dist/projectInstructions.js +123 -0
  39. package/node_modules/@remi/core/dist/responseStyles.js +98 -0
  40. package/node_modules/@remi/core/package.json +8 -0
  41. package/node_modules/@remi/llm/dist/index.js +804 -0
  42. package/node_modules/@remi/llm/package.json +8 -0
  43. package/node_modules/@remi/memory/dist/index.js +312 -0
  44. package/node_modules/@remi/memory/package.json +8 -0
  45. package/node_modules/@remi/permissions/dist/index.js +90 -0
  46. package/node_modules/@remi/permissions/package.json +8 -0
  47. package/node_modules/@remi/sessions/dist/index.js +370 -0
  48. package/node_modules/@remi/sessions/package.json +8 -0
  49. package/node_modules/@remi/skills/dist/index.js +273 -0
  50. package/node_modules/@remi/skills/package.json +8 -0
  51. package/node_modules/@remi/terminal-markdown/dist/index.js +1412 -0
  52. package/node_modules/@remi/terminal-markdown/package.json +8 -0
  53. package/node_modules/@remi/tools/dist/index.js +3875 -0
  54. package/node_modules/@remi/tools/package.json +8 -0
  55. package/package.json +48 -0
@@ -0,0 +1,3875 @@
1
+ import { autoReviewToolPermission, evaluateToolPermission, summarizeToolInput, } from '@remi/permissions';
2
+ import { spawn } from 'node:child_process';
3
+ import { mkdir, readdir, readFile, realpath, rmdir, stat, unlink, writeFile } from 'node:fs/promises';
4
+ import { homedir } from 'node:os';
5
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
6
+ export const toolsPackageName = '@remi/tools';
7
+ export function createToolRegistry(tools = []) {
8
+ const byName = new Map();
9
+ const registry = {
10
+ register(tool) {
11
+ validateToolDefinition(tool);
12
+ if (byName.has(tool.name)) {
13
+ throw new Error(`Tool already registered: ${tool.name}`);
14
+ }
15
+ byName.set(tool.name, tool);
16
+ return registry;
17
+ },
18
+ get(name) {
19
+ return byName.get(name);
20
+ },
21
+ require(name) {
22
+ const tool = byName.get(name);
23
+ if (!tool) {
24
+ throw new Error(`Unknown tool: ${name}`);
25
+ }
26
+ return tool;
27
+ },
28
+ list() {
29
+ return [...byName.values()];
30
+ },
31
+ toJSONSchemas() {
32
+ return Object.fromEntries([...byName.values()].map(tool => [tool.name, toolInputSchemaToJsonSchema(tool.inputSchema)]));
33
+ },
34
+ };
35
+ for (const tool of tools) {
36
+ registry.register(tool);
37
+ }
38
+ return registry;
39
+ }
40
+ export function toolInputSchemaToJsonSchema(schema) {
41
+ return {
42
+ type: schema.type,
43
+ ...(schema.description ? { description: schema.description } : {}),
44
+ properties: Object.fromEntries(Object.entries(schema.properties).map(([name, property]) => [name, toolSchemaPropertyToJsonSchema(property)])),
45
+ ...(schema.required ? { required: schema.required } : {}),
46
+ additionalProperties: schema.additionalProperties ?? false,
47
+ };
48
+ }
49
+ export function toolOutputSchemaToJsonSchema(schema) {
50
+ return toolInputSchemaToJsonSchema(schema);
51
+ }
52
+ export function commandCandidatesForCapability(capabilityId, platform, options = {}) {
53
+ const candidate = (command, risk, extra = {}) => ({
54
+ capabilityId,
55
+ platform,
56
+ command,
57
+ risk,
58
+ requiresApproval: risk !== 'read-only',
59
+ ...extra,
60
+ });
61
+ if (capabilityId === 'network.ip') {
62
+ if (platform === 'win32') {
63
+ return [candidate('ipconfig /all', 'read-only')];
64
+ }
65
+ if (platform === 'linux') {
66
+ return [
67
+ candidate('ip addr show', 'read-only'),
68
+ candidate('ifconfig -a', 'read-only'),
69
+ candidate('hostname -I', 'read-only'),
70
+ ];
71
+ }
72
+ return [candidate('ifconfig -a', 'read-only')];
73
+ }
74
+ if (capabilityId === 'network.mac') {
75
+ if (platform === 'win32') {
76
+ return [
77
+ candidate('getmac /v /fo csv', 'read-only'),
78
+ candidate('ipconfig /all', 'read-only'),
79
+ ];
80
+ }
81
+ if (platform === 'linux') {
82
+ return [
83
+ candidate('ip link', 'read-only'),
84
+ candidate('ifconfig -a', 'read-only'),
85
+ ];
86
+ }
87
+ return [candidate('ifconfig -a', 'read-only')];
88
+ }
89
+ if (capabilityId === 'network.ports') {
90
+ const port = normalizedPort(options.port);
91
+ const portSpecific = port ? `lsof -i :${port} -P -n` : 'lsof -i -P -n';
92
+ if (platform === 'win32') {
93
+ return [candidate('netstat -ano', 'read-only')];
94
+ }
95
+ if (platform === 'linux') {
96
+ return [
97
+ candidate(portSpecific, 'read-only', port ? { target: String(port) } : {}),
98
+ candidate('ss -ltn', 'read-only'),
99
+ candidate('netstat -an', 'read-only'),
100
+ ];
101
+ }
102
+ return [
103
+ candidate(portSpecific, 'read-only', port ? { target: String(port) } : {}),
104
+ candidate('netstat -an', 'read-only'),
105
+ ];
106
+ }
107
+ if (capabilityId === 'logs.search') {
108
+ const pattern = options.pattern?.trim();
109
+ const path = options.path?.trim() || '.';
110
+ if (!pattern) {
111
+ return [];
112
+ }
113
+ return [
114
+ candidate(`rg -n ${quoteCommandArg(pattern)} ${quoteCommandArg(path)}`, 'read-only', { target: path }),
115
+ candidate(`grep -n ${quoteCommandArg(pattern)} ${quoteCommandArg(path)}`, 'read-only', { target: path }),
116
+ ];
117
+ }
118
+ if (capabilityId === 'download.url') {
119
+ const url = safeCapabilityUrl(options.url);
120
+ if (!url) {
121
+ return [];
122
+ }
123
+ return [
124
+ candidate(`curl -L ${quoteCommandArg(url)}`, 'network', { target: url }),
125
+ candidate(`wget -O - ${quoteCommandArg(url)}`, 'network', { target: url }),
126
+ ];
127
+ }
128
+ const installTarget = safeCapabilityToken(options.packageName) ?? safeCapabilityToken(options.toolName);
129
+ if (capabilityId === 'tool.install' && installTarget) {
130
+ if (platform === 'darwin') {
131
+ return [candidate(`brew install ${quoteCommandArg(installTarget)}`, 'install', { packageManager: 'brew', target: installTarget })];
132
+ }
133
+ if (platform === 'linux') {
134
+ return [
135
+ candidate(`apt install ${quoteCommandArg(installTarget)}`, 'admin', { packageManager: 'apt', target: installTarget, requiresAdmin: true }),
136
+ candidate(`apt-get install ${quoteCommandArg(installTarget)}`, 'admin', { packageManager: 'apt-get', target: installTarget, requiresAdmin: true }),
137
+ ];
138
+ }
139
+ return [
140
+ candidate(`winget install ${quoteCommandArg(installTarget)}`, 'install', { packageManager: 'winget', target: installTarget }),
141
+ candidate(`scoop install ${quoteCommandArg(installTarget)}`, 'install', { packageManager: 'scoop', target: installTarget }),
142
+ candidate(`choco install ${quoteCommandArg(installTarget)}`, 'admin', { packageManager: 'choco', target: installTarget, requiresAdmin: true }),
143
+ ];
144
+ }
145
+ return [];
146
+ }
147
+ export function normalizeCommandProbeResult(input) {
148
+ if (input.permissionStatus === 'deny' || input.errorCode === 'COMMAND_DENIED' || input.errorCode === 'PERMISSION_DENIED' || input.errorCode === 'PERMISSION_REQUIRED') {
149
+ return 'denied';
150
+ }
151
+ if (input.exitCode === 0) {
152
+ return 'available';
153
+ }
154
+ const stderr = input.stderr?.toLowerCase() ?? '';
155
+ if (input.exitCode === 127 ||
156
+ stderr.includes('command not found') ||
157
+ stderr.includes('not recognized as an internal or external command') ||
158
+ stderr.includes('no such file or directory')) {
159
+ return 'missing';
160
+ }
161
+ return 'failed';
162
+ }
163
+ function normalizedPort(port) {
164
+ return typeof port === 'number' && Number.isSafeInteger(port) && port >= 1 && port <= 65_535 ? port : undefined;
165
+ }
166
+ function safeCapabilityToken(value) {
167
+ const trimmed = value?.trim();
168
+ if (!trimmed || trimmed.length > 120) {
169
+ return undefined;
170
+ }
171
+ return /^[A-Za-z0-9@][A-Za-z0-9._+:/@-]*$/.test(trimmed) ? trimmed : undefined;
172
+ }
173
+ function safeCapabilityUrl(value) {
174
+ const trimmed = value?.trim();
175
+ if (!trimmed || /[\s\r\n]/.test(trimmed)) {
176
+ return undefined;
177
+ }
178
+ try {
179
+ const url = new URL(trimmed);
180
+ return url.protocol === 'https:' || url.protocol === 'http:' ? url.toString() : undefined;
181
+ }
182
+ catch {
183
+ return undefined;
184
+ }
185
+ }
186
+ function toolSchemaPropertyToJsonSchema(property) {
187
+ return {
188
+ type: property.type,
189
+ description: property.description,
190
+ ...(property.enum ? { enum: property.enum } : {}),
191
+ ...(property.items ? { items: toolSchemaPropertyToJsonSchema(property.items) } : {}),
192
+ ...(property.properties
193
+ ? {
194
+ properties: Object.fromEntries(Object.entries(property.properties).map(([name, nested]) => [name, toolSchemaPropertyToJsonSchema(nested)])),
195
+ }
196
+ : {}),
197
+ ...(property.required ? { required: property.required } : {}),
198
+ ...(property.additionalProperties !== undefined ? { additionalProperties: property.additionalProperties } : {}),
199
+ };
200
+ }
201
+ function validateToolDefinition(tool) {
202
+ if (!/^[a-z][a-z0-9_]*$/.test(tool.name)) {
203
+ throw new Error(`Invalid tool name: ${tool.name}`);
204
+ }
205
+ if (tool.description.trim().length === 0) {
206
+ throw new Error(`Tool ${tool.name} must include a description`);
207
+ }
208
+ if (!Number.isSafeInteger(tool.outputLimit.maxBytes) || tool.outputLimit.maxBytes <= 0) {
209
+ throw new Error(`Tool ${tool.name} must define a positive outputLimit.maxBytes`);
210
+ }
211
+ if (tool.permissionPolicy.reason.trim().length === 0) {
212
+ throw new Error(`Tool ${tool.name} must explain its permission policy`);
213
+ }
214
+ if (tool.outputSchema.type !== 'object') {
215
+ throw new Error(`Tool ${tool.name} must define an object outputSchema`);
216
+ }
217
+ }
218
+ const readonlyPermissionPolicy = {
219
+ mode: 'ask',
220
+ requirements: ['filesystem-read'],
221
+ reason: 'Reads local filesystem content. Requires read-only permission mode or explicit approval before execution.',
222
+ };
223
+ const readonlyCommandPermissionPolicy = {
224
+ mode: 'ask',
225
+ requirements: ['shell-exec', 'filesystem-read'],
226
+ reason: 'Executes a conservative allowlist of read-only local inspection commands. Requires read-only permission mode or explicit approval.',
227
+ };
228
+ const executeShellPermissionPolicy = {
229
+ mode: 'ask',
230
+ requirements: ['shell-exec'],
231
+ reason: 'Executes local shell-style commands without using a general shell. Requires explicit approval unless full access or a narrow saved rule applies.',
232
+ };
233
+ const agentStatePermissionPolicy = {
234
+ mode: 'allow',
235
+ requirements: ['agent-state'],
236
+ reason: 'Updates Remi task state only. Does not read or modify the filesystem.',
237
+ };
238
+ const readOnlyCommandAllowlistDescription = 'Allowed: pwd and safe read-only forms of common local inspection commands such as ls, cat, head, tail, wc, file, stat, du, df, grep, find, rg, ps, uname, date, whoami, id, which/where, sort, uniq, cut, nl, diff, cmp, comm, strings, hexdump, od, xxd, basename, dirname, realpath, readlink, tar list, uptime, hostname, ifconfig, ip, ipconfig, getmac, netstat, ss, lsof, arp, and read-only git subcommands. ' +
239
+ 'Not allowed: cd, go/go run/go test/go mod, npm, pnpm, node, python, bash, sh, package managers, interpreters, build/test commands, write commands, curl/wget downloads, pipes, redirects, chained commands, backgrounding, command substitution, or dangerous flags.';
240
+ const writePermissionPolicy = {
241
+ mode: 'ask',
242
+ requirements: ['filesystem-write'],
243
+ reason: 'Writes local filesystem content. Requires auto permission mode or explicit approval before execution.',
244
+ };
245
+ const deletePermissionPolicy = {
246
+ mode: 'ask',
247
+ requirements: ['filesystem-write'],
248
+ reason: 'Deletes a local file. Requires explicit approval unless full-access is enabled.',
249
+ };
250
+ const pathProperty = {
251
+ type: 'string',
252
+ description: 'Path relative to the current working directory. Absolute paths are rejected by the future executor unless explicitly approved.',
253
+ };
254
+ const maxBytesProperty = {
255
+ type: 'integer',
256
+ description: 'Maximum bytes to return before truncation. The executor will clamp this to the tool output limit.',
257
+ };
258
+ export const readFileTool = {
259
+ name: 'read_file',
260
+ description: 'Read UTF-8 text from a local file without modifying it.',
261
+ inputSchema: {
262
+ type: 'object',
263
+ properties: {
264
+ path: pathProperty,
265
+ startLine: { type: 'integer', description: 'Optional 1-based first line to include.' },
266
+ endLine: { type: 'integer', description: 'Optional 1-based last line to include.' },
267
+ maxBytes: maxBytesProperty,
268
+ },
269
+ required: ['path'],
270
+ additionalProperties: false,
271
+ },
272
+ outputSchema: {
273
+ type: 'object',
274
+ properties: {
275
+ path: { type: 'string', description: 'Normalized path that was read.' },
276
+ content: { type: 'string', description: 'UTF-8 file content or requested line range.' },
277
+ bytes: { type: 'integer', description: 'Number of bytes returned.' },
278
+ truncated: { type: 'boolean', description: 'Whether content was truncated by output limits.' },
279
+ },
280
+ required: ['path', 'content', 'bytes', 'truncated'],
281
+ additionalProperties: false,
282
+ },
283
+ riskLevel: 'read',
284
+ permissionPolicy: readonlyPermissionPolicy,
285
+ outputLimit: { maxBytes: 128 * 1024, truncate: 'tail' },
286
+ };
287
+ export const listFilesTool = {
288
+ name: 'list_files',
289
+ description: 'List files and directories under a local project path without reading file contents.',
290
+ inputSchema: {
291
+ type: 'object',
292
+ properties: {
293
+ path: pathProperty,
294
+ recursive: { type: 'boolean', description: 'Whether to recursively traverse child directories.' },
295
+ includeHidden: { type: 'boolean', description: 'Whether to include dotfiles and hidden directories.' },
296
+ maxEntries: { type: 'integer', description: 'Maximum number of entries to return.' },
297
+ },
298
+ required: ['path'],
299
+ additionalProperties: false,
300
+ },
301
+ outputSchema: {
302
+ type: 'object',
303
+ properties: {
304
+ path: { type: 'string', description: 'Normalized listed path.' },
305
+ entries: {
306
+ type: 'array',
307
+ description: 'Directory entries.',
308
+ items: {
309
+ type: 'object',
310
+ description: 'A single file or directory entry.',
311
+ properties: {
312
+ path: { type: 'string', description: 'Entry path relative to cwd.' },
313
+ type: { type: 'string', description: 'Entry kind.', enum: ['file', 'directory', 'symlink', 'other'] },
314
+ sizeBytes: { type: 'integer', description: 'File size in bytes when available.' },
315
+ },
316
+ required: ['path', 'type'],
317
+ additionalProperties: false,
318
+ },
319
+ },
320
+ truncated: { type: 'boolean', description: 'Whether entries were truncated by maxEntries or output limits.' },
321
+ },
322
+ required: ['path', 'entries', 'truncated'],
323
+ additionalProperties: false,
324
+ },
325
+ riskLevel: 'read',
326
+ permissionPolicy: readonlyPermissionPolicy,
327
+ outputLimit: { maxBytes: 64 * 1024, truncate: 'tail' },
328
+ };
329
+ export const searchTextTool = {
330
+ name: 'search_text',
331
+ description: 'Search inside local project file contents without modifying them. Do not use for file names, extensions, or glob/path patterns.',
332
+ inputSchema: {
333
+ type: 'object',
334
+ properties: {
335
+ query: { type: 'string', description: 'Plain text or regex content query, depending on isRegex. Not a file name, wildcard, extension, or path glob.' },
336
+ path: pathProperty,
337
+ includeGlob: { type: 'string', description: 'Optional glob pattern limiting searched files.' },
338
+ isRegex: { type: 'boolean', description: 'Whether query should be treated as a regular expression.' },
339
+ caseSensitive: { type: 'boolean', description: 'Whether matching is case-sensitive.' },
340
+ maxResults: { type: 'integer', description: 'Maximum number of matches to return.' },
341
+ },
342
+ required: ['query'],
343
+ additionalProperties: false,
344
+ },
345
+ outputSchema: {
346
+ type: 'object',
347
+ properties: {
348
+ matches: {
349
+ type: 'array',
350
+ description: 'Text search matches.',
351
+ items: {
352
+ type: 'object',
353
+ description: 'A single match.',
354
+ properties: {
355
+ path: { type: 'string', description: 'Matched file path relative to cwd.' },
356
+ line: { type: 'integer', description: '1-based line number.' },
357
+ column: { type: 'integer', description: '1-based column number.' },
358
+ text: { type: 'string', description: 'Matching line preview.' },
359
+ },
360
+ required: ['path', 'line', 'column', 'text'],
361
+ additionalProperties: false,
362
+ },
363
+ },
364
+ truncated: { type: 'boolean', description: 'Whether matches were truncated by maxResults or output limits.' },
365
+ },
366
+ required: ['matches', 'truncated'],
367
+ additionalProperties: false,
368
+ },
369
+ riskLevel: 'read',
370
+ permissionPolicy: readonlyPermissionPolicy,
371
+ outputLimit: { maxBytes: 128 * 1024, truncate: 'tail' },
372
+ };
373
+ export const globTool = {
374
+ name: 'glob',
375
+ description: 'Find local project paths that match a glob pattern without reading file contents.',
376
+ inputSchema: {
377
+ type: 'object',
378
+ properties: {
379
+ pattern: { type: 'string', description: 'Glob pattern to match file or directory paths relative to cwd, such as **/*.ts, *.go, go.*, or **/main.go.' },
380
+ path: pathProperty,
381
+ includeHidden: { type: 'boolean', description: 'Whether to include dotfiles and hidden directories.' },
382
+ maxResults: { type: 'integer', description: 'Maximum number of paths to return.' },
383
+ },
384
+ required: ['pattern'],
385
+ additionalProperties: false,
386
+ },
387
+ outputSchema: {
388
+ type: 'object',
389
+ properties: {
390
+ matches: {
391
+ type: 'array',
392
+ description: 'Matched paths relative to cwd.',
393
+ items: { type: 'string', description: 'Matched path.' },
394
+ },
395
+ truncated: { type: 'boolean', description: 'Whether matches were truncated by maxResults or output limits.' },
396
+ },
397
+ required: ['matches', 'truncated'],
398
+ additionalProperties: false,
399
+ },
400
+ riskLevel: 'read',
401
+ permissionPolicy: readonlyPermissionPolicy,
402
+ outputLimit: { maxBytes: 64 * 1024, truncate: 'tail' },
403
+ };
404
+ export const todoWriteTool = {
405
+ name: 'todo_write',
406
+ description: 'Create or update the visible plan checklist for the current task. Send the complete current list each time.',
407
+ inputSchema: {
408
+ type: 'object',
409
+ properties: {
410
+ items: {
411
+ type: 'array',
412
+ description: 'Complete current plan items in display order.',
413
+ items: {
414
+ type: 'object',
415
+ description: 'One plan item.',
416
+ properties: {
417
+ id: { type: 'string', description: 'Optional stable item id.' },
418
+ content: { type: 'string', description: 'Short user-visible plan item text.' },
419
+ status: { type: 'string', description: 'Current item status.', enum: ['pending', 'in_progress', 'completed'] },
420
+ },
421
+ required: ['content', 'status'],
422
+ additionalProperties: false,
423
+ },
424
+ },
425
+ },
426
+ required: ['items'],
427
+ additionalProperties: false,
428
+ },
429
+ outputSchema: {
430
+ type: 'object',
431
+ properties: {
432
+ items: {
433
+ type: 'array',
434
+ description: 'Normalized plan items.',
435
+ items: {
436
+ type: 'object',
437
+ description: 'One normalized plan item.',
438
+ properties: {
439
+ id: { type: 'string', description: 'Stable item id when provided.' },
440
+ content: { type: 'string', description: 'Plan item text.' },
441
+ status: { type: 'string', description: 'Current item status.', enum: ['pending', 'in_progress', 'completed'] },
442
+ },
443
+ required: ['content', 'status'],
444
+ additionalProperties: false,
445
+ },
446
+ },
447
+ },
448
+ required: ['items'],
449
+ additionalProperties: false,
450
+ },
451
+ riskLevel: 'read',
452
+ permissionPolicy: agentStatePermissionPolicy,
453
+ outputLimit: { maxBytes: 32 * 1024, truncate: 'tail' },
454
+ };
455
+ export const createDirectoryTool = {
456
+ name: 'create_directory',
457
+ description: 'Create a local directory. Does not write file contents.',
458
+ inputSchema: {
459
+ type: 'object',
460
+ properties: {
461
+ path: pathProperty,
462
+ recursive: { type: 'boolean', description: 'Whether to create missing parent directories. Defaults to true.' },
463
+ },
464
+ required: ['path'],
465
+ additionalProperties: false,
466
+ },
467
+ outputSchema: {
468
+ type: 'object',
469
+ properties: {
470
+ path: { type: 'string', description: 'Normalized created path.' },
471
+ created: { type: 'boolean', description: 'Whether the directory did not exist before this call.' },
472
+ },
473
+ required: ['path', 'created'],
474
+ additionalProperties: false,
475
+ },
476
+ riskLevel: 'write',
477
+ permissionPolicy: writePermissionPolicy,
478
+ outputLimit: { maxBytes: 16 * 1024, truncate: 'tail' },
479
+ };
480
+ export const writeFileTool = {
481
+ name: 'write_file',
482
+ description: 'Write UTF-8 text to a local file. Refuses to overwrite existing files unless overwrite is true and the file was read first.',
483
+ inputSchema: {
484
+ type: 'object',
485
+ properties: {
486
+ path: pathProperty,
487
+ content: { type: 'string', description: 'UTF-8 text content to write.' },
488
+ overwrite: { type: 'boolean', description: 'Whether to overwrite an existing file. Defaults to false.' },
489
+ createDirectories: { type: 'boolean', description: 'Whether to create missing parent directories. Defaults to true.' },
490
+ },
491
+ required: ['path', 'content'],
492
+ additionalProperties: false,
493
+ },
494
+ outputSchema: {
495
+ type: 'object',
496
+ properties: {
497
+ path: { type: 'string', description: 'Normalized written path.' },
498
+ bytes: { type: 'integer', description: 'Number of bytes written.' },
499
+ created: { type: 'boolean', description: 'Whether this call created a new file.' },
500
+ overwritten: { type: 'boolean', description: 'Whether this call overwrote an existing file.' },
501
+ addedLines: { type: 'integer', description: 'Number of added lines in the write diff.' },
502
+ removedLines: { type: 'integer', description: 'Number of removed lines in the write diff.' },
503
+ diffPreview: { type: 'string', description: 'A bounded unified-style diff preview for user display.' },
504
+ },
505
+ required: ['path', 'bytes', 'created', 'overwritten'],
506
+ additionalProperties: false,
507
+ },
508
+ riskLevel: 'write',
509
+ permissionPolicy: writePermissionPolicy,
510
+ outputLimit: { maxBytes: 16 * 1024, truncate: 'tail' },
511
+ };
512
+ export const editFileTool = {
513
+ name: 'edit_file',
514
+ description: 'Modify an existing UTF-8 text file by exact string replacement. Requires a prior full read_file result for the same file.',
515
+ inputSchema: {
516
+ type: 'object',
517
+ properties: {
518
+ path: pathProperty,
519
+ oldString: { type: 'string', description: 'Exact existing text to replace. Must match the current file content.' },
520
+ newString: { type: 'string', description: 'Replacement text.' },
521
+ replaceAll: { type: 'boolean', description: 'Whether to replace every occurrence. Defaults to false and requires a unique match.' },
522
+ },
523
+ required: ['path', 'oldString', 'newString'],
524
+ additionalProperties: false,
525
+ },
526
+ outputSchema: {
527
+ type: 'object',
528
+ properties: {
529
+ path: { type: 'string', description: 'Normalized edited path.' },
530
+ bytes: { type: 'integer', description: 'Number of bytes written after the edit.' },
531
+ addedLines: { type: 'integer', description: 'Number of added lines in the edit diff.' },
532
+ removedLines: { type: 'integer', description: 'Number of removed lines in the edit diff.' },
533
+ diffPreview: { type: 'string', description: 'A bounded unified-style diff preview for user display.' },
534
+ },
535
+ required: ['path', 'bytes', 'addedLines', 'removedLines', 'diffPreview'],
536
+ additionalProperties: false,
537
+ },
538
+ riskLevel: 'write',
539
+ permissionPolicy: writePermissionPolicy,
540
+ outputLimit: { maxBytes: 16 * 1024, truncate: 'tail' },
541
+ };
542
+ export const deleteFileTool = {
543
+ name: 'delete_file',
544
+ description: 'Delete one local file after permission review. Does not delete directories; use this instead of rm for user-requested file deletion.',
545
+ inputSchema: {
546
+ type: 'object',
547
+ properties: {
548
+ path: pathProperty,
549
+ },
550
+ required: ['path'],
551
+ additionalProperties: false,
552
+ },
553
+ outputSchema: {
554
+ type: 'object',
555
+ properties: {
556
+ path: { type: 'string', description: 'Normalized deleted path.' },
557
+ deleted: { type: 'boolean', description: 'Whether the file was deleted.' },
558
+ bytes: { type: 'integer', description: 'Number of bytes deleted.' },
559
+ addedLines: { type: 'integer', description: 'Number of added lines in the deletion diff. Always 0 for a deleted file.' },
560
+ removedLines: { type: 'integer', description: 'Number of removed lines in the deletion diff.' },
561
+ diffPreview: { type: 'string', description: 'A bounded diff preview for user display.' },
562
+ },
563
+ required: ['path', 'deleted', 'bytes', 'addedLines', 'removedLines', 'diffPreview'],
564
+ additionalProperties: false,
565
+ },
566
+ riskLevel: 'write',
567
+ permissionPolicy: deletePermissionPolicy,
568
+ outputLimit: { maxBytes: 16 * 1024, truncate: 'tail' },
569
+ };
570
+ export const deleteDirectoryTool = {
571
+ name: 'delete_directory',
572
+ description: 'Delete one empty local directory after permission review. Refuses non-empty directories and files; use this instead of rmdir for user-requested empty directory deletion.',
573
+ inputSchema: {
574
+ type: 'object',
575
+ properties: {
576
+ path: pathProperty,
577
+ },
578
+ required: ['path'],
579
+ additionalProperties: false,
580
+ },
581
+ outputSchema: {
582
+ type: 'object',
583
+ properties: {
584
+ path: { type: 'string', description: 'Normalized deleted directory path.' },
585
+ deleted: { type: 'boolean', description: 'Whether the empty directory was deleted.' },
586
+ },
587
+ required: ['path', 'deleted'],
588
+ additionalProperties: false,
589
+ },
590
+ riskLevel: 'write',
591
+ permissionPolicy: deletePermissionPolicy,
592
+ outputLimit: { maxBytes: 16 * 1024, truncate: 'tail' },
593
+ };
594
+ export const runCommandTool = {
595
+ name: 'run_command',
596
+ description: `Run one conservative read-only local inspection command without a shell. ${readOnlyCommandAllowlistDescription}`,
597
+ inputSchema: {
598
+ type: 'object',
599
+ description: readOnlyCommandAllowlistDescription,
600
+ properties: {
601
+ command: {
602
+ type: 'string',
603
+ description: `A simple allowlisted command line. ${readOnlyCommandAllowlistDescription}`,
604
+ },
605
+ timeoutMs: { type: 'integer', description: 'Optional timeout in milliseconds. Clamped to a safe maximum.' },
606
+ maxBytes: maxBytesProperty,
607
+ },
608
+ required: ['command'],
609
+ additionalProperties: false,
610
+ },
611
+ outputSchema: {
612
+ type: 'object',
613
+ properties: {
614
+ command: { type: 'string', description: 'Command that was executed.' },
615
+ exitCode: { type: 'integer', description: 'Process exit code, or -1 when terminated by timeout or abort.' },
616
+ stdout: { type: 'string', description: 'Captured standard output.' },
617
+ stderr: { type: 'string', description: 'Captured standard error.' },
618
+ bytes: { type: 'integer', description: 'Total captured output bytes.' },
619
+ timedOut: { type: 'boolean', description: 'Whether execution was terminated by timeout.' },
620
+ truncated: { type: 'boolean', description: 'Whether output was truncated by output limits.' },
621
+ },
622
+ required: ['command', 'exitCode', 'stdout', 'stderr', 'bytes', 'timedOut', 'truncated'],
623
+ additionalProperties: false,
624
+ },
625
+ riskLevel: 'execute',
626
+ permissionPolicy: readonlyCommandPermissionPolicy,
627
+ outputLimit: { maxBytes: 128 * 1024, truncate: 'tail' },
628
+ };
629
+ export const executeShellTool = {
630
+ name: 'execute_shell',
631
+ description: 'Run a local validation/build, install/download, or side-effect command after permission review. Supports simple commands, safe cd segments, and sequential && or ; segments, executed without a general shell. Use this for go test, pnpm test, build, package managers, curl/wget downloads, sed/cp/mv/rm/rmdir/sh, scripts, and other execution commands that are outside read-only run_command.',
632
+ inputSchema: {
633
+ type: 'object',
634
+ properties: {
635
+ command: {
636
+ type: 'string',
637
+ description: 'Command to execute after approval. Supported syntax is simple argv, safe cd segments, plus && or ; sequencing. Pipes, redirects, backgrounding, command substitution, environment assignments, and shell glob expansion are rejected.',
638
+ },
639
+ cwd: { type: 'string', description: 'Optional working directory for the command. Must resolve inside allowed read roots.' },
640
+ timeoutMs: { type: 'integer', description: 'Optional timeout in milliseconds. Clamped to a safe maximum.' },
641
+ maxBytes: maxBytesProperty,
642
+ },
643
+ required: ['command'],
644
+ additionalProperties: false,
645
+ },
646
+ outputSchema: {
647
+ type: 'object',
648
+ properties: {
649
+ command: { type: 'string', description: 'Original command line.' },
650
+ cwd: { type: 'string', description: 'Working directory where the command ran.' },
651
+ exitCode: { type: 'integer', description: 'Final process exit code, or -1 when terminated by timeout or abort.' },
652
+ stdout: { type: 'string', description: 'Combined captured standard output.' },
653
+ stderr: { type: 'string', description: 'Combined captured standard error.' },
654
+ bytes: { type: 'integer', description: 'Total captured output bytes.' },
655
+ timedOut: { type: 'boolean', description: 'Whether any segment was terminated by timeout.' },
656
+ truncated: { type: 'boolean', description: 'Whether output was truncated by output limits.' },
657
+ segments: {
658
+ type: 'array',
659
+ description: 'Executed command segments.',
660
+ items: {
661
+ type: 'object',
662
+ description: 'One command segment result.',
663
+ properties: {
664
+ command: { type: 'string', description: 'Segment command.' },
665
+ exitCode: { type: 'integer', description: 'Segment exit code.' },
666
+ stdout: { type: 'string', description: 'Segment stdout.' },
667
+ stderr: { type: 'string', description: 'Segment stderr.' },
668
+ timedOut: { type: 'boolean', description: 'Whether the segment timed out.' },
669
+ },
670
+ required: ['command', 'exitCode', 'stdout', 'stderr', 'timedOut'],
671
+ additionalProperties: false,
672
+ },
673
+ },
674
+ },
675
+ required: ['command', 'cwd', 'exitCode', 'stdout', 'stderr', 'bytes', 'timedOut', 'truncated', 'segments'],
676
+ additionalProperties: false,
677
+ },
678
+ riskLevel: 'execute',
679
+ permissionPolicy: executeShellPermissionPolicy,
680
+ outputLimit: { maxBytes: 128 * 1024, truncate: 'tail' },
681
+ };
682
+ export const readOnlyTools = [readFileTool, listFilesTool, searchTextTool, globTool, runCommandTool];
683
+ export const builtInTools = [
684
+ readFileTool,
685
+ listFilesTool,
686
+ searchTextTool,
687
+ globTool,
688
+ todoWriteTool,
689
+ createDirectoryTool,
690
+ editFileTool,
691
+ writeFileTool,
692
+ deleteFileTool,
693
+ deleteDirectoryTool,
694
+ runCommandTool,
695
+ executeShellTool,
696
+ ];
697
+ export function createReadOnlyToolRegistry() {
698
+ return createToolRegistry([...readOnlyTools]);
699
+ }
700
+ export function createBuiltInToolRegistry() {
701
+ return createToolRegistry([...builtInTools]);
702
+ }
703
+ export function createReadOnlyDryRunExecutor(registry = createReadOnlyToolRegistry(), evaluatePermission = evaluateToolPermission) {
704
+ return {
705
+ async execute(request) {
706
+ const tool = registry.get(request.call.toolName);
707
+ if (!tool) {
708
+ const decision = {
709
+ status: 'deny',
710
+ reason: `Unknown tool: ${request.call.toolName}`,
711
+ requirements: [],
712
+ };
713
+ const result = {
714
+ ok: false,
715
+ callId: request.call.id,
716
+ toolName: request.call.toolName,
717
+ error: {
718
+ code: 'UNKNOWN_TOOL',
719
+ message: decision.reason,
720
+ },
721
+ };
722
+ return {
723
+ dryRun: true,
724
+ callId: request.call.id,
725
+ toolName: request.call.toolName,
726
+ permissionDecision: decision,
727
+ transcriptEvents: [toolResultTranscriptEvent(result)],
728
+ result,
729
+ };
730
+ }
731
+ const permissionRequest = createToolPermissionRequest(tool, request.call, request.context);
732
+ const permissionDecision = evaluatePermission(permissionRequest);
733
+ const result = dryRunResult(tool, request.call, permissionDecision);
734
+ return {
735
+ dryRun: true,
736
+ callId: request.call.id,
737
+ toolName: request.call.toolName,
738
+ permissionRequest,
739
+ permissionDecision,
740
+ transcriptEvents: [
741
+ {
742
+ type: 'tool_call',
743
+ callId: request.call.id,
744
+ toolName: request.call.toolName,
745
+ input: request.call.input,
746
+ riskLevel: tool.riskLevel,
747
+ permissionPolicy: tool.permissionPolicy,
748
+ status: permissionDecision.status === 'deny' ? 'denied' : permissionDecision.status === 'ask' ? 'pending-permission' : 'dry-run',
749
+ },
750
+ toolResultTranscriptEvent(result),
751
+ ],
752
+ result,
753
+ };
754
+ },
755
+ };
756
+ }
757
+ export function createReadOnlyFileSystemExecutor(registry = createReadOnlyToolRegistry(), evaluatePermission = evaluateToolPermission) {
758
+ return createLocalToolExecutor(registry, evaluatePermission);
759
+ }
760
+ export function createLocalToolExecutor(registry = createBuiltInToolRegistry(), evaluatePermission = evaluateToolPermission) {
761
+ return {
762
+ async execute(request) {
763
+ const tool = registry.get(request.call.toolName);
764
+ if (!tool) {
765
+ return unknownToolOutcome(request);
766
+ }
767
+ const permissionRequest = createToolPermissionRequest(tool, request.call, request.context);
768
+ const initialDecision = evaluatePermission(permissionRequest);
769
+ let permissionDecision = await applyLocalPermissionMode(tool, permissionRequest, request.call, request.context, initialDecision);
770
+ if (permissionDecision.status === 'ask' && request.context.permissionProfile === 'auto-review') {
771
+ permissionDecision = autoReviewToolPermission(permissionRequest) ?? permissionDecision;
772
+ }
773
+ if (permissionDecision.status === 'ask' && commandPermissionRulesAllow(permissionRequest, request.context.permissionRules ?? [])) {
774
+ permissionDecision = {
775
+ status: 'allow',
776
+ reason: 'A saved command-prefix permission rule allows this request.',
777
+ requirements: permissionDecision.requirements,
778
+ };
779
+ }
780
+ if (permissionDecision.status === 'ask' && await filesystemPermissionRulesAllow(request.call, request.context, request.context.permissionRules ?? [])) {
781
+ permissionDecision = {
782
+ status: 'allow',
783
+ reason: 'A saved filesystem directory permission rule allows this request.',
784
+ requirements: permissionDecision.requirements,
785
+ };
786
+ }
787
+ if (permissionDecision.status === 'ask' && request.context.requestPermission) {
788
+ try {
789
+ permissionDecision = await request.context.requestPermission(permissionRequest, permissionDecision);
790
+ }
791
+ catch (error) {
792
+ permissionDecision = {
793
+ status: 'deny',
794
+ reason: error instanceof Error ? error.message : 'Permission request was rejected.',
795
+ requirements: permissionDecision.requirements,
796
+ };
797
+ }
798
+ }
799
+ const callEvent = {
800
+ type: 'tool_call',
801
+ callId: request.call.id,
802
+ toolName: request.call.toolName,
803
+ input: request.call.input,
804
+ riskLevel: tool.riskLevel,
805
+ permissionPolicy: tool.permissionPolicy,
806
+ status: permissionDecision.status === 'deny' ? 'denied' : 'running',
807
+ };
808
+ if (permissionDecision.status !== 'allow') {
809
+ const result = {
810
+ ok: false,
811
+ callId: request.call.id,
812
+ toolName: tool.name,
813
+ error: {
814
+ code: deniedToolErrorCode(tool.name, permissionDecision),
815
+ message: permissionDecision.reason,
816
+ },
817
+ };
818
+ return {
819
+ dryRun: false,
820
+ callId: request.call.id,
821
+ toolName: tool.name,
822
+ permissionRequest,
823
+ permissionDecision,
824
+ transcriptEvents: [{ ...callEvent, status: 'denied' }, toolResultTranscriptEvent(result)],
825
+ result,
826
+ };
827
+ }
828
+ const result = await executeLocalTool(tool.name, await requestWithApprovedWriteScope(request, permissionDecision));
829
+ return {
830
+ dryRun: false,
831
+ callId: request.call.id,
832
+ toolName: tool.name,
833
+ permissionRequest,
834
+ permissionDecision,
835
+ transcriptEvents: [callEvent, toolResultTranscriptEvent(result)],
836
+ result,
837
+ };
838
+ },
839
+ };
840
+ }
841
+ function deniedToolErrorCode(toolName, decision) {
842
+ if (decision.status === 'ask') {
843
+ return 'PERMISSION_REQUIRED';
844
+ }
845
+ if ((toolName === 'run_command' || toolName === 'execute_shell') && isCommandPolicyDenial(decision.reason)) {
846
+ return 'COMMAND_DENIED';
847
+ }
848
+ return 'PERMISSION_DENIED';
849
+ }
850
+ function isCommandPolicyDenial(reason) {
851
+ return [
852
+ 'Command must not be empty.',
853
+ 'Command is too long.',
854
+ 'Multi-line commands are not allowed.',
855
+ 'Unclosed quote in command.',
856
+ 'Shell command cannot end with an operator.',
857
+ 'Shell command contains an empty segment.',
858
+ 'Shell operators, redirects, backgrounding, and command substitution are not allowed.',
859
+ 'Backgrounding is not supported by execute_shell.',
860
+ 'Pipes are not supported by execute_shell.',
861
+ 'Shell redirects are not supported by execute_shell.',
862
+ 'Command substitution and shell variable expansion are not supported by execute_shell.',
863
+ 'Environment variable assignments are not supported by execute_shell.',
864
+ 'Command must be an allowlisted executable name, not a path.',
865
+ 'Executable paths must be relative to the current workspace',
866
+ 'Command is blocked by Remi safety policy:',
867
+ 'Dangerous removal target is not allowed through execute_shell:',
868
+ 'Command is not in the read-only allowlist:',
869
+ 'Only read-only git subcommands are allowed',
870
+ 'Executable path is outside supported relative workspace form:',
871
+ 'cd supports at most one target directory in execute_shell.',
872
+ 'cd - is not supported by execute_shell.',
873
+ 'cd flags are not supported by execute_shell.',
874
+ 'run_command requires a string command.',
875
+ 'execute_shell requires a string command.',
876
+ 'Flag is not allowed',
877
+ 'Flag is not in the read-only allowlist',
878
+ 'Flag value is not in the read-only allowlist',
879
+ 'Argument is not in the read-only allowlist',
880
+ 'Flag is blocked in read-only mode',
881
+ 'Streaming flags are not allowed',
882
+ 'read-only mode allows',
883
+ 'Only tar list mode is allowed in read-only mode.',
884
+ 'tar list mode must name an archive file with -f.',
885
+ 'pwd does not accept arguments in read-only mode.',
886
+ ].some(pattern => reason.includes(pattern));
887
+ }
888
+ export function createToolPermissionRequest(tool, call, context) {
889
+ const command = buildToolPermissionCommand(tool.name, call.input, context);
890
+ const targetPath = permissionTargetPath(call.input);
891
+ const suggestedRules = buildFilesystemPermissionRules(tool.name, call.input, context);
892
+ return {
893
+ kind: 'tool',
894
+ toolName: tool.name,
895
+ riskLevel: tool.riskLevel,
896
+ policy: tool.permissionPolicy,
897
+ cwd: context.cwd,
898
+ sessionId: context.sessionId,
899
+ inputSummary: summarizeToolInput(call.input),
900
+ ...(targetPath ? { targetPath } : {}),
901
+ ...(command ? { command } : {}),
902
+ ...(suggestedRules.length > 0 ? { suggestedRules, canPersist: true } : {}),
903
+ };
904
+ }
905
+ function permissionTargetPath(input) {
906
+ const path = input['path'];
907
+ return typeof path === 'string' && path.trim().length > 0 ? path : undefined;
908
+ }
909
+ function dryRunResult(tool, call, decision) {
910
+ const summary = `Dry run only: ${tool.name} was not executed. Permission decision: ${decision.status}.`;
911
+ return {
912
+ ok: true,
913
+ callId: call.id,
914
+ toolName: tool.name,
915
+ output: JSON.stringify({
916
+ dryRun: true,
917
+ toolName: tool.name,
918
+ permissionDecision: decision.status,
919
+ message: summary,
920
+ }),
921
+ truncated: false,
922
+ bytes: Buffer.byteLength(summary, 'utf8'),
923
+ };
924
+ }
925
+ function toolResultTranscriptEvent(result) {
926
+ if (result.ok) {
927
+ return {
928
+ type: 'tool_result',
929
+ callId: result.callId,
930
+ toolName: result.toolName,
931
+ ok: true,
932
+ summary: result.output,
933
+ bytes: result.bytes,
934
+ truncated: result.truncated,
935
+ };
936
+ }
937
+ return {
938
+ type: 'tool_result',
939
+ callId: result.callId,
940
+ toolName: result.toolName,
941
+ ok: false,
942
+ summary: result.error.message,
943
+ errorCode: result.error.code,
944
+ };
945
+ }
946
+ function unknownToolOutcome(request) {
947
+ const decision = {
948
+ status: 'deny',
949
+ reason: `Unknown tool: ${request.call.toolName}`,
950
+ requirements: [],
951
+ };
952
+ const result = {
953
+ ok: false,
954
+ callId: request.call.id,
955
+ toolName: request.call.toolName,
956
+ error: {
957
+ code: 'UNKNOWN_TOOL',
958
+ message: decision.reason,
959
+ },
960
+ };
961
+ return {
962
+ dryRun: false,
963
+ callId: request.call.id,
964
+ toolName: request.call.toolName,
965
+ permissionDecision: decision,
966
+ transcriptEvents: [toolResultTranscriptEvent(result)],
967
+ result,
968
+ };
969
+ }
970
+ async function applyLocalPermissionMode(tool, permissionRequest, call, context, decision) {
971
+ const mode = context.permissionMode ?? 'ask';
972
+ if (tool.name === 'run_command') {
973
+ const validation = validateReadOnlyCommandInput(call.input);
974
+ if (!validation.ok) {
975
+ return {
976
+ status: 'deny',
977
+ reason: validation.reason,
978
+ requirements: tool.permissionPolicy.requirements,
979
+ };
980
+ }
981
+ if (decision.status === 'ask' && (mode === 'readonly' || mode === 'auto' || mode === 'bypass')) {
982
+ return {
983
+ status: 'allow',
984
+ reason: `${mode} permission mode allows validated read-only local inspection commands.`,
985
+ requirements: decision.requirements,
986
+ };
987
+ }
988
+ }
989
+ if (tool.name === 'execute_shell') {
990
+ const validation = validateExecuteShellInput(call.input, context.cwd);
991
+ if (!validation.ok) {
992
+ return {
993
+ status: 'deny',
994
+ reason: validation.reason,
995
+ requirements: tool.permissionPolicy.requirements,
996
+ };
997
+ }
998
+ if (decision.status === 'ask' && mode === 'bypass') {
999
+ return {
1000
+ status: 'allow',
1001
+ reason: 'full-access permission profile allows local command execution without prompting.',
1002
+ requirements: decision.requirements,
1003
+ };
1004
+ }
1005
+ }
1006
+ if (tool.name === 'delete_file' || tool.name === 'delete_directory') {
1007
+ const writeScope = await writableScopeForCall(call, context);
1008
+ if (!writeScope.ok) {
1009
+ return {
1010
+ status: 'deny',
1011
+ reason: writeScope.reason,
1012
+ requirements: decision.requirements,
1013
+ };
1014
+ }
1015
+ if (decision.status === 'ask' && mode === 'bypass') {
1016
+ return {
1017
+ status: 'allow',
1018
+ reason: `full-access permission profile allows this ${tool.name === 'delete_file' ? 'file' : 'directory'} deletion without prompting.`,
1019
+ requirements: decision.requirements,
1020
+ };
1021
+ }
1022
+ return {
1023
+ status: 'ask',
1024
+ reason: `${tool.name === 'delete_file' ? 'Deleting a file' : 'Deleting an empty directory'} requires approval: ${writeScope.displayPath}`,
1025
+ requirements: decision.requirements,
1026
+ };
1027
+ }
1028
+ if (decision.status !== 'ask') {
1029
+ return decision;
1030
+ }
1031
+ const isReadOnlyTool = tool.riskLevel === 'read' && tool.permissionPolicy.requirements.every(requirement => requirement === 'filesystem-read');
1032
+ if (isReadOnlyTool && (mode === 'readonly' || mode === 'auto' || mode === 'bypass')) {
1033
+ return {
1034
+ status: 'allow',
1035
+ reason: `${mode} permission mode allows read-only filesystem tools.`,
1036
+ requirements: decision.requirements,
1037
+ };
1038
+ }
1039
+ const isWriteTool = tool.riskLevel === 'write' && tool.permissionPolicy.requirements.every(requirement => requirement === 'filesystem-write');
1040
+ if (isWriteTool && (mode === 'auto' || mode === 'bypass')) {
1041
+ const writeScope = await writableScopeForCall(call, context);
1042
+ if (!writeScope.ok) {
1043
+ return {
1044
+ status: 'deny',
1045
+ reason: writeScope.reason,
1046
+ requirements: decision.requirements,
1047
+ };
1048
+ }
1049
+ if (writeScope.outsideAllowedRoots && mode !== 'bypass') {
1050
+ return {
1051
+ status: 'ask',
1052
+ reason: `Writing outside the current workspace requires approval: ${writeScope.displayPath}`,
1053
+ requirements: decision.requirements,
1054
+ };
1055
+ }
1056
+ return {
1057
+ status: 'allow',
1058
+ reason: writeScope.outsideAllowedRoots
1059
+ ? 'full-access permission profile allows this filesystem write without prompting.'
1060
+ : `${mode} permission mode allows local filesystem writes inside the workspace.`,
1061
+ requirements: decision.requirements,
1062
+ };
1063
+ }
1064
+ return decision;
1065
+ }
1066
+ async function requestWithApprovedWriteScope(request, decision) {
1067
+ if (decision.status !== 'allow') {
1068
+ return request;
1069
+ }
1070
+ const writeScope = await writableScopeForCall(request.call, request.context);
1071
+ if (!writeScope.ok || !writeScope.outsideAllowedRoots) {
1072
+ return request;
1073
+ }
1074
+ return {
1075
+ ...request,
1076
+ context: {
1077
+ ...request.context,
1078
+ allowedWriteRoots: uniqueStrings([...(request.context.allowedWriteRoots ?? [request.context.cwd]), writeScope.approvalRoot]),
1079
+ },
1080
+ };
1081
+ }
1082
+ async function writableScopeForCall(call, context) {
1083
+ const inputPath = writablePathInput(call);
1084
+ if (inputPath === undefined) {
1085
+ return { ok: true, outsideAllowedRoots: false, approvalRoot: context.cwd, displayPath: '.' };
1086
+ }
1087
+ if (inputPath.trim().length === 0) {
1088
+ return { ok: false, reason: 'Path must not be empty.' };
1089
+ }
1090
+ const absolutePath = isAbsolute(inputPath) ? resolve(inputPath) : resolve(context.cwd, inputPath);
1091
+ const normalizedPath = await normalizeWritablePath(absolutePath);
1092
+ if (isSensitivePath(normalizedPath)) {
1093
+ return { ok: false, reason: `Refusing to write sensitive path: ${displayPath(normalizedPath, context.cwd)}` };
1094
+ }
1095
+ const allowedRoots = await Promise.all((context.allowedWriteRoots?.length ? context.allowedWriteRoots : [context.cwd]).map(root => safeRealRoot(root)));
1096
+ const outsideAllowedRoots = !allowedRoots.some(root => isPathInside(normalizedPath, root));
1097
+ return {
1098
+ ok: true,
1099
+ outsideAllowedRoots,
1100
+ approvalRoot: normalizedPath,
1101
+ displayPath: displayPath(normalizedPath, await safeRealRoot(context.cwd)),
1102
+ };
1103
+ }
1104
+ function writablePathInput(call) {
1105
+ if (call.toolName !== 'create_directory' && call.toolName !== 'edit_file' && call.toolName !== 'write_file' && call.toolName !== 'delete_file' && call.toolName !== 'delete_directory') {
1106
+ return undefined;
1107
+ }
1108
+ return typeof call.input['path'] === 'string' ? call.input['path'] : '';
1109
+ }
1110
+ function uniqueStrings(values) {
1111
+ return Array.from(new Set(values));
1112
+ }
1113
+ async function executeLocalTool(toolName, request) {
1114
+ try {
1115
+ if (toolName === 'read_file') {
1116
+ return await executeReadFile(request);
1117
+ }
1118
+ if (toolName === 'list_files') {
1119
+ return await executeListFiles(request);
1120
+ }
1121
+ if (toolName === 'search_text') {
1122
+ return await executeSearchText(request);
1123
+ }
1124
+ if (toolName === 'glob') {
1125
+ return await executeGlob(request);
1126
+ }
1127
+ if (toolName === 'todo_write') {
1128
+ return executeTodoWrite(request);
1129
+ }
1130
+ if (toolName === 'create_directory') {
1131
+ return await executeCreateDirectory(request);
1132
+ }
1133
+ if (toolName === 'edit_file') {
1134
+ return await executeEditFile(request);
1135
+ }
1136
+ if (toolName === 'write_file') {
1137
+ return await executeWriteFile(request);
1138
+ }
1139
+ if (toolName === 'delete_file') {
1140
+ return await executeDeleteFile(request);
1141
+ }
1142
+ if (toolName === 'delete_directory') {
1143
+ return await executeDeleteDirectory(request);
1144
+ }
1145
+ if (toolName === 'run_command') {
1146
+ return await executeRunCommand(request);
1147
+ }
1148
+ if (toolName === 'execute_shell') {
1149
+ return await executeShell(request);
1150
+ }
1151
+ return {
1152
+ ok: false,
1153
+ callId: request.call.id,
1154
+ toolName,
1155
+ error: { code: 'UNKNOWN_TOOL', message: `Unknown tool: ${toolName}` },
1156
+ };
1157
+ }
1158
+ catch (error) {
1159
+ return {
1160
+ ok: false,
1161
+ callId: request.call.id,
1162
+ toolName,
1163
+ error: normalizeToolError(error),
1164
+ };
1165
+ }
1166
+ }
1167
+ async function executeReadFile(request) {
1168
+ const input = request.call.input;
1169
+ const path = requiredString(input, 'path');
1170
+ const target = await resolveReadablePath(path, request.context, { requireFile: true, rejectSensitive: true });
1171
+ const stats = await stat(target.absolutePath);
1172
+ const content = await readFile(target.absolutePath, 'utf8');
1173
+ const startLine = optionalPositiveInteger(input, 'startLine');
1174
+ const endLine = optionalPositiveInteger(input, 'endLine');
1175
+ const rangedContent = sliceLineRange(content, startLine, endLine);
1176
+ const maxBytes = clampPositiveInteger(optionalPositiveInteger(input, 'maxBytes') ?? request.context.maxOutputBytes, 1, outputLimitFor('read_file', request.context));
1177
+ const payload = boundedReadFilePayload(target.displayPath, rangedContent, maxBytes, outputLimitFor('read_file', request.context));
1178
+ recordReadFileState(request.context, target, {
1179
+ content: startLine === undefined && endLine === undefined && !payload.truncated ? content : payload.content,
1180
+ mtimeMs: stats.mtimeMs,
1181
+ partial: startLine !== undefined || endLine !== undefined || payload.truncated,
1182
+ });
1183
+ return jsonToolResult(request, payload);
1184
+ }
1185
+ async function executeListFiles(request) {
1186
+ const input = request.call.input;
1187
+ const path = requiredString(input, 'path');
1188
+ const recursive = optionalBoolean(input, 'recursive') ?? false;
1189
+ const includeHidden = optionalBoolean(input, 'includeHidden') ?? false;
1190
+ const maxEntries = clampPositiveInteger(optionalPositiveInteger(input, 'maxEntries') ?? 200, 1, 5000);
1191
+ const target = await resolveReadablePath(path, request.context, { requireDirectory: true, rejectSensitive: false });
1192
+ const entries = await collectDirectoryEntries(target.absolutePath, request.context, { recursive, includeHidden, maxEntries });
1193
+ return jsonToolResult(request, {
1194
+ path: target.displayPath,
1195
+ entries: entries.items.map(item => ({
1196
+ path: displayPath(item.absolutePath, request.context.cwd),
1197
+ type: item.type,
1198
+ ...(item.sizeBytes !== undefined ? { sizeBytes: item.sizeBytes } : {}),
1199
+ })),
1200
+ truncated: entries.truncated,
1201
+ });
1202
+ }
1203
+ function boundedReadFilePayload(path, content, requestedMaxBytes, outputMaxBytes) {
1204
+ let contentMaxBytes = Math.max(1, Math.min(requestedMaxBytes, outputMaxBytes));
1205
+ let truncated = truncateStringBytes(content, contentMaxBytes, 'tail');
1206
+ let payload = readFilePayload(path, truncated.text, truncated.truncated);
1207
+ while (Buffer.byteLength(JSON.stringify(payload), 'utf8') > outputMaxBytes && contentMaxBytes > 1) {
1208
+ const overflow = Buffer.byteLength(JSON.stringify(payload), 'utf8') - outputMaxBytes;
1209
+ contentMaxBytes = Math.max(1, contentMaxBytes - overflow - 64);
1210
+ truncated = truncateStringBytes(content, contentMaxBytes, 'tail');
1211
+ payload = readFilePayload(path, truncated.text, true);
1212
+ }
1213
+ return payload;
1214
+ }
1215
+ function readFilePayload(path, content, truncated) {
1216
+ return {
1217
+ path,
1218
+ content,
1219
+ bytes: Buffer.byteLength(content, 'utf8'),
1220
+ truncated,
1221
+ };
1222
+ }
1223
+ async function executeSearchText(request) {
1224
+ const input = request.call.input;
1225
+ const query = requiredString(input, 'query');
1226
+ const path = optionalString(input, 'path') ?? '.';
1227
+ const includeGlob = optionalString(input, 'includeGlob');
1228
+ const isRegex = optionalBoolean(input, 'isRegex') ?? false;
1229
+ const caseSensitive = optionalBoolean(input, 'caseSensitive') ?? false;
1230
+ const maxResults = clampPositiveInteger(optionalPositiveInteger(input, 'maxResults') ?? 50, 1, 500);
1231
+ const target = await resolveReadablePath(path, request.context, { rejectSensitive: false });
1232
+ const matcher = createTextMatcher(query, { isRegex, caseSensitive });
1233
+ const includeMatcher = includeGlob ? createGlobMatcher(includeGlob) : undefined;
1234
+ const files = (await collectFiles(target.absolutePath, request.context, { includeHidden: false, maxEntries: 5000 })).items;
1235
+ const matches = [];
1236
+ for (const file of files) {
1237
+ if (matches.length >= maxResults) {
1238
+ break;
1239
+ }
1240
+ const fileDisplayPath = displayPath(file.absolutePath, request.context.cwd);
1241
+ if (includeMatcher && !includeMatcher(fileDisplayPath)) {
1242
+ continue;
1243
+ }
1244
+ if (isSensitivePath(file.absolutePath)) {
1245
+ continue;
1246
+ }
1247
+ let content = '';
1248
+ try {
1249
+ content = await readFile(file.absolutePath, 'utf8');
1250
+ }
1251
+ catch {
1252
+ continue;
1253
+ }
1254
+ const lines = content.split(/\r?\n/);
1255
+ for (let index = 0; index < lines.length && matches.length < maxResults; index += 1) {
1256
+ const match = matcher(lines[index] ?? '');
1257
+ if (!match) {
1258
+ continue;
1259
+ }
1260
+ matches.push({
1261
+ path: fileDisplayPath,
1262
+ line: index + 1,
1263
+ column: match.column,
1264
+ text: truncateStringBytes(match.text, 500, 'tail').text,
1265
+ });
1266
+ }
1267
+ }
1268
+ return jsonToolResult(request, {
1269
+ matches,
1270
+ truncated: matches.length >= maxResults,
1271
+ });
1272
+ }
1273
+ async function executeGlob(request) {
1274
+ const input = request.call.input;
1275
+ const pattern = requiredString(input, 'pattern');
1276
+ const path = optionalString(input, 'path') ?? '.';
1277
+ const includeHidden = optionalBoolean(input, 'includeHidden') ?? false;
1278
+ const maxResults = clampPositiveInteger(optionalPositiveInteger(input, 'maxResults') ?? 200, 1, 5000);
1279
+ const target = await resolveReadablePath(path, request.context, { rejectSensitive: false });
1280
+ const matcher = createGlobMatcher(pattern);
1281
+ const entries = await collectDirectoryEntries(target.absolutePath, request.context, { recursive: true, includeHidden, maxEntries: maxResults });
1282
+ const matches = entries.items
1283
+ .map(item => displayPath(item.absolutePath, request.context.cwd))
1284
+ .filter(item => matcher(item))
1285
+ .slice(0, maxResults);
1286
+ return jsonToolResult(request, {
1287
+ matches,
1288
+ truncated: entries.truncated || matches.length >= maxResults,
1289
+ });
1290
+ }
1291
+ function executeTodoWrite(request) {
1292
+ const items = normalizeTodoItems(request.call.input['items']);
1293
+ const inProgressCount = items.filter(item => item.status === 'in_progress').length;
1294
+ if (inProgressCount > 1) {
1295
+ throw toolError('TODO_MULTIPLE_IN_PROGRESS', 'Only one plan item can be in_progress at a time.');
1296
+ }
1297
+ return jsonToolResult(request, { items });
1298
+ }
1299
+ async function executeCreateDirectory(request) {
1300
+ const input = request.call.input;
1301
+ const path = requiredString(input, 'path');
1302
+ const recursive = optionalBoolean(input, 'recursive') ?? true;
1303
+ const target = await resolveWritablePath(path, request.context);
1304
+ const existed = await pathExists(target.absolutePath);
1305
+ if (existed) {
1306
+ const stats = await stat(target.absolutePath);
1307
+ if (!stats.isDirectory()) {
1308
+ throw toolError('NOT_A_DIRECTORY', `Path exists and is not a directory: ${path}`);
1309
+ }
1310
+ }
1311
+ await mkdir(target.absolutePath, { recursive });
1312
+ return jsonToolResult(request, {
1313
+ path: target.displayPath,
1314
+ created: !existed,
1315
+ });
1316
+ }
1317
+ async function executeEditFile(request) {
1318
+ const input = request.call.input;
1319
+ const path = requiredString(input, 'path');
1320
+ const oldString = requiredString(input, 'oldString');
1321
+ const newString = requiredString(input, 'newString');
1322
+ const replaceAll = optionalBoolean(input, 'replaceAll') ?? false;
1323
+ const target = await resolveWritablePath(path, request.context);
1324
+ const existed = await pathExists(target.absolutePath);
1325
+ if (!existed) {
1326
+ throw toolError('PATH_NOT_FOUND', `Path does not exist: ${path}`);
1327
+ }
1328
+ const entry = await requireFullReadState(target, request.context, path);
1329
+ if (oldString.length === 0) {
1330
+ throw toolError('INVALID_INPUT', 'oldString must not be empty. Use write_file for whole-file creation or replacement.');
1331
+ }
1332
+ const matches = countOccurrences(entry.content, oldString);
1333
+ if (matches === 0) {
1334
+ throw toolError('STRING_NOT_FOUND', `oldString was not found in ${target.displayPath}. Read the file again if it changed.`);
1335
+ }
1336
+ if (matches > 1 && !replaceAll) {
1337
+ throw toolError('STRING_NOT_UNIQUE', `oldString appears ${matches} times in ${target.displayPath}. Set replaceAll=true or provide more context.`);
1338
+ }
1339
+ const nextContent = replaceAll ? entry.content.split(oldString).join(newString) : entry.content.replace(oldString, newString);
1340
+ await writeFile(target.absolutePath, nextContent, { encoding: 'utf8', flag: 'w' });
1341
+ const stats = await stat(target.absolutePath);
1342
+ updateReadFileState(request.context, target.absolutePath, {
1343
+ content: nextContent,
1344
+ displayPath: target.displayPath,
1345
+ mtimeMs: stats.mtimeMs,
1346
+ partial: false,
1347
+ });
1348
+ const bytes = Buffer.byteLength(nextContent, 'utf8');
1349
+ const diff = buildTextDiff(entry.content, nextContent);
1350
+ return jsonToolResult(request, {
1351
+ path: target.displayPath,
1352
+ bytes,
1353
+ addedLines: diff.addedLines,
1354
+ removedLines: diff.removedLines,
1355
+ diffPreview: diff.preview,
1356
+ });
1357
+ }
1358
+ async function executeWriteFile(request) {
1359
+ const input = request.call.input;
1360
+ const path = requiredString(input, 'path');
1361
+ const content = requiredString(input, 'content');
1362
+ const overwrite = optionalBoolean(input, 'overwrite') ?? false;
1363
+ const createDirectories = optionalBoolean(input, 'createDirectories') ?? true;
1364
+ const target = await resolveWritablePath(path, request.context);
1365
+ const existed = await pathExists(target.absolutePath);
1366
+ let previousContent = '';
1367
+ if (existed) {
1368
+ const stats = await stat(target.absolutePath);
1369
+ if (!stats.isFile()) {
1370
+ throw toolError('NOT_A_FILE', `Path exists and is not a file: ${path}`);
1371
+ }
1372
+ if (!overwrite) {
1373
+ throw toolError('FILE_EXISTS', `Refusing to overwrite existing file without overwrite=true: ${path}`);
1374
+ }
1375
+ if (!isSensitivePath(target.absolutePath)) {
1376
+ previousContent = (await requireFullReadState(target, request.context, path)).content;
1377
+ }
1378
+ }
1379
+ if (createDirectories) {
1380
+ await mkdir(dirname(target.absolutePath), { recursive: true });
1381
+ }
1382
+ await writeFile(target.absolutePath, content, { encoding: 'utf8', flag: overwrite ? 'w' : 'wx' });
1383
+ const stats = await stat(target.absolutePath);
1384
+ updateReadFileState(request.context, target.absolutePath, {
1385
+ content,
1386
+ displayPath: target.displayPath,
1387
+ mtimeMs: stats.mtimeMs,
1388
+ partial: false,
1389
+ });
1390
+ const bytes = Buffer.byteLength(content, 'utf8');
1391
+ const diff = isSensitivePath(target.absolutePath) ? { addedLines: 0, removedLines: 0, preview: '' } : buildTextDiff(previousContent, content);
1392
+ return jsonToolResult(request, {
1393
+ path: target.displayPath,
1394
+ bytes,
1395
+ created: !existed,
1396
+ overwritten: existed,
1397
+ addedLines: diff.addedLines,
1398
+ removedLines: diff.removedLines,
1399
+ diffPreview: diff.preview,
1400
+ });
1401
+ }
1402
+ async function executeDeleteFile(request) {
1403
+ const input = request.call.input;
1404
+ const path = requiredString(input, 'path');
1405
+ const target = await resolveWritablePath(path, request.context);
1406
+ const existed = await pathExists(target.absolutePath);
1407
+ if (!existed) {
1408
+ throw toolError('PATH_NOT_FOUND', `Path does not exist: ${path}`);
1409
+ }
1410
+ const stats = await stat(target.absolutePath);
1411
+ if (!stats.isFile()) {
1412
+ throw toolError('NOT_A_FILE', `delete_file only deletes regular files: ${path}`);
1413
+ }
1414
+ const previousContent = await readFile(target.absolutePath);
1415
+ const diff = buildDeletionDiff(previousContent);
1416
+ await unlink(target.absolutePath);
1417
+ request.context.readFileState?.delete(target.absolutePath);
1418
+ return jsonToolResult(request, {
1419
+ path: target.displayPath,
1420
+ deleted: true,
1421
+ bytes: stats.size,
1422
+ addedLines: diff.addedLines,
1423
+ removedLines: diff.removedLines,
1424
+ diffPreview: diff.preview,
1425
+ });
1426
+ }
1427
+ async function executeDeleteDirectory(request) {
1428
+ const input = request.call.input;
1429
+ const path = requiredString(input, 'path');
1430
+ const target = await resolveWritablePath(path, request.context);
1431
+ const existed = await pathExists(target.absolutePath);
1432
+ if (!existed) {
1433
+ throw toolError('PATH_NOT_FOUND', `Path does not exist: ${path}`);
1434
+ }
1435
+ const stats = await stat(target.absolutePath);
1436
+ if (!stats.isDirectory()) {
1437
+ throw toolError('NOT_A_DIRECTORY', `delete_directory only deletes directories: ${path}`);
1438
+ }
1439
+ const entries = await readdir(target.absolutePath);
1440
+ if (entries.length > 0) {
1441
+ throw toolError('DIRECTORY_NOT_EMPTY', `delete_directory only deletes empty directories: ${path}`);
1442
+ }
1443
+ await rmdir(target.absolutePath);
1444
+ return jsonToolResult(request, {
1445
+ path: target.displayPath,
1446
+ deleted: true,
1447
+ });
1448
+ }
1449
+ function recordReadFileState(context, target, entry) {
1450
+ const previous = context.readFileState?.get(target.absolutePath);
1451
+ if (entry.partial && previous && !previous.partial) {
1452
+ return;
1453
+ }
1454
+ updateReadFileState(context, target.absolutePath, {
1455
+ content: entry.content,
1456
+ displayPath: target.displayPath,
1457
+ mtimeMs: entry.mtimeMs,
1458
+ partial: entry.partial,
1459
+ });
1460
+ }
1461
+ function updateReadFileState(context, absolutePath, entry) {
1462
+ const state = context.readFileState ?? new Map();
1463
+ state.set(absolutePath, entry);
1464
+ context.readFileState = state;
1465
+ }
1466
+ async function requireFullReadState(target, context, inputPath) {
1467
+ const entry = context.readFileState?.get(target.absolutePath);
1468
+ if (!entry) {
1469
+ throw toolError('FILE_NOT_READ', `File must be read with read_file before editing or overwriting: ${inputPath}`);
1470
+ }
1471
+ if (entry.partial) {
1472
+ throw toolError('FILE_PARTIALLY_READ', `File was only partially read. Read the full file before editing or overwriting: ${entry.displayPath}`);
1473
+ }
1474
+ const stats = await stat(target.absolutePath);
1475
+ if (!stats.isFile()) {
1476
+ throw toolError('NOT_A_FILE', `Path is not a file: ${inputPath}`);
1477
+ }
1478
+ const currentContent = await readFile(target.absolutePath, 'utf8');
1479
+ if (currentContent !== entry.content) {
1480
+ throw toolError('FILE_CHANGED_SINCE_READ', `File changed after it was read. Read it again before editing or overwriting: ${entry.displayPath}`);
1481
+ }
1482
+ updateReadFileState(context, target.absolutePath, {
1483
+ ...entry,
1484
+ mtimeMs: stats.mtimeMs,
1485
+ });
1486
+ return {
1487
+ ...entry,
1488
+ mtimeMs: stats.mtimeMs,
1489
+ };
1490
+ }
1491
+ function countOccurrences(text, search) {
1492
+ let count = 0;
1493
+ let index = 0;
1494
+ while (true) {
1495
+ const next = text.indexOf(search, index);
1496
+ if (next < 0) {
1497
+ return count;
1498
+ }
1499
+ count += 1;
1500
+ index = next + search.length;
1501
+ }
1502
+ }
1503
+ const defaultDiffContextLines = 1;
1504
+ const maxLcsDiffCells = 2_000_000;
1505
+ const diffOmissionMarker = '⋮';
1506
+ function buildTextDiff(before, after, maxPreviewLines = 80, contextLines = defaultDiffContextLines) {
1507
+ const beforeLines = splitDiffLines(before);
1508
+ const afterLines = splitDiffLines(after);
1509
+ const diffLines = beforeLines.length * afterLines.length <= maxLcsDiffCells
1510
+ ? buildLcsDiff(beforeLines, afterLines)
1511
+ : [
1512
+ ...beforeLines.map((text, index) => ({ kind: 'remove', text, oldLine: index + 1 })),
1513
+ ...afterLines.map((text, index) => ({ kind: 'add', text, newLine: index + 1 })),
1514
+ ];
1515
+ const addedLines = diffLines.filter(line => line.kind === 'add').length;
1516
+ const removedLines = diffLines.filter(line => line.kind === 'remove').length;
1517
+ const selectedLines = selectDiffPreviewLines(diffLines, contextLines, maxPreviewLines);
1518
+ const previewLines = selectedLines.map(line => formatDiffPreviewLine(line, selectedLines));
1519
+ const renderedDiffLines = selectedLines.filter(line => line.kind === 'add' || line.kind === 'remove').length;
1520
+ const omitted = addedLines + removedLines - renderedDiffLines;
1521
+ if (omitted > 0) {
1522
+ previewLines.push(`${diffOmissionMarker} ${omitted} diff lines omitted`);
1523
+ }
1524
+ return {
1525
+ addedLines,
1526
+ removedLines,
1527
+ preview: previewLines.join('\n'),
1528
+ };
1529
+ }
1530
+ function buildDeletionDiff(content) {
1531
+ if (isProbablyBinary(content)) {
1532
+ return { addedLines: 0, removedLines: 0, preview: '' };
1533
+ }
1534
+ return buildTextDiff(content.toString('utf8'), '');
1535
+ }
1536
+ function isProbablyBinary(content) {
1537
+ const sampleLength = Math.min(content.length, 8_000);
1538
+ for (let index = 0; index < sampleLength; index += 1) {
1539
+ if (content[index] === 0) {
1540
+ return true;
1541
+ }
1542
+ }
1543
+ return false;
1544
+ }
1545
+ function selectDiffPreviewLines(diffLines, contextLines, maxPreviewLines) {
1546
+ const include = new Set();
1547
+ for (let index = 0; index < diffLines.length; index += 1) {
1548
+ if (diffLines[index]?.kind === 'same') {
1549
+ continue;
1550
+ }
1551
+ const start = Math.max(0, index - contextLines);
1552
+ const end = Math.min(diffLines.length - 1, index + contextLines);
1553
+ for (let candidate = start; candidate <= end; candidate += 1) {
1554
+ include.add(candidate);
1555
+ }
1556
+ }
1557
+ const selectedIndexes = [...include].sort((left, right) => left - right);
1558
+ const preview = [];
1559
+ let previousIndex = -1;
1560
+ for (const index of selectedIndexes) {
1561
+ if (previousIndex >= 0 && index > previousIndex + 1) {
1562
+ preview.push({ kind: 'ellipsis' });
1563
+ }
1564
+ const line = diffLines[index];
1565
+ if (line) {
1566
+ preview.push(line);
1567
+ }
1568
+ previousIndex = index;
1569
+ }
1570
+ if (preview.length <= maxPreviewLines) {
1571
+ return preview;
1572
+ }
1573
+ const capped = preview.slice(0, maxPreviewLines);
1574
+ if (capped[capped.length - 1]?.kind !== 'ellipsis') {
1575
+ capped.push({ kind: 'ellipsis' });
1576
+ }
1577
+ return capped;
1578
+ }
1579
+ function formatDiffPreviewLine(line, allLines) {
1580
+ if (line.kind === 'ellipsis') {
1581
+ return diffOmissionMarker;
1582
+ }
1583
+ const lineNumber = displayDiffLineNumber(line);
1584
+ const width = diffLineNumberWidth(allLines);
1585
+ const marker = line.kind === 'add' ? '+' : line.kind === 'remove' ? '-' : ' ';
1586
+ return `${String(lineNumber).padStart(width)} ${marker} ${line.text}`;
1587
+ }
1588
+ function diffLineNumberWidth(lines) {
1589
+ const maxLineNumber = lines.reduce((max, line) => {
1590
+ if (line.kind === 'ellipsis') {
1591
+ return max;
1592
+ }
1593
+ return Math.max(max, displayDiffLineNumber(line));
1594
+ }, 1);
1595
+ return String(maxLineNumber).length;
1596
+ }
1597
+ function displayDiffLineNumber(line) {
1598
+ return line.kind === 'remove' ? line.oldLine ?? line.newLine ?? 0 : line.newLine ?? line.oldLine ?? 0;
1599
+ }
1600
+ function splitDiffLines(content) {
1601
+ const normalized = content.replace(/\r\n/g, '\n');
1602
+ if (normalized.length === 0) {
1603
+ return [];
1604
+ }
1605
+ return normalized.endsWith('\n') ? normalized.slice(0, -1).split('\n') : normalized.split('\n');
1606
+ }
1607
+ function buildLcsDiff(beforeLines, afterLines) {
1608
+ const rows = beforeLines.length;
1609
+ const columns = afterLines.length;
1610
+ const table = Array.from({ length: rows + 1 }, () => Array(columns + 1).fill(0));
1611
+ for (let row = rows - 1; row >= 0; row -= 1) {
1612
+ for (let column = columns - 1; column >= 0; column -= 1) {
1613
+ table[row][column] =
1614
+ beforeLines[row] === afterLines[column]
1615
+ ? table[row + 1][column + 1] + 1
1616
+ : Math.max(table[row + 1][column], table[row][column + 1]);
1617
+ }
1618
+ }
1619
+ const diff = [];
1620
+ let row = 0;
1621
+ let column = 0;
1622
+ while (row < rows && column < columns) {
1623
+ const beforeLine = beforeLines[row];
1624
+ const afterLine = afterLines[column];
1625
+ if (beforeLine === afterLine) {
1626
+ diff.push({ kind: 'same', text: beforeLine ?? '', oldLine: row + 1, newLine: column + 1 });
1627
+ row += 1;
1628
+ column += 1;
1629
+ }
1630
+ else if (table[row + 1][column] >= table[row][column + 1]) {
1631
+ diff.push({ kind: 'remove', text: beforeLine ?? '', oldLine: row + 1 });
1632
+ row += 1;
1633
+ }
1634
+ else {
1635
+ diff.push({ kind: 'add', text: afterLine ?? '', newLine: column + 1 });
1636
+ column += 1;
1637
+ }
1638
+ }
1639
+ while (row < rows) {
1640
+ diff.push({ kind: 'remove', text: beforeLines[row] ?? '', oldLine: row + 1 });
1641
+ row += 1;
1642
+ }
1643
+ while (column < columns) {
1644
+ diff.push({ kind: 'add', text: afterLines[column] ?? '', newLine: column + 1 });
1645
+ column += 1;
1646
+ }
1647
+ return diff;
1648
+ }
1649
+ async function executeRunCommand(request) {
1650
+ const command = requiredString(request.call.input, 'command');
1651
+ const parsed = parseSimpleCommand(command);
1652
+ const validation = validateParsedReadOnlyCommand(parsed);
1653
+ if (!validation.ok) {
1654
+ throw toolError('COMMAND_DENIED', validation.reason);
1655
+ }
1656
+ await validateCommandPathOperands(parsed, request.context);
1657
+ const timeoutMs = clampPositiveInteger(optionalPositiveInteger(request.call.input, 'timeoutMs') ?? request.context.commandTimeoutMs ?? 30_000, 1_000, 60_000);
1658
+ const maxBytes = clampPositiveInteger(optionalPositiveInteger(request.call.input, 'maxBytes') ?? request.context.maxOutputBytes, 1, outputLimitFor('run_command', request.context));
1659
+ const result = await spawnReadOnlyCommand(parsed, request.context, { timeoutMs, maxBytes });
1660
+ return jsonToolResult(request, {
1661
+ command: stringifyCommand(parsed),
1662
+ exitCode: result.exitCode,
1663
+ stdout: result.stdout,
1664
+ stderr: result.stderr,
1665
+ bytes: result.bytes,
1666
+ timedOut: result.timedOut,
1667
+ truncated: result.truncated,
1668
+ });
1669
+ }
1670
+ async function executeShell(request) {
1671
+ const command = requiredString(request.call.input, 'command');
1672
+ const requestedCwd = optionalString(request.call.input, 'cwd') ?? request.context.cwd;
1673
+ const workdir = await resolveReadablePath(requestedCwd, request.context, { requireDirectory: true, rejectSensitive: false });
1674
+ const commandContext = { ...request.context, cwd: workdir.absolutePath };
1675
+ const parsed = parseShellCommand(command);
1676
+ for (const segment of parsed.segments) {
1677
+ const validation = validateParsedExecuteShellCommand(segment, commandContext.cwd);
1678
+ if (!validation.ok) {
1679
+ throw toolError('COMMAND_DENIED', validation.reason);
1680
+ }
1681
+ }
1682
+ const timeoutMs = clampPositiveInteger(optionalPositiveInteger(request.call.input, 'timeoutMs') ?? request.context.commandTimeoutMs ?? 120_000, 1_000, 10 * 60_000);
1683
+ const maxBytes = clampPositiveInteger(optionalPositiveInteger(request.call.input, 'maxBytes') ?? request.context.maxOutputBytes, 1, outputLimitFor('execute_shell', request.context));
1684
+ const result = await spawnShellSegments(parsed, commandContext, { timeoutMs, maxBytes });
1685
+ return jsonToolResult(request, {
1686
+ command,
1687
+ cwd: workdir.displayPath,
1688
+ exitCode: result.exitCode,
1689
+ stdout: result.stdout,
1690
+ stderr: result.stderr,
1691
+ bytes: result.bytes,
1692
+ timedOut: result.timedOut,
1693
+ truncated: result.truncated,
1694
+ segments: result.segments,
1695
+ });
1696
+ }
1697
+ function jsonToolResult(request, payload) {
1698
+ const raw = JSON.stringify(payload);
1699
+ const limit = outputLimitFor(request.call.toolName, request.context);
1700
+ const truncated = truncateStringBytes(raw, limit, 'tail');
1701
+ return {
1702
+ ok: true,
1703
+ callId: request.call.id,
1704
+ toolName: request.call.toolName,
1705
+ output: truncated.text,
1706
+ truncated: truncated.truncated,
1707
+ bytes: Buffer.byteLength(truncated.text, 'utf8'),
1708
+ };
1709
+ }
1710
+ const neverPersistShellPrefixes = new Set([
1711
+ 'cp',
1712
+ 'mv',
1713
+ 'sed',
1714
+ 'rm',
1715
+ 'rmdir',
1716
+ 'sudo',
1717
+ 'su',
1718
+ 'doas',
1719
+ 'pkexec',
1720
+ 'sh',
1721
+ 'bash',
1722
+ 'zsh',
1723
+ 'fish',
1724
+ 'dash',
1725
+ 'env',
1726
+ 'xargs',
1727
+ 'awk',
1728
+ 'curl',
1729
+ 'wget',
1730
+ 'brew',
1731
+ 'apt',
1732
+ 'apt-get',
1733
+ 'winget',
1734
+ 'choco',
1735
+ 'scoop',
1736
+ 'nc',
1737
+ 'netcat',
1738
+ 'ssh',
1739
+ 'scp',
1740
+ 'sftp',
1741
+ 'chmod',
1742
+ 'chown',
1743
+ 'chgrp',
1744
+ 'ruby',
1745
+ 'perl',
1746
+ 'php',
1747
+ 'python',
1748
+ 'python2',
1749
+ 'python3',
1750
+ 'node',
1751
+ 'deno',
1752
+ 'bun',
1753
+ 'npx',
1754
+ 'tsx',
1755
+ 'lua',
1756
+ 'make',
1757
+ 'cmake',
1758
+ ]);
1759
+ function buildToolPermissionCommand(toolName, input, context) {
1760
+ if (toolName === 'execute_shell') {
1761
+ return buildExecuteShellPermissionCommand(input, context);
1762
+ }
1763
+ if (toolName === 'create_directory') {
1764
+ return buildCreateDirectoryPermissionCommand(input, context);
1765
+ }
1766
+ if (toolName === 'write_file') {
1767
+ return undefined;
1768
+ }
1769
+ if (toolName === 'edit_file') {
1770
+ return undefined;
1771
+ }
1772
+ if (toolName === 'delete_file') {
1773
+ return buildDeleteFilePermissionCommand(input, context);
1774
+ }
1775
+ if (toolName === 'delete_directory') {
1776
+ return buildDeleteDirectoryPermissionCommand(input);
1777
+ }
1778
+ return undefined;
1779
+ }
1780
+ function buildFilesystemPermissionRules(toolName, input, context) {
1781
+ const operation = filesystemOperationForTool(toolName);
1782
+ if (!operation) {
1783
+ return [];
1784
+ }
1785
+ const path = input['path'];
1786
+ if (typeof path !== 'string' || path.trim().length === 0) {
1787
+ return [];
1788
+ }
1789
+ const root = suggestedFilesystemPermissionRoot(path, context);
1790
+ if (!root) {
1791
+ return [];
1792
+ }
1793
+ return [
1794
+ {
1795
+ kind: 'filesystem-write-root',
1796
+ root,
1797
+ operations: filesystemOperationsForSuggestedRule(operation),
1798
+ },
1799
+ ];
1800
+ }
1801
+ function filesystemOperationsForSuggestedRule(operation) {
1802
+ if (operation === 'write' || operation === 'edit') {
1803
+ return ['write', 'edit'];
1804
+ }
1805
+ return [operation];
1806
+ }
1807
+ function filesystemOperationForTool(toolName) {
1808
+ if (toolName === 'create_directory') {
1809
+ return 'create';
1810
+ }
1811
+ if (toolName === 'write_file') {
1812
+ return 'write';
1813
+ }
1814
+ if (toolName === 'edit_file') {
1815
+ return 'edit';
1816
+ }
1817
+ if (toolName === 'delete_file') {
1818
+ return 'delete';
1819
+ }
1820
+ if (toolName === 'delete_directory') {
1821
+ return 'delete';
1822
+ }
1823
+ return undefined;
1824
+ }
1825
+ function suggestedFilesystemPermissionRoot(inputPath, context) {
1826
+ const absolutePath = isAbsolute(inputPath) ? resolve(inputPath) : resolve(context.cwd, inputPath);
1827
+ const cwd = resolve(context.cwd);
1828
+ if (isPathInside(absolutePath, cwd)) {
1829
+ return cwd;
1830
+ }
1831
+ const home = homedir();
1832
+ const desktop = resolve(home, 'Desktop');
1833
+ if (isPathInside(absolutePath, desktop)) {
1834
+ const rel = relative(desktop, absolutePath);
1835
+ const [project] = rel.split(sep).filter(Boolean);
1836
+ if (project) {
1837
+ return resolve(desktop, project);
1838
+ }
1839
+ }
1840
+ return dirname(absolutePath);
1841
+ }
1842
+ function buildExecuteShellPermissionCommand(input, context) {
1843
+ const command = input['command'];
1844
+ if (typeof command !== 'string') {
1845
+ return undefined;
1846
+ }
1847
+ try {
1848
+ const parsed = parseShellCommand(command);
1849
+ const cwd = shellPermissionCwd(input, context.cwd);
1850
+ const capabilities = commandCapabilitiesForSegments(parsed.segments);
1851
+ const allowPersistentRules = canPersistShellCommand(parsed.segments, capabilities);
1852
+ const fullCommandRule = allowPersistentRules && parsed.segments.some(segment => segment.command === 'cd') && !parsed.hasWildcard
1853
+ ? { kind: 'shell-prefix', prefix: command.trim(), cwd: resolve(cwd) }
1854
+ : undefined;
1855
+ let segmentCwd = cwd;
1856
+ const segments = parsed.segments.map(segment => {
1857
+ const cwdForSegment = segmentCwd;
1858
+ const suggestedRule = fullCommandRule || !allowPersistentRules ? undefined : parsed.hasWildcard ? undefined : suggestShellPermissionRule(segment, cwdForSegment);
1859
+ const result = {
1860
+ command: segment.command,
1861
+ args: segment.args,
1862
+ display: stringifyCommand(segment),
1863
+ cwd: cwdForSegment,
1864
+ ...(suggestedRule ? { suggestedPrefix: suggestedRule.prefix, suggestedRule } : {}),
1865
+ };
1866
+ const nextCwd = cdTargetCwd(segment, cwdForSegment);
1867
+ if (nextCwd) {
1868
+ segmentCwd = nextCwd;
1869
+ }
1870
+ return result;
1871
+ });
1872
+ const suggestedRules = segments
1873
+ .map(segment => segment.suggestedRule)
1874
+ .filter((rule) => rule !== undefined && rule.kind === 'shell-prefix');
1875
+ const effectiveSuggestedRules = allowPersistentRules ? fullCommandRule ? [fullCommandRule] : suggestedRules : [];
1876
+ const suggestedRule = effectiveSuggestedRules.length === 1 ? effectiveSuggestedRules[0] : undefined;
1877
+ const suggestedPrefix = suggestedRule?.prefix;
1878
+ return {
1879
+ commandLine: command,
1880
+ segments,
1881
+ cwd,
1882
+ ...(capabilities.length === 1 ? { capability: capabilities[0] } : {}),
1883
+ ...(capabilities.length > 0 ? { capabilities } : {}),
1884
+ ...(suggestedPrefix ? { suggestedPrefix } : {}),
1885
+ ...(suggestedRule ? { suggestedRule } : {}),
1886
+ ...(effectiveSuggestedRules.length > 0 ? { suggestedRules: effectiveSuggestedRules } : {}),
1887
+ canPersist: effectiveSuggestedRules.length > 0 && (fullCommandRule !== undefined || effectiveSuggestedRules.length === segments.length),
1888
+ rulesMatchable: !parsed.hasWildcard,
1889
+ };
1890
+ }
1891
+ catch {
1892
+ return {
1893
+ commandLine: command,
1894
+ segments: [],
1895
+ canPersist: false,
1896
+ rulesMatchable: false,
1897
+ };
1898
+ }
1899
+ }
1900
+ function buildCreateDirectoryPermissionCommand(input, context) {
1901
+ const path = input['path'];
1902
+ if (typeof path !== 'string' || path.trim().length === 0) {
1903
+ return undefined;
1904
+ }
1905
+ const recursive = input['recursive'] !== false;
1906
+ const args = recursive ? ['-p', path] : [path];
1907
+ const segment = {
1908
+ command: 'mkdir',
1909
+ args,
1910
+ display: stringifyCommand({ command: 'mkdir', args }),
1911
+ };
1912
+ return {
1913
+ commandLine: segment.display,
1914
+ segments: [segment],
1915
+ canPersist: false,
1916
+ };
1917
+ }
1918
+ function buildDeleteFilePermissionCommand(input, context) {
1919
+ const path = input['path'];
1920
+ if (typeof path !== 'string' || path.trim().length === 0) {
1921
+ return undefined;
1922
+ }
1923
+ const segment = {
1924
+ command: 'rm',
1925
+ args: [path],
1926
+ display: `rm ${quoteCommandArg(path)}`,
1927
+ };
1928
+ return {
1929
+ commandLine: segment.display,
1930
+ segments: [segment],
1931
+ canPersist: false,
1932
+ };
1933
+ }
1934
+ function buildDeleteDirectoryPermissionCommand(input) {
1935
+ const path = input['path'];
1936
+ if (typeof path !== 'string' || path.trim().length === 0) {
1937
+ return undefined;
1938
+ }
1939
+ const segment = {
1940
+ command: 'rmdir',
1941
+ args: [path],
1942
+ display: `rmdir ${quoteCommandArg(path)}`,
1943
+ };
1944
+ return {
1945
+ commandLine: segment.display,
1946
+ segments: [segment],
1947
+ canPersist: false,
1948
+ };
1949
+ }
1950
+ function commandCapabilitiesForSegments(segments) {
1951
+ return segments
1952
+ .map(segment => commandCapabilityForSegment(segment))
1953
+ .filter((capability) => capability !== undefined);
1954
+ }
1955
+ function commandCapabilityForSegment(segment) {
1956
+ const command = segment.command.toLowerCase();
1957
+ const platform = currentHostPlatform();
1958
+ if (command === 'curl' || command === 'wget') {
1959
+ const target = segment.args.find(arg => /^https?:\/\//i.test(arg));
1960
+ return {
1961
+ id: 'download.url',
1962
+ risk: 'network',
1963
+ ...(platform ? { platform } : {}),
1964
+ ...(target ? { target } : {}),
1965
+ };
1966
+ }
1967
+ const installCapability = packageManagerInstallCapability(command, segment.args, platform);
1968
+ if (installCapability) {
1969
+ return installCapability;
1970
+ }
1971
+ if (command === 'ifconfig' || command === 'ip' || command === 'ipconfig') {
1972
+ return { id: 'network.ip', risk: 'read-only', ...(platform ? { platform } : {}) };
1973
+ }
1974
+ if (command === 'getmac' || command === 'arp') {
1975
+ return { id: 'network.mac', risk: 'read-only', ...(platform ? { platform } : {}) };
1976
+ }
1977
+ if (command === 'netstat' || command === 'ss' || command === 'lsof') {
1978
+ return { id: 'network.ports', risk: 'read-only', ...(platform ? { platform } : {}) };
1979
+ }
1980
+ if (command === 'rg' || command === 'grep') {
1981
+ return { id: 'logs.search', risk: 'read-only', ...(platform ? { platform } : {}) };
1982
+ }
1983
+ return undefined;
1984
+ }
1985
+ function packageManagerInstallCapability(command, args, platform) {
1986
+ const installSubcommand = args[0]?.toLowerCase();
1987
+ if (!['install', 'add'].includes(installSubcommand ?? '')) {
1988
+ return undefined;
1989
+ }
1990
+ const packageManager = packageManagerForCommand(command);
1991
+ if (!packageManager) {
1992
+ return undefined;
1993
+ }
1994
+ const target = firstNonFlagArgument(args.slice(1));
1995
+ const requiresAdmin = packageManager === 'apt' || packageManager === 'apt-get' || packageManager === 'choco';
1996
+ return {
1997
+ id: 'tool.install',
1998
+ risk: requiresAdmin ? 'admin' : 'install',
1999
+ packageManager,
2000
+ requiresAdmin,
2001
+ ...(platform ? { platform } : {}),
2002
+ ...(target ? { target } : {}),
2003
+ };
2004
+ }
2005
+ function packageManagerForCommand(command) {
2006
+ if (command === 'brew' || command === 'apt' || command === 'apt-get' || command === 'winget' || command === 'choco' || command === 'scoop') {
2007
+ return command;
2008
+ }
2009
+ return undefined;
2010
+ }
2011
+ function firstNonFlagArgument(args) {
2012
+ for (const arg of args) {
2013
+ if (arg === '--') {
2014
+ continue;
2015
+ }
2016
+ if (arg.startsWith('-') || arg.startsWith('/')) {
2017
+ continue;
2018
+ }
2019
+ return arg;
2020
+ }
2021
+ return undefined;
2022
+ }
2023
+ function currentHostPlatform() {
2024
+ if (process.platform === 'darwin' || process.platform === 'linux' || process.platform === 'win32') {
2025
+ return process.platform;
2026
+ }
2027
+ return undefined;
2028
+ }
2029
+ function canPersistShellCommand(segments, capabilities) {
2030
+ if (segments.some(segment => isEphemeralApprovalShellCommand(segment.command))) {
2031
+ return false;
2032
+ }
2033
+ return capabilities.every(capability => capability.risk === 'read-only');
2034
+ }
2035
+ function isEphemeralApprovalShellCommand(command) {
2036
+ return command === 'curl' ||
2037
+ command === 'wget' ||
2038
+ command === 'brew' ||
2039
+ command === 'apt' ||
2040
+ command === 'apt-get' ||
2041
+ command === 'winget' ||
2042
+ command === 'choco' ||
2043
+ command === 'scoop';
2044
+ }
2045
+ function validateReadOnlyCommandInput(input) {
2046
+ const command = input['command'];
2047
+ if (typeof command !== 'string') {
2048
+ return { ok: false, reason: 'run_command requires a string command.' };
2049
+ }
2050
+ try {
2051
+ return validateParsedReadOnlyCommand(parseSimpleCommand(command, { allowDollarLiteral: true, allowEnvAssignmentArgs: true }));
2052
+ }
2053
+ catch (error) {
2054
+ return { ok: false, reason: error instanceof Error ? error.message : String(error) };
2055
+ }
2056
+ }
2057
+ function validateExecuteShellInput(input, defaultCwd) {
2058
+ const command = input['command'];
2059
+ if (typeof command !== 'string') {
2060
+ return { ok: false, reason: 'execute_shell requires a string command.' };
2061
+ }
2062
+ try {
2063
+ const parsed = parseShellCommand(command);
2064
+ const cwd = shellPermissionCwd(input, defaultCwd);
2065
+ for (const segment of parsed.segments) {
2066
+ const validation = validateParsedExecuteShellCommand(segment, cwd);
2067
+ if (!validation.ok) {
2068
+ return validation;
2069
+ }
2070
+ }
2071
+ return { ok: true };
2072
+ }
2073
+ catch (error) {
2074
+ return { ok: false, reason: error instanceof Error ? error.message : String(error) };
2075
+ }
2076
+ }
2077
+ function parseShellCommand(commandLine) {
2078
+ if (commandLine.trim().length === 0) {
2079
+ throw new Error('Command must not be empty.');
2080
+ }
2081
+ if (commandLine.length > 4000) {
2082
+ throw new Error('Command is too long.');
2083
+ }
2084
+ if (/[\r\n]/.test(commandLine)) {
2085
+ throw new Error('Multi-line commands are not allowed.');
2086
+ }
2087
+ const parts = splitShellCommandSegments(commandLine);
2088
+ const segments = parts.segments.map(segment => parseSimpleCommand(segment, { allowOperators: false, allowExecutablePath: true }));
2089
+ if (segments.length === 0) {
2090
+ throw new Error('Command must not be empty.');
2091
+ }
2092
+ return {
2093
+ segments,
2094
+ operators: parts.operators,
2095
+ hasWildcard: parts.hasWildcard,
2096
+ };
2097
+ }
2098
+ function splitShellCommandSegments(commandLine) {
2099
+ const segments = [];
2100
+ const operators = [];
2101
+ let current = '';
2102
+ let quote;
2103
+ let hasWildcard = false;
2104
+ for (let index = 0; index < commandLine.length; index += 1) {
2105
+ const char = commandLine[index];
2106
+ if (char === undefined) {
2107
+ continue;
2108
+ }
2109
+ if (quote) {
2110
+ if (char === quote) {
2111
+ quote = undefined;
2112
+ current += char;
2113
+ continue;
2114
+ }
2115
+ current += char;
2116
+ continue;
2117
+ }
2118
+ if (char === '"' || char === "'") {
2119
+ quote = char;
2120
+ current += char;
2121
+ continue;
2122
+ }
2123
+ if (char === '*' || char === '?' || char === '[' || char === ']') {
2124
+ hasWildcard = true;
2125
+ current += char;
2126
+ continue;
2127
+ }
2128
+ if (char === ';') {
2129
+ pushShellSegment(segments, current);
2130
+ operators.push(';');
2131
+ current = '';
2132
+ continue;
2133
+ }
2134
+ if (char === '&') {
2135
+ if (commandLine[index + 1] === '&') {
2136
+ pushShellSegment(segments, current);
2137
+ operators.push('&&');
2138
+ current = '';
2139
+ index += 1;
2140
+ continue;
2141
+ }
2142
+ throw new Error('Backgrounding is not supported by execute_shell.');
2143
+ }
2144
+ if (char === '|') {
2145
+ throw new Error('Pipes are not supported by execute_shell.');
2146
+ }
2147
+ if (char === '<' || char === '>') {
2148
+ throw new Error('Shell redirects are not supported by execute_shell.');
2149
+ }
2150
+ if (char === '`' || char === '$') {
2151
+ throw new Error('Command substitution and shell variable expansion are not supported by execute_shell.');
2152
+ }
2153
+ current += char;
2154
+ }
2155
+ if (quote) {
2156
+ throw new Error('Unclosed quote in command.');
2157
+ }
2158
+ pushShellSegment(segments, current);
2159
+ if (operators.length >= segments.length) {
2160
+ throw new Error('Shell command cannot end with an operator.');
2161
+ }
2162
+ return { segments, operators, hasWildcard };
2163
+ }
2164
+ function pushShellSegment(segments, raw) {
2165
+ const segment = raw.trim();
2166
+ if (segment.length === 0) {
2167
+ throw new Error('Shell command contains an empty segment.');
2168
+ }
2169
+ segments.push(segment);
2170
+ }
2171
+ function parseSimpleCommand(commandLine, options = {}) {
2172
+ if (commandLine.trim().length === 0) {
2173
+ throw new Error('Command must not be empty.');
2174
+ }
2175
+ if (commandLine.length > 4000) {
2176
+ throw new Error('Command is too long.');
2177
+ }
2178
+ if (/[\r\n]/.test(commandLine)) {
2179
+ throw new Error('Multi-line commands are not allowed.');
2180
+ }
2181
+ const operatorPattern = options.allowDollarLiteral ? /[|<>;&`]/ : /[|<>;&`$]/;
2182
+ if ((options.allowOperators ?? false) === false && operatorPattern.test(commandLine)) {
2183
+ throw new Error('Shell operators, redirects, backgrounding, and command substitution are not allowed.');
2184
+ }
2185
+ const tokens = tokenizeCommandLine(commandLine);
2186
+ if (tokens.length === 0) {
2187
+ throw new Error('Command must not be empty.');
2188
+ }
2189
+ const [command, ...args] = tokens;
2190
+ if (!command) {
2191
+ throw new Error('Command must not be empty.');
2192
+ }
2193
+ if ((options.allowExecutablePath ?? false) === false && command.includes('/')) {
2194
+ throw new Error('Command must be an allowlisted executable name, not a path.');
2195
+ }
2196
+ if (command.includes('/') && !isAllowedExecutablePathToken(command)) {
2197
+ throw new Error('Executable paths must be relative to the current workspace, such as ./run.sh.');
2198
+ }
2199
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(command)) {
2200
+ throw new Error('Environment variable assignments are not supported by execute_shell.');
2201
+ }
2202
+ for (const arg of args) {
2203
+ if ((options.allowEnvAssignmentArgs ?? false) === false && /^[A-Za-z_][A-Za-z0-9_]*=/.test(arg)) {
2204
+ throw new Error('Environment variable assignments are not supported by execute_shell.');
2205
+ }
2206
+ }
2207
+ return { command, args };
2208
+ }
2209
+ function isAllowedExecutablePathToken(command) {
2210
+ return command.startsWith('./');
2211
+ }
2212
+ function tokenizeCommandLine(commandLine) {
2213
+ const tokens = [];
2214
+ let current = '';
2215
+ let quote;
2216
+ for (let index = 0; index < commandLine.length; index += 1) {
2217
+ const char = commandLine[index];
2218
+ if (char === undefined) {
2219
+ continue;
2220
+ }
2221
+ if (quote) {
2222
+ if (char === quote) {
2223
+ quote = undefined;
2224
+ continue;
2225
+ }
2226
+ current += char;
2227
+ continue;
2228
+ }
2229
+ if (char === '"' || char === "'") {
2230
+ quote = char;
2231
+ continue;
2232
+ }
2233
+ if (/\s/.test(char)) {
2234
+ if (current.length > 0) {
2235
+ tokens.push(current);
2236
+ current = '';
2237
+ }
2238
+ continue;
2239
+ }
2240
+ current += char;
2241
+ }
2242
+ if (quote) {
2243
+ throw new Error('Unclosed quote in command.');
2244
+ }
2245
+ if (current.length > 0) {
2246
+ tokens.push(current);
2247
+ }
2248
+ return tokens;
2249
+ }
2250
+ function validateParsedReadOnlyCommand(command) {
2251
+ if (command.command === 'pwd') {
2252
+ return command.args.length === 0 ? { ok: true } : { ok: false, reason: 'pwd does not accept arguments in read-only mode.' };
2253
+ }
2254
+ if (command.command === 'whoami') {
2255
+ return command.args.length === 0 ? { ok: true } : { ok: false, reason: 'whoami does not accept arguments in read-only mode.' };
2256
+ }
2257
+ if (command.command === 'ls') {
2258
+ return validateFlags(command, {
2259
+ allowedFlags: new Set(['-l', '-h', '-lh', '-hl', '--color=never', '--color=auto']),
2260
+ deniedFlags: new Set(['-a', '-A', '--all', '--almost-all', '-R', '--recursive']),
2261
+ });
2262
+ }
2263
+ if (command.command === 'cat') {
2264
+ return validateFlags(command, {
2265
+ allowedFlags: new Set(['-n', '-b', '-s']),
2266
+ deniedFlags: new Set([]),
2267
+ });
2268
+ }
2269
+ if (command.command === 'head') {
2270
+ return validateHeadTailCommand(command, { allowFollow: false });
2271
+ }
2272
+ if (command.command === 'tail') {
2273
+ return validateHeadTailCommand(command, { allowFollow: false });
2274
+ }
2275
+ if (command.command === 'wc') {
2276
+ return validateFlags(command, {
2277
+ allowedFlags: new Set(['-l', '-w', '-c', '-m', '-L']),
2278
+ deniedFlags: new Set([]),
2279
+ });
2280
+ }
2281
+ if (command.command === 'file') {
2282
+ return validateFlags(command, {
2283
+ allowedFlags: new Set(['-b', '--brief', '-i', '--mime', '--mime-type', '--mime-encoding', '-h', '--no-dereference', '-L', '--dereference']),
2284
+ deniedFlags: new Set(['-f', '--files-from']),
2285
+ });
2286
+ }
2287
+ if (command.command === 'stat') {
2288
+ return validateFlagsWithValues(command, {
2289
+ allowedFlags: new Set(['-L', '-x', '-s', '-r', '-q', '-t']),
2290
+ valueFlags: new Set(['-f', '-c']),
2291
+ deniedFlags: new Set([]),
2292
+ });
2293
+ }
2294
+ if (command.command === 'du') {
2295
+ return validateFlagsWithValues(command, {
2296
+ allowedFlags: new Set(['-s', '-h', '-k', '-m', '-g', '-x', '-c']),
2297
+ valueFlags: new Set(['-d', '--max-depth']),
2298
+ deniedFlags: new Set(['-a', '--all', '-I', '--exclude', '--files0-from']),
2299
+ });
2300
+ }
2301
+ if (command.command === 'df') {
2302
+ return validateFlags(command, {
2303
+ allowedFlags: new Set(['-h', '-H', '-k', '-m', '-g', '-P', '-T', '-i']),
2304
+ deniedFlags: new Set([]),
2305
+ });
2306
+ }
2307
+ if (command.command === 'grep') {
2308
+ return validateGrepCommand(command);
2309
+ }
2310
+ if (command.command === 'find') {
2311
+ return validateFindCommand(command);
2312
+ }
2313
+ if (command.command === 'rg') {
2314
+ return validateRipgrepCommand(command);
2315
+ }
2316
+ if (command.command === 'ps') {
2317
+ return validatePsCommand(command);
2318
+ }
2319
+ if (command.command === 'uname') {
2320
+ return validateFlags(command, {
2321
+ allowedFlags: new Set(['-a', '-s', '-n', '-r', '-v', '-m', '-p']),
2322
+ deniedFlags: new Set([]),
2323
+ });
2324
+ }
2325
+ if (command.command === 'date') {
2326
+ return validateDateCommand(command);
2327
+ }
2328
+ if (command.command === 'id') {
2329
+ return validateFlags(command, {
2330
+ allowedFlags: new Set(['-u', '-g', '-G', '-n', '-r', '-p']),
2331
+ deniedFlags: new Set([]),
2332
+ });
2333
+ }
2334
+ if (command.command === 'which') {
2335
+ return validateFlags(command, {
2336
+ allowedFlags: new Set(['-a', '-s']),
2337
+ deniedFlags: new Set([]),
2338
+ });
2339
+ }
2340
+ if (command.command === 'where') {
2341
+ return validateWhereCommand(command);
2342
+ }
2343
+ if (command.command === 'hostname') {
2344
+ return validateHostnameCommand(command);
2345
+ }
2346
+ if (command.command === 'ifconfig') {
2347
+ return validateIfconfigCommand(command);
2348
+ }
2349
+ if (command.command === 'ip') {
2350
+ return validateIpCommand(command);
2351
+ }
2352
+ if (command.command === 'ipconfig') {
2353
+ return validateIpconfigCommand(command);
2354
+ }
2355
+ if (command.command === 'getmac') {
2356
+ return validateGetmacCommand(command);
2357
+ }
2358
+ if (command.command === 'netstat') {
2359
+ return validateNetstatCommand(command);
2360
+ }
2361
+ if (command.command === 'ss') {
2362
+ return validateSsCommand(command);
2363
+ }
2364
+ if (command.command === 'lsof') {
2365
+ return validateLsofCommand(command);
2366
+ }
2367
+ if (command.command === 'arp') {
2368
+ return validateArpCommand(command);
2369
+ }
2370
+ if (command.command === 'uptime') {
2371
+ return command.args.length === 0 ? { ok: true } : { ok: false, reason: `${command.command} does not accept arguments in read-only mode.` };
2372
+ }
2373
+ if (command.command === 'basename') {
2374
+ return validateFlagsWithValues(command, {
2375
+ allowedFlags: new Set(['-a']),
2376
+ valueFlags: new Set(['-s']),
2377
+ deniedFlags: new Set([]),
2378
+ });
2379
+ }
2380
+ if (command.command === 'dirname') {
2381
+ return validateFlags(command, {
2382
+ allowedFlags: new Set([]),
2383
+ deniedFlags: new Set([]),
2384
+ });
2385
+ }
2386
+ if (command.command === 'realpath' || command.command === 'readlink') {
2387
+ return validateFlags(command, {
2388
+ allowedFlags: new Set(['-f', '-m', '-n', '-q']),
2389
+ deniedFlags: new Set([]),
2390
+ });
2391
+ }
2392
+ if (command.command === 'sort') {
2393
+ return validateFlagsWithValues(command, {
2394
+ allowedFlags: new Set(['-b', '-d', '-f', '-g', '-h', '-M', '-n', '-r', '-u', '-V', '-c', '-C', '-s']),
2395
+ valueFlags: new Set(['-k', '--key', '-t', '--field-separator']),
2396
+ deniedFlags: new Set(['-o', '--output', '--files0-from', '--compress-program', '-T', '--temporary-directory']),
2397
+ });
2398
+ }
2399
+ if (command.command === 'uniq') {
2400
+ return validateFlagsWithValues(command, {
2401
+ allowedFlags: new Set(['-c', '-d', '-D', '-u', '-i']),
2402
+ valueFlags: new Set(['-f', '-s', '-w']),
2403
+ deniedFlags: new Set([]),
2404
+ });
2405
+ }
2406
+ if (command.command === 'cut') {
2407
+ return validateFlagsWithValues(command, {
2408
+ allowedFlags: new Set(['-s', '--only-delimited', '--complement']),
2409
+ valueFlags: new Set(['-b', '--bytes', '-c', '--characters', '-f', '--fields', '-d', '--delimiter', '--output-delimiter']),
2410
+ deniedFlags: new Set([]),
2411
+ });
2412
+ }
2413
+ if (command.command === 'nl') {
2414
+ return validateFlagsWithValues(command, {
2415
+ allowedFlags: new Set(['-p']),
2416
+ valueFlags: new Set(['-b', '-d', '-f', '-h', '-i', '-l', '-n', '-s', '-v', '-w']),
2417
+ deniedFlags: new Set([]),
2418
+ });
2419
+ }
2420
+ if (command.command === 'diff') {
2421
+ return validateFlagsWithValues(command, {
2422
+ allowedFlags: new Set(['-u', '-c', '-q', '-s', '-r', '-N', '-B', '-b', '-w', '-i', '--brief', '--report-identical-files']),
2423
+ valueFlags: new Set(['-U', '--unified', '-C', '--context', '-L', '--label', '-I', '--ignore-matching-lines', '-x', '--exclude']),
2424
+ deniedFlags: new Set(['--output']),
2425
+ });
2426
+ }
2427
+ if (command.command === 'cmp') {
2428
+ return validateFlagsWithValues(command, {
2429
+ allowedFlags: new Set(['-s', '-l', '-b']),
2430
+ valueFlags: new Set(['-n', '-i']),
2431
+ deniedFlags: new Set([]),
2432
+ });
2433
+ }
2434
+ if (command.command === 'comm') {
2435
+ return validateFlagsWithValues(command, {
2436
+ allowedFlags: new Set(['-1', '-2', '-3', '--check-order', '--nocheck-order']),
2437
+ valueFlags: new Set(['--output-delimiter']),
2438
+ deniedFlags: new Set([]),
2439
+ });
2440
+ }
2441
+ if (command.command === 'strings') {
2442
+ return validateFlagsWithValues(command, {
2443
+ allowedFlags: new Set(['-a', '-f']),
2444
+ valueFlags: new Set(['-n', '-t', '-e']),
2445
+ deniedFlags: new Set([]),
2446
+ });
2447
+ }
2448
+ if (command.command === 'hexdump') {
2449
+ return validateFlagsWithValues(command, {
2450
+ allowedFlags: new Set(['-C', '-b', '-c', '-d', '-o', '-x', '-v']),
2451
+ valueFlags: new Set(['-n', '-s']),
2452
+ deniedFlags: new Set(['-e', '-f']),
2453
+ });
2454
+ }
2455
+ if (command.command === 'od') {
2456
+ return validateFlagsWithValues(command, {
2457
+ allowedFlags: new Set(['-a', '-b', '-c', '-d', '-f', '-h', '-i', '-l', '-o', '-s', '-x', '-v']),
2458
+ valueFlags: new Set(['-A', '-j', '-N', '-t', '-w']),
2459
+ deniedFlags: new Set([]),
2460
+ });
2461
+ }
2462
+ if (command.command === 'xxd') {
2463
+ return validateFlagsWithValues(command, {
2464
+ allowedFlags: new Set(['-a', '-b', '-c', '-E', '-g', '-i', '-l', '-p', '-ps', '-u']),
2465
+ valueFlags: new Set(['-c', '-g', '-l', '-s']),
2466
+ deniedFlags: new Set(['-r']),
2467
+ });
2468
+ }
2469
+ if (command.command === 'tar') {
2470
+ return validateTarListCommand(command);
2471
+ }
2472
+ if (command.command === 'git') {
2473
+ return validateGitCommand(command);
2474
+ }
2475
+ return {
2476
+ ok: false,
2477
+ reason: `Command is not in the read-only allowlist: ${command.command}`,
2478
+ };
2479
+ }
2480
+ function validateParsedExecuteShellCommand(command, cwd) {
2481
+ const deniedCommands = new Set([
2482
+ 'sudo',
2483
+ 'su',
2484
+ 'chown',
2485
+ 'chgrp',
2486
+ 'mkfs',
2487
+ 'diskutil',
2488
+ 'launchctl',
2489
+ 'security',
2490
+ ]);
2491
+ if (deniedCommands.has(command.command)) {
2492
+ return { ok: false, reason: `Command is blocked by Remi safety policy: ${command.command}` };
2493
+ }
2494
+ if (command.command === 'cd') {
2495
+ return validateCdCommand(command);
2496
+ }
2497
+ if (command.command === 'rm' || command.command === 'rmdir') {
2498
+ return validateRemovalCommand(command, cwd);
2499
+ }
2500
+ if (command.command.includes('/') && !isAllowedExecutablePathToken(command.command)) {
2501
+ return { ok: false, reason: `Executable path is outside supported relative workspace form: ${command.command}` };
2502
+ }
2503
+ return { ok: true };
2504
+ }
2505
+ function validateCdCommand(command) {
2506
+ if (command.args.length > 1) {
2507
+ return { ok: false, reason: 'cd supports at most one target directory in execute_shell.' };
2508
+ }
2509
+ const target = command.args[0];
2510
+ if (target === '-') {
2511
+ return { ok: false, reason: 'cd - is not supported by execute_shell.' };
2512
+ }
2513
+ if (target?.startsWith('-') && target !== '-') {
2514
+ return { ok: false, reason: 'cd flags are not supported by execute_shell.' };
2515
+ }
2516
+ return { ok: true };
2517
+ }
2518
+ function validateRemovalCommand(command, cwd) {
2519
+ for (const target of removalPathOperands(command)) {
2520
+ const dangerousReason = dangerousRemovalReason(target, cwd);
2521
+ if (dangerousReason) {
2522
+ return { ok: false, reason: dangerousReason };
2523
+ }
2524
+ }
2525
+ return { ok: true };
2526
+ }
2527
+ function removalPathOperands(command) {
2528
+ const operands = [];
2529
+ let endOfOptions = false;
2530
+ for (const arg of command.args) {
2531
+ if (!endOfOptions && arg === '--') {
2532
+ endOfOptions = true;
2533
+ continue;
2534
+ }
2535
+ if (!endOfOptions && arg.startsWith('-') && arg !== '-') {
2536
+ continue;
2537
+ }
2538
+ operands.push(arg);
2539
+ }
2540
+ return operands;
2541
+ }
2542
+ function dangerousRemovalReason(inputPath, cwd) {
2543
+ const normalizedInput = inputPath.trim().replace(/[\\/]+/g, '/');
2544
+ if (!normalizedInput) {
2545
+ return undefined;
2546
+ }
2547
+ if (normalizedInput === '*' || /[*?[\]]/.test(normalizedInput) || normalizedInput.endsWith('/*')) {
2548
+ return `Dangerous removal target is not allowed through execute_shell: ${inputPath}`;
2549
+ }
2550
+ const expandedPath = normalizedInput === '~'
2551
+ ? homedir()
2552
+ : normalizedInput.startsWith('~/')
2553
+ ? resolve(homedir(), normalizedInput.slice(2))
2554
+ : normalizedInput;
2555
+ const absolutePath = isAbsolute(expandedPath) ? resolve(expandedPath) : resolve(cwd, expandedPath);
2556
+ const normalizedPath = absolutePath === '/' ? '/' : absolutePath.replace(/\/+$/, '');
2557
+ if (normalizedPath === '/') {
2558
+ return `Dangerous removal target is not allowed through execute_shell: ${inputPath}`;
2559
+ }
2560
+ const normalizedHome = homedir().replace(/[\\/]+/g, '/').replace(/\/+$/, '');
2561
+ if (normalizedPath.replace(/[\\/]+/g, '/') === normalizedHome) {
2562
+ return `Dangerous removal target is not allowed through execute_shell: ${inputPath}`;
2563
+ }
2564
+ if (dirname(normalizedPath) === '/') {
2565
+ return `Dangerous removal target is not allowed through execute_shell: ${inputPath}`;
2566
+ }
2567
+ return undefined;
2568
+ }
2569
+ function shellPermissionCwd(input, defaultCwd) {
2570
+ const requested = typeof input['cwd'] === 'string' && input['cwd'].trim().length > 0 ? input['cwd'] : defaultCwd;
2571
+ return isAbsolute(requested) ? resolve(requested) : resolve(defaultCwd, requested);
2572
+ }
2573
+ function cdTargetCwd(command, cwd) {
2574
+ if (command.command !== 'cd') {
2575
+ return undefined;
2576
+ }
2577
+ const target = command.args[0] ?? homedir();
2578
+ if (target === '-') {
2579
+ return undefined;
2580
+ }
2581
+ if (target === '~') {
2582
+ return homedir();
2583
+ }
2584
+ if (target.startsWith('~/')) {
2585
+ return resolve(homedir(), target.slice(2));
2586
+ }
2587
+ return isAbsolute(target) ? resolve(target) : resolve(cwd, target);
2588
+ }
2589
+ function buildExactCommandRule(command, args, cwd) {
2590
+ return {
2591
+ kind: 'shell-prefix',
2592
+ prefix: stringifyCommand({ command, args }),
2593
+ cwd: resolve(cwd),
2594
+ };
2595
+ }
2596
+ function suggestShellPermissionRule(command, cwd) {
2597
+ const prefix = suggestShellPrefix(command) ?? stringifyCommand(command);
2598
+ if (!prefix) {
2599
+ return undefined;
2600
+ }
2601
+ return {
2602
+ kind: 'shell-prefix',
2603
+ prefix,
2604
+ cwd: resolve(cwd),
2605
+ };
2606
+ }
2607
+ function suggestShellPrefix(command) {
2608
+ const [firstArg, secondArg] = command.args;
2609
+ if (isNeverPersistShellPrefix(command.command)) {
2610
+ return undefined;
2611
+ }
2612
+ if (command.command === 'go' && firstArg) {
2613
+ if (firstArg === 'mod' && secondArg) {
2614
+ return `go mod ${secondArg}`;
2615
+ }
2616
+ return `go ${firstArg}`;
2617
+ }
2618
+ if ((command.command === 'pnpm' || command.command === 'npm' || command.command === 'yarn') && firstArg) {
2619
+ return firstArg === 'run' && secondArg ? `${command.command} run ${secondArg}` : `${command.command} ${firstArg}`;
2620
+ }
2621
+ if (command.command === 'git' && firstArg) {
2622
+ return `git ${firstArg}`;
2623
+ }
2624
+ if ((command.command === 'python' || command.command === 'python3') && firstArg === '-m' && secondArg) {
2625
+ if (['pytest', 'unittest', 'mypy', 'ruff'].includes(secondArg)) {
2626
+ return `${command.command} -m ${secondArg}`;
2627
+ }
2628
+ return undefined;
2629
+ }
2630
+ if (command.command === 'python' || command.command === 'python3') {
2631
+ return undefined;
2632
+ }
2633
+ if (command.command === 'node' && firstArg) {
2634
+ if (firstArg === '-e' || firstArg === '--eval' || firstArg === '-p' || firstArg === '--print') {
2635
+ return undefined;
2636
+ }
2637
+ return `node ${firstArg}`;
2638
+ }
2639
+ if (command.command === 'node') {
2640
+ return undefined;
2641
+ }
2642
+ if (command.command.startsWith('./')) {
2643
+ return stringifyCommand(command);
2644
+ }
2645
+ if (firstArg && /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(firstArg)) {
2646
+ return `${command.command} ${firstArg}`;
2647
+ }
2648
+ return command.command;
2649
+ }
2650
+ function isNeverPersistShellPrefix(command) {
2651
+ return neverPersistShellPrefixes.has(command);
2652
+ }
2653
+ function commandPermissionRulesAllow(request, rules) {
2654
+ if (!request.command || rules.length === 0 || request.command.rulesMatchable === false) {
2655
+ return false;
2656
+ }
2657
+ if (!canPersistShellCommand(request.command.segments, request.command.capabilities ?? [])) {
2658
+ return false;
2659
+ }
2660
+ const commandCwd = request.command.cwd ?? request.cwd;
2661
+ if (rules.some(rule => rawCommandRuleMatches(rule, request.command?.commandLine ?? '', commandCwd))) {
2662
+ return true;
2663
+ }
2664
+ return request.command.segments.every(segment => {
2665
+ const segmentCwd = segment.cwd ?? commandCwd;
2666
+ return rules.some(rule => commandPrefixRuleMatches(rule, segment, segmentCwd));
2667
+ });
2668
+ }
2669
+ async function filesystemPermissionRulesAllow(call, context, rules) {
2670
+ const operation = filesystemOperationForTool(call.toolName);
2671
+ if (!operation || rules.length === 0) {
2672
+ return false;
2673
+ }
2674
+ const scope = await writableScopeForCall(call, context);
2675
+ if (!scope.ok) {
2676
+ return false;
2677
+ }
2678
+ for (const rule of rules) {
2679
+ if (rule.kind !== 'filesystem-write-root') {
2680
+ continue;
2681
+ }
2682
+ if (rule.operations?.length && !rule.operations.includes(operation)) {
2683
+ continue;
2684
+ }
2685
+ const root = await normalizeWritablePath(resolve(rule.root));
2686
+ if (isPathInside(scope.approvalRoot, root)) {
2687
+ return true;
2688
+ }
2689
+ }
2690
+ return false;
2691
+ }
2692
+ function commandPrefixRuleMatches(rule, segment, cwd) {
2693
+ if (rule.kind !== 'shell-prefix' || rule.prefix.trim().length === 0) {
2694
+ return false;
2695
+ }
2696
+ if (rule.cwd && !isPathInside(resolve(cwd), resolve(rule.cwd))) {
2697
+ return false;
2698
+ }
2699
+ let parsedRule;
2700
+ try {
2701
+ parsedRule = parseSimpleCommand(rule.prefix, { allowExecutablePath: true });
2702
+ }
2703
+ catch {
2704
+ return false;
2705
+ }
2706
+ if (parsedRule.command !== segment.command) {
2707
+ return false;
2708
+ }
2709
+ if (segment.command === 'rm' || segment.command === 'mkdir') {
2710
+ return parsedRule.args.length === segment.args.length && parsedRule.args.every((arg, index) => segment.args[index] === arg);
2711
+ }
2712
+ if (segment.command === 'write' || segment.command === 'edit') {
2713
+ return false;
2714
+ }
2715
+ if (parsedRule.args.length > segment.args.length) {
2716
+ return false;
2717
+ }
2718
+ return parsedRule.args.every((arg, index) => segment.args[index] === arg);
2719
+ }
2720
+ function rawCommandRuleMatches(rule, commandLine, cwd) {
2721
+ if (rule.kind !== 'shell-prefix' || rule.prefix.trim().length === 0) {
2722
+ return false;
2723
+ }
2724
+ if (rule.cwd && !isPathInside(resolve(cwd), resolve(rule.cwd))) {
2725
+ return false;
2726
+ }
2727
+ const prefix = rule.prefix.trim();
2728
+ if (!/[;&|<>`$]/.test(prefix)) {
2729
+ return false;
2730
+ }
2731
+ return commandLine.trim() === prefix;
2732
+ }
2733
+ function validateFlags(command, options) {
2734
+ for (const arg of command.args) {
2735
+ if (!arg.startsWith('-') || arg === '-') {
2736
+ continue;
2737
+ }
2738
+ if (options.deniedFlags.has(arg)) {
2739
+ return { ok: false, reason: `Flag is not allowed for ${command.command}: ${arg}` };
2740
+ }
2741
+ if (!options.allowedFlags.has(arg) && !isNumericCountFlag(arg)) {
2742
+ return { ok: false, reason: `Flag is not in the read-only allowlist for ${command.command}: ${arg}` };
2743
+ }
2744
+ }
2745
+ return { ok: true };
2746
+ }
2747
+ function validateFlagsWithValues(command, options) {
2748
+ for (let index = 0; index < command.args.length; index += 1) {
2749
+ const arg = command.args[index];
2750
+ if (!arg || !arg.startsWith('-') || arg === '-') {
2751
+ continue;
2752
+ }
2753
+ const [flagName] = arg.split('=', 1);
2754
+ if (!flagName) {
2755
+ continue;
2756
+ }
2757
+ if (options.deniedFlags.has(arg) || options.deniedFlags.has(flagName)) {
2758
+ return { ok: false, reason: `Flag is not allowed for ${command.command}: ${arg}` };
2759
+ }
2760
+ if (options.valueFlags.has(flagName)) {
2761
+ if (!arg.includes('=')) {
2762
+ index += 1;
2763
+ }
2764
+ continue;
2765
+ }
2766
+ if (!options.allowedFlags.has(arg) && !options.allowedFlags.has(flagName) && !isNumericCountFlag(arg)) {
2767
+ return { ok: false, reason: `Flag is not in the read-only allowlist for ${command.command}: ${arg}` };
2768
+ }
2769
+ }
2770
+ return { ok: true };
2771
+ }
2772
+ function validateHeadTailCommand(command, options) {
2773
+ const denied = new Set(['-f', '--follow', '--retry', '--pid']);
2774
+ for (let index = 0; index < command.args.length; index += 1) {
2775
+ const arg = command.args[index];
2776
+ if (!arg || !arg.startsWith('-') || arg === '-') {
2777
+ continue;
2778
+ }
2779
+ if (denied.has(arg) || (!options.allowFollow && arg.startsWith('--follow'))) {
2780
+ return { ok: false, reason: `Streaming flags are not allowed for ${command.command}: ${arg}` };
2781
+ }
2782
+ if (arg === '-n' || arg === '-c' || arg === '--lines' || arg === '--bytes') {
2783
+ index += 1;
2784
+ continue;
2785
+ }
2786
+ if (arg.startsWith('-n') || arg.startsWith('-c') || arg.startsWith('--lines=') || arg.startsWith('--bytes=') || isNumericCountFlag(arg)) {
2787
+ continue;
2788
+ }
2789
+ return { ok: false, reason: `Flag is not in the read-only allowlist for ${command.command}: ${arg}` };
2790
+ }
2791
+ return { ok: true };
2792
+ }
2793
+ function validateRipgrepCommand(command) {
2794
+ const deniedPrefixes = [
2795
+ '--hidden',
2796
+ '--no-ignore',
2797
+ '--unrestricted',
2798
+ '--pre',
2799
+ '--pre-glob',
2800
+ '--files-with-matches',
2801
+ '--files-without-match',
2802
+ ];
2803
+ const deniedExact = new Set(['-u', '-uu', '-uuu', '--debug']);
2804
+ for (const arg of command.args) {
2805
+ if (deniedExact.has(arg) || deniedPrefixes.some(prefix => arg === prefix || arg.startsWith(`${prefix}=`))) {
2806
+ return { ok: false, reason: `Flag is not allowed for rg in read-only mode: ${arg}` };
2807
+ }
2808
+ }
2809
+ return { ok: true };
2810
+ }
2811
+ function validateGrepCommand(command) {
2812
+ const valueFlags = new Set(['-e', '--regexp', '-f', '--file', '-m', '--max-count', '-A', '-B', '-C', '--after-context', '--before-context', '--context']);
2813
+ const allowedFlags = new Set([
2814
+ '-n',
2815
+ '--line-number',
2816
+ '-H',
2817
+ '--with-filename',
2818
+ '-h',
2819
+ '--no-filename',
2820
+ '-i',
2821
+ '--ignore-case',
2822
+ '-I',
2823
+ '-v',
2824
+ '--invert-match',
2825
+ '-w',
2826
+ '--word-regexp',
2827
+ '-x',
2828
+ '--line-regexp',
2829
+ '-F',
2830
+ '--fixed-strings',
2831
+ '-E',
2832
+ '--extended-regexp',
2833
+ '-G',
2834
+ '--basic-regexp',
2835
+ '-c',
2836
+ '--count',
2837
+ '-l',
2838
+ '--files-with-matches',
2839
+ '-L',
2840
+ '--files-without-match',
2841
+ '-o',
2842
+ '--only-matching',
2843
+ '-s',
2844
+ '--no-messages',
2845
+ ]);
2846
+ const deniedFlags = new Set(['-r', '-R', '--recursive', '-d', '--directories', '-D', '--devices', '--exclude', '--exclude-from', '--include']);
2847
+ return validateFlagsWithValues(command, { allowedFlags, valueFlags, deniedFlags });
2848
+ }
2849
+ function validateFindCommand(command) {
2850
+ const denied = new Set([
2851
+ '-exec',
2852
+ '-execdir',
2853
+ '-ok',
2854
+ '-okdir',
2855
+ '-delete',
2856
+ '-fls',
2857
+ '-fprint',
2858
+ '-fprint0',
2859
+ '-fprintf',
2860
+ '-printf',
2861
+ ]);
2862
+ for (const arg of command.args) {
2863
+ if (denied.has(arg)) {
2864
+ return { ok: false, reason: `Flag is not allowed for find in read-only mode: ${arg}` };
2865
+ }
2866
+ }
2867
+ return { ok: true };
2868
+ }
2869
+ function validatePsCommand(command) {
2870
+ const allowedFlags = new Set(['-a', '-A', '-e', '-f', '-j', '-l', '-u', '-x', '-r', '-p', '-o', '-c', '-M', '-m', '-ww', '-w']);
2871
+ const deniedFlags = new Set(['--sort']);
2872
+ return validateFlagsWithValues(command, {
2873
+ allowedFlags,
2874
+ valueFlags: new Set(['-p', '-o', '-u', '-U', '-G', '-g', '-t']),
2875
+ deniedFlags,
2876
+ });
2877
+ }
2878
+ function validateDateCommand(command) {
2879
+ for (let index = 0; index < command.args.length; index += 1) {
2880
+ const arg = command.args[index];
2881
+ if (!arg) {
2882
+ continue;
2883
+ }
2884
+ if (arg === '-s' || arg === '--set' || arg.startsWith('--set=')) {
2885
+ return { ok: false, reason: `Flag is not allowed for date in read-only mode: ${arg}` };
2886
+ }
2887
+ if (arg === '-r' || arg === '-j' || arg === '-u' || arg === '-R' || arg === '-I') {
2888
+ continue;
2889
+ }
2890
+ if (arg === '-f') {
2891
+ index += 2;
2892
+ continue;
2893
+ }
2894
+ if (arg.startsWith('+')) {
2895
+ continue;
2896
+ }
2897
+ return { ok: false, reason: `Argument is not in the read-only allowlist for date: ${arg}` };
2898
+ }
2899
+ return { ok: true };
2900
+ }
2901
+ function validateHostnameCommand(command) {
2902
+ if (command.args.length === 0) {
2903
+ return { ok: true };
2904
+ }
2905
+ const allowedFlags = new Set(['-f', '-s', '-d', '-i', '-I']);
2906
+ for (const arg of command.args) {
2907
+ if (!allowedFlags.has(arg)) {
2908
+ return { ok: false, reason: `Flag is not in the read-only allowlist for hostname: ${arg}` };
2909
+ }
2910
+ }
2911
+ return { ok: true };
2912
+ }
2913
+ function validateWhereCommand(command) {
2914
+ const allowedFlags = new Set(['/q', '/t', '/f']);
2915
+ const deniedFlags = new Set(['/r']);
2916
+ for (const arg of command.args) {
2917
+ const normalized = arg.toLowerCase();
2918
+ if (deniedFlags.has(normalized)) {
2919
+ return { ok: false, reason: `Flag is blocked in read-only mode for where: ${arg}` };
2920
+ }
2921
+ if (normalized.startsWith('/')) {
2922
+ if (!allowedFlags.has(normalized)) {
2923
+ return { ok: false, reason: `Flag is not in the read-only allowlist for where: ${arg}` };
2924
+ }
2925
+ continue;
2926
+ }
2927
+ if (!/^[A-Za-z0-9_.:-]+$/.test(arg)) {
2928
+ return { ok: false, reason: `Argument is not in the read-only allowlist for where: ${arg}` };
2929
+ }
2930
+ }
2931
+ return { ok: true };
2932
+ }
2933
+ function validateIfconfigCommand(command) {
2934
+ if (command.args.length === 0) {
2935
+ return { ok: true };
2936
+ }
2937
+ if (command.args.length === 1 && command.args[0] === '-a') {
2938
+ return { ok: true };
2939
+ }
2940
+ if (command.args.length === 1 && /^[A-Za-z0-9_.:-]+$/.test(command.args[0] ?? '')) {
2941
+ return { ok: true };
2942
+ }
2943
+ return { ok: false, reason: 'ifconfig read-only mode allows no arguments, -a, or one interface name only.' };
2944
+ }
2945
+ function validateIpCommand(command) {
2946
+ const denied = new Set(['add', 'del', 'delete', 'replace', 'change', 'set', 'flush', 'save', 'restore', 'monitor']);
2947
+ for (const arg of command.args) {
2948
+ if (denied.has(arg)) {
2949
+ return { ok: false, reason: `Argument is not in the read-only allowlist for ip: ${arg}` };
2950
+ }
2951
+ }
2952
+ const allowedGlobalFlags = new Set(['-o', '-oneline', '-brief', '-br', '-4', '-6', '-j', '-json']);
2953
+ const args = command.args.filter(arg => !allowedGlobalFlags.has(arg));
2954
+ if (args.length === 0) {
2955
+ return { ok: true };
2956
+ }
2957
+ const family = args[0];
2958
+ if (!family || !['addr', 'address', 'link', 'route', 'neigh', 'neighbor'].includes(family)) {
2959
+ return { ok: false, reason: `Argument is not in the read-only allowlist for ip: ${family ?? ''}` };
2960
+ }
2961
+ const action = args[1];
2962
+ if (!action) {
2963
+ return { ok: true };
2964
+ }
2965
+ if (!['show', 'list', 'ls', 'get'].includes(action)) {
2966
+ return { ok: false, reason: `Argument is not in the read-only allowlist for ip: ${action}` };
2967
+ }
2968
+ const allowedQualifiers = new Set(['dev', 'to', 'from', 'scope', 'table', 'vrf']);
2969
+ for (let index = 2; index < args.length; index += 1) {
2970
+ const arg = args[index];
2971
+ if (!arg) {
2972
+ continue;
2973
+ }
2974
+ if (allowedQualifiers.has(arg)) {
2975
+ index += 1;
2976
+ continue;
2977
+ }
2978
+ if (/^[A-Za-z0-9_.:/%-]+$/.test(arg)) {
2979
+ continue;
2980
+ }
2981
+ return { ok: false, reason: `Argument is not in the read-only allowlist for ip: ${arg}` };
2982
+ }
2983
+ return { ok: true };
2984
+ }
2985
+ function validateIpconfigCommand(command) {
2986
+ if (command.args.length === 0) {
2987
+ return { ok: true };
2988
+ }
2989
+ const allowed = new Set(['/all', '/displaydns', '/showclassid', '/?']);
2990
+ const denied = new Set(['/release', '/release6', '/renew', '/renew6', '/flushdns', '/registerdns', '/setclassid']);
2991
+ for (const arg of command.args) {
2992
+ const normalized = arg.toLowerCase();
2993
+ if (denied.has(normalized)) {
2994
+ return { ok: false, reason: `Flag is blocked in read-only mode for ipconfig: ${arg}` };
2995
+ }
2996
+ if (!allowed.has(normalized) && !/^[A-Za-z0-9*_.:-]+$/.test(arg)) {
2997
+ return { ok: false, reason: `Flag is not in the read-only allowlist for ipconfig: ${arg}` };
2998
+ }
2999
+ }
3000
+ return { ok: true };
3001
+ }
3002
+ function validateGetmacCommand(command) {
3003
+ const denied = new Set(['/s', '/u', '/p']);
3004
+ const allowedNoValue = new Set(['/v', '/nh', '/?']);
3005
+ const allowedValue = new Set(['/fo']);
3006
+ for (let index = 0; index < command.args.length; index += 1) {
3007
+ const arg = command.args[index];
3008
+ if (!arg) {
3009
+ continue;
3010
+ }
3011
+ const normalized = arg.toLowerCase();
3012
+ if (denied.has(normalized)) {
3013
+ return { ok: false, reason: `Flag is blocked in read-only mode for getmac: ${arg}` };
3014
+ }
3015
+ if (allowedValue.has(normalized)) {
3016
+ const value = command.args[index + 1]?.toLowerCase();
3017
+ if (!value || !['table', 'list', 'csv'].includes(value)) {
3018
+ return { ok: false, reason: `Flag value is not in the read-only allowlist for getmac: ${arg}` };
3019
+ }
3020
+ index += 1;
3021
+ continue;
3022
+ }
3023
+ if (!allowedNoValue.has(normalized)) {
3024
+ return { ok: false, reason: `Flag is not in the read-only allowlist for getmac: ${arg}` };
3025
+ }
3026
+ }
3027
+ return { ok: true };
3028
+ }
3029
+ function validateNetstatCommand(command) {
3030
+ for (const arg of command.args) {
3031
+ if (/^[-/][A-Za-z0-9]+$/.test(arg)) {
3032
+ continue;
3033
+ }
3034
+ if (/^(tcp|udp|tcp4|udp4|tcp6|udp6)$/i.test(arg)) {
3035
+ continue;
3036
+ }
3037
+ return { ok: false, reason: `Argument is not in the read-only allowlist for netstat: ${arg}` };
3038
+ }
3039
+ return { ok: true };
3040
+ }
3041
+ function validateSsCommand(command) {
3042
+ const allowedLong = new Set(['--all', '--listening', '--tcp', '--udp', '--numeric', '--processes', '--resolve', '--summary', '--ipv4', '--ipv6']);
3043
+ for (const arg of command.args) {
3044
+ if (allowedLong.has(arg)) {
3045
+ continue;
3046
+ }
3047
+ if (/^-[altunprs46]+$/.test(arg)) {
3048
+ continue;
3049
+ }
3050
+ if (arg.startsWith('-')) {
3051
+ return { ok: false, reason: `Flag is not in the read-only allowlist for ss: ${arg}` };
3052
+ }
3053
+ if (/^(state|sport|dport|src|dst|=|:|[0-9]+|LISTEN|ESTAB|TIME-WAIT)$/i.test(arg)) {
3054
+ continue;
3055
+ }
3056
+ if (/^[:0-9A-Za-z_.%-]+$/.test(arg)) {
3057
+ continue;
3058
+ }
3059
+ return { ok: false, reason: `Argument is not in the read-only allowlist for ss: ${arg}` };
3060
+ }
3061
+ return { ok: true };
3062
+ }
3063
+ function validateLsofCommand(command) {
3064
+ const allowedNoValue = new Set(['-P', '-n', '-a']);
3065
+ const allowedValue = new Set(['-i', '-p', '-u', '-s']);
3066
+ for (let index = 0; index < command.args.length; index += 1) {
3067
+ const arg = command.args[index];
3068
+ if (!arg) {
3069
+ continue;
3070
+ }
3071
+ if (allowedNoValue.has(arg) || arg.startsWith('-i') || arg.startsWith('-sTCP:')) {
3072
+ continue;
3073
+ }
3074
+ if (allowedValue.has(arg)) {
3075
+ const value = command.args[index + 1];
3076
+ if (!value || !/^[A-Za-z0-9_.,:@%+-]+$/.test(value)) {
3077
+ return { ok: false, reason: `Flag value is not in the read-only allowlist for lsof: ${arg}` };
3078
+ }
3079
+ index += 1;
3080
+ continue;
3081
+ }
3082
+ if (/^:[0-9]+$/.test(arg)) {
3083
+ continue;
3084
+ }
3085
+ return { ok: false, reason: `Argument is not in the read-only allowlist for lsof: ${arg}` };
3086
+ }
3087
+ return { ok: true };
3088
+ }
3089
+ function validateArpCommand(command) {
3090
+ if (command.args.length === 0) {
3091
+ return { ok: true };
3092
+ }
3093
+ const allowed = new Set(['-a', '-n', '-v', '/a']);
3094
+ for (const arg of command.args) {
3095
+ if (allowed.has(arg.toLowerCase())) {
3096
+ continue;
3097
+ }
3098
+ if (arg.startsWith('-') || arg.startsWith('/')) {
3099
+ return { ok: false, reason: `Flag is not in the read-only allowlist for arp: ${arg}` };
3100
+ }
3101
+ if (/^[0-9A-Fa-f:.%-]+$/.test(arg)) {
3102
+ continue;
3103
+ }
3104
+ return { ok: false, reason: `Argument is not in the read-only allowlist for arp: ${arg}` };
3105
+ }
3106
+ return { ok: true };
3107
+ }
3108
+ function validateTarListCommand(command) {
3109
+ let hasListMode = false;
3110
+ let hasFileFlag = false;
3111
+ const deniedLong = new Set([
3112
+ '--extract',
3113
+ '--get',
3114
+ '--create',
3115
+ '--append',
3116
+ '--update',
3117
+ '--delete',
3118
+ '--to-command',
3119
+ '--checkpoint-action',
3120
+ '--remove-files',
3121
+ '--overwrite',
3122
+ ]);
3123
+ for (let index = 0; index < command.args.length; index += 1) {
3124
+ const arg = command.args[index];
3125
+ if (!arg) {
3126
+ continue;
3127
+ }
3128
+ if (deniedLong.has(arg) || Array.from(deniedLong).some(flag => arg.startsWith(`${flag}=`))) {
3129
+ return { ok: false, reason: `Flag is not allowed for tar in read-only mode: ${arg}` };
3130
+ }
3131
+ if (arg === '-t' || arg === '--list' || (arg.startsWith('-') && arg.includes('t'))) {
3132
+ hasListMode = true;
3133
+ }
3134
+ if (arg === '-f' || arg === '--file') {
3135
+ hasFileFlag = true;
3136
+ index += 1;
3137
+ continue;
3138
+ }
3139
+ if (arg.startsWith('--file=')) {
3140
+ hasFileFlag = true;
3141
+ continue;
3142
+ }
3143
+ if (arg.startsWith('-')) {
3144
+ if (/[xcduA]/.test(arg)) {
3145
+ return { ok: false, reason: `Flag is not allowed for tar in read-only mode: ${arg}` };
3146
+ }
3147
+ continue;
3148
+ }
3149
+ }
3150
+ if (!hasListMode) {
3151
+ return { ok: false, reason: 'Only tar list mode is allowed in read-only mode.' };
3152
+ }
3153
+ if (!hasFileFlag && !command.args.some(arg => arg === '-tf' || arg === '-tvf' || arg === '-ztf' || arg === '-jtf' || arg === '-Jtf')) {
3154
+ return { ok: false, reason: 'tar list mode must name an archive file with -f.' };
3155
+ }
3156
+ return { ok: true };
3157
+ }
3158
+ function validateGitCommand(command) {
3159
+ const subcommand = command.args.find(arg => !arg.startsWith('-'));
3160
+ const allowedSubcommands = new Set(['status', 'diff', 'log', 'show', 'branch', 'rev-parse', 'ls-files', 'grep', 'blame', 'describe', 'tag']);
3161
+ if (!subcommand || !allowedSubcommands.has(subcommand)) {
3162
+ return { ok: false, reason: `Only read-only git subcommands are allowed: ${Array.from(allowedSubcommands).join(', ')}.` };
3163
+ }
3164
+ const deniedPrefixes = ['--git-dir', '--work-tree', '--exec-path', '--output', '--ext-diff', '--upload-pack', '--receive-pack'];
3165
+ const deniedExact = new Set(['--no-index', '--external-diff', '--quiet', '--paginate']);
3166
+ for (const arg of command.args) {
3167
+ if (deniedExact.has(arg) || deniedPrefixes.some(prefix => arg === prefix || arg.startsWith(`${prefix}=`))) {
3168
+ return { ok: false, reason: `Flag is not allowed for git in read-only mode: ${arg}` };
3169
+ }
3170
+ }
3171
+ return { ok: true };
3172
+ }
3173
+ function isNumericCountFlag(arg) {
3174
+ return /^-\d+$/.test(arg);
3175
+ }
3176
+ async function validateCommandPathOperands(command, context) {
3177
+ const operands = pathOperands(command);
3178
+ for (const operand of operands) {
3179
+ await resolveReadablePath(operand, context, { rejectSensitive: true });
3180
+ }
3181
+ }
3182
+ function pathOperands(command) {
3183
+ if ([
3184
+ 'pwd',
3185
+ 'git',
3186
+ 'ps',
3187
+ 'uname',
3188
+ 'date',
3189
+ 'whoami',
3190
+ 'id',
3191
+ 'which',
3192
+ 'where',
3193
+ 'hostname',
3194
+ 'uptime',
3195
+ 'ifconfig',
3196
+ 'ip',
3197
+ 'ipconfig',
3198
+ 'getmac',
3199
+ 'netstat',
3200
+ 'ss',
3201
+ 'lsof',
3202
+ 'arp',
3203
+ 'basename',
3204
+ 'dirname',
3205
+ ].includes(command.command)) {
3206
+ return [];
3207
+ }
3208
+ if (command.command === 'rg') {
3209
+ const nonFlags = command.args.filter(arg => !arg.startsWith('-'));
3210
+ if (command.args.includes('--files')) {
3211
+ return nonFlags;
3212
+ }
3213
+ return nonFlags.slice(1);
3214
+ }
3215
+ if (command.command === 'grep') {
3216
+ return grepPathOperands(command);
3217
+ }
3218
+ if (command.command === 'find') {
3219
+ return findPathOperands(command);
3220
+ }
3221
+ if (command.command === 'tar') {
3222
+ return tarPathOperands(command);
3223
+ }
3224
+ const skipValueFlagsByCommand = {
3225
+ stat: new Set(['-f', '-c', '-t']),
3226
+ du: new Set(['-d', '--max-depth']),
3227
+ sort: new Set(['-k', '--key', '-t', '--field-separator']),
3228
+ uniq: new Set(['-f', '-s', '-w']),
3229
+ cut: new Set(['-b', '--bytes', '-c', '--characters', '-f', '--fields', '-d', '--delimiter', '--output-delimiter']),
3230
+ nl: new Set(['-b', '-d', '-f', '-h', '-i', '-l', '-n', '-s', '-v', '-w']),
3231
+ diff: new Set(['-U', '--unified', '-C', '--context', '-L', '--label', '-I', '--ignore-matching-lines', '-x', '--exclude']),
3232
+ cmp: new Set(['-n', '-i']),
3233
+ comm: new Set(['--output-delimiter']),
3234
+ strings: new Set(['-n', '-t', '-e']),
3235
+ hexdump: new Set(['-n', '-s']),
3236
+ od: new Set(['-A', '-j', '-N', '-t', '-w']),
3237
+ xxd: new Set(['-c', '-g', '-l', '-s']),
3238
+ };
3239
+ const skipValueFlags = skipValueFlagsByCommand[command.command];
3240
+ const operands = [];
3241
+ for (let index = 0; index < command.args.length; index += 1) {
3242
+ const arg = command.args[index];
3243
+ if (!arg) {
3244
+ continue;
3245
+ }
3246
+ if (command.command === 'head' || command.command === 'tail') {
3247
+ if (arg === '-n' || arg === '-c' || arg === '--lines' || arg === '--bytes') {
3248
+ index += 1;
3249
+ continue;
3250
+ }
3251
+ }
3252
+ if (command.command === 'stat' && (arg === '-f' || arg === '-c' || arg === '-t')) {
3253
+ index += 1;
3254
+ continue;
3255
+ }
3256
+ if (command.command === 'du' && (arg === '-d' || arg === '--max-depth')) {
3257
+ index += 1;
3258
+ continue;
3259
+ }
3260
+ if (skipValueFlags?.has(arg)) {
3261
+ index += 1;
3262
+ continue;
3263
+ }
3264
+ if (arg.includes('=')) {
3265
+ const [flagName] = arg.split('=', 1);
3266
+ if (flagName && skipValueFlags?.has(flagName)) {
3267
+ continue;
3268
+ }
3269
+ }
3270
+ if (arg.startsWith('-') && arg !== '-') {
3271
+ continue;
3272
+ }
3273
+ operands.push(arg);
3274
+ }
3275
+ return operands;
3276
+ }
3277
+ function grepPathOperands(command) {
3278
+ const operands = [];
3279
+ let patternConsumed = false;
3280
+ for (let index = 0; index < command.args.length; index += 1) {
3281
+ const arg = command.args[index];
3282
+ if (!arg) {
3283
+ continue;
3284
+ }
3285
+ if (arg === '-e' || arg === '--regexp' || arg === '-f' || arg === '--file' || arg === '-m' || arg === '--max-count' || arg === '-A' || arg === '-B' || arg === '-C' || arg === '--after-context' || arg === '--before-context' || arg === '--context') {
3286
+ index += 1;
3287
+ if (arg === '-e' || arg === '--regexp') {
3288
+ patternConsumed = true;
3289
+ }
3290
+ continue;
3291
+ }
3292
+ if (arg.startsWith('-')) {
3293
+ continue;
3294
+ }
3295
+ if (!patternConsumed) {
3296
+ patternConsumed = true;
3297
+ continue;
3298
+ }
3299
+ operands.push(arg);
3300
+ }
3301
+ return operands;
3302
+ }
3303
+ function findPathOperands(command) {
3304
+ const operands = [];
3305
+ for (const arg of command.args) {
3306
+ if (arg.startsWith('-') || arg === '!' || arg === '(' || arg === ')') {
3307
+ break;
3308
+ }
3309
+ operands.push(arg);
3310
+ }
3311
+ return operands.length > 0 ? operands : ['.'];
3312
+ }
3313
+ function tarPathOperands(command) {
3314
+ const operands = [];
3315
+ for (let index = 0; index < command.args.length; index += 1) {
3316
+ const arg = command.args[index];
3317
+ if (!arg) {
3318
+ continue;
3319
+ }
3320
+ if (arg === '-f' || arg === '--file') {
3321
+ const archive = command.args[index + 1];
3322
+ if (archive) {
3323
+ operands.push(archive);
3324
+ }
3325
+ index += 1;
3326
+ continue;
3327
+ }
3328
+ if (arg.startsWith('--file=')) {
3329
+ operands.push(arg.slice('--file='.length));
3330
+ continue;
3331
+ }
3332
+ if (arg.startsWith('-') && arg.includes('f')) {
3333
+ const archive = command.args[index + 1];
3334
+ if (archive) {
3335
+ operands.push(archive);
3336
+ }
3337
+ index += 1;
3338
+ continue;
3339
+ }
3340
+ if (arg.startsWith('-')) {
3341
+ continue;
3342
+ }
3343
+ }
3344
+ return operands;
3345
+ }
3346
+ function stringifyCommand(command) {
3347
+ return [command.command, ...command.args].join(' ');
3348
+ }
3349
+ function quoteCommandArg(arg) {
3350
+ if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(arg)) {
3351
+ return arg;
3352
+ }
3353
+ return `'${arg.replaceAll("'", "'\\''")}'`;
3354
+ }
3355
+ async function spawnReadOnlyCommand(command, context, options) {
3356
+ return await new Promise((resolvePromise, rejectPromise) => {
3357
+ const child = spawn(command.command, command.args, {
3358
+ cwd: context.cwd,
3359
+ shell: false,
3360
+ env: safeCommandEnv(),
3361
+ stdio: ['ignore', 'pipe', 'pipe'],
3362
+ });
3363
+ let stdout = Buffer.alloc(0);
3364
+ let stderr = Buffer.alloc(0);
3365
+ let timedOut = false;
3366
+ let truncated = false;
3367
+ let settled = false;
3368
+ const finish = (exitCode) => {
3369
+ if (settled)
3370
+ return;
3371
+ settled = true;
3372
+ clearTimeout(timeout);
3373
+ context.signal?.removeEventListener('abort', onAbort);
3374
+ resolvePromise({
3375
+ exitCode,
3376
+ stdout: stdout.toString('utf8'),
3377
+ stderr: stderr.toString('utf8'),
3378
+ bytes: stdout.byteLength + stderr.byteLength,
3379
+ timedOut,
3380
+ truncated,
3381
+ });
3382
+ };
3383
+ const kill = () => {
3384
+ if (!child.killed) {
3385
+ child.kill('SIGTERM');
3386
+ }
3387
+ };
3388
+ const append = (stream, chunk) => {
3389
+ const remaining = Math.max(0, options.maxBytes - stdout.byteLength - stderr.byteLength);
3390
+ if (remaining <= 0) {
3391
+ truncated = true;
3392
+ kill();
3393
+ return;
3394
+ }
3395
+ const next = chunk.byteLength > remaining ? chunk.subarray(0, remaining) : chunk;
3396
+ if (stream === 'stdout') {
3397
+ stdout = Buffer.concat([stdout, next]);
3398
+ }
3399
+ else {
3400
+ stderr = Buffer.concat([stderr, next]);
3401
+ }
3402
+ if (next.byteLength < chunk.byteLength || stdout.byteLength + stderr.byteLength >= options.maxBytes) {
3403
+ truncated = true;
3404
+ kill();
3405
+ }
3406
+ };
3407
+ const onAbort = () => {
3408
+ kill();
3409
+ };
3410
+ const timeout = setTimeout(() => {
3411
+ timedOut = true;
3412
+ kill();
3413
+ }, options.timeoutMs);
3414
+ context.signal?.addEventListener('abort', onAbort, { once: true });
3415
+ child.stdout?.on('data', chunk => append('stdout', Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))));
3416
+ child.stderr?.on('data', chunk => append('stderr', Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))));
3417
+ child.on('error', error => {
3418
+ if (settled)
3419
+ return;
3420
+ settled = true;
3421
+ clearTimeout(timeout);
3422
+ context.signal?.removeEventListener('abort', onAbort);
3423
+ if (isSpawnCommandNotFound(error)) {
3424
+ const stderrText = commandNotFoundStderr(command.command);
3425
+ resolvePromise({
3426
+ exitCode: 127,
3427
+ stdout: '',
3428
+ stderr: stderrText,
3429
+ bytes: Buffer.byteLength(stderrText, 'utf8'),
3430
+ timedOut: false,
3431
+ truncated: false,
3432
+ });
3433
+ return;
3434
+ }
3435
+ rejectPromise(error);
3436
+ });
3437
+ child.on('close', code => finish(code ?? -1));
3438
+ });
3439
+ }
3440
+ function isSpawnCommandNotFound(error) {
3441
+ return Boolean(error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT');
3442
+ }
3443
+ function commandNotFoundStderr(command) {
3444
+ return [
3445
+ `Command not found: ${command}`,
3446
+ 'This is recoverable: try a platform-equivalent built-in command, or use execute_shell for an approved package-manager install when the user wants the missing tool installed.',
3447
+ '',
3448
+ ].join('\n');
3449
+ }
3450
+ async function spawnShellSegments(command, context, options) {
3451
+ const segments = [];
3452
+ let stdout = '';
3453
+ let stderr = '';
3454
+ let exitCode = 0;
3455
+ let timedOut = false;
3456
+ let truncated = false;
3457
+ let bytes = 0;
3458
+ let segmentContext = context;
3459
+ for (let index = 0; index < command.segments.length; index += 1) {
3460
+ const segment = command.segments[index];
3461
+ if (!segment) {
3462
+ continue;
3463
+ }
3464
+ const remainingBytes = Math.max(1, options.maxBytes - bytes);
3465
+ const display = stringifyCommand(segment);
3466
+ const result = segment.command === 'cd'
3467
+ ? await executeCdSegment(segment, segmentContext)
3468
+ : await spawnReadOnlyCommand(segment, segmentContext, { timeoutMs: options.timeoutMs, maxBytes: remainingBytes });
3469
+ segments.push({
3470
+ command: display,
3471
+ exitCode: result.exitCode,
3472
+ stdout: result.stdout,
3473
+ stderr: result.stderr,
3474
+ timedOut: result.timedOut,
3475
+ });
3476
+ stdout += result.stdout;
3477
+ stderr += result.stderr;
3478
+ bytes += result.bytes;
3479
+ exitCode = result.exitCode;
3480
+ timedOut = timedOut || result.timedOut;
3481
+ truncated = truncated || result.truncated;
3482
+ if (result.truncated || result.timedOut) {
3483
+ break;
3484
+ }
3485
+ if (segment.command === 'cd' && result.exitCode === 0 && result.cwd) {
3486
+ segmentContext = { ...segmentContext, cwd: result.cwd };
3487
+ }
3488
+ const nextOperator = command.operators[index];
3489
+ if (nextOperator === '&&' && result.exitCode !== 0) {
3490
+ break;
3491
+ }
3492
+ }
3493
+ return {
3494
+ exitCode,
3495
+ stdout,
3496
+ stderr,
3497
+ bytes,
3498
+ timedOut,
3499
+ truncated,
3500
+ segments,
3501
+ };
3502
+ }
3503
+ async function executeCdSegment(command, context) {
3504
+ const target = command.args[0] ?? homedir();
3505
+ try {
3506
+ const workdir = await resolveReadablePath(target, context, { requireDirectory: true, rejectSensitive: false });
3507
+ return {
3508
+ exitCode: 0,
3509
+ stdout: '',
3510
+ stderr: '',
3511
+ bytes: 0,
3512
+ timedOut: false,
3513
+ truncated: false,
3514
+ cwd: workdir.absolutePath,
3515
+ };
3516
+ }
3517
+ catch (error) {
3518
+ const message = error instanceof Error ? error.message : String(error);
3519
+ const stderr = `cd: ${message}\n`;
3520
+ return {
3521
+ exitCode: 1,
3522
+ stdout: '',
3523
+ stderr,
3524
+ bytes: Buffer.byteLength(stderr, 'utf8'),
3525
+ timedOut: false,
3526
+ truncated: false,
3527
+ };
3528
+ }
3529
+ }
3530
+ function safeCommandEnv() {
3531
+ return {
3532
+ PATH: process.env['PATH'] ?? '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin',
3533
+ HOME: process.env['HOME'] ?? '',
3534
+ LANG: process.env['LANG'] ?? 'C.UTF-8',
3535
+ LC_ALL: process.env['LC_ALL'] ?? '',
3536
+ TERM: process.env['TERM'] ?? 'dumb',
3537
+ NO_COLOR: '1',
3538
+ GIT_CONFIG_NOSYSTEM: '1',
3539
+ };
3540
+ }
3541
+ function outputLimitFor(toolName, context) {
3542
+ const toolLimit = toolName === 'read_file' || toolName === 'search_text' || toolName === 'run_command' || toolName === 'execute_shell'
3543
+ ? 128 * 1024
3544
+ : toolName === 'list_files' || toolName === 'glob'
3545
+ ? 64 * 1024
3546
+ : toolName === 'edit_file' || toolName === 'write_file' || toolName === 'create_directory' || toolName === 'delete_file'
3547
+ ? 16 * 1024
3548
+ : 16 * 1024;
3549
+ return clampPositiveInteger(Math.min(toolLimit, context.maxOutputBytes), 1, toolLimit);
3550
+ }
3551
+ async function resolveReadablePath(inputPath, context, options = {}) {
3552
+ if (inputPath.trim().length === 0) {
3553
+ throw toolError('INVALID_INPUT', 'Path must not be empty.');
3554
+ }
3555
+ const absolutePath = isAbsolute(inputPath) ? resolve(inputPath) : resolve(context.cwd, inputPath);
3556
+ const realTarget = await realpath(absolutePath).catch(() => {
3557
+ throw toolError('PATH_NOT_FOUND', `Path does not exist: ${inputPath}`);
3558
+ });
3559
+ const allowedRoots = await Promise.all((context.allowedReadRoots?.length ? context.allowedReadRoots : [context.cwd]).map(root => safeRealRoot(root)));
3560
+ if (!allowedRoots.some(root => isPathInside(realTarget, root))) {
3561
+ throw toolError('PATH_OUTSIDE_ALLOWED_ROOTS', `Path is outside allowed read roots: ${inputPath}`);
3562
+ }
3563
+ if (options.rejectSensitive && isSensitivePath(realTarget)) {
3564
+ throw toolError('SENSITIVE_FILE_DENIED', `Refusing to read sensitive file: ${displayPath(realTarget, context.cwd)}`);
3565
+ }
3566
+ const stats = await stat(realTarget);
3567
+ if (options.requireFile && !stats.isFile()) {
3568
+ throw toolError('NOT_A_FILE', `Path is not a file: ${inputPath}`);
3569
+ }
3570
+ if (options.requireDirectory && !stats.isDirectory()) {
3571
+ throw toolError('NOT_A_DIRECTORY', `Path is not a directory: ${inputPath}`);
3572
+ }
3573
+ return { absolutePath: realTarget, displayPath: displayPath(realTarget, await safeRealRoot(context.cwd)) };
3574
+ }
3575
+ async function resolveWritablePath(inputPath, context) {
3576
+ if (inputPath.trim().length === 0) {
3577
+ throw toolError('INVALID_INPUT', 'Path must not be empty.');
3578
+ }
3579
+ const absolutePath = isAbsolute(inputPath) ? resolve(inputPath) : resolve(context.cwd, inputPath);
3580
+ const normalizedPath = await normalizeWritablePath(absolutePath);
3581
+ if (isSensitivePath(normalizedPath)) {
3582
+ throw toolError('SENSITIVE_FILE_DENIED', `Refusing to write sensitive path: ${displayPath(normalizedPath, context.cwd)}`);
3583
+ }
3584
+ const allowedRoots = await Promise.all((context.allowedWriteRoots?.length ? context.allowedWriteRoots : [context.cwd]).map(root => safeRealRoot(root)));
3585
+ if (!allowedRoots.some(root => isPathInside(normalizedPath, root))) {
3586
+ throw toolError('PATH_OUTSIDE_ALLOWED_ROOTS', `Path is outside allowed write roots: ${inputPath}`);
3587
+ }
3588
+ return { absolutePath: normalizedPath, displayPath: displayPath(normalizedPath, await safeRealRoot(context.cwd)) };
3589
+ }
3590
+ async function safeRealRoot(root) {
3591
+ return realpath(root).catch(() => resolve(root));
3592
+ }
3593
+ async function normalizeWritablePath(absolutePath) {
3594
+ const pending = [];
3595
+ let current = absolutePath;
3596
+ while (true) {
3597
+ try {
3598
+ return join(await realpath(current), ...pending.reverse());
3599
+ }
3600
+ catch {
3601
+ const parent = dirname(current);
3602
+ if (parent === current) {
3603
+ return absolutePath;
3604
+ }
3605
+ pending.push(basename(current));
3606
+ current = parent;
3607
+ }
3608
+ }
3609
+ }
3610
+ async function pathExists(path) {
3611
+ return stat(path).then(() => true, () => false);
3612
+ }
3613
+ function isPathInside(target, root) {
3614
+ const rel = relative(root, target);
3615
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
3616
+ }
3617
+ function displayPath(absolutePath, cwd) {
3618
+ const rel = relative(cwd, absolutePath);
3619
+ if (rel === '') {
3620
+ return '.';
3621
+ }
3622
+ if (!rel.startsWith('..') && !isAbsolute(rel)) {
3623
+ return rel.split(sep).join('/');
3624
+ }
3625
+ return absolutePath;
3626
+ }
3627
+ function isSensitivePath(absolutePath) {
3628
+ const normalized = absolutePath.split(sep).join('/');
3629
+ const name = basename(absolutePath);
3630
+ const lowerName = name.toLowerCase();
3631
+ if (normalized.endsWith('/.agent/config.local.json')) {
3632
+ return true;
3633
+ }
3634
+ return (lowerName === '.env' ||
3635
+ lowerName.startsWith('.env.') ||
3636
+ lowerName === '.npmrc' ||
3637
+ lowerName === '.pypirc' ||
3638
+ lowerName === '.netrc' ||
3639
+ lowerName === 'id_rsa' ||
3640
+ lowerName === 'id_dsa' ||
3641
+ lowerName === 'id_ecdsa' ||
3642
+ lowerName === 'id_ed25519' ||
3643
+ lowerName.endsWith('.pem') ||
3644
+ lowerName.endsWith('.key') ||
3645
+ lowerName.endsWith('.p12') ||
3646
+ lowerName.endsWith('.pfx') ||
3647
+ lowerName.includes('token') ||
3648
+ lowerName.includes('secret'));
3649
+ }
3650
+ async function collectDirectoryEntries(root, context, options) {
3651
+ const items = [];
3652
+ let truncated = false;
3653
+ const visit = async (dir) => {
3654
+ if (items.length >= options.maxEntries) {
3655
+ truncated = true;
3656
+ return;
3657
+ }
3658
+ const entries = await readdir(dir, { withFileTypes: true });
3659
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
3660
+ if (items.length >= options.maxEntries) {
3661
+ truncated = true;
3662
+ return;
3663
+ }
3664
+ if (!options.includeHidden && entry.name.startsWith('.')) {
3665
+ continue;
3666
+ }
3667
+ const absolutePath = join(dir, entry.name);
3668
+ if (isSensitivePath(absolutePath)) {
3669
+ continue;
3670
+ }
3671
+ const type = entry.isDirectory() ? 'directory' : entry.isFile() ? 'file' : entry.isSymbolicLink() ? 'symlink' : 'other';
3672
+ const sizeBytes = type === 'file' ? await fileSizeBytes(absolutePath) : undefined;
3673
+ items.push({ absolutePath, type, ...(sizeBytes !== undefined ? { sizeBytes } : {}) });
3674
+ if (options.recursive && entry.isDirectory() && !isSkippedDirectory(entry.name)) {
3675
+ await visit(absolutePath);
3676
+ }
3677
+ }
3678
+ };
3679
+ await visit(root);
3680
+ return { items, truncated };
3681
+ }
3682
+ async function fileSizeBytes(path) {
3683
+ try {
3684
+ return (await stat(path)).size;
3685
+ }
3686
+ catch {
3687
+ return undefined;
3688
+ }
3689
+ }
3690
+ async function collectFiles(root, context, options) {
3691
+ const stats = await stat(root);
3692
+ if (stats.isFile()) {
3693
+ return { items: [{ absolutePath: root }], truncated: false };
3694
+ }
3695
+ const entries = await collectDirectoryEntries(root, context, { recursive: true, includeHidden: options.includeHidden, maxEntries: options.maxEntries });
3696
+ return {
3697
+ items: entries.items.filter(item => item.type === 'file').map(item => ({ absolutePath: item.absolutePath })),
3698
+ truncated: entries.truncated,
3699
+ };
3700
+ }
3701
+ function isSkippedDirectory(name) {
3702
+ return name === 'node_modules' || name === '.git' || name === 'dist' || name === '.agent';
3703
+ }
3704
+ function sliceLineRange(content, startLine, endLine) {
3705
+ if (startLine === undefined && endLine === undefined) {
3706
+ return content;
3707
+ }
3708
+ const lines = content.split(/\r?\n/);
3709
+ const startIndex = Math.max(0, (startLine ?? 1) - 1);
3710
+ const endIndex = endLine === undefined ? lines.length : Math.max(startIndex, endLine);
3711
+ return lines.slice(startIndex, endIndex).join('\n');
3712
+ }
3713
+ function truncateStringBytes(text, maxBytes, mode) {
3714
+ const bytes = Buffer.from(text, 'utf8');
3715
+ if (bytes.byteLength <= maxBytes) {
3716
+ return { text, truncated: false };
3717
+ }
3718
+ if (mode === 'head') {
3719
+ return { text: bytes.subarray(0, maxBytes).toString('utf8'), truncated: true };
3720
+ }
3721
+ if (mode === 'middle') {
3722
+ const left = Math.max(0, Math.floor((maxBytes - 20) / 2));
3723
+ const right = Math.max(0, maxBytes - 20 - left);
3724
+ return {
3725
+ text: `${bytes.subarray(0, left).toString('utf8')}\n... truncated ...\n${bytes.subarray(bytes.byteLength - right).toString('utf8')}`,
3726
+ truncated: true,
3727
+ };
3728
+ }
3729
+ return { text: bytes.subarray(bytes.byteLength - maxBytes).toString('utf8'), truncated: true };
3730
+ }
3731
+ function createTextMatcher(query, options) {
3732
+ if (options.isRegex) {
3733
+ let regex;
3734
+ try {
3735
+ regex = new RegExp(query, options.caseSensitive ? '' : 'i');
3736
+ }
3737
+ catch {
3738
+ throw toolError('INVALID_REGEX', `Invalid regular expression: ${query}`);
3739
+ }
3740
+ return line => {
3741
+ const match = regex.exec(line);
3742
+ return match ? { column: match.index + 1, text: line } : undefined;
3743
+ };
3744
+ }
3745
+ const needle = options.caseSensitive ? query : query.toLowerCase();
3746
+ return line => {
3747
+ const haystack = options.caseSensitive ? line : line.toLowerCase();
3748
+ const index = haystack.indexOf(needle);
3749
+ return index >= 0 ? { column: index + 1, text: line } : undefined;
3750
+ };
3751
+ }
3752
+ function createGlobMatcher(pattern) {
3753
+ const regex = new RegExp(`^${globToRegex(pattern)}$`);
3754
+ return path => regex.test(path);
3755
+ }
3756
+ function globToRegex(pattern) {
3757
+ let output = '';
3758
+ for (let index = 0; index < pattern.length; index += 1) {
3759
+ const char = pattern[index];
3760
+ const next = pattern[index + 1];
3761
+ if (char === '*' && next === '*') {
3762
+ output += '.*';
3763
+ index += 1;
3764
+ }
3765
+ else if (char === '*') {
3766
+ output += '[^/]*';
3767
+ }
3768
+ else if (char === '?') {
3769
+ output += '[^/]';
3770
+ }
3771
+ else {
3772
+ output += escapeRegex(char ?? '');
3773
+ }
3774
+ }
3775
+ return output;
3776
+ }
3777
+ function escapeRegex(value) {
3778
+ return value.replace(/[|\\{}()[\]^$+?.]/g, '\\$&');
3779
+ }
3780
+ function normalizeTodoItems(value) {
3781
+ if (!Array.isArray(value)) {
3782
+ throw toolError('INVALID_INPUT', 'items must be an array.');
3783
+ }
3784
+ if (value.length === 0) {
3785
+ throw toolError('INVALID_INPUT', 'items must include at least one plan item.');
3786
+ }
3787
+ if (value.length > 30) {
3788
+ throw toolError('INVALID_INPUT', 'items must include at most 30 plan items.');
3789
+ }
3790
+ return value.map((item, index) => normalizeTodoItem(item, index));
3791
+ }
3792
+ function normalizeTodoItem(value, index) {
3793
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
3794
+ throw toolError('INVALID_INPUT', `items[${index}] must be an object.`);
3795
+ }
3796
+ const record = value;
3797
+ const content = record['content'];
3798
+ const status = record['status'];
3799
+ const id = record['id'];
3800
+ if (typeof content !== 'string' || content.trim().length === 0) {
3801
+ throw toolError('INVALID_INPUT', `items[${index}].content must be a non-empty string.`);
3802
+ }
3803
+ if (status !== 'pending' && status !== 'in_progress' && status !== 'completed') {
3804
+ throw toolError('INVALID_INPUT', `items[${index}].status must be pending, in_progress, or completed.`);
3805
+ }
3806
+ if (id !== undefined && (typeof id !== 'string' || id.trim().length === 0)) {
3807
+ throw toolError('INVALID_INPUT', `items[${index}].id must be a non-empty string when provided.`);
3808
+ }
3809
+ return {
3810
+ ...(typeof id === 'string' ? { id: id.trim() } : {}),
3811
+ content: content.trim(),
3812
+ status,
3813
+ };
3814
+ }
3815
+ function requiredString(input, key) {
3816
+ const value = input[key];
3817
+ if (typeof value !== 'string') {
3818
+ throw toolError('INVALID_INPUT', `${key} must be a string.`);
3819
+ }
3820
+ return value;
3821
+ }
3822
+ function optionalString(input, key) {
3823
+ const value = input[key];
3824
+ if (value === undefined)
3825
+ return undefined;
3826
+ if (typeof value !== 'string') {
3827
+ throw toolError('INVALID_INPUT', `${key} must be a string.`);
3828
+ }
3829
+ return value;
3830
+ }
3831
+ function optionalBoolean(input, key) {
3832
+ const value = input[key];
3833
+ if (value === undefined)
3834
+ return undefined;
3835
+ if (typeof value !== 'boolean') {
3836
+ throw toolError('INVALID_INPUT', `${key} must be a boolean.`);
3837
+ }
3838
+ return value;
3839
+ }
3840
+ function optionalPositiveInteger(input, key) {
3841
+ const value = input[key];
3842
+ if (value === undefined)
3843
+ return undefined;
3844
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) {
3845
+ throw toolError('INVALID_INPUT', `${key} must be a positive integer.`);
3846
+ }
3847
+ return value;
3848
+ }
3849
+ function clampPositiveInteger(value, min, max) {
3850
+ return Math.max(min, Math.min(max, Math.floor(value)));
3851
+ }
3852
+ function toolError(code, message) {
3853
+ const error = new Error(message);
3854
+ error.code = code;
3855
+ return error;
3856
+ }
3857
+ function normalizeToolError(error) {
3858
+ if (error && typeof error === 'object' && 'code' in error && typeof error.code === 'string') {
3859
+ return {
3860
+ code: error.code,
3861
+ message: error instanceof Error ? error.message : String(error.code),
3862
+ };
3863
+ }
3864
+ const message = error instanceof Error ? error.message : String(error);
3865
+ if (isCommandPolicyDenial(message)) {
3866
+ return {
3867
+ code: 'COMMAND_DENIED',
3868
+ message,
3869
+ };
3870
+ }
3871
+ return {
3872
+ code: 'TOOL_EXECUTION_FAILED',
3873
+ message,
3874
+ };
3875
+ }