@warlock.js/ai-workspace 5.43.0 → 5.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +14 -1
  3. package/cjs/index.cjs +1847 -141
  4. package/cjs/index.cjs.map +1 -1
  5. package/esm/backends/local.d.mts.map +1 -1
  6. package/esm/backends/local.mjs +36 -22
  7. package/esm/backends/local.mjs.map +1 -1
  8. package/esm/contracts/index.d.mts +5 -1
  9. package/esm/contracts/shell-approval.type.d.mts +88 -0
  10. package/esm/contracts/shell-approval.type.d.mts.map +1 -0
  11. package/esm/contracts/tool-io.type.d.mts +45 -1
  12. package/esm/contracts/tool-io.type.d.mts.map +1 -1
  13. package/esm/contracts/workspace-backend.contract.d.mts +11 -1
  14. package/esm/contracts/workspace-backend.contract.d.mts.map +1 -1
  15. package/esm/contracts/workspace-edit.type.d.mts +66 -0
  16. package/esm/contracts/workspace-edit.type.d.mts.map +1 -0
  17. package/esm/contracts/workspace-ops.contract.d.mts +7 -1
  18. package/esm/contracts/workspace-ops.contract.d.mts.map +1 -1
  19. package/esm/contracts/workspace-output.type.d.mts +57 -0
  20. package/esm/contracts/workspace-output.type.d.mts.map +1 -0
  21. package/esm/contracts/workspace-policy.type.d.mts +38 -1
  22. package/esm/contracts/workspace-policy.type.d.mts.map +1 -1
  23. package/esm/contracts/workspace.contract.d.mts +23 -1
  24. package/esm/contracts/workspace.contract.d.mts.map +1 -1
  25. package/esm/edit/apply-replacement.mjs +58 -0
  26. package/esm/edit/apply-replacement.mjs.map +1 -0
  27. package/esm/edit/match.d.mts +16 -0
  28. package/esm/edit/match.d.mts.map +1 -0
  29. package/esm/edit/match.mjs +214 -0
  30. package/esm/edit/match.mjs.map +1 -0
  31. package/esm/errors.d.mts +12 -1
  32. package/esm/errors.d.mts.map +1 -1
  33. package/esm/errors.mjs +1 -0
  34. package/esm/errors.mjs.map +1 -1
  35. package/esm/hooks/after-edit.mjs +36 -0
  36. package/esm/hooks/after-edit.mjs.map +1 -0
  37. package/esm/index.d.mts +7 -2
  38. package/esm/index.mjs +3 -2
  39. package/esm/ops.d.mts +3 -1
  40. package/esm/ops.d.mts.map +1 -1
  41. package/esm/ops.mjs +213 -75
  42. package/esm/ops.mjs.map +1 -1
  43. package/esm/output/spill-dir.mjs +15 -0
  44. package/esm/output/spill-dir.mjs.map +1 -0
  45. package/esm/output/spill.mjs +223 -0
  46. package/esm/output/spill.mjs.map +1 -0
  47. package/esm/policy/approval-helpers.mjs +22 -0
  48. package/esm/policy/approval-helpers.mjs.map +1 -0
  49. package/esm/policy/policy.d.mts +3 -1
  50. package/esm/policy/policy.d.mts.map +1 -1
  51. package/esm/policy/policy.mjs +12 -4
  52. package/esm/policy/policy.mjs.map +1 -1
  53. package/esm/policy/powershell-aliases.mjs +275 -0
  54. package/esm/policy/powershell-aliases.mjs.map +1 -0
  55. package/esm/policy/powershell-parser.mjs +420 -0
  56. package/esm/policy/powershell-parser.mjs.map +1 -0
  57. package/esm/policy/shell-check.d.mts +32 -2
  58. package/esm/policy/shell-check.d.mts.map +1 -1
  59. package/esm/policy/shell-check.mjs +183 -31
  60. package/esm/policy/shell-check.mjs.map +1 -1
  61. package/esm/policy/shell-interpreter.d.mts +63 -0
  62. package/esm/policy/shell-interpreter.d.mts.map +1 -0
  63. package/esm/policy/shell-interpreter.mjs +123 -0
  64. package/esm/policy/shell-interpreter.mjs.map +1 -0
  65. package/esm/policy/shell-parser.d.mts +33 -0
  66. package/esm/policy/shell-parser.d.mts.map +1 -0
  67. package/esm/policy/shell-parser.mjs +3 -0
  68. package/esm/policy/shell-parser.mjs.map +1 -1
  69. package/esm/tools/glob.mjs +1 -0
  70. package/esm/tools/glob.mjs.map +1 -1
  71. package/esm/tools/run-shell.d.mts +10 -0
  72. package/esm/tools/run-shell.d.mts.map +1 -1
  73. package/esm/tools/run-shell.mjs +19 -1
  74. package/esm/tools/run-shell.mjs.map +1 -1
  75. package/esm/tools/run-tests.d.mts +7 -0
  76. package/esm/tools/run-tests.d.mts.map +1 -1
  77. package/esm/tools/run-tests.mjs +5 -4
  78. package/esm/tools/run-tests.mjs.map +1 -1
  79. package/esm/tools/write-file.mjs +3 -2
  80. package/esm/tools/write-file.mjs.map +1 -1
  81. package/esm/workspace.d.mts.map +1 -1
  82. package/esm/workspace.mjs +27 -5
  83. package/esm/workspace.mjs.map +1 -1
  84. package/llms-full.txt +70 -17
  85. package/package.json +3 -3
  86. package/skills/use-a-workspace/SKILL.md +70 -17
package/cjs/index.cjs CHANGED
@@ -30,6 +30,7 @@ let _warlock_js_ai = require("@warlock.js/ai");
30
30
  let node_path = require("node:path");
31
31
  node_path = __toESM(node_path, 1);
32
32
  let node_fs_promises = require("node:fs/promises");
33
+ let node_fs = require("node:fs");
33
34
  let _warlock_js_fs = require("@warlock.js/fs");
34
35
  let node_child_process = require("node:child_process");
35
36
  let node_process = require("node:process");
@@ -89,6 +90,7 @@ var WorkspaceEditError = class extends _warlock_js_ai.AIError {
89
90
  this.expectedHash = options.expectedHash;
90
91
  this.actualHash = options.actualHash;
91
92
  this.editIndex = options.editIndex;
93
+ this.hint = options.hint;
92
94
  this.hunkIndex = options.hunkIndex;
93
95
  }
94
96
  };
@@ -180,6 +182,826 @@ function isInsideJailLexically(policy, target) {
180
182
  return (policy.allowPaths ?? []).some((allowRoot) => isInside(target, node_path.default.resolve(allowRoot)));
181
183
  }
182
184
 
