@warlock.js/ai-workspace 5.30.0 → 5.31.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 (114) hide show
  1. package/CHANGELOG.md +46 -2
  2. package/README.md +34 -21
  3. package/cjs/index.cjs +3116 -309
  4. package/cjs/index.cjs.map +1 -1
  5. package/esm/backends/local.d.mts.map +1 -1
  6. package/esm/backends/local.mjs +165 -27
  7. package/esm/backends/local.mjs.map +1 -1
  8. package/esm/backends/mock.d.mts +11 -5
  9. package/esm/backends/mock.d.mts.map +1 -1
  10. package/esm/backends/mock.mjs +34 -0
  11. package/esm/backends/mock.mjs.map +1 -1
  12. package/esm/contracts/index.d.mts +3 -3
  13. package/esm/contracts/tool-io.type.d.mts +202 -4
  14. package/esm/contracts/tool-io.type.d.mts.map +1 -1
  15. package/esm/contracts/workspace-backend.contract.d.mts +61 -1
  16. package/esm/contracts/workspace-backend.contract.d.mts.map +1 -1
  17. package/esm/contracts/workspace-ops.contract.d.mts +32 -3
  18. package/esm/contracts/workspace-ops.contract.d.mts.map +1 -1
  19. package/esm/contracts/workspace-policy.type.d.mts +133 -12
  20. package/esm/contracts/workspace-policy.type.d.mts.map +1 -1
  21. package/esm/contracts/workspace.contract.d.mts +63 -9
  22. package/esm/contracts/workspace.contract.d.mts.map +1 -1
  23. package/esm/errors.d.mts +79 -5
  24. package/esm/errors.d.mts.map +1 -1
  25. package/esm/errors.mjs +22 -1
  26. package/esm/errors.mjs.map +1 -1
  27. package/esm/index.d.mts +13 -5
  28. package/esm/index.mjs +10 -2
  29. package/esm/ops.d.mts +21 -2
  30. package/esm/ops.d.mts.map +1 -1
  31. package/esm/ops.mjs +230 -64
  32. package/esm/ops.mjs.map +1 -1
  33. package/esm/policy/jail.mjs +73 -0
  34. package/esm/policy/jail.mjs.map +1 -0
  35. package/esm/policy/policy.d.mts +25 -15
  36. package/esm/policy/policy.d.mts.map +1 -1
  37. package/esm/policy/policy.mjs +61 -85
  38. package/esm/policy/policy.mjs.map +1 -1
  39. package/esm/policy/shell-check.d.mts +64 -0
  40. package/esm/policy/shell-check.d.mts.map +1 -0
  41. package/esm/policy/shell-check.mjs +627 -0
  42. package/esm/policy/shell-check.mjs.map +1 -0
  43. package/esm/policy/shell-parser.mjs +802 -0
  44. package/esm/policy/shell-parser.mjs.map +1 -0
  45. package/esm/policy/tokenize-command.d.mts +63 -0
  46. package/esm/policy/tokenize-command.d.mts.map +1 -0
  47. package/esm/policy/tokenize-command.mjs +75 -23
  48. package/esm/policy/tokenize-command.mjs.map +1 -1
  49. package/esm/shell-jobs.d.mts +45 -0
  50. package/esm/shell-jobs.d.mts.map +1 -0
  51. package/esm/shell-jobs.mjs +247 -0
  52. package/esm/shell-jobs.mjs.map +1 -0
  53. package/esm/todos.d.mts +22 -0
  54. package/esm/todos.d.mts.map +1 -0
  55. package/esm/todos.mjs +73 -0
  56. package/esm/todos.mjs.map +1 -0
  57. package/esm/tools/edit-file.d.mts.map +1 -1
  58. package/esm/tools/edit-file.mjs +2 -1
  59. package/esm/tools/edit-file.mjs.map +1 -1
  60. package/esm/tools/glob.d.mts.map +1 -1
  61. package/esm/tools/glob.mjs +2 -1
  62. package/esm/tools/glob.mjs.map +1 -1
  63. package/esm/tools/grep.d.mts.map +1 -1
  64. package/esm/tools/grep.mjs +2 -1
  65. package/esm/tools/grep.mjs.map +1 -1
  66. package/esm/tools/json-schemas.mjs +175 -0
  67. package/esm/tools/json-schemas.mjs.map +1 -0
  68. package/esm/tools/list-dir.d.mts +32 -0
  69. package/esm/tools/list-dir.d.mts.map +1 -0
  70. package/esm/tools/list-dir.mjs +50 -0
  71. package/esm/tools/list-dir.mjs.map +1 -0
  72. package/esm/tools/multi-edit.d.mts +38 -0
  73. package/esm/tools/multi-edit.d.mts.map +1 -0
  74. package/esm/tools/multi-edit.mjs +121 -0
  75. package/esm/tools/multi-edit.mjs.map +1 -0
  76. package/esm/tools/read-file.d.mts.map +1 -1
  77. package/esm/tools/read-file.mjs +2 -1
  78. package/esm/tools/read-file.mjs.map +1 -1
  79. package/esm/tools/run-shell.d.mts +34 -12
  80. package/esm/tools/run-shell.d.mts.map +1 -1
  81. package/esm/tools/run-shell.mjs +59 -53
  82. package/esm/tools/run-shell.mjs.map +1 -1
  83. package/esm/tools/run-tests.d.mts +8 -0
  84. package/esm/tools/run-tests.d.mts.map +1 -1
  85. package/esm/tools/run-tests.mjs +10 -3
  86. package/esm/tools/run-tests.mjs.map +1 -1
  87. package/esm/tools/schema.mjs +23 -18
  88. package/esm/tools/schema.mjs.map +1 -1
  89. package/esm/tools/shell-kill.d.mts +25 -0
  90. package/esm/tools/shell-kill.d.mts.map +1 -0
  91. package/esm/tools/shell-kill.mjs +36 -0
  92. package/esm/tools/shell-kill.mjs.map +1 -0
  93. package/esm/tools/shell-output.d.mts +30 -0
  94. package/esm/tools/shell-output.d.mts.map +1 -0
  95. package/esm/tools/shell-output.mjs +49 -0
  96. package/esm/tools/shell-output.mjs.map +1 -0
  97. package/esm/tools/todo.d.mts +43 -0
  98. package/esm/tools/todo.d.mts.map +1 -0
  99. package/esm/tools/todo.mjs +126 -0
  100. package/esm/tools/todo.mjs.map +1 -0
  101. package/esm/tools/write-file.d.mts.map +1 -1
  102. package/esm/tools/write-file.mjs +2 -1
  103. package/esm/tools/write-file.mjs.map +1 -1
  104. package/esm/workspace.d.mts +11 -8
  105. package/esm/workspace.d.mts.map +1 -1
  106. package/esm/workspace.mjs +108 -27
  107. package/esm/workspace.mjs.map +1 -1
  108. package/llms-full.txt +155 -34
  109. package/llms.txt +1 -1
  110. package/package.json +3 -3
  111. package/skills/README.md +2 -2
  112. package/skills/build-loop-agent/SKILL.md +7 -7
  113. package/skills/index.md +6 -1
  114. package/skills/use-a-workspace/SKILL.md +148 -27
package/cjs/index.cjs CHANGED
@@ -33,6 +33,7 @@ let node_fs_promises = require("node:fs/promises");
33
33
  let _warlock_js_fs = require("@warlock.js/fs");
34
34
  let node_child_process = require("node:child_process");
35
35
  let node_process = require("node:process");
36
+ let node_string_decoder = require("node:string_decoder");
36
37
 
37
38
  //#region ../../packages/ai-workspace/src/errors.ts
