noumen 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +95 -16
  2. package/dist/a2a/index.d.ts +5 -5
  3. package/dist/a2a/index.js +3 -3
  4. package/dist/a2a/index.js.map +1 -1
  5. package/dist/acp/index.d.ts +5 -5
  6. package/dist/acp/index.js +4 -4
  7. package/dist/acp/index.js.map +1 -1
  8. package/dist/{agent-BrkbZyOT.d.ts → agent-1nFVUP9E.d.ts} +319 -15
  9. package/dist/{cache-DVqaCX8v.d.ts → cache-DsRqxx6v.d.ts} +1 -1
  10. package/dist/{chunk-BGG2E6JD.js → chunk-3HEYCV26.js} +1 -1
  11. package/dist/chunk-3SK5GCI6.js +75 -0
  12. package/dist/chunk-3SK5GCI6.js.map +1 -0
  13. package/dist/{chunk-NBDFQYUZ.js → chunk-4HW6LN6D.js} +4784 -2411
  14. package/dist/chunk-4HW6LN6D.js.map +1 -0
  15. package/dist/{chunk-7ZMN7XJE.js → chunk-5JN4SPI7.js} +6 -6
  16. package/dist/chunk-5JN4SPI7.js.map +1 -0
  17. package/dist/{chunk-CPFHEPW4.js → chunk-CS6WNDCF.js} +73 -41
  18. package/dist/chunk-CS6WNDCF.js.map +1 -0
  19. package/dist/chunk-EKOGVTBT.js +472 -0
  20. package/dist/chunk-EKOGVTBT.js.map +1 -0
  21. package/dist/{chunk-KY6ZPWHO.js → chunk-HEQQQGK5.js} +47 -28
  22. package/dist/chunk-HEQQQGK5.js.map +1 -0
  23. package/dist/{chunk-QTJ7VTJY.js → chunk-HL6JCRZJ.js} +1599 -481
  24. package/dist/chunk-HL6JCRZJ.js.map +1 -0
  25. package/dist/chunk-L3L3FG5T.js +16 -0
  26. package/dist/chunk-L3L3FG5T.js.map +1 -0
  27. package/dist/cli/index.js +36 -30
  28. package/dist/cli/index.js.map +1 -1
  29. package/dist/client/index.d.ts +2 -2
  30. package/dist/{headless-Q7XHHZIW.js → headless-FFU2DESQ.js} +3 -4
  31. package/dist/headless-FFU2DESQ.js.map +1 -0
  32. package/dist/index.d.ts +218 -68
  33. package/dist/index.js +37 -23
  34. package/dist/lsp/index.d.ts +4 -4
  35. package/dist/mcp/index.d.ts +5 -5
  36. package/dist/mcp/index.js +2 -1
  37. package/dist/mcp/index.js.map +1 -1
  38. package/dist/{provider-factory-34MSWJZ3.js → provider-factory-KCLIF34X.js} +2 -2
  39. package/dist/providers/anthropic.d.ts +2 -2
  40. package/dist/providers/anthropic.js +5 -3
  41. package/dist/providers/anthropic.js.map +1 -1
  42. package/dist/providers/bedrock.d.ts +2 -2
  43. package/dist/providers/bedrock.js +5 -3
  44. package/dist/providers/bedrock.js.map +1 -1
  45. package/dist/providers/gemini.d.ts +2 -1
  46. package/dist/providers/gemini.js +133 -95
  47. package/dist/providers/gemini.js.map +1 -1
  48. package/dist/providers/ollama.d.ts +13 -0
  49. package/dist/{ollama-YNXAYP3R.js → providers/ollama.js} +6 -4
  50. package/dist/providers/ollama.js.map +1 -0
  51. package/dist/providers/openai.d.ts +4 -1
  52. package/dist/providers/openai.js +2 -1
  53. package/dist/providers/openrouter.d.ts +1 -1
  54. package/dist/providers/openrouter.js +2 -1
  55. package/dist/providers/openrouter.js.map +1 -1
  56. package/dist/providers/vertex.d.ts +4 -2
  57. package/dist/providers/vertex.js +6 -3
  58. package/dist/providers/vertex.js.map +1 -1
  59. package/dist/{resolve-XM52G7YE.js → resolve-4JA2BBDA.js} +2 -2
  60. package/dist/server/index.d.ts +35 -20
  61. package/dist/server/index.js +276 -207
  62. package/dist/server/index.js.map +1 -1
  63. package/dist/{server-Cg1yWGaV.d.ts → server-CHMxuWKq.d.ts} +1 -1
  64. package/dist/{types-DwdzmXfs.d.ts → types-CD0rUKKT.d.ts} +2 -0
  65. package/dist/{types-3c88cRKH.d.ts → types-LrU4LRmX.d.ts} +28 -0
  66. package/dist/{types-CwKKucOF.d.ts → types-RPKUTu1k.d.ts} +27 -2
  67. package/dist/uuid-RVN2T26F.js +8 -0
  68. package/dist/uuid-RVN2T26F.js.map +1 -0
  69. package/dist/zod-7YXKWYMC.js +12 -0
  70. package/dist/zod-7YXKWYMC.js.map +1 -0
  71. package/package.json +22 -13
  72. package/dist/chunk-2ZTGQLYK.js +0 -356
  73. package/dist/chunk-2ZTGQLYK.js.map +0 -1
  74. package/dist/chunk-7ZMN7XJE.js.map +0 -1
  75. package/dist/chunk-CPFHEPW4.js.map +0 -1
  76. package/dist/chunk-KY6ZPWHO.js.map +0 -1
  77. package/dist/chunk-NBDFQYUZ.js.map +0 -1
  78. package/dist/chunk-QTJ7VTJY.js.map +0 -1
  79. package/dist/headless-Q7XHHZIW.js.map +0 -1
  80. package/dist/ollama-YNXAYP3R.js.map +0 -1
  81. /package/dist/{chunk-BGG2E6JD.js.map → chunk-3HEYCV26.js.map} +0 -0
  82. /package/dist/{provider-factory-34MSWJZ3.js.map → provider-factory-KCLIF34X.js.map} +0 -0
  83. /package/dist/{resolve-XM52G7YE.js.map → resolve-4JA2BBDA.js.map} +0 -0
@@ -1,9 +1,15 @@
1
+ import {
2
+ formatZodValidationError
3
+ } from "./chunk-3SK5GCI6.js";
1
4
  import {
2
5
  IMAGE_EXTENSIONS,
3
6
  compressImageBufferWithTokenLimit,
4
7
  createImageMetadataText,
5
8
  maybeResizeAndDownsampleImageBuffer
6
9
  } from "./chunk-5GEX6ZSB.js";
10
+ import {
11
+ contentToString
12
+ } from "./chunk-JACGEMTF.js";
7
13
 
8
14
  // src/tools/tool-search.ts
9
15
  var TOOL_SEARCH_NAME = "ToolSearch";
@@ -166,75 +172,6 @@ function createToolSearchTool(getDeferredTools, getAllTools, getToolsByNames, on
166
172
  };
167
173
  }
168
174
 
169
- // src/utils/zod.ts
170
- var cache = /* @__PURE__ */ new WeakMap();
171
- function zodToJsonSchema(schema) {
172
- const hit = cache.get(schema);
173
- if (hit) return hit;
174
- const zod = schema._zod ? schema : void 0;
175
- if (!zod) {
176
- throw new Error(
177
- "zodToJsonSchema requires a Zod v4 schema. Install zod and pass a z.object(\u2026) schema."
178
- );
179
- }
180
- let toJSONSchema;
181
- try {
182
- const sAny = schema;
183
- if (typeof sAny._toJSONSchema === "function") {
184
- const result = sAny._toJSONSchema();
185
- cache.set(schema, result);
186
- return result;
187
- }
188
- toJSONSchema = globalThis.__noumen_toJSONSchema;
189
- } catch {
190
- }
191
- if (toJSONSchema) {
192
- const result = toJSONSchema(schema);
193
- cache.set(schema, result);
194
- return result;
195
- }
196
- throw new Error(
197
- "Could not convert Zod schema to JSON Schema. Call `registerZodToJsonSchema(toJSONSchema)` from zod/v4 or upgrade to Zod v4."
198
- );
199
- }
200
- function registerZodToJsonSchema(fn) {
201
- globalThis.__noumen_toJSONSchema = fn;
202
- }
203
- function formatZodValidationError(toolName, issues) {
204
- if (!issues || !issues.issues.length) {
205
- return `${toolName}: validation failed with unknown error`;
206
- }
207
- const parts = [];
208
- const missing = issues.issues.filter(
209
- (i) => i.code === "invalid_type" && i.message.includes("required")
210
- );
211
- const unrecognized = issues.issues.filter(
212
- (i) => i.code === "unrecognized_keys"
213
- );
214
- const other = issues.issues.filter(
215
- (i) => !missing.includes(i) && !unrecognized.includes(i)
216
- );
217
- if (missing.length) {
218
- parts.push(
219
- `Missing required parameter${missing.length > 1 ? "s" : ""}: ${missing.map((m) => formatPath(m.path)).join(", ")}`
220
- );
221
- }
222
- if (unrecognized.length) {
223
- parts.push(
224
- `Unrecognized parameter${unrecognized.length > 1 ? "s" : ""}: ${unrecognized.map((u) => u.message).join(", ")}`
225
- );
226
- }
227
- for (const issue of other) {
228
- const path2 = formatPath(issue.path);
229
- parts.push(`${path2 ? path2 + ": " : ""}${issue.message}`);
230
- }
231
- return `${toolName} failed due to the following ${parts.length > 1 ? "issues" : "issue"}:
232
- ${parts.join("\n")}`;
233
- }
234
- function formatPath(path2) {
235
- return path2.map((p, i) => typeof p === "number" ? `[${p}]` : i > 0 ? `.${p}` : p).join("");
236
- }
237
-
238
175
  // src/tools/prompts/read.ts
