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.
- package/README.md +95 -16
- package/dist/a2a/index.d.ts +5 -5
- package/dist/a2a/index.js +3 -3
- package/dist/a2a/index.js.map +1 -1
- package/dist/acp/index.d.ts +5 -5
- package/dist/acp/index.js +4 -4
- package/dist/acp/index.js.map +1 -1
- package/dist/{agent-BrkbZyOT.d.ts → agent-1nFVUP9E.d.ts} +319 -15
- package/dist/{cache-DVqaCX8v.d.ts → cache-DsRqxx6v.d.ts} +1 -1
- package/dist/{chunk-BGG2E6JD.js → chunk-3HEYCV26.js} +1 -1
- package/dist/chunk-3SK5GCI6.js +75 -0
- package/dist/chunk-3SK5GCI6.js.map +1 -0
- package/dist/{chunk-NBDFQYUZ.js → chunk-4HW6LN6D.js} +4784 -2411
- package/dist/chunk-4HW6LN6D.js.map +1 -0
- package/dist/{chunk-7ZMN7XJE.js → chunk-5JN4SPI7.js} +6 -6
- package/dist/chunk-5JN4SPI7.js.map +1 -0
- package/dist/{chunk-CPFHEPW4.js → chunk-CS6WNDCF.js} +73 -41
- package/dist/chunk-CS6WNDCF.js.map +1 -0
- package/dist/chunk-EKOGVTBT.js +472 -0
- package/dist/chunk-EKOGVTBT.js.map +1 -0
- package/dist/{chunk-KY6ZPWHO.js → chunk-HEQQQGK5.js} +47 -28
- package/dist/chunk-HEQQQGK5.js.map +1 -0
- package/dist/{chunk-QTJ7VTJY.js → chunk-HL6JCRZJ.js} +1599 -481
- package/dist/chunk-HL6JCRZJ.js.map +1 -0
- package/dist/chunk-L3L3FG5T.js +16 -0
- package/dist/chunk-L3L3FG5T.js.map +1 -0
- package/dist/cli/index.js +36 -30
- package/dist/cli/index.js.map +1 -1
- package/dist/client/index.d.ts +2 -2
- package/dist/{headless-Q7XHHZIW.js → headless-FFU2DESQ.js} +3 -4
- package/dist/headless-FFU2DESQ.js.map +1 -0
- package/dist/index.d.ts +218 -68
- package/dist/index.js +37 -23
- package/dist/lsp/index.d.ts +4 -4
- package/dist/mcp/index.d.ts +5 -5
- package/dist/mcp/index.js +2 -1
- package/dist/mcp/index.js.map +1 -1
- package/dist/{provider-factory-34MSWJZ3.js → provider-factory-KCLIF34X.js} +2 -2
- package/dist/providers/anthropic.d.ts +2 -2
- package/dist/providers/anthropic.js +5 -3
- package/dist/providers/anthropic.js.map +1 -1
- package/dist/providers/bedrock.d.ts +2 -2
- package/dist/providers/bedrock.js +5 -3
- package/dist/providers/bedrock.js.map +1 -1
- package/dist/providers/gemini.d.ts +2 -1
- package/dist/providers/gemini.js +133 -95
- package/dist/providers/gemini.js.map +1 -1
- package/dist/providers/ollama.d.ts +13 -0
- package/dist/{ollama-YNXAYP3R.js → providers/ollama.js} +6 -4
- package/dist/providers/ollama.js.map +1 -0
- package/dist/providers/openai.d.ts +4 -1
- package/dist/providers/openai.js +2 -1
- package/dist/providers/openrouter.d.ts +1 -1
- package/dist/providers/openrouter.js +2 -1
- package/dist/providers/openrouter.js.map +1 -1
- package/dist/providers/vertex.d.ts +4 -2
- package/dist/providers/vertex.js +6 -3
- package/dist/providers/vertex.js.map +1 -1
- package/dist/{resolve-XM52G7YE.js → resolve-4JA2BBDA.js} +2 -2
- package/dist/server/index.d.ts +35 -20
- package/dist/server/index.js +276 -207
- package/dist/server/index.js.map +1 -1
- package/dist/{server-Cg1yWGaV.d.ts → server-CHMxuWKq.d.ts} +1 -1
- package/dist/{types-DwdzmXfs.d.ts → types-CD0rUKKT.d.ts} +2 -0
- package/dist/{types-3c88cRKH.d.ts → types-LrU4LRmX.d.ts} +28 -0
- package/dist/{types-CwKKucOF.d.ts → types-RPKUTu1k.d.ts} +27 -2
- package/dist/uuid-RVN2T26F.js +8 -0
- package/dist/uuid-RVN2T26F.js.map +1 -0
- package/dist/zod-7YXKWYMC.js +12 -0
- package/dist/zod-7YXKWYMC.js.map +1 -0
- package/package.json +22 -13
- package/dist/chunk-2ZTGQLYK.js +0 -356
- package/dist/chunk-2ZTGQLYK.js.map +0 -1
- package/dist/chunk-7ZMN7XJE.js.map +0 -1
- package/dist/chunk-CPFHEPW4.js.map +0 -1
- package/dist/chunk-KY6ZPWHO.js.map +0 -1
- package/dist/chunk-NBDFQYUZ.js.map +0 -1
- package/dist/chunk-QTJ7VTJY.js.map +0 -1
- package/dist/headless-Q7XHHZIW.js.map +0 -1
- package/dist/ollama-YNXAYP3R.js.map +0 -1
- /package/dist/{chunk-BGG2E6JD.js.map → chunk-3HEYCV26.js.map} +0 -0
- /package/dist/{provider-factory-34MSWJZ3.js.map → provider-factory-KCLIF34X.js.map} +0 -0
- /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 =
|
|
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
|
|
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
|
|
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/
|
|
452
|
-
var
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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/
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
|
548
|
-
|
|
549
|
-
|
|
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
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
let
|
|
563
|
-
|
|
564
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
579
|
-
if (
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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 (
|
|
588
|
-
|
|
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
|
|
478
|
+
if (ruleContent === content) return true;
|
|
479
|
+
const stripped = stripForRuleMatching(content);
|
|
480
|
+
return stripped !== content && ruleContent === stripped;
|
|
591
481
|
}
|
|
592
|
-
function
|
|
593
|
-
let
|
|
594
|
-
let
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
if (
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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
|
-
|
|
608
|
-
|
|
497
|
+
regex += escapeRegex(pattern[i]);
|
|
498
|
+
i++;
|
|
609
499
|
}
|
|
610
500
|
}
|
|
611
|
-
|
|
501
|
+
regex += "$";
|
|
502
|
+
return new RegExp(regex).test(value);
|
|
612
503
|
}
|
|
613
|
-
function
|
|
614
|
-
|
|
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
|
|
628
|
-
const
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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
|
-
|
|
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/
|
|
642
|
-
var
|
|
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
|
-
|
|
645
|
-
-
|
|
646
|
-
-
|
|
647
|
-
-
|
|
648
|
-
-
|
|
649
|
-
-
|
|
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
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
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
|
-
|
|
663
|
-
|
|
622
|
+
shouldBlock: parsed.shouldBlock ?? false,
|
|
623
|
+
reason: parsed.reason ?? "unknown"
|
|
664
624
|
};
|
|
665
|
-
}
|
|
666
|
-
|
|
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(
|
|
807
|
-
const normalized =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
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
|
-
|
|
1041
|
-
|
|
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
|
-
|
|
1192
|
-
|
|
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
|
-
|
|
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
|
|
1367
|
-
|
|
1368
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
1437
|
-
cwd:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
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
|
|
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
|
|
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(
|
|
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: ${
|
|
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(
|
|
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 ${
|
|
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-
|
|
3112
|
+
//# sourceMappingURL=chunk-HL6JCRZJ.js.map
|