38
39
  /**
@@ -60,6 +61,8 @@ var WorkspacePolicyError = class extends _warlock_js_ai.AIError {
60
61
  this.path = options.path;
61
62
  this.command = options.command;
62
63
  this.pattern = options.pattern;
64
+ this.reason = options.reason;
65
+ this.executable = options.executable;
63
66
  }
64
67
  };
65
68
  /**
@@ -85,8 +88,896 @@ var WorkspaceEditError = class extends _warlock_js_ai.AIError {
85
88
  this.matches = options.matches;
86
89
  this.expectedHash = options.expectedHash;
87
90
  this.actualHash = options.actualHash;
91
+ this.editIndex = options.editIndex;
88
92
  }
89
93
  };
94
+ /**
95
+ * A `shell_output` / `shell_kill` call (or `shellOutput()` / `shellKill()`)
96
+ * named a background command this workspace does not know. Returned to the
97
+ * agent as tool-error data like the other workspace errors.
98
+ *
99
+ * @example
100
+ * if (error instanceof WorkspaceJobError && error.type === "not-found") {
101
+ * console.warn(`No background command ${error.jobId}`);
102
+ * }
103
+ */
104
+ var WorkspaceJobError = class extends _warlock_js_ai.AIError {
105
+ constructor(message, options) {
106
+ super("TOOL_EXEC_FAILED", message, options);
107
+ this.name = "WorkspaceJobError";
108
+ this.type = options.type;
109
+ this.jobId = options.jobId;
110
+ }
111
+ };
112
+
113
+ //#endregion
114
+ //#region ../../packages/ai-workspace/src/policy/jail.ts
115
+ /**
116
+ * Whether `child` is contained within `root` (or equals it), comparing
117
+ * absolute paths. Guards against the `/srv/app-evil` vs `/srv/app`
118
+ * prefix-collision by comparing path segments, not string prefixes.
119
+ */
120
+ function isInside(child, root) {
121
+ const relative = node_path.default.relative(root, child);
122
+ return relative === "" || !relative.startsWith("..") && !node_path.default.isAbsolute(relative);
123
+ }
124
+ /**
125
+ * Translate a glob (the small subset used by `denyPaths` and
126
+ * `list.ignore` — `*`, `**`, `?`) into an anchored `RegExp` over a
127
+ * `/`-separated relative path. `**` spans path separators; a single `*`
128
+ * does not.
129
+ */
130
+ function globToRegExp(glob) {
131
+ let source = "";
132
+ for (let index = 0; index < glob.length; index++) {
133
+ const char = glob.charAt(index);
134
+ if (char === "*") {
135
+ if (glob.charAt(index + 1) === "*") {
136
+ source += ".*";
137
+ index++;
138
+ if (glob.charAt(index + 1) === "/") index++;
139
+ } else source += "[^/]*";
140
+ continue;
141
+ }
142
+ if (char === "?") {
143
+ source += "[^/]";
144
+ continue;
145
+ }
146
+ source += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
147
+ }
148
+ return new RegExp(`^${source}$`);
149
+ }
150
+ /**
151
+ * Whether a workspace-relative (`/`-separated) path matches any of the
152
+ * globs. A glob also covers everything beneath a matched directory
153
+ * (`".git/**"` covers `.git/config`), and a bare name without wildcards
154
+ * (`".git"`, `"node_modules"`) covers its contents.
155
+ */
156
+ function matchesAnyGlob(relativePath, globs) {
157
+ return globs.some((glob) => {
158
+ if (globToRegExp(glob).test(relativePath)) return true;
159
+ if (!glob.includes("*") && !glob.includes("?")) {
160
+ const prefix = glob.endsWith("/") ? glob : `${glob}/`;
161
+ return relativePath.startsWith(prefix);
162
+ }
163
+ return false;
164
+ });
165
+ }
166
+ /**
167
+ * The jail check without touching the filesystem: is the absolute path
168
+ * `target` (already resolved lexically) inside `cwd` and off every
169
+ * `denyPaths` glob, or inside an `allowPaths` root? Symlinks are NOT
170
+ * resolved here — the shell policy uses it to refuse obvious escapes
171
+ * synchronously, and `exec` re-checks every path with `resolveInJail`.
172
+ */
173
+ function isInsideJailLexically(policy, target) {
174
+ const root = node_path.default.resolve(policy.cwd);
175
+ if (isInside(target, root)) {
176
+ const relative = node_path.default.relative(root, target).split(node_path.default.sep).join("/");
177
+ return !(policy.denyPaths && policy.denyPaths.length > 0 && matchesAnyGlob(relative, policy.denyPaths));
178
+ }
179
+ return (policy.allowPaths ?? []).some((allowRoot) => isInside(target, node_path.default.resolve(allowRoot)));
180
+ }
181
+
182
+ //#endregion
183
+ //#region ../../packages/ai-workspace/src/policy/shell-parser.ts
184
+ /** Deepest nesting of subshells and substitutions the parser accepts. */
185
+ const MAX_NESTING = 16;
186
+ /**
187
+ * Words that start a compound command (or negate a pipeline) when they
188
+ * appear unquoted in command position. The parser does not read compound
189
+ * commands, so a line using one is refused.
190
+ */
191
+ const RESERVED_WORDS = new Set([
192
+ "!",
193
+ "{",
194
+ "}",
195
+ "[[",
196
+ "]]",
197
+ "case",
198
+ "coproc",
199
+ "do",
200
+ "done",
201
+ "elif",
202
+ "else",
203
+ "esac",
204
+ "fi",
205
+ "for",
206
+ "function",
207
+ "if",
208
+ "in",
209
+ "select",
210
+ "then",
211
+ "until",
212
+ "while"
213
+ ]);
214
+ /** cmd.exe keywords the `"cmd"` dialect refuses in command position. */
215
+ const CMD_RESERVED_WORDS = new Set([
216
+ "if",
217
+ "for",
218
+ "goto",
219
+ "setlocal",
220
+ "endlocal"
221
+ ]);
222
+ /** A parameter name inside `${…}`: a variable, a positional or a special parameter. */
223
+ const PARAMETER_NAME = String.raw`(?:[A-Za-z_][A-Za-z0-9_]*|[0-9]+|[@*#?$!-])`;
224
+ /**
225
+ * Allowed `${…}` forms: `${name}`, `${#name}`, `${name-word}`,
226
+ * `${name:-word}` (also `=`, `+`, `?`), `${name%word}`, `${name%%word}`,
227
+ * `${name#word}`, `${name##word}`. Everything else — substrings and
228
+ * subscripts (arithmetic in bash), `${!name}`, `${name@op}`, pattern
229
+ * substitution — is refused.
230
+ */
231
+ const PARAMETER_EXPANSION = new RegExp(`^(?:#${PARAMETER_NAME}|${PARAMETER_NAME}(?:(?::?[-=+?]|%%?|##?)[^\`\$'"\\\\{}()@\\n]*)?)$`);
232
+ /** A refusal raised while parsing; caught by {@link parseShellLine}. */
233
+ var ShellSyntaxRefusal = class extends Error {
234
+ constructor(reason, message) {
235
+ super(message);
236
+ this.reason = reason;
237
+ }
238
+ };
239
+ /** Refuse a construct the parser does not read with confidence. */
240
+ function unsupported(message) {
241
+ return new ShellSyntaxRefusal("unsupported-syntax", message);
242
+ }
243
+ /** Refuse an unterminated quote / substitution. */
244
+ function unbalanced(message) {
245
+ return new ShellSyntaxRefusal("unbalanced-quotes", message);
246
+ }
247
+ /** Parser for the POSIX `sh` dialect. */
248
+ var PosixParser = class PosixParser {
249
+ constructor(src, state) {
250
+ this.src = src;
251
+ this.state = state;
252
+ this.pos = 0;
253
+ this.pendingHeredocs = [];
254
+ }
255
+ /** Parse the whole line as a command list. */
256
+ parseProgram(segments) {
257
+ this.parseList(null, segments);
258
+ this.assertNoPendingHeredocs();
259
+ }
260
+ /** Parse the contents of a backtick substitution (a full list until EOF). */
261
+ parseNestedProgram() {
262
+ this.parseList(null, void 0);
263
+ this.assertNoPendingHeredocs();
264
+ }
265
+ /**
266
+ * Scan an unquoted-delimiter here-document body: `$…`, `${…}`, `$(…)` and
267
+ * backticks expand there exactly as inside double quotes (but `"` is an
268
+ * ordinary character), so any command substitution in it runs.
269
+ */
270
+ parseExpandingText() {
271
+ while (!this.atEnd()) {
272
+ const char = this.peek();
273
+ if (char === "\\") {
274
+ this.pos += 2;
275
+ continue;
276
+ }
277
+ if (char === "$") {
278
+ this.parseDollar("heredoc");
279
+ continue;
280
+ }
281
+ if (char === "`") {
282
+ this.parseBackticks(false);
283
+ continue;
284
+ }
285
+ this.pos++;
286
+ }
287
+ }
288
+ atEnd() {
289
+ return this.pos >= this.src.length;
290
+ }
291
+ peek(offset = 0) {
292
+ return this.src.charAt(this.pos + offset);
293
+ }
294
+ assertNoPendingHeredocs() {
295
+ if (this.pendingHeredocs.length > 0) throw unsupported(`The here-document delimited by \`${this.pendingHeredocs[0]?.delimiter ?? ""}\` is never terminated.`);
296
+ }
297
+ enterNesting() {
298
+ this.state.nesting++;
299
+ if (this.state.nesting > MAX_NESTING) throw unsupported("The line nests subshells or substitutions too deeply.");
300
+ }
301
+ exitNesting() {
302
+ this.state.nesting--;
303
+ }
304
+ /** Skip spaces, tabs and backslash-newline continuations. */
305
+ skipBlanks() {
306
+ while (!this.atEnd()) {
307
+ const char = this.peek();
308
+ if (char === " " || char === " ") {
309
+ this.pos++;
310
+ continue;
311
+ }
312
+ if (char === "\\" && this.peek(1) === "\n") {
313
+ this.pos += 2;
314
+ continue;
315
+ }
316
+ break;
317
+ }
318
+ }
319
+ /** Skip a `#` comment up to (not including) the newline. */
320
+ skipComment() {
321
+ const newline = this.src.indexOf("\n", this.pos);
322
+ this.pos = newline === -1 ? this.src.length : newline;
323
+ }
324
+ /** Consume one newline and read any here-document bodies queued before it. */
325
+ consumeNewline() {
326
+ this.pos++;
327
+ if (this.pendingHeredocs.length === 0) return;
328
+ if (this.state.nesting > 0) throw unsupported("Here-documents inside subshells or substitutions are not supported.");
329
+ const pending = this.pendingHeredocs;
330
+ this.pendingHeredocs = [];
331
+ for (const heredoc of pending) this.readHeredocBody(heredoc);
332
+ }
333
+ /** Skip blanks, comments and newlines (separators between commands). */
334
+ skipBlanksAndNewlines() {
335
+ while (!this.atEnd()) {
336
+ this.skipBlanks();
337
+ if (this.peek() === "#") {
338
+ this.skipComment();
339
+ continue;
340
+ }
341
+ if (this.peek() === "\n") {
342
+ this.consumeNewline();
343
+ continue;
344
+ }
345
+ break;
346
+ }
347
+ }
348
+ readHeredocBody(heredoc) {
349
+ const bodyLines = [];
350
+ while (true) {
351
+ if (this.atEnd()) throw unsupported(`The here-document delimited by \`${heredoc.delimiter}\` is never terminated.`);
352
+ const lineEnd = this.src.indexOf("\n", this.pos);
353
+ const line = lineEnd === -1 ? this.src.slice(this.pos) : this.src.slice(this.pos, lineEnd);
354
+ const compared = heredoc.stripTabs ? line.replace(/^\t+/, "") : line;
355
+ this.pos = lineEnd === -1 ? this.src.length : lineEnd + 1;
356
+ if (compared === heredoc.delimiter) break;
357
+ bodyLines.push(compared);
358
+ }
359
+ if (heredoc.expand) new PosixParser(bodyLines.join("\n"), this.state).parseExpandingText();
360
+ }
361
+ /**
362
+ * Parse a command list until `closing` (`")"`) or the end of the input.
363
+ * `segments`, when given, collects the top-level pipelines.
364
+ */
365
+ parseList(closing, segments) {
366
+ let sawCommand = false;
367
+ while (true) {
368
+ this.skipBlanksAndNewlines();
369
+ if (this.atEnd()) {
370
+ if (closing !== null) throw unbalanced(`A \`(\` or \`$(\` is never closed with \`${closing}\`.`);
371
+ break;
372
+ }
373
+ if (closing !== null && this.peek() === closing) break;
374
+ const start = this.pos;
375
+ this.parsePipeline(closing);
376
+ const end = this.pos;
377
+ sawCommand = true;
378
+ this.skipBlanks();
379
+ if (this.peek() === "#") this.skipComment();
380
+ const operator = this.readListOperator();
381
+ segments?.push({
382
+ source: this.src.slice(start, end).trim(),
383
+ ...operator !== null ? { operator } : {}
384
+ });
385
+ if (operator === "&&" || operator === "||") {
386
+ this.skipBlanksAndNewlines();
387
+ if (this.atEnd() || closing !== null && this.peek() === closing) throw unsupported(`The line ends with \`${operator}\` but no command follows it.`);
388
+ continue;
389
+ }
390
+ if (operator === "&") this.state.background = true;
391
+ if (operator === null) {
392
+ if (this.atEnd() || closing !== null && this.peek() === closing) break;
393
+ throw unsupported(`Unexpected \`${this.peek()}\`.`);
394
+ }
395
+ }
396
+ if (!sawCommand) throw unsupported(closing === null ? "The command is empty." : "A `( )` or `$( )` is empty.");
397
+ }
398
+ /** Read `&&`, `||`, `;`, `&` or a newline after a pipeline. */
399
+ readListOperator() {
400
+ const char = this.peek();
401
+ const next = this.peek(1);
402
+ if (char === "&") {
403
+ if (next === "&") {
404
+ this.pos += 2;
405
+ return "&&";
406
+ }
407
+ if (next === ">") throw unsupported("`&>` (redirect both streams) is not portable; write `> file 2>&1` instead.");
408
+ this.pos++;
409
+ return "&";
410
+ }
411
+ if (char === "|" && next === "|") {
412
+ this.pos += 2;
413
+ return "||";
414
+ }
415
+ if (char === ";") {
416
+ if (next === ";") throw unsupported("`;;` (a `case` clause) is not supported.");
417
+ this.pos++;
418
+ return ";";
419
+ }
420
+ if (char === "\n") {
421
+ this.consumeNewline();
422
+ return "\n";
423
+ }
424
+ return null;
425
+ }
426
+ parsePipeline(closing) {
427
+ this.parseCommand(closing);
428
+ while (true) {
429
+ this.skipBlanks();
430
+ if (this.peek() !== "|" || this.peek(1) === "|") return;
431
+ if (this.peek(1) === "&") throw unsupported("`|&` is not portable; write `2>&1 |` instead.");
432
+ this.pos++;
433
+ this.skipBlanksAndNewlines();
434
+ if (this.atEnd() || closing !== null && this.peek() === closing) throw unsupported("The line ends with `|` but no command follows it.");
435
+ this.parseCommand(closing);
436
+ }
437
+ }
438
+ parseCommand(closing) {
439
+ this.skipBlanks();
440
+ if (this.peek() === "(") {
441
+ if (this.peek(1) === "(") throw unsupported("`((…))` (arithmetic) is not supported.");
442
+ this.parseSubshell();
443
+ return;
444
+ }
445
+ if (this.peek() === ")") throw unsupported(closing === null ? "Unexpected `)`." : "A `( )` or `$( )` is empty.");
446
+ this.parseSimpleCommand();
447
+ }
448
+ parseSubshell() {
449
+ const start = this.pos;
450
+ this.pos++;
451
+ this.enterNesting();
452
+ this.parseList(")", void 0);
453
+ if (this.peek() !== ")") throw unbalanced("A `(` is never closed with `)`.");
454
+ this.pos++;
455
+ this.exitNesting();
456
+ const redirects = [];
457
+ while (true) {
458
+ this.skipBlanks();
459
+ if (!this.atRedirectStart()) break;
460
+ redirects.push(this.parseRedirect());
461
+ }
462
+ if (redirects.length > 0) this.state.commands.push({
463
+ assignments: [],
464
+ words: [],
465
+ redirects,
466
+ source: this.src.slice(start, this.pos).trim()
467
+ });
468
+ if (!this.atEnd() && !this.atOperatorBoundary()) throw unsupported("Only an operator or a redirection may follow `)`.");
469
+ }
470
+ /** Whether the next character ends a simple command. */
471
+ atOperatorBoundary() {
472
+ const char = this.peek();
473
+ return char === "\n" || char === ";" || char === "&" || char === "|" || char === ")" || char === "#" || char === " " || char === " ";
474
+ }
475
+ /** Whether a redirection starts here (`>`, `<`, or digits glued to one). */
476
+ atRedirectStart() {
477
+ let index = this.pos;
478
+ while (index < this.src.length && /[0-9]/.test(this.src.charAt(index))) index++;
479
+ const char = this.src.charAt(index);
480
+ return char === "<" || char === ">";
481
+ }
482
+ parseSimpleCommand() {
483
+ const start = this.pos;
484
+ const command = {
485
+ assignments: [],
486
+ words: [],
487
+ redirects: [],
488
+ source: ""
489
+ };
490
+ while (true) {
491
+ this.skipBlanks();
492
+ if (this.atEnd()) break;
493
+ const char = this.peek();
494
+ if (char === "\n" || char === ";" || char === "&" || char === "|" || char === ")") break;
495
+ if (char === "(") throw unsupported("`(` is only supported at the start of a command (a subshell); quote it to pass it as text.");
496
+ if (char === "#") {
497
+ this.skipComment();
498
+ break;
499
+ }
500
+ if (this.atRedirectStart()) {
501
+ command.redirects.push(this.parseRedirect());
502
+ continue;
503
+ }
504
+ const word = this.parseWord();
505
+ if (command.words.length === 0 && word.assignmentName !== void 0) {
506
+ command.assignments.push(word);
507
+ continue;
508
+ }
509
+ command.words.push(word);
510
+ if (command.words.length === 1 && !word.quoted && RESERVED_WORDS.has(word.value)) throw unsupported(`\`${word.value}\` starts a shell compound command, which is not supported; run the commands one at a time.`);
511
+ }
512
+ if (command.words.length === 0 && command.assignments.length === 0 && command.redirects.length === 0) throw unsupported("The line has an empty command (two operators in a row?).");
513
+ command.source = this.src.slice(start, this.pos).trim();
514
+ this.state.commands.push(command);
515
+ }
516
+ parseRedirect() {
517
+ let digits = "";
518
+ while (/[0-9]/.test(this.peek())) {
519
+ digits += this.peek();
520
+ this.pos++;
521
+ }
522
+ const rest = this.src.slice(this.pos, this.pos + 3);
523
+ let op;
524
+ if (rest.startsWith("<<<")) throw unsupported("`<<<` (a here-string) is not portable; use a here-document.");
525
+ else if (rest.startsWith("<<-")) op = "<<-";
526
+ else if (rest.startsWith("<<")) op = "<<";
527
+ else if (rest.startsWith("<>")) op = "<>";
528
+ else if (rest.startsWith("<&")) op = "<&";
529
+ else if (rest.startsWith("<(") || rest.startsWith(">(")) throw unsupported("Process substitution (`<(…)`, `>(…)`) is not supported.");
530
+ else if (rest.startsWith(">>")) op = ">>";
531
+ else if (rest.startsWith(">|")) op = ">|";
532
+ else if (rest.startsWith(">&")) op = ">&";
533
+ else op = this.peek();
534
+ this.pos += op.length;
535
+ const fd = digits === "" ? void 0 : Number(digits);
536
+ this.skipBlanks();
537
+ if (this.atEnd() || /[\n;&|<>()]/.test(this.peek())) throw unsupported(`The redirection \`${op}\` has no target.`);
538
+ const target = this.parseWord();
539
+ if (op === "<<" || op === "<<-") {
540
+ if (this.state.nesting > 0) throw unsupported("Here-documents inside subshells or substitutions are not supported.");
541
+ if (target.dynamic) throw unsupported("A here-document delimiter must be literal text.");
542
+ this.pendingHeredocs.push({
543
+ delimiter: target.value,
544
+ stripTabs: op === "<<-",
545
+ expand: !target.quoted
546
+ });
547
+ return {
548
+ op,
549
+ ...fd !== void 0 ? { fd } : {},
550
+ target,
551
+ type: "heredoc"
552
+ };
553
+ }
554
+ if (op === ">&" || op === "<&") {
555
+ if (target.dynamic || !/^(?:[0-9]+|-)$/.test(target.value)) throw unsupported(`\`${op}${target.raw}\` is not portable; write \`> file 2>&1\` to send both streams to a file.`);
556
+ return {
557
+ op,
558
+ ...fd !== void 0 ? { fd } : {},
559
+ target,
560
+ type: "dup"
561
+ };
562
+ }
563
+ return {
564
+ op,
565
+ ...fd !== void 0 ? { fd } : {},
566
+ target,
567
+ type: op === "<" ? "read" : "write"
568
+ };
569
+ }
570
+ /** Parse one unquoted-context word. */
571
+ parseWord() {
572
+ const start = this.pos;
573
+ let value = "";
574
+ let dynamic = false;
575
+ let quoted = false;
576
+ let braces = false;
577
+ let nameCandidate = true;
578
+ let assignmentName;
579
+ while (!this.atEnd()) {
580
+ const char = this.peek();
581
+ if (char === " " || char === " " || char === "\n" || char === ";" || char === "&" || char === "|" || char === "<" || char === ">" || char === "(" || char === ")") break;
582
+ if (char === "\r" || char === "\0") throw unsupported("Carriage returns and NUL bytes are not supported in a command line.");
583
+ if (char === "\\") {
584
+ const next = this.peek(1);
585
+ if (next === "") throw unsupported("The line ends with a backslash.");
586
+ this.pos += 2;
587
+ if (next !== "\n") {
588
+ value += next;
589
+ quoted = true;
590
+ nameCandidate = false;
591
+ }
592
+ continue;
593
+ }
594
+ if (char === "'") {
595
+ const closing = this.src.indexOf("'", this.pos + 1);
596
+ if (closing === -1) throw unbalanced("A `'` quote is never closed.");
597
+ value += this.src.slice(this.pos + 1, closing);
598
+ quoted = true;
599
+ nameCandidate = false;
600
+ this.pos = closing + 1;
601
+ continue;
602
+ }
603
+ if (char === "\"") {
604
+ const part = this.parseDoubleQuoted();
605
+ value += part.value;
606
+ dynamic = dynamic || part.dynamic;
607
+ quoted = true;
608
+ nameCandidate = false;
609
+ continue;
610
+ }
611
+ if (char === "$") {
612
+ const part = this.parseDollar("unquoted");
613
+ value += part.text;
614
+ dynamic = dynamic || part.dynamic;
615
+ nameCandidate = false;
616
+ continue;
617
+ }
618
+ if (char === "`") {
619
+ value += this.parseBackticks(false);
620
+ dynamic = true;
621
+ nameCandidate = false;
622
+ continue;
623
+ }
624
+ if (char === "=" && nameCandidate && assignmentName === void 0) {
625
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) assignmentName = value;
626
+ nameCandidate = false;
627
+ } else if (!/[A-Za-z0-9_]/.test(char)) nameCandidate = false;
628
+ if (char === "*" || char === "?" || char === "[") dynamic = true;
629
+ if (char === "~") {
630
+ const previous = this.src.charAt(this.pos - 1);
631
+ if (this.pos === start || assignmentName !== void 0 && (previous === "=" || previous === ":")) dynamic = true;
632
+ }
633
+ if (char === "{" || char === "}") braces = true;
634
+ value += char;
635
+ this.pos++;
636
+ }
637
+ const raw = this.src.slice(start, this.pos);
638
+ if (braces && raw !== "{}") throw unsupported(`Unquoted \`{\` or \`}\` in \`${raw}\` (brace expansion or a command group) is not supported; quote it.`);
639
+ if (raw === "[" || raw === "]") dynamic = false;
640
+ return {
641
+ raw,
642
+ value,
643
+ dynamic,
644
+ quoted,
645
+ ...assignmentName !== void 0 ? { assignmentName } : {}
646
+ };
647
+ }
648
+ /** Parse a `"…"` span (cursor on the opening quote). */
649
+ parseDoubleQuoted() {
650
+ this.pos++;
651
+ let value = "";
652
+ let dynamic = false;
653
+ while (true) {
654
+ if (this.atEnd()) throw unbalanced("A `\"` quote is never closed.");
655
+ const char = this.peek();
656
+ if (char === "\"") {
657
+ this.pos++;
658
+ return {
659
+ value,
660
+ dynamic
661
+ };
662
+ }
663
+ if (char === "\0") throw unsupported("NUL bytes are not supported in a command line.");
664
+ if (char === "\\") {
665
+ const next = this.peek(1);
666
+ if (next === "") throw unbalanced("A `\"` quote is never closed.");
667
+ if (next === "$" || next === "`" || next === "\"" || next === "\\") {
668
+ value += next;
669
+ this.pos += 2;
670
+ continue;
671
+ }
672
+ if (next === "\n") {
673
+ this.pos += 2;
674
+ continue;
675
+ }
676
+ value += char;
677
+ this.pos++;
678
+ continue;
679
+ }
680
+ if (char === "$") {
681
+ const part = this.parseDollar("dquote");
682
+ value += part.text;
683
+ dynamic = dynamic || part.dynamic;
684
+ continue;
685
+ }
686
+ if (char === "`") {
687
+ value += this.parseBackticks(true);
688
+ dynamic = true;
689
+ continue;
690
+ }
691
+ value += char;
692
+ this.pos++;
693
+ }
694
+ }
695
+ /** Parse a `$…` expansion (cursor on the `$`). */
696
+ parseDollar(context) {
697
+ const start = this.pos;
698
+ const next = this.peek(1);
699
+ if (next === "(") {
700
+ if (this.peek(2) === "(") throw unsupported("`$((…))` (arithmetic expansion) is not supported.");
701
+ this.pos += 2;
702
+ this.enterNesting();
703
+ this.parseList(")", void 0);
704
+ if (this.peek() !== ")") throw unbalanced("A `$(` is never closed with `)`.");
705
+ this.pos++;
706
+ this.exitNesting();
707
+ return {
708
+ text: this.src.slice(start, this.pos),
709
+ dynamic: true
710
+ };
711
+ }
712
+ if (next === "{") {
713
+ const closing = this.src.indexOf("}", this.pos + 2);
714
+ if (closing === -1) throw unbalanced("A `${` is never closed with `}`.");
715
+ const body = this.src.slice(this.pos + 2, closing);
716
+ if (!PARAMETER_EXPANSION.test(body)) throw unsupported(`\`\${${body}}\` is not a supported parameter expansion; use \`$name\`, \`\${name}\` or \`\${name:-default}\`.`);
717
+ this.pos = closing + 1;
718
+ return {
719
+ text: this.src.slice(start, this.pos),
720
+ dynamic: true
721
+ };
722
+ }
723
+ if (next === "[") throw unsupported("`$[…]` (arithmetic expansion) is not supported.");
724
+ if (context === "unquoted" && (next === "'" || next === "\"")) throw unsupported(`\`$${next}…${next}\` quoting is not portable; use plain quotes.`);
725
+ if (/[A-Za-z_]/.test(next)) {
726
+ this.pos += 2;
727
+ while (/[A-Za-z0-9_]/.test(this.peek())) this.pos++;
728
+ return {
729
+ text: this.src.slice(start, this.pos),
730
+ dynamic: true
731
+ };
732
+ }
733
+ if (/[0-9@*#?$!-]/.test(next) && next !== "") {
734
+ this.pos += 2;
735
+ return {
736
+ text: this.src.slice(start, this.pos),
737
+ dynamic: true
738
+ };
739
+ }
740
+ this.pos++;
741
+ return {
742
+ text: "$",
743
+ dynamic: false
744
+ };
745
+ }
746
+ /** Parse a backtick substitution (cursor on the opening backtick); returns its source. */
747
+ parseBackticks(insideDoubleQuotes) {
748
+ const start = this.pos;
749
+ let content = "";
750
+ this.pos++;
751
+ while (true) {
752
+ if (this.atEnd()) throw unbalanced("A backtick substitution is never closed.");
753
+ const char = this.peek();
754
+ if (char === "`") {
755
+ this.pos++;
756
+ break;
757
+ }
758
+ if (char === "\\") {
759
+ const next = this.peek(1);
760
+ if (next === "") throw unbalanced("A backtick substitution is never closed.");
761
+ if (next === "`" || next === "\\" || next === "$") {
762
+ content += next;
763
+ this.pos += 2;
764
+ continue;
765
+ }
766
+ if (next === "\"" && insideDoubleQuotes) throw unsupported("An escaped `\\\"` inside backticks inside double quotes is not portable; use `$(…)`.");
767
+ content += char + next;
768
+ this.pos += 2;
769
+ continue;
770
+ }
771
+ content += char;
772
+ this.pos++;
773
+ }
774
+ this.enterNesting();
775
+ new PosixParser(content, this.state).parseNestedProgram();
776
+ this.exitNesting();
777
+ return this.src.slice(start, this.pos);
778
+ }
779
+ };
780
+ /** Parser for the restricted `cmd.exe` dialect. */
781
+ var CmdParser = class {
782
+ constructor(src, commands) {
783
+ this.src = src;
784
+ this.commands = commands;
785
+ this.pos = 0;
786
+ }
787
+ parseProgram(segments) {
788
+ if (/[\r\n\0]/.test(this.src)) throw unsupported("Line breaks and NUL bytes are not supported in a cmd.exe command line.");
789
+ while (true) {
790
+ this.skipBlanks();
791
+ if (this.atEnd()) throw unsupported(segments.length === 0 ? "The command is empty." : "The line ends with an operator but no command follows it.");
792
+ const start = this.pos;
793
+ this.parsePipeline();
794
+ const end = this.pos;
795
+ this.skipBlanks();
796
+ const operator = this.readListOperator();
797
+ segments.push({
798
+ source: this.src.slice(start, end).trim(),
799
+ ...operator !== null ? { operator } : {}
800
+ });
801
+ if (operator === null) {
802
+ if (!this.atEnd()) throw unsupported(`Unexpected \`${this.peek()}\`.`);
803
+ return;
804
+ }
805
+ }
806
+ }
807
+ atEnd() {
808
+ return this.pos >= this.src.length;
809
+ }
810
+ peek(offset = 0) {
811
+ return this.src.charAt(this.pos + offset);
812
+ }
813
+ skipBlanks() {
814
+ while (this.peek() === " " || this.peek() === " ") this.pos++;
815
+ }
816
+ readListOperator() {
817
+ if (this.peek() === "&") {
818
+ if (this.peek(1) === "&") {
819
+ this.pos += 2;
820
+ return "&&";
821
+ }
822
+ this.pos++;
823
+ return "&";
824
+ }
825
+ if (this.peek() === "|" && this.peek(1) === "|") {
826
+ this.pos += 2;
827
+ return "||";
828
+ }
829
+ return null;
830
+ }
831
+ parsePipeline() {
832
+ this.parseSimpleCommand();
833
+ while (this.peek() === "|" && this.peek(1) !== "|") {
834
+ this.pos++;
835
+ this.skipBlanks();
836
+ if (this.atEnd()) throw unsupported("The line ends with `|` but no command follows it.");
837
+ this.parseSimpleCommand();
838
+ }
839
+ }
840
+ atRedirectStart() {
841
+ const char = this.peek();
842
+ if (char === "<" || char === ">") return true;
843
+ return /[0-9]/.test(char) && (this.peek(1) === ">" || this.peek(1) === "<");
844
+ }
845
+ parseSimpleCommand() {
846
+ const start = this.pos;
847
+ const command = {
848
+ assignments: [],
849
+ words: [],
850
+ redirects: [],
851
+ source: ""
852
+ };
853
+ while (true) {
854
+ this.skipBlanks();
855
+ if (this.atEnd() || this.peek() === "&" || this.peek() === "|") break;
856
+ if (this.atRedirectStart()) {
857
+ command.redirects.push(this.parseRedirect());
858
+ continue;
859
+ }
860
+ const word = this.parseWord();
861
+ command.words.push(word);
862
+ if (command.words.length === 1) this.checkCommandWord(word);
863
+ }
864
+ if (command.words.length === 0 && command.redirects.length === 0) throw unsupported("The line has an empty command (two operators in a row?).");
865
+ command.source = this.src.slice(start, this.pos).trim();
866
+ this.commands.push(command);
867
+ }
868
+ checkCommandWord(word) {
869
+ if (word.raw.startsWith("@") || word.raw.startsWith(":")) throw unsupported(`\`${word.raw}\` is not supported as a cmd.exe command name.`);
870
+ if (/[/=]/.test(word.value)) throw unsupported(`A cmd.exe command name may not contain \`/\` or \`=\` (\`${word.value}\`); use \`\\\` in paths.`);
871
+ if (!word.quoted && CMD_RESERVED_WORDS.has(word.value.toLowerCase())) throw unsupported(`\`${word.value}\` starts a cmd.exe compound command, which is not supported.`);
872
+ }
873
+ parseRedirect() {
874
+ let fd;
875
+ if (/[0-9]/.test(this.peek())) {
876
+ fd = Number(this.peek());
877
+ this.pos++;
878
+ }
879
+ let op;
880
+ if (this.src.startsWith(">>", this.pos)) op = ">>";
881
+ else if (this.src.startsWith(">&", this.pos)) op = ">&";
882
+ else if (this.src.startsWith("<&", this.pos) || this.src.startsWith("<<", this.pos)) throw unsupported(`\`${this.src.slice(this.pos, this.pos + 2)}\` is not supported by cmd.exe.`);
883
+ else op = this.peek();
884
+ this.pos += op.length;
885
+ this.skipBlanks();
886
+ if (this.atEnd() || /[&|<>]/.test(this.peek())) throw unsupported(`The redirection \`${op}\` has no target.`);
887
+ const target = this.parseWord();
888
+ if (op === ">&") {
889
+ if (!/^[0-9]$/.test(target.value)) throw unsupported(`\`>&${target.raw}\` is not supported; write \`> file 2>&1\`.`);
890
+ return {
891
+ op,
892
+ ...fd !== void 0 ? { fd } : {},
893
+ target,
894
+ type: "dup"
895
+ };
896
+ }
897
+ return {
898
+ op,
899
+ ...fd !== void 0 ? { fd } : {},
900
+ target,
901
+ type: op === "<" ? "read" : "write"
902
+ };
903
+ }
904
+ parseWord() {
905
+ const start = this.pos;
906
+ let value = "";
907
+ let dynamic = false;
908
+ let quoted = false;
909
+ while (!this.atEnd()) {
910
+ const char = this.peek();
911
+ if (char === " " || char === " " || char === "&" || char === "|" || char === "<" || char === ">") break;
912
+ if (char === "\"") {
913
+ const closing = this.src.indexOf("\"", this.pos + 1);
914
+ if (closing === -1) throw unbalanced("A `\"` quote is never closed.");
915
+ const inner = this.src.slice(this.pos + 1, closing);
916
+ if (/[%!]/.test(inner)) throw unsupported("`%` and `!` expand even inside quotes in cmd.exe; they are not supported.");
917
+ if (value.endsWith("\\") || inner.endsWith("\\")) throw unsupported("A backslash before a `\"` is read differently by cmd.exe and programs.");
918
+ value += inner;
919
+ quoted = true;
920
+ this.pos = closing + 1;
921
+ continue;
922
+ }
923
+ if (char === "%" || char === "!" || char === "^" || char === "(" || char === ")" || char === ";" || char === ",") throw unsupported(`Unquoted \`${char}\` has a special meaning in cmd.exe and is not supported.`);
924
+ if (char === "\\" && this.peek(1) === "\"") throw unsupported("A backslash before a `\"` is read differently by cmd.exe and programs.");
925
+ if (char === "*" || char === "?") dynamic = true;
926
+ value += char;
927
+ this.pos++;
928
+ }
929
+ return {
930
+ raw: this.src.slice(start, this.pos),
931
+ value,
932
+ dynamic,
933
+ quoted
934
+ };
935
+ }
936
+ };
937
+ /**
938
+ * Parse a command line for the shell policy. Never throws: a construct the
939
+ * parser does not read with confidence comes back as `{ ok: false }` with a
940
+ * message naming it.
941
+ *
942
+ * @example
943
+ * const parsed = parseShellLine("git add -A && git commit -m 'wip'", "posix");
944
+ * // parsed.ok === true; parsed.commands.map((c) => c.words.map((w) => w.value))
945
+ * // → [["git", "add", "-A"], ["git", "commit", "-m", "wip"]]
946
+ */
947
+ function parseShellLine(line, dialect) {
948
+ const segments = [];
949
+ try {
950
+ if (dialect === "cmd") {
951
+ const commands = [];
952
+ new CmdParser(line, commands).parseProgram(segments);
953
+ return {
954
+ ok: true,
955
+ commands,
956
+ segments,
957
+ background: false
958
+ };
959
+ }
960
+ const state = {
961
+ commands: [],
962
+ background: false,
963
+ nesting: 0
964
+ };
965
+ new PosixParser(line, state).parseProgram(segments);
966
+ return {
967
+ ok: true,
968
+ commands: state.commands,
969
+ segments,
970
+ background: state.background
971
+ };
972
+ } catch (error) {
973
+ if (error instanceof ShellSyntaxRefusal) return {
974
+ ok: false,
975
+ reason: error.reason,
976
+ message: error.message
977
+ };
978
+ throw error;
979
+ }
980
+ }
90
981
 
91
982
  //#endregion
92
983
  //#region ../../packages/ai-workspace/src/policy/tokenize-command.ts
@@ -110,6 +1001,85 @@ const UNQUOTED_METACHARACTERS = new Set([
110
1001
  "(",
111
1002
  ")"
112
1003
  ]);
1004
+ /** Two-character operators reported as one token in a refusal. */
1005
+ const TWO_CHARACTER_OPERATORS = new Set([
1006
+ "&&",
1007
+ "||",
1008
+ ">>",
1009
+ "<<",
1010
+ "$(",
1011
+ "${",
1012
+ ">&",
1013
+ "<&"
1014
+ ]);
1015
+ /**
1016
+ * Tokenize a command line into an argv the way {@link tokenizeCommand}
1017
+ * does, but say **why** a line cannot be represented as one argv — which
1018
+ * quote is unbalanced, or which unquoted operator (`&&`, `|`, `>`, `$(`,
1019
+ * a newline, …) was found first. The shell policy uses it to phrase a
1020
+ * refusal that tells the model how to split the line.
1021
+ *
1022
+ * @example
1023
+ * analyzeArgvCommand("npm test"); // { ok: true, argv: ["npm", "test"] }
1024
+ * analyzeArgvCommand("git add -A && git commit"); // { ok: false, type: "metacharacter", token: "&&", index: 11 }
1025
+ */
1026
+ function analyzeArgvCommand(command) {
1027
+ const argv = [];
1028
+ let current = "";
1029
+ let inToken = false;
1030
+ let index = 0;
1031
+ while (index < command.length) {
1032
+ const char = command.charAt(index);
1033
+ if (char === "'" || char === "\"") {
1034
+ const closing = command.indexOf(char, index + 1);
1035
+ if (closing === -1) return {
1036
+ ok: false,
1037
+ type: "unbalanced-quotes",
1038
+ quote: char,
1039
+ index
1040
+ };
1041
+ current += command.slice(index + 1, closing);
1042
+ inToken = true;
1043
+ index = closing + 1;
1044
+ continue;
1045
+ }
1046
+ if (char === " " || char === " ") {
1047
+ if (inToken) {
1048
+ argv.push(current);
1049
+ current = "";
1050
+ inToken = false;
1051
+ }
1052
+ index++;
1053
+ continue;
1054
+ }
1055
+ if (char === "\n" || char === "\r") return {
1056
+ ok: false,
1057
+ type: "metacharacter",
1058
+ token: "\n",
1059
+ index
1060
+ };
1061
+ if (UNQUOTED_METACHARACTERS.has(char)) {
1062
+ const pair = command.slice(index, index + 2);
1063
+ return {
1064
+ ok: false,
1065
+ type: "metacharacter",
1066
+ token: TWO_CHARACTER_OPERATORS.has(pair) ? pair : char,
1067
+ index
1068
+ };
1069
+ }
1070
+ current += char;
1071
+ inToken = true;
1072
+ index++;
1073
+ }
1074
+ if (inToken) argv.push(current);
1075
+ return argv.length > 0 ? {
1076
+ ok: true,
1077
+ argv
1078
+ } : {
1079
+ ok: false,
1080
+ type: "empty"
1081
+ };
1082
+ }
113
1083
  /**
114
1084
  * Tokenize a command line into an argv array WITHOUT any shell semantics.
115
1085
  *
@@ -125,6 +1095,7 @@ const UNQUOTED_METACHARACTERS = new Set([
125
1095
  * shell metacharacter / newline (see {@link UNQUOTED_METACHARACTERS}). The
126
1096
  * policy gate treats `null` as denied and the local backend refuses to
127
1097
  * spawn it, which is what closes the `allowed_cmd; anything-else` injection.
1098
+ * {@link analyzeArgvCommand} says why a line was refused.
128
1099
  *
129
1100
  * @example
130
1101
  * tokenizeCommand('npm test'); // ["npm", "test"]
@@ -132,36 +1103,628 @@ const UNQUOTED_METACHARACTERS = new Set([
132
1103
  * tokenizeCommand('npm test; curl http://evil'); // null (unquoted `;`)
133
1104
  */