239
176
  var READ_PROMPT = `Reads a file from the local filesystem. You can access any file directly by using this tool.
240
177
  Assume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.
@@ -253,7 +190,7 @@ Usage:
253
190
  // src/tools/read.ts
254
191
  import * as path from "path";
255
192
  var DEFAULT_MAX_IMAGE_TOKENS = 1600;
256
- var MAX_FILE_SIZE = 10 * 1024 * 1024;
193
+ var MAX_FILE_SIZE = 256 * 1024;
257
194
  var BLOCKED_DEVICE_PATHS = /* @__PURE__ */ new Set([
258
195
  "/dev/zero",
259
196
  "/dev/random",
@@ -331,6 +268,9 @@ var readFileTool = {
331
268
  const filePath = args.file_path;
332
269
  const offset = args.offset ?? 1;
333
270
  const limit = args.limit;
271
+ if (filePath.startsWith("\\\\") || filePath.startsWith("//")) {
272
+ return { content: "Error: UNC paths are not allowed", isError: true };
273
+ }
334
274
  try {
335
275
  const resolved = path.resolve(ctx.cwd, filePath);
336
276
  if (BLOCKED_DEVICE_PATHS.has(resolved)) {
@@ -339,6 +279,12 @@ var readFileTool = {
339
279
  isError: true
340
280
  };
341
281
  }
282
+ if (resolved.startsWith("/proc/") && (resolved.endsWith("/fd/0") || resolved.endsWith("/fd/1") || resolved.endsWith("/fd/2"))) {
283
+ return {
284
+ content: `Error: Cannot read process file descriptor ${filePath}.`,
285
+ isError: true
286
+ };
287
+ }
342
288
  const ext = path.extname(filePath).toLowerCase();
343
289
  if (BINARY_EXTENSIONS.has(ext)) {
344
290
  return {
@@ -351,9 +297,9 @@ var readFileTool = {
351
297
  }
352
298
  try {
353
299
  const stat = await ctx.fs.stat(filePath);
354
- if (stat.size !== void 0 && stat.size > MAX_FILE_SIZE) {
300
+ if (stat.size !== void 0 && stat.size > MAX_FILE_SIZE && !limit) {
355
301
  return {
356
- content: `Error: File is too large (${Math.round(stat.size / 1024 / 1024)}MB). Use offset/limit to read specific portions.`,
302
+ content: `Error: File is too large (${Math.round(stat.size / 1024)}KB, max ${MAX_FILE_SIZE / 1024}KB). Use offset/limit to read specific portions.`,
357
303
  isError: true
358
304
  };
359
305
  }
@@ -372,7 +318,11 @@ var readFileTool = {
372
318
  }
373
319
  }
374
320
  }
375
- const content = await ctx.fs.readFile(filePath);
321
+ const maxReadBytes = limit ? Math.min((limit + (offset - 1)) * 500, 10 * 1024 * 1024) : void 0;
322
+ const content = await ctx.fs.readFile(
323
+ filePath,
324
+ maxReadBytes ? { maxBytes: maxReadBytes } : void 0
325
+ );
376
326
  const lines = content.split("\n");
377
327
  const startIdx = Math.max(0, offset - 1);
378
328
  const endIdx = limit ? Math.min(lines.length, startIdx + limit) : lines.length;
@@ -396,7 +346,8 @@ var readFileTool = {
396
346
  content: selectedLines.join("\n"),
397
347
  timestamp: mtime,
398
348
  offset,
399
- limit
349
+ limit,
350
+ isPartialView: !!(limit || offset > 1)
400
351
  });
401
352
  }
402
353
  return { content: result || "File is empty." };
@@ -448,347 +399,233 @@ async function readImageFile(filePath, ext, ctx) {
448
399
  return { content: parts };
449
400
  }
450
401
 
451
- // src/tools/prompts/write.ts
452
- var WRITE_PROMPT = `Writes a file to the local filesystem. Parent directories are created automatically if they don't exist.
453
-
454
- Usage:
455
- - This tool will overwrite the existing file if there is one at the provided path.
456
- - If this is an existing file, you MUST use the ReadFile tool first to read the file's contents. This tool will fail if you did not read the file first.
457
- - Prefer the EditFile tool for modifying existing files \u2014 it only sends the diff. Only use this tool to create new files or for complete rewrites.
458
- - NEVER create documentation files (*.md) or README files unless explicitly requested by the User.
459
- - Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked.
460
- `;
402
+ // src/permissions/types.ts
403
+ var RULE_SOURCE_PRECEDENCE = [
404
+ "policy",
405
+ "project",
406
+ "user",
407
+ "session"
408
+ ];
461
409
 
462
- // src/tools/write.ts
463
- var writeFileTool = {
464
- name: "WriteFile",
465
- description: "Create or overwrite a file with the given content. Parent directories are created automatically if they don't exist.",
466
- prompt: WRITE_PROMPT,
467
- isReadOnly: false,
468
- checkPermissions(args) {
469
- const filePath = args.file_path;
470
- return {
471
- behavior: "passthrough",
472
- message: `Write to ${filePath}`
473
- };
474
- },
475
- parameters: {
476
- type: "object",
477
- properties: {
478
- file_path: {
479
- type: "string",
480
- description: "The path of the file to write (absolute or relative to cwd)"
481
- },
482
- content: {
483
- type: "string",
484
- description: "The content to write to the file"
485
- }
486
- },
487
- required: ["file_path", "content"]
488
- },
489
- async call(args, ctx) {
490
- const filePath = args.file_path;
491
- const content = args.content;
492
- try {
493
- if (ctx.checkpointManager && ctx.currentMessageId) {
494
- await ctx.checkpointManager.trackEdit(filePath, ctx.currentMessageId, ctx.sessionId ?? "");
495
- }
496
- const existed = await ctx.fs.exists(filePath);
497
- if (existed && ctx.fileStateCache) {
498
- const cached = ctx.fileStateCache.get(filePath);
499
- if (!cached) {
500
- return {
501
- content: `Error: File ${filePath} exists but has not been read yet. Read it first before overwriting.`,
502
- isError: true
503
- };
504
- }
505
- }
506
- await ctx.fs.writeFile(filePath, content);
507
- ctx.notifyHook?.("FileWrite", {
508
- event: "FileWrite",
509
- sessionId: ctx.sessionId ?? "",
510
- toolName: "WriteFile",
511
- filePath,
512
- isNew: !existed
513
- }).catch(() => {
514
- });
515
- if (ctx.fileStateCache) {
516
- let mtime = 0;
517
- try {
518
- const stat = await ctx.fs.stat(filePath);
519
- mtime = stat.modifiedAt ? Math.floor(stat.modifiedAt.getTime()) : 0;
520
- } catch {
521
- }
522
- ctx.fileStateCache.set(filePath, {
523
- content,
524
- timestamp: mtime
525
- });
526
- }
527
- return {
528
- content: existed ? `File updated successfully at: ${filePath}` : `File created successfully at: ${filePath}`
529
- };
530
- } catch (err) {
531
- return {
532
- content: `Error writing file: ${err instanceof Error ? err.message : String(err)}`,
533
- isError: true
534
- };
410
+ // src/permissions/rules.ts
411
+ import * as path2 from "path";
412
+ import * as fs from "fs";
413
+ function toolMatchesRule(toolName, rule, mcpInfo) {
414
+ if (rule.toolName === toolName) return true;
415
+ if (mcpInfo) {
416
+ const serverPrefix = parseMcpServerPrefix(rule.toolName);
417
+ if (serverPrefix && serverPrefix === mcpInfo.serverName) {
418
+ return true;
535
419
  }
536
420
  }
537
- };
538
-
539
- // src/tools/edit-utils.ts
540
- var LEFT_SINGLE_CURLY = "\u2018";
541
- var RIGHT_SINGLE_CURLY = "\u2019";
542
- var LEFT_DOUBLE_CURLY = "\u201C";
543
- var RIGHT_DOUBLE_CURLY = "\u201D";
544
- function normalizeQuotes(str) {
545
- return str.replaceAll(LEFT_SINGLE_CURLY, "'").replaceAll(RIGHT_SINGLE_CURLY, "'").replaceAll(LEFT_DOUBLE_CURLY, '"').replaceAll(RIGHT_DOUBLE_CURLY, '"');
421
+ return false;
546
422
  }
547
- function findActualString(fileContent, searchString) {
548
- if (fileContent.includes(searchString)) {
549
- return searchString;
550
- }
551
- const normalizedSearch = normalizeQuotes(searchString);
552
- const normalizedFile = normalizeQuotes(fileContent);
553
- const searchIndex = normalizedFile.indexOf(normalizedSearch);
554
- if (searchIndex !== -1) {
555
- return fileContent.substring(searchIndex, searchIndex + searchString.length);
556
- }
557
- return null;
423
+ function parseMcpServerPrefix(ruleName) {
424
+ const parts = ruleName.split("__");
425
+ if (parts.length !== 2 || parts[0] !== "mcp" || !parts[1]) return null;
426
+ return parts[1];
558
427
  }
559
- function countOccurrences(haystack, needle) {
560
- const normalizedNeedle = normalizeQuotes(needle);
561
- const normalizedHaystack = normalizeQuotes(haystack);
562
- let count = 0;
563
- let pos = 0;
564
- while (true) {
565
- const idx = normalizedHaystack.indexOf(normalizedNeedle, pos);
566
- if (idx === -1) break;
567
- count++;
568
- pos = idx + 1;
428
+ var SAFE_WRAPPERS = ["timeout", "time", "nice", "nohup", "stdbuf"];
429
+ var COMPOUND_OPERATORS_RE = /\s*(?:;|&&|\|\||\|)\s*/;
430
+ function stripForRuleMatching(command) {
431
+ let cmd = command.trim();
432
+ while (/^[A-Za-z_][A-Za-z0-9_]*=\S*\s/.test(cmd)) {
433
+ cmd = cmd.replace(/^[A-Za-z_][A-Za-z0-9_]*=\S*\s+/, "");
569
434
  }
570
- return count;
435
+ let changed = true;
436
+ while (changed) {
437
+ changed = false;
438
+ for (const wrapper of SAFE_WRAPPERS) {
439
+ if (cmd.startsWith(wrapper + " ")) {
440
+ cmd = cmd.slice(wrapper.length).trim();
441
+ while (cmd.startsWith("-")) {
442
+ const spaceIdx = cmd.indexOf(" ");
443
+ if (spaceIdx === -1) break;
444
+ cmd = cmd.slice(spaceIdx).trim();
445
+ }
446
+ while (/^[A-Za-z_][A-Za-z0-9_]*=\S*\s/.test(cmd)) {
447
+ cmd = cmd.replace(/^[A-Za-z_][A-Za-z0-9_]*=\S*\s+/, "");
448
+ }
449
+ changed = true;
450
+ }
451
+ }
452
+ }
453
+ return cmd;
571
454
  }
572
- function usesCurlyQuotes(str) {
573
- return {
574
- singleCurly: str.includes(LEFT_SINGLE_CURLY) || str.includes(RIGHT_SINGLE_CURLY),
575
- doubleCurly: str.includes(LEFT_DOUBLE_CURLY) || str.includes(RIGHT_DOUBLE_CURLY)
576
- };
455
+ function isCompoundCommand(content) {
456
+ return COMPOUND_OPERATORS_RE.test(content);
577
457
  }
578
- function preserveQuoteStyle(oldString, actualOldString, newString) {
579
- if (oldString === actualOldString) {
580
- return newString;
581
- }
582
- const fileStyle = usesCurlyQuotes(actualOldString);
583
- let result = newString;
584
- if (fileStyle.singleCurly) {
585
- result = convertStraightToCurlySingle(result);
458
+ function contentMatchesRule(content, ruleContent) {
459
+ if (ruleContent.endsWith(":*")) {
460
+ const prefix = ruleContent.slice(0, -2);
461
+ const matches = content === prefix || content.startsWith(prefix + " ");
462
+ if (matches && isCompoundCommand(content)) return false;
463
+ if (matches) return true;
464
+ const stripped2 = stripForRuleMatching(content);
465
+ if (stripped2 !== content) {
466
+ const strippedMatches = stripped2 === prefix || stripped2.startsWith(prefix + " ");
467
+ if (strippedMatches && isCompoundCommand(stripped2)) return false;
468
+ return strippedMatches;
469
+ }
470
+ return false;
586
471
  }
587
- if (fileStyle.doubleCurly) {
588
- result = convertStraightToCurlyDouble(result);
472
+ if (ruleContent.includes("*")) {
473
+ if (matchSimpleGlob(ruleContent, content)) return true;
474
+ const stripped2 = stripForRuleMatching(content);
475
+ if (stripped2 !== content) return matchSimpleGlob(ruleContent, stripped2);
476
+ return false;
589
477
  }
590
- return result;
478
+ if (ruleContent === content) return true;
479
+ const stripped = stripForRuleMatching(content);
480
+ return stripped !== content && ruleContent === stripped;
591
481
  }
592
- function convertStraightToCurlySingle(str) {
593
- let result = "";
594
- let inWord = false;
595
- for (let i = 0; i < str.length; i++) {
596
- const ch = str[i];
597
- if (ch === "'") {
598
- const prev = i > 0 ? str[i - 1] : " ";
599
- if (/\s/.test(prev) || prev === "(" || prev === "[" || prev === "{") {
600
- result += LEFT_SINGLE_CURLY;
601
- inWord = true;
602
- } else {
603
- result += RIGHT_SINGLE_CURLY;
604
- inWord = false;
605
- }
482
+ function matchSimpleGlob(pattern, value) {
483
+ let regex = "^";
484
+ let i = 0;
485
+ while (i < pattern.length) {
486
+ if (pattern[i] === "*" && pattern[i + 1] === "*") {
487
+ regex += ".*";
488
+ i += 2;
489
+ if (pattern[i] === "/") i++;
490
+ } else if (pattern[i] === "*") {
491
+ regex += "[^/]*";
492
+ i++;
493
+ } else if (pattern[i] === "?") {
494
+ regex += "[^/]";
495
+ i++;
606
496
  } else {
607
- result += ch;
608
- inWord = /\w/.test(ch);
497
+ regex += escapeRegex(pattern[i]);
498
+ i++;
609
499
  }
610
500
  }
611
- return result;
501
+ regex += "$";
502
+ return new RegExp(regex).test(value);
612
503
  }
613
- function convertStraightToCurlyDouble(str) {
614
- let result = "";
615
- let open = true;
616
- for (let i = 0; i < str.length; i++) {
617
- const ch = str[i];
618
- if (ch === '"') {
619
- result += open ? LEFT_DOUBLE_CURLY : RIGHT_DOUBLE_CURLY;
620
- open = !open;
621
- } else {
622
- result += ch;
623
- }
624
- }
625
- return result;
504
+ function escapeRegex(s) {
505
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
626
506
  }
627
- function stripTrailingWhitespace(str) {
628
- const parts = str.split(/(\r\n|\n|\r)/);
629
- const result = [];
630
- for (let i = 0; i < parts.length; i++) {
631
- const part = parts[i];
632
- if (i % 2 === 0) {
633
- result.push(part.replace(/[\t ]+$/, ""));
634
- } else {
635
- result.push(part);
507
+ function getMatchingRules(context, toolName, behavior, content, mcpInfo) {
508
+ const matched = context.rules.filter((rule) => {
509
+ if (rule.behavior !== behavior) return false;
510
+ if (!toolMatchesRule(toolName, rule, mcpInfo)) return false;
511
+ if (rule.ruleContent !== void 0) {
512
+ if (content === void 0) return false;
513
+ return contentMatchesRule(content, rule.ruleContent);
636
514
  }
515
+ return true;
516
+ });
517
+ matched.sort((a, b) => {
518
+ const aIdx = a.source ? RULE_SOURCE_PRECEDENCE.indexOf(a.source) : RULE_SOURCE_PRECEDENCE.length;
519
+ const bIdx = b.source ? RULE_SOURCE_PRECEDENCE.indexOf(b.source) : RULE_SOURCE_PRECEDENCE.length;
520
+ return aIdx - bIdx;
521
+ });
522
+ return matched;
523
+ }
524
+ function containsShellExpansion(p) {
525
+ if (p.includes("$") || p.includes("%") || p.startsWith("=")) return true;
526
+ if (p.includes("`")) return true;
527
+ if (/^~[^/]/.test(p)) return true;
528
+ if (p.startsWith("\\\\")) return true;
529
+ return false;
530
+ }
531
+ function isPathInWorkingDirectories(filePath, workingDirectories) {
532
+ if (workingDirectories.length === 0) return false;
533
+ if (containsShellExpansion(filePath)) return false;
534
+ const normalized = normalizePath(filePath);
535
+ return workingDirectories.some((dir) => {
536
+ const normalizedDir = normalizePath(dir);
537
+ return normalized === normalizedDir || normalized.startsWith(normalizedDir + "/");
538
+ });
539
+ }
540
+ function normalizePath(p) {
541
+ let result = path2.resolve(p);
542
+ try {
543
+ result = fs.realpathSync(result);
544
+ } catch {
637
545
  }
638
- return result.join("");
546
+ while (result.endsWith("/") && result.length > 1) {
547
+ result = result.slice(0, -1);
548
+ }
549
+ if (process.platform === "darwin" || process.platform === "win32") {
550
+ result = result.toLowerCase();
551
+ }
552
+ return result;
639
553
  }
640
554
 
641
- // src/tools/prompts/edit.ts
642
- var EDIT_PROMPT = `Performs exact string replacements in files.
555
+ // src/permissions/classifier.ts
556
+ var DEFAULT_CLASSIFIER_PROMPT = `You are a security classifier for an AI coding agent.
557
+ Your job is to determine whether a tool call should be automatically approved or blocked.
643
558
 
644
- Usage:
645
- - You must use the ReadFile tool at least once before editing a file. This tool will error if you attempt an edit without reading the file first.
646
- - When editing text from ReadFile output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: spaces + line number + pipe. Everything after that is the actual file content to match. Never include any part of the line number prefix in the old_string or new_string.
647
- - ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
648
- - Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
649
- - The edit will FAIL if \`old_string\` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use \`replace_all\` to change every instance of \`old_string\`.
650
- - Use \`replace_all\` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.
651
- `;
559
+ Automatically APPROVE tool calls that:
560
+ - Read files within the project directory
561
+ - Write/edit files within the project directory
562
+ - Run common development commands (build, test, lint, format, git status/diff/log)
563
+ - Search for files or code patterns
564
+ - Create or update task items
652
565
 