185
+ //#endregion
186
+ //#region ../../packages/ai-workspace/src/output/spill-dir.ts
187
+ /**
188
+ * The absolute directory spill files go to, or `undefined` when the policy
189
+ * has no `output.spillDir` (spilling is off).
190
+ */
191
+ function resolveSpillDir(policy) {
192
+ const dir = policy.output?.spillDir;
193
+ return dir === void 0 ? void 0 : node_path.default.resolve(policy.cwd, dir);
194
+ }
195
+
196
+ //#endregion
197
+ //#region ../../packages/ai-workspace/src/policy/powershell-aliases.ts
198
+ const FAMILIES = [
199
+ {
200
+ canonical: "rm",
201
+ names: [
202
+ "remove-item",
203
+ "ri",
204
+ "rm",
205
+ "del",
206
+ "erase",
207
+ "rd",
208
+ "rmdir"
209
+ ],
210
+ recurseFlag: "r",
211
+ forceFlag: "f",
212
+ params: [
213
+ ["recurse", "recurse"],
214
+ ["force", "force"],
215
+ ["path", "operand"],
216
+ ["literalpath", "operand"],
217
+ ["whatif", "switch"],
218
+ ["confirm", "switch"],
219
+ ["filter", "value"],
220
+ ["include", "value"],
221
+ ["exclude", "value"],
222
+ ["credential", "value"],
223
+ ["stream", "value"]
224
+ ]
225
+ },
226
+ {
227
+ canonical: "cp",
228
+ names: [
229
+ "copy-item",
230
+ "cpi",
231
+ "cp",
232
+ "copy"
233
+ ],
234
+ recurseFlag: "r",
235
+ forceFlag: "f",
236
+ params: [
237
+ ["recurse", "recurse"],
238
+ ["force", "force"],
239
+ ["path", "operand"],
240
+ ["literalpath", "operand"],
241
+ ["destination", "operand"],
242
+ ["whatif", "switch"],
243
+ ["confirm", "switch"],
244
+ ["filter", "value"],
245
+ ["include", "value"],
246
+ ["exclude", "value"],
247
+ ["credential", "value"]
248
+ ]
249
+ },
250
+ {
251
+ canonical: "mv",
252
+ names: [
253
+ "move-item",
254
+ "mi",
255
+ "mv",
256
+ "move"
257
+ ],
258
+ recurseFlag: "",
259
+ forceFlag: "f",
260
+ params: [
261
+ ["force", "force"],
262
+ ["path", "operand"],
263
+ ["literalpath", "operand"],
264
+ ["destination", "operand"],
265
+ ["whatif", "switch"],
266
+ ["confirm", "switch"],
267
+ ["filter", "value"],
268
+ ["include", "value"],
269
+ ["exclude", "value"],
270
+ ["credential", "value"]
271
+ ]
272
+ },
273
+ {
274
+ canonical: "ls",
275
+ names: [
276
+ "get-childitem",
277
+ "gci",
278
+ "ls",
279
+ "dir"
280
+ ],
281
+ recurseFlag: "R",
282
+ forceFlag: "a",
283
+ params: [
284
+ ["recurse", "recurse"],
285
+ ["force", "force"],
286
+ ["path", "operand"],
287
+ ["literalpath", "operand"],
288
+ ["filter", "value"],
289
+ ["include", "value"],
290
+ ["exclude", "value"],
291
+ ["depth", "value"],
292
+ ["name", "switch"]
293
+ ]
294
+ },
295
+ {
296
+ canonical: "cat",
297
+ names: [
298
+ "get-content",
299
+ "gc",
300
+ "cat",
301
+ "type"
302
+ ],
303
+ recurseFlag: "",
304
+ forceFlag: "",
305
+ params: [
306
+ ["path", "operand"],
307
+ ["literalpath", "operand"],
308
+ ["totalcount", "value"],
309
+ ["tail", "value"],
310
+ ["head", "value"],
311
+ ["encoding", "value"],
312
+ ["filter", "value"],
313
+ ["include", "value"],
314
+ ["exclude", "value"],
315
+ ["force", "switch"],
316
+ ["raw", "switch"],
317
+ ["wait", "switch"]
318
+ ]
319
+ },
320
+ {
321
+ canonical: "curl",
322
+ names: [
323
+ "invoke-webrequest",
324
+ "iwr",
325
+ "invoke-restmethod",
326
+ "irm",
327
+ "curl",
328
+ "wget"
329
+ ],
330
+ recurseFlag: "",
331
+ forceFlag: "",
332
+ params: [
333
+ ["uri", "operand"],
334
+ ["outfile", "outfile"],
335
+ ["method", "method"],
336
+ ["headers", "value"],
337
+ ["body", "value"],
338
+ ["contenttype", "value"],
339
+ ["useragent", "value"],
340
+ ["credential", "value"],
341
+ ["timeoutsec", "value"],
342
+ ["proxy", "value"]
343
+ ]
344
+ },
345
+ {
346
+ canonical: "kill",
347
+ names: [
348
+ "stop-process",
349
+ "spps",
350
+ "kill"
351
+ ],
352
+ recurseFlag: "",
353
+ forceFlag: "9",
354
+ params: [
355
+ ["id", "operand"],
356
+ ["name", "operand"],
357
+ ["force", "force"],
358
+ ["inputobject", "value"],
359
+ ["whatif", "switch"],
360
+ ["confirm", "switch"]
361
+ ]
362
+ }
363
+ ];
364
+ const BY_NAME = /* @__PURE__ */ new Map();
365
+ for (const family of FAMILIES) for (const name of family.names) BY_NAME.set(name, family);
366
+ /** PowerShell reads an en dash, em dash or horizontal bar as the parameter dash too. */
367
+ const FLAG_ORDER = "rRfa9";
368
+ const DASH = /^[-–—―]/;
369
+ /** Lowercase executable name used to look an alias up. */
370
+ function aliasKey(first) {
371
+ return (first.split(/[\\/]/).pop() ?? first).replace(/\.(exe|cmd|bat|com)$/i, "").toLowerCase();
372
+ }
373
+ /** Split `-Name` / `-Name:value` into the lowercase name and the inline value. */
374
+ function parameter(arg) {
375
+ if (!DASH.test(arg) || arg.length < 2) return null;
376
+ const body = arg.slice(1);
377
+ const colon = body.indexOf(":");
378
+ return colon === -1 ? { name: body.toLowerCase() } : {
379
+ name: body.slice(0, colon).toLowerCase(),
380
+ inline: body.slice(colon + 1)
381
+ };
382
+ }
383
+ /**
384
+ * The canonical argv of a PowerShell command that uses an alias or cmdlet
385
+ * name from the table (`Remove-Item -Recurse -Force x` → `["rm", "-rf", "x"]`),
386
+ * or `undefined` when the command has no canonical form.
387
+ */
388
+ function canonicalPowerShellArgv(argv) {
389
+ const family = BY_NAME.get(aliasKey(argv[0] ?? ""));
390
+ if (family === void 0) return;
391
+ let flags = "";
392
+ const operands = [];
393
+ const extras = [];
394
+ for (let index = 1; index < argv.length; index++) {
395
+ const arg = argv[index] ?? "";
396
+ const parsed = parameter(arg);
397
+ if (parsed === null) {
398
+ operands.push(arg);
399
+ continue;
400
+ }
401
+ const match = family.params.find(([name]) => parsed.name !== "" && name.startsWith(parsed.name));
402
+ if (match === void 0) continue;
403
+ const role = match[1];
404
+ if (role === "recurse") flags += family.recurseFlag;
405
+ else if (role === "force") flags += family.forceFlag;
406
+ else if (role === "switch") continue;
407
+ else {
408
+ const value = parsed.inline ?? argv[++index] ?? "";
409
+ if (role === "operand") operands.push(value);
410
+ else if (role === "outfile") extras.push("-o", value);
411
+ else if (role === "method") extras.push("-X", value);
412
+ }
413
+ }
414
+ const unique = [...FLAG_ORDER].filter((flag) => flags.includes(flag)).join("");
415
+ return [
416
+ family.canonical,
417
+ ...unique !== "" ? [`-${unique}`] : [],
418
+ ...extras,
419
+ ...operands
420
+ ];
421
+ }
422
+ /** The names PowerShell's `cd` goes by. */
423
+ const POWERSHELL_CD_NAMES = /* @__PURE__ */ new Set([
424
+ "cd",
425
+ "sl",
426
+ "set-location",
427
+ "chdir"
428
+ ]);
429
+ /** Directory stack commands, refused because the policy does not track them. */
430
+ const POWERSHELL_DIRECTORY_STACK = /* @__PURE__ */ new Set([
431
+ "push-location",
432
+ "pushd",
433
+ "pop-location",
434
+ "popd"
435
+ ]);
436
+ /**
437
+ * Normalise a PowerShell `cd` to `[cd, directory]`: `-Path x`, `-LiteralPath x`
438
+ * (and their prefixes, `-Path:x`) become the directory operand. Returns `null`
439
+ * for any other parameter (`-PassThru`, `-StackName`, …).
440
+ */
441
+ function normalizePowerShellCd(words) {
442
+ const first = words[0];
443
+ if (first === void 0) return null;
444
+ const operands = [];
445
+ for (let index = 1; index < words.length; index++) {
446
+ const word = words[index];
447
+ const parsed = word.quoted ? null : parameter(word.value);
448
+ if (parsed === null) {
449
+ operands.push(word);
450
+ continue;
451
+ }
452
+ const isPath = parsed.name.length >= 2 && "path".startsWith(parsed.name);
453
+ const isLiteral = parsed.name.length >= 1 && "literalpath".startsWith(parsed.name);
454
+ if (!isPath && !isLiteral) return null;
455
+ if (parsed.inline !== void 0) operands.push({
456
+ ...word,
457
+ raw: parsed.inline,
458
+ value: parsed.inline
459
+ });
460
+ else {
461
+ const next = words[++index];
462
+ if (next === void 0) return null;
463
+ operands.push(next);
464
+ }
465
+ }
466
+ return [first, ...operands];
467
+ }
468
+
469
+ //#endregion
470
+ //#region ../../packages/ai-workspace/src/policy/shell-interpreter.ts
471
+ /** The real host: the running platform, `process.env.PATH`, and the filesystem. */
472
+ function currentShellHost() {
473
+ return {
474
+ platform: process.platform,
475
+ pathEnv: process.env.PATH ?? process.env.Path,
476
+ isExecutable: (file) => {
477
+ try {
478
+ (0, node_fs.accessSync)(file, process.platform === "win32" ? node_fs.constants.F_OK : node_fs.constants.X_OK);
479
+ return true;
480
+ } catch {
481
+ return false;
482
+ }
483
+ }
484
+ };
485
+ }
486
+ /** The grammar a concrete interpreter reads on `platform`. */
487
+ function dialectOf(interpreter, platform = process.platform, edition) {
488
+ if (interpreter === "powershell") return edition === "desktop" ? "powershell5" : "powershell";
489
+ if (interpreter === "cmd") return "cmd";
490
+ return interpreter === "sh" && platform === "win32" ? "cmd" : "posix";
491
+ }
492
+ /** The first of `names` found in the host's `PATH` (Windows: with `.exe`). */
493
+ function findOnPath(names, host) {
494
+ const windows = host.platform === "win32";
495
+ const separator = windows ? ";" : ":";
496
+ const join = windows ? node_path.default.win32.join : node_path.default.posix.join;
497
+ const directories = (host.pathEnv ?? "").split(separator).filter((entry) => entry !== "");
498
+ for (const name of names) for (const directory of directories) {
499
+ const candidate = join(directory, windows ? `${name}.exe` : name);
500
+ if (host.isExecutable(candidate)) return candidate;
501
+ }
502
+ }
503
+ /** Git for Windows' `bash.exe`, derived from the `git` found on `PATH`. */
504
+ function findGitBash(host) {
505
+ const git = findOnPath(["git"], host);
506
+ if (git === void 0) return;
507
+ const win = node_path.default.win32;
508
+ const roots = [win.dirname(win.dirname(git)), win.dirname(win.dirname(win.dirname(git)))];
509
+ for (const root of roots) for (const candidate of [win.join(root, "bin", "bash.exe"), win.join(root, "usr", "bin", "bash.exe")]) if (host.isExecutable(candidate)) return candidate;
510
+ }
511
+ /** The PowerShell executable names, newest first. */
512
+ function powershellNames(host) {
513
+ return host.platform === "win32" ? ["pwsh", "powershell"] : ["pwsh"];
514
+ }
515
+ /** The edition of the PowerShell executable `file`: `powershell.exe` is Windows PowerShell, anything else (`pwsh`) is PowerShell 7+. */
516
+ function editionOf(file) {
517
+ return /(^|[\\/])powershell(\.exe)?$/i.test(file) ? "desktop" : "core";
518
+ }
519
+ function powershellLabel(edition) {
520
+ if (edition === "desktop") return "Windows PowerShell 5.1 (powershell)";
521
+ return edition === "core" ? "PowerShell 7 (pwsh)" : "PowerShell";
522
+ }
523
+ /**
524
+ * Resolve a shell policy's `interpreter` (and `interpreterPath`) to the
525
+ * concrete shell that will run lines, and the grammar they are checked with.
526
+ *
527
+ * `"auto"` picks, on Windows, `pwsh` (PowerShell 7+), then Git Bash (found next
528
+ * to `git`), then Windows PowerShell 5.1, then `cmd.exe`; elsewhere `bash`,
529
+ * then `sh`. Windows PowerShell 5.1 gets its own dialect (`"powershell5"`)
530
+ * because it cannot run `&&` / `||`; `edition` and `version` say which one
531
+ * was found. An explicit interpreter whose executable
532
+ * is not on `PATH` keeps its name and has no `file`, so the spawn reports the
533
+ * missing shell instead of quietly running another one.
534
+ *
535
+ * @example
536
+ * resolveShell({ interpreter: "auto" }, { platform: "win32", pathEnv: "C:\\bin", isExecutable })
537
+ */
538
+ function resolveShell(shell, host = currentShellHost()) {
539
+ const requested = shell?.interpreter ?? "none";
540
+ const explicit = shell?.interpreterPath;
541
+ if (requested === "none") return {
542
+ interpreter: "none",
543
+ dialect: "posix",
544
+ label: "none"
545
+ };
546
+ let interpreter;
547
+ let file = explicit;
548
+ if (requested === "auto") {
549
+ if (explicit !== void 0) interpreter = /(^|[\\/])(pwsh|powershell)(\.exe)?$/i.test(explicit) ? "powershell" : /(^|[\\/])cmd(\.exe)?$/i.test(explicit) ? "cmd" : /(^|[\\/])bash(\.exe)?$/i.test(explicit) ? "bash" : "sh";
550
+ else if (host.platform === "win32") {
551
+ const core = findOnPath(["pwsh"], host);
552
+ const gitBash = core === void 0 ? findGitBash(host) : void 0;
553
+ const desktop = core === void 0 && gitBash === void 0 ? findOnPath(["powershell"], host) : void 0;
554
+ const found = core ?? gitBash ?? desktop;
555
+ interpreter = core !== void 0 || desktop !== void 0 ? "powershell" : gitBash !== void 0 ? "bash" : "cmd";
556
+ file = found;
557
+ } else {
558
+ const bash = findOnPath(["bash"], host);
559
+ interpreter = bash !== void 0 ? "bash" : "sh";
560
+ file = bash;
561
+ }
562
+ } else interpreter = requested;
563
+ if (file === void 0) {
564
+ if (interpreter === "powershell") file = findOnPath(powershellNames(host), host);
565
+ else if (interpreter === "bash") file = findOnPath(["bash"], host) ?? (host.platform === "win32" ? findGitBash(host) : void 0);
566
+ else if (interpreter === "cmd" && host.platform === "win32") file = process.env.ComSpec ?? "cmd.exe";
567
+ }
568
+ const edition = interpreter === "powershell" && file !== void 0 ? editionOf(file) : void 0;
569
+ const version = edition === "desktop" ? "5.1" : edition === "core" && file !== void 0 ? host.powershellVersion?.(file) : void 0;
570
+ const labels = {
571
+ none: "none",
572
+ sh: host.platform === "win32" ? "cmd.exe" : "sh",
573
+ bash: "bash",
574
+ cmd: "cmd.exe",
575
+ powershell: powershellLabel(edition)
576
+ };
577
+ return {
578
+ interpreter,
579
+ dialect: dialectOf(interpreter, host.platform, edition),
580
+ ...edition !== void 0 ? { edition } : {},
581
+ ...version !== void 0 ? { version } : {},
582
+ ...file !== void 0 ? { file } : {},
583
+ label: labels[interpreter]
584
+ };
585
+ }
586
+
587
+ //#endregion
588
+ //#region ../../packages/ai-workspace/src/policy/powershell-parser.ts
589
+ /**
590
+ * Parser for the PowerShell dialect (`powershell -Command` / `pwsh -Command`).
591
+ *
592
+ * Like the other dialects it is deliberately conservative: it finds every
593
+ * simple command a line would run and every redirection target, and refuses
594
+ * what it cannot read with confidence. Supported: `;` and line breaks, `&&`,
595
+ * `||`, `|`, `$( )` subexpressions (their commands are checked too), the call
596
+ * operator `&` in front of a command, single and double
597
+ * quotes (including the typographic quotes PowerShell treats as quotes),
598
+ * backtick escapes, `$name` / `${name}` / `$env:NAME` variables (dynamic
599
+ * words), and the redirections `>`, `>>`, `2>`, `2>>`, `*>`, `2>&1`.
600
+ *
601
+ * Refused: script blocks and braces, bare parentheses,
602
+ * `@( )` / `@{ }` / splatting / here-strings, the stop-parsing token `--%`,
603
+ * `<` redirection, comments, the comma operator, compound-statement
604
+ * keywords (`if`, `foreach`, `function`, `try`, …), dot-sourcing, a `&` that
605
+ * is not a call operator (background jobs), and type literals.
606
+ */
607
+ /** Statement keywords that start a compound command; refused in command position. */
608
+ const POWERSHELL_KEYWORDS = /* @__PURE__ */ new Set([
609
+ "if",
610
+ "elseif",
611
+ "else",
612
+ "switch",
613
+ "foreach",
614
+ "for",
615
+ "while",
616
+ "do",
617
+ "until",
618
+ "function",
619
+ "filter",
620
+ "workflow",
621
+ "param",
622
+ "begin",
623
+ "process",
624
+ "end",
625
+ "try",
626
+ "catch",
627
+ "finally",
628
+ "trap",
629
+ "data",
630
+ "class",
631
+ "enum",
632
+ "using",
633
+ "return",
634
+ "break",
635
+ "continue",
636
+ "throw",
637
+ "parallel",
638
+ "sequence",
639
+ "inlinescript"
640
+ ]);
641
+ /** Characters PowerShell reads as a single quote. */
642
+ const SINGLE_QUOTES = "'‘’‚‛";
643
+ /** Characters PowerShell reads as a double quote. */
644
+ const DOUBLE_QUOTES = "\"“”„";
645
+ /** Variable-name characters after `$`. */
646
+ const VARIABLE_CHAR = /[A-Za-z0-9_:?^$]/;
647
+ /** Whether `char` (never the empty end-of-input string) is one of `set`. */
648
+ function inSet(set, char) {
649
+ return char !== "" && set.includes(char);
650
+ }
651
+ var PowerShellRefusal = class extends Error {
652
+ constructor(reason, message) {
653
+ super(message);
654
+ this.reason = reason;
655
+ }
656
+ };
657
+ function unsupported$1(message) {
658
+ return new PowerShellRefusal("unsupported-syntax", message);
659
+ }
660
+ var PowerShellParser = class PowerShellParser {
661
+ constructor(src, commands = [], depth = 0, chaining = true) {
662
+ this.src = src;
663
+ this.commands = commands;
664
+ this.depth = depth;
665
+ this.chaining = chaining;
666
+ this.pos = 0;
667
+ }
668
+ atEnd() {
669
+ return this.pos >= this.src.length;
670
+ }
671
+ peek(offset = 0) {
672
+ return this.src.charAt(this.pos + offset);
673
+ }
674
+ skipBlanks() {
675
+ while (this.peek() === " " || this.peek() === " ") this.pos++;
676
+ }
677
+ atLineBreak() {
678
+ return this.peek() === "\n" || this.peek() === "\r";
679
+ }
680
+ parseProgram(segments) {
681
+ while (true) {
682
+ this.skipBlanks();
683
+ if (this.atEnd()) {
684
+ if (segments.length === 0) throw unsupported$1("The command is empty.");
685
+ if (segments[segments.length - 1]?.operator === "&&" || segments[segments.length - 1]?.operator === "||") throw unsupported$1("The line ends with an operator but no command follows it.");
686
+ return;
687
+ }
688
+ const start = this.pos;
689
+ this.parsePipeline();
690
+ const end = this.pos;
691
+ this.skipBlanks();
692
+ const operator = this.readListOperator();
693
+ segments.push({
694
+ source: this.src.slice(start, end).trim(),
695
+ ...operator !== null ? { operator } : {}
696
+ });
697
+ if (operator === null) {
698
+ if (!this.atEnd()) throw unsupported$1(`Unexpected \`${this.peek()}\`.`);
699
+ return;
700
+ }
701
+ if (operator === ";" || operator === "\n") while (this.peek() === " " || this.peek() === " " || this.atLineBreak()) this.pos++;
702
+ else this.skipBlanks();
703
+ }
704
+ }
705
+ readListOperator() {
706
+ const char = this.peek();
707
+ if (!this.chaining && (char === "&" && this.peek(1) === "&" || char === "|" && this.peek(1) === "|")) throw unsupported$1(`Windows PowerShell 5.1 cannot run ${char}${char}; use ; or run each command in its own call instead.`);
708
+ if (char === "&" && this.peek(1) === "&") {
709
+ this.pos += 2;
710
+ return "&&";
711
+ }
712
+ if (char === "|" && this.peek(1) === "|") {
713
+ this.pos += 2;
714
+ return "||";
715
+ }
716
+ if (char === ";") {
717
+ this.pos++;
718
+ return ";";
719
+ }
720
+ if (this.atLineBreak()) {
721
+ while (this.atLineBreak()) this.pos++;
722
+ return "\n";
723
+ }
724
+ return null;
725
+ }
726
+ parsePipeline() {
727
+ this.parseCommand();
728
+ while (this.peek() === "|" && this.peek(1) !== "|") {
729
+ this.pos++;
730
+ this.skipBlanks();
731
+ if (this.atEnd() || this.atLineBreak() || this.peek() === ";") throw unsupported$1("The line ends with `|` but no command follows it.");
732
+ this.parseCommand();
733
+ }
734
+ }
735
+ atRedirectStart() {
736
+ const char = this.peek();
737
+ if (char === ">") return true;
738
+ return /[1-6*]/.test(char) && this.peek(1) === ">";
739
+ }
740
+ parseCommand() {
741
+ const start = this.pos;
742
+ const command = {
743
+ assignments: [],
744
+ words: [],
745
+ redirects: [],
746
+ source: ""
747
+ };
748
+ let callOperator = false;
749
+ if (this.peek() === "&" && this.peek(1) !== "&") {
750
+ callOperator = true;
751
+ this.pos++;
752
+ this.skipBlanks();
753
+ }
754
+ while (true) {
755
+ this.skipBlanks();
756
+ if (this.atEnd() || this.atLineBreak() || this.peek() === ";" || this.peek() === "|") break;
757
+ if (this.peek() === "&") {
758
+ if (this.peek(1) === "&") break;
759
+ throw unsupported$1("`&` is only supported as the call operator in front of a command (not as a background job).");
760
+ }
761
+ if (this.peek() === "<") throw unsupported$1("`<` is not supported in PowerShell command lines.");
762
+ if (this.atRedirectStart()) {
763
+ command.redirects.push(this.parseRedirect());
764
+ continue;
765
+ }
766
+ const word = this.parseWord();
767
+ command.words.push(word);
768
+ if (command.words.length === 1) this.checkCommandWord(word);
769
+ }
770
+ if (command.words.length === 0 && (callOperator || command.redirects.length === 0)) throw unsupported$1("The line has an empty command (two operators in a row?).");
771
+ command.source = this.src.slice(start, this.pos).trim();
772
+ this.commands.push(command);
773
+ }
774
+ checkCommandWord(word) {
775
+ if (word.raw.startsWith("[")) throw unsupported$1(`\`${word.raw}\` (a type literal) is not supported.`);
776
+ if (!word.quoted && word.value === ".") throw unsupported$1("Dot-sourcing (`. file`) runs a script in the current scope and is not supported.");
777
+ if (!word.quoted && POWERSHELL_KEYWORDS.has(word.value.toLowerCase())) throw unsupported$1(`\`${word.value}\` starts a PowerShell statement, which is not supported.`);
778
+ }
779
+ parseRedirect() {
780
+ let fd;
781
+ if (this.peek() === "*") this.pos++;
782
+ else if (/[1-6]/.test(this.peek())) {
783
+ fd = Number(this.peek());
784
+ this.pos++;
785
+ }
786
+ let op = ">";
787
+ if (this.peek(1) === ">") op = ">>";
788
+ this.pos += op.length;
789
+ if (op === ">" && this.peek() === "&") {
790
+ if (/[1-6]/.test(this.peek(1))) {
791
+ const target = {
792
+ raw: this.peek(1),
793
+ value: this.peek(1),
794
+ dynamic: false,
795
+ quoted: false
796
+ };
797
+ this.pos += 2;
798
+ return {
799
+ op: ">&",
800
+ ...fd !== void 0 ? { fd } : {},
801
+ target,
802
+ type: "dup"
803
+ };
804
+ }
805
+ throw unsupported$1("Only `>&1`-style stream merges (`2>&1`) are supported.");
806
+ }
807
+ this.skipBlanks();
808
+ if (this.atEnd() || this.atLineBreak() || /[;&|<>]/.test(this.peek())) throw unsupported$1(`The redirection \`${op}\` has no target.`);
809
+ const target = this.parseWord();
810
+ return {
811
+ op,
812
+ ...fd !== void 0 ? { fd } : {},
813
+ target,
814
+ type: "write"
815
+ };
816
+ }
817
+ parseWord() {
818
+ const start = this.pos;
819
+ let value = "";
820
+ let dynamic = false;
821
+ let quoted = false;
822
+ if (this.src.startsWith("--%", this.pos) && /[\s;|&]|^$/.test(this.src.charAt(this.pos + 3))) throw unsupported$1("The stop-parsing token `--%` is not supported.");
823
+ while (!this.atEnd()) {
824
+ const char = this.peek();
825
+ if (char === " " || char === " " || char === "\n" || char === "\r" || char === ";" || char === "|" || char === "&" || char === ">" || char === "<") break;
826
+ if (inSet(SINGLE_QUOTES, char)) {
827
+ this.pos++;
828
+ quoted = true;
829
+ while (true) {
830
+ if (this.atEnd()) throw new PowerShellRefusal("unbalanced-quotes", "A single quote is never closed.");
831
+ const inner = this.peek();
832
+ if (inSet(SINGLE_QUOTES, inner)) {
833
+ if (inSet(SINGLE_QUOTES, this.peek(1))) {
834
+ value += this.peek(1);
835
+ this.pos += 2;
836
+ continue;
837
+ }
838
+ this.pos++;
839
+ break;
840
+ }
841
+ value += inner;
842
+ this.pos++;
843
+ }
844
+ continue;
845
+ }
846
+ if (inSet(DOUBLE_QUOTES, char)) {
847
+ this.pos++;
848
+ quoted = true;
849
+ while (true) {
850
+ if (this.atEnd()) throw new PowerShellRefusal("unbalanced-quotes", "A double quote is never closed.");
851
+ const inner = this.peek();
852
+ if (inSet(DOUBLE_QUOTES, inner)) {
853
+ if (inSet(DOUBLE_QUOTES, this.peek(1))) {
854
+ value += this.peek(1);
855
+ this.pos += 2;
856
+ continue;
857
+ }
858
+ this.pos++;
859
+ break;
860
+ }
861
+ if (inner === "`") {
862
+ const next = this.peek(1);
863
+ if (next !== "" && (inSet(DOUBLE_QUOTES, next) || next === "`" || next === "$")) {
864
+ value += next;
865
+ this.pos += 2;
866
+ continue;
867
+ }
868
+ throw unsupported$1("Only `\"`, `` ` `` and `$` can follow a backtick inside double quotes.");
869
+ }
870
+ if (inner === "$") {
871
+ const read = this.readVariable();
872
+ value += read;
873
+ dynamic = true;
874
+ continue;
875
+ }
876
+ value += inner;
877
+ this.pos++;
878
+ }
879
+ continue;
880
+ }
881
+ if (char === "`") {
882
+ const next = this.peek(1);
883
+ if (next === "" || next === "\n" || next === "\r") throw unsupported$1("A backtick at the end of a line (line continuation) is not supported.");
884
+ value += next;
885
+ quoted = true;
886
+ this.pos += 2;
887
+ continue;
888
+ }
889
+ if (char === "$") {
890
+ const wasLiteral = this.src.charAt(this.pos + 1);
891
+ if (wasLiteral === "" || !(wasLiteral === "{" || wasLiteral === "(" || VARIABLE_CHAR.test(wasLiteral))) {
892
+ value += "$";
893
+ this.pos++;
894
+ continue;
895
+ }
896
+ value += this.readVariable();
897
+ dynamic = true;
898
+ continue;
899
+ }
900
+ if (char === "@" && this.pos === start) {
901
+ const next = this.peek(1);
902
+ if (next === "(" || next === "{" || next === "'" || next === "\"" || /[A-Za-z_$]/.test(next)) throw unsupported$1("Arrays (`@( )`), hashtables, splatting (`@name`) and here-strings are not supported.");
903
+ }
904
+ if (char === "{" || char === "}") throw unsupported$1("Script blocks and braces are not supported.");
905
+ if (char === "(" || char === ")") throw unsupported$1("Parentheses (grouping and `$( )` subexpressions) are not supported; quote the text.");
906
+ if (char === ",") throw unsupported$1("The comma operator builds an array and is not supported; quote the text.");
907
+ if (char === "#" && this.pos === start) throw unsupported$1("Comments are not supported in a command line.");
908
+ if (char === "*" || char === "?" || char === "[" || char === "~" && this.pos === start) dynamic = true;
909
+ value += char;
910
+ this.pos++;
911
+ }
912
+ return {
913
+ raw: this.src.slice(start, this.pos),
914
+ value,
915
+ dynamic,
916
+ quoted
917
+ };
918
+ }
919
+ /**
920
+ * Parse a `$( … )` subexpression: its commands run, so they are collected
921
+ * (before the command that contains it) and checked like any other.
922
+ */
923
+ parseSubexpression() {
924
+ if (this.depth >= 8) throw unsupported$1("`$( )` subexpressions are nested too deeply.");
925
+ const open = this.pos;
926
+ let level = 0;
927
+ let index = open;
928
+ for (; index < this.src.length; index++) {
929
+ const char = this.src.charAt(index);
930
+ if (inSet(SINGLE_QUOTES, char) || inSet(DOUBLE_QUOTES, char)) {
931
+ const set = inSet(SINGLE_QUOTES, char) ? SINGLE_QUOTES : DOUBLE_QUOTES;
932
+ index++;
933
+ while (index < this.src.length) {
934
+ const inner = this.src.charAt(index);
935
+ if (inner === "`" && set === DOUBLE_QUOTES) {
936
+ index += 2;
937
+ continue;
938
+ }
939
+ if (inSet(set, inner)) {
940
+ if (inSet(set, this.src.charAt(index + 1))) {
941
+ index += 2;
942
+ continue;
943
+ }
944
+ break;
945
+ }
946
+ index++;
947
+ }
948
+ continue;
949
+ }
950
+ if (char === "`") {
951
+ index++;
952
+ continue;
953
+ }
954
+ if (char === "(") level++;
955
+ else if (char === ")") {
956
+ level--;
957
+ if (level === 0) break;
958
+ }
959
+ }
960
+ if (level !== 0) throw new PowerShellRefusal("unbalanced-quotes", "A `$(` subexpression is never closed.");
961
+ const inner = this.src.slice(open + 1, index);
962
+ new PowerShellParser(inner, this.commands, this.depth + 1, this.chaining).parseProgram([]);
963
+ this.pos = index + 1;
964
+ }
965
+ /** Read `$name`, `${name}` or `$env:NAME` at the cursor; a `$(` is refused. */
966
+ readVariable() {
967
+ const begin = this.pos;
968
+ this.pos++;
969
+ if (this.peek() === "(") {
970
+ this.parseSubexpression();
971
+ return this.src.slice(begin, this.pos);
972
+ }
973
+ if (this.peek() === "{") {
974
+ const close = this.src.indexOf("}", this.pos);
975
+ if (close === -1) throw new PowerShellRefusal("unbalanced-quotes", "A `${` variable name is never closed.");
976
+ this.pos = close + 1;
977
+ return this.src.slice(begin, this.pos);
978
+ }
979
+ while (!this.atEnd() && VARIABLE_CHAR.test(this.peek())) this.pos++;
980
+ return this.src.slice(begin, this.pos);
981
+ }
982
+ };
983
+ /** Parse a PowerShell command line for the shell policy. Never throws. */
984
+ function parsePowerShellLine(line, options) {
985
+ const segments = [];
986
+ const parser = new PowerShellParser(line, [], 0, options?.chaining !== false);
987
+ try {
988
+ parser.parseProgram(segments);
989
+ return {
990
+ ok: true,
991
+ commands: parser.commands,
992
+ segments,
993
+ background: false
994
+ };
995
+ } catch (error) {
996
+ if (error instanceof PowerShellRefusal) return {
997
+ ok: false,
998
+ reason: error.reason,
999
+ message: error.message
1000
+ };
1001
+ throw error;
1002
+ }
1003
+ }
1004
+
183
1005
  //#endregion