134
1105
  function tokenizeCommand(command) {
135
- const argv = [];
136
- let current = "";
137
- let inToken = false;
138
- let index = 0;
139
- while (index < command.length) {
140
- const char = command[index];
141
- if (char === "'" || char === "\"") {
142
- const closing = command.indexOf(char, index + 1);
143
- if (closing === -1) return null;
144
- current += command.slice(index + 1, closing);
145
- inToken = true;
146
- index = closing + 1;
1106
+ const analysis = analyzeArgvCommand(command);
1107
+ return analysis.ok ? analysis.argv : null;
1108
+ }
1109
+
1110
+ //#endregion
1111
+ //#region ../../packages/ai-workspace/src/policy/shell-check.ts
1112
+ /**
1113
+ * Programs and shell builtins that run a command or code the policy cannot
1114
+ * see (a script string, a file, a command read from input, another user).
1115
+ * With deny rules in force they would hide a denied command, so they are
1116
+ * refused unless the policy names them in `allow`.
1117
+ */
1118
+ const OPAQUE_RUNNERS = new Set([
1119
+ "sh",
1120
+ "bash",
1121
+ "dash",
1122
+ "zsh",
1123
+ "ksh",
1124
+ "mksh",
1125
+ "ash",
1126
+ "yash",
1127
+ "posh",
1128
+ "fish",
1129
+ "csh",
1130
+ "tcsh",
1131
+ "busybox",
1132
+ "cmd",
1133
+ "powershell",
1134
+ "pwsh",
1135
+ "wsl",
1136
+ "sudo",
1137
+ "doas",
1138
+ "su",
1139
+ "runuser",
1140
+ "pkexec",
1141
+ "runas",
1142
+ "chroot",
1143
+ "nsenter",
1144
+ "unshare",
1145
+ "setsid",
1146
+ "systemd-run",
1147
+ "start-stop-daemon",
1148
+ "daemonize",
1149
+ "xargs",
1150
+ "parallel",
1151
+ "watch",
1152
+ "script",
1153
+ "expect",
1154
+ "at",
1155
+ "batch",
1156
+ "screen",
1157
+ "tmux",
1158
+ "stdbuf",
1159
+ "flock"
1160
+ ]);
1161
+ /** POSIX shell builtins that evaluate code or change how later names resolve. */
1162
+ const SHELL_OPAQUE_BUILTINS = new Set([
1163
+ "eval",
1164
+ ".",
1165
+ "source",
1166
+ "alias",
1167
+ "unalias",
1168
+ "hash",
1169
+ "enable",
1170
+ "trap",
1171
+ "let",
1172
+ "declare",
1173
+ "typeset",
1174
+ "local",
1175
+ "set",
1176
+ "shopt",
1177
+ "fc",
1178
+ "bind",
1179
+ "complete",
1180
+ "compgen",
1181
+ "compopt",
1182
+ "mapfile",
1183
+ "readarray"
1184
+ ]);
1185
+ /** cmd.exe builtins that run commands or change the environment. */
1186
+ const CMD_OPAQUE_BUILTINS = new Set([
1187
+ "call",
1188
+ "start",
1189
+ "set",
1190
+ "pushd",
1191
+ "popd"
1192
+ ]);
1193
+ /**
1194
+ * Shell builtins whose arguments bash may evaluate as arithmetic (array
1195
+ * subscripts such as `a[$(cmd)]`): their arguments must be literal and free
1196
+ * of `[`.
1197
+ */
1198
+ const ARITHMETIC_SENSITIVE_BUILTINS = new Set([
1199
+ "test",
1200
+ "[",
1201
+ "printf",
1202
+ "read",
1203
+ "unset",
1204
+ "export",
1205
+ "readonly",
1206
+ "getopts",
1207
+ "wait",
1208
+ "kill",
1209
+ "shift",
1210
+ "return",
1211
+ "exit",
1212
+ "break",
1213
+ "continue",
1214
+ "ulimit"
1215
+ ]);
1216
+ /**
1217
+ * Variables a shell-mode line may not assign: they change how the shell
1218
+ * parses, traces or resolves what follows (`CDPATH` would move `cd` out of
1219
+ * the jail's sight, `PS4` is expanded — command substitutions included —
1220
+ * on every traced line, `IFS` re-splits expansions).
1221
+ */
1222
+ const SENSITIVE_VARIABLES = new Set([
1223
+ "CDPATH",
1224
+ "ENV",
1225
+ "BASH_ENV",
1226
+ "SHELLOPTS",
1227
+ "BASHOPTS",
1228
+ "IFS",
1229
+ "PS1",
1230
+ "PS2",
1231
+ "PS3",
1232
+ "PS4",
1233
+ "PROMPT_COMMAND",
1234
+ "BASH_XTRACEFD",
1235
+ "GLOBIGNORE",
1236
+ "EXECIGNORE",
1237
+ "BASH_LOADABLES_PATH"
1238
+ ]);
1239
+ /** Redirection targets that are always fine even though they sit outside the jail. */
1240
+ const DEVICE_TARGETS = new Set([
1241
+ "/dev/null",
1242
+ "/dev/stdout",
1243
+ "/dev/stderr"
1244
+ ]);
1245
+ /** cmd.exe's null device. */
1246
+ const CMD_DEVICE_TARGETS = new Set(["nul"]);
1247
+ /** Deepest chain of wrappers (`env time nice …`) the policy unwraps. */
1248
+ const MAX_WRAPPER_DEPTH = 8;
1249
+ /** Most distinct directories a line may `cd` between. */
1250
+ const MAX_CD_CANDIDATES = 32;
1251
+ /** Longest command shown verbatim in a refusal before it is abbreviated. */
1252
+ const MAX_SHOWN_COMMAND = 48;
1253
+ /** The dialect shell mode parses on this platform. */
1254
+ function shellDialect() {
1255
+ return process.platform === "win32" ? "cmd" : "posix";
1256
+ }
1257
+ /**
1258
+ * Reduce an argv's first element to the basename the allow/deny policy is
1259
+ * keyed on. `"npm"` → `"npm"`; `"/usr/bin/node"` → `"node"`; `"node.exe"`
1260
+ * → `"node"` (the `.exe`/`.cmd`/`.bat`/`.com` Windows extension is
1261
+ * stripped). The cmd dialect splits Windows paths too.
1262
+ */
1263
+ function executableBasename(firstToken, dialect = "posix") {
1264
+ return (dialect === "cmd" ? node_path.default.win32.basename(firstToken) : node_path.default.basename(firstToken)).replace(/\.(exe|cmd|bat|com)$/i, "");
1265
+ }
1266
+ /** Show a command in a refusal, abbreviated at a word boundary when long. */
1267
+ function show(source) {
1268
+ const flat = source.replace(/\s+/g, " ").trim();
1269
+ if (flat.length <= MAX_SHOWN_COMMAND) return `\`${flat}\``;
1270
+ const cut = flat.lastIndexOf(" ", MAX_SHOWN_COMMAND);
1271
+ return `\`${flat.slice(0, cut > 0 ? cut : MAX_SHOWN_COMMAND)} …\``;
1272
+ }
1273
+ function compilePattern(pattern) {
1274
+ if (pattern instanceof RegExp) {
1275
+ const regex = new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, ""));
1276
+ return {
1277
+ source: pattern,
1278
+ test: (argv, dialect) => regex.test([executableBasename(argv[0] ?? "", dialect), ...argv.slice(1)].join(" "))
1279
+ };
1280
+ }
1281
+ const tokens = tokenizeCommand(pattern);
1282
+ if (tokens === null) return {
1283
+ source: pattern,
1284
+ test: (argv, dialect) => [executableBasename(argv[0] ?? "", dialect), ...argv.slice(1)].join(" ").includes(pattern)
1285
+ };
1286
+ return {
1287
+ source: pattern,
1288
+ test: (argv, dialect) => {
1289
+ if (argv.length < tokens.length) return false;
1290
+ return tokens.every((token, index) => {
1291
+ const actual = argv[index] ?? "";
1292
+ return index === 0 ? executableBasename(actual, dialect).toLowerCase() === executableBasename(token, dialect).toLowerCase() : actual === token;
1293
+ });
1294
+ }
1295
+ };
1296
+ }
1297
+ /** Thrown inside the checker to stop at the first refusal. */
1298
+ var Refusal = class extends Error {
1299
+ constructor(reason, message, executable, refusedPath) {
1300
+ super(message);
1301
+ this.reason = reason;
1302
+ this.executable = executable;
1303
+ this.refusedPath = refusedPath;
1304
+ }
1305
+ };
1306
+ /** Per-line checker state. */
1307
+ var LineChecker = class {
1308
+ constructor(policy, shell, line, dialect) {
1309
+ this.policy = policy;
1310
+ this.shell = shell;
1311
+ this.line = line;
1312
+ this.dialect = dialect;
1313
+ this.commands = [];
1314
+ this.paths = [];
1315
+ this.deny = new Set((shell.deny ?? []).map((name) => name.toLowerCase()));
1316
+ this.allow = shell.allow ?? [];
1317
+ this.patterns = (shell.denyPatterns ?? []).map(compilePattern);
1318
+ this.interpreter = shell.interpreter ?? "none";
1319
+ this.hasCommandRules = this.deny.size > 0 || this.patterns.length > 0 || shell.allowCommand !== void 0;
1320
+ }
1321
+ /** Whether argument hooks (`denyPatterns` / `allowCommand`) need literal argv. */
1322
+ get hasArgumentHooks() {
1323
+ return this.patterns.length > 0 || this.shell.allowCommand !== void 0;
1324
+ }
1325
+ get shellMode() {
1326
+ return this.interpreter === "sh";
1327
+ }
1328
+ /** Run `denyPatterns` and `allowCommand` on one argv. */
1329
+ checkHooks(argv, nested) {
1330
+ const shown = show(argv.join(" "));
1331
+ for (const pattern of this.patterns) if (pattern.test(argv, this.dialect)) throw new Refusal("denied-pattern", `${shown} matches a command pattern the workspace shell policy denies.`, executableBasename(argv[0] ?? "", this.dialect));
1332
+ const hook = this.shell.allowCommand;
1333
+ if (hook === void 0) return;
1334
+ let verdict;
1335
+ try {
1336
+ verdict = hook([...argv], {
1337
+ command: this.line,
1338
+ interpreter: this.interpreter,
1339
+ nested
1340
+ });
1341
+ } catch {
1342
+ verdict = false;
1343
+ }
1344
+ if (verdict === true) return;
1345
+ throw new Refusal("not-allowed", `${shown} was refused by the workspace shell policy.${typeof verdict === "string" && verdict.trim() !== "" ? ` ${verdict.trim()}` : ""}`, executableBasename(argv[0] ?? "", this.dialect));
1346
+ }
1347
+ /** Whether the policy names `executable` in a non-empty `allow` list. */
1348
+ explicitlyAllowed(executable) {
1349
+ return this.allow.includes(executable);
1350
+ }
1351
+ /** Refuse a command runner the policy cannot see into, unless explicitly allowed. */
1352
+ checkOpaque(executable, argv) {
1353
+ if (this.explicitlyAllowed(executable) || !this.hasCommandRules) return;
1354
+ throw new Refusal("nested-command", `\`${executable}\` runs a command the workspace shell policy cannot inspect (in ${show(argv.join(" "))}). Run the inner command directly instead.`, executable);
1355
+ }
1356
+ /**
1357
+ * Check one argv: `deny`, `allow`, `denyPatterns`, `allowCommand`, then
1358
+ * follow wrappers and `find -exec` to the command they run. `dynamic[i]`
1359
+ * says whether `argv[i]` is decided at run time (shell mode).
1360
+ */
1361
+ checkArgv(argv, dynamic, depth) {
1362
+ const first = argv[0] ?? "";
1363
+ const executable = executableBasename(first, this.dialect);
1364
+ const key = executable.toLowerCase();
1365
+ if (executable === "") throw new Refusal("not-allowed", "The command has no executable name.");
1366
+ if (dynamic[0] === true) throw new Refusal("unsupported-syntax", `The command name \`${first}\` is decided when the line runs, so the workspace shell policy cannot check it. Write the command name literally.`);
1367
+ if (this.deny.has(key)) throw new Refusal("denied-executable", `\`${executable}\` is denied by the workspace shell policy (in ${show(argv.join(" "))}).`, executable);
1368
+ if (this.allow.length > 0 && !this.allow.includes(executable)) throw new Refusal("not-allowed", `\`${executable}\` is not in the workspace shell allowlist. Allowed commands: ${this.allow.slice(0, 20).join(", ")}${this.allow.length > 20 ? ", …" : ""}.`, executable);
1369
+ this.checkHooks(argv, depth > 0);
1370
+ this.commands.push([...argv]);
1371
+ 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)) {
1372
+ this.checkOpaque(executable, argv);
1373
+ return;
1374
+ }
1375
+ if (this.shellMode && this.dialect === "posix" && ARITHMETIC_SENSITIVE_BUILTINS.has(key)) this.checkArithmeticSensitive(executable, argv, dynamic);
1376
+ const ranges = this.unwrap(key, argv);
1377
+ if (ranges === void 0) return;
1378
+ 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);
1379
+ for (const [start, end] of ranges) this.checkArgv(argv.slice(start, end), dynamic.slice(start, end), depth + 1);
1380
+ }
1381
+ /**
1382
+ * Builtins bash may evaluate arguments of as arithmetic (array subscripts
1383
+ * like `a[$(cmd)]` run the substitution): arguments must be literal, free
1384
+ * of `[`, and must not name a variable that changes how the shell runs.
1385
+ */
1386
+ checkArithmeticSensitive(executable, argv, dynamic) {
1387
+ for (let index = 1; index < argv.length; index++) {
1388
+ const argument = argv[index] ?? "";
1389
+ if (dynamic[index] === true) throw new Refusal("unsupported-syntax", `\`${executable}\` arguments must be literal here (\`${argument}\` is not): bash may evaluate them as arithmetic.`, executable);
1390
+ if (argument.includes("[") && argument !== "[" && argument !== "]") throw new Refusal("unsupported-syntax", `\`${executable}\` arguments with \`[\` (array subscripts, evaluated as arithmetic) are not supported.`, executable);
1391
+ const name = argument.split("=")[0] ?? "";
1392
+ if (SENSITIVE_VARIABLES.has(name) || name.startsWith("BASH_FUNC_")) throw new Refusal("unsupported-syntax", `Changing \`${name}\` is not supported: it changes how the shell runs the commands that follow.`, executable);
1393
+ }
1394
+ }
1395
+ /**
1396
+ * The argv ranges a wrapper runs. `undefined` — not a wrapper (or one that
1397
+ * runs nothing, like a bare `env`); `null` — a wrapper whose options the
1398
+ * policy cannot read (refused).
1399
+ */
1400
+ unwrap(key, argv) {
1401
+ switch (key) {
1402
+ case "env": return suffixFrom(argv, unwrapEnv(argv));
1403
+ case "nice": return suffixFrom(argv, unwrapNice(argv));
1404
+ case "nohup": return suffixFrom(argv, unwrapSimple(argv, []));
1405
+ case "time": return suffixFrom(argv, unwrapSimple(argv, ["-p"]));
1406
+ case "timeout": return suffixFrom(argv, unwrapTimeout(argv));
1407
+ case "command": return argv.slice(1).some((arg) => arg === "-v" || arg === "-V") ? void 0 : suffixFrom(argv, unwrapSimple(argv, ["-p"]));
1408
+ case "builtin": return suffixFrom(argv, unwrapSimple(argv, []));
1409
+ case "exec": return this.dialect === "posix" ? suffixFrom(argv, unwrapExec(argv)) : void 0;
1410
+ case "find": return unwrapFind(argv);
1411
+ default: return;
1412
+ }
1413
+ }
1414
+ /** Check a `cd` (shell mode): literal, one operand, inside the jail from every possible cwd. */
1415
+ checkCd(words, cwds) {
1416
+ const argv = words.map((word) => word.value);
1417
+ if (this.deny.has("cd")) throw new Refusal("denied-executable", "`cd` is denied by the workspace shell policy.", "cd");
1418
+ this.checkHooks(argv, false);
1419
+ this.commands.push(argv);
1420
+ const operands = words.slice(1);
1421
+ while (this.dialect === "posix" && operands.length > 0 && [
1422
+ "-P",
1423
+ "-L",
1424
+ "--"
1425
+ ].includes(operands[0]?.value ?? "")) operands.shift();
1426
+ const target = operands[0];
1427
+ if (operands.length !== 1 || target === void 0 || target.value === "-" || target.dynamic) throw new Refusal("unsupported-syntax", "`cd` needs exactly one literal directory inside the workspace (no `-`, `~` or variables).", "cd");
1428
+ const next = new Set(cwds);
1429
+ for (const cwd of cwds) {
1430
+ const resolved = this.resolveTarget(cwd, target.value);
1431
+ this.assertInJail(resolved, target.value, "cd");
1432
+ this.paths.push({
1433
+ path: resolved,
1434
+ access: "cwd",
1435
+ target: target.value
1436
+ });
1437
+ next.add(resolved);
1438
+ }
1439
+ if (next.size > MAX_CD_CANDIDATES) throw new Refusal("unsupported-syntax", "The line changes directory too many times.", "cd");
1440
+ return [...next];
1441
+ }
1442
+ /** Check a redirection target from every possible cwd. */
1443
+ checkRedirectTarget(word, access, cwds) {
1444
+ if (word.dynamic) throw new Refusal("unsupported-syntax", `The redirection target \`${word.raw}\` must be a literal path (no variables, globs or \`~\`).`);
1445
+ const value = word.value;
1446
+ if (this.dialect === "posix" && DEVICE_TARGETS.has(value) || this.dialect === "cmd" && CMD_DEVICE_TARGETS.has(value.toLowerCase())) return;
1447
+ for (const cwd of cwds) {
1448
+ const resolved = this.resolveTarget(cwd, value);
1449
+ this.assertInJail(resolved, value, "redirect");
1450
+ this.paths.push({
1451
+ path: resolved,
1452
+ access,
1453
+ target: value
1454
+ });
1455
+ }
1456
+ }
1457
+ resolveTarget(cwd, target) {
1458
+ return node_path.default.resolve(cwd, target);
1459
+ }
1460
+ assertInJail(resolved, target, kind) {
1461
+ if (isInsideJailLexically(this.policy, resolved)) return;
1462
+ throw new Refusal("path-escape", kind === "cd" ? `\`cd ${target}\` leaves the workspace jail (or enters a denied path); commands must stay inside the workspace.` : `The redirection target \`${target}\` is outside the workspace jail or on a denied path.`, void 0, target);
1463
+ }
1464
+ };
1465
+ /**
1466
+ * Turn a wrapper's "inner command starts at `start`" into a range; a
1467
+ * wrapper that runs nothing (`start === argv.length`) becomes `undefined`.
1468
+ */
1469
+ function suffixFrom(argv, start) {
1470
+ if (start === null) return null;
1471
+ return start < argv.length ? [[start, argv.length]] : void 0;
1472
+ }
1473
+ /** `nohup` / `time` / `command` / `builtin`: skip the listed flags and `--`. */
1474
+ function unwrapSimple(argv, flags) {
1475
+ let index = 1;
1476
+ while (index < argv.length) {
1477
+ const arg = argv[index] ?? "";
1478
+ if (arg === "--") {
1479
+ index++;
1480
+ break;
1481
+ }
1482
+ if (flags.includes(arg)) {
1483
+ index++;
147
1484
  continue;
148
1485
  }
149
- if (char === " " || char === " ") {
150
- if (inToken) {
151
- argv.push(current);
152
- current = "";
153
- inToken = false;
154
- }
1486
+ if (arg.startsWith("-") && arg !== "-") return null;
1487
+ break;
1488
+ }
1489
+ return index;
1490
+ }
1491
+ /** `env [-i] [-u NAME] [NAME=value]… command…` */
1492
+ function unwrapEnv(argv) {
1493
+ let index = 1;
1494
+ while (index < argv.length) {
1495
+ const arg = argv[index] ?? "";
1496
+ if (arg === "-i" || arg === "-" || arg === "--ignore-environment") {
155
1497
  index++;
156
1498
  continue;
157
1499
  }
158
- if (char === "\n" || char === "\r" || UNQUOTED_METACHARACTERS.has(char)) return null;
159
- current += char;
160
- inToken = true;
161
- index++;
1500
+ if (arg === "-u" || arg === "--unset") {
1501
+ index += 2;
1502
+ continue;
1503
+ }
1504
+ if (arg.startsWith("--unset=") || arg.startsWith("-u") && arg.length > 2) {
1505
+ index++;
1506
+ continue;
1507
+ }
1508
+ if (arg === "--") {
1509
+ index++;
1510
+ break;
1511
+ }
1512
+ if (arg.startsWith("-")) return null;
1513
+ break;
1514
+ }
1515
+ while (index < argv.length && /^[^=]+=/.test(argv[index] ?? "")) index++;
1516
+ return index;
1517
+ }
1518
+ /** `nice [-n N | -N | --adjustment=N] command…` */
1519
+ function unwrapNice(argv) {
1520
+ let index = 1;
1521
+ while (index < argv.length) {
1522
+ const arg = argv[index] ?? "";
1523
+ if (arg === "-n" || arg === "--adjustment") {
1524
+ index += 2;
1525
+ continue;
1526
+ }
1527
+ if (/^-n-?[0-9]+$/.test(arg) || /^--adjustment=-?[0-9]+$/.test(arg) || /^-[0-9]+$/.test(arg)) {
1528
+ index++;
1529
+ continue;
1530
+ }
1531
+ if (arg === "--") {
1532
+ index++;
1533
+ break;
1534
+ }
1535
+ if (arg.startsWith("-")) return null;
1536
+ break;
1537
+ }
1538
+ return index;
1539
+ }
1540
+ /** `timeout [options] DURATION command…` */
1541
+ function unwrapTimeout(argv) {
1542
+ let index = 1;
1543
+ while (index < argv.length) {
1544
+ const arg = argv[index] ?? "";
1545
+ if (arg === "-s" || arg === "--signal" || arg === "-k" || arg === "--kill-after") {
1546
+ index += 2;
1547
+ continue;
1548
+ }
1549
+ if (/^-[sk].+$/.test(arg) || arg.startsWith("--signal=") || arg.startsWith("--kill-after=") || arg === "--preserve-status" || arg === "--foreground" || arg === "-v" || arg === "--verbose") {
1550
+ index++;
1551
+ continue;
1552
+ }
1553
+ if (arg === "--") {
1554
+ index++;
1555
+ break;
1556
+ }
1557
+ if (arg.startsWith("-")) return null;
1558
+ break;
1559
+ }
1560
+ if (index >= argv.length - 1) return null;
1561
+ return index + 1;
1562
+ }
1563
+ /** `exec [-cl] [-a name] command…` (bash). */
1564
+ function unwrapExec(argv) {
1565
+ let index = 1;
1566
+ while (index < argv.length) {
1567
+ const arg = argv[index] ?? "";
1568
+ if (arg === "-c" || arg === "-l" || arg === "-cl" || arg === "-lc") {
1569
+ index++;
1570
+ continue;
1571
+ }
1572
+ if (arg === "-a") {
1573
+ index += 2;
1574
+ continue;
1575
+ }
1576
+ if (arg === "--") {
1577
+ index++;
1578
+ break;
1579
+ }
1580
+ if (arg.startsWith("-")) return null;
1581
+ break;
1582
+ }
1583
+ return index;
1584
+ }
1585
+ /** The commands `find -exec / -execdir / -ok / -okdir … ;|+` runs. */
1586
+ function unwrapFind(argv) {
1587
+ const inner = [];
1588
+ for (let index = 1; index < argv.length; index++) {
1589
+ const arg = argv[index] ?? "";
1590
+ if (arg !== "-exec" && arg !== "-execdir" && arg !== "-ok" && arg !== "-okdir") continue;
1591
+ const start = index + 1;
1592
+ let end = start;
1593
+ while (end < argv.length && argv[end] !== ";" && argv[end] !== "+") end++;
1594
+ if (end >= argv.length || end === start) return null;
1595
+ inner.push([start, end]);
1596
+ index = end;
1597
+ }
1598
+ return inner.length > 0 ? inner : void 0;
1599
+ }
1600
+ /** Build the argv-mode refusal for a line that is not a single argv. */
1601
+ function argvModeRefusal(command) {
1602
+ const analysis = analyzeArgvCommand(command);
1603
+ if (analysis.ok) return new Refusal("metacharacter", "The command cannot run as a single argv.");
1604
+ if (analysis.type === "empty") return new Refusal("not-allowed", "The command is empty.");
1605
+ if (analysis.type === "unbalanced-quotes") return new Refusal("unbalanced-quotes", `The command has an unbalanced \`${analysis.quote}\` quote. Close it, or quote the argument with the other kind of quote.`);
1606
+ const token = analysis.token;
1607
+ const suffix = " (run_shell executes a single command without a shell.)";
1608
+ const parsed = parseShellLine(command, "posix");
1609
+ const segments = parsed.ok ? parsed.segments : [];
1610
+ if ((token === "&&" || token === "||" || token === ";" || token === "\n") && segments.length >= 2) {
1611
+ const shownOperator = token === "\n" ? "A line break" : `\`${token}\``;
1612
+ if ((segments[0]?.source ?? "").trim().split(/\s+/)[0] === "cd") return new Refusal("metacharacter", `${shownOperator} is not supported in argv mode, and \`cd\` does not carry over: every command starts in the workspace root. Run ${segments.slice(1).map((segment) => show(segment.source)).join(", then ")} on its own, with paths relative to the root.${suffix}`);
1613
+ const steps = segments.map((segment) => show(segment.source));
1614
+ const joiner = token === "||" ? ", and if it fails, " : ", then ";
1615
+ return new Refusal("metacharacter", `${shownOperator} is not supported in argv mode: run ${steps.join(joiner)}, as separate commands.${suffix}`);
1616
+ }
1617
+ if (token === "&" && segments.length >= 1) {
1618
+ const steps = segments.map((segment) => show(segment.source));
1619
+ return new Refusal("metacharacter", `\`&\` is not supported in argv mode: ${steps.length === 1 ? `run ${steps[0] ?? ""} without it` : `run ${steps.join(", then ")} as separate commands`}; start a long-running command with background: true.${suffix}`);
1620
+ }
1621
+ if (token === "|") return new Refusal("metacharacter", `\`|\` (a pipe) is not supported in argv mode: run ${show(command.split("|").map((part) => part.trim()).filter(Boolean)[0] ?? command)} on its own and read its output yourself.${suffix}`);
1622
+ if (token === ">" || token === ">>" || token === ">&") return new Refusal("metacharacter", `\`${token}\` (redirection) is not supported in argv mode: run the command without it and save the output with write_file if you need it.${suffix}`);
1623
+ if (token === "<" || token === "<<" || token === "<&") return new Refusal("metacharacter", `\`${token}\` (input redirection) is not supported in argv mode: pass the file path to the command as an argument.${suffix}`);
1624
+ if (token === "$(" || token === "`") return new Refusal("metacharacter", `Command substitution (${token === "$(" ? "`$(…)`" : "backticks"}) is not supported in argv mode: run the inner command first, then pass its output literally.${suffix}`);
1625
+ if (token === "$" || token === "${") return new Refusal("metacharacter", `\`$\` (variable expansion) is not supported in argv mode: write the value literally, or put the \`$\` inside quotes to pass it as text.${suffix}`);
1626
+ if (token === "(" || token === ")") return new Refusal("metacharacter", `\`${token}\` is not supported in argv mode: put it inside quotes to pass it as text.${suffix}`);
1627
+ 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}`);
1628
+ }
1629
+ /** Check a shell-mode (`interpreter: "sh"`) line. */
1630
+ function checkShellLine(checker, policy, command, dialect) {
1631
+ if (/^[-+]/.test(command)) throw new Refusal("unsupported-syntax", "A command line may not start with `-` or `+`.");
1632
+ const parsed = parseShellLine(command, dialect);
1633
+ if (!parsed.ok) throw new Refusal(parsed.reason, `${parsed.message} The workspace shell policy only runs lines it can check.`);
1634
+ 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.");
1635
+ let cwds = [node_path.default.resolve(policy.cwd)];
1636
+ for (const simple of parsed.commands) cwds = checkSimpleCommand(checker, simple, cwds, dialect);
1637
+ }
1638
+ /** Check one parsed simple command; returns the possible working directories after it. */
1639
+ function checkSimpleCommand(checker, simple, cwds, dialect) {
1640
+ for (const assignment of simple.assignments) {
1641
+ const name = assignment.assignmentName ?? "";
1642
+ if (SENSITIVE_VARIABLES.has(name) || name.startsWith("BASH_FUNC_")) throw new Refusal("unsupported-syntax", `Assigning \`${name}\` is not supported: it changes how the shell runs the commands that follow.`);
1643
+ }
1644
+ for (const redirect of simple.redirects) if (redirect.type === "read" || redirect.type === "write") checker.checkRedirectTarget(redirect.target, redirect.type, cwds);
1645
+ const first = simple.words[0];
1646
+ if (first === void 0) return cwds;
1647
+ if (!first.dynamic && first.value.includes("=")) throw new Refusal("unsupported-syntax", `\`${first.raw}\` is not a plain command name or \`NAME=value\` assignment.`);
1648
+ if (/^[-+]/.test(first.value)) throw new Refusal("unsupported-syntax", `\`${first.raw}\` is not a command name.`);
1649
+ if (checker.hasArgumentHooks) {
1650
+ const dynamic = simple.words.find((word) => word.dynamic);
1651
+ 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).`);
1652
+ }
1653
+ const executable = first.dynamic ? "" : executableBasename(first.value, dialect).toLowerCase();
1654
+ if (executable === "cd") return checker.checkCd(simple.words, cwds);
1655
+ if (dialect === "posix" && (executable === "pushd" || executable === "popd")) throw new Refusal("unsupported-syntax", `\`${executable}\` is not supported; use \`cd\`.`);
1656
+ checker.checkArgv(simple.words.map((word) => word.dynamic ? word.raw : word.value), simple.words.map((word) => word.dynamic), 0);
1657
+ return cwds;
1658
+ }
1659
+ /**
1660
+ * The shell policy's verdict on a command line, without touching the
1661
+ * filesystem.
1662
+ *
1663
+ * - **argv mode** (`interpreter: "none"`, the default): the line must
1664
+ * tokenize into one argv; otherwise it is refused with a
1665
+ * `"metacharacter"` / `"unbalanced-quotes"` reason and a message that
1666
+ * names the operator and suggests the split.
1667
+ * - **shell mode** (`interpreter: "sh"`): the line is parsed and every
1668
+ * simple command — inside `;`, `&&`, `||`, pipes, subshells, `$( )`,
1669
+ * backticks and here-documents — is checked; redirection targets and `cd`
1670
+ * directories must stay in the jail (checked lexically here; `exec`
1671
+ * re-checks them with symlinks resolved).
1672
+ *
1673
+ * Every argv goes through `deny` (case-insensitive, wins), `allow`
1674
+ * (exhaustive when non-empty), `denyPatterns` and `allowCommand`.
1675
+ * Wrappers (`env`, `time`, `timeout`, `nice`, `nohup`, `command`,
1676
+ * `builtin`, `exec`) and `find -exec` are followed to the command they run.
1677
+ * A runner the policy cannot see into (`bash -c`, `eval`, `xargs`, `sudo`,
1678
+ * …) is refused while the policy has deny rules, unless `allow` names it.
1679
+ *
1680
+ * @example
1681
+ * checkShellCommand({ cwd, shell: { allow: ["git"] } }, "git add -A && git commit -m wip");
1682
+ * // { allowed: false, reason: "metacharacter", message: "`&&` is not supported in argv mode: run `git add -A`, then `git commit -m wip`, …" }
1683
+ */
1684
+ function checkShellCommand(policy, command) {
1685
+ return checkShellCommandInDialect(policy, command, shellDialect());
1686
+ }
1687
+ /**
1688
+ * {@link checkShellCommand} with the shell-mode dialect chosen explicitly
1689
+ * instead of from the platform (used to test the cmd.exe rules anywhere).
1690
+ * Argv mode ignores `dialect`.
1691
+ */
1692
+ function checkShellCommandInDialect(policy, command, dialect) {
1693
+ const shell = policy.shell;
1694
+ if (!shell) return {
1695
+ allowed: false,
1696
+ reason: "not-allowed",
1697
+ message: "The workspace has no shell policy, so no command may run.",
1698
+ commands: [],
1699
+ paths: []
1700
+ };
1701
+ const interpreter = shell.interpreter ?? "none";
1702
+ const checker = new LineChecker(policy, shell, command, interpreter === "sh" ? dialect : "posix");
1703
+ try {
1704
+ if (command.trim() === "") throw new Refusal("not-allowed", "The command is empty.");
1705
+ if (interpreter === "sh") checkShellLine(checker, policy, command, dialect);
1706
+ else {
1707
+ const argv = tokenizeCommand(command);
1708
+ if (argv === null) throw argvModeRefusal(command);
1709
+ checker.checkArgv(argv, argv.map(() => false), 0);
1710
+ }
1711
+ return {
1712
+ allowed: true,
1713
+ commands: checker.commands,
1714
+ paths: checker.paths
1715
+ };
1716
+ } catch (error) {
1717
+ if (!(error instanceof Refusal)) throw error;
1718
+ return {
1719
+ allowed: false,
1720
+ reason: error.reason,
1721
+ message: error.message,
1722
+ ...error.executable !== void 0 ? { executable: error.executable } : {},
1723
+ ...error.refusedPath !== void 0 ? { path: error.refusedPath } : {},
1724
+ commands: checker.commands,
1725
+ paths: checker.paths
1726
+ };
162
1727
  }
163
- if (inToken) argv.push(current);
164
- return argv.length > 0 ? argv : null;
165
1728
  }