653
- // src/tools/edit.ts
654
- var editFileTool = {
655
- name: "EditFile",
656
- description: "Edit a file by replacing an exact string match with new content. The old_string must match exactly (including whitespace and indentation). Set replace_all to true to replace all occurrences.",
657
- prompt: EDIT_PROMPT,
658
- isReadOnly: false,
659
- checkPermissions(args) {
660
- const filePath = args.file_path;
566
+ Automatically BLOCK tool calls that:
567
+ - Execute potentially destructive commands (rm -rf, drop database, force push)
568
+ - Access files outside the project directory
569
+ - Make network requests to unknown hosts
570
+ - Run commands that could affect the system (install packages globally, modify system files)
571
+ - Access secrets, credentials, or environment variables
572
+
573
+ Respond with a JSON object: {"shouldBlock": boolean, "reason": "brief explanation"}`;
574
+ async function classifyPermission(toolName, args, recentMessages, provider, opts) {
575
+ const model = opts?.classifierModel ?? opts?.model;
576
+ if (!model) {
577
+ return { shouldBlock: true, reason: "No model configured for classifier." };
578
+ }
579
+ const contextWindow = recentMessages.slice(-6);
580
+ const contextText = contextWindow.map((m) => `${m.role}: ${contentToString(m.content).slice(0, 200)}`).join("\n");
581
+ const userPrompt = `Tool: ${toolName}
582
+ Arguments: ${JSON.stringify(args, null, 2).slice(0, 1e3)}
583
+
584
+ Recent conversation context:
585
+ ${contextText}`;
586
+ const params = {
587
+ model,
588
+ system: opts?.classifierPrompt ?? DEFAULT_CLASSIFIER_PROMPT,
589
+ messages: [{ role: "user", content: userPrompt }],
590
+ max_tokens: 256,
591
+ temperature: 0,
592
+ outputFormat: {
593
+ type: "json_schema",
594
+ schema: {
595
+ type: "object",
596
+ properties: {
597
+ shouldBlock: { type: "boolean" },
598
+ reason: { type: "string" }
599
+ },
600
+ required: ["shouldBlock", "reason"],
601
+ additionalProperties: false
602
+ },
603
+ name: "classifier_result",
604
+ strict: true
605
+ }
606
+ };
607
+ try {
608
+ let text = "";
609
+ for await (const chunk of provider.chat(params)) {
610
+ if (opts?.signal?.aborted) break;
611
+ for (const choice of chunk.choices) {
612
+ if (choice.delta.content) {
613
+ text += choice.delta.content;
614
+ }
615
+ }
616
+ }
617
+ if (opts?.signal?.aborted) {
618
+ throw new DOMException("Aborted", "AbortError");
619
+ }
620
+ const parsed = JSON.parse(text);
661
621
  return {
662
- behavior: "passthrough",
663
- message: `Edit ${filePath}`
622
+ shouldBlock: parsed.shouldBlock ?? false,
623
+ reason: parsed.reason ?? "unknown"
664
624
  };
665
- },
666
- parameters: {
667
- type: "object",
668
- properties: {
669
- file_path: {
670
- type: "string",
671
- description: "The path of the file to edit"
672
- },
673
- old_string: {
674
- type: "string",
675
- description: "The exact string to find and replace"
676
- },
677
- new_string: {
678
- type: "string",
679
- description: "The replacement string"
680
- },
681
- replace_all: {
682
- type: "boolean",
683
- description: "If true, replace all occurrences of old_string. Defaults to false."
684
- }
685
- },
686
- required: ["file_path", "old_string", "new_string"]
687
- },
688
- async call(args, ctx) {
689
- const filePath = args.file_path;
690
- const oldString = args.old_string;
691
- const newString = args.new_string;
692
- const replaceAll = args.replace_all ?? false;
693
- if (filePath.endsWith(".ipynb")) {
694
- return {
695
- content: `Error: ${filePath} is a Jupyter Notebook. Use the NotebookEdit tool to edit notebook files.`,
696
- isError: true
697
- };
698
- }
699
- if (oldString === newString) {
700
- return {
701
- content: "No changes to make: old_string and new_string are exactly the same.",
702
- isError: true
703
- };
704
- }
705
- try {
706
- if (ctx.fileStateCache) {
707
- const cached = ctx.fileStateCache.get(filePath);
708
- if (!cached || cached.isPartialView) {
709
- return {
710
- content: `Error: File has not been read yet. Use ReadFile on ${filePath} before editing.`,
711
- isError: true
712
- };
713
- }
714
- try {
715
- const stat = await ctx.fs.stat(filePath);
716
- const mtime = stat.modifiedAt ? Math.floor(stat.modifiedAt.getTime()) : 0;
717
- if (mtime > cached.timestamp) {
718
- const currentContent = await ctx.fs.readFile(filePath);
719
- if (currentContent !== cached.content) {
720
- return {
721
- content: `Error: ${filePath} has been modified since last read. Re-read the file before editing.`,
722
- isError: true
723
- };
724
- }
725
- }
726
- } catch {
727
- }
728
- }
729
- if (ctx.checkpointManager && ctx.currentMessageId) {
730
- await ctx.checkpointManager.trackEdit(filePath, ctx.currentMessageId, ctx.sessionId ?? "");
731
- }
732
- const content = await ctx.fs.readFile(filePath);
733
- const actualOldString = findActualString(content, oldString);
734
- if (!actualOldString) {
735
- return {
736
- content: `Error: old_string not found in ${filePath}. Make sure the string matches exactly, including whitespace and indentation.`,
737
- isError: true
738
- };
739
- }
740
- if (!replaceAll) {
741
- const count = content.split(actualOldString).length - 1;
742
- if (count > 1) {
743
- return {
744
- content: `Error: old_string appears ${count} times in ${filePath}. Provide more context to make it unique, or set replace_all to true.`,
745
- isError: true
746
- };
747
- }
748
- }
749
- const actualNewString = preserveQuoteStyle(oldString, actualOldString, newString);
750
- let updated;
751
- if (replaceAll) {
752
- updated = content.split(actualOldString).join(actualNewString);
753
- } else if (actualNewString === "") {
754
- const hasTrailingNewline = !actualOldString.endsWith("\n") && content.includes(actualOldString + "\n");
755
- const deleteTarget = hasTrailingNewline ? actualOldString + "\n" : actualOldString;
756
- updated = content.replace(deleteTarget, () => actualNewString);
757
- } else {
758
- updated = content.replace(actualOldString, () => actualNewString);
759
- }
760
- await ctx.fs.writeFile(filePath, updated);
761
- ctx.notifyHook?.("FileWrite", {
762
- event: "FileWrite",
763
- sessionId: ctx.sessionId ?? "",
764
- toolName: "EditFile",
765
- filePath,
766
- isNew: false
767
- }).catch(() => {
768
- });
769
- if (ctx.fileStateCache) {
770
- let mtime = 0;
771
- try {
772
- const stat = await ctx.fs.stat(filePath);
773
- mtime = stat.modifiedAt ? Math.floor(stat.modifiedAt.getTime()) : 0;
774
- } catch {
775
- }
776
- ctx.fileStateCache.set(filePath, {
777
- content: updated,
778
- timestamp: mtime
779
- });
780
- }
781
- return {
782
- content: `File ${filePath} has been updated successfully.`
783
- };
784
- } catch (err) {
785
- return {
786
- content: `Error editing file: ${err instanceof Error ? err.message : String(err)}`,
787
- isError: true
788
- };
789
- }
625
+ } catch {
626
+ return { shouldBlock: true, reason: "Classifier failed; defaulting to block." };
790
627
  }
791
- };
628
+ }
792
629
 
793
630
  // src/tools/shell-safety/git-safety.ts
794
631
  var GIT_INTERNAL_PATTERNS = [
@@ -803,8 +640,8 @@ var GIT_INTERNAL_PATTERNS = [
803
640
  /\.git\/shallow$/,
804
641
  /\.git\/modules\//
805
642
  ];
806
- function isGitInternalPath(path2) {
807
- const normalized = path2.replace(/\\/g, "/");
643
+ function isGitInternalPath(path6) {
644
+ const normalized = path6.replace(/\\/g, "/");
808
645
  return GIT_INTERNAL_PATTERNS.some((p) => p.test(normalized));
809
646
  }
810
647
  var BARE_REPO_MARKERS = ["HEAD", "objects", "refs"];
@@ -813,15 +650,36 @@ function looksLikeBareRepo(dirEntries) {
813
650
  if (entrySet.has(".git")) return false;
814
651
  return BARE_REPO_MARKERS.every((m) => entrySet.has(m));
815
652
  }
653
+ var BARE_REPO_INTERNAL_PATTERNS = [
654
+ /^hooks\//,
655
+ /^config$/,
656
+ /^info\//,
657
+ /^objects\//,
658
+ /^refs\//,
659
+ /^HEAD$/,
660
+ /^index$/,
661
+ /^packed-refs$/,
662
+ /^shallow$/,
663
+ /^modules\//
664
+ ];
665
+ function isBareRepoInternalPath(filePath) {
666
+ const normalized = filePath.replace(/\\/g, "/").replace(/^\.\//, "");
667
+ return BARE_REPO_INTERNAL_PATTERNS.some((p) => p.test(normalized));
668
+ }
816
669
  function commandWritesGitInternals(command) {
817
670
  const redirectPattern = /(?:>{1,2}|tee\s+)\s*(\S+)/g;
818
671
  let match;
819
672
  while ((match = redirectPattern.exec(command)) !== null) {
820
- if (isGitInternalPath(match[1])) return true;
673
+ if (isGitInternalPath(match[1]) || isBareRepoInternalPath(match[1])) return true;
674
+ }
675
+ const copyPatternDotGit = /\b(?:cp|mv|ln)\b.*\s(\S*\.git\/\S+)/;
676
+ const copyMatchDotGit = command.match(copyPatternDotGit);
677
+ if (copyMatchDotGit && isGitInternalPath(copyMatchDotGit[1])) return true;
678
+ const copyPatternBare = /\b(?:cp|mv|ln)\b.*\s(\S+)/g;
679
+ let bareMatch;
680
+ while ((bareMatch = copyPatternBare.exec(command)) !== null) {
681
+ if (isBareRepoInternalPath(bareMatch[1])) return true;
821
682
  }
822
- const copyPattern = /\b(?:cp|mv|ln)\b.*\s(\S*\.git\/\S+)/;
823
- const copyMatch = command.match(copyPattern);
824
- if (copyMatch && isGitInternalPath(copyMatch[1])) return true;
825
683
  return false;
826
684
  }
827
685
 
@@ -840,9 +698,7 @@ var READ_ONLY_COMMANDS = /* @__PURE__ */ new Set([
840
698
  "whereis",
841
699
  "type",
842
700
  "pwd",
843
- "date",
844
701
  "uname",
845
- "hostname",
846
702
  "whoami",
847
703
  "id",
848
704
  "groups",
@@ -850,7 +706,6 @@ var READ_ONLY_COMMANDS = /* @__PURE__ */ new Set([
850
706
  "ll",
851
707
  "la",
852
708
  "dir",
853
- "tree",
854
709
  "stat",
855
710
  "du",
856
711
  "df",
@@ -875,9 +730,6 @@ var READ_ONLY_COMMANDS = /* @__PURE__ */ new Set([
875
730
  "rg",
876
731
  "ag",
877
732
  "ack",
878
- "find",
879
- "fd",
880
- "fdfind",
881
733
  "locate",
882
734
  "readlink",
883
735
  "realpath",
@@ -889,9 +741,6 @@ var READ_ONLY_COMMANDS = /* @__PURE__ */ new Set([
889
741
  "uniq",
890
742
  "cut",
891
743
  "tr",
892
- "awk",
893
- "sed",
894
- // sed -i is destructive but caught by destructive patterns
895
744
  "jq",
896
745
  "yq",
897
746
  "xxd",
@@ -908,17 +757,37 @@ var READ_ONLY_COMMANDS = /* @__PURE__ */ new Set([
908
757
  "[[",
909
758
  "man",
910
759
  "help",
911
- "info",
912
760
  "nproc",
913
761
  "arch",
914
762
  "lscpu",
915
763
  "lsb_release",
916
764
  "sw_vers",
917
765
  "sysctl",
918
- "getconf",
919
- "dotnet"
920
- // dotnet --info, dotnet --list-sdks
766
+ "getconf"
921
767
  ]);
768
+ var CONDITIONAL_READ_ONLY = {
769
+ awk: () => false,
770
+ // awk has system() — never read-only
771
+ sed: (_cmd, tokens) => !tokens.some(
772
+ (t) => t === "-i" || t === "--in-place" || t.startsWith("-") && !t.startsWith("--") && t.includes("i")
773
+ ),
774
+ find: (cmd) => !/\b(-exec\b|-execdir\b|-ok\b|-okdir\b|-delete\b|-fprint\b|-fls\b|-fprintf\b)/.test(cmd),
775
+ fd: (_cmd, tokens) => !tokens.some((t) => ["-x", "--exec", "-X", "--exec-batch"].includes(t)),
776
+ fdfind: (_cmd, tokens) => !tokens.some((t) => ["-x", "--exec", "-X", "--exec-batch"].includes(t)),
777
+ date: (_cmd, tokens) => !tokens.some((t) => ["-s", "--set"].includes(t)),
778
+ hostname: (_cmd, tokens) => {
779
+ const positional = tokens.filter((t) => !t.startsWith("-"));
780
+ return positional.length === 0;
781
+ },
782
+ info: (_cmd, tokens) => !tokens.some((t) => ["-o", "--output", "--dribble", "--init-file"].includes(t)),
783
+ tree: (_cmd, tokens) => !tokens.some((t) => t === "-R"),
784
+ dotnet: (_cmd, tokens) => {
785
+ const positional = tokens.filter((t) => !t.startsWith("-"));
786
+ if (positional.length === 0) return true;
787
+ const sub = positional[0];
788
+ return ["--version", "--info", "--list-sdks", "--list-runtimes"].includes(sub) || tokens.includes("--version") || tokens.includes("--info") || tokens.includes("--list-sdks") || tokens.includes("--list-runtimes");
789
+ }
790
+ };
922
791
  var GIT_READ_ONLY_SUBCOMMANDS = /* @__PURE__ */ new Set([
923
792
  "status",
924
793
  "log",
@@ -938,9 +807,10 @@ var GIT_READ_ONLY_SUBCOMMANDS = /* @__PURE__ */ new Set([
938
807
  "count-objects",
939
808
  "fsck",
940
809
  "verify-pack",
941
- "reflog",
942
810
  "stash",
943
811
  // "stash list" / "stash show" — stash apply/pop are not here
812
+ "reflog",
813
+ // bare / "reflog show" / "reflog list" — expire/delete caught below
944
814
  "tag",
945
815
  // "tag -l" is safe; "tag <name>" creates — caught below
946
816
  "branch",
@@ -954,6 +824,9 @@ var GIT_READ_ONLY_SUBCOMMANDS = /* @__PURE__ */ new Set([
954
824
  "--version",
955
825
  "--help"
956
826
  ]);
827
+ var GIT_READ_ONLY_WRITE_FLAGS = /* @__PURE__ */ new Set([
828
+ "--output"
829
+ ]);
957
830
  var GIT_MUTATING_SUBCOMMANDS = /* @__PURE__ */ new Set([
958
831
  "push",
959
832
  "pull",
@@ -1004,8 +877,8 @@ var DESTRUCTIVE_PATTERNS = [
1004
877
  /\bDROP\s+(TABLE|DATABASE|SCHEMA)\b/i,
1005
878
  /\bTRUNCATE\s+TABLE\b/i,
1006
879
  /\bDELETE\s+FROM\b/i,
1007
- // sed in-place
1008
- /\bsed\s+(-[a-zA-Z]*i[a-zA-Z]*|--in-place)\b/,
880
+ // sed in-place (matches -i anywhere in args, not just first flag)
881
+ /\bsed\b.*\s(-[a-zA-Z]*i[a-zA-Z]*|--in-place)\b/,
1009
882
  // Container/system destruction
1010
883
  /\bdocker\s+(rm|rmi|system\s+prune|volume\s+rm)\b/,
1011
884
  /\bkubectl\s+delete\b/,
@@ -1024,24 +897,149 @@ function hasTokenFlag(tokens, ...flags) {
1024
897
  function splitCompoundCommand(command) {
1025
898
  return command.split(/\s*(?:;|&&|\|\||(?<!\|)\|(?!\|))\s*/).map((s) => s.trim()).filter(Boolean);
1026
899
  }
1027
- function stripPrefixes(command) {
900
+ var DANGEROUS_ENV_VARS = /* @__PURE__ */ new Set([
901
+ "GIT_CONFIG_GLOBAL",
902
+ "GIT_CONFIG_SYSTEM",
903
+ "GIT_DIR",
904
+ "GIT_WORK_TREE",
905
+ "GIT_EXEC_PATH",
906
+ "GIT_TEMPLATE_DIR",
907
+ "LD_PRELOAD",
908
+ "LD_LIBRARY_PATH",
909
+ "PATH",
910
+ "PYTHONPATH",
911
+ "NODE_PATH",
912
+ "PERL5LIB"
913
+ ]);
914
+ function hasDangerousEnvVars(command) {
915
+ const envPattern = /^[A-Za-z_][A-Za-z0-9_]*(?==)/;
1028
916
  let cmd = command.trim();
1029
917
  while (/^[A-Za-z_][A-Za-z0-9_]*=\S*\s/.test(cmd)) {
918
+ const match = cmd.match(envPattern);
919
+ if (match && DANGEROUS_ENV_VARS.has(match[0])) return true;
1030
920
  cmd = cmd.replace(/^[A-Za-z_][A-Za-z0-9_]*=\S*\s+/, "");
1031
921
  }
1032
- for (const prefix of ["sudo", "env", "nohup", "time", "nice", "ionice", "strace", "ltrace"]) {
1033
- if (cmd.startsWith(prefix + " ")) {
1034
- cmd = cmd.slice(prefix.length).trim();
1035
- while (cmd.startsWith("-")) {
1036
- const spaceIdx = cmd.indexOf(" ");
1037
- if (spaceIdx === -1) break;
1038
- cmd = cmd.slice(spaceIdx).trim();
922
+ return false;
923
+ }
924
+ var ZSH_DANGEROUS_COMMANDS = /* @__PURE__ */ new Set([
925
+ "zmodload",
926
+ "emulate",
927
+ "sysopen",
928
+ "sysread",
929
+ "syswrite",
930
+ "sysseek",
931
+ "zpty",
932
+ "ztcp",
933
+ "zsocket",
934
+ "zf_rm",
935
+ "zf_mv",
936
+ "zf_ln",
937
+ "zf_chmod",
938
+ "zf_chown",
939
+ "zf_mkdir",
940
+ "zf_rmdir",
941
+ "zf_chgrp"
942
+ ]);
943
+ var MAX_SUBCOMMANDS = 50;
944
+ function detectInjectionPatterns(command) {
945
+ if (/>\(/.test(command)) return "Output process substitution >(...)";
946
+ if (/=\(/.test(command)) return "Zsh =(...) process substitution";
947
+ if (/\$\{[^}]*[`$]/.test(command)) return "Nested expansion in ${...}";
948
+ if (/[\x00-\x08\x0e-\x1f\x7f]/.test(command)) return "Control character injection";
949
+ if (/[\u00A0\u1680\u2000-\u200A\u202F\u205F\u3000\u200B-\u200D\uFEFF]/.test(command)) {
950
+ return "Unicode whitespace injection";
951
+ }
952
+ if (/\w#/.test(command) && !/['"][^'"]*#/.test(command)) {
953
+ const stripped = command.replace(/'[^']*'/g, "").replace(/"[^"]*"/g, "");
954
+ if (/\w#/.test(stripped)) return "Mid-word comment injection";
955
+ }
956
+ if (/\\n/.test(command)) {
957
+ const stripped = command.replace(/'[^']*'/g, "");
958
+ if (/\$'[^']*\\n/.test(stripped)) return "Escaped newline in $'...' string";
959
+ }
960
+ return null;
961
+ }
962
+ function hasCommandSubstitution(command) {
963
+ return /\$\(/.test(command) || /`[^`]+`/.test(command) || /<\(/.test(command);
964
+ }
965
+ function hasUnquotedExpansion(command) {
966
+ let inSingle = false;
967
+ for (let i = 0; i < command.length; i++) {
968
+ const ch = command[i];
969
+ if (ch === "'" && !inSingle) {
970
+ inSingle = true;
971
+ continue;
972
+ }
973
+ if (ch === "'" && inSingle) {
974
+ inSingle = false;
975
+ continue;
976
+ }
977
+ if (inSingle) continue;
978
+ if (ch === "\\" && i + 1 < command.length) {
979
+ i++;
980
+ continue;
981
+ }
982
+ if (ch === "$" && i + 1 < command.length) {
983
+ const next = command[i + 1];
984
+ if (next === "(") continue;
985
+ if (next === "{" || next >= "A" && next <= "Z" || next >= "a" && next <= "z" || next === "_") {
986
+ return true;
987
+ }
988
+ }
989
+ }
990
+ return false;
991
+ }
992
+ var WRAPPER_COMMANDS = ["sudo", "env", "nohup", "time", "nice", "ionice", "strace", "ltrace", "stdbuf"];
993
+ var WRAPPER_WITH_DURATION = /* @__PURE__ */ new Set(["timeout"]);
994
+ function stripEnvVars(cmd) {
995
+ let result = cmd.trim();
996
+ while (/^[A-Za-z_][A-Za-z0-9_]*=\S*\s/.test(result)) {
997
+ result = result.replace(/^[A-Za-z_][A-Za-z0-9_]*=\S*\s+/, "");
998
+ }
999
+ return result;
1000
+ }
1001
+ function stripWrappers(cmd) {
1002
+ let result = cmd.trim();
1003
+ let prev = "";
1004
+ while (prev !== result) {
1005
+ prev = result;
1006
+ for (const prefix of WRAPPER_COMMANDS) {
1007
+ if (result.startsWith(prefix + " ")) {
1008
+ result = result.slice(prefix.length).trim();
1009
+ while (result.startsWith("-")) {
1010
+ const spaceIdx = result.indexOf(" ");
1011
+ if (spaceIdx === -1) break;
1012
+ result = result.slice(spaceIdx).trim();
1013
+ }
1039
1014
  }
1040
- while (/^[A-Za-z_][A-Za-z0-9_]*=\S*\s/.test(cmd)) {
1041
- cmd = cmd.replace(/^[A-Za-z_][A-Za-z0-9_]*=\S*\s+/, "");
1015
+ }
1016
+ for (const prefix of WRAPPER_WITH_DURATION) {
1017
+ if (result.startsWith(prefix + " ")) {
1018
+ result = result.slice(prefix.length).trim();
1019
+ while (result.startsWith("-")) {
1020
+ const spaceIdx = result.indexOf(" ");
1021
+ if (spaceIdx === -1) break;
1022
+ result = result.slice(spaceIdx).trim();
1023
+ }
1024
+ if (result && !result.startsWith("-")) {
1025
+ const spaceIdx = result.indexOf(" ");
1026
+ if (spaceIdx !== -1) {
1027
+ result = result.slice(spaceIdx).trim();
1028
+ }
1029
+ }
1042
1030
  }
1043
1031
  }
1044
1032
  }
1033
+ return result;
1034
+ }
1035
+ function stripPrefixes(command) {
1036
+ let cmd = command.trim();
1037
+ let prev = "";
1038
+ while (prev !== cmd) {
1039
+ prev = cmd;
1040
+ cmd = stripEnvVars(cmd);
1041
+ cmd = stripWrappers(cmd);
1042
+ }
1045
1043
  return cmd;
1046
1044
  }
1047
1045
  function extractCommandName(command) {
@@ -1102,6 +1100,16 @@ function classifyGitCommand(command) {
1102
1100
  }
1103
1101
  return { isReadOnly: false, isDestructive: false, reason: "git stash mutating operation" };
1104
1102
  }
1103
+ if (subcommand === "reflog") {
1104
+ const reflogSubcmd = positional[0];
1105
+ if (!reflogSubcmd || reflogSubcmd === "show" || reflogSubcmd === "list") {
1106
+ return { isReadOnly: true, isDestructive: false, reason: `git reflog${reflogSubcmd ? " " + reflogSubcmd : ""} is read-only` };
1107
+ }
1108
+ if (reflogSubcmd === "expire" || reflogSubcmd === "delete") {
1109
+ return { isReadOnly: false, isDestructive: true, reason: `git reflog ${reflogSubcmd} is destructive` };
1110
+ }
1111
+ return { isReadOnly: false, isDestructive: false, reason: `git reflog ${reflogSubcmd} is mutating` };
1112
+ }
1105
1113
  if (subcommand === "config") {
1106
1114
  if (hasTokenFlag(flags, "--set", "--add", "--unset", "--unset-all", "--replace-all", "--rename-section", "--remove-section")) {
1107
1115
  return { isReadOnly: false, isDestructive: false, reason: "git config write operation" };
@@ -1116,6 +1124,12 @@ function classifyGitCommand(command) {
1116
1124
  return { isReadOnly: false, isDestructive: false, reason: "git remote mutating operation" };
1117
1125
  }
1118
1126
  }
1127
+ for (const flag of flags) {
1128
+ const flagName = flag.split("=")[0];
1129
+ if (GIT_READ_ONLY_WRITE_FLAGS.has(flagName)) {
1130
+ return { isReadOnly: false, isDestructive: false, reason: `git ${subcommand} with ${flagName} may write files` };
1131
+ }
1132
+ }
1119
1133
  return { isReadOnly: true, isDestructive: false, reason: `git ${subcommand} is read-only` };
1120
1134
  }
1121
1135
  if (GIT_MUTATING_SUBCOMMANDS.has(subcommand)) {
@@ -1133,6 +1147,21 @@ function classifySingleCommand(command, config) {
1133
1147
  if (!name) {
1134
1148
  return { isReadOnly: false, isDestructive: false, reason: "Empty command" };
1135
1149
  }
1150
+ const injectionReason = detectInjectionPatterns(command);
1151
+ if (injectionReason) {
1152
+ return {
1153
+ isReadOnly: false,
1154
+ isDestructive: true,
1155
+ reason: `Injection detected: ${injectionReason}`
1156
+ };
1157
+ }
1158
+ if (ZSH_DANGEROUS_COMMANDS.has(name)) {
1159
+ return {
1160
+ isReadOnly: false,
1161
+ isDestructive: true,
1162
+ reason: `Zsh dangerous command: ${name}`
1163
+ };
1164
+ }
1136
1165
  const allDestructive = [
1137
1166
  ...DESTRUCTIVE_PATTERNS,
1138
1167
  ...config?.extraDestructivePatterns ?? []
@@ -1152,9 +1181,27 @@ function classifySingleCommand(command, config) {
1152
1181
  if (name === "xargs" && /\bgit\b/.test(command)) {
1153
1182
  return classifyGitCommand(command);
1154
1183
  }
1184
+ if (hasCommandSubstitution(command)) {
1185
+ return { isReadOnly: false, isDestructive: false, reason: `Command contains command substitution` };
1186
+ }
1187
+ if (hasUnquotedExpansion(command)) {
1188
+ return { isReadOnly: false, isDestructive: false, reason: `Command contains unquoted variable expansion` };
1189
+ }
1190
+ if (hasDangerousEnvVars(command)) {
1191
+ return { isReadOnly: false, isDestructive: false, reason: `Command uses dangerous environment variable prefix` };
1192
+ }
1155
1193
  if ((name === "echo" || name === "printf") && SAFE_ECHO_RE.test(stripPrefixes(command).trim())) {
1156
1194
  return { isReadOnly: true, isDestructive: false, reason: `${name} with safe arguments is read-only` };
1157
1195
  }
1196
+ const conditionalCheck = CONDITIONAL_READ_ONLY[name];
1197
+ if (conditionalCheck) {
1198
+ const stripped = stripPrefixes(command).trim();
1199
+ const tokens = stripped.split(/\s+/).slice(1);
1200
+ if (conditionalCheck(command, tokens)) {
1201
+ return { isReadOnly: true, isDestructive: false, reason: `${name} is read-only (flags validated)` };
1202
+ }
1203
+ return { isReadOnly: false, isDestructive: false, reason: `${name} has potentially dangerous flags` };
1204
+ }
1158
1205
  const extraReadOnly = new Set(config?.extraReadOnlyCommands ?? []);
1159
1206
  if (READ_ONLY_COMMANDS.has(name) || extraReadOnly.has(name)) {
1160
1207
  return { isReadOnly: true, isDestructive: false, reason: `${name} is read-only` };
@@ -1173,6 +1220,13 @@ function classifyCommand(command, config) {
1173
1220
  if (subCommands.length === 0) {
1174
1221
  return { isReadOnly: true, isDestructive: false, reason: "Empty command" };
1175
1222
  }
1223
+ if (subCommands.length > MAX_SUBCOMMANDS) {
1224
+ return {
1225
+ isReadOnly: false,
1226
+ isDestructive: false,
1227
+ reason: `Too many subcommands (${subCommands.length} > ${MAX_SUBCOMMANDS})`
1228
+ };
1229
+ }
1176
1230
  if (subCommands.length > 1) {
1177
1231
  const hasCd = subCommands.some((s) => /^(cd|pushd)\s/.test(s.trim()));
1178
1232
  const hasGit = subCommands.some((s) => {
@@ -1181,34 +1235,515 @@ function classifyCommand(command, config) {
1181
1235
  });
1182
1236
  if (hasCd && hasGit) {
1183
1237
  return {
1184
- isReadOnly: false,
1185
- isDestructive: false,
1186
- reason: "cd + git compound may escape working directory (bare-repo risk)"
1238
+ isReadOnly: false,
1239
+ isDestructive: false,
1240
+ reason: "cd + git compound may escape working directory (bare-repo risk)"
1241
+ };
1242
+ }
1243
+ if (hasGit && commandWritesGitInternals(command)) {
1244
+ return {
1245
+ isReadOnly: false,
1246
+ isDestructive: true,
1247
+ reason: "Compound command writes to git internal paths before running git"
1248
+ };
1249
+ }
1250
+ }
1251
+ let allReadOnly = true;
1252
+ let anyDestructive = false;
1253
+ const reasons = [];
1254
+ for (const sub of subCommands) {
1255
+ const result = classifySingleCommand(sub, config);
1256
+ if (!result.isReadOnly) allReadOnly = false;
1257
+ if (result.isDestructive) anyDestructive = true;
1258
+ if (result.reason) reasons.push(result.reason);
1259
+ }
1260
+ return {
1261
+ isReadOnly: allReadOnly,
1262
+ isDestructive: anyDestructive,
1263
+ reason: reasons.join("; ")
1264
+ };
1265
+ }
1266
+
1267
+ // src/permissions/pipeline.ts
1268
+ import * as path5 from "path";
1269
+ import * as fs2 from "fs";
1270
+
1271
+ // src/tools/write.ts
1272
+ import * as nodePath from "path";
1273
+
1274
+ // src/tools/prompts/write.ts
1275
+ var WRITE_PROMPT = `Writes a file to the local filesystem. Parent directories are created automatically if they don't exist.
1276
+
1277
+ Usage:
1278
+ - This tool will overwrite the existing file if there is one at the provided path.
1279
+ - If this is an existing file, you MUST use the ReadFile tool first to read the file's contents. This tool will fail if you did not read the file first.
1280
+ - Prefer the EditFile tool for modifying existing files \u2014 it only sends the diff. Only use this tool to create new files or for complete rewrites.
1281
+ - NEVER create documentation files (*.md) or README files unless explicitly requested by the User.
1282
+ - Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked.
1283
+ `;
1284
+
1285
+ // src/tools/file-lock.ts
1286
+ var fileLocks = /* @__PURE__ */ new Map();
1287
+ async function withFileLock(filePath, fn) {
1288
+ const prev = fileLocks.get(filePath) ?? Promise.resolve();
1289
+ let release;
1290
+ const lock = new Promise((resolve4) => {
1291
+ release = resolve4;
1292
+ });
1293
+ fileLocks.set(filePath, lock);
1294
+ await prev;
1295
+ try {
1296
+ return await fn();
1297
+ } finally {
1298
+ release();
1299
+ if (fileLocks.get(filePath) === lock) {
1300
+ fileLocks.delete(filePath);
1301
+ }
1302
+ }
1303
+ }
1304
+
1305
+ // src/tools/write.ts
1306
+ var writeFileTool = {
1307
+ name: "WriteFile",
1308
+ description: "Create or overwrite a file with the given content. Parent directories are created automatically if they don't exist.",
1309
+ prompt: WRITE_PROMPT,
1310
+ isReadOnly: false,
1311
+ checkPermissions(args, ctx) {
1312
+ const filePath = args.file_path;
1313
+ if (filePath.startsWith("\\\\") || filePath.startsWith("//")) {
1314
+ return {
1315
+ behavior: "deny",
1316
+ message: "Error: UNC paths are not allowed"
1317
+ };
1318
+ }
1319
+ if (isDangerousPath(filePath, ctx.cwd)) {
1320
+ return {
1321
+ behavior: "ask",
1322
+ message: `Write targets sensitive path: ${filePath}`,
1323
+ reason: "safetyCheck"
1324
+ };
1325
+ }
1326
+ return {
1327
+ behavior: "passthrough",
1328
+ message: `Write to ${filePath}`
1329
+ };
1330
+ },
1331
+ parameters: {
1332
+ type: "object",
1333
+ properties: {
1334
+ file_path: {
1335
+ type: "string",
1336
+ description: "The path of the file to write (absolute or relative to cwd)"
1337
+ },
1338
+ content: {
1339
+ type: "string",
1340
+ description: "The content to write to the file"
1341
+ }
1342
+ },
1343
+ required: ["file_path", "content"]
1344
+ },
1345
+ async call(args, ctx) {
1346
+ const filePath = args.file_path;
1347
+ const content = args.content;
1348
+ try {
1349
+ if (ctx.checkpointManager && ctx.currentMessageId) {
1350
+ await ctx.checkpointManager.trackEdit(filePath, ctx.currentMessageId, ctx.sessionId ?? "");
1351
+ }
1352
+ const existed = await ctx.fs.exists(filePath);
1353
+ if (existed && ctx.fileStateCache) {
1354
+ const cached = ctx.fileStateCache.get(filePath);
1355
+ if (!cached) {
1356
+ return {
1357
+ content: `Error: File ${filePath} exists but has not been read yet. Read it first before overwriting.`,
1358
+ isError: true
1359
+ };
1360
+ }
1361
+ try {
1362
+ const stat = await ctx.fs.stat(filePath);
1363
+ const mtime = stat.modifiedAt ? Math.floor(stat.modifiedAt.getTime()) : 0;
1364
+ if (mtime > cached.timestamp) {
1365
+ const currentContent = await ctx.fs.readFile(filePath);
1366
+ if (currentContent !== cached.content) {
1367
+ return {
1368
+ content: `Error: ${filePath} has been modified since last read. Re-read the file before overwriting.`,
1369
+ isError: true
1370
+ };
1371
+ }
1372
+ }
1373
+ } catch {
1374
+ }
1375
+ }
1376
+ const dir = nodePath.dirname(filePath);
1377
+ if (dir && dir !== "." && dir !== "/") {
1378
+ await ctx.fs.mkdir(dir, { recursive: true }).catch(() => {
1379
+ });
1380
+ }
1381
+ await withFileLock(filePath, async () => {
1382
+ await ctx.fs.writeFile(filePath, content);
1383
+ });
1384
+ ctx.notifyHook?.("FileWrite", {
1385
+ event: "FileWrite",
1386
+ sessionId: ctx.sessionId ?? "",
1387
+ toolName: "WriteFile",
1388
+ filePath,
1389
+ isNew: !existed
1390
+ }).catch(() => {
1391
+ });
1392
+ if (ctx.fileStateCache) {
1393
+ let mtime = 0;
1394
+ try {
1395
+ const stat = await ctx.fs.stat(filePath);
1396
+ mtime = stat.modifiedAt ? Math.floor(stat.modifiedAt.getTime()) : 0;
1397
+ } catch {
1398
+ }
1399
+ ctx.fileStateCache.set(filePath, {
1400
+ content,
1401
+ timestamp: mtime
1402
+ });
1403
+ }
1404
+ return {
1405
+ content: existed ? `File updated successfully at: ${filePath}` : `File created successfully at: ${filePath}`
1406
+ };
1407
+ } catch (err) {
1408
+ return {
1409
+ content: `Error writing file: ${err instanceof Error ? err.message : String(err)}`,
1410
+ isError: true
1411
+ };
1412
+ }
1413
+ }
1414
+ };
1415
+
1416
+ // src/tools/edit.ts
1417
+ import * as nodePath2 from "path";
1418
+
1419
+ // src/tools/edit-utils.ts
1420
+ var LEFT_SINGLE_CURLY = "\u2018";
1421
+ var RIGHT_SINGLE_CURLY = "\u2019";
1422
+ var LEFT_DOUBLE_CURLY = "\u201C";
1423
+ var RIGHT_DOUBLE_CURLY = "\u201D";
1424
+ function normalizeQuotes(str) {
1425
+ return str.replaceAll(LEFT_SINGLE_CURLY, "'").replaceAll(RIGHT_SINGLE_CURLY, "'").replaceAll(LEFT_DOUBLE_CURLY, '"').replaceAll(RIGHT_DOUBLE_CURLY, '"');
1426
+ }
1427
+ function findActualString(fileContent, searchString) {
1428
+ if (fileContent.includes(searchString)) {
1429
+ return searchString;
1430
+ }
1431
+ const normalizedSearch = normalizeQuotes(searchString);
1432
+ const normalizedFile = normalizeQuotes(fileContent);
1433
+ const searchIndex = normalizedFile.indexOf(normalizedSearch);
1434
+ if (searchIndex !== -1) {
1435
+ return fileContent.substring(searchIndex, searchIndex + searchString.length);
1436
+ }
1437
+ return null;
1438
+ }
1439
+ function countOccurrences(haystack, needle) {
1440
+ const normalizedNeedle = normalizeQuotes(needle);
1441
+ const normalizedHaystack = normalizeQuotes(haystack);
1442
+ let count = 0;
1443
+ let pos = 0;
1444
+ while (true) {
1445
+ const idx = normalizedHaystack.indexOf(normalizedNeedle, pos);
1446
+ if (idx === -1) break;
1447
+ count++;
1448
+ pos = idx + 1;
1449
+ }
1450
+ return count;
1451
+ }
1452
+ function usesCurlyQuotes(str) {
1453
+ return {
1454
+ singleCurly: str.includes(LEFT_SINGLE_CURLY) || str.includes(RIGHT_SINGLE_CURLY),
1455
+ doubleCurly: str.includes(LEFT_DOUBLE_CURLY) || str.includes(RIGHT_DOUBLE_CURLY)
1456
+ };
1457
+ }
1458
+ function preserveQuoteStyle(oldString, actualOldString, newString) {
1459
+ if (oldString === actualOldString) {
1460
+ return newString;
1461
+ }
1462
+ const fileStyle = usesCurlyQuotes(actualOldString);
1463
+ let result = newString;
1464
+ if (fileStyle.singleCurly) {
1465
+ result = convertStraightToCurlySingle(result);
1466
+ }
1467
+ if (fileStyle.doubleCurly) {
1468
+ result = convertStraightToCurlyDouble(result);
1469
+ }
1470
+ return result;
1471
+ }
1472
+ function convertStraightToCurlySingle(str) {
1473
+ let result = "";
1474
+ let inWord = false;
1475
+ for (let i = 0; i < str.length; i++) {
1476
+ const ch = str[i];
1477
+ if (ch === "'") {
1478
+ const prev = i > 0 ? str[i - 1] : " ";
1479
+ if (/\s/.test(prev) || prev === "(" || prev === "[" || prev === "{") {
1480
+ result += LEFT_SINGLE_CURLY;
1481
+ inWord = true;
1482
+ } else {
1483
+ result += RIGHT_SINGLE_CURLY;
1484
+ inWord = false;
1485
+ }
1486
+ } else {
1487
+ result += ch;
1488
+ inWord = /\w/.test(ch);
1489
+ }
1490
+ }
1491
+ return result;
1492
+ }
1493
+ function convertStraightToCurlyDouble(str) {
1494
+ let result = "";
1495
+ let open = true;
1496
+ for (let i = 0; i < str.length; i++) {
1497
+ const ch = str[i];
1498
+ if (ch === '"') {
1499
+ result += open ? LEFT_DOUBLE_CURLY : RIGHT_DOUBLE_CURLY;
1500
+ open = !open;
1501
+ } else {
1502
+ result += ch;
1503
+ }
1504
+ }
1505
+ return result;
1506
+ }
1507
+ function stripTrailingWhitespace(str) {
1508
+ const parts = str.split(/(\r\n|\n|\r)/);
1509
+ const result = [];
1510
+ for (let i = 0; i < parts.length; i++) {
1511
+ const part = parts[i];
1512
+ if (i % 2 === 0) {
1513
+ result.push(part.replace(/[\t ]+$/, ""));
1514
+ } else {
1515
+ result.push(part);
1516
+ }
1517
+ }
1518
+ return result.join("");
1519
+ }
1520
+
1521
+ // src/tools/prompts/edit.ts
1522
+ var EDIT_PROMPT = `Performs exact string replacements in files.
1523
+
1524
+ Usage:
1525
+ - You must use the ReadFile tool at least once before editing a file. This tool will error if you attempt an edit without reading the file first.
1526
+ - When editing text from ReadFile output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: spaces + line number + pipe. Everything after that is the actual file content to match. Never include any part of the line number prefix in the old_string or new_string.
1527
+ - ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
1528
+ - Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
1529
+ - The edit will FAIL if \`old_string\` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use \`replace_all\` to change every instance of \`old_string\`.
1530
+ - Use \`replace_all\` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.
1531
+ `;
1532
+
1533
+ // src/tools/edit.ts
1534
+ var editFileTool = {
1535
+ name: "EditFile",
1536
+ description: "Edit a file by replacing an exact string match with new content. The old_string must match exactly (including whitespace and indentation). Set replace_all to true to replace all occurrences.",
1537
+ prompt: EDIT_PROMPT,
1538
+ isReadOnly: false,
1539
+ checkPermissions(args, ctx) {
1540
+ const filePath = args.file_path;
1541
+ if (filePath.startsWith("\\\\") || filePath.startsWith("//")) {
1542
+ return {
1543
+ behavior: "deny",
1544
+ message: "Error: UNC paths are not allowed"
1545
+ };
1546
+ }
1547
+ if (isDangerousPath(filePath, ctx.cwd)) {
1548
+ return {
1549
+ behavior: "ask",
1550
+ message: `Edit targets sensitive path: ${filePath}`,
1551
+ reason: "safetyCheck"
1552
+ };
1553
+ }
1554
+ return {
1555
+ behavior: "passthrough",
1556
+ message: `Edit ${filePath}`
1557
+ };
1558
+ },
1559
+ parameters: {
1560
+ type: "object",
1561
+ properties: {
1562
+ file_path: {
1563
+ type: "string",
1564
+ description: "The path of the file to edit"
1565
+ },
1566
+ old_string: {
1567
+ type: "string",
1568
+ description: "The exact string to find and replace"
1569
+ },
1570
+ new_string: {
1571
+ type: "string",
1572
+ description: "The replacement string"
1573
+ },
1574
+ replace_all: {
1575
+ type: "boolean",
1576
+ description: "If true, replace all occurrences of old_string. Defaults to false."
1577
+ }
1578
+ },
1579
+ required: ["file_path", "old_string", "new_string"]
1580
+ },
1581
+ async call(args, ctx) {
1582
+ const filePath = args.file_path;
1583
+ const oldString = args.old_string;
1584
+ const newString = args.new_string;
1585
+ const replaceAll = args.replace_all ?? false;
1586
+ if (filePath.endsWith(".ipynb")) {
1587
+ return {
1588
+ content: `Error: ${filePath} is a Jupyter Notebook. Use the NotebookEdit tool to edit notebook files.`,
1589
+ isError: true
1590
+ };
1591
+ }
1592
+ if (oldString === newString) {
1593
+ return {
1594
+ content: "No changes to make: old_string and new_string are exactly the same.",
1595
+ isError: true
1596
+ };
1597
+ }
1598
+ if (oldString === "") {
1599
+ const exists = await ctx.fs.exists(filePath);
1600
+ if (!exists) {
1601
+ if (ctx.checkpointManager && ctx.currentMessageId) {
1602
+ await ctx.checkpointManager.trackEdit(filePath, ctx.currentMessageId, ctx.sessionId ?? "");
1603
+ }
1604
+ await ctx.fs.writeFile(filePath, newString);
1605
+ ctx.notifyHook?.("FileWrite", {
1606
+ event: "FileWrite",
1607
+ sessionId: ctx.sessionId ?? "",
1608
+ toolName: "EditFile",
1609
+ filePath,
1610
+ isNew: true
1611
+ }).catch(() => {
1612
+ });
1613
+ if (ctx.fileStateCache) {
1614
+ ctx.fileStateCache.set(filePath, { content: newString, timestamp: Date.now() });
1615
+ }
1616
+ return { content: `Created new file ${filePath}.` };
1617
+ }
1618
+ const existing = await ctx.fs.readFile(filePath);
1619
+ if (existing.trim() !== "") {
1620
+ return {
1621
+ content: "Error: old_string is empty but file already has content. Use WriteFile to overwrite, or provide the exact text to replace.",
1622
+ isError: true
1623
+ };
1624
+ }
1625
+ if (ctx.checkpointManager && ctx.currentMessageId) {
1626
+ await ctx.checkpointManager.trackEdit(filePath, ctx.currentMessageId, ctx.sessionId ?? "");
1627
+ }
1628
+ await ctx.fs.writeFile(filePath, newString);
1629
+ if (ctx.fileStateCache) {
1630
+ ctx.fileStateCache.set(filePath, { content: newString, timestamp: Date.now() });
1631
+ }
1632
+ return { content: `File ${filePath} has been updated successfully.` };
1633
+ }
1634
+ const MAX_EDIT_FILE_SIZE = 1024 * 1024 * 1024;
1635
+ try {
1636
+ try {
1637
+ const stat = await ctx.fs.stat(filePath);
1638
+ if (stat.size !== void 0 && stat.size > MAX_EDIT_FILE_SIZE) {
1639
+ return {
1640
+ content: `Error: File is too large to edit (${Math.round(stat.size / 1024 / 1024)} MiB). Max: 1 GiB.`,
1641
+ isError: true
1642
+ };
1643
+ }
1644
+ } catch {
1645
+ }
1646
+ if (ctx.fileStateCache) {
1647
+ const cached = ctx.fileStateCache.get(filePath);
1648
+ if (!cached || cached.isPartialView) {
1649
+ return {
1650
+ content: `Error: File has not been read yet. Use ReadFile on ${filePath} before editing.`,
1651
+ isError: true
1652
+ };
1653
+ }
1654
+ try {
1655
+ const stat = await ctx.fs.stat(filePath);
1656
+ const mtime = stat.modifiedAt ? Math.floor(stat.modifiedAt.getTime()) : 0;
1657
+ if (mtime > cached.timestamp) {
1658
+ const currentContent = await ctx.fs.readFile(filePath);
1659
+ if (currentContent !== cached.content) {
1660
+ return {
1661
+ content: `Error: ${filePath} has been modified since last read. Re-read the file before editing.`,
1662
+ isError: true
1663
+ };
1664
+ }
1665
+ }
1666
+ } catch {
1667
+ }
1668
+ }
1669
+ if (ctx.checkpointManager && ctx.currentMessageId) {
1670
+ await ctx.checkpointManager.trackEdit(filePath, ctx.currentMessageId, ctx.sessionId ?? "");
1671
+ }
1672
+ const dir = nodePath2.dirname(filePath);
1673
+ if (dir && dir !== "." && dir !== "/") {
1674
+ await ctx.fs.mkdir(dir, { recursive: true }).catch(() => {
1675
+ });
1676
+ }
1677
+ const updated = await withFileLock(filePath, async () => {
1678
+ const rawContent = await ctx.fs.readFile(filePath);
1679
+ const hasCRLF = rawContent.includes("\r\n");
1680
+ const content = hasCRLF ? rawContent.replaceAll("\r\n", "\n") : rawContent;
1681
+ const actualOldString = findActualString(content, oldString);
1682
+ if (!actualOldString) {
1683
+ return {
1684
+ error: `Error: old_string not found in ${filePath}. Make sure the string matches exactly, including whitespace and indentation.`
1685
+ };
1686
+ }
1687
+ if (!replaceAll) {
1688
+ const count = content.split(actualOldString).length - 1;
1689
+ if (count > 1) {
1690
+ return {
1691
+ error: `Error: old_string appears ${count} times in ${filePath}. Provide more context to make it unique, or set replace_all to true.`
1692
+ };
1693
+ }
1694
+ }
1695
+ const actualNewString = preserveQuoteStyle(oldString, actualOldString, newString);
1696
+ let result;
1697
+ if (replaceAll) {
1698
+ result = content.split(actualOldString).join(actualNewString);
1699
+ } else if (actualNewString === "") {
1700
+ const hasTrailingNewline = !actualOldString.endsWith("\n") && content.includes(actualOldString + "\n");
1701
+ const deleteTarget = hasTrailingNewline ? actualOldString + "\n" : actualOldString;
1702
+ result = content.replace(deleteTarget, () => actualNewString);
1703
+ } else {
1704
+ result = content.replace(actualOldString, () => actualNewString);
1705
+ }
1706
+ if (hasCRLF) {
1707
+ result = result.replaceAll("\n", "\r\n");
1708
+ }
1709
+ await ctx.fs.writeFile(filePath, result);
1710
+ return { content: result };
1711
+ });
1712
+ if ("error" in updated) {
1713
+ return { content: String(updated.error), isError: true };
1714
+ }
1715
+ const editedContent = updated.content;
1716
+ ctx.notifyHook?.("FileWrite", {
1717
+ event: "FileWrite",
1718
+ sessionId: ctx.sessionId ?? "",
1719
+ toolName: "EditFile",
1720
+ filePath,
1721
+ isNew: false
1722
+ }).catch(() => {
1723
+ });
1724
+ if (ctx.fileStateCache) {
1725
+ let mtime = 0;
1726
+ try {
1727
+ const stat = await ctx.fs.stat(filePath);
1728
+ mtime = stat.modifiedAt ? Math.floor(stat.modifiedAt.getTime()) : 0;
1729
+ } catch {
1730
+ }
1731
+ ctx.fileStateCache.set(filePath, {
1732
+ content: editedContent,
1733
+ timestamp: mtime
1734
+ });
1735
+ }
1736
+ return {
1737
+ content: `File ${filePath} has been updated successfully.`
1187
1738
  };
1188
- }
1189
- if (hasGit && commandWritesGitInternals(command)) {
1739
+ } catch (err) {
1190
1740
  return {
1191
- isReadOnly: false,
1192
- isDestructive: true,
1193
- reason: "Compound command writes to git internal paths before running git"
1741
+ content: `Error editing file: ${err instanceof Error ? err.message : String(err)}`,
1742
+ isError: true
1194
1743
  };
1195
1744
  }
1196
1745
  }
1197
- let allReadOnly = true;
1198
- let anyDestructive = false;
1199
- const reasons = [];
1200
- for (const sub of subCommands) {
1201
- const result = classifySingleCommand(sub, config);
1202
- if (!result.isReadOnly) allReadOnly = false;
1203
- if (result.isDestructive) anyDestructive = true;
1204
- if (result.reason) reasons.push(result.reason);
1205
- }
1206
- return {
1207
- isReadOnly: allReadOnly,
1208
- isDestructive: anyDestructive,
1209
- reason: reasons.join("; ")
1210
- };
1211
- }
1746
+ };
1212
1747
 