184
1006
  //#region ../../packages/ai-workspace/src/policy/shell-parser.ts
185
1007
  /** Deepest nesting of subshells and substitutions the parser accepts. */
@@ -947,6 +1769,7 @@ var CmdParser = class {
947
1769
  */
948
1770
  function parseShellLine(line, dialect) {
949
1771
  const segments = [];
1772
+ if (dialect === "powershell" || dialect === "powershell5") return parsePowerShellLine(line, { chaining: dialect === "powershell" });
950
1773
  try {
951
1774
  if (dialect === "cmd") {
952
1775
  const commands = [];
@@ -1159,6 +1982,26 @@ const OPAQUE_RUNNERS = /* @__PURE__ */ new Set([
1159
1982
  "stdbuf",
1160
1983
  "flock"
1161
1984
  ]);
1985
+ /**
1986
+ * PowerShell cmdlets and aliases that run code or commands the policy cannot
1987
+ * see, or change what later names mean (lowercase).
1988
+ */
1989
+ const POWERSHELL_OPAQUE = /* @__PURE__ */ new Set([
1990
+ "iex",
1991
+ "invoke-expression",
1992
+ "invoke-command",
1993
+ "icm",
1994
+ "start-process",
1995
+ "saps",
1996
+ "start",
1997
+ "add-type",
1998
+ "set-alias",
1999
+ "sal",
2000
+ "new-alias",
2001
+ "nal",
2002
+ "invoke-item",
2003
+ "ii"
2004
+ ]);
1162
2005
  /** POSIX shell builtins that evaluate code or change how later names resolve. */
1163
2006
  const SHELL_OPAQUE_BUILTINS = /* @__PURE__ */ new Set([
1164
2007
  "eval",
@@ -1251,7 +2094,7 @@ const MAX_WRAPPER_DEPTH = 8;
1251
2094
  const MAX_CD_CANDIDATES = 32;
1252
2095
  /** Longest command shown verbatim in a refusal before it is abbreviated. */
1253
2096
  const MAX_SHOWN_COMMAND = 48;
1254
- /** The dialect shell mode parses on this platform. */
2097
+ /** The dialect `interpreter: "sh"` parses on this platform. */
1255
2098
  function shellDialect() {
1256
2099
  return process.platform === "win32" ? "cmd" : "posix";
1257
2100
  }
@@ -1262,7 +2105,7 @@ function shellDialect() {
1262
2105
  * stripped). The cmd dialect splits Windows paths too.
1263
2106
  */
1264
2107
  function executableBasename(firstToken, dialect = "posix") {
1265
- return (dialect === "cmd" ? node_path.default.win32.basename(firstToken) : node_path.default.basename(firstToken)).replace(/\.(exe|cmd|bat|com)$/i, "");
2108
+ return (dialect === "cmd" || dialect === "powershell" || dialect === "powershell5" ? node_path.default.win32.basename(firstToken) : node_path.default.basename(firstToken)).replace(dialect === "powershell" || dialect === "powershell5" ? /\.(exe|cmd|bat|com|ps1)$/i : /\.(exe|cmd|bat|com)$/i, "");
1266
2109
  }
1267
2110
  /** Show a command in a refusal, abbreviated at a word boundary when long. */
1268
2111
  function show(source) {
@@ -1306,25 +2149,75 @@ var Refusal = class extends Error {
1306
2149
  };
1307
2150
  /** Per-line checker state. */
1308
2151
  var LineChecker = class {
1309
- constructor(policy, shell, line, dialect) {
2152
+ constructor(policy, shell, line, dialect, interpreter, remembered = []) {
1310
2153
  this.policy = policy;
1311
2154
  this.shell = shell;
1312
2155
  this.line = line;
1313
2156
  this.dialect = dialect;
2157
+ this.interpreter = interpreter;
1314
2158
  this.commands = [];
1315
2159
  this.paths = [];
2160
+ this.asks = [];
1316
2161
  this.deny = new Set((shell.deny ?? []).map((name) => name.toLowerCase()));
1317
2162
  this.allow = shell.allow ?? [];
1318
2163
  this.patterns = (shell.denyPatterns ?? []).map(compilePattern);
1319
- this.interpreter = shell.interpreter ?? "none";
1320
- this.hasCommandRules = this.deny.size > 0 || this.patterns.length > 0 || shell.allowCommand !== void 0;
2164
+ this.rules = (shell.rules ?? []).map((rule) => ({
2165
+ rule,
2166
+ pattern: compilePattern(rule.match)
2167
+ }));
2168
+ this.remembered = remembered.map(compilePattern);
2169
+ this.askMode = shell.onAsk !== void 0;
2170
+ this.hasCommandRules = this.deny.size > 0 || this.patterns.length > 0 || this.rules.length > 0 || shell.allowCommand !== void 0;
1321
2171
  }
1322
2172
  /** Whether argument hooks (`denyPatterns` / `allowCommand`) need literal argv. */
1323
2173
  get hasArgumentHooks() {
1324
2174
  return this.patterns.length > 0 || this.shell.allowCommand !== void 0;
1325
2175
  }
1326
2176
  get shellMode() {
1327
- return this.interpreter === "sh";
2177
+ return this.interpreter !== "none";
2178
+ }
2179
+ /** The last rule that matches `argv` (later rules win). */
2180
+ matchRule(argv) {
2181
+ for (let index = this.rules.length - 1; index >= 0; index--) {
2182
+ const compiled = this.rules[index];
2183
+ if (compiled.pattern.test(argv, this.dialect)) return compiled.rule;
2184
+ }
2185
+ }
2186
+ isRemembered(argv) {
2187
+ return this.remembered.some((pattern) => pattern.test(argv, this.dialect));
2188
+ }
2189
+ /** Record a command that needs a decision (once per argv and reason). */
2190
+ addAsk(argv, reason, rule) {
2191
+ const key = `${reason}\u0000${argv.join("\0")}`;
2192
+ if (this.asks.some((ask) => `${ask.reason}\u0000${ask.argv.join("\0")}` === key)) return;
2193
+ this.asks.push({
2194
+ argv: [...argv],
2195
+ reason,
2196
+ ...rule !== void 0 ? { rule } : {}
2197
+ });
2198
+ }
2199
+ /** Refuse when the matching rule denies. */
2200
+ assertNotDenied(argv, rule) {
2201
+ if (rule?.effect !== "deny") return;
2202
+ const explanation = rule.reason !== void 0 && rule.reason.trim() !== "" ? ` ${rule.reason.trim()}` : "";
2203
+ throw new Refusal("denied-pattern", `${show(argv.join(" "))} is denied by a workspace shell rule.${explanation}`, executableBasename(argv[0] ?? "", this.dialect));
2204
+ }
2205
+ /**
2206
+ * Check the canonical form of a PowerShell alias (`Remove-Item -Recurse
2207
+ * -Force x` as `rm -rf x`): deny, `denyPatterns`, `allowCommand` and the
2208
+ * rules, but not the `allow` list (which names the spelling that was written).
2209
+ */
2210
+ checkCanonical(argv) {
2211
+ const key = executableBasename(argv[0] ?? "", this.dialect).toLowerCase();
2212
+ if (this.deny.has(key)) throw new Refusal("denied-executable", `\`${key}\` is denied by the workspace shell policy (in ${show(argv.join(" "))}).`, key);
2213
+ this.checkHooks(argv, true);
2214
+ const rule = this.matchRule(argv);
2215
+ this.assertNotDenied(argv, rule);
2216
+ if (rule?.effect === "ask" && !this.isRemembered(argv)) this.addAsk(argv, "rule", rule);
2217
+ }
2218
+ /** Refuse a denied executable (used for `cd` spellings). */
2219
+ assertExecutableNotDenied(key, argv) {
2220
+ if (this.deny.has(key)) throw new Refusal("denied-executable", `\`${key}\` is denied by the workspace shell policy (in ${show(argv.join(" "))}).`, key);
1328
2221
  }
1329
2222
  /** Run `denyPatterns` and `allowCommand` on one argv. */
1330
2223
  checkHooks(argv, nested) {
@@ -1372,15 +2265,34 @@ var LineChecker = class {
1372
2265
  throw new Refusal("not-allowed", `\`${executable}\` is not in the workspace shell allowlist. Allowed commands: ${listed}${this.allow.length > 20 ? ", …" : ""}.`, executable);
1373
2266
  }
1374
2267
  this.checkHooks(argv, depth > 0);
2268
+ const rule = this.matchRule(argv);
2269
+ this.assertNotDenied(argv, rule);
1375
2270
  this.commands.push([...argv]);
1376
- if (OPAQUE_RUNNERS.has(key) || this.shellMode && this.dialect === "posix" && SHELL_OPAQUE_BUILTINS.has(key) || this.shellMode && this.dialect === "cmd" && CMD_OPAQUE_BUILTINS.has(key)) {
2271
+ const remembered = this.isRemembered(argv);
2272
+ const approved = rule?.effect === "allow" || remembered || this.explicitlyAllowed(executable);
2273
+ if (OPAQUE_RUNNERS.has(key) || this.shellMode && this.dialect === "posix" && SHELL_OPAQUE_BUILTINS.has(key) || this.shellMode && this.dialect === "cmd" && CMD_OPAQUE_BUILTINS.has(key) || this.shellMode && (this.dialect === "powershell" || this.dialect === "powershell5") && POWERSHELL_OPAQUE.has(key)) {
2274
+ if (approved) return;
2275
+ if (this.askMode || rule?.effect === "ask") {
2276
+ this.addAsk(argv, "nested", rule);
2277
+ return;
2278
+ }
1377
2279
  this.checkOpaque(executable, argv);
1378
2280
  return;
1379
2281
  }
1380
2282
  if (this.shellMode && this.dialect === "posix" && ARITHMETIC_SENSITIVE_BUILTINS.has(key)) this.checkArithmeticSensitive(executable, argv, dynamic);
1381
2283
  const ranges = this.unwrap(key, argv);
2284
+ if (!approved && ranges === void 0) {
2285
+ if (rule?.effect === "ask") this.addAsk(argv, "rule", rule);
2286
+ else if (rule === void 0 && this.askMode && this.allow.length === 0) this.addAsk(argv, "rule");
2287
+ } else if (!approved && rule?.effect === "ask") this.addAsk(argv, "rule", rule);
1382
2288
  if (ranges === void 0) return;
1383
- if (ranges === null || depth >= MAX_WRAPPER_DEPTH) throw new Refusal("nested-command", `\`${executable}\` is used with options the workspace shell policy cannot read (in ${show(argv.join(" "))}). Run the inner command directly instead.`, executable);
2289
+ if (ranges === null || depth >= MAX_WRAPPER_DEPTH) {
2290
+ if (this.askMode) {
2291
+ this.addAsk(argv, "nested", rule);
2292
+ return;
2293
+ }
2294
+ throw new Refusal("nested-command", `\`${executable}\` is used with options the workspace shell policy cannot read (in ${show(argv.join(" "))}). Run the inner command directly instead.`, executable);
2295
+ }
1384
2296
  for (const [start, end] of ranges) this.checkArgv(argv.slice(start, end), dynamic.slice(start, end), depth + 1);
1385
2297
  }
1386
2298
  /**
@@ -1446,6 +2358,7 @@ var LineChecker = class {
1446
2358
  }
1447
2359
  /** Check a redirection target from every possible cwd. */
1448
2360
  checkRedirectTarget(word, access, cwds) {
2361
+ if ((this.dialect === "powershell" || this.dialect === "powershell5") && !word.quoted && word.value.toLowerCase() === "$null") return;
1449
2362
  if (word.dynamic) throw new Refusal("unsupported-syntax", `The redirection target \`${word.raw}\` must be a literal path (no variables, globs or \`~\`).`);
1450
2363
  const value = word.value;
1451
2364
  if (this.dialect === "posix" && DEVICE_TARGETS.has(value) || this.dialect === "cmd" && CMD_DEVICE_TARGETS.has(value.toLowerCase())) return;
@@ -1638,11 +2551,17 @@ function argvModeRefusal(command) {
1638
2551
  if (token === "(" || token === ")") return new Refusal("metacharacter", `\`${token}\` is not supported in argv mode: put it inside quotes to pass it as text.${suffix}`);
1639
2552
  return new Refusal("metacharacter", `\`${token === "\n" ? "\\n" : token}\` is not supported in argv mode: run one command per call, and quote any argument that contains it.${suffix}`);
1640
2553
  }
1641
- /** Check a shell-mode (`interpreter: "sh"`) line. */
2554
+ /** Check a shell-mode line. */
1642
2555
  function checkShellLine(checker, policy, command, dialect) {
1643
2556
  if (/^[-+]/.test(command)) throw new Refusal("unsupported-syntax", "A command line may not start with `-` or `+`.");
1644
2557
  const parsed = parseShellLine(command, dialect);
1645
- if (!parsed.ok) throw new Refusal(parsed.reason, `${parsed.message} The workspace shell policy only runs lines it can check.`);
2558
+ if (!parsed.ok) {
2559
+ if (checker.askMode) {
2560
+ checker.addAsk([command], "unparsed");
2561
+ return;
2562
+ }
2563
+ throw new Refusal(parsed.reason, `${parsed.message} The workspace shell policy only runs lines it can check.`);
2564
+ }
1646
2565
  if (parsed.background) throw new Refusal("unsupported-syntax", "`&` (run in the background) is not supported in a command line: start a long-running command with background: true instead.");
1647
2566
  let cwds = [node_path.default.resolve(policy.cwd)];
1648
2567
  for (const simple of parsed.commands) cwds = checkSimpleCommand(checker, simple, cwds, dialect);
@@ -1656,16 +2575,38 @@ function checkSimpleCommand(checker, simple, cwds, dialect) {
1656
2575
  for (const redirect of simple.redirects) if (redirect.type === "read" || redirect.type === "write") checker.checkRedirectTarget(redirect.target, redirect.type, cwds);
1657
2576
  const first = simple.words[0];
1658
2577
  if (first === void 0) return cwds;
1659
- if (!first.dynamic && first.value.includes("=")) throw new Refusal("unsupported-syntax", `\`${first.raw}\` is not a plain command name or \`NAME=value\` assignment.`);
1660
- if (/^[-+]/.test(first.value)) throw new Refusal("unsupported-syntax", `\`${first.raw}\` is not a command name.`);
1661
- if (checker.hasArgumentHooks) {
1662
- const dynamic = simple.words.find((word) => word.dynamic);
1663
- if (dynamic !== void 0) throw new Refusal("unsupported-syntax", `\`${dynamic.raw}\` is decided when the line runs, so it cannot be checked against the workspace shell policy's command rules. Write the value literally (quote globs).`);
2578
+ const executable = first.dynamic ? "" : executableBasename(first.value, dialect).toLowerCase();
2579
+ const powershell = dialect === "powershell" || dialect === "powershell5";
2580
+ if (executable === "cd" || powershell && POWERSHELL_CD_NAMES.has(executable)) {
2581
+ checker.assertExecutableNotDenied(executable, simple.words.map((word) => word.value));
2582
+ if (powershell) {
2583
+ const words = normalizePowerShellCd(simple.words);
2584
+ if (words === null) throw new Refusal("unsupported-syntax", "`Set-Location` is supported with one directory (`-Path` / `-LiteralPath` or positional) only.", "cd");
2585
+ return checker.checkCd(words, cwds);
2586
+ }
2587
+ return checker.checkCd(simple.words, cwds);
2588
+ }
2589
+ try {
2590
+ if (!first.dynamic && first.value.includes("=")) throw new Refusal("unsupported-syntax", `\`${first.raw}\` is not a plain command name or \`NAME=value\` assignment.`);
2591
+ if (/^[-+]/.test(first.value)) throw new Refusal("unsupported-syntax", `\`${first.raw}\` is not a command name.`);
2592
+ if (checker.hasArgumentHooks) {
2593
+ const dynamic = simple.words.find((word) => word.dynamic);
2594
+ if (dynamic !== void 0) throw new Refusal("unsupported-syntax", `\`${dynamic.raw}\` is decided when the line runs, so it cannot be checked against the workspace shell policy's command rules. Write the value literally (quote globs).`);
2595
+ }
2596
+ if (dialect === "posix" && (executable === "pushd" || executable === "popd") || powershell && POWERSHELL_DIRECTORY_STACK.has(executable)) throw new Refusal("unsupported-syntax", `\`${executable}\` is not supported; use \`${powershell ? "Set-Location" : "cd"}\`.`);
2597
+ const argv = simple.words.map((word) => word.dynamic ? word.raw : word.value);
2598
+ checker.checkArgv(argv, simple.words.map((word) => word.dynamic), 0);
2599
+ if (powershell) {
2600
+ const canonical = canonicalPowerShellArgv(argv);
2601
+ if (canonical !== void 0 && canonical.join("\0") !== argv.join("\0")) checker.checkCanonical(canonical);
2602
+ }
2603
+ } catch (error) {
2604
+ if (error instanceof Refusal && error.reason === "unsupported-syntax" && checker.askMode) {
2605
+ checker.addAsk(simple.words.map((word) => word.dynamic ? word.raw : word.value), "unparsed");
2606
+ return cwds;
2607
+ }
2608
+ throw error;
1664
2609
  }
1665
- const executable = first.dynamic ? "" : executableBasename(first.value, dialect).toLowerCase();
1666
- if (executable === "cd") return checker.checkCd(simple.words, cwds);
1667
- if (dialect === "posix" && (executable === "pushd" || executable === "popd")) throw new Refusal("unsupported-syntax", `\`${executable}\` is not supported; use \`cd\`.`);
1668
- checker.checkArgv(simple.words.map((word) => word.dynamic ? word.raw : word.value), simple.words.map((word) => word.dynamic), 0);
1669
2610
  return cwds;
1670
2611
  }
1671
2612
  /**
@@ -1693,35 +2634,44 @@ function checkSimpleCommand(checker, simple, cwds, dialect) {
1693
2634
  * checkShellCommand({ cwd, shell: { allow: ["git"] } }, "git add -A && git commit -m wip");
1694
2635
  * // { allowed: false, reason: "metacharacter", message: "`&&` is not supported in argv mode: run `git add -A`, then `git commit -m wip`, …" }
1695
2636
  */
1696
- function checkShellCommand(policy, command) {
1697
- return checkShellCommandInDialect(policy, command, shellDialect());
2637
+ function checkShellCommand(policy, command, options) {
2638
+ const resolved = options?.resolved ?? resolveShell(policy.shell, options?.host ?? currentShellHost());
2639
+ return runCheck(policy, command, resolved.interpreter, resolved.dialect, options?.remembered);
1698
2640
  }
1699
- /**
1700
- * {@link checkShellCommand} with the shell-mode dialect chosen explicitly
1701
- * instead of from the platform (used to test the cmd.exe rules anywhere).
1702
- * Argv mode ignores `dialect`.
1703
- */
1704
- function checkShellCommandInDialect(policy, command, dialect) {
2641
+ function runCheck(policy, command, interpreter, dialect, remembered) {
1705
2642
  const shell = policy.shell;
1706
2643
  if (!shell) return {
1707
2644
  allowed: false,
1708
2645
  reason: "not-allowed",
1709
2646
  message: "The workspace has no shell policy, so no command may run.",
2647
+ asks: [],
1710
2648
  commands: [],
1711
2649
  paths: []
1712
2650
  };
1713
- const interpreter = shell.interpreter ?? "none";
1714
- const checker = new LineChecker(policy, shell, command, interpreter === "sh" ? dialect : "posix");
2651
+ const shellMode = interpreter !== "none";
2652
+ const checker = new LineChecker(policy, shell, command, shellMode ? dialect : "posix", interpreter, remembered);
1715
2653
  try {
1716
2654
  if (command.trim() === "") throw new Refusal("not-allowed", "The command is empty.");
1717
- if (interpreter === "sh") checkShellLine(checker, policy, command, dialect);
2655
+ if (shellMode) checkShellLine(checker, policy, command, dialect);
1718
2656
  else {
1719
2657
  const argv = tokenizeCommand(command);
1720
2658
  if (argv === null) throw argvModeRefusal(command);
1721
2659
  checker.checkArgv(argv, argv.map(() => false), 0);
1722
2660
  }
2661
+ if (checker.asks.length > 0) {
2662
+ const first = checker.asks[0];
2663
+ return {
2664
+ allowed: false,
2665
+ reason: "approval-required",
2666
+ message: `${show(first.argv.join(" "))} needs approval, and the workspace shell policy has no approval handler (shell.onAsk).${first.rule?.reason !== void 0 ? ` ${first.rule.reason}` : ""}`,
2667
+ asks: checker.asks,
2668
+ commands: checker.commands,
2669
+ paths: checker.paths
2670
+ };
2671
+ }
1723
2672
  return {
1724
2673
  allowed: true,
2674
+ asks: [],
1725
2675
  commands: checker.commands,
1726
2676
  paths: checker.paths
1727
2677
  };
@@ -1733,11 +2683,34 @@ function checkShellCommandInDialect(policy, command, dialect) {
1733
2683
  message: error.message,
1734
2684
  ...error.executable !== void 0 ? { executable: error.executable } : {},
1735
2685
  ...error.refusedPath !== void 0 ? { path: error.refusedPath } : {},
2686
+ asks: [],
1736
2687
  commands: checker.commands,
1737
2688
  paths: checker.paths
1738
2689
  };
1739
2690
  }
1740
2691
  }
2692
+ /**
2693
+ * The simple commands a line would run (pipeline members, `&&` / `||` / `;`
2694
+ * parts, subshells and substitutions included), without checking anything.
2695
+ * A line the parser cannot read with confidence comes back with `opaque`
2696
+ * set to the reason and no commands.
2697
+ *
2698
+ * The line is read with the policy's shell dialect when its `interpreter`
2699
+ * is a shell, and with the platform's default shell otherwise.
2700
+ *
2701
+ * @example
2702
+ * classifyShellCommand("a | b"); // { commands: [["a"], ["b"]] }
2703
+ */
2704
+ function classifyShellCommand(command, policy, host) {
2705
+ const resolved = resolveShell(policy?.shell, host ?? currentShellHost());
2706
+ const dialect = resolved.interpreter === "none" ? shellDialect() : resolved.dialect;
2707
+ const parsed = parseShellLine(command, dialect);
2708
+ if (!parsed.ok) return {
2709
+ commands: [],
2710
+ opaque: parsed.message
2711
+ };
2712
+ return { commands: parsed.commands.filter((simple) => simple.words.length > 0).map((simple) => simple.words.map((word) => word.dynamic ? word.raw : word.value)) };
2713
+ }
1741
2714
 
1742
2715
  //#endregion
1743
2716
  //#region ../../packages/ai-workspace/src/policy/policy.ts
@@ -1785,13 +2758,15 @@ async function canonicalize$1(target) {
1785
2758
  *
1786
2759
  * @param policy - The bounding policy (its `cwd` is the jail root).
1787
2760
  * @param inputPath - A workspace-relative or absolute path to resolve.
2761
+ * @param access - `"read"` also accepts the `output.spillDir` directory (only
2762
+ * `readFile` asks for it); `"write"` (default) never does.
1788
2763
  * @returns The canonical absolute path plus its `/`-separated relative form.
1789
2764
  * @throws {WorkspacePolicyError} When the path escapes the jail or hits a deny glob.
1790
2765
  *
1791
2766
  * @example
1792
2767
  * const { absolutePath } = await resolveInJail(policy, "src/index.ts");
1793
2768
  */
1794
- async function resolveInJail(policy, inputPath) {
2769
+ async function resolveInJail(policy, inputPath, access = "write") {
1795
2770
  const jailRoot = await canonicalize$1(policy.cwd);
1796
2771
  const absolutePath = await canonicalize$1(node_path.default.isAbsolute(inputPath) ? inputPath : node_path.default.join(policy.cwd, inputPath));
1797
2772
  const insideCwd = isInside(absolutePath, jailRoot);
@@ -1804,6 +2779,11 @@ async function resolveInJail(policy, inputPath) {
1804
2779
  break;
1805
2780
  }
1806
2781
  }
2782
+ const spillDir = access === "read" && !insideCwd ? resolveSpillDir(policy) : void 0;
2783
+ if (spillDir !== void 0 && isInside(absolutePath, await canonicalize$1(spillDir))) return {
2784
+ absolutePath,
2785
+ relativePath: ""
2786
+ };
1807
2787
  if (!insideCwd && !insideAllow) throw new WorkspacePolicyError(`Path "${inputPath}" resolves outside the workspace jail.`, {
1808
2788
  type: "path-escape",
1809
2789
  path: inputPath
@@ -1870,8 +2850,8 @@ function buildEnv(policy) {
1870
2850
  return env;
1871
2851
  }
1872
2852
  /**
1873
- * Variables removed from the environment of a shell-mode (`interpreter:
1874
- * "sh"`) command: each changes how the shell itself starts, parses or
2853
+ * Variables removed from the environment of a shell-mode (`interpreter`
2854
+ * other than `"none"`) command: each changes how the shell itself starts, parses or
1875
2855
  * resolves the line (`ENV`/`BASH_ENV` run a startup file, `SHELLOPTS` +
1876
2856
  * `PS4` can run command substitutions, `BASH_FUNC_*` imports functions that
1877
2857
  * shadow commands, `CDPATH` moves `cd`, `IFS` re-splits words).
@@ -1893,7 +2873,7 @@ const SHELL_STARTUP_VARIABLES = /* @__PURE__ */ new Set([
1893
2873
  */
1894
2874
  function buildCommandEnv(policy) {
1895
2875
  const env = buildEnv(policy);
1896
- if ((policy.shell?.interpreter ?? "none") !== "sh") return env;
2876
+ if ((policy.shell?.interpreter ?? "none") === "none") return env;
1897
2877
  for (const key of Object.keys(env)) {
1898
2878
  const upper = key.toUpperCase();
1899
2879
  if (SHELL_STARTUP_VARIABLES.has(upper) || upper.startsWith("BASH_FUNC_")) delete env[key];
@@ -2388,6 +3368,543 @@ function applyPatchWithOps(ops, input) {
2388
3368
  });
2389
3369
  }
2390
3370
 
3371
+ //#endregion
3372
+ //#region ../../packages/ai-workspace/src/edit/match.ts
3373
+ /** Most lines of file text a miss hint shows. */
3374
+ const MAX_HINT_LINES = 12;
3375
+ /** Most characters of one line a miss hint shows. */
3376
+ const MAX_HINT_LINE_CHARS = 200;
3377
+ const FOLD_MAP = {
3378
+ "‘": "'",
3379
+ "’": "'",
3380
+ "‚": "'",
3381
+ "‛": "'",
3382
+ "“": "\"",
3383
+ "”": "\"",
3384
+ "„": "\"",
3385
+ "‟": "\"",
3386
+ "‐": "-",
3387
+ "‑": "-",
3388
+ "‒": "-",
3389
+ "–": "-",
3390
+ "—": "-",
3391
+ "―": "-",
3392
+ "−": "-",
3393
+ "\xA0": " ",
3394
+ " ": " ",
3395
+ " ": " ",
3396
+ " ": " ",
3397
+ " ": " ",
3398
+ " ": " ",
3399
+ " ": " "
3400
+ };
3401
+ /** Fold look-alike characters one for one, so string indexes stay aligned. */
3402
+ function foldUnicode(value) {
3403
+ let out = "";
3404
+ for (const char of value) out += FOLD_MAP[char] ?? char;
3405
+ return out;
3406
+ }
3407
+ /** Every non-overlapping occurrence of `needle` in `haystack`. */
3408
+ function findAll(haystack, needle) {
3409
+ const ranges = [];
3410
+ if (needle === "") return ranges;
3411
+ let from = 0;
3412
+ for (;;) {
3413
+ const index = haystack.indexOf(needle, from);
3414
+ if (index === -1) return ranges;
3415
+ ranges.push({
3416
+ start: index,
3417
+ end: index + needle.length
3418
+ });
3419
+ from = index + needle.length;
3420
+ }
3421
+ }
3422
+ /** The file's dominant line ending. */
3423
+ function detectEol(content) {
3424
+ const crlf = content.split("\r\n").length - 1;
3425
+ return crlf > content.split("\n").length - 1 - crlf ? "\r\n" : "\n";
3426
+ }
3427
+ /** Convert any line endings in `text` to `eol`. */
3428
+ function withEol(text, eol) {
3429
+ const normalised = text.replace(/\r\n/g, "\n");
3430
+ return eol === "\n" ? normalised : normalised.replace(/\n/g, "\r\n");
3431
+ }
3432
+ /** An LF view of `content` plus the original index of each view character. */
3433
+ function lfView(content) {
3434
+ if (!content.includes("\r\n")) return {
3435
+ view: content,
3436
+ map: null
3437
+ };
3438
+ let view = "";
3439
+ const map = [];
3440
+ for (let index = 0; index < content.length; index++) {
3441
+ if (content[index] === "\r" && content[index + 1] === "\n") continue;
3442
+ map.push(content[index] === "\n" && content[index - 1] === "\r" ? index - 1 : index);
3443
+ view += content[index];
3444
+ }
3445
+ map.push(content.length);
3446
+ return {
3447
+ view,
3448
+ map
3449
+ };
3450
+ }
3451
+ /** Line-based search ignoring trailing whitespace, on already folded text. */
3452
+ function findByLines(view, needle) {
3453
+ const needleLines = needle.split("\n");
3454
+ const endsWithBreak = needleLines.length > 1 && needleLines[needleLines.length - 1] === "";
3455
+ if (endsWithBreak) needleLines.pop();
3456
+ const wanted = needleLines.map((line) => line.trimEnd());
3457
+ if (wanted.every((line) => line === "")) return [];
3458
+ const lines = view.split("\n");
3459
+ const starts = [];
3460
+ let offset = 0;
3461
+ for (const line of lines) {
3462
+ starts.push(offset);
3463
+ offset += line.length + 1;
3464
+ }
3465
+ const ranges = [];
3466
+ for (let index = 0; index + wanted.length <= lines.length;) {
3467
+ let same = true;
3468
+ for (let k = 0; k < wanted.length; k++) if ((lines[index + k] ?? "").trimEnd() !== wanted[k]) {
3469
+ same = false;
3470
+ break;
3471
+ }
3472
+ if (!same) {
3473
+ index++;
3474
+ continue;
3475
+ }
3476
+ const last = index + wanted.length - 1;
3477
+ let end = (starts[last] ?? 0) + (lines[last] ?? "").length;
3478
+ if (endsWithBreak && view[end] === "\n") end++;
3479
+ ranges.push({
3480
+ start: starts[index] ?? 0,
3481
+ end
3482
+ });
3483
+ index += wanted.length;
3484
+ }
3485
+ return ranges;
3486
+ }
3487
+ /**
3488
+ * Find `oldString` in `content`. `"exact"` only looks for the text as given;
3489
+ * `"tiered"` walks the tiers described on {@link WorkspaceEditMatching} and
3490
+ * stops at the first one with a match. The ranges are always in the original
3491
+ * content, whatever view found them.
3492
+ */
3493
+ function locateEdit(content, oldString, matching) {
3494
+ const exact = findAll(content, oldString);
3495
+ if (exact.length > 0 || matching === "exact") return {
3496
+ tier: exact.length > 0 ? "exact" : null,
3497
+ ranges: exact,
3498
+ viaView: false
3499
+ };
3500
+ const { view, map } = lfView(content);
3501
+ const needle = oldString.replace(/\r\n/g, "\n");
3502
+ const back = (ranges) => map === null ? ranges : ranges.map((range) => ({
3503
+ start: map[range.start] ?? content.length,
3504
+ end: map[range.end] ?? content.length
3505
+ }));
3506
+ if (map !== null || needle !== oldString) {
3507
+ const plain = findAll(view, needle);
3508
+ if (plain.length > 0) return {
3509
+ tier: "exact",
3510
+ ranges: back(plain),
3511
+ viaView: true
3512
+ };
3513
+ }
3514
+ const foldedView = foldUnicode(view);
3515
+ const foldedNeedle = foldUnicode(needle);
3516
+ const folded = findAll(foldedView, foldedNeedle);
3517
+ if (folded.length > 0) return {
3518
+ tier: "unicode",
3519
+ ranges: back(folded),
3520
+ viaView: true
3521
+ };
3522
+ const lined = findByLines(foldedView, foldedNeedle);
3523
+ if (lined.length > 0) return {
3524
+ tier: "whitespace",
3525
+ ranges: back(lined),
3526
+ viaView: true
3527
+ };
3528
+ return {
3529
+ tier: null,
3530
+ ranges: [],
3531
+ viaView: false
3532
+ };
3533
+ }
3534
+ function clipLine(line) {
3535
+ const flat = line.replace(/\r$/, "");
3536
+ return flat.length > 200 ? `${flat.slice(0, 200)}…` : flat;
3537
+ }
3538
+ function tokens(line) {
3539
+ return new Set(line.match(/[A-Za-z0-9_$]+/g) ?? []);
3540
+ }
3541
+ function similarity(left, right) {
3542
+ if (left.size === 0 || right.size === 0) return 0;
3543
+ let shared = 0;
3544
+ for (const token of left) if (right.has(token)) shared++;
3545
+ return shared / (left.size + right.size - shared);
3546
+ }
3547
+ /**
3548
+ * Explain a miss: either `newString` is already in the file (the edit was
3549
+ * probably applied), or the file text closest to `oldString`, at most
3550
+ * {@link MAX_HINT_LINES} lines of {@link MAX_HINT_LINE_CHARS} characters.
3551
+ * `undefined` when nothing is close.
3552
+ */
3553
+ function describeMiss(content, oldString, newString, maxLines = 12) {
3554
+ if (maxLines <= 0) return;
3555
+ const lines = content.split("\n");
3556
+ const present = newString.trim() === "" ? -1 : content.indexOf(newString);
3557
+ if (present !== -1 && newString !== oldString) return `newString already present in the file (line ${content.slice(0, present).split("\n").length}); the edit may already have been applied.`;
3558
+ const fileTokens = lines.map((line) => tokens(line));
3559
+ const wanted = oldString.split("\n");
3560
+ let best = {
3561
+ score: 0,
3562
+ fileLine: -1,
3563
+ offset: 0
3564
+ };
3565
+ wanted.forEach((text, offset) => {
3566
+ const own = tokens(text);
3567
+ fileTokens.forEach((candidate, fileLine) => {
3568
+ const score = similarity(own, candidate);
3569
+ if (score > best.score) best = {
3570
+ score,
3571
+ fileLine,
3572
+ offset
3573
+ };
3574
+ });
3575
+ });
3576
+ if (best.fileLine === -1 || best.score < .5) return;
3577
+ const first = Math.max(0, best.fileLine - best.offset);
3578
+ const count = Math.min(maxLines, 12, Math.max(1, wanted.length));
3579
+ const shown = lines.slice(first, first + count).map((line, index) => `${first + index + 1}: ${clipLine(line)}`);
3580
+ return `Closest text in the file (lines ${first + 1}-${first + shown.length}):\n${shown.join("\n")}`;
3581
+ }
3582
+
3583
+ //#endregion
3584
+ //#region ../../packages/ai-workspace/src/edit/apply-replacement.ts
3585
+ /**
3586
+ * Apply one replacement to `content` under the edit rules: `oldString` must
3587
+ * be found (see {@link WorkspaceEditMatching}), and exactly once unless
3588
+ * `replaceAll` — a fuzzy match must always be unique, so a loose match never
3589
+ * rewrites several places at once.
3590
+ */
3591
+ function applyReplacement(content, edit, inputPath, shownPath, options, editIndex) {
3592
+ const { matching } = options;
3593
+ const located = edit.oldString === "" ? null : locateEdit(content, edit.oldString, matching);
3594
+ const count = located?.ranges.length ?? 0;
3595
+ const which = editIndex === void 0 ? "" : `Edit ${editIndex + 1}: `;
3596
+ const nothingApplied = editIndex === void 0 ? "" : " None of the edits were applied.";
3597
+ const indexOption = editIndex === void 0 ? {} : { editIndex };
3598
+ if (located === null || count === 0) {
3599
+ let hint = describeMiss(content, edit.oldString, edit.newString, options.missHintLines);
3600
+ if (hint !== void 0 && options.hintMaxBytes !== void 0) hint = Buffer.byteLength(hint, "utf8") > options.hintMaxBytes ? void 0 : hint;
3601
+ throw new WorkspaceEditError(`${which}The text to replace was not found in "${inputPath}".` + (hint === void 0 ? "" : ` ${hint}`) + nothingApplied, {
3602
+ type: "not-found",
3603
+ path: shownPath,
3604
+ matches: 0,
3605
+ ...hint !== void 0 ? { hint } : {},
3606
+ ...indexOption
3607
+ });
3608
+ }
3609
+ const fuzzy = located.tier !== "exact";
3610
+ if (count > 1 && (!edit.replaceAll || fuzzy)) {
3611
+ const advice = fuzzy ? "include more surrounding context." : "pass replaceAll or include more surrounding context.";
3612
+ throw new WorkspaceEditError(`${which}The text to replace is not unique in "${inputPath}" (${count} matches${fuzzy && located.viaView ? ", found by loose matching" : ""}); ${advice}${nothingApplied}`, {
3613
+ type: "not-unique",
3614
+ path: shownPath,
3615
+ matches: count,
3616
+ ...indexOption
3617
+ });
3618
+ }
3619
+ const eol = options.lineEndings === "lf" ? "\n" : options.lineEndings === "crlf" ? "\r\n" : matching === "tiered" ? detectEol(content) : void 0;
3620
+ const replacement = eol === void 0 ? edit.newString : withEol(edit.newString, eol);
3621
+ const ranges = edit.replaceAll ? located.ranges : located.ranges.slice(0, 1);
3622
+ let out = "";
3623
+ let cursor = 0;
3624
+ for (const range of ranges) {
3625
+ out += content.slice(cursor, range.start) + replacement;
3626
+ cursor = range.end;
3627
+ }
3628
+ out += content.slice(cursor);
3629
+ return {
3630
+ content: out,
3631
+ replacements: ranges.length,
3632
+ ...fuzzy ? { matchTier: located.tier } : {}
3633
+ };
3634
+ }
3635
+
3636
+ //#endregion
3637
+ //#region ../../packages/ai-workspace/src/hooks/after-edit.ts
3638
+ /** How long a hook may take when the policy sets no `afterEditTimeoutMs`. */
3639
+ const DEFAULT_HOOK_TIMEOUT_MS = 1e4;
3640
+ /** Longest diagnostics text kept when the policy sets no `afterEditMaxChars`. */
3641
+ const DEFAULT_MAX_CHARS = 4e3;
3642
+ function wrap(text) {
3643
+ return `[diagnostics]\n${text}\n[/diagnostics]`;
3644
+ }
3645
+ /**
3646
+ * Run the policy's `afterEdit` hook for one call and return the text to put
3647
+ * on the result's `diagnostics`, or `undefined` when there is none. A throw
3648
+ * or a timeout yields "diagnostics unavailable"; it never rejects.
3649
+ */
3650
+ async function runAfterEdit(policy, event) {
3651
+ const hook = policy.hooks?.afterEdit;
3652
+ if (hook === void 0 || event.paths.length === 0) return;
3653
+ const timeoutMs = policy.hooks?.afterEditTimeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS;
3654
+ const maxChars = policy.hooks?.afterEditMaxChars ?? DEFAULT_MAX_CHARS;
3655
+ let timer;
3656
+ try {
3657
+ const answer = await Promise.race([Promise.resolve().then(() => hook(event)), new Promise((_resolve, reject) => {
3658
+ timer = setTimeout(() => reject(/* @__PURE__ */ new Error("afterEdit hook timed out")), timeoutMs);
3659
+ })]);
3660
+ if (typeof answer !== "string" || answer.trim() === "") return;
3661
+ const text = answer.trim();
3662
+ return wrap(text.length > maxChars ? `${text.slice(0, maxChars)}\n[diagnostics truncated: showing ${maxChars} of ${text.length} characters]` : text);
3663
+ } catch {
3664
+ return wrap("diagnostics unavailable");
3665
+ } finally {
3666
+ clearTimeout(timer);
3667
+ }
3668
+ }
3669
+
3670
+ //#endregion
3671
+ //#region ../../packages/ai-workspace/src/output/spill.ts
3672
+ const DEFAULT_MAX_LINES = 2e3;
3673
+ const DEFAULT_MAX_BYTES = 51200;
3674
+ const DEFAULT_RETENTION_MS = 6048e5;
3675
+ const SPILL_NAME = /^spill-(\d+)-[a-z0-9]+-[a-z0-9_-]+\.txt$/;
3676
+ function byteLength(value) {
3677
+ return Buffer.byteLength(value, "utf8");
3678
+ }
3679
+ /** The first `maxBytes` bytes of `value`, cut on a character boundary. */
3680
+ function headOf(value, maxBytes) {
3681
+ const bytes = Buffer.from(value, "utf8");
3682
+ if (bytes.byteLength <= maxBytes) return value;
3683
+ let cut = Math.max(0, maxBytes);
3684
+ while (cut > 0 && ((bytes[cut] ?? 0) & 192) === 128) cut--;
3685
+ return bytes.subarray(0, cut).toString("utf8");
3686
+ }
3687
+ /** The last `maxBytes` bytes of `value`, cut on a character boundary. */
3688
+ function tailOf(value, maxBytes) {
3689
+ const bytes = Buffer.from(value, "utf8");
3690
+ if (bytes.byteLength <= maxBytes) return value;
3691
+ let from = bytes.byteLength - Math.max(0, maxBytes);
3692
+ while (from < bytes.byteLength && ((bytes[from] ?? 0) & 192) === 128) from++;
3693
+ return bytes.subarray(from).toString("utf8");
3694
+ }
3695
+ /** Lines of `value`; a trailing line break does not add an empty line. */
3696
+ function splitLines(value) {
3697
+ if (value === "") return [];
3698
+ const lines = value.split("\n");
3699
+ if (lines[lines.length - 1] === "") lines.pop();
3700
+ return lines;
3701
+ }
3702
+ /** Pick lines from the front within `lines` / `bytes`; one oversized line is clipped. */
3703
+ function takeHead(lines, maxLines, maxBytes) {
3704
+ const out = [];
3705
+ let used = 0;
3706
+ for (const line of lines) {
3707
+ if (out.length >= maxLines) break;
3708
+ const cost = byteLength(line) + 1;
3709
+ if (used + cost > maxBytes) {
3710
+ if (out.length === 0 && maxBytes > 0) out.push(headOf(line, maxBytes));
3711
+ break;
3712
+ }
3713
+ out.push(line);
3714
+ used += cost;
3715
+ }
3716
+ return out;
3717
+ }
3718
+ /** Pick lines from the back, same limits; the result is in file order. */
3719
+ function takeTail(lines, maxLines, maxBytes) {
3720
+ const out = [];
3721
+ let used = 0;
3722
+ for (let index = lines.length - 1; index >= 0; index--) {
3723
+ const line = lines[index] ?? "";
3724
+ if (out.length >= maxLines) break;
3725
+ const cost = byteLength(line) + 1;
3726
+ if (used + cost > maxBytes) {
3727
+ if (out.length === 0 && maxBytes > 0) out.push(tailOf(line, maxBytes));
3728
+ break;
3729
+ }
3730
+ out.unshift(line);
3731
+ used += cost;
3732
+ }
3733
+ return out;
3734
+ }
3735
+ /** Choose the lines to keep for `keep`; `headCount` lines come from the front. */
3736
+ function select(lines, keep, maxLines, maxBytes) {
3737
+ if (keep === "head") return {
3738
+ head: takeHead(lines, maxLines, maxBytes),
3739
+ tail: []
3740
+ };
3741
+ if (keep === "tail") return {
3742
+ head: [],
3743
+ tail: takeTail(lines, maxLines, maxBytes)
3744
+ };
3745
+ const headLines = Math.ceil(maxLines / 2);
3746
+ const headBytes = Math.ceil(maxBytes / 2);
3747
+ const head = takeHead(lines, headLines, headBytes);
3748
+ return {
3749
+ head,
3750
+ tail: takeTail(lines.slice(head.length), maxLines - head.length, maxBytes - headBytes)
3751
+ };
3752
+ }
3753
+ /**
3754
+ * Keeps oversized command, search and read output from flooding the model:
3755
+ * with `output.spillDir` set it saves the full output to a file and returns
3756
+ * the part the policy keeps plus a pointer to it. One instance per ops layer;
3757
+ * it remembers the files it wrote so `dispose()` can remove them.
3758
+ */
3759
+ var OutputSpiller = class {
3760
+ constructor(backend, policy) {
3761
+ this.backend = backend;
3762
+ this.policy = policy;
3763
+ this.created = [];
3764
+ this.dir = resolveSpillDir(policy);
3765
+ if (this.dir !== void 0 && this.retentionMs > 0) this.sweep();
3766
+ }
3767
+ /** True when `output.spillDir` is set. */
3768
+ get enabled() {
3769
+ return this.dir !== void 0;
3770
+ }
3771
+ /** The spill directory, when spilling is on. */
3772
+ get spillDir() {
3773
+ return this.dir;
3774
+ }
3775
+ get retentionMs() {
3776
+ return this.policy.output?.retentionMs ?? DEFAULT_RETENTION_MS;
3777
+ }
3778
+ limits(cap) {
3779
+ const configured = this.policy.output?.maxBytes ?? DEFAULT_MAX_BYTES;
3780
+ return {
3781
+ maxLines: Math.max(1, Math.floor(this.policy.output?.maxLines ?? DEFAULT_MAX_LINES)),
3782
+ maxBytes: Math.max(1, Math.floor(cap === void 0 ? configured : Math.min(configured, cap)))
3783
+ };
3784
+ }
3785
+ /** Remove spill files older than the retention (only files named like ours). */
3786
+ async sweep() {
3787
+ if (this.dir === void 0) return;
3788
+ try {
3789
+ const now = Date.now();
3790
+ for (const file of await this.backend.list(this.dir)) {
3791
+ const match = SPILL_NAME.exec(node_path.default.basename(file));
3792
+ if (match !== null && now - Number(match[1]) > this.retentionMs) await this.backend.remove(file).catch(() => void 0);
3793
+ }
3794
+ } catch {}
3795
+ }
3796
+ /** Write `content` to a new spill file and return its absolute path. */
3797
+ async write(label, content) {
3798
+ if (this.dir === void 0) return;
3799
+ try {
3800
+ if (this.retentionMs > 0) await this.sweep();
3801
+ const safe = label.toLowerCase().replace(/[^a-z0-9_-]+/g, "-") || "output";
3802
+ const rand = Math.random().toString(36).slice(2, 8) || "x";
3803
+ const file = node_path.default.join(this.dir, `spill-${Date.now()}-${rand}-${safe}.txt`);
3804
+ await this.backend.mkdir(this.dir);
3805
+ await this.backend.writeFile(file, content);
3806
+ this.created.push(file);
3807
+ return file;
3808
+ } catch {
3809
+ return;
3810
+ }
3811
+ }
3812
+ /**
3813
+ * Fit `value` to the policy. Returns it untouched when it fits. Over the
3814
+ * limits with spilling on, the whole text is saved and the kept lines come
3815
+ * back with a `[showing lines … of …; full output saved to …]` marker.
3816
+ * `cap` is a tighter byte limit from elsewhere (`shell.maxOutputBytes`).
3817
+ */
3818
+ async text(label, value, defaultKeep, cap) {
3819
+ if (this.dir === void 0) return { value };
3820
+ const { maxLines, maxBytes } = this.limits(cap);
3821
+ const lines = splitLines(value);
3822
+ const totalBytes = byteLength(value);
3823
+ if (lines.length <= maxLines && totalBytes <= maxBytes) return { value };
3824
+ const { head, tail } = select(lines, this.policy.output?.keep ?? defaultKeep, maxLines, maxBytes);
3825
+ const file = await this.write(label, value);
3826
+ const shown = head.length + tail.length;
3827
+ const total = lines.length;
3828
+ const where = file === void 0 ? "the rest was dropped" : `full output saved to ${file}`;
3829
+ const marker = `[showing ${tail.length === 0 ? `lines 1-${head.length}` : head.length === 0 ? `lines ${total - tail.length + 1}-${total}` : `lines 1-${head.length} and ${total - tail.length + 1}-${total}`} of ${total}; ${where}]`;
3830
+ const parts = [...head.length > 0 ? [head.join("\n")] : [], marker];
3831
+ if (tail.length > 0) {
3832
+ parts.push(tail.join("\n"));
3833
+ if (file !== void 0) parts.push(`[full output saved to ${file}]`);
3834
+ }
3835
+ return {
3836
+ value: parts.join("\n"),
3837
+ truncation: {
3838
+ shown: { lines: shown },
3839
+ total: {
3840
+ lines: total,
3841
+ bytes: totalBytes
3842
+ },
3843
+ ...file !== void 0 ? { path: file } : {}
3844
+ }
3845
+ };
3846
+ }
3847
+ /**
3848
+ * Fit a list of entries (one line each when rendered) to the policy; the
3849
+ * rendered full list is what gets saved.
3850
+ */
3851
+ async list(label, items, render, defaultKeep) {
3852
+ if (this.dir === void 0) return { items };
3853
+ const { maxLines, maxBytes } = this.limits(void 0);
3854
+ const lines = items.map(render);
3855
+ const totalBytes = lines.reduce((sum, line) => sum + byteLength(line) + 1, 0);
3856
+ if (items.length <= maxLines && totalBytes <= maxBytes) return { items };
3857
+ const { head, tail } = select(lines, this.policy.output?.keep ?? defaultKeep, maxLines, maxBytes);
3858
+ const headCount = head.length;
3859
+ const tailCount = tail.length;
3860
+ const file = await this.write(label, lines.join("\n") + "\n");
3861
+ return {
3862
+ items: [...items.slice(0, headCount), ...items.slice(items.length - tailCount)],
3863
+ truncation: {
3864
+ shown: { lines: headCount + tailCount },
3865
+ total: {
3866
+ lines: items.length,
3867
+ bytes: totalBytes
3868
+ },
3869
+ ...file !== void 0 ? { path: file } : {}
3870
+ }
3871
+ };
3872
+ }
3873
+ /** Save the complete text of an output that was already cut elsewhere. */
3874
+ async save(label, content) {
3875
+ return this.write(label, content);
3876
+ }
3877
+ /** Remove expired files, and this run's own when `retentionMs` is `0`. */
3878
+ async dispose() {
3879
+ if (this.dir === void 0) return;
3880
+ if (this.retentionMs === 0) {
3881
+ for (const file of this.created.splice(0)) await this.backend.remove(file).catch(() => void 0);
3882
+ return;
3883
+ }
3884
+ await this.sweep();
3885
+ }
3886
+ };
3887
+
3888
+ //#endregion
3889
+ //#region ../../packages/ai-workspace/src/policy/approval-helpers.ts
3890
+ /** The longest refusal reason passed on to the model, in characters. */
3891
+ const MAX_REFUSAL_REASON = 500;
3892
+ /** Whether two command patterns are the same (strings by value, RegExps by source and flags). */
3893
+ function samePattern(a, b) {
3894
+ if (typeof a === "string" || typeof b === "string") return a === b;
3895
+ return a.source === b.source && a.flags === b.flags;
3896
+ }
3897
+ /**
3898
+ * The tail of a refusal message for an owner's reason: `"."` without one,
3899
+ * otherwise `" — <reason>"` cut at {@link MAX_REFUSAL_REASON} characters with
3900
+ * a visible marker.
3901
+ */
3902
+ function refusalReason(reason) {
3903
+ const text = typeof reason === "string" ? reason.trim() : "";
3904
+ if (text.length === 0) return ".";
3905
+ return ` — ${text.length > 500 ? `${text.slice(0, 500)}… [truncated]` : text}`;
3906
+ }
3907
+
2391
3908
  //#endregion
2392
3909
  //#region ../../packages/ai-workspace/src/shell-jobs.ts
2393
3910
  /** Finished commands kept for a last `shell_output` read before the oldest are pruned. */
@@ -2764,6 +4281,26 @@ var Ops = class {
2764
4281
  this.policy = policy;
2765
4282
  this.runtime = runtime;
2766
4283
  this.pathLocks = /* @__PURE__ */ new Map();
4284
+ this.resolvedShell = resolveShell(policy.shell);
4285
+ this.approvals = runtime.approvals ?? [];
4286
+ this.spiller = new OutputSpiller(backend, policy);
4287
+ }
4288
+ /** The edit-policy settings applied to every replacement. */
4289
+ editOptions() {
4290
+ const edit = this.policy.edit;
4291
+ return {
4292
+ matching: edit?.matching ?? "tiered",
4293
+ missHintLines: edit?.missHintLines ?? 12,
4294
+ lineEndings: edit?.lineEndings ?? "preserve",
4295
+ ...this.policy.read?.maxBytes !== void 0 ? { hintMaxBytes: this.policy.read.maxBytes } : {}
4296
+ };
4297
+ }
4298
+ /** Run `afterEdit` for a call that wrote `paths`; `undefined` when there is no output. */
4299
+ diagnosticsFor(tool, paths) {
4300
+ return runAfterEdit(this.policy, {
4301
+ paths,
4302
+ tool
4303
+ });
2767
4304
  }
2768
4305
  /** Run `task` after every earlier mutation of `absolutePath` has settled. */
2769
4306
  async withPathLock(absolutePath, task) {
@@ -2808,7 +4345,7 @@ var Ops = class {
2808
4345
  }
2809
4346
  }
2810
4347
  async readFile(inputPath, opts) {
2811
- const { absolutePath } = await resolveInJail(this.policy, inputPath);
4348
+ const { absolutePath } = await resolveInJail(this.policy, inputPath, "read");
2812
4349
  const raw = await this.backend.readFile(absolutePath);
2813
4350
  const hash = _warlock_js_fs.fs.hash.string(raw);
2814
4351
  const lines = raw.split("\n");
@@ -2817,10 +4354,30 @@ var Ops = class {
2817
4354
  const limit = opts?.limit ?? this.policy.read?.defaultLines ?? DEFAULT_READ_LINES;
2818
4355
  const maxBytes = this.policy.read?.maxBytes;
2819
4356
  const startIndex = offset - 1;
2820
- const { content, returnedLines, bytesCapped } = numberWindow(lines.slice(startIndex, startIndex + Math.max(0, limit)), offset, maxBytes === void 0 ? void 0 : Math.max(0, Math.floor(maxBytes)));
4357
+ const window = lines.slice(startIndex, startIndex + Math.max(0, limit));
4358
+ const { content, returnedLines, bytesCapped } = numberWindow(window, offset, maxBytes === void 0 ? void 0 : Math.max(0, Math.floor(maxBytes)));
2821
4359
  const endLine = offset + returnedLines - 1;
4360
+ const spillDir = this.spiller.spillDir;
4361
+ let spilled;
4362
+ if (bytesCapped && spillDir !== void 0 && !isInside(absolutePath, spillDir)) {
4363
+ const full = numberWindow(window, offset, void 0).content;
4364
+ const file = await this.spiller.save("read_file", full);
4365
+ if (file !== void 0) spilled = {
4366
+ content: `${content}\n[showing lines 1-${returnedLines} of ${window.length}; full output saved to ${file}]`,
4367
+ truncation: {
4368
+ shown: { lines: returnedLines },
4369
+ total: {
4370
+ lines: window.length,
4371
+ bytes: Buffer.byteLength(full, "utf8")
4372
+ },
4373
+ path: file
4374
+ }
4375
+ };
4376
+ }
2822
4377
  return {
2823
- content,
4378
+ content: spilled?.content ?? content,
4379
+ ...spilled !== void 0 ? { truncation: spilled.truncation } : {},
4380
+ ...spilled?.truncation.path !== void 0 ? { spilledTo: [spilled.truncation.path] } : {},
2824
4381
  startLine: offset,
2825
4382
  endLine,
2826
4383
  totalLines,
@@ -2831,8 +4388,8 @@ var Ops = class {
2831
4388
  };
2832
4389
  }
2833
4390
  async writeFile(inputPath, content) {
2834
- const { absolutePath } = await resolveInJail(this.policy, inputPath);
2835
- return this.withPathLock(absolutePath, async () => {
4391
+ const { absolutePath, relativePath } = await resolveInJail(this.policy, inputPath);
4392
+ const written = await this.withPathLock(absolutePath, async () => {
2836
4393
  const parent = node_path.default.dirname(absolutePath);
2837
4394
  await this.backend.mkdir(parent);
2838
4395
  await this.backend.writeFile(absolutePath, content);
@@ -2841,10 +4398,20 @@ var Ops = class {
2841
4398
  bytesWritten: Buffer.byteLength(content, "utf8")
2842
4399
  };
2843
4400
  });
4401
+ const diagnostics = await this.diagnosticsFor("write_file", [relativePath || inputPath]);
4402
+ return {
4403
+ ...written,
4404
+ ...diagnostics !== void 0 ? { diagnostics } : {}
4405
+ };
2844
4406
  }
2845
4407
  async editFile(input) {
2846
4408
  const { absolutePath, relativePath } = await resolveInJail(this.policy, input.path);
2847
- return this.withPathLock(absolutePath, () => this.applyEdit(input, absolutePath, relativePath));
4409
+ const result = await this.withPathLock(absolutePath, () => this.applyEdit(input, absolutePath, relativePath));
4410
+ const diagnostics = await this.diagnosticsFor("edit_file", [result.path]);
4411
+ return diagnostics === void 0 ? result : {
4412
+ ...result,
4413
+ diagnostics
4414
+ };
2848
4415
  }
2849
4416
  /** The read-check-replace-write of {@link editFile}, run under its path lock. */
2850
4417
  async applyEdit(input, absolutePath, relativePath) {
@@ -2856,19 +4423,20 @@ var Ops = class {
2856
4423
  expectedHash: input.expectHash,
2857
4424
  actualHash: currentHash
2858
4425
  });
2859
- const { content: updated, replacements } = applyReplacement(current, input, input.path, relativePath || input.path);
4426
+ const { content: updated, replacements, matchTier } = applyReplacement(current, input, input.path, relativePath || input.path, this.editOptions());
2860
4427
  await this.backend.writeFile(absolutePath, updated);
2861
4428
  return {
2862
4429
  path: relativePath || input.path,
2863
4430
  replacements,
2864
- hash: _warlock_js_fs.fs.hash.string(updated)
4431
+ hash: _warlock_js_fs.fs.hash.string(updated),
4432
+ ...matchTier !== void 0 ? { matchTier } : {}
2865
4433
  };
2866
4434
  }
2867
4435
  async multiEdit(input) {
2868
4436
  const { absolutePath, relativePath } = await resolveInJail(this.policy, input.path);
2869
4437
  const shownPath = relativePath || input.path;
2870
4438
  if (input.edits.length === 0 || input.edits.length > 100) throw new RangeError(`A multi-edit needs between 1 and ${100} edits (got ${input.edits.length}).`);
2871
- return this.withPathLock(absolutePath, async () => {
4439
+ const result = await this.withPathLock(absolutePath, async () => {
2872
4440
  const current = await this.backend.readFile(absolutePath);
2873
4441
  const currentHash = _warlock_js_fs.fs.hash.string(current);
2874
4442
  if (input.expectHash !== void 0 && input.expectHash !== currentHash) throw new WorkspaceEditError(`File "${input.path}" changed since it was read; none of the edits were applied.`, {
@@ -2879,21 +4447,40 @@ var Ops = class {
2879
4447
  });
2880
4448
  let content = current;
2881
4449
  let replacements = 0;
4450
+ let matchTier;
4451
+ const options = this.editOptions();
2882
4452
  input.edits.forEach((edit, editIndex) => {
2883
- const applied = applyReplacement(content, edit, input.path, shownPath, editIndex);
4453
+ const applied = applyReplacement(content, edit, input.path, shownPath, options, editIndex);
2884
4454
  content = applied.content;
2885
4455
  replacements += applied.replacements;
4456
+ if (applied.matchTier === "whitespace" || matchTier === void 0) matchTier = applied.matchTier ?? matchTier;
2886
4457
  });
2887
4458
  await this.backend.writeFile(absolutePath, content);
2888
4459
  return {
2889
4460
  path: shownPath,
2890
4461
  edits: input.edits.length,
2891
4462
  replacements,
2892
- hash: _warlock_js_fs.fs.hash.string(content)
4463
+ hash: _warlock_js_fs.fs.hash.string(content),
4464
+ ...matchTier !== void 0 ? { matchTier } : {}
2893
4465
  };
2894
4466
  });
4467
+ const diagnostics = await this.diagnosticsFor("multi_edit", [shownPath]);
4468
+ return diagnostics === void 0 ? result : {
4469
+ ...result,
4470
+ diagnostics
4471
+ };
2895
4472
  }
2896
4473
  async applyPatch(input) {
4474
+ const result = await this.runPatch(input);
4475
+ if (result.dryRun) return result;
4476
+ const paths = result.files.filter((file) => file.action !== "deleted").map((file) => file.path);
4477
+ const diagnostics = await this.diagnosticsFor("apply_patch", paths);
4478
+ return diagnostics === void 0 ? result : {
4479
+ ...result,
4480
+ diagnostics
4481
+ };
4482
+ }
4483
+ runPatch(input) {
2897
4484
  return executePatch(input, {
2898
4485
  resolve: (inputPath) => resolveInJail(this.policy, inputPath),
2899
4486
  exists: (absolutePath) => this.backend.exists(absolutePath),
@@ -2913,9 +4500,14 @@ var Ops = class {
2913
4500
  * (argv or shell mode), then — shell mode — every redirection target and
2914
4501
  * `cd` directory against the jail with symlinks resolved.
2915
4502
  */
2916
- async assertCommandAllowed(command) {
2917
- const verdict = checkShellCommand(this.policy, command);
2918
- if (!verdict.allowed) throw new WorkspacePolicyError(verdict.message ?? `Command is not permitted by the workspace shell policy: ${command}`, {
4503
+ async assertCommandAllowed(command, signal) {
4504
+ const verdict = checkShellCommand(this.policy, command, {
4505
+ remembered: this.approvals,
4506
+ resolved: this.resolvedShell
4507
+ });
4508
+ const onAsk = this.policy.shell?.onAsk;
4509
+ const needsApproval = !verdict.allowed && verdict.reason === "approval-required" && verdict.asks.length > 0;
4510
+ if (!verdict.allowed && !(needsApproval && onAsk !== void 0)) throw new WorkspacePolicyError(verdict.message ?? `Command is not permitted by the workspace shell policy: ${command}`, {
2919
4511
  type: "denied-command",
2920
4512
  command,
2921
4513
  ...verdict.reason !== void 0 ? { reason: verdict.reason } : {},
@@ -2938,9 +4530,35 @@ var Ops = class {
2938
4530
  reason: "not-allowed",
2939
4531
  command
2940
4532
  });
4533
+ if (needsApproval && onAsk !== void 0) await this.askForApproval(command, verdict.asks, onAsk, signal);
4534
+ }
4535
+ /** Put the commands that need approval to `shell.onAsk`; refuse the line unless it says yes. */
4536
+ async askForApproval(command, asks, onAsk, signal) {
4537
+ let decision;
4538
+ try {
4539
+ decision = await onAsk({
4540
+ command,
4541
+ asks,
4542
+ ...signal ? { signal } : {}
4543
+ });
4544
+ } catch {
4545
+ decision = void 0;
4546
+ }
4547
+ if (decision?.allow !== true) {
4548
+ const reason = refusalReason(decision?.reason);
4549
+ throw new WorkspacePolicyError(`Running ${asks.map((ask) => `\`${ask.argv.join(" ")}\``).join(", ")} was not approved${reason}`, {
4550
+ type: "denied-command",
4551
+ reason: "approval-denied",
4552
+ command
4553
+ });
4554
+ }
4555
+ if (decision.remember !== void 0) {
4556
+ const patterns = Array.isArray(decision.remember) ? decision.remember : [decision.remember];
4557
+ for (const pattern of patterns) if (!this.approvals.some((entry) => samePattern(entry, pattern))) this.approvals.push(pattern);
4558
+ }
2941
4559
  }
2942
4560
  async exec(command, opts) {
2943
- await this.assertCommandAllowed(command);
4561
+ await this.assertCommandAllowed(command, opts?.signal);
2944
4562
  const signal = opts?.signal;
2945
4563
  if (signal?.aborted) return {
2946
4564
  exitCode: CANCELLED_BEFORE_START_EXIT_CODE,
@@ -2953,24 +4571,28 @@ var Ops = class {
2953
4571
  const shell = this.policy.shell;
2954
4572
  const timeoutMs = opts?.timeoutMs ?? shell?.timeoutMs;
2955
4573
  const maxOutputBytes = shell?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
2956
- const interpreter = shell?.interpreter ?? "none";
4574
+ const interpreter = this.resolvedShell.interpreter;
2957
4575
  const result = await this.backend.exec(command, {
2958
4576
  cwd: this.policy.cwd,
2959
4577
  timeoutMs,
2960
4578
  env: buildCommandEnv(this.policy),
2961
4579
  ...signal ? { signal } : {},
2962
- ...interpreter !== "none" ? { interpreter } : {}
4580
+ ...interpreter !== "none" ? { interpreter } : {},
4581
+ ...interpreter !== "none" && this.resolvedShell.file !== void 0 ? { interpreterPath: this.resolvedShell.file } : {}
2963
4582
  });
2964
- const stdout = capOutput(result.stdout, maxOutputBytes);
2965
- const stderr = capOutput(result.stderr, maxOutputBytes);
4583
+ const stdout = await this.fitStream("run_shell-stdout", result.stdout, maxOutputBytes);
4584
+ const stderr = await this.fitStream("run_shell-stderr", result.stderr, maxOutputBytes);
2966
4585
  const cancelled = result.cancelled === true;
4586
+ const truncation = streamTruncation(stdout.truncation, stderr.truncation);
2967
4587
  return {
2968
4588
  exitCode: result.exitCode,
2969
4589
  stdout: stdout.value,
2970
4590
  stderr: stderr.value,
2971
4591
  truncated: stdout.truncated || stderr.truncated,
2972
4592
  timedOut: result.timedOut && !cancelled,
2973
- cancelled
4593
+ cancelled,
4594
+ ...truncation !== void 0 ? { truncation } : {},
4595
+ ...spilledField(truncation)
2974
4596
  };
2975
4597
  }
2976
4598
  async startShell(command, opts) {
@@ -2988,7 +4610,8 @@ var Ops = class {
2988
4610
  command
2989
4611
  });
2990
4612
  const shell = this.policy.shell;
2991
- const interpreter = shell?.interpreter ?? "none";
4613
+ const interpreter = this.resolvedShell.interpreter;
4614
+ const interpreterPath = interpreter !== "none" ? this.resolvedShell.file : void 0;
2992
4615
  const timeoutMs = lowerOf(background.timeoutMs, opts?.timeoutMs);
2993
4616
  const env = buildCommandEnv(this.policy);
2994
4617
  return this.runtime.jobs.start({
@@ -3000,12 +4623,52 @@ var Ops = class {
3000
4623
  cwd: this.policy.cwd,
3001
4624
  env,
3002
4625
  ...interpreter !== "none" ? { interpreter } : {},
4626
+ ...interpreterPath !== void 0 ? { interpreterPath } : {},
3003
4627
  onOutput
3004
4628
  })
3005
4629
  });
3006
4630
  }
3007
- shellOutput(id, opts) {
3008
- return this.runtime.jobs.output(id, opts);
4631
+ async shellOutput(id, opts) {
4632
+ const result = await this.runtime.jobs.output(id, opts);
4633
+ if (!this.spiller.enabled) return result;
4634
+ const stdout = await this.spiller.text(`shell_output-${id}-stdout`, result.stdout, "head+tail");
4635
+ const stderr = await this.spiller.text(`shell_output-${id}-stderr`, result.stderr, "head+tail");
4636
+ const truncation = streamTruncation(stdout.truncation, stderr.truncation);
4637
+ return truncation === void 0 ? result : {
4638
+ ...result,
4639
+ stdout: stdout.value,
4640
+ stderr: stderr.value,
4641
+ truncated: true,
4642
+ truncation,
4643
+ ...spilledField(truncation)
4644
+ };
4645
+ }
4646
+ /**
4647
+ * Fit one captured stream to the limits: spilled to a file when
4648
+ * `output.spillDir` is set, otherwise clipped at `maxBytes` as always.
4649
+ */
4650
+ async fitStream(label, value, maxBytes) {
4651
+ if (this.spiller.enabled) {
4652
+ const cap = this.policy.shell?.maxOutputBytes;
4653
+ const spilled = await this.spiller.text(label, value, "head+tail", cap);
4654
+ if (spilled.truncation !== void 0) return {
4655
+ value: spilled.value,
4656
+ truncated: true,
4657
+ truncation: spilled.truncation
4658
+ };
4659
+ }
4660
+ const capped = capOutput(value, maxBytes);
4661
+ if (!capped.truncated) return capped;
4662
+ return {
4663
+ ...capped,
4664
+ truncation: {
4665
+ shown: { lines: splitLines(capped.value).length },
4666
+ total: {
4667
+ lines: splitLines(value).length,
4668
+ bytes: Buffer.byteLength(value, "utf8")
4669
+ }
4670
+ }
4671
+ };
3009
4672
  }
3010
4673
  shellKill(id) {
3011
4674
  return this.runtime.jobs.kill(id);
@@ -3013,8 +4676,9 @@ var Ops = class {
3013
4676
  listShells() {
3014
4677
  return this.runtime.jobs.list();
3015
4678
  }
3016
- dispose() {
3017
- return this.runtime.jobs.dispose();
4679
+ async dispose() {
4680
+ await this.runtime.jobs.dispose();
4681
+ await this.spiller.dispose();
3018
4682
  }
3019
4683
  async listDir(inputPath = ".", opts) {
3020
4684
  const { absolutePath: root, relativePath: rootRelative } = await resolveInJail(this.policy, inputPath);
@@ -3118,16 +4782,34 @@ var Ops = class {
3118
4782
  line: index + 1,
3119
4783
  text: line
3120
4784
  });
3121
- if (matches.length >= DEFAULT_MAX_GREP_MATCHES) return {
3122
- matches,
3123
- total: matches.length
3124
- };
4785
+ if (matches.length >= DEFAULT_MAX_GREP_MATCHES) return this.fitMatches(matches);
3125
4786
  }
3126
4787
  }
3127
4788
  }
3128
- return {
4789
+ return this.fitMatches(matches);
4790
+ }
4791
+ /** Apply the `output` limits to a finished match list. */
4792
+ async fitMatches(matches) {
4793
+ const fitted = await this.spiller.list("grep", matches, (match) => `${match.path}:${match.line}:${match.text}`, "head");
4794
+ return fitted.truncation === void 0 ? {
3129
4795
  matches,
3130
4796
  total: matches.length
4797
+ } : {
4798
+ matches: fitted.items,
4799
+ total: matches.length,
4800
+ truncated: true,
4801
+ truncation: fitted.truncation,
4802
+ ...spilledField({ stdout: fitted.truncation })
4803
+ };
4804
+ }
4805
+ async globResult(pattern) {
4806
+ const all = await this.glob(pattern);
4807
+ const fitted = await this.spiller.list("glob", all, (entry) => entry, "head");
4808
+ return fitted.truncation === void 0 ? { paths: all } : {
4809
+ paths: fitted.items,
4810
+ truncated: true,
4811
+ truncation: fitted.truncation,
4812
+ ...spilledField({ stdout: fitted.truncation })
3131
4813
  };
3132
4814
  }
3133
4815
  async glob(pattern) {
@@ -3160,55 +4842,23 @@ var Ops = class {
3160
4842
  return denyPaths !== void 0 && denyPaths.length > 0 && matchesAnyGlob(relativePath, denyPaths);
3161
4843
  }
3162
4844
  };
3163
- /** The lower of two optional limits (either may be absent). */
3164
- function lowerOf(policyLimit, requested) {
3165
- if (requested === void 0 || requested <= 0) return policyLimit;
3166
- return policyLimit === void 0 ? requested : Math.min(policyLimit, requested);
4845
+ /** `{ spilledTo }` for every spill file named in the truncation metadata, or nothing. */
4846
+ function spilledField(truncation) {
4847
+ const paths = [truncation?.stdout?.path, truncation?.stderr?.path].filter((file) => file !== void 0);
4848
+ return paths.length > 0 ? { spilledTo: paths } : {};
3167
4849
  }
3168
- /**
3169
- * Apply one exact-string replacement to `content` under the edit rules:
3170
- * `oldString` must occur, and exactly once unless `replaceAll`.
3171
- */
3172
- function applyReplacement(content, edit, inputPath, shownPath, editIndex) {
3173
- const occurrences = countOccurrences(content, edit.oldString);
3174
- const which = editIndex === void 0 ? "" : `Edit ${editIndex + 1}: `;
3175
- const nothingApplied = editIndex === void 0 ? "" : " None of the edits were applied.";
3176
- const indexOption = editIndex === void 0 ? {} : { editIndex };
3177
- if (occurrences === 0) throw new WorkspaceEditError(`${which}The text to replace was not found in "${inputPath}".${nothingApplied}`, {
3178
- type: "not-found",
3179
- path: shownPath,
3180
- matches: 0,
3181
- ...indexOption
3182
- });
3183
- if (occurrences > 1 && !edit.replaceAll) throw new WorkspaceEditError(`${which}The text to replace is not unique in "${inputPath}" (${occurrences} matches); pass replaceAll or include more surrounding context.${nothingApplied}`, {
3184
- type: "not-unique",
3185
- path: shownPath,
3186
- matches: occurrences,
3187
- ...indexOption
3188
- });
4850
+ /** Combine per-stream truncation metadata; `undefined` when neither stream was clipped. */
4851
+ function streamTruncation(stdout, stderr) {
4852
+ if (stdout === void 0 && stderr === void 0) return;
3189
4853
  return {
3190
- content: edit.replaceAll ? content.split(edit.oldString).join(edit.newString) : replaceFirst(content, edit.oldString, edit.newString),
3191
- replacements: edit.replaceAll ? occurrences : 1
4854
+ ...stdout !== void 0 ? { stdout } : {},
4855
+ ...stderr !== void 0 ? { stderr } : {}
3192
4856
  };
3193
4857
  }
3194
- /** Count non-overlapping occurrences of `needle` in `haystack`. */
3195
- function countOccurrences(haystack, needle) {
3196
- if (needle === "") return 0;
3197
- let count = 0;
3198
- let from = 0;
3199
- while (true) {
3200
- const index = haystack.indexOf(needle, from);
3201
- if (index === -1) break;
3202
- count++;
3203
- from = index + needle.length;
3204
- }
3205
- return count;
3206
- }
3207
- /** Replace the first occurrence of `needle` with `replacement`. */
3208
- function replaceFirst(haystack, needle, replacement) {
3209
- const index = haystack.indexOf(needle);
3210
- if (index === -1) return haystack;
3211
- return haystack.slice(0, index) + replacement + haystack.slice(index + needle.length);
4858
+ /** The lower of two optional limits (either may be absent). */
4859
+ function lowerOf(policyLimit, requested) {
4860
+ if (requested === void 0 || requested <= 0) return policyLimit;
4861
+ return policyLimit === void 0 ? requested : Math.min(policyLimit, requested);
3212
4862
  }
3213
4863
  /**
3214
4864
  * Create the policy-enforced operation layer over a backend.
@@ -3337,25 +4987,39 @@ const POSIX_SHELL = "/bin/sh";
3337
4987
  * `/s` keeps the line verbatim inside the outer quotes). The ops layer has
3338
4988
  * already checked every command in the line.
3339
4989
  */
3340
- function planSpawn(command, interpreter) {
3341
- if (interpreter === "sh") {
3342
- if (node_process.platform === "win32") return {
3343
- file: process.env.ComSpec ?? "cmd.exe",
3344
- args: [
3345
- "/d",
3346
- "/v:off",
3347
- "/s",
3348
- "/c",
3349
- `"${command}"`
3350
- ],
3351
- windowsVerbatimArguments: true
3352
- };
3353
- return {
3354
- file: POSIX_SHELL,
3355
- args: ["-c", command],
3356
- windowsVerbatimArguments: false
3357
- };
3358
- }
4990
+ function planSpawn(command, interpreter, interpreterPath, hostPlatform = node_process.platform) {
4991
+ if (interpreter === "powershell") return {
4992
+ file: interpreterPath ?? (hostPlatform === "win32" ? "powershell.exe" : "pwsh"),
4993
+ args: [
4994
+ "-NoLogo",
4995
+ "-NoProfile",
4996
+ "-NonInteractive",
4997
+ "-Command",
4998
+ command
4999
+ ],
5000
+ windowsVerbatimArguments: false
5001
+ };
5002
+ if (interpreter === "bash") return {
5003
+ file: interpreterPath ?? "bash",
5004
+ args: ["-c", command],
5005
+ windowsVerbatimArguments: false
5006
+ };
5007
+ if (interpreter === "cmd" || interpreter === "sh" && hostPlatform === "win32") return {
5008
+ file: interpreterPath ?? process.env.ComSpec ?? "cmd.exe",
5009
+ args: [
5010
+ "/d",
5011
+ "/v:off",
5012
+ "/s",
5013
+ "/c",
5014
+ `"${command}"`
5015
+ ],
5016
+ windowsVerbatimArguments: true
5017
+ };
5018
+ if (interpreter === "sh") return {
5019
+ file: interpreterPath ?? POSIX_SHELL,
5020
+ args: ["-c", command],
5021
+ windowsVerbatimArguments: false
5022
+ };
3359
5023
  const argv = tokenizeCommand(command);
3360
5024
  const executable = argv?.[0];
3361
5025
  if (argv === null || executable === void 0) return { refused: "Command was not executed: it is empty, has unbalanced quotes, or contains unquoted shell metacharacters (;, &, |, `, $, <, >, parentheses). Commands run without a shell — pass metacharacters inside quotes as literal arguments, or run one command at a time." };
@@ -3511,7 +5175,7 @@ var LocalBackend = class {
3511
5175
  });
3512
5176
  return;
3513
5177
  }
3514
- const plan = planSpawn(command, opts.interpreter ?? "none");
5178
+ const plan = planSpawn(command, opts.interpreter ?? "none", opts.interpreterPath);
3515
5179
  if ("refused" in plan) {
3516
5180
  refuse(plan.refused);
3517
5181
  return;
@@ -3585,7 +5249,7 @@ var LocalBackend = class {
3585
5249
  * still-running tree is also killed when the Node process exits.
3586
5250
  */
3587
5251
  start(command, opts) {
3588
- const plan = planSpawn(command, opts.interpreter ?? "none");
5252
+ const plan = planSpawn(command, opts.interpreter ?? "none", opts.interpreterPath);
3589
5253
  if ("refused" in plan) {
3590
5254
  opts.onOutput("stderr", plan.refused);
3591
5255
  return {
@@ -4310,6 +5974,7 @@ function makeGlobTool(ops, options) {
4310
5974
  action: (input) => `Finding files matching ${input.pattern}`,
4311
5975
  input: globInputSchema,
4312
5976
  async execute(input) {
5977
+ if (ops.globResult !== void 0) return ops.globResult(input.pattern);
4313
5978
  return { paths: await ops.glob(input.pattern) };
4314
5979
  }
4315
5980
  });
@@ -4605,6 +6270,24 @@ function runShellInputSchema(background) {
4605
6270
  }
4606
6271
  } }, runShellJsonSchema(background));
4607
6272
  }
6273
+ const DIALECT_RULES = {
6274
+ posix: "Use POSIX shell syntax: `;`, `&&`, `||`, pipes, quotes, `$( )` and redirections are supported, and every command in the line is checked. Compound statements (`if`, `for`), brace expansion, arithmetic and `&` are not.",
6275
+ cmd: "Use cmd.exe syntax: `&&`, `||`, `&`, `|` and simple redirections are supported, and every command in the line is checked. `%` and `!` expansion, `^`, parentheses and `if` / `for` are not.",
6276
+ powershell: "Use PowerShell syntax: `;`, `&&`, `||`, pipes, `$( )`, the call operator `&` and the redirections `>`, `>>`, `2>` and `2>&1` are supported, and every command in the line is checked (aliases such as `rm` and `Remove-Item` are treated alike). Script blocks, `Invoke-Expression`, here-strings, splatting and `if` / `foreach` are not.",
6277
+ powershell5: "Use Windows PowerShell 5.1 syntax: `;`, pipes, `$( )`, the call operator `&` and the redirections `>`, `>>`, `2>` and `2>&1` are supported, and every command in the line is checked (aliases such as `rm` and `Remove-Item` are treated alike). This version cannot chain with `&&` or `||`: put one command per call, or join with `;` (which runs the next command whatever the exit code). Script blocks, `Invoke-Expression`, here-strings, splatting and `if` / `foreach` are not supported."
6278
+ };
6279
+ const PLATFORM_NAMES = {
6280
+ win32: "Windows",
6281
+ darwin: "macOS",
6282
+ linux: "Linux"
6283
+ };
6284
+ /** The sentence that names the shell and its syntax, or `""` in argv mode. */
6285
+ function shellSentence(options) {
6286
+ const dialect = options?.shellDialect;
6287
+ if (options?.shellLabel === void 0 || dialect === void 0 || dialect === "none") return "";
6288
+ const platform = options.platform ?? process.platform;
6289
+ return ` Commands run on ${PLATFORM_NAMES[platform] ?? platform} using ${options.shellLabel}. ` + DIALECT_RULES[dialect];
6290
+ }
4608
6291
  /** The lower of the model's timeout and the cap (either may be absent). */
4609
6292
  function cappedTimeout(requested, cap) {
4610
6293
  if (requested === void 0) return cap;
@@ -4614,7 +6297,7 @@ function makeRunShellTool(ops, options) {
4614
6297
  const background = options?.background === true;
4615
6298
  return (0, _warlock_js_ai.tool)({
4616
6299
  name: options?.name ?? DEFAULT_RUN_SHELL_TOOL_NAME,
4617
- description: "Run a shell command inside the workspace. Every command in it must be permitted by the shell policy; a refusal explains how to fix the command. Output is byte-capped and the run is time-limited. A non-zero exit code is returned as data, not an error." + (background ? " Pass background: true for a long-running command (dev server, watcher): it returns an id at once; read its output with shell_output and stop it with shell_kill." : ""),
6300
+ description: "Run a shell command inside the workspace. Every command in it must be permitted by the shell policy; a refusal explains how to fix the command. Output is byte-capped and the run is time-limited. A non-zero exit code is returned as data, not an error." + shellSentence(options) + (background ? " Pass background: true for a long-running command (dev server, watcher): it returns an id at once; read its output with shell_output and stop it with shell_kill." : ""),
4618
6301
  action: (input) => `Running \`${input.command}\``,
4619
6302
  input: runShellInputSchema(background),
4620
6303
  execute: async (input, ctx) => {
@@ -4674,8 +6357,9 @@ const runTestsInputSchema = withJsonSchema({ "~standard": {
4674
6357
  }
4675
6358
  } }, RUN_TESTS_JSON_SCHEMA);
4676
6359
  /** Quote the test pattern as one literal argument for the line's interpreter. */
4677
- function quotePattern(pattern, interpreter) {
4678
- if (interpreter === "sh" && process.platform !== "win32") return `'${pattern.replace(/'/g, "'\\''")}'`;
6360
+ function quotePattern(pattern, interpreter, dialect, platform = process.platform) {
6361
+ if (dialect === "powershell" || dialect === "powershell5" || interpreter === "powershell") return `'${pattern.replace(/['\u2018\u2019\u201A\u201B]/g, "$&$&")}'`;
6362
+ if (interpreter === "bash" || interpreter === "sh" && platform !== "win32") return `'${pattern.replace(/'/g, "'\\''")}'`;
4679
6363
  return `"${pattern}"`;
4680
6364
  }
4681
6365
  /**
@@ -4710,7 +6394,7 @@ function makeRunTestsTool(ops, options) {
4710
6394
  action: (input) => input.pattern ? `Running tests matching "${input.pattern}"` : "Running tests",
4711
6395
  input: runTestsInputSchema,
4712
6396
  execute: (input, ctx) => {
4713
- const command = input.pattern ? `${baseCommand} ${quotePattern(input.pattern, options?.interpreter ?? "none")}` : baseCommand;
6397
+ const command = input.pattern ? `${baseCommand} ${quotePattern(input.pattern, options?.interpreter ?? "none", options?.dialect, options?.platform)}` : baseCommand;
4714
6398
  return ops.exec(command, ctx?.signal ? { signal: ctx.signal } : void 0);
4715
6399
  }
4716
6400
  });
@@ -5016,11 +6700,12 @@ function makeWriteFileTool(ops, options) {
5016
6700
  description: "Write full content to a workspace file, creating it (and any parent directories) if absent and overwriting it otherwise. The write is atomic. Returns the bytes written and the new content hash.",
5017
6701
  input: inputSchema,
5018
6702
  async execute(input) {
5019
- const { hash, bytesWritten } = await ops.writeFile(input.path, input.content);
6703
+ const { hash, bytesWritten, diagnostics } = await ops.writeFile(input.path, input.content);
5020
6704
  return {
5021
6705
  path: input.path,
5022
6706
  bytesWritten,
5023
- hash
6707
+ hash,
6708
+ ...diagnostics !== void 0 ? { diagnostics } : {}
5024
6709
  };
5025
6710
  }
5026
6711
  });
@@ -5094,10 +6779,26 @@ var WorkspaceImpl = class WorkspaceImpl {
5094
6779
  this.allowedTools = new Set(allowedTools);
5095
6780
  this.state = state ?? {
5096
6781
  jobs: new ShellJobs(),
5097
- todos: new TodoStore()
6782
+ todos: new TodoStore(),
6783
+ approvals: []
6784
+ };
6785
+ this.resolvedShell = resolveShell(policy.shell);
6786
+ const approvals = this.state.approvals;
6787
+ this.shell = {
6788
+ classify: (line) => classifyShellCommand(line, policy),
6789
+ remembered: () => [...approvals],
6790
+ forget: (pattern) => {
6791
+ const index = approvals.findIndex((entry) => samePattern(entry, pattern));
6792
+ if (index === -1) return false;
6793
+ approvals.splice(index, 1);
6794
+ return true;
6795
+ }
5098
6796
  };
5099
6797
  const backend = selectBackend(policy);
5100
- this.ops = createOps(backend, policy, { jobs: this.state.jobs });
6798
+ this.ops = createOps(backend, policy, {
6799
+ jobs: this.state.jobs,
6800
+ approvals: this.state.approvals
6801
+ });
5101
6802
  this.tools = this.buildTools();
5102
6803
  }
5103
6804
  /**
@@ -5111,7 +6812,7 @@ var WorkspaceImpl = class WorkspaceImpl {
5111
6812
  const erase = (contract) => contract;
5112
6813
  const shell = this.policy.shell;
5113
6814
  const background = shell?.background !== void 0;
5114
- const interpreter = shell?.interpreter ?? "none";
6815
+ const resolvedShell = this.resolvedShell;
5115
6816
  const factories = {
5116
6817
  readFile: (opts) => erase(makeReadFileTool(this.ops, opts)),
5117
6818
  editFile: (opts) => erase(makeEditFileTool(this.ops, opts)),
@@ -5122,13 +6823,16 @@ var WorkspaceImpl = class WorkspaceImpl {
5122
6823
  runShell: (opts) => erase(makeRunShellTool(this.ops, {
5123
6824
  ...opts?.name !== void 0 ? { name: opts.name } : {},
5124
6825
  ...shell?.timeoutMs !== void 0 ? { maxTimeoutMs: shell.timeoutMs } : {},
5125
- background
6826
+ background,
6827
+ ...resolvedShell.interpreter !== "none" ? { shellLabel: resolvedShell.label } : {},
6828
+ shellDialect: resolvedShell.interpreter === "none" ? "none" : resolvedShell.dialect
5126
6829
  })),
5127
6830
  shellOutput: (opts) => erase(makeShellOutputTool(this.ops, opts)),
5128
6831
  shellKill: (opts) => erase(makeShellKillTool(this.ops, opts)),
5129
6832
  runTests: (opts) => erase(makeRunTestsTool(this.ops, {
5130
6833
  ...opts,
5131
- interpreter
6834
+ interpreter: resolvedShell.interpreter,
6835
+ dialect: resolvedShell.dialect
5132
6836
  })),
5133
6837
  grep: (opts) => erase(makeGrepTool(this.ops, opts)),
5134
6838
  glob: (opts) => erase(makeGlobTool(this.ops, opts)),
@@ -5311,6 +7015,7 @@ exports.applyHunks = applyHunks;
5311
7015
  exports.applyPatchWithOps = applyPatchWithOps;
5312
7016
  exports.buildEnv = buildEnv;
5313
7017
  exports.checkShellCommand = checkShellCommand;
7018
+ exports.classifyShellCommand = classifyShellCommand;
5314
7019
  exports.createLocalBackend = createLocalBackend;
5315
7020
  exports.createMockBackend = createMockBackend;
5316
7021
  exports.createOps = createOps;
@@ -5331,6 +7036,7 @@ exports.makeTodoWriteTool = makeTodoWriteTool;
5331
7036
  exports.makeWriteFileTool = makeWriteFileTool;
5332
7037
  exports.parsePatch = parsePatch;
5333
7038
  exports.resolveInJail = resolveInJail;
7039
+ exports.resolveShell = resolveShell;
5334
7040
  exports.tokenizeCommand = tokenizeCommand;
5335
7041
  exports.workspace = workspace;
5336
7042
  //# sourceMappingURL=index.cjs.map