166
1729
 
167
1730
  //#endregion
@@ -188,55 +1751,6 @@ async function canonicalize$1(target) {
188
1751
  }
189
1752
  }
190
1753
  /**
191
- * Whether `child` is contained within `root` (or equals it), comparing
192
- * canonical absolute paths. Guards against the `/srv/app-evil` vs
193
- * `/srv/app` prefix-collision by anchoring on a path separator.
194
- */
195
- function isInside(child, root) {
196
- const relative = node_path.default.relative(root, child);
197
- return relative === "" || !relative.startsWith("..") && !node_path.default.isAbsolute(relative);
198
- }
199
- /**
200
- * Translate a glob (the small subset used by `denyPaths` — `*`, `**`,
201
- * `?`) into an anchored `RegExp` over a `/`-separated relative path.
202
- * `**` spans path separators; a single `*` does not.
203
- */
204
- function globToRegExp$1(glob) {
205
- let source = "";
206
- for (let index = 0; index < glob.length; index++) {
207
- const char = glob[index];
208
- if (char === "*") {
209
- if (glob[index + 1] === "*") {
210
- source += ".*";
211
- index++;
212
- if (glob[index + 1] === "/") index++;
213
- } else source += "[^/]*";
214
- continue;
215
- }
216
- if (char === "?") {
217
- source += "[^/]";
218
- continue;
219
- }
220
- source += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
221
- }
222
- return new RegExp(`^${source}$`);
223
- }
224
- /**
225
- * Whether a workspace-relative (`/`-separated) path matches any of the
226
- * policy's `denyPaths` globs. A deny glob also blocks everything beneath
227
- * a matched directory (`".git/**"` blocks `.git/config`).
228
- */
229
- function matchesDeny(relativePath, denyPaths) {
230
- return denyPaths.some((glob) => {
231
- if (globToRegExp$1(glob).test(relativePath)) return true;
232
- if (!glob.includes("*") && !glob.includes("?")) {
233
- const prefix = glob.endsWith("/") ? glob : `${glob}/`;
234
- return relativePath.startsWith(prefix);
235
- }
236
- return false;
237
- });
238
- }
239
- /**
240
1754
  * Resolve and jail a single input path against a {@link WorkspacePolicy}.
241
1755
  *
242
1756
  * The input is resolved against `policy.cwd`, canonicalized (symlinks in
@@ -246,6 +1760,17 @@ function matchesDeny(relativePath, denyPaths) {
246
1760
  * `denyPaths` glob even while inside `cwd`, is rejected with a
247
1761
  * {@link WorkspacePolicyError} of type `"path-escape"`.
248
1762
  *
1763
+ * Why this does not use `@warlock.js/fs`'s confined reader (`fs.confined`):
1764
+ * the two answer different questions. The confined reader refuses absolute
1765
+ * paths, refuses every symbolic link (or follows only links inside its one
1766
+ * root), requires the path to exist, and reads the file itself through a
1767
+ * no-follow, identity-checked open. This jail must instead accept absolute
1768
+ * paths inside `cwd`, several `allowPaths` roots, `denyPaths` globs, a
1769
+ * symlinked `cwd`, links whose target stays inside, and paths that do not
1770
+ * exist yet (`writeFile`, `mkdir`), and it returns a path that writes,
1771
+ * `exec`'s working directory and redirections use afterwards. Switching would
1772
+ * change what agents may touch, so the workspace keeps this resolver.
1773
+ *
249
1774
  * @param policy - The bounding policy (its `cwd` is the jail root).
250
1775
  * @param inputPath - A workspace-relative or absolute path to resolve.
251
1776
  * @returns The canonical absolute path plus its `/`-separated relative form.
@@ -272,7 +1797,7 @@ async function resolveInJail(policy, inputPath) {
272
1797
  });
273
1798
  const relativeToJail = insideCwd ? node_path.default.relative(jailRoot, absolutePath).split(node_path.default.sep).join("/") : "";
274
1799
  if (insideCwd && policy.denyPaths && policy.denyPaths.length > 0) {
275
- if (matchesDeny(relativeToJail, policy.denyPaths)) throw new WorkspacePolicyError(`Path "${inputPath}" is blocked by the workspace deny list.`, {
1800
+ if (matchesAnyGlob(relativeToJail, policy.denyPaths)) throw new WorkspacePolicyError(`Path "${inputPath}" is blocked by the workspace deny list.`, {
276
1801
  type: "path-escape",
277
1802
  path: inputPath
278
1803
  });
@@ -283,32 +1808,22 @@ async function resolveInJail(policy, inputPath) {
283
1808
  };
284
1809
  }
285
1810
  /**
286
- * Reduce an argv's first element to the basename the allow/deny policy is
287
- * keyed on. `"npm"` → `"npm"`; `"/usr/bin/node"` → `"node"`; `"node.exe"`
288
- * → `"node"` (the `.exe`/`.cmd`/`.bat`/`.com` Windows extension is
289
- * stripped).
290
- */
291
- function executableBasename(firstToken) {
292
- return node_path.default.basename(firstToken).replace(/\.(exe|cmd|bat|com)$/i, "");
293
- }
294
- /**
295
- * Whether a shell command is permitted by the policy's `shell` sub-policy.
1811
+ * Whether a command line is permitted by the policy's `shell` sub-policy —
1812
+ * `checkShellCommand(policy, command).allowed`.
296
1813
  *
297
- * The command is first tokenized via {@link tokenizeCommand} — a command
298
- * that cannot be represented as a single argv (unbalanced quotes, or
299
- * unquoted shell metacharacters such as `;`, `&&`, `|`, backticks, `$`,
300
- * redirection) is denied outright. The backend spawns the argv directly
301
- * with no shell, so such a command has no meaning here — and unquoted
302
- * metacharacters were exactly how an injected command chain used to ride
303
- * past the allowlist. The resolved `argv[0]` basename is then matched
304
- * against `shell.deny` then `shell.allow`. **Deny always wins.** When
305
- * `shell.allow` is set, the executable MUST appear in it (fail-closed
306
- * allowlist); when `allow` is absent/empty, any non-denied command is
307
- * permitted. An absent `shell` block means no command may run at all.
1814
+ * In argv mode (the default) the command must tokenize into a single argv
1815
+ * (no unquoted `;`, `&&`, `|`, backticks, `$`, redirection, …): the backend
1816
+ * spawns it with no shell, and unquoted metacharacters were exactly how an
1817
+ * injected command chain used to ride past the allowlist. In shell mode
1818
+ * (`interpreter: "sh"`) every simple command in the line is checked. Each
1819
+ * argv's executable basename is matched against `shell.deny` (wins, case
1820
+ * insensitive) then `shell.allow` (exhaustive when non-empty), then
1821
+ * `shell.denyPatterns` and `shell.allowCommand`. An absent `shell` block
1822
+ * means no command may run at all.
308
1823
  *
309
- * Returns a plain `boolean` rather than throwing — the ops layer raises
310
- * the {@link WorkspacePolicyError} so the thrown context (`command`)
311
- * lives next to the call site.
1824
+ * Returns a plain `boolean`; use {@link checkShellCommand} for the reason
1825
+ * and the model-facing message. Redirection targets are checked lexically
1826
+ * here; `exec` re-checks them with symlinks resolved.
312
1827
  *
313
1828
  * @example
314
1829
  * isCommandAllowed({ cwd, shell: { allow: ["npm"] } }, "npm test"); // true
@@ -316,15 +1831,7 @@ function executableBasename(firstToken) {
316
1831
  * isCommandAllowed({ cwd, shell: { allow: ["npm"] } }, "npm test; rm -rf /"); // false
317
1832
  */
318
1833
  function isCommandAllowed(policy, command) {
319
- const shell = policy.shell;
320
- if (!shell) return false;
321
- const argv = tokenizeCommand(command);
322
- if (argv === null) return false;
323
- const executable = executableBasename(argv[0]);
324
- if (executable === "") return false;
325
- if (shell.deny && shell.deny.includes(executable)) return false;
326
- if (shell.allow && shell.allow.length > 0) return shell.allow.includes(executable);
327
- return true;
1834
+ return checkShellCommand(policy, command).allowed;
328
1835
  }