1213
1748
  // src/tools/shell-safety/git-tracking.ts
1214
1749
  function detectGitOperations(command, stdout) {
@@ -1292,6 +1827,21 @@ While the Bash tool can do similar things, the built-in tools are preferred as t
1292
1827
 
1293
1828
  // src/tools/bash.ts
1294
1829
  var MAX_OUTPUT_CHARS = 1e5;
1830
+ var NON_ERROR_EXIT1_COMMANDS = /* @__PURE__ */ new Set([
1831
+ "grep",
1832
+ "egrep",
1833
+ "fgrep",
1834
+ "rg",
1835
+ "ag",
1836
+ "ack",
1837
+ "diff",
1838
+ "comm"
1839
+ ]);
1840
+ function isExpectedNonZeroExit(command, exitCode) {
1841
+ if (exitCode !== 1) return false;
1842
+ const name = extractCommandName(command);
1843
+ return NON_ERROR_EXIT1_COMMANDS.has(name);
1844
+ }
1295
1845
  var bashTool = {
1296
1846
  name: "Bash",
1297
1847
  description: "Execute a bash shell command. Use this for running scripts, installing packages, git operations, and other system commands.",
@@ -1363,17 +1913,23 @@ ${result.stderr}`;
1363
1913
  output = "(no output)";
1364
1914
  }
1365
1915
  if (output.length > MAX_OUTPUT_CHARS) {
1366
- const totalChars = output.length;
1367
- output = output.slice(0, MAX_OUTPUT_CHARS) + `
1368
- ... output truncated (${totalChars} total chars)`;
1916
+ const headSize = Math.floor(MAX_OUTPUT_CHARS * 0.8);
1917
+ const tailSize = MAX_OUTPUT_CHARS - headSize;
1918
+ const dropped = output.length - MAX_OUTPUT_CHARS;
1919
+ output = output.slice(0, headSize) + `
1920
+
1921
+ ... ${dropped} chars truncated ...
1922
+
1923
+ ` + output.slice(-tailSize);
1369
1924
  }
1925
+ const isSemanticError = result.exitCode !== 0 && !isExpectedNonZeroExit(command, result.exitCode);
1370
1926
  if (result.exitCode !== 0) {
1371
1927
  output = `Exit code: ${result.exitCode}
1372
1928
  ${output}`;
1373
1929
  }
1374
1930
  const toolResult = {
1375
1931
  content: output,
1376
- isError: result.exitCode !== 0
1932
+ isError: isSemanticError
1377
1933
  };
1378
1934
  if (result.exitCode === 0) {
1379
1935
  const gitOps = detectGitOperations(command, result.stdout ?? "");
@@ -1391,6 +1947,9 @@ ${output}`;
1391
1947
  }
1392
1948
  };
