@warlock.js/ai-workspace 5.43.0 → 5.44.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 (84) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +14 -1
  3. package/cjs/index.cjs +1781 -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 +78 -0
  10. package/esm/contracts/shell-approval.type.d.mts.map +1 -0
  11. package/esm/contracts/tool-io.type.d.mts +35 -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 +11 -0
  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 +196 -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 +220 -0
  46. package/esm/output/spill.mjs.map +1 -0
  47. package/esm/policy/policy.d.mts +3 -1
  48. package/esm/policy/policy.d.mts.map +1 -1
  49. package/esm/policy/policy.mjs +12 -4
  50. package/esm/policy/policy.mjs.map +1 -1
  51. package/esm/policy/powershell-aliases.mjs +275 -0
  52. package/esm/policy/powershell-aliases.mjs.map +1 -0
  53. package/esm/policy/powershell-parser.mjs +418 -0
  54. package/esm/policy/powershell-parser.mjs.map +1 -0
  55. package/esm/policy/shell-check.d.mts +32 -2
  56. package/esm/policy/shell-check.d.mts.map +1 -1
  57. package/esm/policy/shell-check.mjs +183 -31
  58. package/esm/policy/shell-check.mjs.map +1 -1
  59. package/esm/policy/shell-interpreter.d.mts +45 -0
  60. package/esm/policy/shell-interpreter.d.mts.map +1 -0
  61. package/esm/policy/shell-interpreter.mjs +109 -0
  62. package/esm/policy/shell-interpreter.mjs.map +1 -0
  63. package/esm/policy/shell-parser.d.mts +31 -0
  64. package/esm/policy/shell-parser.d.mts.map +1 -0
  65. package/esm/policy/shell-parser.mjs +3 -0
  66. package/esm/policy/shell-parser.mjs.map +1 -1
  67. package/esm/tools/glob.mjs +1 -0
  68. package/esm/tools/glob.mjs.map +1 -1
  69. package/esm/tools/run-shell.d.mts +10 -0
  70. package/esm/tools/run-shell.d.mts.map +1 -1
  71. package/esm/tools/run-shell.mjs +18 -1
  72. package/esm/tools/run-shell.mjs.map +1 -1
  73. package/esm/tools/run-tests.d.mts +7 -0
  74. package/esm/tools/run-tests.d.mts.map +1 -1
  75. package/esm/tools/run-tests.mjs +5 -4
  76. package/esm/tools/run-tests.mjs.map +1 -1
  77. package/esm/tools/write-file.mjs +3 -2
  78. package/esm/tools/write-file.mjs.map +1 -1
  79. package/esm/workspace.d.mts.map +1 -1
  80. package/esm/workspace.mjs +16 -5
  81. package/esm/workspace.mjs.map +1 -1
  82. package/llms-full.txt +64 -17
  83. package/package.json +3 -3
  84. package/skills/use-a-workspace/SKILL.md +64 -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,810 @@ 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) {
488
+ if (interpreter === "powershell") return "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
+ function powershellLabel(file) {
516
+ return file !== void 0 && /pwsh(\.exe)?$/i.test(file) ? "PowerShell 7 (pwsh)" : "PowerShell";
517
+ }
518
+ /**
519
+ * Resolve a shell policy's `interpreter` (and `interpreterPath`) to the
520
+ * concrete shell that will run lines, and the grammar they are checked with.
521
+ *
522
+ * `"auto"` picks `pwsh`, then Windows PowerShell, then Git Bash (found next to
523
+ * `git`), then `cmd.exe` on Windows and `bash`, then `sh` elsewhere. An explicit interpreter whose executable
524
+ * is not on `PATH` keeps its name and has no `file`, so the spawn reports the
525
+ * missing shell instead of quietly running another one.
526
+ *
527
+ * @example
528
+ * resolveShell({ interpreter: "auto" }, { platform: "win32", pathEnv: "C:\\bin", isExecutable })
529
+ */
530
+ function resolveShell(shell, host = currentShellHost()) {
531
+ const requested = shell?.interpreter ?? "none";
532
+ const explicit = shell?.interpreterPath;
533
+ if (requested === "none") return {
534
+ interpreter: "none",
535
+ dialect: "posix",
536
+ label: "none"
537
+ };
538
+ let interpreter;
539
+ let file = explicit;
540
+ if (requested === "auto") {
541
+ 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";
542
+ else if (host.platform === "win32") {
543
+ const powershell = findOnPath(powershellNames(host), host);
544
+ const gitBash = powershell === void 0 ? findGitBash(host) : void 0;
545
+ interpreter = powershell !== void 0 ? "powershell" : gitBash !== void 0 ? "bash" : "cmd";
546
+ file = powershell ?? gitBash;
547
+ } else {
548
+ const bash = findOnPath(["bash"], host);
549
+ interpreter = bash !== void 0 ? "bash" : "sh";
550
+ file = bash;
551
+ }
552
+ } else interpreter = requested;
553
+ if (file === void 0) {
554
+ if (interpreter === "powershell") file = findOnPath(powershellNames(host), host);
555
+ else if (interpreter === "bash") file = findOnPath(["bash"], host) ?? (host.platform === "win32" ? findGitBash(host) : void 0);
556
+ else if (interpreter === "cmd" && host.platform === "win32") file = process.env.ComSpec ?? "cmd.exe";
557
+ }
558
+ const labels = {
559
+ none: "none",
560
+ sh: host.platform === "win32" ? "cmd.exe" : "sh",
561
+ bash: "bash",
562
+ cmd: "cmd.exe",
563
+ powershell: powershellLabel(file)
564
+ };
565
+ return {
566
+ interpreter,
567
+ dialect: dialectOf(interpreter, host.platform),
568
+ ...file !== void 0 ? { file } : {},
569
+ label: labels[interpreter]
570
+ };
571
+ }
572
+
573
+ //#endregion
574
+ //#region ../../packages/ai-workspace/src/policy/powershell-parser.ts
575
+ /**
576
+ * Parser for the PowerShell dialect (`powershell -Command` / `pwsh -Command`).
577
+ *
578
+ * Like the other dialects it is deliberately conservative: it finds every
579
+ * simple command a line would run and every redirection target, and refuses
580
+ * what it cannot read with confidence. Supported: `;` and line breaks, `&&`,
581
+ * `||`, `|`, `$( )` subexpressions (their commands are checked too), the call
582
+ * operator `&` in front of a command, single and double
583
+ * quotes (including the typographic quotes PowerShell treats as quotes),
584
+ * backtick escapes, `$name` / `${name}` / `$env:NAME` variables (dynamic
585
+ * words), and the redirections `>`, `>>`, `2>`, `2>>`, `*>`, `2>&1`.
586
+ *
587
+ * Refused: script blocks and braces, bare parentheses,
588
+ * `@( )` / `@{ }` / splatting / here-strings, the stop-parsing token `--%`,
589
+ * `<` redirection, comments, the comma operator, compound-statement
590
+ * keywords (`if`, `foreach`, `function`, `try`, …), dot-sourcing, a `&` that
591
+ * is not a call operator (background jobs), and type literals.
592
+ */
593
+ /** Statement keywords that start a compound command; refused in command position. */
594
+ const POWERSHELL_KEYWORDS = /* @__PURE__ */ new Set([
595
+ "if",
596
+ "elseif",
597
+ "else",
598
+ "switch",
599
+ "foreach",
600
+ "for",
601
+ "while",
602
+ "do",
603
+ "until",
604
+ "function",
605
+ "filter",
606
+ "workflow",
607
+ "param",
608
+ "begin",
609
+ "process",
610
+ "end",
611
+ "try",
612
+ "catch",
613
+ "finally",
614
+ "trap",
615
+ "data",
616
+ "class",
617
+ "enum",
618
+ "using",
619
+ "return",
620
+ "break",
621
+ "continue",
622
+ "throw",
623
+ "parallel",
624
+ "sequence",
625
+ "inlinescript"
626
+ ]);
627
+ /** Characters PowerShell reads as a single quote. */
628
+ const SINGLE_QUOTES = "'‘’‚‛";
629
+ /** Characters PowerShell reads as a double quote. */
630
+ const DOUBLE_QUOTES = "\"“”„";
631
+ /** Variable-name characters after `$`. */
632
+ const VARIABLE_CHAR = /[A-Za-z0-9_:?^$]/;
633
+ /** Whether `char` (never the empty end-of-input string) is one of `set`. */
634
+ function inSet(set, char) {
635
+ return char !== "" && set.includes(char);
636
+ }
637
+ var PowerShellRefusal = class extends Error {
638
+ constructor(reason, message) {
639
+ super(message);
640
+ this.reason = reason;
641
+ }
642
+ };
643
+ function unsupported$1(message) {
644
+ return new PowerShellRefusal("unsupported-syntax", message);
645
+ }
646
+ var PowerShellParser = class PowerShellParser {
647
+ constructor(src, commands = [], depth = 0) {
648
+ this.src = src;
649
+ this.commands = commands;
650
+ this.depth = depth;
651
+ this.pos = 0;
652
+ }
653
+ atEnd() {
654
+ return this.pos >= this.src.length;
655
+ }
656
+ peek(offset = 0) {
657
+ return this.src.charAt(this.pos + offset);
658
+ }
659
+ skipBlanks() {
660
+ while (this.peek() === " " || this.peek() === " ") this.pos++;
661
+ }
662
+ atLineBreak() {
663
+ return this.peek() === "\n" || this.peek() === "\r";
664
+ }
665
+ parseProgram(segments) {
666
+ while (true) {
667
+ this.skipBlanks();
668
+ if (this.atEnd()) {
669
+ if (segments.length === 0) throw unsupported$1("The command is empty.");
670
+ 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.");
671
+ return;
672
+ }
673
+ const start = this.pos;
674
+ this.parsePipeline();
675
+ const end = this.pos;
676
+ this.skipBlanks();
677
+ const operator = this.readListOperator();
678
+ segments.push({
679
+ source: this.src.slice(start, end).trim(),
680
+ ...operator !== null ? { operator } : {}
681
+ });
682
+ if (operator === null) {
683
+ if (!this.atEnd()) throw unsupported$1(`Unexpected \`${this.peek()}\`.`);
684
+ return;
685
+ }
686
+ if (operator === ";" || operator === "\n") while (this.peek() === " " || this.peek() === " " || this.atLineBreak()) this.pos++;
687
+ else this.skipBlanks();
688
+ }
689
+ }
690
+ readListOperator() {
691
+ const char = this.peek();
692
+ if (char === "&" && this.peek(1) === "&") {
693
+ this.pos += 2;
694
+ return "&&";
695
+ }
696
+ if (char === "|" && this.peek(1) === "|") {
697
+ this.pos += 2;
698
+ return "||";
699
+ }
700
+ if (char === ";") {
701
+ this.pos++;
702
+ return ";";
703
+ }
704
+ if (this.atLineBreak()) {
705
+ while (this.atLineBreak()) this.pos++;
706
+ return "\n";
707
+ }
708
+ return null;
709
+ }
710
+ parsePipeline() {
711
+ this.parseCommand();
712
+ while (this.peek() === "|" && this.peek(1) !== "|") {
713
+ this.pos++;
714
+ this.skipBlanks();
715
+ if (this.atEnd() || this.atLineBreak() || this.peek() === ";") throw unsupported$1("The line ends with `|` but no command follows it.");
716
+ this.parseCommand();
717
+ }
718
+ }
719
+ atRedirectStart() {
720
+ const char = this.peek();
721
+ if (char === ">") return true;
722
+ return /[1-6*]/.test(char) && this.peek(1) === ">";
723
+ }
724
+ parseCommand() {
725
+ const start = this.pos;
726
+ const command = {
727
+ assignments: [],
728
+ words: [],
729
+ redirects: [],
730
+ source: ""
731
+ };
732
+ let callOperator = false;
733
+ if (this.peek() === "&" && this.peek(1) !== "&") {
734
+ callOperator = true;
735
+ this.pos++;
736
+ this.skipBlanks();
737
+ }
738
+ while (true) {
739
+ this.skipBlanks();
740
+ if (this.atEnd() || this.atLineBreak() || this.peek() === ";" || this.peek() === "|") break;
741
+ if (this.peek() === "&") {
742
+ if (this.peek(1) === "&") break;
743
+ throw unsupported$1("`&` is only supported as the call operator in front of a command (not as a background job).");
744
+ }
745
+ if (this.peek() === "<") throw unsupported$1("`<` is not supported in PowerShell command lines.");
746
+ if (this.atRedirectStart()) {
747
+ command.redirects.push(this.parseRedirect());
748
+ continue;
749
+ }
750
+ const word = this.parseWord();
751
+ command.words.push(word);
752
+ if (command.words.length === 1) this.checkCommandWord(word);
753
+ }
754
+ if (command.words.length === 0 && (callOperator || command.redirects.length === 0)) throw unsupported$1("The line has an empty command (two operators in a row?).");
755
+ command.source = this.src.slice(start, this.pos).trim();
756
+ this.commands.push(command);
757
+ }
758
+ checkCommandWord(word) {
759
+ if (word.raw.startsWith("[")) throw unsupported$1(`\`${word.raw}\` (a type literal) is not supported.`);
760
+ if (!word.quoted && word.value === ".") throw unsupported$1("Dot-sourcing (`. file`) runs a script in the current scope and is not supported.");
761
+ if (!word.quoted && POWERSHELL_KEYWORDS.has(word.value.toLowerCase())) throw unsupported$1(`\`${word.value}\` starts a PowerShell statement, which is not supported.`);
762
+ }
763
+ parseRedirect() {
764
+ let fd;
765
+ if (this.peek() === "*") this.pos++;
766
+ else if (/[1-6]/.test(this.peek())) {
767
+ fd = Number(this.peek());
768
+ this.pos++;
769
+ }
770
+ let op = ">";
771
+ if (this.peek(1) === ">") op = ">>";
772
+ this.pos += op.length;
773
+ if (op === ">" && this.peek() === "&") {
774
+ if (/[1-6]/.test(this.peek(1))) {
775
+ const target = {
776
+ raw: this.peek(1),
777
+ value: this.peek(1),
778
+ dynamic: false,
779
+ quoted: false
780
+ };
781
+ this.pos += 2;
782
+ return {
783
+ op: ">&",
784
+ ...fd !== void 0 ? { fd } : {},
785
+ target,
786
+ type: "dup"
787
+ };
788
+ }
789
+ throw unsupported$1("Only `>&1`-style stream merges (`2>&1`) are supported.");
790
+ }
791
+ this.skipBlanks();
792
+ if (this.atEnd() || this.atLineBreak() || /[;&|<>]/.test(this.peek())) throw unsupported$1(`The redirection \`${op}\` has no target.`);
793
+ const target = this.parseWord();
794
+ return {
795
+ op,
796
+ ...fd !== void 0 ? { fd } : {},
797
+ target,
798
+ type: "write"
799
+ };
800
+ }
801
+ parseWord() {
802
+ const start = this.pos;
803
+ let value = "";
804
+ let dynamic = false;
805
+ let quoted = false;
806
+ if (this.src.startsWith("--%", this.pos) && /[\s;|&]|^$/.test(this.src.charAt(this.pos + 3))) throw unsupported$1("The stop-parsing token `--%` is not supported.");
807
+ while (!this.atEnd()) {
808
+ const char = this.peek();
809
+ if (char === " " || char === " " || char === "\n" || char === "\r" || char === ";" || char === "|" || char === "&" || char === ">" || char === "<") break;
810
+ if (inSet(SINGLE_QUOTES, char)) {
811
+ this.pos++;
812
+ quoted = true;
813
+ while (true) {
814
+ if (this.atEnd()) throw new PowerShellRefusal("unbalanced-quotes", "A single quote is never closed.");
815
+ const inner = this.peek();
816
+ if (inSet(SINGLE_QUOTES, inner)) {
817
+ if (inSet(SINGLE_QUOTES, this.peek(1))) {
818
+ value += this.peek(1);
819
+ this.pos += 2;
820
+ continue;
821
+ }
822
+ this.pos++;
823
+ break;
824
+ }
825
+ value += inner;
826
+ this.pos++;
827
+ }
828
+ continue;
829
+ }
830
+ if (inSet(DOUBLE_QUOTES, char)) {
831
+ this.pos++;
832
+ quoted = true;
833
+ while (true) {
834
+ if (this.atEnd()) throw new PowerShellRefusal("unbalanced-quotes", "A double quote is never closed.");
835
+ const inner = this.peek();
836
+ if (inSet(DOUBLE_QUOTES, inner)) {
837
+ if (inSet(DOUBLE_QUOTES, this.peek(1))) {
838
+ value += this.peek(1);
839
+ this.pos += 2;
840
+ continue;
841
+ }
842
+ this.pos++;
843
+ break;
844
+ }
845
+ if (inner === "`") {
846
+ const next = this.peek(1);
847
+ if (next !== "" && (inSet(DOUBLE_QUOTES, next) || next === "`" || next === "$")) {
848
+ value += next;
849
+ this.pos += 2;
850
+ continue;
851
+ }
852
+ throw unsupported$1("Only `\"`, `` ` `` and `$` can follow a backtick inside double quotes.");
853
+ }
854
+ if (inner === "$") {
855
+ const read = this.readVariable();
856
+ value += read;
857
+ dynamic = true;
858
+ continue;
859
+ }
860
+ value += inner;
861
+ this.pos++;
862
+ }
863
+ continue;
864
+ }
865
+ if (char === "`") {
866
+ const next = this.peek(1);
867
+ if (next === "" || next === "\n" || next === "\r") throw unsupported$1("A backtick at the end of a line (line continuation) is not supported.");
868
+ value += next;
869
+ quoted = true;
870
+ this.pos += 2;
871
+ continue;
872
+ }
873
+ if (char === "$") {
874
+ const wasLiteral = this.src.charAt(this.pos + 1);
875
+ if (wasLiteral === "" || !(wasLiteral === "{" || wasLiteral === "(" || VARIABLE_CHAR.test(wasLiteral))) {
876
+ value += "$";
877
+ this.pos++;
878
+ continue;
879
+ }
880
+ value += this.readVariable();
881
+ dynamic = true;
882
+ continue;
883
+ }
884
+ if (char === "@" && this.pos === start) {
885
+ const next = this.peek(1);
886
+ if (next === "(" || next === "{" || next === "'" || next === "\"" || /[A-Za-z_$]/.test(next)) throw unsupported$1("Arrays (`@( )`), hashtables, splatting (`@name`) and here-strings are not supported.");
887
+ }
888
+ if (char === "{" || char === "}") throw unsupported$1("Script blocks and braces are not supported.");
889
+ if (char === "(" || char === ")") throw unsupported$1("Parentheses (grouping and `$( )` subexpressions) are not supported; quote the text.");
890
+ if (char === ",") throw unsupported$1("The comma operator builds an array and is not supported; quote the text.");
891
+ if (char === "#" && this.pos === start) throw unsupported$1("Comments are not supported in a command line.");
892
+ if (char === "*" || char === "?" || char === "[" || char === "~" && this.pos === start) dynamic = true;
893
+ value += char;
894
+ this.pos++;
895
+ }
896
+ return {
897
+ raw: this.src.slice(start, this.pos),
898
+ value,
899
+ dynamic,
900
+ quoted
901
+ };
902
+ }
903
+ /**
904
+ * Parse a `$( … )` subexpression: its commands run, so they are collected
905
+ * (before the command that contains it) and checked like any other.
906
+ */
907
+ parseSubexpression() {
908
+ if (this.depth >= 8) throw unsupported$1("`$( )` subexpressions are nested too deeply.");
909
+ const open = this.pos;
910
+ let level = 0;
911
+ let index = open;
912
+ for (; index < this.src.length; index++) {
913
+ const char = this.src.charAt(index);
914
+ if (inSet(SINGLE_QUOTES, char) || inSet(DOUBLE_QUOTES, char)) {
915
+ const set = inSet(SINGLE_QUOTES, char) ? SINGLE_QUOTES : DOUBLE_QUOTES;
916
+ index++;
917
+ while (index < this.src.length) {
918
+ const inner = this.src.charAt(index);
919
+ if (inner === "`" && set === DOUBLE_QUOTES) {
920
+ index += 2;
921
+ continue;
922
+ }
923
+ if (inSet(set, inner)) {
924
+ if (inSet(set, this.src.charAt(index + 1))) {
925
+ index += 2;
926
+ continue;
927
+ }
928
+ break;
929
+ }
930
+ index++;
931
+ }
932
+ continue;
933
+ }
934
+ if (char === "`") {
935
+ index++;
936
+ continue;
937
+ }
938
+ if (char === "(") level++;
939
+ else if (char === ")") {
940
+ level--;
941
+ if (level === 0) break;
942
+ }
943
+ }
944
+ if (level !== 0) throw new PowerShellRefusal("unbalanced-quotes", "A `$(` subexpression is never closed.");
945
+ const inner = this.src.slice(open + 1, index);
946
+ new PowerShellParser(inner, this.commands, this.depth + 1).parseProgram([]);
947
+ this.pos = index + 1;
948
+ }
949
+ /** Read `$name`, `${name}` or `$env:NAME` at the cursor; a `$(` is refused. */
950
+ readVariable() {
951
+ const begin = this.pos;
952
+ this.pos++;
953
+ if (this.peek() === "(") {
954
+ this.parseSubexpression();
955
+ return this.src.slice(begin, this.pos);
956
+ }
957
+ if (this.peek() === "{") {
958
+ const close = this.src.indexOf("}", this.pos);
959
+ if (close === -1) throw new PowerShellRefusal("unbalanced-quotes", "A `${` variable name is never closed.");
960
+ this.pos = close + 1;
961
+ return this.src.slice(begin, this.pos);
962
+ }
963
+ while (!this.atEnd() && VARIABLE_CHAR.test(this.peek())) this.pos++;
964
+ return this.src.slice(begin, this.pos);
965
+ }
966
+ };
967
+ /** Parse a PowerShell command line for the shell policy. Never throws. */
968
+ function parsePowerShellLine(line) {
969
+ const segments = [];
970
+ const parser = new PowerShellParser(line);
971
+ try {
972
+ parser.parseProgram(segments);
973
+ return {
974
+ ok: true,
975
+ commands: parser.commands,
976
+ segments,
977
+ background: false
978
+ };
979
+ } catch (error) {
980
+ if (error instanceof PowerShellRefusal) return {
981
+ ok: false,
982
+ reason: error.reason,
983
+ message: error.message
984
+ };
985
+ throw error;
986
+ }
987
+ }
988
+
183
989
  //#endregion
184
990
  //#region ../../packages/ai-workspace/src/policy/shell-parser.ts
185
991
  /** Deepest nesting of subshells and substitutions the parser accepts. */
@@ -947,6 +1753,7 @@ var CmdParser = class {
947
1753
  */
948
1754
  function parseShellLine(line, dialect) {
949
1755
  const segments = [];
1756
+ if (dialect === "powershell") return parsePowerShellLine(line);
950
1757
  try {
951
1758
  if (dialect === "cmd") {
952
1759
  const commands = [];
@@ -1159,6 +1966,26 @@ const OPAQUE_RUNNERS = /* @__PURE__ */ new Set([
1159
1966
  "stdbuf",
1160
1967
  "flock"
1161
1968
  ]);
1969
+ /**
1970
+ * PowerShell cmdlets and aliases that run code or commands the policy cannot
1971
+ * see, or change what later names mean (lowercase).
1972
+ */
1973
+ const POWERSHELL_OPAQUE = /* @__PURE__ */ new Set([
1974
+ "iex",
1975
+ "invoke-expression",
1976
+ "invoke-command",
1977
+ "icm",
1978
+ "start-process",
1979
+ "saps",
1980
+ "start",
1981
+ "add-type",
1982
+ "set-alias",
1983
+ "sal",
1984
+ "new-alias",
1985
+ "nal",
1986
+ "invoke-item",
1987
+ "ii"
1988
+ ]);
1162
1989
  /** POSIX shell builtins that evaluate code or change how later names resolve. */
1163
1990
  const SHELL_OPAQUE_BUILTINS = /* @__PURE__ */ new Set([
1164
1991
  "eval",
@@ -1251,7 +2078,7 @@ const MAX_WRAPPER_DEPTH = 8;
1251
2078
  const MAX_CD_CANDIDATES = 32;
1252
2079
  /** Longest command shown verbatim in a refusal before it is abbreviated. */
1253
2080
  const MAX_SHOWN_COMMAND = 48;
1254
- /** The dialect shell mode parses on this platform. */
2081
+ /** The dialect `interpreter: "sh"` parses on this platform. */
1255
2082
  function shellDialect() {
1256
2083
  return process.platform === "win32" ? "cmd" : "posix";
1257
2084
  }
@@ -1262,7 +2089,7 @@ function shellDialect() {
1262
2089
  * stripped). The cmd dialect splits Windows paths too.
1263
2090
  */
1264
2091
  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, "");
2092
+ return (dialect === "cmd" || dialect === "powershell" ? node_path.default.win32.basename(firstToken) : node_path.default.basename(firstToken)).replace(dialect === "powershell" ? /\.(exe|cmd|bat|com|ps1)$/i : /\.(exe|cmd|bat|com)$/i, "");
1266
2093
  }
1267
2094
  /** Show a command in a refusal, abbreviated at a word boundary when long. */
1268
2095
  function show(source) {
@@ -1306,25 +2133,75 @@ var Refusal = class extends Error {
1306
2133
  };
1307
2134
  /** Per-line checker state. */
1308
2135
  var LineChecker = class {
1309
- constructor(policy, shell, line, dialect) {
2136
+ constructor(policy, shell, line, dialect, interpreter, remembered = []) {
1310
2137
  this.policy = policy;
1311
2138
  this.shell = shell;
1312
2139
  this.line = line;
1313
2140
  this.dialect = dialect;
2141
+ this.interpreter = interpreter;
1314
2142
  this.commands = [];
1315
2143
  this.paths = [];
2144
+ this.asks = [];
1316
2145
  this.deny = new Set((shell.deny ?? []).map((name) => name.toLowerCase()));
1317
2146
  this.allow = shell.allow ?? [];
1318
2147
  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;
2148
+ this.rules = (shell.rules ?? []).map((rule) => ({
2149
+ rule,
2150
+ pattern: compilePattern(rule.match)
2151
+ }));
2152
+ this.remembered = remembered.map(compilePattern);
2153
+ this.askMode = shell.onAsk !== void 0;
2154
+ this.hasCommandRules = this.deny.size > 0 || this.patterns.length > 0 || this.rules.length > 0 || shell.allowCommand !== void 0;
1321
2155
  }
1322
2156
  /** Whether argument hooks (`denyPatterns` / `allowCommand`) need literal argv. */
1323
2157
  get hasArgumentHooks() {
1324
2158
  return this.patterns.length > 0 || this.shell.allowCommand !== void 0;
1325
2159
  }
1326
2160
  get shellMode() {
1327
- return this.interpreter === "sh";
2161
+ return this.interpreter !== "none";
2162
+ }
2163
+ /** The last rule that matches `argv` (later rules win). */
2164
+ matchRule(argv) {
2165
+ for (let index = this.rules.length - 1; index >= 0; index--) {
2166
+ const compiled = this.rules[index];
2167
+ if (compiled.pattern.test(argv, this.dialect)) return compiled.rule;
2168
+ }
2169
+ }
2170
+ isRemembered(argv) {
2171
+ return this.remembered.some((pattern) => pattern.test(argv, this.dialect));
2172
+ }
2173
+ /** Record a command that needs a decision (once per argv and reason). */
2174
+ addAsk(argv, reason, rule) {
2175
+ const key = `${reason}\u0000${argv.join("\0")}`;
2176
+ if (this.asks.some((ask) => `${ask.reason}\u0000${ask.argv.join("\0")}` === key)) return;
2177
+ this.asks.push({
2178
+ argv: [...argv],
2179
+ reason,
2180
+ ...rule !== void 0 ? { rule } : {}
2181
+ });
2182
+ }
2183
+ /** Refuse when the matching rule denies. */
2184
+ assertNotDenied(argv, rule) {
2185
+ if (rule?.effect !== "deny") return;
2186
+ const explanation = rule.reason !== void 0 && rule.reason.trim() !== "" ? ` ${rule.reason.trim()}` : "";
2187
+ throw new Refusal("denied-pattern", `${show(argv.join(" "))} is denied by a workspace shell rule.${explanation}`, executableBasename(argv[0] ?? "", this.dialect));
2188
+ }
2189
+ /**
2190
+ * Check the canonical form of a PowerShell alias (`Remove-Item -Recurse
2191
+ * -Force x` as `rm -rf x`): deny, `denyPatterns`, `allowCommand` and the
2192
+ * rules, but not the `allow` list (which names the spelling that was written).
2193
+ */
2194
+ checkCanonical(argv) {
2195
+ const key = executableBasename(argv[0] ?? "", this.dialect).toLowerCase();
2196
+ if (this.deny.has(key)) throw new Refusal("denied-executable", `\`${key}\` is denied by the workspace shell policy (in ${show(argv.join(" "))}).`, key);
2197
+ this.checkHooks(argv, true);
2198
+ const rule = this.matchRule(argv);
2199
+ this.assertNotDenied(argv, rule);
2200
+ if (rule?.effect === "ask" && !this.isRemembered(argv)) this.addAsk(argv, "rule", rule);
2201
+ }
2202
+ /** Refuse a denied executable (used for `cd` spellings). */
2203
+ assertExecutableNotDenied(key, argv) {
2204
+ if (this.deny.has(key)) throw new Refusal("denied-executable", `\`${key}\` is denied by the workspace shell policy (in ${show(argv.join(" "))}).`, key);
1328
2205
  }
1329
2206
  /** Run `denyPatterns` and `allowCommand` on one argv. */
1330
2207
  checkHooks(argv, nested) {
@@ -1372,15 +2249,34 @@ var LineChecker = class {
1372
2249
  throw new Refusal("not-allowed", `\`${executable}\` is not in the workspace shell allowlist. Allowed commands: ${listed}${this.allow.length > 20 ? ", …" : ""}.`, executable);
1373
2250
  }
1374
2251
  this.checkHooks(argv, depth > 0);
2252
+ const rule = this.matchRule(argv);
2253
+ this.assertNotDenied(argv, rule);
1375
2254
  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)) {
2255
+ const remembered = this.isRemembered(argv);
2256
+ const approved = rule?.effect === "allow" || remembered || this.explicitlyAllowed(executable);
2257
+ 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" && POWERSHELL_OPAQUE.has(key)) {
2258
+ if (approved) return;
2259
+ if (this.askMode || rule?.effect === "ask") {
2260
+ this.addAsk(argv, "nested", rule);
2261
+ return;
2262
+ }
1377
2263
  this.checkOpaque(executable, argv);
1378
2264
  return;
1379
2265
  }
1380
2266
  if (this.shellMode && this.dialect === "posix" && ARITHMETIC_SENSITIVE_BUILTINS.has(key)) this.checkArithmeticSensitive(executable, argv, dynamic);
1381
2267
  const ranges = this.unwrap(key, argv);
2268
+ if (!approved && ranges === void 0) {
2269
+ if (rule?.effect === "ask") this.addAsk(argv, "rule", rule);
2270
+ else if (rule === void 0 && this.askMode && this.allow.length === 0) this.addAsk(argv, "rule");
2271
+ } else if (!approved && rule?.effect === "ask") this.addAsk(argv, "rule", rule);
1382
2272
  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);
2273
+ if (ranges === null || depth >= MAX_WRAPPER_DEPTH) {
2274
+ if (this.askMode) {
2275
+ this.addAsk(argv, "nested", rule);
2276
+ return;
2277
+ }
2278
+ 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);
2279
+ }
1384
2280
  for (const [start, end] of ranges) this.checkArgv(argv.slice(start, end), dynamic.slice(start, end), depth + 1);
1385
2281
  }
1386
2282
  /**
@@ -1446,6 +2342,7 @@ var LineChecker = class {
1446
2342
  }
1447
2343
  /** Check a redirection target from every possible cwd. */
1448
2344
  checkRedirectTarget(word, access, cwds) {
2345
+ if (this.dialect === "powershell" && !word.quoted && word.value.toLowerCase() === "$null") return;
1449
2346
  if (word.dynamic) throw new Refusal("unsupported-syntax", `The redirection target \`${word.raw}\` must be a literal path (no variables, globs or \`~\`).`);
1450
2347
  const value = word.value;
1451
2348
  if (this.dialect === "posix" && DEVICE_TARGETS.has(value) || this.dialect === "cmd" && CMD_DEVICE_TARGETS.has(value.toLowerCase())) return;
@@ -1638,11 +2535,17 @@ function argvModeRefusal(command) {
1638
2535
  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
2536
  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
2537
  }
1641
- /** Check a shell-mode (`interpreter: "sh"`) line. */
2538
+ /** Check a shell-mode line. */
1642
2539
  function checkShellLine(checker, policy, command, dialect) {
1643
2540
  if (/^[-+]/.test(command)) throw new Refusal("unsupported-syntax", "A command line may not start with `-` or `+`.");
1644
2541
  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.`);
2542
+ if (!parsed.ok) {
2543
+ if (checker.askMode) {
2544
+ checker.addAsk([command], "unparsed");
2545
+ return;
2546
+ }
2547
+ throw new Refusal(parsed.reason, `${parsed.message} The workspace shell policy only runs lines it can check.`);
2548
+ }
1646
2549
  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
2550
  let cwds = [node_path.default.resolve(policy.cwd)];
1648
2551
  for (const simple of parsed.commands) cwds = checkSimpleCommand(checker, simple, cwds, dialect);
@@ -1656,16 +2559,38 @@ function checkSimpleCommand(checker, simple, cwds, dialect) {
1656
2559
  for (const redirect of simple.redirects) if (redirect.type === "read" || redirect.type === "write") checker.checkRedirectTarget(redirect.target, redirect.type, cwds);
1657
2560
  const first = simple.words[0];
1658
2561
  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).`);
1664
- }
1665
2562
  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);
2563
+ const powershell = dialect === "powershell";
2564
+ if (executable === "cd" || powershell && POWERSHELL_CD_NAMES.has(executable)) {
2565
+ checker.assertExecutableNotDenied(executable, simple.words.map((word) => word.value));
2566
+ if (powershell) {
2567
+ const words = normalizePowerShellCd(simple.words);
2568
+ if (words === null) throw new Refusal("unsupported-syntax", "`Set-Location` is supported with one directory (`-Path` / `-LiteralPath` or positional) only.", "cd");
2569
+ return checker.checkCd(words, cwds);
2570
+ }
2571
+ return checker.checkCd(simple.words, cwds);
2572
+ }
2573
+ try {
2574
+ if (!first.dynamic && first.value.includes("=")) throw new Refusal("unsupported-syntax", `\`${first.raw}\` is not a plain command name or \`NAME=value\` assignment.`);
2575
+ if (/^[-+]/.test(first.value)) throw new Refusal("unsupported-syntax", `\`${first.raw}\` is not a command name.`);
2576
+ if (checker.hasArgumentHooks) {
2577
+ const dynamic = simple.words.find((word) => word.dynamic);
2578
+ 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).`);
2579
+ }
2580
+ 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"}\`.`);
2581
+ const argv = simple.words.map((word) => word.dynamic ? word.raw : word.value);
2582
+ checker.checkArgv(argv, simple.words.map((word) => word.dynamic), 0);
2583
+ if (powershell) {
2584
+ const canonical = canonicalPowerShellArgv(argv);
2585
+ if (canonical !== void 0 && canonical.join("\0") !== argv.join("\0")) checker.checkCanonical(canonical);
2586
+ }
2587
+ } catch (error) {
2588
+ if (error instanceof Refusal && error.reason === "unsupported-syntax" && checker.askMode) {
2589
+ checker.addAsk(simple.words.map((word) => word.dynamic ? word.raw : word.value), "unparsed");
2590
+ return cwds;
2591
+ }
2592
+ throw error;
2593
+ }
1669
2594
  return cwds;
1670
2595
  }
1671
2596
  /**
@@ -1693,35 +2618,44 @@ function checkSimpleCommand(checker, simple, cwds, dialect) {
1693
2618
  * checkShellCommand({ cwd, shell: { allow: ["git"] } }, "git add -A && git commit -m wip");
1694
2619
  * // { allowed: false, reason: "metacharacter", message: "`&&` is not supported in argv mode: run `git add -A`, then `git commit -m wip`, …" }
1695
2620
  */
1696
- function checkShellCommand(policy, command) {
1697
- return checkShellCommandInDialect(policy, command, shellDialect());
2621
+ function checkShellCommand(policy, command, options) {
2622
+ const resolved = options?.resolved ?? resolveShell(policy.shell, options?.host ?? currentShellHost());
2623
+ return runCheck(policy, command, resolved.interpreter, resolved.dialect, options?.remembered);
1698
2624
  }
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) {
2625
+ function runCheck(policy, command, interpreter, dialect, remembered) {
1705
2626
  const shell = policy.shell;
1706
2627
  if (!shell) return {
1707
2628
  allowed: false,
1708
2629
  reason: "not-allowed",
1709
2630
  message: "The workspace has no shell policy, so no command may run.",
2631
+ asks: [],
1710
2632
  commands: [],
1711
2633
  paths: []
1712
2634
  };
1713
- const interpreter = shell.interpreter ?? "none";
1714
- const checker = new LineChecker(policy, shell, command, interpreter === "sh" ? dialect : "posix");
2635
+ const shellMode = interpreter !== "none";
2636
+ const checker = new LineChecker(policy, shell, command, shellMode ? dialect : "posix", interpreter, remembered);
1715
2637
  try {
1716
2638
  if (command.trim() === "") throw new Refusal("not-allowed", "The command is empty.");
1717
- if (interpreter === "sh") checkShellLine(checker, policy, command, dialect);
2639
+ if (shellMode) checkShellLine(checker, policy, command, dialect);
1718
2640
  else {
1719
2641
  const argv = tokenizeCommand(command);
1720
2642
  if (argv === null) throw argvModeRefusal(command);
1721
2643
  checker.checkArgv(argv, argv.map(() => false), 0);
1722
2644
  }
2645
+ if (checker.asks.length > 0) {
2646
+ const first = checker.asks[0];
2647
+ return {
2648
+ allowed: false,
2649
+ reason: "approval-required",
2650
+ 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}` : ""}`,
2651
+ asks: checker.asks,
2652
+ commands: checker.commands,
2653
+ paths: checker.paths
2654
+ };
2655
+ }
1723
2656
  return {
1724
2657
  allowed: true,
2658
+ asks: [],
1725
2659
  commands: checker.commands,
1726
2660
  paths: checker.paths
1727
2661
  };
@@ -1733,11 +2667,34 @@ function checkShellCommandInDialect(policy, command, dialect) {
1733
2667
  message: error.message,
1734
2668
  ...error.executable !== void 0 ? { executable: error.executable } : {},
1735
2669
  ...error.refusedPath !== void 0 ? { path: error.refusedPath } : {},
2670
+ asks: [],
1736
2671
  commands: checker.commands,
1737
2672
  paths: checker.paths
1738
2673
  };
1739
2674
  }
1740
2675
  }
2676
+ /**
2677
+ * The simple commands a line would run (pipeline members, `&&` / `||` / `;`
2678
+ * parts, subshells and substitutions included), without checking anything.
2679
+ * A line the parser cannot read with confidence comes back with `opaque`
2680
+ * set to the reason and no commands.
2681
+ *
2682
+ * The line is read with the policy's shell dialect when its `interpreter`
2683
+ * is a shell, and with the platform's default shell otherwise.
2684
+ *
2685
+ * @example
2686
+ * classifyShellCommand("a | b"); // { commands: [["a"], ["b"]] }
2687
+ */
2688
+ function classifyShellCommand(command, policy, host) {
2689
+ const resolved = resolveShell(policy?.shell, host ?? currentShellHost());
2690
+ const dialect = resolved.interpreter === "none" ? shellDialect() : resolved.dialect;
2691
+ const parsed = parseShellLine(command, dialect);
2692
+ if (!parsed.ok) return {
2693
+ commands: [],
2694
+ opaque: parsed.message
2695
+ };
2696
+ return { commands: parsed.commands.filter((simple) => simple.words.length > 0).map((simple) => simple.words.map((word) => word.dynamic ? word.raw : word.value)) };
2697
+ }
1741
2698
 
1742
2699
  //#endregion
1743
2700
  //#region ../../packages/ai-workspace/src/policy/policy.ts
@@ -1785,13 +2742,15 @@ async function canonicalize$1(target) {
1785
2742
  *
1786
2743
  * @param policy - The bounding policy (its `cwd` is the jail root).
1787
2744
  * @param inputPath - A workspace-relative or absolute path to resolve.
2745
+ * @param access - `"read"` also accepts the `output.spillDir` directory (only
2746
+ * `readFile` asks for it); `"write"` (default) never does.
1788
2747
  * @returns The canonical absolute path plus its `/`-separated relative form.
1789
2748
  * @throws {WorkspacePolicyError} When the path escapes the jail or hits a deny glob.
1790
2749
  *
1791
2750
  * @example
1792
2751
  * const { absolutePath } = await resolveInJail(policy, "src/index.ts");
1793
2752
  */
1794
- async function resolveInJail(policy, inputPath) {
2753
+ async function resolveInJail(policy, inputPath, access = "write") {
1795
2754
  const jailRoot = await canonicalize$1(policy.cwd);
1796
2755
  const absolutePath = await canonicalize$1(node_path.default.isAbsolute(inputPath) ? inputPath : node_path.default.join(policy.cwd, inputPath));
1797
2756
  const insideCwd = isInside(absolutePath, jailRoot);
@@ -1804,6 +2763,11 @@ async function resolveInJail(policy, inputPath) {
1804
2763
  break;
1805
2764
  }
1806
2765
  }
2766
+ const spillDir = access === "read" && !insideCwd ? resolveSpillDir(policy) : void 0;
2767
+ if (spillDir !== void 0 && isInside(absolutePath, await canonicalize$1(spillDir))) return {
2768
+ absolutePath,
2769
+ relativePath: ""
2770
+ };
1807
2771
  if (!insideCwd && !insideAllow) throw new WorkspacePolicyError(`Path "${inputPath}" resolves outside the workspace jail.`, {
1808
2772
  type: "path-escape",
1809
2773
  path: inputPath
@@ -1870,8 +2834,8 @@ function buildEnv(policy) {
1870
2834
  return env;
1871
2835
  }
1872
2836
  /**
1873
- * Variables removed from the environment of a shell-mode (`interpreter:
1874
- * "sh"`) command: each changes how the shell itself starts, parses or
2837
+ * Variables removed from the environment of a shell-mode (`interpreter`
2838
+ * other than `"none"`) command: each changes how the shell itself starts, parses or
1875
2839
  * resolves the line (`ENV`/`BASH_ENV` run a startup file, `SHELLOPTS` +
1876
2840
  * `PS4` can run command substitutions, `BASH_FUNC_*` imports functions that
1877
2841
  * shadow commands, `CDPATH` moves `cd`, `IFS` re-splits words).
@@ -1893,7 +2857,7 @@ const SHELL_STARTUP_VARIABLES = /* @__PURE__ */ new Set([
1893
2857
  */
1894
2858
  function buildCommandEnv(policy) {
1895
2859
  const env = buildEnv(policy);
1896
- if ((policy.shell?.interpreter ?? "none") !== "sh") return env;
2860
+ if ((policy.shell?.interpreter ?? "none") === "none") return env;
1897
2861
  for (const key of Object.keys(env)) {
1898
2862
  const upper = key.toUpperCase();
1899
2863
  if (SHELL_STARTUP_VARIABLES.has(upper) || upper.startsWith("BASH_FUNC_")) delete env[key];
@@ -2388,6 +3352,520 @@ function applyPatchWithOps(ops, input) {
2388
3352
  });
2389
3353
  }
2390
3354
 
3355
+ //#endregion
3356
+ //#region ../../packages/ai-workspace/src/edit/match.ts
3357
+ /** Most lines of file text a miss hint shows. */
3358
+ const MAX_HINT_LINES = 12;
3359
+ /** Most characters of one line a miss hint shows. */
3360
+ const MAX_HINT_LINE_CHARS = 200;
3361
+ const FOLD_MAP = {
3362
+ "‘": "'",
3363
+ "’": "'",
3364
+ "‚": "'",
3365
+ "‛": "'",
3366
+ "“": "\"",
3367
+ "”": "\"",
3368
+ "„": "\"",
3369
+ "‟": "\"",
3370
+ "‐": "-",
3371
+ "‑": "-",
3372
+ "‒": "-",
3373
+ "–": "-",
3374
+ "—": "-",
3375
+ "―": "-",
3376
+ "−": "-",
3377
+ "\xA0": " ",
3378
+ " ": " ",
3379
+ " ": " ",
3380
+ " ": " ",
3381
+ " ": " ",
3382
+ " ": " ",
3383
+ " ": " "
3384
+ };
3385
+ /** Fold look-alike characters one for one, so string indexes stay aligned. */
3386
+ function foldUnicode(value) {
3387
+ let out = "";
3388
+ for (const char of value) out += FOLD_MAP[char] ?? char;
3389
+ return out;
3390
+ }
3391
+ /** Every non-overlapping occurrence of `needle` in `haystack`. */
3392
+ function findAll(haystack, needle) {
3393
+ const ranges = [];
3394
+ if (needle === "") return ranges;
3395
+ let from = 0;
3396
+ for (;;) {
3397
+ const index = haystack.indexOf(needle, from);
3398
+ if (index === -1) return ranges;
3399
+ ranges.push({
3400
+ start: index,
3401
+ end: index + needle.length
3402
+ });
3403
+ from = index + needle.length;
3404
+ }
3405
+ }
3406
+ /** The file's dominant line ending. */
3407
+ function detectEol(content) {
3408
+ const crlf = content.split("\r\n").length - 1;
3409
+ return crlf > content.split("\n").length - 1 - crlf ? "\r\n" : "\n";
3410
+ }
3411
+ /** Convert any line endings in `text` to `eol`. */
3412
+ function withEol(text, eol) {
3413
+ const normalised = text.replace(/\r\n/g, "\n");
3414
+ return eol === "\n" ? normalised : normalised.replace(/\n/g, "\r\n");
3415
+ }
3416
+ /** An LF view of `content` plus the original index of each view character. */
3417
+ function lfView(content) {
3418
+ if (!content.includes("\r\n")) return {
3419
+ view: content,
3420
+ map: null
3421
+ };
3422
+ let view = "";
3423
+ const map = [];
3424
+ for (let index = 0; index < content.length; index++) {
3425
+ if (content[index] === "\r" && content[index + 1] === "\n") continue;
3426
+ map.push(content[index] === "\n" && content[index - 1] === "\r" ? index - 1 : index);
3427
+ view += content[index];
3428
+ }
3429
+ map.push(content.length);
3430
+ return {
3431
+ view,
3432
+ map
3433
+ };
3434
+ }
3435
+ /** Line-based search ignoring trailing whitespace, on already folded text. */
3436
+ function findByLines(view, needle) {
3437
+ const needleLines = needle.split("\n");
3438
+ const endsWithBreak = needleLines.length > 1 && needleLines[needleLines.length - 1] === "";
3439
+ if (endsWithBreak) needleLines.pop();
3440
+ const wanted = needleLines.map((line) => line.trimEnd());
3441
+ if (wanted.every((line) => line === "")) return [];
3442
+ const lines = view.split("\n");
3443
+ const starts = [];
3444
+ let offset = 0;
3445
+ for (const line of lines) {
3446
+ starts.push(offset);
3447
+ offset += line.length + 1;
3448
+ }
3449
+ const ranges = [];
3450
+ for (let index = 0; index + wanted.length <= lines.length;) {
3451
+ let same = true;
3452
+ for (let k = 0; k < wanted.length; k++) if ((lines[index + k] ?? "").trimEnd() !== wanted[k]) {
3453
+ same = false;
3454
+ break;
3455
+ }
3456
+ if (!same) {
3457
+ index++;
3458
+ continue;
3459
+ }
3460
+ const last = index + wanted.length - 1;
3461
+ let end = (starts[last] ?? 0) + (lines[last] ?? "").length;
3462
+ if (endsWithBreak && view[end] === "\n") end++;
3463
+ ranges.push({
3464
+ start: starts[index] ?? 0,
3465
+ end
3466
+ });
3467
+ index += wanted.length;
3468
+ }
3469
+ return ranges;
3470
+ }
3471
+ /**
3472
+ * Find `oldString` in `content`. `"exact"` only looks for the text as given;
3473
+ * `"tiered"` walks the tiers described on {@link WorkspaceEditMatching} and
3474
+ * stops at the first one with a match. The ranges are always in the original
3475
+ * content, whatever view found them.
3476
+ */
3477
+ function locateEdit(content, oldString, matching) {
3478
+ const exact = findAll(content, oldString);
3479
+ if (exact.length > 0 || matching === "exact") return {
3480
+ tier: exact.length > 0 ? "exact" : null,
3481
+ ranges: exact,
3482
+ viaView: false
3483
+ };
3484
+ const { view, map } = lfView(content);
3485
+ const needle = oldString.replace(/\r\n/g, "\n");
3486
+ const back = (ranges) => map === null ? ranges : ranges.map((range) => ({
3487
+ start: map[range.start] ?? content.length,
3488
+ end: map[range.end] ?? content.length
3489
+ }));
3490
+ if (map !== null || needle !== oldString) {
3491
+ const plain = findAll(view, needle);
3492
+ if (plain.length > 0) return {
3493
+ tier: "exact",
3494
+ ranges: back(plain),
3495
+ viaView: true
3496
+ };
3497
+ }
3498
+ const foldedView = foldUnicode(view);
3499
+ const foldedNeedle = foldUnicode(needle);
3500
+ const folded = findAll(foldedView, foldedNeedle);
3501
+ if (folded.length > 0) return {
3502
+ tier: "unicode",
3503
+ ranges: back(folded),
3504
+ viaView: true
3505
+ };
3506
+ const lined = findByLines(foldedView, foldedNeedle);
3507
+ if (lined.length > 0) return {
3508
+ tier: "whitespace",
3509
+ ranges: back(lined),
3510
+ viaView: true
3511
+ };
3512
+ return {
3513
+ tier: null,
3514
+ ranges: [],
3515
+ viaView: false
3516
+ };
3517
+ }
3518
+ function clipLine(line) {
3519
+ const flat = line.replace(/\r$/, "");
3520
+ return flat.length > 200 ? `${flat.slice(0, 200)}…` : flat;
3521
+ }
3522
+ function tokens(line) {
3523
+ return new Set(line.match(/[A-Za-z0-9_$]+/g) ?? []);
3524
+ }
3525
+ function similarity(left, right) {
3526
+ if (left.size === 0 || right.size === 0) return 0;
3527
+ let shared = 0;
3528
+ for (const token of left) if (right.has(token)) shared++;
3529
+ return shared / (left.size + right.size - shared);
3530
+ }
3531
+ /**
3532
+ * Explain a miss: either `newString` is already in the file (the edit was
3533
+ * probably applied), or the file text closest to `oldString`, at most
3534
+ * {@link MAX_HINT_LINES} lines of {@link MAX_HINT_LINE_CHARS} characters.
3535
+ * `undefined` when nothing is close.
3536
+ */
3537
+ function describeMiss(content, oldString, newString, maxLines = 12) {
3538
+ if (maxLines <= 0) return;
3539
+ const lines = content.split("\n");
3540
+ const present = newString.trim() === "" ? -1 : content.indexOf(newString);
3541
+ 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.`;
3542
+ const fileTokens = lines.map((line) => tokens(line));
3543
+ const wanted = oldString.split("\n");
3544
+ let best = {
3545
+ score: 0,
3546
+ fileLine: -1,
3547
+ offset: 0
3548
+ };
3549
+ wanted.forEach((text, offset) => {
3550
+ const own = tokens(text);
3551
+ fileTokens.forEach((candidate, fileLine) => {
3552
+ const score = similarity(own, candidate);
3553
+ if (score > best.score) best = {
3554
+ score,
3555
+ fileLine,
3556
+ offset
3557
+ };
3558
+ });
3559
+ });
3560
+ if (best.fileLine === -1 || best.score < .5) return;
3561
+ const first = Math.max(0, best.fileLine - best.offset);
3562
+ const count = Math.min(maxLines, 12, Math.max(1, wanted.length));
3563
+ const shown = lines.slice(first, first + count).map((line, index) => `${first + index + 1}: ${clipLine(line)}`);
3564
+ return `Closest text in the file (lines ${first + 1}-${first + shown.length}):\n${shown.join("\n")}`;
3565
+ }
3566
+
3567
+ //#endregion
3568
+ //#region ../../packages/ai-workspace/src/edit/apply-replacement.ts
3569
+ /**
3570
+ * Apply one replacement to `content` under the edit rules: `oldString` must
3571
+ * be found (see {@link WorkspaceEditMatching}), and exactly once unless
3572
+ * `replaceAll` — a fuzzy match must always be unique, so a loose match never
3573
+ * rewrites several places at once.
3574
+ */
3575
+ function applyReplacement(content, edit, inputPath, shownPath, options, editIndex) {
3576
+ const { matching } = options;
3577
+ const located = edit.oldString === "" ? null : locateEdit(content, edit.oldString, matching);
3578
+ const count = located?.ranges.length ?? 0;
3579
+ const which = editIndex === void 0 ? "" : `Edit ${editIndex + 1}: `;
3580
+ const nothingApplied = editIndex === void 0 ? "" : " None of the edits were applied.";
3581
+ const indexOption = editIndex === void 0 ? {} : { editIndex };
3582
+ if (located === null || count === 0) {
3583
+ let hint = describeMiss(content, edit.oldString, edit.newString, options.missHintLines);
3584
+ if (hint !== void 0 && options.hintMaxBytes !== void 0) hint = Buffer.byteLength(hint, "utf8") > options.hintMaxBytes ? void 0 : hint;
3585
+ throw new WorkspaceEditError(`${which}The text to replace was not found in "${inputPath}".` + (hint === void 0 ? "" : ` ${hint}`) + nothingApplied, {
3586
+ type: "not-found",
3587
+ path: shownPath,
3588
+ matches: 0,
3589
+ ...hint !== void 0 ? { hint } : {},
3590
+ ...indexOption
3591
+ });
3592
+ }
3593
+ const fuzzy = located.tier !== "exact";
3594
+ if (count > 1 && (!edit.replaceAll || fuzzy)) {
3595
+ const advice = fuzzy ? "include more surrounding context." : "pass replaceAll or include more surrounding context.";
3596
+ throw new WorkspaceEditError(`${which}The text to replace is not unique in "${inputPath}" (${count} matches${fuzzy && located.viaView ? ", found by loose matching" : ""}); ${advice}${nothingApplied}`, {
3597
+ type: "not-unique",
3598
+ path: shownPath,
3599
+ matches: count,
3600
+ ...indexOption
3601
+ });
3602
+ }
3603
+ const eol = options.lineEndings === "lf" ? "\n" : options.lineEndings === "crlf" ? "\r\n" : matching === "tiered" ? detectEol(content) : void 0;
3604
+ const replacement = eol === void 0 ? edit.newString : withEol(edit.newString, eol);
3605
+ const ranges = edit.replaceAll ? located.ranges : located.ranges.slice(0, 1);
3606
+ let out = "";
3607
+ let cursor = 0;
3608
+ for (const range of ranges) {
3609
+ out += content.slice(cursor, range.start) + replacement;
3610
+ cursor = range.end;
3611
+ }
3612
+ out += content.slice(cursor);
3613
+ return {
3614
+ content: out,
3615
+ replacements: ranges.length,
3616
+ ...fuzzy ? { matchTier: located.tier } : {}
3617
+ };
3618
+ }
3619
+
3620
+ //#endregion
3621
+ //#region ../../packages/ai-workspace/src/hooks/after-edit.ts
3622
+ /** How long a hook may take when the policy sets no `afterEditTimeoutMs`. */
3623
+ const DEFAULT_HOOK_TIMEOUT_MS = 1e4;
3624
+ /** Longest diagnostics text kept when the policy sets no `afterEditMaxChars`. */
3625
+ const DEFAULT_MAX_CHARS = 4e3;
3626
+ function wrap(text) {
3627
+ return `[diagnostics]\n${text}\n[/diagnostics]`;
3628
+ }
3629
+ /**
3630
+ * Run the policy's `afterEdit` hook for one call and return the text to put
3631
+ * on the result's `diagnostics`, or `undefined` when there is none. A throw
3632
+ * or a timeout yields "diagnostics unavailable"; it never rejects.
3633
+ */
3634
+ async function runAfterEdit(policy, event) {
3635
+ const hook = policy.hooks?.afterEdit;
3636
+ if (hook === void 0 || event.paths.length === 0) return;
3637
+ const timeoutMs = policy.hooks?.afterEditTimeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS;
3638
+ const maxChars = policy.hooks?.afterEditMaxChars ?? DEFAULT_MAX_CHARS;
3639
+ let timer;
3640
+ try {
3641
+ const answer = await Promise.race([Promise.resolve().then(() => hook(event)), new Promise((_resolve, reject) => {
3642
+ timer = setTimeout(() => reject(/* @__PURE__ */ new Error("afterEdit hook timed out")), timeoutMs);
3643
+ })]);
3644
+ if (typeof answer !== "string" || answer.trim() === "") return;
3645
+ const text = answer.trim();
3646
+ return wrap(text.length > maxChars ? `${text.slice(0, maxChars)}\n[diagnostics truncated: showing ${maxChars} of ${text.length} characters]` : text);
3647
+ } catch {
3648
+ return wrap("diagnostics unavailable");
3649
+ } finally {
3650
+ clearTimeout(timer);
3651
+ }
3652
+ }
3653
+
3654
+ //#endregion
3655
+ //#region ../../packages/ai-workspace/src/output/spill.ts
3656
+ const DEFAULT_MAX_LINES = 2e3;
3657
+ const DEFAULT_MAX_BYTES = 51200;
3658
+ const DEFAULT_RETENTION_MS = 6048e5;
3659
+ const SPILL_NAME = /^spill-(\d+)-[a-z0-9]+-[a-z0-9_-]+\.txt$/;
3660
+ function byteLength(value) {
3661
+ return Buffer.byteLength(value, "utf8");
3662
+ }
3663
+ /** The first `maxBytes` bytes of `value`, cut on a character boundary. */
3664
+ function headOf(value, maxBytes) {
3665
+ const bytes = Buffer.from(value, "utf8");
3666
+ if (bytes.byteLength <= maxBytes) return value;
3667
+ let cut = Math.max(0, maxBytes);
3668
+ while (cut > 0 && ((bytes[cut] ?? 0) & 192) === 128) cut--;
3669
+ return bytes.subarray(0, cut).toString("utf8");
3670
+ }
3671
+ /** The last `maxBytes` bytes of `value`, cut on a character boundary. */
3672
+ function tailOf(value, maxBytes) {
3673
+ const bytes = Buffer.from(value, "utf8");
3674
+ if (bytes.byteLength <= maxBytes) return value;
3675
+ let from = bytes.byteLength - Math.max(0, maxBytes);
3676
+ while (from < bytes.byteLength && ((bytes[from] ?? 0) & 192) === 128) from++;
3677
+ return bytes.subarray(from).toString("utf8");
3678
+ }
3679
+ /** Lines of `value`; a trailing line break does not add an empty line. */
3680
+ function splitLines(value) {
3681
+ if (value === "") return [];
3682
+ const lines = value.split("\n");
3683
+ if (lines[lines.length - 1] === "") lines.pop();
3684
+ return lines;
3685
+ }
3686
+ /** Pick lines from the front within `lines` / `bytes`; one oversized line is clipped. */
3687
+ function takeHead(lines, maxLines, maxBytes) {
3688
+ const out = [];
3689
+ let used = 0;
3690
+ for (const line of lines) {
3691
+ if (out.length >= maxLines) break;
3692
+ const cost = byteLength(line) + 1;
3693
+ if (used + cost > maxBytes) {
3694
+ if (out.length === 0 && maxBytes > 0) out.push(headOf(line, maxBytes));
3695
+ break;
3696
+ }
3697
+ out.push(line);
3698
+ used += cost;
3699
+ }
3700
+ return out;
3701
+ }
3702
+ /** Pick lines from the back, same limits; the result is in file order. */
3703
+ function takeTail(lines, maxLines, maxBytes) {
3704
+ const out = [];
3705
+ let used = 0;
3706
+ for (let index = lines.length - 1; index >= 0; index--) {
3707
+ const line = lines[index] ?? "";
3708
+ if (out.length >= maxLines) break;
3709
+ const cost = byteLength(line) + 1;
3710
+ if (used + cost > maxBytes) {
3711
+ if (out.length === 0 && maxBytes > 0) out.push(tailOf(line, maxBytes));
3712
+ break;
3713
+ }
3714
+ out.unshift(line);
3715
+ used += cost;
3716
+ }
3717
+ return out;
3718
+ }
3719
+ /** Choose the lines to keep for `keep`; `headCount` lines come from the front. */
3720
+ function select(lines, keep, maxLines, maxBytes) {
3721
+ if (keep === "head") return {
3722
+ head: takeHead(lines, maxLines, maxBytes),
3723
+ tail: []
3724
+ };
3725
+ if (keep === "tail") return {
3726
+ head: [],
3727
+ tail: takeTail(lines, maxLines, maxBytes)
3728
+ };
3729
+ const headLines = Math.ceil(maxLines / 2);
3730
+ const headBytes = Math.ceil(maxBytes / 2);
3731
+ const head = takeHead(lines, headLines, headBytes);
3732
+ return {
3733
+ head,
3734
+ tail: takeTail(lines.slice(head.length), maxLines - head.length, maxBytes - headBytes)
3735
+ };
3736
+ }
3737
+ /**
3738
+ * Keeps oversized command, search and read output from flooding the model:
3739
+ * with `output.spillDir` set it saves the full output to a file and returns
3740
+ * the part the policy keeps plus a pointer to it. One instance per ops layer;
3741
+ * it remembers the files it wrote so `dispose()` can remove them.
3742
+ */
3743
+ var OutputSpiller = class {
3744
+ constructor(backend, policy) {
3745
+ this.backend = backend;
3746
+ this.policy = policy;
3747
+ this.created = [];
3748
+ this.dir = resolveSpillDir(policy);
3749
+ if (this.dir !== void 0 && this.retentionMs > 0) this.sweep();
3750
+ }
3751
+ /** True when `output.spillDir` is set. */
3752
+ get enabled() {
3753
+ return this.dir !== void 0;
3754
+ }
3755
+ /** The spill directory, when spilling is on. */
3756
+ get spillDir() {
3757
+ return this.dir;
3758
+ }
3759
+ get retentionMs() {
3760
+ return this.policy.output?.retentionMs ?? DEFAULT_RETENTION_MS;
3761
+ }
3762
+ limits(cap) {
3763
+ const configured = this.policy.output?.maxBytes ?? DEFAULT_MAX_BYTES;
3764
+ return {
3765
+ maxLines: Math.max(1, Math.floor(this.policy.output?.maxLines ?? DEFAULT_MAX_LINES)),
3766
+ maxBytes: Math.max(1, Math.floor(cap === void 0 ? configured : Math.min(configured, cap)))
3767
+ };
3768
+ }
3769
+ /** Remove spill files older than the retention (only files named like ours). */
3770
+ async sweep() {
3771
+ if (this.dir === void 0) return;
3772
+ try {
3773
+ const now = Date.now();
3774
+ for (const file of await this.backend.list(this.dir)) {
3775
+ const match = SPILL_NAME.exec(node_path.default.basename(file));
3776
+ if (match !== null && now - Number(match[1]) > this.retentionMs) await this.backend.remove(file).catch(() => void 0);
3777
+ }
3778
+ } catch {}
3779
+ }
3780
+ /** Write `content` to a new spill file and return its absolute path. */
3781
+ async write(label, content) {
3782
+ if (this.dir === void 0) return;
3783
+ try {
3784
+ if (this.retentionMs > 0) await this.sweep();
3785
+ const safe = label.toLowerCase().replace(/[^a-z0-9_-]+/g, "-") || "output";
3786
+ const rand = Math.random().toString(36).slice(2, 8) || "x";
3787
+ const file = node_path.default.join(this.dir, `spill-${Date.now()}-${rand}-${safe}.txt`);
3788
+ await this.backend.mkdir(this.dir);
3789
+ await this.backend.writeFile(file, content);
3790
+ this.created.push(file);
3791
+ return file;
3792
+ } catch {
3793
+ return;
3794
+ }
3795
+ }
3796
+ /**
3797
+ * Fit `value` to the policy. Returns it untouched when it fits. Over the
3798
+ * limits with spilling on, the whole text is saved and the kept lines come
3799
+ * back with a `[showing lines … of …; full output saved to …]` marker.
3800
+ * `cap` is a tighter byte limit from elsewhere (`shell.maxOutputBytes`).
3801
+ */
3802
+ async text(label, value, defaultKeep, cap) {
3803
+ if (this.dir === void 0) return { value };
3804
+ const { maxLines, maxBytes } = this.limits(cap);
3805
+ const lines = splitLines(value);
3806
+ const totalBytes = byteLength(value);
3807
+ if (lines.length <= maxLines && totalBytes <= maxBytes) return { value };
3808
+ const { head, tail } = select(lines, this.policy.output?.keep ?? defaultKeep, maxLines, maxBytes);
3809
+ const file = await this.write(label, value);
3810
+ const shown = head.length + tail.length;
3811
+ const total = lines.length;
3812
+ const where = file === void 0 ? "the rest was dropped" : `full output saved to ${file}`;
3813
+ 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}]`;
3814
+ const parts = [...head.length > 0 ? [head.join("\n")] : [], marker];
3815
+ if (tail.length > 0) parts.push(tail.join("\n"));
3816
+ return {
3817
+ value: parts.join("\n"),
3818
+ truncation: {
3819
+ shown: { lines: shown },
3820
+ total: {
3821
+ lines: total,
3822
+ bytes: totalBytes
3823
+ },
3824
+ ...file !== void 0 ? { path: file } : {}
3825
+ }
3826
+ };
3827
+ }
3828
+ /**
3829
+ * Fit a list of entries (one line each when rendered) to the policy; the
3830
+ * rendered full list is what gets saved.
3831
+ */
3832
+ async list(label, items, render, defaultKeep) {
3833
+ if (this.dir === void 0) return { items };
3834
+ const { maxLines, maxBytes } = this.limits(void 0);
3835
+ const lines = items.map(render);
3836
+ const totalBytes = lines.reduce((sum, line) => sum + byteLength(line) + 1, 0);
3837
+ if (items.length <= maxLines && totalBytes <= maxBytes) return { items };
3838
+ const { head, tail } = select(lines, this.policy.output?.keep ?? defaultKeep, maxLines, maxBytes);
3839
+ const headCount = head.length;
3840
+ const tailCount = tail.length;
3841
+ const file = await this.write(label, lines.join("\n") + "\n");
3842
+ return {
3843
+ items: [...items.slice(0, headCount), ...items.slice(items.length - tailCount)],
3844
+ truncation: {
3845
+ shown: { lines: headCount + tailCount },
3846
+ total: {
3847
+ lines: items.length,
3848
+ bytes: totalBytes
3849
+ },
3850
+ ...file !== void 0 ? { path: file } : {}
3851
+ }
3852
+ };
3853
+ }
3854
+ /** Save the complete text of an output that was already cut elsewhere. */
3855
+ async save(label, content) {
3856
+ return this.write(label, content);
3857
+ }
3858
+ /** Remove expired files, and this run's own when `retentionMs` is `0`. */
3859
+ async dispose() {
3860
+ if (this.dir === void 0) return;
3861
+ if (this.retentionMs === 0) {
3862
+ for (const file of this.created.splice(0)) await this.backend.remove(file).catch(() => void 0);
3863
+ return;
3864
+ }
3865
+ await this.sweep();
3866
+ }
3867
+ };
3868
+
2391
3869
  //#endregion
2392
3870
  //#region ../../packages/ai-workspace/src/shell-jobs.ts
2393
3871
  /** Finished commands kept for a last `shell_output` read before the oldest are pruned. */
@@ -2764,6 +4242,26 @@ var Ops = class {
2764
4242
  this.policy = policy;
2765
4243
  this.runtime = runtime;
2766
4244
  this.pathLocks = /* @__PURE__ */ new Map();
4245
+ this.resolvedShell = resolveShell(policy.shell);
4246
+ this.approvals = runtime.approvals ?? [];
4247
+ this.spiller = new OutputSpiller(backend, policy);
4248
+ }
4249
+ /** The edit-policy settings applied to every replacement. */
4250
+ editOptions() {
4251
+ const edit = this.policy.edit;
4252
+ return {
4253
+ matching: edit?.matching ?? "tiered",
4254
+ missHintLines: edit?.missHintLines ?? 12,
4255
+ lineEndings: edit?.lineEndings ?? "preserve",
4256
+ ...this.policy.read?.maxBytes !== void 0 ? { hintMaxBytes: this.policy.read.maxBytes } : {}
4257
+ };
4258
+ }
4259
+ /** Run `afterEdit` for a call that wrote `paths`; `undefined` when there is no output. */
4260
+ diagnosticsFor(tool, paths) {
4261
+ return runAfterEdit(this.policy, {
4262
+ paths,
4263
+ tool
4264
+ });
2767
4265
  }
2768
4266
  /** Run `task` after every earlier mutation of `absolutePath` has settled. */
2769
4267
  async withPathLock(absolutePath, task) {
@@ -2808,7 +4306,7 @@ var Ops = class {
2808
4306
  }
2809
4307
  }
2810
4308
  async readFile(inputPath, opts) {
2811
- const { absolutePath } = await resolveInJail(this.policy, inputPath);
4309
+ const { absolutePath } = await resolveInJail(this.policy, inputPath, "read");
2812
4310
  const raw = await this.backend.readFile(absolutePath);
2813
4311
  const hash = _warlock_js_fs.fs.hash.string(raw);
2814
4312
  const lines = raw.split("\n");
@@ -2817,10 +4315,29 @@ var Ops = class {
2817
4315
  const limit = opts?.limit ?? this.policy.read?.defaultLines ?? DEFAULT_READ_LINES;
2818
4316
  const maxBytes = this.policy.read?.maxBytes;
2819
4317
  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)));
4318
+ const window = lines.slice(startIndex, startIndex + Math.max(0, limit));
4319
+ const { content, returnedLines, bytesCapped } = numberWindow(window, offset, maxBytes === void 0 ? void 0 : Math.max(0, Math.floor(maxBytes)));
2821
4320
  const endLine = offset + returnedLines - 1;
4321
+ const spillDir = this.spiller.spillDir;
4322
+ let spilled;
4323
+ if (bytesCapped && spillDir !== void 0 && !isInside(absolutePath, spillDir)) {
4324
+ const full = numberWindow(window, offset, void 0).content;
4325
+ const file = await this.spiller.save("read_file", full);
4326
+ if (file !== void 0) spilled = {
4327
+ content: `${content}\n[showing lines 1-${returnedLines} of ${window.length}; full output saved to ${file}]`,
4328
+ truncation: {
4329
+ shown: { lines: returnedLines },
4330
+ total: {
4331
+ lines: window.length,
4332
+ bytes: Buffer.byteLength(full, "utf8")
4333
+ },
4334
+ path: file
4335
+ }
4336
+ };
4337
+ }
2822
4338
  return {
2823
- content,
4339
+ content: spilled?.content ?? content,
4340
+ ...spilled !== void 0 ? { truncation: spilled.truncation } : {},
2824
4341
  startLine: offset,
2825
4342
  endLine,
2826
4343
  totalLines,
@@ -2831,8 +4348,8 @@ var Ops = class {
2831
4348
  };
2832
4349
  }
2833
4350
  async writeFile(inputPath, content) {
2834
- const { absolutePath } = await resolveInJail(this.policy, inputPath);
2835
- return this.withPathLock(absolutePath, async () => {
4351
+ const { absolutePath, relativePath } = await resolveInJail(this.policy, inputPath);
4352
+ const written = await this.withPathLock(absolutePath, async () => {
2836
4353
  const parent = node_path.default.dirname(absolutePath);
2837
4354
  await this.backend.mkdir(parent);
2838
4355
  await this.backend.writeFile(absolutePath, content);
@@ -2841,10 +4358,20 @@ var Ops = class {
2841
4358
  bytesWritten: Buffer.byteLength(content, "utf8")
2842
4359
  };
2843
4360
  });
4361
+ const diagnostics = await this.diagnosticsFor("write_file", [relativePath || inputPath]);
4362
+ return {
4363
+ ...written,
4364
+ ...diagnostics !== void 0 ? { diagnostics } : {}
4365
+ };
2844
4366
  }
2845
4367
  async editFile(input) {
2846
4368
  const { absolutePath, relativePath } = await resolveInJail(this.policy, input.path);
2847
- return this.withPathLock(absolutePath, () => this.applyEdit(input, absolutePath, relativePath));
4369
+ const result = await this.withPathLock(absolutePath, () => this.applyEdit(input, absolutePath, relativePath));
4370
+ const diagnostics = await this.diagnosticsFor("edit_file", [result.path]);
4371
+ return diagnostics === void 0 ? result : {
4372
+ ...result,
4373
+ diagnostics
4374
+ };
2848
4375
  }
2849
4376
  /** The read-check-replace-write of {@link editFile}, run under its path lock. */
2850
4377
  async applyEdit(input, absolutePath, relativePath) {
@@ -2856,19 +4383,20 @@ var Ops = class {
2856
4383
  expectedHash: input.expectHash,
2857
4384
  actualHash: currentHash
2858
4385
  });
2859
- const { content: updated, replacements } = applyReplacement(current, input, input.path, relativePath || input.path);
4386
+ const { content: updated, replacements, matchTier } = applyReplacement(current, input, input.path, relativePath || input.path, this.editOptions());
2860
4387
  await this.backend.writeFile(absolutePath, updated);
2861
4388
  return {
2862
4389
  path: relativePath || input.path,
2863
4390
  replacements,
2864
- hash: _warlock_js_fs.fs.hash.string(updated)
4391
+ hash: _warlock_js_fs.fs.hash.string(updated),
4392
+ ...matchTier !== void 0 ? { matchTier } : {}
2865
4393
  };
2866
4394
  }
2867
4395
  async multiEdit(input) {
2868
4396
  const { absolutePath, relativePath } = await resolveInJail(this.policy, input.path);
2869
4397
  const shownPath = relativePath || input.path;
2870
4398
  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 () => {
4399
+ const result = await this.withPathLock(absolutePath, async () => {
2872
4400
  const current = await this.backend.readFile(absolutePath);
2873
4401
  const currentHash = _warlock_js_fs.fs.hash.string(current);
2874
4402
  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 +4407,40 @@ var Ops = class {
2879
4407
  });
2880
4408
  let content = current;
2881
4409
  let replacements = 0;
4410
+ let matchTier;
4411
+ const options = this.editOptions();
2882
4412
  input.edits.forEach((edit, editIndex) => {
2883
- const applied = applyReplacement(content, edit, input.path, shownPath, editIndex);
4413
+ const applied = applyReplacement(content, edit, input.path, shownPath, options, editIndex);
2884
4414
  content = applied.content;
2885
4415
  replacements += applied.replacements;
4416
+ if (applied.matchTier === "whitespace" || matchTier === void 0) matchTier = applied.matchTier ?? matchTier;
2886
4417
  });
2887
4418
  await this.backend.writeFile(absolutePath, content);
2888
4419
  return {
2889
4420
  path: shownPath,
2890
4421
  edits: input.edits.length,
2891
4422
  replacements,
2892
- hash: _warlock_js_fs.fs.hash.string(content)
4423
+ hash: _warlock_js_fs.fs.hash.string(content),
4424
+ ...matchTier !== void 0 ? { matchTier } : {}
2893
4425
  };
2894
4426
  });
4427
+ const diagnostics = await this.diagnosticsFor("multi_edit", [shownPath]);
4428
+ return diagnostics === void 0 ? result : {
4429
+ ...result,
4430
+ diagnostics
4431
+ };
2895
4432
  }
2896
4433
  async applyPatch(input) {
4434
+ const result = await this.runPatch(input);
4435
+ if (result.dryRun) return result;
4436
+ const paths = result.files.filter((file) => file.action !== "deleted").map((file) => file.path);
4437
+ const diagnostics = await this.diagnosticsFor("apply_patch", paths);
4438
+ return diagnostics === void 0 ? result : {
4439
+ ...result,
4440
+ diagnostics
4441
+ };
4442
+ }
4443
+ runPatch(input) {
2897
4444
  return executePatch(input, {
2898
4445
  resolve: (inputPath) => resolveInJail(this.policy, inputPath),
2899
4446
  exists: (absolutePath) => this.backend.exists(absolutePath),
@@ -2913,9 +4460,14 @@ var Ops = class {
2913
4460
  * (argv or shell mode), then — shell mode — every redirection target and
2914
4461
  * `cd` directory against the jail with symlinks resolved.
2915
4462
  */
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}`, {
4463
+ async assertCommandAllowed(command, signal) {
4464
+ const verdict = checkShellCommand(this.policy, command, {
4465
+ remembered: this.approvals,
4466
+ resolved: this.resolvedShell
4467
+ });
4468
+ const onAsk = this.policy.shell?.onAsk;
4469
+ const needsApproval = !verdict.allowed && verdict.reason === "approval-required" && verdict.asks.length > 0;
4470
+ if (!verdict.allowed && !(needsApproval && onAsk !== void 0)) throw new WorkspacePolicyError(verdict.message ?? `Command is not permitted by the workspace shell policy: ${command}`, {
2919
4471
  type: "denied-command",
2920
4472
  command,
2921
4473
  ...verdict.reason !== void 0 ? { reason: verdict.reason } : {},
@@ -2938,9 +4490,29 @@ var Ops = class {
2938
4490
  reason: "not-allowed",
2939
4491
  command
2940
4492
  });
4493
+ if (needsApproval && onAsk !== void 0) await this.askForApproval(command, verdict.asks, onAsk, signal);
4494
+ }
4495
+ /** Put the commands that need approval to `shell.onAsk`; refuse the line unless it says yes. */
4496
+ async askForApproval(command, asks, onAsk, signal) {
4497
+ let decision;
4498
+ try {
4499
+ decision = await onAsk({
4500
+ command,
4501
+ asks,
4502
+ ...signal ? { signal } : {}
4503
+ });
4504
+ } catch {
4505
+ decision = void 0;
4506
+ }
4507
+ if (decision?.allow !== true) throw new WorkspacePolicyError(`Running ${asks.map((ask) => `\`${ask.argv.join(" ")}\``).join(", ")} was not approved.`, {
4508
+ type: "denied-command",
4509
+ reason: "approval-denied",
4510
+ command
4511
+ });
4512
+ if (decision.remember !== void 0 && !this.approvals.includes(decision.remember)) this.approvals.push(decision.remember);
2941
4513
  }
2942
4514
  async exec(command, opts) {
2943
- await this.assertCommandAllowed(command);
4515
+ await this.assertCommandAllowed(command, opts?.signal);
2944
4516
  const signal = opts?.signal;
2945
4517
  if (signal?.aborted) return {
2946
4518
  exitCode: CANCELLED_BEFORE_START_EXIT_CODE,
@@ -2953,24 +4525,27 @@ var Ops = class {
2953
4525
  const shell = this.policy.shell;
2954
4526
  const timeoutMs = opts?.timeoutMs ?? shell?.timeoutMs;
2955
4527
  const maxOutputBytes = shell?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
2956
- const interpreter = shell?.interpreter ?? "none";
4528
+ const interpreter = this.resolvedShell.interpreter;
2957
4529
  const result = await this.backend.exec(command, {
2958
4530
  cwd: this.policy.cwd,
2959
4531
  timeoutMs,
2960
4532
  env: buildCommandEnv(this.policy),
2961
4533
  ...signal ? { signal } : {},
2962
- ...interpreter !== "none" ? { interpreter } : {}
4534
+ ...interpreter !== "none" ? { interpreter } : {},
4535
+ ...interpreter !== "none" && this.resolvedShell.file !== void 0 ? { interpreterPath: this.resolvedShell.file } : {}
2963
4536
  });
2964
- const stdout = capOutput(result.stdout, maxOutputBytes);
2965
- const stderr = capOutput(result.stderr, maxOutputBytes);
4537
+ const stdout = await this.fitStream("run_shell-stdout", result.stdout, maxOutputBytes);
4538
+ const stderr = await this.fitStream("run_shell-stderr", result.stderr, maxOutputBytes);
2966
4539
  const cancelled = result.cancelled === true;
4540
+ const truncation = streamTruncation(stdout.truncation, stderr.truncation);
2967
4541
  return {
2968
4542
  exitCode: result.exitCode,
2969
4543
  stdout: stdout.value,
2970
4544
  stderr: stderr.value,
2971
4545
  truncated: stdout.truncated || stderr.truncated,
2972
4546
  timedOut: result.timedOut && !cancelled,
2973
- cancelled
4547
+ cancelled,
4548
+ ...truncation !== void 0 ? { truncation } : {}
2974
4549
  };
2975
4550
  }
2976
4551
  async startShell(command, opts) {
@@ -2988,7 +4563,8 @@ var Ops = class {
2988
4563
  command
2989
4564
  });
2990
4565
  const shell = this.policy.shell;
2991
- const interpreter = shell?.interpreter ?? "none";
4566
+ const interpreter = this.resolvedShell.interpreter;
4567
+ const interpreterPath = interpreter !== "none" ? this.resolvedShell.file : void 0;
2992
4568
  const timeoutMs = lowerOf(background.timeoutMs, opts?.timeoutMs);
2993
4569
  const env = buildCommandEnv(this.policy);
2994
4570
  return this.runtime.jobs.start({
@@ -3000,12 +4576,51 @@ var Ops = class {
3000
4576
  cwd: this.policy.cwd,
3001
4577
  env,
3002
4578
  ...interpreter !== "none" ? { interpreter } : {},
4579
+ ...interpreterPath !== void 0 ? { interpreterPath } : {},
3003
4580
  onOutput
3004
4581
  })
3005
4582
  });
3006
4583
  }
3007
- shellOutput(id, opts) {
3008
- return this.runtime.jobs.output(id, opts);
4584
+ async shellOutput(id, opts) {
4585
+ const result = await this.runtime.jobs.output(id, opts);
4586
+ if (!this.spiller.enabled) return result;
4587
+ const stdout = await this.spiller.text(`shell_output-${id}-stdout`, result.stdout, "head+tail");
4588
+ const stderr = await this.spiller.text(`shell_output-${id}-stderr`, result.stderr, "head+tail");
4589
+ const truncation = streamTruncation(stdout.truncation, stderr.truncation);
4590
+ return truncation === void 0 ? result : {
4591
+ ...result,
4592
+ stdout: stdout.value,
4593
+ stderr: stderr.value,
4594
+ truncated: true,
4595
+ truncation
4596
+ };
4597
+ }
4598
+ /**
4599
+ * Fit one captured stream to the limits: spilled to a file when
4600
+ * `output.spillDir` is set, otherwise clipped at `maxBytes` as always.
4601
+ */
4602
+ async fitStream(label, value, maxBytes) {
4603
+ if (this.spiller.enabled) {
4604
+ const cap = this.policy.shell?.maxOutputBytes;
4605
+ const spilled = await this.spiller.text(label, value, "head+tail", cap);
4606
+ if (spilled.truncation !== void 0) return {
4607
+ value: spilled.value,
4608
+ truncated: true,
4609
+ truncation: spilled.truncation
4610
+ };
4611
+ }
4612
+ const capped = capOutput(value, maxBytes);
4613
+ if (!capped.truncated) return capped;
4614
+ return {
4615
+ ...capped,
4616
+ truncation: {
4617
+ shown: { lines: splitLines(capped.value).length },
4618
+ total: {
4619
+ lines: splitLines(value).length,
4620
+ bytes: Buffer.byteLength(value, "utf8")
4621
+ }
4622
+ }
4623
+ };
3009
4624
  }
3010
4625
  shellKill(id) {
3011
4626
  return this.runtime.jobs.kill(id);
@@ -3013,8 +4628,9 @@ var Ops = class {
3013
4628
  listShells() {
3014
4629
  return this.runtime.jobs.list();
3015
4630
  }
3016
- dispose() {
3017
- return this.runtime.jobs.dispose();
4631
+ async dispose() {
4632
+ await this.runtime.jobs.dispose();
4633
+ await this.spiller.dispose();
3018
4634
  }
3019
4635
  async listDir(inputPath = ".", opts) {
3020
4636
  const { absolutePath: root, relativePath: rootRelative } = await resolveInJail(this.policy, inputPath);
@@ -3118,16 +4734,32 @@ var Ops = class {
3118
4734
  line: index + 1,
3119
4735
  text: line
3120
4736
  });
3121
- if (matches.length >= DEFAULT_MAX_GREP_MATCHES) return {
3122
- matches,
3123
- total: matches.length
3124
- };
4737
+ if (matches.length >= DEFAULT_MAX_GREP_MATCHES) return this.fitMatches(matches);
3125
4738
  }
3126
4739
  }
3127
4740
  }
3128
- return {
4741
+ return this.fitMatches(matches);
4742
+ }
4743
+ /** Apply the `output` limits to a finished match list. */
4744
+ async fitMatches(matches) {
4745
+ const fitted = await this.spiller.list("grep", matches, (match) => `${match.path}:${match.line}:${match.text}`, "head");
4746
+ return fitted.truncation === void 0 ? {
3129
4747
  matches,
3130
4748
  total: matches.length
4749
+ } : {
4750
+ matches: fitted.items,
4751
+ total: matches.length,
4752
+ truncated: true,
4753
+ truncation: fitted.truncation
4754
+ };
4755
+ }
4756
+ async globResult(pattern) {
4757
+ const all = await this.glob(pattern);
4758
+ const fitted = await this.spiller.list("glob", all, (entry) => entry, "head");
4759
+ return fitted.truncation === void 0 ? { paths: all } : {
4760
+ paths: fitted.items,
4761
+ truncated: true,
4762
+ truncation: fitted.truncation
3131
4763
  };
3132
4764
  }
3133
4765
  async glob(pattern) {
@@ -3160,57 +4792,20 @@ var Ops = class {
3160
4792
  return denyPaths !== void 0 && denyPaths.length > 0 && matchesAnyGlob(relativePath, denyPaths);
3161
4793
  }
3162
4794
  };
4795
+ /** Combine per-stream truncation metadata; `undefined` when neither stream was clipped. */
4796
+ function streamTruncation(stdout, stderr) {
4797
+ if (stdout === void 0 && stderr === void 0) return;
4798
+ return {
4799
+ ...stdout !== void 0 ? { stdout } : {},
4800
+ ...stderr !== void 0 ? { stderr } : {}
4801
+ };
4802
+ }
3163
4803
  /** The lower of two optional limits (either may be absent). */
3164
4804
  function lowerOf(policyLimit, requested) {
3165
4805
  if (requested === void 0 || requested <= 0) return policyLimit;
3166
4806
  return policyLimit === void 0 ? requested : Math.min(policyLimit, requested);
3167
4807
  }
3168
4808
  /**
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
- });
3189
- return {
3190
- content: edit.replaceAll ? content.split(edit.oldString).join(edit.newString) : replaceFirst(content, edit.oldString, edit.newString),
3191
- replacements: edit.replaceAll ? occurrences : 1
3192
- };
3193
- }
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);
3212
- }
3213
- /**
3214
4809
  * Create the policy-enforced operation layer over a backend.
3215
4810
  *
3216
4811
  * The returned {@link WorkspaceOps} is the single seam both the
@@ -3337,25 +4932,39 @@ const POSIX_SHELL = "/bin/sh";
3337
4932
  * `/s` keeps the line verbatim inside the outer quotes). The ops layer has
3338
4933
  * already checked every command in the line.
3339
4934
  */
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
- }
4935
+ function planSpawn(command, interpreter, interpreterPath, hostPlatform = node_process.platform) {
4936
+ if (interpreter === "powershell") return {
4937
+ file: interpreterPath ?? (hostPlatform === "win32" ? "powershell.exe" : "pwsh"),
4938
+ args: [
4939
+ "-NoLogo",
4940
+ "-NoProfile",
4941
+ "-NonInteractive",
4942
+ "-Command",
4943
+ command
4944
+ ],
4945
+ windowsVerbatimArguments: false
4946
+ };
4947
+ if (interpreter === "bash") return {
4948
+ file: interpreterPath ?? "bash",
4949
+ args: ["-c", command],
4950
+ windowsVerbatimArguments: false
4951
+ };
4952
+ if (interpreter === "cmd" || interpreter === "sh" && hostPlatform === "win32") return {
4953
+ file: interpreterPath ?? process.env.ComSpec ?? "cmd.exe",
4954
+ args: [
4955
+ "/d",
4956
+ "/v:off",
4957
+ "/s",
4958
+ "/c",
4959
+ `"${command}"`
4960
+ ],
4961
+ windowsVerbatimArguments: true
4962
+ };
4963
+ if (interpreter === "sh") return {
4964
+ file: interpreterPath ?? POSIX_SHELL,
4965
+ args: ["-c", command],
4966
+ windowsVerbatimArguments: false
4967
+ };
3359
4968
  const argv = tokenizeCommand(command);
3360
4969
  const executable = argv?.[0];
3361
4970
  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 +5120,7 @@ var LocalBackend = class {
3511
5120
  });
3512
5121
  return;
3513
5122
  }
3514
- const plan = planSpawn(command, opts.interpreter ?? "none");
5123
+ const plan = planSpawn(command, opts.interpreter ?? "none", opts.interpreterPath);
3515
5124
  if ("refused" in plan) {
3516
5125
  refuse(plan.refused);
3517
5126
  return;
@@ -3585,7 +5194,7 @@ var LocalBackend = class {
3585
5194
  * still-running tree is also killed when the Node process exits.
3586
5195
  */
3587
5196
  start(command, opts) {
3588
- const plan = planSpawn(command, opts.interpreter ?? "none");
5197
+ const plan = planSpawn(command, opts.interpreter ?? "none", opts.interpreterPath);
3589
5198
  if ("refused" in plan) {
3590
5199
  opts.onOutput("stderr", plan.refused);
3591
5200
  return {
@@ -4310,6 +5919,7 @@ function makeGlobTool(ops, options) {
4310
5919
  action: (input) => `Finding files matching ${input.pattern}`,
4311
5920
  input: globInputSchema,
4312
5921
  async execute(input) {
5922
+ if (ops.globResult !== void 0) return ops.globResult(input.pattern);
4313
5923
  return { paths: await ops.glob(input.pattern) };
4314
5924
  }
4315
5925
  });
@@ -4605,6 +6215,23 @@ function runShellInputSchema(background) {
4605
6215
  }
4606
6216
  } }, runShellJsonSchema(background));
4607
6217
  }
6218
+ const DIALECT_RULES = {
6219
+ 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.",
6220
+ 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.",
6221
+ 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."
6222
+ };
6223
+ const PLATFORM_NAMES = {
6224
+ win32: "Windows",
6225
+ darwin: "macOS",
6226
+ linux: "Linux"
6227
+ };
6228
+ /** The sentence that names the shell and its syntax, or `""` in argv mode. */
6229
+ function shellSentence(options) {
6230
+ const dialect = options?.shellDialect;
6231
+ if (options?.shellLabel === void 0 || dialect === void 0 || dialect === "none") return "";
6232
+ const platform = options.platform ?? process.platform;
6233
+ return ` Commands run on ${PLATFORM_NAMES[platform] ?? platform} using ${options.shellLabel}. ` + DIALECT_RULES[dialect];
6234
+ }
4608
6235
  /** The lower of the model's timeout and the cap (either may be absent). */
4609
6236
  function cappedTimeout(requested, cap) {
4610
6237
  if (requested === void 0) return cap;
@@ -4614,7 +6241,7 @@ function makeRunShellTool(ops, options) {
4614
6241
  const background = options?.background === true;
4615
6242
  return (0, _warlock_js_ai.tool)({
4616
6243
  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." : ""),
6244
+ 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
6245
  action: (input) => `Running \`${input.command}\``,
4619
6246
  input: runShellInputSchema(background),
4620
6247
  execute: async (input, ctx) => {
@@ -4674,8 +6301,9 @@ const runTestsInputSchema = withJsonSchema({ "~standard": {
4674
6301
  }
4675
6302
  } }, RUN_TESTS_JSON_SCHEMA);
4676
6303
  /** 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, "'\\''")}'`;
6304
+ function quotePattern(pattern, interpreter, dialect, platform = process.platform) {
6305
+ if (dialect === "powershell" || interpreter === "powershell") return `'${pattern.replace(/['\u2018\u2019\u201A\u201B]/g, "$&$&")}'`;
6306
+ if (interpreter === "bash" || interpreter === "sh" && platform !== "win32") return `'${pattern.replace(/'/g, "'\\''")}'`;
4679
6307
  return `"${pattern}"`;
4680
6308
  }
4681
6309
  /**
@@ -4710,7 +6338,7 @@ function makeRunTestsTool(ops, options) {
4710
6338
  action: (input) => input.pattern ? `Running tests matching "${input.pattern}"` : "Running tests",
4711
6339
  input: runTestsInputSchema,
4712
6340
  execute: (input, ctx) => {
4713
- const command = input.pattern ? `${baseCommand} ${quotePattern(input.pattern, options?.interpreter ?? "none")}` : baseCommand;
6341
+ const command = input.pattern ? `${baseCommand} ${quotePattern(input.pattern, options?.interpreter ?? "none", options?.dialect, options?.platform)}` : baseCommand;
4714
6342
  return ops.exec(command, ctx?.signal ? { signal: ctx.signal } : void 0);
4715
6343
  }
4716
6344
  });
@@ -5016,11 +6644,12 @@ function makeWriteFileTool(ops, options) {
5016
6644
  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
6645
  input: inputSchema,
5018
6646
  async execute(input) {
5019
- const { hash, bytesWritten } = await ops.writeFile(input.path, input.content);
6647
+ const { hash, bytesWritten, diagnostics } = await ops.writeFile(input.path, input.content);
5020
6648
  return {
5021
6649
  path: input.path,
5022
6650
  bytesWritten,
5023
- hash
6651
+ hash,
6652
+ ...diagnostics !== void 0 ? { diagnostics } : {}
5024
6653
  };
5025
6654
  }
5026
6655
  });
@@ -5094,10 +6723,16 @@ var WorkspaceImpl = class WorkspaceImpl {
5094
6723
  this.allowedTools = new Set(allowedTools);
5095
6724
  this.state = state ?? {
5096
6725
  jobs: new ShellJobs(),
5097
- todos: new TodoStore()
6726
+ todos: new TodoStore(),
6727
+ approvals: []
5098
6728
  };
6729
+ this.resolvedShell = resolveShell(policy.shell);
6730
+ this.shell = { classify: (line) => classifyShellCommand(line, policy) };
5099
6731
  const backend = selectBackend(policy);
5100
- this.ops = createOps(backend, policy, { jobs: this.state.jobs });
6732
+ this.ops = createOps(backend, policy, {
6733
+ jobs: this.state.jobs,
6734
+ approvals: this.state.approvals
6735
+ });
5101
6736
  this.tools = this.buildTools();
5102
6737
  }
5103
6738
  /**
@@ -5111,7 +6746,7 @@ var WorkspaceImpl = class WorkspaceImpl {
5111
6746
  const erase = (contract) => contract;
5112
6747
  const shell = this.policy.shell;
5113
6748
  const background = shell?.background !== void 0;
5114
- const interpreter = shell?.interpreter ?? "none";
6749
+ const resolvedShell = this.resolvedShell;
5115
6750
  const factories = {
5116
6751
  readFile: (opts) => erase(makeReadFileTool(this.ops, opts)),
5117
6752
  editFile: (opts) => erase(makeEditFileTool(this.ops, opts)),
@@ -5122,13 +6757,16 @@ var WorkspaceImpl = class WorkspaceImpl {
5122
6757
  runShell: (opts) => erase(makeRunShellTool(this.ops, {
5123
6758
  ...opts?.name !== void 0 ? { name: opts.name } : {},
5124
6759
  ...shell?.timeoutMs !== void 0 ? { maxTimeoutMs: shell.timeoutMs } : {},
5125
- background
6760
+ background,
6761
+ ...resolvedShell.interpreter !== "none" ? { shellLabel: resolvedShell.label } : {},
6762
+ shellDialect: resolvedShell.interpreter === "none" ? "none" : resolvedShell.dialect
5126
6763
  })),
5127
6764
  shellOutput: (opts) => erase(makeShellOutputTool(this.ops, opts)),
5128
6765
  shellKill: (opts) => erase(makeShellKillTool(this.ops, opts)),
5129
6766
  runTests: (opts) => erase(makeRunTestsTool(this.ops, {
5130
6767
  ...opts,
5131
- interpreter
6768
+ interpreter: resolvedShell.interpreter,
6769
+ dialect: resolvedShell.dialect
5132
6770
  })),
5133
6771
  grep: (opts) => erase(makeGrepTool(this.ops, opts)),
5134
6772
  glob: (opts) => erase(makeGlobTool(this.ops, opts)),
@@ -5311,6 +6949,7 @@ exports.applyHunks = applyHunks;
5311
6949
  exports.applyPatchWithOps = applyPatchWithOps;
5312
6950
  exports.buildEnv = buildEnv;
5313
6951
  exports.checkShellCommand = checkShellCommand;
6952
+ exports.classifyShellCommand = classifyShellCommand;
5314
6953
  exports.createLocalBackend = createLocalBackend;
5315
6954
  exports.createMockBackend = createMockBackend;
5316
6955
  exports.createOps = createOps;
@@ -5331,6 +6970,7 @@ exports.makeTodoWriteTool = makeTodoWriteTool;
5331
6970
  exports.makeWriteFileTool = makeWriteFileTool;
5332
6971
  exports.parsePatch = parsePatch;
5333
6972
  exports.resolveInJail = resolveInJail;
6973
+ exports.resolveShell = resolveShell;
5334
6974
  exports.tokenizeCommand = tokenizeCommand;
5335
6975
  exports.workspace = workspace;
5336
6976
  //# sourceMappingURL=index.cjs.map