329
1836
  /**
330
1837
  * Build the exact environment a spawned process receives — `process.env`
@@ -349,6 +1856,280 @@ function buildEnv(policy) {
349
1856
  if (shell.env) for (const [key, value] of Object.entries(shell.env)) env[key] = value;
350
1857
  return env;
351
1858
  }
1859
+ /**
1860
+ * Variables removed from the environment of a shell-mode (`interpreter:
1861
+ * "sh"`) command: each changes how the shell itself starts, parses or
1862
+ * resolves the line (`ENV`/`BASH_ENV` run a startup file, `SHELLOPTS` +
1863
+ * `PS4` can run command substitutions, `BASH_FUNC_*` imports functions that
1864
+ * shadow commands, `CDPATH` moves `cd`, `IFS` re-splits words).
1865
+ */
1866
+ const SHELL_STARTUP_VARIABLES = new Set([
1867
+ "ENV",
1868
+ "BASH_ENV",
1869
+ "SHELLOPTS",
1870
+ "BASHOPTS",
1871
+ "CDPATH",
1872
+ "IFS",
1873
+ "PS4",
1874
+ "GLOBIGNORE"
1875
+ ]);
1876
+ /**
1877
+ * The environment a command actually receives: {@link buildEnv}, minus the
1878
+ * shell start-up variables when the policy runs lines through a shell.
1879
+ * Compared case-insensitively, because Windows environment names are.
1880
+ */
1881
+ function buildCommandEnv(policy) {
1882
+ const env = buildEnv(policy);
1883
+ if ((policy.shell?.interpreter ?? "none") !== "sh") return env;
1884
+ for (const key of Object.keys(env)) {
1885
+ const upper = key.toUpperCase();
1886
+ if (SHELL_STARTUP_VARIABLES.has(upper) || upper.startsWith("BASH_FUNC_")) delete env[key];
1887
+ }
1888
+ return env;
1889
+ }
1890
+
1891
+ //#endregion
1892
+ //#region ../../packages/ai-workspace/src/shell-jobs.ts
1893
+ /** Finished commands kept for a last `shell_output` read before the oldest are pruned. */
1894
+ const MAX_FINISHED_JOBS = 16;
1895
+ /** Longest `waitMs` a `shellOutput` call may ask for. */
1896
+ const MAX_OUTPUT_WAIT_MS = 3e4;
1897
+ /** How long `kill()` / `dispose()` wait for a killed tree to report its exit. */
1898
+ const KILL_SETTLE_MS = 5e3;
1899
+ /** Resolve after `ms`, without keeping the event loop alive. */
1900
+ function delay(ms) {
1901
+ return new Promise((resolve) => {
1902
+ setTimeout(resolve, ms).unref?.();
1903
+ });
1904
+ }
1905
+ /**
1906
+ * Keep the last `maxBytes` UTF-8 bytes of `text`, cutting forward to a
1907
+ * character boundary so the kept tail never starts mid-character.
1908
+ */
1909
+ function tailUtf8(text, maxBytes) {
1910
+ const buffer = Buffer.from(text, "utf8");
1911
+ if (buffer.byteLength <= maxBytes) return {
1912
+ text,
1913
+ bytes: buffer.byteLength
1914
+ };
1915
+ let start = buffer.byteLength - maxBytes;
1916
+ while (start < buffer.byteLength && ((buffer[start] ?? 0) & 192) === 128) start++;
1917
+ const kept = buffer.subarray(start);
1918
+ return {
1919
+ text: kept.toString("utf8"),
1920
+ bytes: kept.byteLength
1921
+ };
1922
+ }
1923
+ /** Bytes of `text` from byte `skip` on, moved forward to a character boundary. */
1924
+ function skipUtf8(text, skip) {
1925
+ const buffer = Buffer.from(text, "utf8");
1926
+ let start = Math.max(0, skip);
1927
+ while (start < buffer.byteLength && ((buffer[start] ?? 0) & 192) === 128) start++;
1928
+ return buffer.subarray(start).toString("utf8");
1929
+ }
1930
+ /** One background command and its retained output. */
1931
+ var ShellJob = class {
1932
+ constructor(id, command, retainBytes) {
1933
+ this.id = id;
1934
+ this.command = command;
1935
+ this.retainBytes = retainBytes;
1936
+ this.status = "running";
1937
+ this.exitCode = null;
1938
+ this.startedAt = (/* @__PURE__ */ new Date()).toISOString();
1939
+ this.produced = 0;
1940
+ this.readCursor = 0;
1941
+ this.chunks = [];
1942
+ this.retained = 0;
1943
+ this.listeners = /* @__PURE__ */ new Set();
1944
+ this.killRequested = false;
1945
+ this.timedOut = false;
1946
+ }
1947
+ append(stream, chunk) {
1948
+ if (chunk === "") return;
1949
+ const bytes = Buffer.byteLength(chunk, "utf8");
1950
+ this.chunks.push({
1951
+ stream,
1952
+ text: chunk,
1953
+ offset: this.produced,
1954
+ bytes
1955
+ });
1956
+ this.produced += bytes;
1957
+ this.retained += bytes;
1958
+ this.trim();
1959
+ this.notify();
1960
+ }
1961
+ /** Drop the oldest output until at most `retainBytes` are kept. */
1962
+ trim() {
1963
+ while (this.retained > this.retainBytes && this.chunks.length > 0) {
1964
+ const oldest = this.chunks[0];
1965
+ if (oldest === void 0) return;
1966
+ const excess = this.retained - this.retainBytes;
1967
+ if (oldest.bytes <= excess) {
1968
+ this.chunks.shift();
1969
+ this.retained -= oldest.bytes;
1970
+ continue;
1971
+ }
1972
+ const kept = tailUtf8(oldest.text, oldest.bytes - excess);
1973
+ this.retained -= oldest.bytes - kept.bytes;
1974
+ oldest.offset += oldest.bytes - kept.bytes;
1975
+ oldest.text = kept.text;
1976
+ oldest.bytes = kept.bytes;
1977
+ }
1978
+ }
1979
+ finish(exitCode) {
1980
+ if (this.status !== "running") return;
1981
+ this.exitCode = exitCode;
1982
+ this.status = this.timedOut ? "timed-out" : this.killRequested ? "killed" : "exited";
1983
+ this.endedAt = (/* @__PURE__ */ new Date()).toISOString();
1984
+ if (this.timer !== void 0) clearTimeout(this.timer);
1985
+ this.notify();
1986
+ }
1987
+ notify() {
1988
+ for (const listener of [...this.listeners]) listener();
1989
+ }
1990
+ /** Resolve on the next output or state change (or after `ms`, or on abort). */
1991
+ waitForChange(ms, signal) {
1992
+ return new Promise((resolve) => {
1993
+ if (signal?.aborted) {
1994
+ resolve();
1995
+ return;
1996
+ }
1997
+ let timer;
1998
+ const done = () => {
1999
+ if (timer !== void 0) clearTimeout(timer);
2000
+ this.listeners.delete(done);
2001
+ signal?.removeEventListener("abort", done);
2002
+ resolve();
2003
+ };
2004
+ timer = setTimeout(done, ms);
2005
+ timer.unref?.();
2006
+ this.listeners.add(done);
2007
+ signal?.addEventListener("abort", done, { once: true });
2008
+ });
2009
+ }
2010
+ info() {
2011
+ return {
2012
+ id: this.id,
2013
+ command: this.command,
2014
+ status: this.status,
2015
+ exitCode: this.exitCode,
2016
+ ...this.process?.pid !== void 0 ? { pid: this.process.pid } : {},
2017
+ startedAt: this.startedAt,
2018
+ ...this.endedAt !== void 0 ? { endedAt: this.endedAt } : {}
2019
+ };
2020
+ }
2021
+ /** Output produced after byte `since`, plus how much of it is gone. */
2022
+ read(since) {
2023
+ const from = Math.min(Math.max(0, Math.floor(since)), this.produced);
2024
+ const firstRetained = this.chunks[0]?.offset ?? this.produced;
2025
+ const droppedBytes = Math.max(0, firstRetained - from);
2026
+ let stdout = "";
2027
+ let stderr = "";
2028
+ for (const chunk of this.chunks) {
2029
+ if (chunk.offset + chunk.bytes <= from) continue;
2030
+ const text = chunk.offset >= from ? chunk.text : skipUtf8(chunk.text, from - chunk.offset);
2031
+ if (chunk.stream === "stdout") stdout += text;
2032
+ else stderr += text;
2033
+ }
2034
+ this.readCursor = this.produced;
2035
+ return {
2036
+ ...this.info(),
2037
+ stdout,
2038
+ stderr,
2039
+ cursor: this.produced,
2040
+ droppedBytes,
2041
+ truncated: droppedBytes > 0
2042
+ };
2043
+ }
2044
+ };
2045
+ /**
2046
+ * The background commands one workspace owns. Shared by every projection
2047
+ * (`readonly()`, `scope()`) of the workspace, so `dispose()` on any of them
2048
+ * stops them all.
2049
+ */
2050
+ var ShellJobs = class {
2051
+ constructor() {
2052
+ this.jobs = /* @__PURE__ */ new Map();
2053
+ this.nextId = 1;
2054
+ this.disposed = false;
2055
+ }
2056
+ /** Whether {@link dispose} has run. */
2057
+ get isDisposed() {
2058
+ return this.disposed;
2059
+ }
2060
+ /** Start a command as a background job. */
2061
+ start(spec) {
2062
+ if (this.disposed) throw new WorkspacePolicyError("The workspace has been disposed; it cannot start new commands.", {
2063
+ type: "denied-command",
2064
+ reason: "not-allowed",
2065
+ command: spec.command
2066
+ });
2067
+ const running = [...this.jobs.values()].filter((job) => job.status === "running").length;
2068
+ if (running >= spec.maxJobs) throw new WorkspacePolicyError(`${running} background commands are already running (the limit is ${spec.maxJobs}). Stop one with shell_kill first.`, {
2069
+ type: "denied-command",
2070
+ reason: "not-allowed",
2071
+ command: spec.command
2072
+ });
2073
+ const job = new ShellJob(`job-${this.nextId++}`, spec.command, spec.retainBytes);
2074
+ this.jobs.set(job.id, job);
2075
+ const handle = spec.launch((stream, chunk) => job.append(stream, chunk));
2076
+ job.process = handle;
2077
+ if (spec.timeoutMs !== void 0 && spec.timeoutMs > 0) {
2078
+ job.timer = setTimeout(() => {
2079
+ if (job.status === "running") {
2080
+ job.timedOut = true;
2081
+ handle.kill();
2082
+ }
2083
+ }, spec.timeoutMs);
2084
+ job.timer.unref?.();
2085
+ }
2086
+ handle.done.then(({ exitCode }) => job.finish(exitCode));
2087
+ this.prune();
2088
+ return job.info();
2089
+ }
2090
+ /** Forget the oldest finished jobs beyond {@link MAX_FINISHED_JOBS}. */
2091
+ prune() {
2092
+ const finished = [...this.jobs.values()].filter((job) => job.status !== "running");
2093
+ for (const job of finished.slice(0, Math.max(0, finished.length - MAX_FINISHED_JOBS))) this.jobs.delete(job.id);
2094
+ }
2095
+ get(id) {
2096
+ const job = this.jobs.get(id);
2097
+ if (job === void 0) throw new WorkspaceJobError(`No background command with id "${id}" exists in this workspace.`, {
2098
+ type: "not-found",
2099
+ jobId: id
2100
+ });
2101
+ return job;
2102
+ }
2103
+ /** New output of a job (see {@link ShellOutputOptions}). */
2104
+ async output(id, opts) {
2105
+ const job = this.get(id);
2106
+ const since = opts?.since ?? job.readCursor;
2107
+ const waitMs = Math.min(Math.max(0, opts?.waitMs ?? 0), MAX_OUTPUT_WAIT_MS);
2108
+ if (waitMs > 0 && job.status === "running" && job.produced <= since) await job.waitForChange(waitMs, opts?.signal);
2109
+ return job.read(since);
2110
+ }
2111
+ /** Kill a job's process tree and wait (bounded) for it to stop. */
2112
+ async kill(id) {
2113
+ const job = this.get(id);
2114
+ await this.stop(job);
2115
+ return job.info();
2116
+ }
2117
+ async stop(job) {
2118
+ if (job.status !== "running" || job.process === void 0) return;
2119
+ job.killRequested = true;
2120
+ job.process.kill();
2121
+ await Promise.race([job.process.done, delay(KILL_SETTLE_MS)]);
2122
+ }
2123
+ /** Every job, oldest first. */
2124
+ list() {
2125
+ return [...this.jobs.values()].map((job) => job.info());
2126
+ }
2127
+ /** Kill every running job and refuse new ones. Idempotent. */
2128
+ async dispose() {
2129
+ this.disposed = true;
2130
+ await Promise.all([...this.jobs.values()].map((job) => this.stop(job)));
2131
+ }
2132
+ };
352
2133
 
353
2134
  //#endregion
354
2135
  //#region ../../packages/ai-workspace/src/ops.ts
@@ -358,6 +2139,14 @@ const DEFAULT_READ_LINES = 2e3;
358
2139
  const DEFAULT_MAX_GREP_MATCHES = 1e3;
359
2140
  /** Default per-command output byte cap when the policy sets none. */
360
2141
  const DEFAULT_MAX_OUTPUT_BYTES = 1e6;
2142
+ /** Default `list.ignore` when the policy sets none. */
2143
+ const DEFAULT_LIST_IGNORE = ["node_modules", ".git"];
2144
+ /** Default deepest `depth` a listing may ask for. */
2145
+ const DEFAULT_LIST_MAX_DEPTH = 5;
2146
+ /** Default most entries one listing returns. */
2147
+ const DEFAULT_LIST_MAX_ENTRIES = 500;
2148
+ /** Most edits one `multiEdit` call may carry. */
2149
+ const MAX_MULTI_EDITS = 100;
361
2150
  /**
362
2151
  * Exit code reported for a command refused because its signal was already
363
2152
  * aborted (the shell convention for an interrupted command). Callers should
@@ -448,32 +2237,6 @@ function capOutput(value, maxBytes) {
448
2237
  return clipUtf8(value, maxBytes);
449
2238
  }
450
2239
  /**
451
- * Minimal glob match over a `/`-separated relative path. Supports `**`
452
- * (spans separators), `*` (within a segment), and `?` (one non-separator
453
- * char) — enough for the workspace's `glob`/grep narrowing without
454
- * pulling in a runtime dependency.
455
- */
456
- function globToRegExp(glob) {
457
- let source = "";
458
- for (let index = 0; index < glob.length; index++) {
459
- const char = glob[index];
460
- if (char === "*") {
461
- if (glob[index + 1] === "*") {
462
- source += ".*";
463
- index++;
464
- if (glob[index + 1] === "/") index++;
465
- } else source += "[^/]*";
466
- continue;
467
- }
468
- if (char === "?") {
469
- source += "[^/]";
470
- continue;
471
- }
472
- source += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
473
- }
474
- return new RegExp(`^${source}$`);
475
- }
476
- /**
477
2240
  * A quantifier: `+`, `*`, `?`, or a `{m,n}` bound.
478
2241
  */
479
2242
  const QUANTIFIER_SOURCE = String.raw`[+*?]|\{\d*,?\d*\}`;
@@ -495,17 +2258,11 @@ function isSafeGrepPattern(pattern) {
495
2258
  if (pattern.length > MAX_GREP_PATTERN_LENGTH) return false;
496
2259
  return !NESTED_QUANTIFIER_PATTERN.test(pattern);
497
2260
  }
498
- /**
499
- * The internal, single-instance implementation of {@link WorkspaceOps}.
500
- * Holds the backend + policy and is the one place the jail, command
501
- * gating, read caps, and the read-before-edit guard are enforced — both
502
- * the agent-facing tools and the human-facing direct methods funnel
503
- * through this object, so there is exactly one set of rules.
504
- */
505
2261
  var Ops = class {
506
- constructor(backend, policy) {
2262
+ constructor(backend, policy, runtime) {
507
2263
  this.backend = backend;
508
2264
  this.policy = policy;
2265
+ this.runtime = runtime;
509
2266
  this.pathLocks = /* @__PURE__ */ new Map();
510
2267
  }
511
2268
  /** Run `task` after every earlier mutation of `absolutePath` has settled. */
@@ -599,30 +2356,76 @@ var Ops = class {
599
2356
  expectedHash: input.expectHash,
600
2357
  actualHash: currentHash
601
2358
  });
602
- const occurrences = countOccurrences(current, input.oldString);
603
- if (occurrences === 0) throw new WorkspaceEditError(`The text to replace was not found in "${input.path}".`, {
604
- type: "not-found",
605
- path: relativePath || input.path,
606
- matches: 0
607
- });
608
- if (occurrences > 1 && !input.replaceAll) throw new WorkspaceEditError(`The text to replace is not unique in "${input.path}" (${occurrences} matches); pass replaceAll or include more surrounding context.`, {
609
- type: "not-unique",
610
- path: relativePath || input.path,
611
- matches: occurrences
612
- });
613
- const updated = input.replaceAll ? current.split(input.oldString).join(input.newString) : replaceFirst(current, input.oldString, input.newString);
2359
+ const { content: updated, replacements } = applyReplacement(current, input, input.path, relativePath || input.path);
614
2360
  await this.backend.writeFile(absolutePath, updated);
615
2361
  return {
616
2362
  path: relativePath || input.path,
617
- replacements: input.replaceAll ? occurrences : 1,
2363
+ replacements,
618
2364
  hash: _warlock_js_fs.fs.hash.string(updated)
619
2365
  };
620
2366
  }