1393
1949
 
1950
+ // src/tools/glob.ts
1951
+ import * as path3 from "path";
1952
+
1394
1953
  // src/tools/prompts/glob.ts
1395
1954
  var GLOB_PROMPT = `Fast file pattern matching tool that works with any codebase size.
1396
1955
 
@@ -1430,12 +1989,19 @@ var globTool = {
1430
1989
  async call(args, ctx) {
1431
1990
  const pattern = args.pattern;
1432
1991
  const searchPath = args.path ?? ctx.cwd;
1433
- const fullPattern = pattern.startsWith("**/") ? pattern : `**/${pattern}`;
1434
- const command = `rg --files --glob ${shellEscape(fullPattern)} --sort=modified | head -n ${String(MAX_RESULTS + 1)}`;
1992
+ const fullPattern = pattern.startsWith("**/") || path3.isAbsolute(pattern) ? pattern : `**/${pattern}`;
1993
+ const resolvedPath = searchPath === ctx.cwd ? "." : searchPath;
1994
+ const command = `rg --files --hidden --glob ${shellEscape(fullPattern)} --sortr=modified ${shellEscape(resolvedPath)} | head -n ${String(MAX_RESULTS + 1)}`;
1435
1995
  try {
1436
- const result = await ctx.computer.executeCommand(command, {
1437
- cwd: searchPath
1996
+ let result = await ctx.computer.executeCommand(command, {
1997
+ cwd: ctx.cwd
1438
1998
  });
1999
+ if (result.exitCode === 127 || result.stderr?.includes("not found")) {
2000
+ const findCommand = `find ${shellEscape(resolvedPath)} -name ${shellEscape(pattern)} -type f | head -n ${String(MAX_RESULTS + 1)}`;
2001
+ result = await ctx.computer.executeCommand(findCommand, {
2002
+ cwd: ctx.cwd
2003
+ });
2004
+ }
1439
2005
  if (result.exitCode > 1) {
1440
2006
  return {
1441
2007
  content: `Glob error: ${result.stderr || result.stdout}`,
@@ -1522,16 +2088,32 @@ var grepTool = {
1522
2088
  "--line-number",
1523
2089
  "--no-heading",
1524
2090
  "--color=never",
2091
+ "--hidden",
2092
+ "--glob",
2093
+ "'!.git'",
2094
+ "--glob",
2095
+ "'!.svn'",
2096
+ "--glob",
2097
+ "'!.hg'",
2098
+ "--glob",
2099
+ "'!.bzr'",
2100
+ "--glob",
2101
+ "'!.jj'",
2102
+ "--glob",
2103
+ "'!.sl'",
2104
+ "--max-columns",
2105
+ "500",
1525
2106
  `--max-count=${MAX_MATCHES}`
1526
2107
  ];
1527
2108
  if (caseInsensitive) rgArgs.push("-i");
1528
2109
  if (contextLines !== void 0) rgArgs.push(`-C${contextLines}`);
1529
2110
  if (glob) rgArgs.push(`--glob`, shellEscape(glob));
1530
- rgArgs.push("--", shellEscape(pattern), ".");
2111
+ const resolvedPath = searchPath === ctx.cwd ? "." : searchPath;
2112
+ rgArgs.push("--", shellEscape(pattern), shellEscape(resolvedPath));
1531
2113
  const command = rgArgs.join(" ");
1532
2114
  try {
1533
2115
  const result = await ctx.computer.executeCommand(command, {
1534
- cwd: searchPath
2116
+ cwd: ctx.cwd
1535
2117
  });
1536
2118
  if (result.exitCode === 1 && !result.stdout.trim()) {
1537
2119
  return { content: "No matches found." };
@@ -1559,6 +2141,9 @@ var grepTool = {
1559
2141
  }
1560
2142
  };
1561
2143
 
2144
+ // src/tools/web-fetch.ts
2145
+ import * as dns from "dns";
2146
+
1562
2147
  // src/tools/prompts/web-fetch.ts
1563
2148
  var WEB_FETCH_PROMPT = `Fetches content from a specified URL and returns it in a readable format.