621
- async exec(command, opts) {
622
- if (!isCommandAllowed(this.policy, command)) throw new WorkspacePolicyError(`Command is not permitted by the workspace shell policy: ${command}`, {
2367
+ async multiEdit(input) {
2368
+ const { absolutePath, relativePath } = await resolveInJail(this.policy, input.path);
2369
+ const shownPath = relativePath || input.path;
2370
+ 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}).`);
2371
+ return this.withPathLock(absolutePath, async () => {
2372
+ const current = await this.backend.readFile(absolutePath);
2373
+ const currentHash = _warlock_js_fs.fs.hash.string(current);
2374
+ 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.`, {
2375
+ type: "stale-hash",
2376
+ path: shownPath,
2377
+ expectedHash: input.expectHash,
2378
+ actualHash: currentHash
2379
+ });
2380
+ let content = current;
2381
+ let replacements = 0;
2382
+ input.edits.forEach((edit, editIndex) => {
2383
+ const applied = applyReplacement(content, edit, input.path, shownPath, editIndex);
2384
+ content = applied.content;
2385
+ replacements += applied.replacements;
2386
+ });
2387
+ await this.backend.writeFile(absolutePath, content);
2388
+ return {
2389
+ path: shownPath,
2390
+ edits: input.edits.length,
2391
+ replacements,
2392
+ hash: _warlock_js_fs.fs.hash.string(content)
2393
+ };
2394
+ });
2395
+ }
2396
+ /**
2397
+ * Check a command line against the shell policy: every command in it
2398
+ * (argv or shell mode), then — shell mode — every redirection target and
2399
+ * `cd` directory against the jail with symlinks resolved.
2400
+ */
2401
+ async assertCommandAllowed(command) {
2402
+ const verdict = checkShellCommand(this.policy, command);
2403
+ if (!verdict.allowed) throw new WorkspacePolicyError(verdict.message ?? `Command is not permitted by the workspace shell policy: ${command}`, {
2404
+ type: "denied-command",
2405
+ command,
2406
+ ...verdict.reason !== void 0 ? { reason: verdict.reason } : {},
2407
+ ...verdict.executable !== void 0 ? { executable: verdict.executable } : {},
2408
+ ...verdict.path !== void 0 ? { path: verdict.path } : {}
2409
+ });
2410
+ for (const target of verdict.paths) try {
2411
+ await resolveInJail(this.policy, target.path);
2412
+ } catch (error) {
2413
+ if (!(error instanceof WorkspacePolicyError)) throw error;
2414
+ throw new WorkspacePolicyError(target.access === "cwd" ? `\`cd ${target.target}\` resolves (through a symlink) outside the workspace jail or onto a denied path.` : `The redirection target \`${target.target}\` resolves (through a symlink) outside the workspace jail or onto a denied path.`, {
2415
+ type: "denied-command",
2416
+ reason: "path-escape",
2417
+ command,
2418
+ path: target.target
2419
+ });
2420
+ }
2421
+ if (this.runtime.jobs.isDisposed) throw new WorkspacePolicyError("The workspace has been disposed; it cannot run new commands.", {
623
2422
  type: "denied-command",
2423
+ reason: "not-allowed",
624
2424
  command
625
2425
  });
2426
+ }
2427
+ async exec(command, opts) {
2428
+ await this.assertCommandAllowed(command);
626
2429
  const signal = opts?.signal;
627
2430
  if (signal?.aborted) return {
628
2431
  exitCode: CANCELLED_BEFORE_START_EXIT_CODE,
@@ -635,11 +2438,13 @@ var Ops = class {
635
2438
  const shell = this.policy.shell;
636
2439
  const timeoutMs = opts?.timeoutMs ?? shell?.timeoutMs;
637
2440
  const maxOutputBytes = shell?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
2441
+ const interpreter = shell?.interpreter ?? "none";
638
2442
  const result = await this.backend.exec(command, {
639
2443
  cwd: this.policy.cwd,
640
2444
  timeoutMs,
641
- env: buildEnv(this.policy),
642
- ...signal ? { signal } : {}
2445
+ env: buildCommandEnv(this.policy),
2446
+ ...signal ? { signal } : {},
2447
+ ...interpreter !== "none" ? { interpreter } : {}
643
2448
  });
644
2449
  const stdout = capOutput(result.stdout, maxOutputBytes);
645
2450
  const stderr = capOutput(result.stderr, maxOutputBytes);
@@ -653,6 +2458,120 @@ var Ops = class {
653
2458
  cancelled
654
2459
  };
655
2460
  }
2461
+ async startShell(command, opts) {
2462
+ const background = this.policy.shell?.background;
2463
+ if (background === void 0) throw new WorkspacePolicyError("Background commands are not enabled for this workspace. Run the command without background, or ask for shell.background to be enabled.", {
2464
+ type: "denied-command",
2465
+ reason: "not-allowed",
2466
+ command
2467
+ });
2468
+ await this.assertCommandAllowed(command);
2469
+ const start = this.backend.start?.bind(this.backend);
2470
+ if (start === void 0) throw new WorkspacePolicyError("This workspace backend cannot run background commands.", {
2471
+ type: "denied-command",
2472
+ reason: "not-allowed",
2473
+ command
2474
+ });
2475
+ const shell = this.policy.shell;
2476
+ const interpreter = shell?.interpreter ?? "none";
2477
+ const timeoutMs = lowerOf(background.timeoutMs, opts?.timeoutMs);
2478
+ const env = buildCommandEnv(this.policy);
2479
+ return this.runtime.jobs.start({
2480
+ command,
2481
+ retainBytes: shell?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES,
2482
+ maxJobs: background.maxJobs ?? 4,
2483
+ ...timeoutMs !== void 0 ? { timeoutMs } : {},
2484
+ launch: (onOutput) => start(command, {
2485
+ cwd: this.policy.cwd,
2486
+ env,
2487
+ ...interpreter !== "none" ? { interpreter } : {},
2488
+ onOutput
2489
+ })
2490
+ });
2491
+ }
2492
+ shellOutput(id, opts) {
2493
+ return this.runtime.jobs.output(id, opts);
2494
+ }
2495
+ shellKill(id) {
2496
+ return this.runtime.jobs.kill(id);
2497
+ }
2498
+ listShells() {
2499
+ return this.runtime.jobs.list();
2500
+ }
2501
+ dispose() {
2502
+ return this.runtime.jobs.dispose();
2503
+ }
2504
+ async listDir(inputPath = ".", opts) {
2505
+ const { absolutePath: root, relativePath: rootRelative } = await resolveInJail(this.policy, inputPath);
2506
+ const { absolutePath: jailRoot } = await resolveInJail(this.policy, ".");
2507
+ const listPolicy = this.policy.list;
2508
+ const maxDepth = Math.max(1, Math.floor(listPolicy?.maxDepth ?? DEFAULT_LIST_MAX_DEPTH));
2509
+ const depth = Math.min(Math.max(1, Math.floor(opts?.depth ?? 1)), maxDepth);
2510
+ const maxEntries = Math.max(1, Math.floor(listPolicy?.maxEntries ?? DEFAULT_LIST_MAX_ENTRIES));
2511
+ const ignore = [...listPolicy?.ignore ?? DEFAULT_LIST_IGNORE, ...opts?.ignore ?? []];
2512
+ const insideCwd = isInside(root, jailRoot);
2513
+ if (!await this.isDirectory(root)) throw new Error(`"${inputPath}" is not a directory in the workspace.`);
2514
+ const entries = [];
2515
+ let level = [root];
2516
+ let truncated = false;
2517
+ for (let currentDepth = 1; currentDepth <= depth && level.length > 0; currentDepth++) {
2518
+ const nextLevel = [];
2519
+ for (const directory of level) {
2520
+ let children;
2521
+ try {
2522
+ children = await this.backend.list(directory);
2523
+ } catch {
2524
+ continue;
2525
+ }
2526
+ children.sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
2527
+ for (const child of children) {
2528
+ const relativeToJail = insideCwd ? node_path.default.relative(jailRoot, child).split(node_path.default.sep).join("/") : child;
2529
+ if (insideCwd && (this.isDenied(relativeToJail) || matchesAnyGlob(relativeToJail, ignore))) continue;
2530
+ const relativeToRoot = node_path.default.relative(root, child).split(node_path.default.sep).join("/");
2531
+ if (!insideCwd && matchesAnyGlob(relativeToRoot, ignore)) continue;
2532
+ if (entries.length >= maxEntries) {
2533
+ truncated = true;
2534
+ break;
2535
+ }
2536
+ const described = await this.describe(child);
2537
+ entries.push({
2538
+ path: relativeToJail,
2539
+ type: described.type,
2540
+ ...described.size !== void 0 ? { size: described.size } : {},
2541
+ depth: currentDepth
2542
+ });
2543
+ if (described.type === "directory") nextLevel.push(child);
2544
+ }
2545
+ if (truncated) break;
2546
+ }
2547
+ if (truncated) break;
2548
+ level = nextLevel;
2549
+ }
2550
+ return {
2551
+ path: insideCwd ? rootRelative || "." : root,
2552
+ entries,
2553
+ truncated
2554
+ };
2555
+ }
2556
+ /** Kind and size of a listed entry; symlinks are reported, never followed. */
2557
+ async describe(absPath) {
2558
+ if (this.backend.stat) try {
2559
+ const stats = await this.backend.stat(absPath);
2560
+ return stats.type === "file" ? {
2561
+ type: "file",
2562
+ size: stats.size
2563
+ } : { type: stats.type };
2564
+ } catch {
2565
+ return { type: "other" };
2566
+ }
2567
+ if (await this.isDirectory(absPath)) try {
2568
+ await resolveInJail(this.policy, absPath);
2569
+ return { type: "directory" };
2570
+ } catch {
2571
+ return { type: "other" };
2572
+ }
2573
+ return { type: "file" };
2574
+ }
656
2575
  async grep(pattern, opts) {
657
2576
  if (!isSafeGrepPattern(pattern)) throw new WorkspacePolicyError(`Grep pattern is too long or too likely to cause catastrophic regex backtracking: ${pattern}`, {
658
2577
  type: "unsafe-pattern",
@@ -676,7 +2595,7 @@ var Ops = class {
676
2595
  }
677
2596
  const lines = content.split("\n");
678
2597
  for (let index = 0; index < lines.length; index++) {
679
- const line = lines[index];
2598
+ const line = lines[index] ?? "";
680
2599
  if (line.length > MAX_GREP_LINE_SCAN_LENGTH) continue;
681
2600
  if (regex.test(line)) {
682
2601
  matches.push({
@@ -723,17 +2642,40 @@ var Ops = class {
723
2642
  /** Whether a `/`-separated relative path hits the policy deny list. */
724
2643
  isDenied(relativePath) {
725
2644
  const denyPaths = this.policy.denyPaths;
726
- if (!denyPaths || denyPaths.length === 0) return false;
727
- return denyPaths.some((glob) => {
728
- if (globToRegExp(glob).test(relativePath)) return true;
729
- if (!glob.includes("*") && !glob.includes("?")) {
730
- const prefix = glob.endsWith("/") ? glob : `${glob}/`;
731
- return relativePath.startsWith(prefix);
732
- }
733
- return false;
734
- });
2645
+ return denyPaths !== void 0 && denyPaths.length > 0 && matchesAnyGlob(relativePath, denyPaths);
735
2646
  }
736
2647
  };
2648
+ /** The lower of two optional limits (either may be absent). */
2649
+ function lowerOf(policyLimit, requested) {
2650
+ if (requested === void 0 || requested <= 0) return policyLimit;
2651
+ return policyLimit === void 0 ? requested : Math.min(policyLimit, requested);
2652
+ }
2653
+ /**
2654
+ * Apply one exact-string replacement to `content` under the edit rules:
2655
+ * `oldString` must occur, and exactly once unless `replaceAll`.
2656
+ */
2657
+ function applyReplacement(content, edit, inputPath, shownPath, editIndex) {
2658
+ const occurrences = countOccurrences(content, edit.oldString);
2659
+ const which = editIndex === void 0 ? "" : `Edit ${editIndex + 1}: `;
2660
+ const nothingApplied = editIndex === void 0 ? "" : " None of the edits were applied.";
2661
+ const indexOption = editIndex === void 0 ? {} : { editIndex };
2662
+ if (occurrences === 0) throw new WorkspaceEditError(`${which}The text to replace was not found in "${inputPath}".${nothingApplied}`, {
2663
+ type: "not-found",
2664
+ path: shownPath,
2665
+ matches: 0,
2666
+ ...indexOption
2667
+ });
2668
+ 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}`, {
2669
+ type: "not-unique",
2670
+ path: shownPath,
2671
+ matches: occurrences,
2672
+ ...indexOption
2673
+ });
2674
+ return {
2675
+ content: edit.replaceAll ? content.split(edit.oldString).join(edit.newString) : replaceFirst(content, edit.oldString, edit.newString),
2676
+ replacements: edit.replaceAll ? occurrences : 1
2677
+ };
2678
+ }
737
2679
  /** Count non-overlapping occurrences of `needle` in `haystack`. */
738
2680
  function countOccurrences(haystack, needle) {
739
2681
  if (needle === "") return 0;
@@ -765,13 +2707,15 @@ function replaceFirst(haystack, needle, replacement) {
765
2707
  *
766
2708
  * @param backend - The dumb IO executor (local disk or in-memory mock).
767
2709
  * @param policy - The policy that bounds every operation.
2710
+ * @param runtime - State shared with other views of the same workspace
2711
+ * (its background commands); a fresh one when omitted.
768
2712
  *
769
2713
  * @example
770
2714
  * const ops = createOps(localBackend, { cwd: "/srv/api", shell: { allow: ["npm"] } });
771
2715
  * const { content, hash } = await ops.readFile("src/index.ts");
772
2716
  */
773
- function createOps(backend, policy) {
774
- return new Ops(backend, policy);
2717
+ function createOps(backend, policy, runtime) {
2718
+ return new Ops(backend, policy, runtime ?? { jobs: new ShellJobs() });
775
2719
  }
776
2720
 
777
2721
  //#endregion
@@ -866,6 +2810,74 @@ function toWin32CmdInvocation(argv) {
866
2810
  ]
867
2811
  };
868
2812
  }
2813
+ /** The shell a POSIX shell-mode line runs through — an absolute path, never a PATH lookup. */
2814
+ const POSIX_SHELL = "/bin/sh";
2815
+ /**
2816
+ * Work out the spawn for a command line.
2817
+ *
2818
+ * - argv mode: tokenize (no shell semantics) and spawn the argv directly —
2819
+ * on Windows through an individually-quoted `cmd.exe` wrapper.
2820
+ * - shell mode: `/bin/sh -c <line>`, or `cmd.exe /d /v:off /s /c "<line>"`
2821
+ * on Windows (`/d` skips AutoRun, `/v:off` disables delayed `!` expansion,
2822
+ * `/s` keeps the line verbatim inside the outer quotes). The ops layer has
2823
+ * already checked every command in the line.
2824
+ */
2825
+ function planSpawn(command, interpreter) {
2826
+ if (interpreter === "sh") {
2827
+ if (node_process.platform === "win32") return {
2828
+ file: process.env.ComSpec ?? "cmd.exe",
2829
+ args: [
2830
+ "/d",
2831
+ "/v:off",
2832
+ "/s",
2833
+ "/c",
2834
+ `"${command}"`
2835
+ ],
2836
+ windowsVerbatimArguments: true
2837
+ };
2838
+ return {
2839
+ file: POSIX_SHELL,
2840
+ args: ["-c", command],
2841
+ windowsVerbatimArguments: false
2842
+ };
2843
+ }
2844
+ const argv = tokenizeCommand(command);
2845
+ const executable = argv?.[0];
2846
+ 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." };
2847
+ if (node_process.platform === "win32") {
2848
+ const invocation = toWin32CmdInvocation(argv);
2849
+ if (invocation === null) return { refused: "Command was not executed: on Windows, arguments containing \", %, or newlines cannot be passed to cmd.exe safely." };
2850
+ return {
2851
+ ...invocation,
2852
+ windowsVerbatimArguments: true
2853
+ };
2854
+ }
2855
+ return {
2856
+ file: executable,
2857
+ args: argv.slice(1),
2858
+ windowsVerbatimArguments: false
2859
+ };
2860
+ }
2861
+ /**
2862
+ * Kill functions of every background process still running, so they can
2863
+ * be reaped if the Node process exits without the workspace being disposed
2864
+ * (detached process groups would otherwise outlive it).
2865
+ */
2866
+ const liveBackgroundKills = /* @__PURE__ */ new Set();
2867
+ let exitHookInstalled = false;
2868
+ /** Track a live background tree; installs (once) the exit hook that kills them. */
2869
+ function trackBackgroundProcess(kill) {
2870
+ liveBackgroundKills.add(kill);
2871
+ if (!exitHookInstalled) {
2872
+ exitHookInstalled = true;
2873
+ process.on("exit", () => {
2874
+ for (const killTreeNow of liveBackgroundKills) killTreeNow();
2875
+ });
2876
+ }
2877
+ return () => {
2878
+ liveBackgroundKills.delete(kill);
2879
+ };
2880
+ }
869
2881
  /**
870
2882
  * The real-disk executor: every filesystem method delegates to
871
2883
  * `@warlock.js/fs` (never `node:fs`), and {@link LocalBackend.exec} spawns a
@@ -922,6 +2934,17 @@ var LocalBackend = class {
922
2934
  return _warlock_js_fs.fs.dirs.list(absDir);
923
2935
  }
924
2936
  /**
2937
+ * Describe a path without following a final symlink (`lstat` — like
2938
+ * `realpath`, a filesystem operation `@warlock.js/fs` does not expose).
2939
+ */
2940
+ async stat(absPath) {
2941
+ const stats = await (0, node_fs_promises.lstat)(absPath);
2942
+ return {
2943
+ type: stats.isSymbolicLink() ? "symlink" : stats.isDirectory() ? "directory" : stats.isFile() ? "file" : "other",
2944
+ size: stats.size
2945
+ };
2946
+ }
2947
+ /**
925
2948
  * Resolve symlinks and `..` segments to a canonical absolute path — the
926
2949
  * primitive the ops-layer jail uses to detect escapes. Delegates to
927
2950
  * `node:fs/promises` `realpath`, the one filesystem operation `@warlock.js/fs`
@@ -931,14 +2954,17 @@ var LocalBackend = class {
931
2954
  return (0, node_fs_promises.realpath)(absPath);
932
2955
  }
933
2956
  /**
934
- * Run a command and capture its outcome. The command line is tokenized
935
- * into an argv (quotes respected, NO shell semantics — see
936
- * `tokenizeCommand`) and spawned **without a shell**, so metacharacters
937
- * like `;`, `&&`, `|`, backticks, and `$()` can never chain extra
938
- * commands past the ops layer's allowlist; a command they appear
939
- * unquoted in is refused with exit code 127. On Windows the argv runs
940
- * through a `cmd.exe /d /s /c` wrapper (batch shims like `npm.cmd`
2957
+ * Run a command and capture its outcome. In argv mode (the default) the
2958
+ * command line is tokenized into an argv (quotes respected, NO shell
2959
+ * semantics — see `tokenizeCommand`) and spawned **without a shell**, so
2960
+ * metacharacters like `;`, `&&`, `|`, backticks, and `$()` can never
2961
+ * chain extra commands past the ops layer's allowlist; a command they
2962
+ * appear unquoted in is refused with exit code 127. On Windows the argv
2963
+ * runs through a `cmd.exe /d /s /c` wrapper (batch shims like `npm.cmd`
941
2964
  * cannot be spawned shell-less) with every element individually quoted.
2965
+ * With `interpreter: "sh"` the line runs through `/bin/sh -c` (or
2966
+ * `cmd.exe /d /v:off /s /c` on Windows) — only after the ops layer has
2967
+ * checked every command in it.
942
2968
  * `cwd`, `env`, and the timeout are taken verbatim from the ops layer
943
2969
  * (the environment is NOT merged with `process.env`). On timeout the
944
2970
  * process tree is SIGKILLed and `timedOut` is set. When `signal` aborts
@@ -970,31 +2996,17 @@ var LocalBackend = class {
970
2996
  });
971
2997
  return;
972
2998
  }
973
- const argv = tokenizeCommand(command);
974
- const executable = argv?.[0];
975
- if (argv === null || executable === void 0) {
976
- refuse("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.");
2999
+ const plan = planSpawn(command, opts.interpreter ?? "none");
3000
+ if ("refused" in plan) {
3001
+ refuse(plan.refused);
977
3002
  return;
978
3003
  }
979
- let file = executable;
980
- let args = argv.slice(1);
981
- let windowsVerbatimArguments = false;
982
- if (node_process.platform === "win32") {
983
- const invocation = toWin32CmdInvocation(argv);
984
- if (invocation === null) {
985
- refuse("Command was not executed: on Windows, arguments containing \", %, or newlines cannot be passed to cmd.exe safely.");
986
- return;
987
- }
988
- file = invocation.file;
989
- args = invocation.args;
990
- windowsVerbatimArguments = true;
991
- }
992
- const child = (0, node_child_process.spawn)(file, args, {
3004
+ const child = (0, node_child_process.spawn)(plan.file, plan.args, {
993
3005
  cwd: opts.cwd,
994
3006
  env: opts.env,
995
3007
  shell: false,
996
3008
  windowsHide: true,
997
- windowsVerbatimArguments,
3009
+ windowsVerbatimArguments: plan.windowsVerbatimArguments,
998
3010
  detached: node_process.platform !== "win32"
999
3011
  });
1000
3012
  const stdoutChunks = [];
@@ -1050,6 +3062,75 @@ var LocalBackend = class {
1050
3062
  });
1051
3063
  });
1052
3064
  }
3065
+ /**
3066
+ * Start a long-running command and return at once. Spawned like
3067
+ * {@link exec} (same argv/shell planning, own process group on POSIX), with
3068
+ * stdin closed. Output is decoded per stream with a `StringDecoder`, so a
3069
+ * chunk never ends inside a character. `kill()` kills the whole tree; a
3070
+ * still-running tree is also killed when the Node process exits.
3071
+ */
3072
+ start(command, opts) {
3073
+ const plan = planSpawn(command, opts.interpreter ?? "none");
3074
+ if ("refused" in plan) {
3075
+ opts.onOutput("stderr", plan.refused);
3076
+ return {
3077
+ done: Promise.resolve({ exitCode: 127 }),
3078
+ kill: () => {}
3079
+ };
3080
+ }
3081
+ const child = (0, node_child_process.spawn)(plan.file, plan.args, {
3082
+ cwd: opts.cwd,
3083
+ env: opts.env,
3084
+ shell: false,
3085
+ windowsHide: true,
3086
+ windowsVerbatimArguments: plan.windowsVerbatimArguments,
3087
+ detached: node_process.platform !== "win32",
3088
+ stdio: [
3089
+ "ignore",
3090
+ "pipe",
3091
+ "pipe"
3092
+ ]
3093
+ });
3094
+ const stdoutDecoder = new node_string_decoder.StringDecoder("utf8");
3095
+ const stderrDecoder = new node_string_decoder.StringDecoder("utf8");
3096
+ let finished = false;
3097
+ const kill = () => {
3098
+ if (!finished) killTree(child.pid, child);
3099
+ };
3100
+ const untrack = trackBackgroundProcess(kill);
3101
+ child.stdout?.on("data", (chunk) => {
3102
+ const text = stdoutDecoder.write(chunk);
3103
+ if (text !== "") opts.onOutput("stdout", text);
3104
+ });
3105
+ child.stderr?.on("data", (chunk) => {
3106
+ const text = stderrDecoder.write(chunk);
3107
+ if (text !== "") opts.onOutput("stderr", text);
3108
+ });
3109
+ const done = new Promise((resolve) => {
3110
+ const finish = (exitCode) => {
3111
+ if (finished) return;
3112
+ finished = true;
3113
+ untrack();
3114
+ const stdoutTail = stdoutDecoder.end();
3115
+ const stderrTail = stderrDecoder.end();
3116
+ if (stdoutTail !== "") opts.onOutput("stdout", stdoutTail);
3117
+ if (stderrTail !== "") opts.onOutput("stderr", stderrTail);
3118
+ resolve({ exitCode });
3119
+ };
3120
+ child.on("error", (error) => {
3121
+ opts.onOutput("stderr", `Command could not be started: ${error.message}`);
3122
+ finish(127);
3123
+ });
3124
+ child.on("close", (code, signal) => {
3125
+ finish(code === null ? 128 + (signal === "SIGKILL" ? 9 : 1) : code);
3126
+ });
3127
+ });
3128
+ return {
3129
+ ...child.pid !== void 0 ? { pid: child.pid } : {},
3130
+ done,
3131
+ kill
3132
+ };
3133
+ }
1053
3134
  };
1054
3135
  /**
1055
3136
  * Create the **local** workspace backend — the default executor that runs the
@@ -1171,6 +3252,40 @@ var MockBackend = class {
1171
3252
  async realpath(absPath) {
1172
3253
  return canonicalize(absPath);
1173
3254
  }
3255
+ async stat(absPath) {
3256
+ const canonical = canonicalize(absPath);
3257
+ const content = this.files.get(canonical);
3258
+ if (content !== void 0) return {
3259
+ type: "file",
3260
+ size: Buffer.byteLength(content, "utf8")
3261
+ };
3262
+ if (this.directories.has(canonical)) return {
3263
+ type: "directory",
3264
+ size: 0
3265
+ };
3266
+ throw new Error(`Mock backend: no such file or directory: ${canonical}`);
3267
+ }
3268
+ /**
3269
+ * Scripted background start: emits the registered `stdout` / `stderr`
3270
+ * synchronously, then exits with `exitCode` — or, when scripted with
3271
+ * `running: true`, stays running until `kill()` (exit code 137).
3272
+ */
3273
+ start(command, opts) {
3274
+ const scripted = this.commands.get(command);
3275
+ if (scripted?.stdout) opts.onOutput("stdout", scripted.stdout);
3276
+ if (scripted?.stderr) opts.onOutput("stderr", scripted.stderr);
3277
+ if (scripted?.running !== true) return {
3278
+ done: Promise.resolve({ exitCode: scripted?.exitCode ?? 0 }),
3279
+ kill: () => {}
3280
+ };
3281
+ let stop = () => {};
3282
+ return {
3283
+ done: new Promise((resolve) => {
3284
+ stop = resolve;
3285
+ }),
3286
+ kill: () => stop({ exitCode: 137 })
3287
+ };
3288
+ }
1174
3289
  async exec(command, opts) {
1175
3290
  if (opts?.signal?.aborted) return {
1176
3291
  exitCode: 130,
@@ -1261,6 +3376,10 @@ function isMockBackendSeed(seed) {
1261
3376
  */
1262
3377
  /** The vendor tag stamped on every issue these builders produce. */
1263
3378
  const VENDOR = "ai-workspace";
3379
+ /** Attach a JSON Schema description to a hand-rolled Standard Schema. */
3380
+ function withJsonSchema(schema, jsonSchema) {
3381
+ return Object.assign(schema, { jsonSchema });
3382
+ }
1264
3383
  /** Required string field — rejects anything that is not a string. */
1265
3384
  function stringField() {
1266
3385
  return (value, key) => {
@@ -1311,24 +3430,25 @@ function optionalBooleanField() {
1311
3430
  };
1312
3431
  }
1313
3432
  /**
1314
- * Build a {@link StandardSchemaV1} for a flat object whose every property
1315
- * is validated by a {@link FieldValidator}. The input must be a non-null
1316
- * object; each declared field is validated and the (possibly coerced)
1317
- * values are collected into the typed result. All field issues are merged
1318
- * so the caller sees every problem at once.
1319
- *
1320
- * `T` is constrained to `object` rather than `Record<string, unknown>` so
1321
- * the tool IO `interface`s (which carry no implicit string index
1322
- * signature) satisfy it directly — only the declared keys in `shape` are
1323
- * ever read, so a string index signature is never required.
1324
- *
1325
- * @example
1326
- * const schema = objectSchema<{ path: string; limit?: number }>({
1327
- * path: stringField(),
1328
- * limit: optionalNumberField(),
1329
- * });
3433
+ * Optional array-of-strings field — accepts `undefined` or an array whose
3434
+ * every element is a string (at most `maxItems` of them).
1330
3435
  */
1331
- function objectSchema(shape) {
3436
+ function optionalStringArrayField(maxItems = 50) {
3437
+ return (value, key) => {
3438
+ if (value === void 0) return { value: void 0 };
3439
+ if (Array.isArray(value) && value.length <= maxItems && value.every((item) => typeof item === "string")) return { value };
3440
+ return { issues: [{
3441
+ message: `"${key}" must be an array of at most ${maxItems} strings when provided`,
3442
+ path: [key]
3443
+ }] };
3444
+ };
3445
+ }
3446
+ function objectSchema(shape, jsonSchema) {
3447
+ const schema = buildObjectSchema(shape);
3448
+ return jsonSchema === void 0 ? schema : withJsonSchema(schema, jsonSchema);
3449
+ }
3450
+ /** The validator behind {@link objectSchema}. */
3451
+ function buildObjectSchema(shape) {
1332
3452
  return { "~standard": {
1333
3453
  version: 1,
1334
3454
  vendor: VENDOR,
@@ -1352,18 +3472,191 @@ function objectSchema(shape) {
1352
3472
  } };
1353
3473
  }
1354
3474
 
3475
+ //#endregion
3476
+ //#region ../../packages/ai-workspace/src/tools/json-schemas.ts
3477
+ /**
3478
+ * The JSON Schemas the workspace tools publish for their input (read by
3479
+ * `extractJsonSchema` and sent to the provider with each tool), kept short
3480
+ * because they ride along on every model call. They describe what the
3481
+ * hand-rolled validators accept; the validators remain the authority.
3482
+ */
3483
+ const string = (description) => ({
3484
+ type: "string",
3485
+ description
3486
+ });
3487
+ const integer = (description) => ({
3488
+ type: "integer",
3489
+ description
3490
+ });
3491
+ const boolean = (description) => ({
3492
+ type: "boolean",
3493
+ description
3494
+ });
3495
+ /** `read_file`. */
3496
+ const READ_FILE_JSON_SCHEMA = {
3497
+ type: "object",
3498
+ properties: {
3499
+ path: string("Workspace-relative file path."),
3500
+ startLine: integer("1-based first line to return."),
3501
+ limit: integer("Most lines to return.")
3502
+ },
3503
+ required: ["path"]
3504
+ };
3505
+ /** `edit_file`. */
3506
+ const EDIT_FILE_JSON_SCHEMA = {
3507
+ type: "object",
3508
+ properties: {
3509
+ path: string("Workspace-relative file path."),
3510
+ oldString: string("Exact text to replace; must be unique unless replaceAll."),
3511
+ newString: string("Replacement text."),
3512
+ replaceAll: boolean("Replace every occurrence."),
3513
+ expectHash: string("Hash from read_file; rejects the edit if the file changed.")
3514
+ },
3515
+ required: [
3516
+ "path",
3517
+ "oldString",
3518
+ "newString"
3519
+ ]
3520
+ };
3521
+ /** `multi_edit`. */
3522
+ const MULTI_EDIT_JSON_SCHEMA = {
3523
+ type: "object",
3524
+ properties: {
3525
+ path: string("Workspace-relative file path."),
3526
+ edits: {
3527
+ type: "array",
3528
+ description: "Replacements applied in order, each to the result of the previous; all or none.",
3529
+ minItems: 1,
3530
+ maxItems: 100,
3531
+ items: {
3532
+ type: "object",
3533
+ properties: {
3534
+ oldString: string("Exact text to replace; must be unique unless replaceAll."),
3535
+ newString: string("Replacement text."),
3536
+ replaceAll: boolean("Replace every occurrence.")
3537
+ },
3538
+ required: ["oldString", "newString"]
3539
+ }
3540
+ },
3541
+ expectHash: string("Hash from read_file; rejects every edit if the file changed.")
3542
+ },
3543
+ required: ["path", "edits"]
3544
+ };
3545
+ /** `write_file`. */
3546
+ const WRITE_FILE_JSON_SCHEMA = {
3547
+ type: "object",
3548
+ properties: {
3549
+ path: string("Workspace-relative file path."),
3550
+ content: string("Full file content.")
3551
+ },
3552
+ required: ["path", "content"]
3553
+ };
3554
+ /** `list_dir`. */
3555
+ const LIST_DIR_JSON_SCHEMA = {
3556
+ type: "object",
3557
+ properties: {
3558
+ path: string("Workspace-relative directory (default \".\")."),
3559
+ depth: integer("Levels to list; 1 = direct children (default)."),
3560
+ ignore: {
3561
+ type: "array",
3562
+ items: { type: "string" },
3563
+ description: "Extra globs to leave out."
3564
+ }
3565
+ }
3566
+ };
3567
+ /** `run_shell` (the `background` property is only listed when background commands are enabled). */
3568
+ function runShellJsonSchema(background) {
3569
+ return {
3570
+ type: "object",
3571
+ properties: {
3572
+ command: string("The command line to run."),
3573
+ timeoutMs: integer("Wall-clock cap in ms (can only lower the workspace limit)."),
3574
+ ...background ? { background: boolean("Start it in the background and return its id at once (dev servers, watchers).") } : {}
3575
+ },
3576
+ required: ["command"]
3577
+ };
3578
+ }
3579
+ /** `shell_output`. */
3580
+ const SHELL_OUTPUT_JSON_SCHEMA = {
3581
+ type: "object",
3582
+ properties: {
3583
+ id: string("Background command id from run_shell."),
3584
+ since: integer("A cursor from an earlier result; omit for output since the last read."),
3585
+ waitMs: integer("Wait up to this long (max 30000) for new output.")
3586
+ },
3587
+ required: ["id"]
3588
+ };
3589
+ /** `shell_kill`. */
3590
+ const SHELL_KILL_JSON_SCHEMA = {
3591
+ type: "object",
3592
+ properties: { id: string("Background command id from run_shell.") },
3593
+ required: ["id"]
3594
+ };
3595
+ /** `run_tests`. */
3596
+ const RUN_TESTS_JSON_SCHEMA = {
3597
+ type: "object",
3598
+ properties: { pattern: string("Optional test path or name filter.") }
3599
+ };
3600
+ /** `grep`. */
3601
+ const GREP_JSON_SCHEMA = {
3602
+ type: "object",
3603
+ properties: {
3604
+ pattern: string("Regular expression to search for."),
3605
+ glob: string("Only search files matching this glob (e.g. \"src/**/*.ts\")."),
3606
+ ignoreCase: boolean("Case-insensitive match.")
3607
+ },
3608
+ required: ["pattern"]
3609
+ };
3610
+ /** `glob`. */
3611
+ const GLOB_JSON_SCHEMA = {
3612
+ type: "object",
3613
+ properties: { pattern: string("Glob such as \"src/**/*.ts\".") },
3614
+ required: ["pattern"]
3615
+ };
3616
+ /** `todo_write`. */
3617
+ const TODO_WRITE_JSON_SCHEMA = {
3618
+ type: "object",
3619
+ properties: { todos: {
3620
+ type: "array",
3621
+ description: "The complete updated list (replaces the old one).",
3622
+ maxItems: 100,
3623
+ items: {
3624
+ type: "object",
3625
+ properties: {
3626
+ id: string("Stable id; omit to number items by position."),
3627
+ content: string("The step."),
3628
+ status: {
3629
+ type: "string",
3630
+ enum: [
3631
+ "pending",
3632
+ "in_progress",
3633
+ "completed"
3634
+ ]
3635
+ }
3636
+ },
3637
+ required: ["content", "status"]
3638
+ }
3639
+ } },
3640
+ required: ["todos"]
3641
+ };
3642
+ /** `todo_read`. */
3643
+ const TODO_READ_JSON_SCHEMA = {
3644
+ type: "object",
3645
+ properties: {}
3646
+ };
3647
+
1355
3648
  //#endregion
1356
3649
  //#region ../../packages/ai-workspace/src/tools/edit-file.ts
1357
3650
  /** Default tool name exposed to the LLM. */
1358
- const DEFAULT_NAME$2 = "edit_file";
3651
+ const DEFAULT_NAME$6 = "edit_file";
1359
3652
  /** Input schema for the `edit_file` tool. */
1360
- const inputSchema$2 = objectSchema({
3653
+ const inputSchema$6 = objectSchema({
1361
3654
  path: stringField(),
1362
3655
  oldString: stringField(),
1363
3656
  newString: stringField(),
1364
3657
  replaceAll: optionalBooleanField(),
1365
3658
  expectHash: optionalStringField()
1366
- });
3659
+ }, EDIT_FILE_JSON_SCHEMA);
1367
3660
  /**
1368
3661
  * Build the agent-facing `edit_file` tool over a workspace's policy-
1369
3662
  * enforced {@link WorkspaceOps}.
@@ -1395,9 +3688,9 @@ const inputSchema$2 = objectSchema({
1395
3688
  */
1396
3689
  function makeEditFileTool(ops, options) {
1397
3690
  return (0, _warlock_js_ai.tool)({
1398
- name: options?.name ?? DEFAULT_NAME$2,
3691
+ name: options?.name ?? DEFAULT_NAME$6,
1399
3692
  description: "Replace an exact substring in a workspace file. oldString must match uniquely unless replaceAll is set. Pass expectHash (from read_file) to reject the edit if the file changed since you read it. Returns the number of replacements and the new content hash.",
1400
- input: inputSchema$2,
3693
+ input: inputSchema$6,
1401
3694
  async execute(input) {
1402
3695
  return ops.editFile(input);
1403
3696
  }
@@ -1412,7 +3705,7 @@ function makeEditFileTool(ops, options) {
1412
3705
  * (no schema library, matching the validator idiom the `@warlock.js/ai`
1413
3706
  * tool runtime expects).
1414
3707
  */
1415
- const globInputSchema = objectSchema({ pattern: stringField() });
3708
+ const globInputSchema = objectSchema({ pattern: stringField() }, GLOB_JSON_SCHEMA);
1416
3709
  /**
1417
3710
  * Build the agent-facing `glob` tool — resolve a glob pattern to the
1418
3711
  * matching workspace-relative paths within the jail. The returned
@@ -1456,7 +3749,7 @@ const grepInputSchema = objectSchema({
1456
3749
  pattern: stringField(),
1457
3750
  glob: optionalStringField(),
1458
3751
  ignoreCase: optionalBooleanField()
1459
- });
3752
+ }, GREP_JSON_SCHEMA);
1460
3753
  /**
1461
3754
  * Build the agent-facing `grep` tool — a regex content search across the
1462
3755
  * jailed file set. The returned {@link ToolContract} validates the LLM's
@@ -1490,16 +3783,174 @@ function makeGrepTool(ops, options) {
1490
3783
  });
1491
3784
  }
1492
3785
 
3786
+ //#endregion
3787
+ //#region ../../packages/ai-workspace/src/tools/list-dir.ts
3788
+ /** Default tool name exposed to the LLM. */
3789
+ const DEFAULT_NAME$5 = "list_dir";
3790
+ /** Input schema for the `list_dir` tool. */
3791
+ const inputSchema$5 = objectSchema({
3792
+ path: optionalStringField(),
3793
+ depth: optionalNumberField(),
3794
+ ignore: optionalStringArrayField()
3795
+ }, LIST_DIR_JSON_SCHEMA);
3796
+ /**
3797
+ * Build the agent-facing `list_dir` tool over a workspace's policy-enforced
3798
+ * {@link WorkspaceOps}: a bounded, jailed listing of one directory.
3799
+ *
3800
+ * Entries come back breadth-first (each level sorted by name) with their
3801
+ * type and — for files — size, down to `depth` levels (capped by
3802
+ * `list.maxDepth`) and at most `list.maxEntries` entries (`truncated` says
3803
+ * when the cap hit). `denyPaths` are always left out, as are the
3804
+ * `list.ignore` globs (default `node_modules`, `.git`) and any `ignore`
3805
+ * globs the model passes. Symlinks are reported as `"symlink"` and never
3806
+ * followed. A path outside the jail surfaces as a `WorkspacePolicyError` in
3807
+ * the result's `error` field.
3808
+ *
3809
+ * @param ops - The shared, policy-enforced operation layer.
3810
+ * @param options - Optional overrides; `name` renames the LLM-visible tool.
3811
+ *
3812
+ * @example
3813
+ * const listDir = makeListDirTool(ops);
3814
+ * const { data } = await listDir.invoke({ path: "src", depth: 2 });
3815
+ * for (const entry of data?.entries ?? []) console.log(entry.type, entry.path, entry.size);
3816
+ */
3817
+ function makeListDirTool(ops, options) {
3818
+ return (0, _warlock_js_ai.tool)({
3819
+ name: options?.name ?? DEFAULT_NAME$5,
3820
+ description: "List a workspace directory: entries with their type and file size, breadth-first, `depth` levels deep (default 1). Dependency and VCS folders are left out unless you list them directly.",
3821
+ action: (input) => `Listing ${typeof input.path === "string" ? input.path : "."}`,
3822
+ input: inputSchema$5,
3823
+ execute: (input) => ops.listDir(input.path ?? ".", {
3824
+ ...input.depth !== void 0 ? { depth: input.depth } : {},
3825
+ ...input.ignore !== void 0 ? { ignore: input.ignore } : {}
3826
+ })
3827
+ });
3828
+ }
3829
+
3830
+ //#endregion
3831
+ //#region ../../packages/ai-workspace/src/tools/multi-edit.ts
3832
+ /** Default tool name exposed to the LLM. */
3833
+ const DEFAULT_NAME$4 = "multi_edit";
3834
+ /** Validate one element of `edits`. */
3835
+ function validateEdit(value, index) {
3836
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return { issues: [{
3837
+ message: `edits[${index}] must be an object`,
3838
+ path: ["edits", index]
3839
+ }] };
3840
+ const candidate = value;
3841
+ const issues = [];
3842
+ if (typeof candidate.oldString !== "string" || candidate.oldString === "") issues.push({
3843
+ message: `edits[${index}].oldString must be a non-empty string`,
3844
+ path: [
3845
+ "edits",
3846
+ index,
3847
+ "oldString"
3848
+ ]
3849
+ });
3850
+ if (typeof candidate.newString !== "string") issues.push({
3851
+ message: `edits[${index}].newString must be a string`,
3852
+ path: [
3853
+ "edits",
3854
+ index,
3855
+ "newString"
3856
+ ]
3857
+ });
3858
+ if (candidate.replaceAll !== void 0 && typeof candidate.replaceAll !== "boolean") issues.push({
3859
+ message: `edits[${index}].replaceAll must be a boolean when provided`,
3860
+ path: [
3861
+ "edits",
3862
+ index,
3863
+ "replaceAll"
3864
+ ]
3865
+ });
3866
+ if (issues.length > 0 || typeof candidate.oldString !== "string" || typeof candidate.newString !== "string") return { issues };
3867
+ return { edit: {
3868
+ oldString: candidate.oldString,
3869
+ newString: candidate.newString,
3870
+ ...candidate.replaceAll === true ? { replaceAll: true } : {}
3871
+ } };
3872
+ }
3873
+ /** Input schema for the `multi_edit` tool. */
3874
+ const inputSchema$4 = withJsonSchema({ "~standard": {
3875
+ version: 1,
3876
+ vendor: "ai-workspace",
3877
+ validate(value) {
3878
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return { issues: [{ message: "input must be an object" }] };
3879
+ const candidate = value;
3880
+ const issues = [];
3881
+ const edits = [];
3882
+ if (typeof candidate.path !== "string" || candidate.path === "") issues.push({
3883
+ message: "\"path\" must be a non-empty string",
3884
+ path: ["path"]
3885
+ });
3886
+ if (candidate.expectHash !== void 0 && typeof candidate.expectHash !== "string") issues.push({
3887
+ message: "\"expectHash\" must be a string when provided",
3888
+ path: ["expectHash"]
3889
+ });
3890
+ if (!Array.isArray(candidate.edits) || candidate.edits.length === 0 || candidate.edits.length > 100) issues.push({
3891
+ message: `"edits" must be an array of 1 to ${100} edits`,
3892
+ path: ["edits"]
3893
+ });
3894
+ else candidate.edits.forEach((item, index) => {
3895
+ const outcome = validateEdit(item, index);
3896
+ if ("issues" in outcome) issues.push(...outcome.issues);
3897
+ else edits.push(outcome.edit);
3898
+ });
3899
+ if (issues.length > 0 || typeof candidate.path !== "string") return { issues };
3900
+ return { value: {
3901
+ path: candidate.path,
3902
+ edits,
3903
+ ...typeof candidate.expectHash === "string" ? { expectHash: candidate.expectHash } : {}
3904
+ } };
3905
+ }
3906
+ } }, MULTI_EDIT_JSON_SCHEMA);
3907
+ /**
3908
+ * Build the agent-facing `multi_edit` tool over a workspace's policy-
3909
+ * enforced {@link WorkspaceOps}: several exact-string replacements in one
3910
+ * file, **atomic** — every edit applies, in order (each against the result
3911
+ * of the previous ones), and the file is written once; or one edit fails
3912
+ * and nothing is written.
3913
+ *
3914
+ * Each edit follows the `edit_file` rules: `oldString` must be found, and
3915
+ * found exactly once unless `replaceAll`. An optional `expectHash` (from
3916
+ * `read_file`) rejects the whole call when the file changed since it was
3917
+ * read. A failure surfaces as a `WorkspaceEditError` in the result's
3918
+ * `error` field, with `editIndex` naming the failing edit.
3919
+ *
3920
+ * @param ops - The shared, policy-enforced operation layer.
3921
+ * @param options - Optional overrides; `name` renames the LLM-visible tool.
3922
+ *
3923
+ * @example
3924
+ * const multiEdit = makeMultiEditTool(ops);
3925
+ * const { data, error } = await multiEdit.invoke({
3926
+ * path: "src/cart.ts",
3927
+ * expectHash,
3928
+ * edits: [
3929
+ * { oldString: "let total = 0;", newString: "let total = 0n;" },
3930
+ * { oldString: "total += price;", newString: "total += BigInt(price);" },
3931
+ * ],
3932
+ * });
3933
+ */
3934
+ function makeMultiEditTool(ops, options) {
3935
+ return (0, _warlock_js_ai.tool)({
3936
+ name: options?.name ?? DEFAULT_NAME$4,
3937
+ description: "Make several exact-string replacements in one workspace file at once. Edits apply in order, each to the result of the previous; if any edit fails (text not found, or not unique without replaceAll) none are applied. Pass expectHash (from read_file) to reject the call if the file changed since you read it.",
3938
+ action: (input) => `Editing ${typeof input.path === "string" ? input.path : "a file"}`,
3939
+ input: inputSchema$4,
3940
+ execute: (input) => ops.multiEdit(input)
3941
+ });
3942
+ }
3943
+
1493
3944
  //#endregion
1494
3945
  //#region ../../packages/ai-workspace/src/tools/read-file.ts
1495
3946
  /** Default tool name exposed to the LLM. */
1496
- const DEFAULT_NAME$1 = "read_file";
3947
+ const DEFAULT_NAME$3 = "read_file";
1497
3948
  /** Input schema for the `read_file` tool. */
1498
- const inputSchema$1 = objectSchema({
3949
+ const inputSchema$3 = objectSchema({
1499
3950
  path: stringField(),
1500
3951
  startLine: optionalNumberField(),
1501
3952
  limit: optionalNumberField()
1502
- });
3953
+ }, READ_FILE_JSON_SCHEMA);
1503
3954
  /**
1504
3955
  * Build the agent-facing `read_file` tool over a workspace's policy-
1505
3956
  * enforced {@link WorkspaceOps}.
@@ -1526,9 +3977,9 @@ const inputSchema$1 = objectSchema({
1526
3977
  */
1527
3978
  function makeReadFileTool(ops, options) {
1528
3979
  return (0, _warlock_js_ai.tool)({
1529
- name: options?.name ?? DEFAULT_NAME$1,
3980
+ name: options?.name ?? DEFAULT_NAME$3,
1530
3981
  description: "Read a file from the workspace, returning a numbered line window plus the file's content hash. Pass the hash to edit_file's expectHash to guard against editing a stale version. Use startLine/limit to page through large files. Output is byte-capped by the workspace policy; when bytesCapped is true the last returned line may be cut short.",
1531
- input: inputSchema$1,
3982
+ input: inputSchema$3,
1532
3983
  async execute(input) {
1533
3984
  return ops.readFile(input.path, {
1534
3985
  offset: input.startLine !== void 0 ? Math.max(1, input.startLine) : 1,
@@ -1545,64 +3996,67 @@ const DEFAULT_RUN_SHELL_TOOL_NAME = "run_shell";
1545
3996
  /**
1546
3997
  * Hand-rolled Standard Schema for {@link RunShellInput}. We validate the
1547
3998
  * model's arguments without a runtime schema dependency: `command` must be
1548
- * a non-empty string, and `timeoutMs` (when present) a positive number.
1549
- * Invalid args surface as a `SchemaValidationError` in the tool result's
1550
- * `error` field rather than reaching `ops.exec`.
1551
- */
1552
- const runShellInputSchema = { "~standard": {
1553
- version: 1,
1554
- vendor: "@warlock.js/ai-workspace",
1555
- validate: (value) => {
1556
- if (typeof value !== "object" || value === null) return { issues: [{ message: "expected an object" }] };
1557
- const candidate = value;
1558
- if (typeof candidate.command !== "string" || candidate.command.length === 0) return { issues: [{
1559
- message: "command must be a non-empty string",
1560
- path: ["command"]
1561
- }] };
1562
- if (candidate.timeoutMs !== void 0 && (typeof candidate.timeoutMs !== "number" || candidate.timeoutMs <= 0)) return { issues: [{
1563
- message: "timeoutMs must be a positive number",
1564
- path: ["timeoutMs"]
1565
- }] };
1566
- const result = { command: candidate.command };
1567
- if (candidate.timeoutMs !== void 0) result.timeoutMs = candidate.timeoutMs;
1568
- return { value: result };
1569
- }
1570
- } };
1571
- /**
1572
- * Build the `run_shell` tool — a {@link ToolContract} that runs a single
1573
- * shell command through the policy-enforced {@link WorkspaceOps} layer.
1574
- *
1575
- * The command's leading executable basename is gated against the shell
1576
- * allow/deny policy by `ops.exec`; a blocked command throws a
1577
- * `WorkspacePolicyError` which the `tool()` runtime catches and surfaces
1578
- * in the result's `error` field (never a thrown run-killer), so the agent
1579
- * reads the refusal as tool data and self-corrects. A command that runs
1580
- * but exits non-zero is *not* an error — its `exitCode`/`stderr` come back
1581
- * in `data` for the agent to inspect.
1582
- *
1583
- * The agent run's cancellation `signal` (from the dispatch `ctx`) is passed
1584
- * to `ops.exec` per invocation: a cancelled run never starts the command,
1585
- * and one cancelled mid-command kills its process tree (`cancelled: true`).
1586
- *
1587
- * @param ops - The policy-enforced operation layer to delegate `exec` to.
1588
- * @param options - Optional tool-name override.
1589
- *
1590
- * @example
1591
- * const runShell = makeRunShellTool(ops);
1592
- * const { data, error } = await runShell.invoke({ command: "npm run build" });
1593
- * if (error) handleDenied(error);
1594
- * else console.log(data.exitCode, data.stdout);
3999
+ * a non-empty string, `timeoutMs` (when present) a positive number and
4000
+ * `background` (when present) a boolean. Invalid args surface as a
4001
+ * `SchemaValidationError` in the tool result's `error` field rather than
4002
+ * reaching `ops.exec`.
1595
4003
  */
4004
+ function runShellInputSchema(background) {
4005
+ return withJsonSchema({ "~standard": {
4006
+ version: 1,
4007
+ vendor: "@warlock.js/ai-workspace",
4008
+ validate: (value) => {
4009
+ if (typeof value !== "object" || value === null) return { issues: [{ message: "expected an object" }] };
4010
+ const candidate = value;
4011
+ if (typeof candidate.command !== "string" || candidate.command.length === 0) return { issues: [{
4012
+ message: "command must be a non-empty string",
4013
+ path: ["command"]
4014
+ }] };
4015
+ if (candidate.timeoutMs !== void 0 && (typeof candidate.timeoutMs !== "number" || candidate.timeoutMs <= 0)) return { issues: [{
4016
+ message: "timeoutMs must be a positive number",
4017
+ path: ["timeoutMs"]
4018
+ }] };
4019
+ if (candidate.background !== void 0 && typeof candidate.background !== "boolean") return { issues: [{
4020
+ message: "background must be a boolean",
4021
+ path: ["background"]
4022
+ }] };
4023
+ const result = { command: candidate.command };
4024
+ if (typeof candidate.timeoutMs === "number") result.timeoutMs = candidate.timeoutMs;
4025
+ if (candidate.background === true) result.background = true;
4026
+ return { value: result };
4027
+ }
4028
+ } }, runShellJsonSchema(background));
4029
+ }
4030
+ /** The lower of the model's timeout and the cap (either may be absent). */
4031
+ function cappedTimeout(requested, cap) {
4032
+ if (requested === void 0) return cap;
4033
+ return cap === void 0 ? requested : Math.min(requested, cap);
4034
+ }
1596
4035
  function makeRunShellTool(ops, options) {
4036
+ const background = options?.background === true;
1597
4037
  return (0, _warlock_js_ai.tool)({
1598
4038
  name: options?.name ?? DEFAULT_RUN_SHELL_TOOL_NAME,
1599
- description: "Run a single shell command inside the workspace. The command's executable must be permitted by the shell policy; output is byte-capped and the run is time-limited. A non-zero exit code is returned as data, not an error.",
4039
+ 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." : ""),
1600
4040
  action: (input) => `Running \`${input.command}\``,
1601
- input: runShellInputSchema,
1602
- execute: (input, ctx) => ops.exec(input.command, {
1603
- timeoutMs: input.timeoutMs,
1604
- ...ctx?.signal ? { signal: ctx.signal } : {}
1605
- })
4041
+ input: runShellInputSchema(background),
4042
+ execute: async (input, ctx) => {
4043
+ if (input.background === true) {
4044
+ if (!background) throw new WorkspacePolicyError("Background commands are not enabled for this workspace; run the command without background.", {
4045
+ type: "denied-command",
4046
+ reason: "not-allowed",
4047
+ command: input.command
4048
+ });
4049
+ return {
4050
+ ...await ops.startShell(input.command, input.timeoutMs !== void 0 ? { timeoutMs: input.timeoutMs } : void 0),
4051
+ background: true
4052
+ };
4053
+ }
4054
+ const timeoutMs = cappedTimeout(input.timeoutMs, options?.maxTimeoutMs);
4055
+ return ops.exec(input.command, {
4056
+ ...timeoutMs !== void 0 ? { timeoutMs } : {},
4057
+ ...ctx?.signal ? { signal: ctx.signal } : {}
4058
+ });
4059
+ }
1606
4060
  });
1607
4061
  }
1608
4062
 
@@ -1621,7 +4075,7 @@ const DEFAULT_TEST_COMMAND = "npm test";
1621
4075
  * happens without a runtime schema dependency, mirroring the wider tool
1622
4076
  * layer.
1623
4077
  */
1624
- const runTestsInputSchema = { "~standard": {
4078
+ const runTestsInputSchema = withJsonSchema({ "~standard": {
1625
4079
  version: 1,
1626
4080
  vendor: "@warlock.js/ai-workspace",
1627
4081
  validate: (value) => {
@@ -1640,7 +4094,12 @@ const runTestsInputSchema = { "~standard": {
1640
4094
  if (candidate.pattern !== void 0) result.pattern = candidate.pattern;
1641
4095
  return { value: result };
1642
4096
  }
1643
- } };
4097
+ } }, RUN_TESTS_JSON_SCHEMA);
4098
+ /** Quote the test pattern as one literal argument for the line's interpreter. */
4099
+ function quotePattern(pattern, interpreter) {
4100
+ if (interpreter === "sh" && process.platform !== "win32") return `'${pattern.replace(/'/g, "'\\''")}'`;
4101
+ return `"${pattern}"`;
4102
+ }
1644
4103
  /**
1645
4104
  * Build the `run_tests` tool — a {@link ToolContract} convenience over
1646
4105
  * `run_shell` that runs the workspace's configured test command through
@@ -1673,12 +4132,275 @@ function makeRunTestsTool(ops, options) {
1673
4132
  action: (input) => input.pattern ? `Running tests matching "${input.pattern}"` : "Running tests",
1674
4133
  input: runTestsInputSchema,
1675
4134
  execute: (input, ctx) => {
1676
- const command = input.pattern ? `${baseCommand} "${input.pattern}"` : baseCommand;
4135
+ const command = input.pattern ? `${baseCommand} ${quotePattern(input.pattern, options?.interpreter ?? "none")}` : baseCommand;
1677
4136
  return ops.exec(command, ctx?.signal ? { signal: ctx.signal } : void 0);
1678
4137
  }
1679
4138
  });
1680
4139
  }
1681
4140
 
4141
+ //#endregion
4142
+ //#region ../../packages/ai-workspace/src/tools/shell-kill.ts
4143
+ /** Default tool name exposed to the LLM. */
4144
+ const DEFAULT_NAME$2 = "shell_kill";
4145
+ /** Input schema for the `shell_kill` tool. */
4146
+ const inputSchema$2 = objectSchema({ id: stringField() }, SHELL_KILL_JSON_SCHEMA);
4147
+ /**
4148
+ * Build the agent-facing `shell_kill` tool: stop a background command
4149
+ * started with `run_shell({ background: true })`, killing its whole process
4150
+ * tree, and return its final state. Killing a command that already stopped
4151
+ * just reports its state. An unknown id surfaces as a `WorkspaceJobError`
4152
+ * in the result's `error` field.
4153
+ *
4154
+ * @param ops - The shared, policy-enforced operation layer.
4155
+ * @param options - Optional overrides; `name` renames the LLM-visible tool.
4156
+ *
4157
+ * @example
4158
+ * const { data } = await makeShellKillTool(ops).invoke({ id: "job-1" });
4159
+ * console.log(data?.status); // "killed"
4160
+ */
4161
+ function makeShellKillTool(ops, options) {
4162
+ return (0, _warlock_js_ai.tool)({
4163
+ name: options?.name ?? DEFAULT_NAME$2,
4164
+ description: "Stop a background command started with run_shell (background: true), killing it and everything it started. Returns its final state.",
4165
+ action: (input) => `Stopping ${typeof input.id === "string" ? input.id : "a background command"}`,
4166
+ input: inputSchema$2,
4167
+ execute: (input) => ops.shellKill(input.id)
4168
+ });
4169
+ }
4170
+
4171
+ //#endregion
4172
+ //#region ../../packages/ai-workspace/src/tools/shell-output.ts
4173
+ /** Default tool name exposed to the LLM. */
4174
+ const DEFAULT_NAME$1 = "shell_output";
4175
+ /** Input schema for the `shell_output` tool. */
4176
+ const inputSchema$1 = objectSchema({
4177
+ id: stringField(),
4178
+ since: optionalNumberField(),
4179
+ waitMs: optionalNumberField()
4180
+ }, SHELL_OUTPUT_JSON_SCHEMA);
4181
+ /**
4182
+ * Build the agent-facing `shell_output` tool: the new output of a
4183
+ * background command started with `run_shell({ background: true })`, plus
4184
+ * its state (`running` / `exited` / `killed` / `timed-out`, `exitCode`).
4185
+ *
4186
+ * Without `since` it returns everything produced since the previous read;
4187
+ * `cursor` in the result is the position to pass as `since` next time.
4188
+ * `waitMs` (at most 30 s) waits for new output or for the command to stop
4189
+ * when there is nothing new yet — the agent run's signal ends the wait
4190
+ * early. Only the newest `shell.maxOutputBytes` of a command's output are
4191
+ * kept; `droppedBytes` says how much the reader missed.
4192
+ *
4193
+ * @param ops - The shared, policy-enforced operation layer.
4194
+ * @param options - Optional overrides; `name` renames the LLM-visible tool.
4195
+ *
4196
+ * @example
4197
+ * const { data } = await makeShellOutputTool(ops).invoke({ id: "job-1", waitMs: 2_000 });
4198
+ * if (data?.stdout.includes("ready")) console.log("dev server is up");
4199
+ */
4200
+ function makeShellOutputTool(ops, options) {
4201
+ return (0, _warlock_js_ai.tool)({
4202
+ name: options?.name ?? DEFAULT_NAME$1,
4203
+ description: "Read new output of a background command started with run_shell (background: true) and see whether it is still running. Returns output since the last read; waitMs waits briefly for more.",
4204
+ action: (input) => `Reading output of ${typeof input.id === "string" ? input.id : "a background command"}`,
4205
+ input: inputSchema$1,
4206
+ execute: (input, ctx) => ops.shellOutput(input.id, {
4207
+ ...input.since !== void 0 ? { since: input.since } : {},
4208
+ ...input.waitMs !== void 0 ? { waitMs: input.waitMs } : {},
4209
+ ...ctx?.signal ? { signal: ctx.signal } : {}
4210
+ })
4211
+ });
4212
+ }
4213
+
4214
+ //#endregion
4215
+ //#region ../../packages/ai-workspace/src/todos.ts
4216
+ /** Most items a todo list may hold. */
4217
+ const MAX_TODOS = 100;
4218
+ /** Longest `content` one todo item may have. */
4219
+ const MAX_TODO_CONTENT_LENGTH = 1e3;
4220
+ /** The statuses a todo item may have, in display order. */
4221
+ const TODO_STATUSES = [
4222
+ "pending",
4223
+ "in_progress",
4224
+ "completed"
4225
+ ];
4226
+ /**
4227
+ * Validate a whole replacement list. Returns the problems found (empty when
4228
+ * the list is valid) so the tool schema and the direct method share one
4229
+ * set of rules.
4230
+ */
4231
+ function todoListProblems(todos) {
4232
+ const problems = [];
4233
+ const ids = /* @__PURE__ */ new Set();
4234
+ if (todos.length > 100) problems.push(`a todo list holds at most ${100} items (got ${todos.length})`);
4235
+ todos.forEach((todo, index) => {
4236
+ if (todo.content.trim() === "") problems.push(`todos[${index}].content must not be empty`);
4237
+ if (todo.content.length > 1e3) problems.push(`todos[${index}].content is longer than ${MAX_TODO_CONTENT_LENGTH} characters`);
4238
+ if (!TODO_STATUSES.includes(todo.status)) problems.push(`todos[${index}].status must be one of ${TODO_STATUSES.join(", ")}`);
4239
+ const id = todo.id ?? String(index + 1);
4240
+ if (ids.has(id)) problems.push(`todos[${index}].id "${id}" is used more than once`);
4241
+ ids.add(id);
4242
+ });
4243
+ return problems;
4244
+ }
4245
+ /**
4246
+ * The in-memory todo list of one workspace (shared by its projections).
4247
+ * `write` replaces the whole list, the way an agent's plan tool usually
4248
+ * works: the model sends the full updated list every time.
4249
+ */
4250
+ var TodoStore = class {
4251
+ constructor() {
4252
+ this.items = [];
4253
+ }
4254
+ /** The current list and a count per status. */
4255
+ read() {
4256
+ const counts = {
4257
+ pending: 0,
4258
+ in_progress: 0,
4259
+ completed: 0
4260
+ };
4261
+ for (const item of this.items) counts[item.status]++;
4262
+ return {
4263
+ todos: this.items.map((item) => ({ ...item })),
4264
+ counts
4265
+ };
4266
+ }
4267
+ /**
4268
+ * Replace the list. Items without an `id` get their 1-based position as
4269
+ * one. Throws a `RangeError` naming every problem when the list is invalid
4270
+ * (too long, empty content, unknown status, duplicate id); the old list is
4271
+ * kept in that case.
4272
+ */
4273
+ write(todos) {
4274
+ const problems = todoListProblems(todos);
4275
+ if (problems.length > 0) throw new RangeError(`Invalid todo list: ${problems.join("; ")}.`);
4276
+ this.items = todos.map((todo, index) => ({
4277
+ id: todo.id ?? String(index + 1),
4278
+ content: todo.content.trim(),
4279
+ status: todo.status
4280
+ }));
4281
+ return this.read();
4282
+ }
4283
+ };
4284
+
4285
+ //#endregion
4286
+ //#region ../../packages/ai-workspace/src/tools/todo.ts
4287
+ /** Whether `value` is a todo status. */
4288
+ function isTodoStatus(value) {
4289
+ return typeof value === "string" && TODO_STATUSES.includes(value);
4290
+ }
4291
+ /** Input schema for the `todo_write` tool. */
4292
+ const todoWriteSchema = withJsonSchema({ "~standard": {
4293
+ version: 1,
4294
+ vendor: "ai-workspace",
4295
+ validate(value) {
4296
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return { issues: [{ message: "input must be an object" }] };
4297
+ const raw = value.todos;
4298
+ if (!Array.isArray(raw) || raw.length > 100) return { issues: [{
4299
+ message: `"todos" must be an array of at most ${100} items`,
4300
+ path: ["todos"]
4301
+ }] };
4302
+ const issues = [];
4303
+ const todos = [];
4304
+ raw.forEach((item, index) => {
4305
+ if (typeof item !== "object" || item === null || Array.isArray(item)) {
4306
+ issues.push({
4307
+ message: `todos[${index}] must be an object`,
4308
+ path: ["todos", index]
4309
+ });
4310
+ return;
4311
+ }
4312
+ const candidate = item;
4313
+ if (typeof candidate.content !== "string") issues.push({
4314
+ message: `todos[${index}].content must be a string`,
4315
+ path: [
4316
+ "todos",
4317
+ index,
4318
+ "content"
4319
+ ]
4320
+ });
4321
+ if (!isTodoStatus(candidate.status)) issues.push({
4322
+ message: `todos[${index}].status must be one of ${TODO_STATUSES.join(", ")}`,
4323
+ path: [
4324
+ "todos",
4325
+ index,
4326
+ "status"
4327
+ ]
4328
+ });
4329
+ if (candidate.id !== void 0 && typeof candidate.id !== "string") issues.push({
4330
+ message: `todos[${index}].id must be a string when provided`,
4331
+ path: [
4332
+ "todos",
4333
+ index,
4334
+ "id"
4335
+ ]
4336
+ });
4337
+ if (typeof candidate.content === "string" && isTodoStatus(candidate.status)) todos.push({
4338
+ content: candidate.content,
4339
+ status: candidate.status,
4340
+ ...typeof candidate.id === "string" ? { id: candidate.id } : {}
4341
+ });
4342
+ });
4343
+ if (issues.length === 0) for (const problem of todoListProblems(todos)) issues.push({
4344
+ message: problem,
4345
+ path: ["todos"]
4346
+ });
4347
+ return issues.length > 0 ? { issues } : { value: { todos } };
4348
+ }
4349
+ } }, TODO_WRITE_JSON_SCHEMA);
4350
+ /** Input schema for the `todo_read` tool (no arguments). */
4351
+ const todoReadSchema = withJsonSchema({ "~standard": {
4352
+ version: 1,
4353
+ vendor: "ai-workspace",
4354
+ validate: () => ({ value: {} })
4355
+ } }, TODO_READ_JSON_SCHEMA);
4356
+ /**
4357
+ * Build the agent-facing `todo_write` tool over a workspace's todo list:
4358
+ * the model sends the **complete** updated list (each item `content` +
4359
+ * `status` of `pending` / `in_progress` / `completed`, optional `id`) and it
4360
+ * replaces the old one. The list lives in memory, per workspace (shared by
4361
+ * its `readonly()` / `scope()` projections), for the length of the run.
4362
+ *
4363
+ * @param store - The workspace's todo list.
4364
+ * @param options - Optional overrides; `name` renames the LLM-visible tool.
4365
+ *
4366
+ * @example
4367
+ * await makeTodoWriteTool(store).invoke({
4368
+ * todos: [
4369
+ * { content: "Reproduce the failing test", status: "completed" },
4370
+ * { content: "Fix the rounding bug", status: "in_progress" },
4371
+ * ],
4372
+ * });
4373
+ */
4374
+ function makeTodoWriteTool(store, options) {
4375
+ return (0, _warlock_js_ai.tool)({
4376
+ name: options?.name ?? "todo_write",
4377
+ description: "Replace your todo list for this task with the complete updated list (each item: content and status pending | in_progress | completed). Use it to plan multi-step work and to mark progress.",
4378
+ action: "Updating the todo list",
4379
+ input: todoWriteSchema,
4380
+ execute: async (input) => store.write(input.todos)
4381
+ });
4382
+ }
4383
+ /**
4384
+ * Build the agent-facing `todo_read` tool: the workspace's current todo
4385
+ * list and a count per status.
4386
+ *
4387
+ * @param store - The workspace's todo list.
4388
+ * @param options - Optional overrides; `name` renames the LLM-visible tool.
4389
+ *
4390
+ * @example
4391
+ * const { data } = await makeTodoReadTool(store).invoke({});
4392
+ * console.log(data?.counts.pending);
4393
+ */
4394
+ function makeTodoReadTool(store, options) {
4395
+ return (0, _warlock_js_ai.tool)({
4396
+ name: options?.name ?? "todo_read",
4397
+ description: "Read your current todo list for this task.",
4398
+ action: "Reading the todo list",
4399
+ input: todoReadSchema,
4400
+ execute: async () => store.read()
4401
+ });
4402
+ }
4403
+
1682
4404
  //#endregion
1683
4405
  //#region ../../packages/ai-workspace/src/tools/write-file.ts
1684
4406
  /** Default tool name exposed to the LLM. */
@@ -1687,7 +4409,7 @@ const DEFAULT_NAME = "write_file";
1687
4409
  const inputSchema = objectSchema({
1688
4410
  path: stringField(),
1689
4411
  content: stringField()
1690
- });
4412
+ }, WRITE_FILE_JSON_SCHEMA);
1691
4413
  /**
1692
4414
  * Build the agent-facing `write_file` tool over a workspace's policy-
1693
4415
  * enforced {@link WorkspaceOps}.
@@ -1735,23 +4457,35 @@ function makeWriteFileTool(ops, options) {
1735
4457
  const ALL_TOOL_NAMES = [
1736
4458
  "readFile",
1737
4459
  "editFile",
4460
+ "multiEdit",
1738
4461
  "writeFile",
4462
+ "listDir",
1739
4463
  "runShell",
4464
+ "shellOutput",
4465
+ "shellKill",
1740
4466
  "runTests",
1741
4467
  "grep",
1742
- "glob"
4468
+ "glob",
4469
+ "todoWrite",
4470
+ "todoRead"
1743
4471
  ];
1744
4472
  /**
1745
4473
  * The subset a {@link Workspace.readonly} projection exposes — the
1746
- * non-mutating tools only. `editFile` / `writeFile` / `runShell` /
1747
- * `runTests` are deliberately omitted so a reviewer agent has no path to
1748
- * change the tree.
4474
+ * tools that cannot change the tree. `editFile` / `multiEdit` /
4475
+ * `writeFile` / the shell tools / `runTests` are deliberately omitted so a
4476
+ * reviewer agent has no path to change it. The todo tools stay: they only
4477
+ * touch the in-memory plan.
1749
4478
  */
1750
4479
  const READONLY_TOOL_NAMES = [
1751
4480
  "readFile",
4481
+ "listDir",
1752
4482
  "grep",
1753
- "glob"
4483
+ "glob",
4484
+ "todoWrite",
4485
+ "todoRead"
1754
4486
  ];
4487
+ /** Tools vended only when the policy enables background commands. */
4488
+ const BACKGROUND_TOOL_NAMES = new Set(["shellOutput", "shellKill"]);
1755
4489
  /**
1756
4490
  * Choose the dumb IO executor for a policy. `"mock"` selects the
1757
4491
  * in-memory backend (hermetic tests); anything else — including the
@@ -1776,11 +4510,15 @@ function selectBackend(policy) {
1776
4510
  * Constructed via {@link workspace}; the class itself is internal.
1777
4511
  */
1778
4512
  var WorkspaceImpl = class WorkspaceImpl {
1779
- constructor(policy, allowedTools = ALL_TOOL_NAMES) {
4513
+ constructor(policy, allowedTools = ALL_TOOL_NAMES, state) {
1780
4514
  this.policy = policy;
1781
4515
  this.allowedTools = new Set(allowedTools);
4516
+ this.state = state ?? {
4517
+ jobs: new ShellJobs(),
4518
+ todos: new TodoStore()
4519
+ };
1782
4520
  const backend = selectBackend(policy);
1783
- this.ops = createOps(backend, policy);
4521
+ this.ops = createOps(backend, policy, { jobs: this.state.jobs });
1784
4522
  this.tools = this.buildTools();
1785
4523
  }
1786
4524
  /**
@@ -1792,26 +4530,49 @@ var WorkspaceImpl = class WorkspaceImpl {
1792
4530
  */
1793
4531
  buildTools() {
1794
4532
  const erase = (contract) => contract;
4533
+ const shell = this.policy.shell;
4534
+ const background = shell?.background !== void 0;
4535
+ const interpreter = shell?.interpreter ?? "none";
1795
4536
  const factories = {
1796
4537
  readFile: (opts) => erase(makeReadFileTool(this.ops, opts)),
1797
4538
  editFile: (opts) => erase(makeEditFileTool(this.ops, opts)),
4539
+ multiEdit: (opts) => erase(makeMultiEditTool(this.ops, opts)),
1798
4540
  writeFile: (opts) => erase(makeWriteFileTool(this.ops, opts)),
1799
- runShell: (opts) => erase(makeRunShellTool(this.ops, opts)),
1800
- runTests: (opts) => erase(makeRunTestsTool(this.ops, opts)),
4541
+ listDir: (opts) => erase(makeListDirTool(this.ops, opts)),
4542
+ runShell: (opts) => erase(makeRunShellTool(this.ops, {
4543
+ ...opts?.name !== void 0 ? { name: opts.name } : {},
4544
+ ...shell?.timeoutMs !== void 0 ? { maxTimeoutMs: shell.timeoutMs } : {},
4545
+ background
4546
+ })),
4547
+ shellOutput: (opts) => erase(makeShellOutputTool(this.ops, opts)),
4548
+ shellKill: (opts) => erase(makeShellKillTool(this.ops, opts)),
4549
+ runTests: (opts) => erase(makeRunTestsTool(this.ops, {
4550
+ ...opts,
4551
+ interpreter
4552
+ })),
1801
4553
  grep: (opts) => erase(makeGrepTool(this.ops, opts)),
1802
- glob: (opts) => erase(makeGlobTool(this.ops, opts))
4554
+ glob: (opts) => erase(makeGlobTool(this.ops, opts)),
4555
+ todoWrite: (opts) => erase(makeTodoWriteTool(this.state.todos, opts)),
4556
+ todoRead: (opts) => erase(makeTodoReadTool(this.state.todos, opts))
1803
4557
  };
1804
4558
  const build = (name, opts) => factories[name](opts);
4559
+ const vendedByAll = (name) => this.allowedTools.has(name) && (background || !BACKGROUND_TOOL_NAMES.has(name));
1805
4560
  return {
1806
- all: () => ALL_TOOL_NAMES.filter((name) => this.allowedTools.has(name)).map((name) => build(name)),
4561
+ all: () => ALL_TOOL_NAMES.filter(vendedByAll).map((name) => build(name)),
1807
4562
  pick: (...names) => names.filter((name) => this.allowedTools.has(name)).map((name) => build(name)),
1808
4563
  readFile: (opts) => build("readFile", opts),
1809
4564
  editFile: (opts) => build("editFile", opts),
4565
+ multiEdit: (opts) => build("multiEdit", opts),
1810
4566
  writeFile: (opts) => build("writeFile", opts),
4567
+ listDir: (opts) => build("listDir", opts),
1811
4568
  runShell: (opts) => build("runShell", opts),
4569
+ shellOutput: (opts) => build("shellOutput", opts),
4570
+ shellKill: (opts) => build("shellKill", opts),
1812
4571
  runTests: (opts) => build("runTests", opts),
1813
4572
  grep: (opts) => build("grep", opts),
1814
- glob: (opts) => build("glob", opts)
4573
+ glob: (opts) => build("glob", opts),
4574
+ todoWrite: (opts) => build("todoWrite", opts),
4575
+ todoRead: (opts) => build("todoRead", opts)
1815
4576
  };
1816
4577
  }
1817
4578
  /**
@@ -1837,10 +4598,40 @@ var WorkspaceImpl = class WorkspaceImpl {
1837
4598
  this.assertWritable("editFile");
1838
4599
  return this.ops.editFile(input);
1839
4600
  }
4601
+ async multiEdit(input) {
4602
+ this.assertWritable("multiEdit");
4603
+ return this.ops.multiEdit(input);
4604
+ }
4605
+ listDir(dirPath, opts) {
4606
+ return this.ops.listDir(dirPath, opts);
4607
+ }
1840
4608
  async exec(command, opts) {
1841
4609
  this.assertWritable("exec");
1842
4610
  return this.ops.exec(command, opts);
1843
4611
  }
4612
+ async startShell(command, opts) {
4613
+ this.assertWritable("startShell");
4614
+ return this.ops.startShell(command, opts);
4615
+ }
4616
+ shellOutput(id, opts) {
4617
+ return this.ops.shellOutput(id, opts);
4618
+ }
4619
+ async shellKill(id) {
4620
+ this.assertWritable("shellKill");
4621
+ return this.ops.shellKill(id);
4622
+ }
4623
+ listShells() {
4624
+ return this.ops.listShells();
4625
+ }
4626
+ readTodos() {
4627
+ return this.state.todos.read();
4628
+ }
4629
+ writeTodos(todos) {
4630
+ return this.state.todos.write(todos);
4631
+ }
4632
+ dispose() {
4633
+ return this.ops.dispose();
4634
+ }
1844
4635
  grep(pattern, opts) {
1845
4636
  return this.ops.grep(pattern, opts);
1846
4637
  }
@@ -1859,44 +4650,49 @@ var WorkspaceImpl = class WorkspaceImpl {
1859
4650
  return this.ops.remove(filePath);
1860
4651
  }
1861
4652
  /**
1862
- * A read-only projection over the SAME policy — only the read/grep/glob
1863
- * tools are vended and every mutating direct method rejects with a
1864
- * {@link WorkspacePolicyError}. A fresh ops/backend is built from the
1865
- * identical policy, so the projection sees the same jailed tree.
4653
+ * A read-only projection over the SAME policy — only the read / list /
4654
+ * grep / glob / todo tools are vended and every mutating direct method
4655
+ * rejects with a {@link WorkspacePolicyError}. A fresh ops/backend is
4656
+ * built from the identical policy, so the projection sees the same jailed
4657
+ * tree; it shares this workspace's background commands and todo list.
1866
4658
  */
1867
4659
  readonly() {
1868
- return new WorkspaceImpl(this.policy, READONLY_TOOL_NAMES);
4660
+ return new WorkspaceImpl(this.policy, READONLY_TOOL_NAMES.filter((name) => this.allowedTools.has(name)), this.state);
1869
4661
  }
1870
4662
  /**
1871
4663
  * A sub-jailed view rooted at `subdir` (relative to this workspace's
1872
4664
  * `cwd`). Returns a brand-new workspace whose policy is this policy
1873
4665
  * with `cwd` narrowed to `join(cwd, subdir)` — same backend selection,
1874
- * same allow/deny/shell/read sub-policies, but a tighter jail root.
4666
+ * same allow/deny/shell/read sub-policies, but a tighter jail root. It
4667
+ * shares this workspace's background commands and todo list.
1875
4668
  */
1876
4669
  scope(subdir) {
1877
4670
  return new WorkspaceImpl({
1878
4671
  ...this.policy,
1879
4672
  cwd: node_path.default.join(this.policy.cwd, subdir)
1880
- }, [...this.allowedTools]);
4673
+ }, [...this.allowedTools], this.state);
1881
4674
  }
1882
4675
  };
1883
4676
  /**
1884
4677
  * Build a {@link Workspace} — the integrator that wires a
1885
4678
  * {@link WorkspacePolicy} to a backend, the shared policy-enforced ops
1886
- * layer, and the seven agent-facing tool factories.
4679
+ * layer, and the agent-facing tool factories.
1887
4680
  *
1888
4681
  * The backend is chosen from `policy.backend`: `"mock"` runs in memory
1889
4682
  * (hermetic tests); the `"local"` default (and any absent value) runs
1890
4683
  * over the real disk via `@warlock.js/fs` + `node:child_process`. The
1891
4684
  * returned workspace exposes:
1892
4685
  *
1893
- * - **`tools.*`** — `readFile` / `editFile` / `writeFile` / `runShell` /
1894
- * `runTests` / `grep` / `glob`, plus `all()` (every tool) and
1895
- * `pick(...)` (a least-privilege subset).
1896
- * - **direct methods** — `readFile` / `writeFile` / `editFile` / `exec` /
1897
- * `grep` / `glob` / `exists` / `mkdir` / `remove`, each delegating 1:1
1898
- * to the shared ops layer.
1899
- * - **`readonly()`** — a projection that vends only read/grep/glob and
4686
+ * - **`tools.*`** — `readFile` / `editFile` / `multiEdit` / `writeFile` /
4687
+ * `listDir` / `runShell` / `shellOutput` / `shellKill` / `runTests` /
4688
+ * `grep` / `glob` / `todoWrite` / `todoRead`, plus `all()` (every tool)
4689
+ * and `pick(...)` (a least-privilege subset).
4690
+ * - **direct methods** — `readFile` / `writeFile` / `editFile` /
4691
+ * `multiEdit` / `listDir` / `exec` / `startShell` / `shellOutput` /
4692
+ * `shellKill` / `listShells` / `grep` / `glob` / `exists` / `mkdir` /
4693
+ * `remove` / `readTodos` / `writeTodos`, each delegating to the shared
4694
+ * ops layer, and `dispose()`.
4695
+ * - **`readonly()`** — a projection that vends only the read-side tools and
1900
4696
  * rejects every mutating direct method.
1901
4697
  * - **`scope(subdir)`** — a sub-jailed workspace rooted at `subdir`.
1902
4698
  *
@@ -1921,9 +4717,13 @@ function workspace(policy) {
1921
4717
  _warlock_js_ai.ai.workspace = workspace;
1922
4718
 
1923
4719
  //#endregion
4720
+ exports.TodoStore = TodoStore;
1924
4721
  exports.WorkspaceEditError = WorkspaceEditError;
4722
+ exports.WorkspaceJobError = WorkspaceJobError;
1925
4723
  exports.WorkspacePolicyError = WorkspacePolicyError;
4724
+ exports.analyzeArgvCommand = analyzeArgvCommand;
1926
4725
  exports.buildEnv = buildEnv;
4726
+ exports.checkShellCommand = checkShellCommand;
1927
4727
  exports.createLocalBackend = createLocalBackend;
1928
4728
  exports.createMockBackend = createMockBackend;
1929
4729
  exports.createOps = createOps;
@@ -1931,10 +4731,17 @@ exports.isCommandAllowed = isCommandAllowed;
1931
4731
  exports.makeEditFileTool = makeEditFileTool;
1932
4732
  exports.makeGlobTool = makeGlobTool;
1933
4733
  exports.makeGrepTool = makeGrepTool;
4734
+ exports.makeListDirTool = makeListDirTool;
4735
+ exports.makeMultiEditTool = makeMultiEditTool;
1934
4736
  exports.makeReadFileTool = makeReadFileTool;
1935
4737
  exports.makeRunShellTool = makeRunShellTool;
1936
4738
  exports.makeRunTestsTool = makeRunTestsTool;
4739
+ exports.makeShellKillTool = makeShellKillTool;
4740
+ exports.makeShellOutputTool = makeShellOutputTool;
4741
+ exports.makeTodoReadTool = makeTodoReadTool;
4742
+ exports.makeTodoWriteTool = makeTodoWriteTool;
1937
4743
  exports.makeWriteFileTool = makeWriteFileTool;
1938
4744
  exports.resolveInJail = resolveInJail;
4745
+ exports.tokenizeCommand = tokenizeCommand;
1939
4746
  exports.workspace = workspace;
1940
4747
  //# sourceMappingURL=index.cjs.map