1564
2149
 
@@ -1576,9 +2161,63 @@ Usage notes:
1576
2161
  `;
1577
2162
 
1578
2163
  // src/tools/web-fetch.ts
2164
+ function stripWww(hostname) {
2165
+ return hostname.replace(/^www\./, "");
2166
+ }
1579
2167
  var MAX_CONTENT_LENGTH = 5 * 1024 * 1024;
1580
2168
  var FETCH_TIMEOUT_MS = 3e4;
1581
2169
  var MAX_OUTPUT_CHARS2 = 1e5;
2170
+ var MAX_REDIRECTS = 5;
2171
+ function isPrivateIP(ip) {
2172
+ const stripped = ip.replace(/^\[|\]$/g, "");
2173
+ if (stripped === "::1" || stripped === "0.0.0.0" || stripped === "::") return true;
2174
+ if (stripped.startsWith("fe80:")) return true;
2175
+ const firstTwo = stripped.slice(0, 2).toLowerCase();
2176
+ if (firstTwo === "fc" || firstTwo === "fd") return true;
2177
+ if (stripped.toLowerCase().startsWith("::ffff:")) {
2178
+ const embedded = stripped.slice(7);
2179
+ return embedded.includes(".") ? isPrivateIP(embedded) : true;
2180
+ }
2181
+ const parts = stripped.split(".");
2182
+ if (parts.length === 4 && parts.every((p) => /^\d+$/.test(p))) {
2183
+ const [a, b] = parts.map(Number);
2184
+ if (a === 127) return true;
2185
+ if (a === 10) return true;
2186
+ if (a === 172 && b >= 16 && b <= 31) return true;
2187
+ if (a === 192 && b === 168) return true;
2188
+ if (a === 169 && b === 254) return true;
2189
+ if (a === 0) return true;
2190
+ }
2191
+ return false;
2192
+ }
2193
+ function isPrivateHost(hostname) {
2194
+ if (hostname === "localhost" || hostname === "[::1]" || hostname === "0.0.0.0") {
2195
+ return true;
2196
+ }
2197
+ if (isPrivateIP(hostname)) return true;
2198
+ if (hostname.startsWith("fe80:") || hostname.startsWith("[fe80:")) return true;
2199
+ return false;
2200
+ }
2201
+ async function checkDnsRebinding(hostname) {
2202
+ if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname) || hostname.includes(":")) {
2203
+ return isPrivateIP(hostname) ? hostname : null;
2204
+ }
2205
+ try {
2206
+ const addrs = await dns.promises.resolve4(hostname);
2207
+ for (const addr of addrs) {
2208
+ if (isPrivateIP(addr)) return addr;
2209
+ }
2210
+ } catch {
2211
+ }
2212
+ try {
2213
+ const addrs6 = await dns.promises.resolve6(hostname);
2214
+ for (const addr of addrs6) {
2215
+ if (isPrivateIP(addr)) return addr;
2216
+ }
2217
+ } catch {
2218
+ }
2219
+ return null;
2220
+ }
1582
2221
  var webFetchTool = {
1583
2222
  name: "WebFetch",
1584
2223
  description: "Fetch a URL and return its contents as markdown. Useful for reading web pages, documentation, API responses, and other online content. Provide an optional prompt to extract specific information.",
@@ -1602,26 +2241,66 @@ var webFetchTool = {
1602
2241
  async call(args, _ctx) {
1603
2242
  const url = args.url;
1604
2243
  const prompt = args.prompt;
2244
+ let parsedUrl;
1605
2245
  try {
1606
- new URL(url);
2246
+ parsedUrl = new URL(url);
1607
2247
  } catch {
1608
2248
  return { content: `Invalid URL: ${url}`, isError: true };
1609
2249
  }
2250
+ if (parsedUrl.username || parsedUrl.password) {
2251
+ return { content: `Blocked: URLs with embedded credentials are not allowed`, isError: true };
2252
+ }
2253
+ if (parsedUrl.protocol === "http:") {
2254
+ parsedUrl.protocol = "https:";
2255
+ }
2256
+ if (isPrivateHost(parsedUrl.hostname)) {
2257
+ return { content: `Blocked: "${parsedUrl.hostname}" resolves to a private/internal address`, isError: true };
2258
+ }
2259
+ const rebindIP = await checkDnsRebinding(parsedUrl.hostname);
2260
+ if (rebindIP) {
2261
+ return { content: `Blocked: "${parsedUrl.hostname}" resolves to private address ${rebindIP}`, isError: true };
2262
+ }
1610
2263
  try {
1611
2264
  const controller = new AbortController();
1612
2265
  const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
1613
- const response = await fetch(url, {
1614
- signal: controller.signal,
1615
- headers: {
1616
- "User-Agent": "noumen-agent/1.0",
1617
- Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,text/plain;q=0.8,*/*;q=0.7"
1618
- },
1619
- redirect: "follow"
1620
- });
2266
+ let currentUrl = parsedUrl.toString();
2267
+ const originalHost = stripWww(parsedUrl.hostname);
2268
+ let response;
2269
+ for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects++) {
2270
+ response = await fetch(currentUrl, {
2271
+ signal: controller.signal,
2272
+ headers: {
2273
+ "User-Agent": "noumen-agent/1.0",
2274
+ Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,text/plain;q=0.8,*/*;q=0.7"
2275
+ },
2276
+ redirect: "manual"
2277
+ });
2278
+ if (response.status >= 300 && response.status < 400) {
2279
+ const location = response.headers.get("location");
2280
+ if (!location) break;
2281
+ const redirectUrl = new URL(location, currentUrl);
2282
+ if (stripWww(redirectUrl.hostname) !== originalHost) {
2283
+ clearTimeout(timeoutId);
2284
+ return { content: `Blocked: redirect to different host "${redirectUrl.hostname}" (original: "${parsedUrl.hostname}")`, isError: true };
2285
+ }
2286
+ if (isPrivateHost(redirectUrl.hostname)) {
2287
+ clearTimeout(timeoutId);
2288
+ return { content: `Blocked: redirect to private/internal address "${redirectUrl.hostname}"`, isError: true };
2289
+ }
2290
+ const redirectRebind = await checkDnsRebinding(redirectUrl.hostname);
2291
+ if (redirectRebind) {
2292
+ clearTimeout(timeoutId);
2293
+ return { content: `Blocked: redirect target "${redirectUrl.hostname}" resolves to private address ${redirectRebind}`, isError: true };
2294
+ }
2295
+ currentUrl = redirectUrl.toString();
2296
+ continue;
2297
+ }
2298
+ break;
2299
+ }
1621
2300
  clearTimeout(timeoutId);
1622
- if (!response.ok) {
2301
+ if (!response || !response.ok) {
1623
2302
  return {
1624
- content: `HTTP ${response.status}: ${response.statusText}`,
2303
+ content: `HTTP ${response?.status ?? "unknown"}: ${response?.statusText ?? "no response"}`,
1625
2304
  isError: true
1626
2305
  };
1627
2306
  }
@@ -1729,18 +2408,18 @@ var notebookEditTool = {
1729
2408
  required: ["notebook_path", "cell_index"]
1730
2409
  },
1731
2410
  async call(args, ctx) {
1732
- const path2 = args.notebook_path;
2411
+ const path6 = args.notebook_path;
1733
2412
  const cellIndex = args.cell_index;
1734
2413
  const newSource = args.new_source ?? "";
1735
2414
  const cellType = args.cell_type ?? "code";
1736
2415
  const editMode = args.edit_mode ?? "replace";
1737
2416
  try {
1738
- const raw = await ctx.fs.readFile(path2);
2417
+ const raw = await ctx.fs.readFile(path6);
1739
2418
  let notebook;
1740
2419
  try {
1741
2420
  notebook = JSON.parse(raw);
1742
2421
  } catch {
1743
- return { content: `Not a valid JSON notebook: ${path2}`, isError: true };
2422
+ return { content: `Not a valid JSON notebook: ${path6}`, isError: true };
1744
2423
  }
1745
2424
  if (!Array.isArray(notebook.cells)) {
1746
2425
  return { content: "Notebook has no cells array.", isError: true };
@@ -1792,9 +2471,9 @@ var notebookEditTool = {
1792
2471
  isError: true
1793
2472
  };
1794
2473
  }
1795
- await ctx.fs.writeFile(path2, JSON.stringify(notebook, null, 1) + "\n");
2474
+ await ctx.fs.writeFile(path6, JSON.stringify(notebook, null, 1) + "\n");
1796
2475
  const action = editMode === "delete" ? `Deleted cell ${cellIndex}` : editMode === "insert" ? `Inserted new ${cellType} cell at index ${cellIndex}` : `Replaced cell ${cellIndex} content`;
1797
- return { content: `${action} in ${path2}. Notebook now has ${notebook.cells.length} cells.` };
2476
+ return { content: `${action} in ${path6}. Notebook now has ${notebook.cells.length} cells.` };
1798
2477
  } catch (err) {
1799
2478
  return {
1800
2479
  content: `Error editing notebook: ${err instanceof Error ? err.message : String(err)}`,
@@ -1899,6 +2578,7 @@ var ToolRegistry = class {
1899
2578
  isError: true
1900
2579
  };
1901
2580
  }
2581
+ let validatedArgs = args;
1902
2582
  if (tool.inputSchema) {
1903
2583
  const parsed = tool.inputSchema.safeParse(args);
1904
2584
  if (!parsed.success) {
@@ -1907,8 +2587,16 @@ var ToolRegistry = class {
1907
2587
  isError: true
1908
2588
  };
1909
2589
  }
2590
+ validatedArgs = parsed.data;
2591
+ }
2592
+ try {
2593
+ return await tool.call(validatedArgs, ctx);
2594
+ } catch (err) {
2595
+ return {
2596
+ content: `Error executing ${name}: ${err instanceof Error ? err.message : String(err)}`,
2597
+ isError: true
2598
+ };
1910
2599
  }
1911
- return tool.call(args, ctx);
1912
2600
  }
1913
2601
  toToolDefinitions() {
1914
2602
  return Array.from(this.tools.values()).map((tool) => ({
@@ -1958,16 +2646,451 @@ var ToolRegistry = class {
1958
2646
  }
1959
2647
  };
1960
2648
 
2649
+ // src/permissions/helpers.ts
2650
+ import * as path4 from "path";
2651
+ var ACCEPT_EDITS_BASH_ALLOWLIST = /* @__PURE__ */ new Set([
2652
+ "mkdir",
2653
+ "touch",
2654
+ "mv",
2655
+ "cp",
2656
+ "sed",
2657
+ "chmod"
2658
+ ]);
2659
+ function extractContentHint(tool, input) {
2660
+ if (typeof input.file_path === "string") return input.file_path;
2661
+ if (typeof input.command === "string") return input.command;
2662
+ if (typeof input.path === "string") return input.path;
2663
+ return void 0;
2664
+ }
2665
+ function resolveAcceptEditsDecision(params) {
2666
+ const { toolName, input, effectiveInput, isReadOnly, isDestructive, workingDirectories } = params;
2667
+ if (isDestructive) {
2668
+ return {
2669
+ behavior: "ask",
2670
+ message: `Tool "${toolName}" is destructive and requires approval in acceptEdits mode.`,
2671
+ reason: "mode"
2672
+ };
2673
+ }
2674
+ if (toolName === "Bash") {
2675
+ const cmd = typeof input.command === "string" ? input.command : "";
2676
+ const subCommands = splitCompoundCommand(cmd);
2677
+ for (const sub of subCommands) {
2678
+ const baseName = extractCommandName(sub);
2679
+ if (!ACCEPT_EDITS_BASH_ALLOWLIST.has(baseName)) {
2680
+ return {
2681
+ behavior: "ask",
2682
+ message: `Tool "${toolName}" (${baseName}) is not in the acceptEdits allowlist.`,
2683
+ reason: "mode"
2684
+ };
2685
+ }
2686
+ }
2687
+ if (workingDirectories.length > 0) {
2688
+ for (const sub of subCommands) {
2689
+ const tokens = sub.trim().split(/\s+/).slice(1);
2690
+ for (const token of tokens) {
2691
+ if (token.startsWith("-")) continue;
2692
+ if (path4.isAbsolute(token) && !isPathInWorkingDirectories(token, workingDirectories)) {
2693
+ return {
2694
+ behavior: "ask",
2695
+ message: `Bash command references path "${token}" outside working directories.`,
2696
+ reason: "workingDirectory"
2697
+ };
2698
+ }
2699
+ }
2700
+ }
2701
+ }
2702
+ }
2703
+ if (workingDirectories.length > 0) {
2704
+ const filePath = typeof input.file_path === "string" ? input.file_path : typeof input.path === "string" ? input.path : void 0;
2705
+ if (filePath && !isPathInWorkingDirectories(filePath, workingDirectories)) {
2706
+ return {
2707
+ behavior: "ask",
2708
+ message: `Path "${filePath}" is outside working directories in acceptEdits mode.`,
2709
+ reason: "workingDirectory"
2710
+ };
2711
+ }
2712
+ }
2713
+ const hasFilePath = typeof input.file_path === "string" || typeof input.path === "string";
2714
+ if (!isReadOnly && !hasFilePath && toolName !== "Bash") {
2715
+ return {
2716
+ behavior: "ask",
2717
+ message: `Tool "${toolName}" requires approval in acceptEdits mode.`,
2718
+ reason: "mode"
2719
+ };
2720
+ }
2721
+ return {
2722
+ behavior: "allow",
2723
+ updatedInput: effectiveInput,
2724
+ reason: "mode"
2725
+ };
2726
+ }
2727
+ function resolveAutoModeDecision(params) {
2728
+ const { toolName, effectiveInput, classifierResult, denialTracker, requiresUserInteraction } = params;
2729
+ if (classifierResult.shouldBlock) {
2730
+ if (denialTracker) {
2731
+ denialTracker.recordDenial();
2732
+ const fallback = denialTracker.shouldFallback();
2733
+ if (fallback.triggered) {
2734
+ if (fallback.reason === "repeated_consecutive") {
2735
+ return {
2736
+ behavior: "deny",
2737
+ message: `Auto-mode classifier denied too many actions without user approval. Aborting.`,
2738
+ reason: "denial_limit"
2739
+ };
2740
+ }
2741
+ denialTracker.resetAfterFallback(fallback.reason);
2742
+ return {
2743
+ behavior: "ask",
2744
+ message: `Auto-mode classifier denied too many consecutive actions. Falling back to user prompt.`,
2745
+ reason: "denial_limit"
2746
+ };
2747
+ }
2748
+ }
2749
+ return {
2750
+ behavior: "deny",
2751
+ message: `Auto-mode classifier flagged this call: ${classifierResult.reason}`,
2752
+ reason: "classifier"
2753
+ };
2754
+ }
2755
+ if (requiresUserInteraction) {
2756
+ return {
2757
+ behavior: "ask",
2758
+ message: `Tool "${toolName}" requires user interaction.`,
2759
+ reason: "interaction"
2760
+ };
2761
+ }
2762
+ denialTracker?.recordSuccess();
2763
+ return {
2764
+ behavior: "allow",
2765
+ updatedInput: effectiveInput,
2766
+ reason: "classifier"
2767
+ };
2768
+ }
2769
+
2770
+ // src/permissions/pipeline.ts
2771
+ var DANGEROUS_PATH_PATTERNS = [
2772
+ /(?:^|\/)\.git(?:\/|$)/,
2773
+ /(?:^|\/)\.bashrc$/,
2774
+ /(?:^|\/)\.bash_profile$/,
2775
+ /(?:^|\/)\.zshrc$/,
2776
+ /(?:^|\/)\.zprofile$/,
2777
+ /(?:^|\/)\.profile$/,
2778
+ /(?:^|\/)\.ssh(?:\/|$)/,
2779
+ /(?:^|\/)\.env$/,
2780
+ /(?:^|\/)\.npmrc$/,
2781
+ /(?:^|\/)\.vscode(?:\/|$)/,
2782
+ /(?:^|\/)\.idea(?:\/|$)/,
2783
+ /(?:^|\/)\.claude(?:\/|$)/,
2784
+ /(?:^|\/)\.noumen(?:\/|$)/,
2785
+ /(?:^|\/)\.gitconfig$/,
2786
+ /(?:^|\/)\.gitmodules$/,
2787
+ /(?:^|\/)\.mcp\.json$/,
2788
+ /(?:^|\/)\.ripgreprc$/,
2789
+ /(?:^|\/)\.noumen\.json$/
2790
+ ];
2791
+ async function resolvePermission(tool, input, ctx, permCtx, opts) {
2792
+ const toolName = tool.name;
2793
+ const contentHint = extractContentHint(tool, input);
2794
+ const wholeDenyRules = getMatchingRules(
2795
+ permCtx,
2796
+ toolName,
2797
+ "deny",
2798
+ void 0,
2799
+ tool.mcpInfo
2800
+ );
2801
+ if (wholeDenyRules.length > 0) {
2802
+ return {
2803
+ behavior: "deny",
2804
+ message: `Tool "${toolName}" is denied by rule.`,
2805
+ reason: "rule"
2806
+ };
2807
+ }
2808
+ if (contentHint !== void 0) {
2809
+ const contentDenyRules = getMatchingRules(
2810
+ permCtx,
2811
+ toolName,
2812
+ "deny",
2813
+ contentHint,
2814
+ tool.mcpInfo
2815
+ );
2816
+ if (contentDenyRules.length > 0) {
2817
+ return {
2818
+ behavior: "deny",
2819
+ message: `Tool "${toolName}" with "${contentHint}" is denied by rule.`,
2820
+ reason: "rule"
2821
+ };
2822
+ }
2823
+ }
2824
+ const wholeAskRules = getMatchingRules(
2825
+ permCtx,
2826
+ toolName,
2827
+ "ask",
2828
+ void 0,
2829
+ tool.mcpInfo
2830
+ );
2831
+ if (wholeAskRules.length > 0) {
2832
+ return {
2833
+ behavior: "ask",
2834
+ message: `Tool "${toolName}" requires approval.`,
2835
+ reason: "rule"
2836
+ };
2837
+ }
2838
+ if (contentHint !== void 0) {
2839
+ const contentAskRules = getMatchingRules(
2840
+ permCtx,
2841
+ toolName,
2842
+ "ask",
2843
+ contentHint,
2844
+ tool.mcpInfo
2845
+ );
2846
+ if (contentAskRules.length > 0) {
2847
+ return {
2848
+ behavior: "ask",
2849
+ message: `Tool "${toolName}" with "${contentHint}" requires approval.`,
2850
+ reason: "rule"
2851
+ };
2852
+ }
2853
+ }
2854
+ const dangerousFilePath = typeof input.file_path === "string" ? input.file_path : typeof input.path === "string" ? input.path : void 0;
2855
+ if (dangerousFilePath && isDangerousPath(dangerousFilePath, ctx.cwd)) {
2856
+ return {
2857
+ behavior: "ask",
2858
+ message: `Path "${dangerousFilePath}" targets a sensitive location.`,
2859
+ reason: "safetyCheck"
2860
+ };
2861
+ }
2862
+ if (toolName === "Bash" && typeof input.command === "string") {
2863
+ const subCommands = splitCompoundCommand(input.command);
2864
+ for (const sub of subCommands) {
2865
+ const tokens = sub.trim().split(/\s+/);
2866
+ for (const token of tokens) {
2867
+ if (token.startsWith("-")) continue;
2868
+ if (isDangerousPath(token, ctx.cwd)) {
2869
+ return {
2870
+ behavior: "ask",
2871
+ message: `Bash command references sensitive path "${token}".`,
2872
+ reason: "safetyCheck"
2873
+ };
2874
+ }
2875
+ }
2876
+ }
2877
+ }
2878
+ let toolResult;
2879
+ if (tool.checkPermissions) {
2880
+ if (opts?.signal?.aborted) {
2881
+ throw new DOMException("Aborted", "AbortError");
2882
+ }
2883
+ try {
2884
+ toolResult = await tool.checkPermissions(input, ctx);
2885
+ } catch (err) {
2886
+ if (err instanceof DOMException && err.name === "AbortError") throw err;
2887
+ if (err instanceof Error && err.name === "AbortError") throw err;
2888
+ console.warn(`[noumen/permissions] checkPermissions error for "${toolName}":`, err);
2889
+ }
2890
+ if (toolResult?.behavior === "deny") {
2891
+ return {
2892
+ behavior: "deny",
2893
+ message: toolResult.message,
2894
+ reason: toolResult.reason ?? "tool"
2895
+ };
2896
+ }
2897
+ if (toolResult?.behavior === "ask") {
2898
+ const isSafetyCheck = toolResult.reason === "safetyCheck";
2899
+ const isInteractive = !!tool.requiresUserInteraction;
2900
+ if (isSafetyCheck || isInteractive) {
2901
+ return {
2902
+ behavior: "ask",
2903
+ message: toolResult.message,
2904
+ reason: toolResult.reason ?? "tool",
2905
+ suggestions: toolResult.suggestions
2906
+ };
2907
+ }
2908
+ if (permCtx.mode !== "bypassPermissions") {
2909
+ }
2910
+ }
2911
+ }
2912
+ const effectiveInput = toolResult?.behavior === "allow" && toolResult.updatedInput ? toolResult.updatedInput : input;
2913
+ if (tool.requiresUserInteraction && permCtx.mode === "bypassPermissions") {
2914
+ return {
2915
+ behavior: "ask",
2916
+ message: `Tool "${toolName}" requires user interaction.`,
2917
+ reason: "interaction"
2918
+ };
2919
+ }
2920
+ if (permCtx.mode === "bypassPermissions") {
2921
+ return {
2922
+ behavior: "allow",
2923
+ updatedInput: effectiveInput,
2924
+ reason: "mode"
2925
+ };
2926
+ }
2927
+ const isReadOnly = resolveToolFlag(tool.isReadOnly, input);
2928
+ const isDestructive = resolveToolFlag(tool.isDestructive, input);
2929
+ if (permCtx.mode === "plan" && !isReadOnly) {
2930
+ return {
2931
+ behavior: "deny",
2932
+ message: `Tool "${toolName}" is not allowed in plan mode (read-only).`,
2933
+ reason: "mode"
2934
+ };
2935
+ }
2936
+ if (permCtx.mode === "acceptEdits") {
2937
+ return resolveAcceptEditsDecision({
2938
+ toolName,
2939
+ input,
2940
+ effectiveInput,
2941
+ isReadOnly,
2942
+ isDestructive,
2943
+ workingDirectories: permCtx.workingDirectories
2944
+ });
2945
+ }
2946
+ if (permCtx.mode === "auto" && opts?.autoModeConfig) {
2947
+ if (!opts.provider) {
2948
+ return {
2949
+ behavior: "ask",
2950
+ message: `Auto-mode requires an AI provider for classification. Falling back to ask.`,
2951
+ reason: "classifier"
2952
+ };
2953
+ }
2954
+ const classifierResult = await classifyPermission(
2955
+ toolName,
2956
+ input,
2957
+ opts.recentMessages ?? [],
2958
+ opts.provider,
2959
+ {
2960
+ classifierPrompt: opts.autoModeConfig.classifierPrompt,
2961
+ classifierModel: opts.autoModeConfig.classifierModel,
2962
+ model: opts.model,
2963
+ signal: opts.signal
2964
+ }
2965
+ );
2966
+ return resolveAutoModeDecision({
2967
+ toolName,
2968
+ effectiveInput,
2969
+ classifierResult,
2970
+ denialTracker: opts.denialTracker,
2971
+ requiresUserInteraction: !!tool.requiresUserInteraction
2972
+ });
2973
+ }
2974
+ if (toolResult?.behavior === "allow") {
2975
+ return {
2976
+ behavior: "allow",
2977
+ updatedInput: effectiveInput,
2978
+ reason: toolResult.reason ?? "tool"
2979
+ };
2980
+ }
2981
+ if (isReadOnly && toolResult?.behavior !== "ask") {
2982
+ return {
2983
+ behavior: "allow",
2984
+ updatedInput: effectiveInput,
2985
+ reason: "readOnly"
2986
+ };
2987
+ }
2988
+ if (permCtx.workingDirectories.length > 0) {
2989
+ const filePath = typeof input.file_path === "string" ? input.file_path : typeof input.path === "string" ? input.path : void 0;
2990
+ if (filePath && !isPathInWorkingDirectories(filePath, permCtx.workingDirectories)) {
2991
+ return {
2992
+ behavior: "ask",
2993
+ message: `Path "${filePath}" is outside configured working directories.`,
2994
+ reason: "workingDirectory"
2995
+ };
2996
+ }
2997
+ }
2998
+ if (contentHint !== void 0) {
2999
+ const contentAllowRules = getMatchingRules(
3000
+ permCtx,
3001
+ toolName,
3002
+ "allow",
3003
+ contentHint,
3004
+ tool.mcpInfo
3005
+ );
3006
+ if (contentAllowRules.length > 0) {
3007
+ return {
3008
+ behavior: "allow",
3009
+ updatedInput: effectiveInput,
3010
+ reason: "rule"
3011
+ };
3012
+ }
3013
+ }
3014
+ const wholeAllowRules = getMatchingRules(
3015
+ permCtx,
3016
+ toolName,
3017
+ "allow",
3018
+ void 0,
3019
+ tool.mcpInfo
3020
+ );
3021
+ if (wholeAllowRules.length > 0) {
3022
+ return {
3023
+ behavior: "allow",
3024
+ updatedInput: effectiveInput,
3025
+ reason: "rule"
3026
+ };
3027
+ }
3028
+ let finalAsk;
3029
+ if (toolResult?.behavior === "ask") {
3030
+ finalAsk = {
3031
+ behavior: "ask",
3032
+ message: toolResult.message,
3033
+ reason: toolResult.reason ?? "tool",
3034
+ suggestions: toolResult.suggestions
3035
+ };
3036
+ }
3037
+ if (!finalAsk) {
3038
+ const message = toolResult?.behavior === "passthrough" ? toolResult.message : `Tool "${toolName}" requires approval.`;
3039
+ const suggestions = toolResult?.behavior === "passthrough" ? toolResult.suggestions : void 0;
3040
+ finalAsk = {
3041
+ behavior: "ask",
3042
+ message,
3043
+ reason: "default",
3044
+ suggestions
3045
+ };
3046
+ }
3047
+ if (permCtx.mode === "dontAsk") {
3048
+ return {
3049
+ behavior: "deny",
3050
+ message: `Tool "${toolName}" requires approval, but mode is "dontAsk".`,
3051
+ reason: "mode"
3052
+ };
3053
+ }
3054
+ return finalAsk;
3055
+ }
3056
+ function isDangerousPath(filePath, basePath) {
3057
+ const base = basePath ?? process.cwd();
3058
+ const resolved = path5.resolve(base, filePath);
3059
+ const relative2 = path5.relative(base, resolved);
3060
+ const candidate = (relative2.startsWith("..") ? resolved.replace(/^\/+/, "") : relative2).toLowerCase();
3061
+ if (DANGEROUS_PATH_PATTERNS.some((p) => p.test(candidate))) return true;
3062
+ try {
3063
+ const realPath = fs2.realpathSync(resolved);
3064
+ if (realPath !== resolved) {
3065
+ const realRelative = path5.relative(base, realPath);
3066
+ const realCandidate = (realRelative.startsWith("..") ? realPath.replace(/^\/+/, "") : realRelative).toLowerCase();
3067
+ if (DANGEROUS_PATH_PATTERNS.some((p) => p.test(realCandidate))) return true;
3068
+ }
3069
+ } catch {
3070
+ }
3071
+ return false;
3072
+ }
3073
+
1961
3074
  export {
1962
3075
  TOOL_SEARCH_NAME,
1963
3076
  isDeferredTool,
1964
3077
  formatDeferredToolLine,
1965
3078
  searchToolsWithKeywords,
1966
3079
  createToolSearchTool,
1967
- zodToJsonSchema,
1968
- registerZodToJsonSchema,
1969
- formatZodValidationError,
1970
3080
  readFileTool,
3081
+ RULE_SOURCE_PRECEDENCE,
3082
+ toolMatchesRule,
3083
+ contentMatchesRule,
3084
+ matchSimpleGlob,
3085
+ getMatchingRules,
3086
+ isPathInWorkingDirectories,
3087
+ classifyPermission,
3088
+ isGitInternalPath,
3089
+ looksLikeBareRepo,
3090
+ commandWritesGitInternals,
3091
+ extractCommandName,
3092
+ classifyCommand,
3093
+ resolvePermission,
1971
3094
  writeFileTool,
1972
3095
  normalizeQuotes,
1973
3096
  findActualString,
@@ -1975,11 +3098,6 @@ export {
1975
3098
  preserveQuoteStyle,
1976
3099
  stripTrailingWhitespace,
1977
3100
  editFileTool,
1978
- isGitInternalPath,
1979
- looksLikeBareRepo,
1980
- commandWritesGitInternals,
1981
- extractCommandName,
1982
- classifyCommand,
1983
3101
  detectGitOperations,
1984
3102
  hasGitIndexLockError,
1985
3103
  bashTool,
@@ -1991,4 +3109,4 @@ export {
1991
3109
  resolveToolFlag,
1992
3110
  ToolRegistry
1993
3111
  };
1994
- //# sourceMappingURL=chunk-QTJ7VTJY.js.map
3112
+ //# sourceMappingURL=chunk-HL6JCRZJ.js.map