@warlock.js/ai-workspace 5.29.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 (120) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/README.md +40 -21
  3. package/cjs/index.cjs +3295 -360
  4. package/cjs/index.cjs.map +1 -1
  5. package/esm/backends/local.d.mts +1 -1
  6. package/esm/backends/local.d.mts.map +1 -1
  7. package/esm/backends/local.mjs +199 -31
  8. package/esm/backends/local.mjs.map +1 -1
  9. package/esm/backends/mock.d.mts +12 -6
  10. package/esm/backends/mock.d.mts.map +1 -1
  11. package/esm/backends/mock.mjs +45 -3
  12. package/esm/backends/mock.mjs.map +1 -1
  13. package/esm/contracts/index.d.mts +3 -3
  14. package/esm/contracts/tool-io.type.d.mts +248 -11
  15. package/esm/contracts/tool-io.type.d.mts.map +1 -1
  16. package/esm/contracts/workspace-backend.contract.d.mts +78 -2
  17. package/esm/contracts/workspace-backend.contract.d.mts.map +1 -1
  18. package/esm/contracts/workspace-ops.contract.d.mts +41 -16
  19. package/esm/contracts/workspace-ops.contract.d.mts.map +1 -1
  20. package/esm/contracts/workspace-policy.type.d.mts +142 -14
  21. package/esm/contracts/workspace-policy.type.d.mts.map +1 -1
  22. package/esm/contracts/workspace.contract.d.mts +75 -22
  23. package/esm/contracts/workspace.contract.d.mts.map +1 -1
  24. package/esm/errors.d.mts +80 -6
  25. package/esm/errors.d.mts.map +1 -1
  26. package/esm/errors.mjs +23 -2
  27. package/esm/errors.mjs.map +1 -1
  28. package/esm/index.d.mts +13 -5
  29. package/esm/index.mjs +10 -2
  30. package/esm/ops.d.mts +22 -3
  31. package/esm/ops.d.mts.map +1 -1
  32. package/esm/ops.mjs +341 -82
  33. package/esm/ops.mjs.map +1 -1
  34. package/esm/policy/jail.mjs +73 -0
  35. package/esm/policy/jail.mjs.map +1 -0
  36. package/esm/policy/policy.d.mts +26 -16
  37. package/esm/policy/policy.d.mts.map +1 -1
  38. package/esm/policy/policy.mjs +62 -86
  39. package/esm/policy/policy.mjs.map +1 -1
  40. package/esm/policy/shell-check.d.mts +64 -0
  41. package/esm/policy/shell-check.d.mts.map +1 -0
  42. package/esm/policy/shell-check.mjs +627 -0
  43. package/esm/policy/shell-check.mjs.map +1 -0
  44. package/esm/policy/shell-parser.mjs +802 -0
  45. package/esm/policy/shell-parser.mjs.map +1 -0
  46. package/esm/policy/tokenize-command.d.mts +63 -0
  47. package/esm/policy/tokenize-command.d.mts.map +1 -0
  48. package/esm/policy/tokenize-command.mjs +76 -24
  49. package/esm/policy/tokenize-command.mjs.map +1 -1
  50. package/esm/shell-jobs.d.mts +45 -0
  51. package/esm/shell-jobs.d.mts.map +1 -0
  52. package/esm/shell-jobs.mjs +247 -0
  53. package/esm/shell-jobs.mjs.map +1 -0
  54. package/esm/todos.d.mts +22 -0
  55. package/esm/todos.d.mts.map +1 -0
  56. package/esm/todos.mjs +73 -0
  57. package/esm/todos.mjs.map +1 -0
  58. package/esm/tools/edit-file.d.mts +1 -1
  59. package/esm/tools/edit-file.d.mts.map +1 -1
  60. package/esm/tools/edit-file.mjs +3 -2
  61. package/esm/tools/edit-file.mjs.map +1 -1
  62. package/esm/tools/glob.d.mts +1 -1
  63. package/esm/tools/glob.d.mts.map +1 -1
  64. package/esm/tools/glob.mjs +3 -2
  65. package/esm/tools/glob.mjs.map +1 -1
  66. package/esm/tools/grep.d.mts +1 -1
  67. package/esm/tools/grep.d.mts.map +1 -1
  68. package/esm/tools/grep.mjs +3 -2
  69. package/esm/tools/grep.mjs.map +1 -1
  70. package/esm/tools/json-schemas.mjs +175 -0
  71. package/esm/tools/json-schemas.mjs.map +1 -0
  72. package/esm/tools/list-dir.d.mts +32 -0
  73. package/esm/tools/list-dir.d.mts.map +1 -0
  74. package/esm/tools/list-dir.mjs +50 -0
  75. package/esm/tools/list-dir.mjs.map +1 -0
  76. package/esm/tools/multi-edit.d.mts +38 -0
  77. package/esm/tools/multi-edit.d.mts.map +1 -0
  78. package/esm/tools/multi-edit.mjs +121 -0
  79. package/esm/tools/multi-edit.mjs.map +1 -0
  80. package/esm/tools/read-file.d.mts +5 -5
  81. package/esm/tools/read-file.d.mts.map +1 -1
  82. package/esm/tools/read-file.mjs +10 -20
  83. package/esm/tools/read-file.mjs.map +1 -1
  84. package/esm/tools/run-shell.d.mts +39 -13
  85. package/esm/tools/run-shell.d.mts.map +1 -1
  86. package/esm/tools/run-shell.mjs +60 -47
  87. package/esm/tools/run-shell.mjs.map +1 -1
  88. package/esm/tools/run-tests.d.mts +11 -2
  89. package/esm/tools/run-tests.d.mts.map +1 -1
  90. package/esm/tools/run-tests.mjs +15 -7
  91. package/esm/tools/run-tests.mjs.map +1 -1
  92. package/esm/tools/schema.mjs +24 -19
  93. package/esm/tools/schema.mjs.map +1 -1
  94. package/esm/tools/shell-kill.d.mts +25 -0
  95. package/esm/tools/shell-kill.d.mts.map +1 -0
  96. package/esm/tools/shell-kill.mjs +36 -0
  97. package/esm/tools/shell-kill.mjs.map +1 -0
  98. package/esm/tools/shell-output.d.mts +30 -0
  99. package/esm/tools/shell-output.d.mts.map +1 -0
  100. package/esm/tools/shell-output.mjs +49 -0
  101. package/esm/tools/shell-output.mjs.map +1 -0
  102. package/esm/tools/todo.d.mts +43 -0
  103. package/esm/tools/todo.d.mts.map +1 -0
  104. package/esm/tools/todo.mjs +126 -0
  105. package/esm/tools/todo.mjs.map +1 -0
  106. package/esm/tools/write-file.d.mts +1 -1
  107. package/esm/tools/write-file.d.mts.map +1 -1
  108. package/esm/tools/write-file.mjs +3 -2
  109. package/esm/tools/write-file.mjs.map +1 -1
  110. package/esm/workspace.d.mts +12 -9
  111. package/esm/workspace.d.mts.map +1 -1
  112. package/esm/workspace.mjs +109 -28
  113. package/esm/workspace.mjs.map +1 -1
  114. package/llms-full.txt +192 -35
  115. package/llms.txt +1 -1
  116. package/package.json +5 -4
  117. package/skills/README.md +2 -2
  118. package/skills/build-loop-agent/SKILL.md +7 -7
  119. package/skills/index.md +6 -1
  120. package/skills/use-a-workspace/SKILL.md +185 -28
@@ -0,0 +1,627 @@
1
+ import { isInsideJailLexically } from "./jail.mjs";
2
+ import { parseShellLine } from "./shell-parser.mjs";
3
+ import { analyzeArgvCommand, tokenizeCommand } from "./tokenize-command.mjs";
4
+ import path from "node:path";
5
+
6
+ //#region ../../packages/ai-workspace/src/policy/shell-check.ts
7
+ /**
8
+ * Programs and shell builtins that run a command or code the policy cannot
9
+ * see (a script string, a file, a command read from input, another user).
10
+ * With deny rules in force they would hide a denied command, so they are
11
+ * refused unless the policy names them in `allow`.
12
+ */
13
+ const OPAQUE_RUNNERS = new Set([
14
+ "sh",
15
+ "bash",
16
+ "dash",
17
+ "zsh",
18
+ "ksh",
19
+ "mksh",
20
+ "ash",
21
+ "yash",
22
+ "posh",
23
+ "fish",
24
+ "csh",
25
+ "tcsh",
26
+ "busybox",
27
+ "cmd",
28
+ "powershell",
29
+ "pwsh",
30
+ "wsl",
31
+ "sudo",
32
+ "doas",
33
+ "su",
34
+ "runuser",
35
+ "pkexec",
36
+ "runas",
37
+ "chroot",
38
+ "nsenter",
39
+ "unshare",
40
+ "setsid",
41
+ "systemd-run",
42
+ "start-stop-daemon",
43
+ "daemonize",
44
+ "xargs",
45
+ "parallel",
46
+ "watch",
47
+ "script",
48
+ "expect",
49
+ "at",
50
+ "batch",
51
+ "screen",
52
+ "tmux",
53
+ "stdbuf",
54
+ "flock"
55
+ ]);
56
+ /** POSIX shell builtins that evaluate code or change how later names resolve. */
57
+ const SHELL_OPAQUE_BUILTINS = new Set([
58
+ "eval",
59
+ ".",
60
+ "source",
61
+ "alias",
62
+ "unalias",
63
+ "hash",
64
+ "enable",
65
+ "trap",
66
+ "let",
67
+ "declare",
68
+ "typeset",
69
+ "local",
70
+ "set",
71
+ "shopt",
72
+ "fc",
73
+ "bind",
74
+ "complete",
75
+ "compgen",
76
+ "compopt",
77
+ "mapfile",
78
+ "readarray"
79
+ ]);
80
+ /** cmd.exe builtins that run commands or change the environment. */
81
+ const CMD_OPAQUE_BUILTINS = new Set([
82
+ "call",
83
+ "start",
84
+ "set",
85
+ "pushd",
86
+ "popd"
87
+ ]);
88
+ /**
89
+ * Shell builtins whose arguments bash may evaluate as arithmetic (array
90
+ * subscripts such as `a[$(cmd)]`): their arguments must be literal and free
91
+ * of `[`.
92
+ */
93
+ const ARITHMETIC_SENSITIVE_BUILTINS = new Set([
94
+ "test",
95
+ "[",
96
+ "printf",
97
+ "read",
98
+ "unset",
99
+ "export",
100
+ "readonly",
101
+ "getopts",
102
+ "wait",
103
+ "kill",
104
+ "shift",
105
+ "return",
106
+ "exit",
107
+ "break",
108
+ "continue",
109
+ "ulimit"
110
+ ]);
111
+ /**
112
+ * Variables a shell-mode line may not assign: they change how the shell
113
+ * parses, traces or resolves what follows (`CDPATH` would move `cd` out of
114
+ * the jail's sight, `PS4` is expanded — command substitutions included —
115
+ * on every traced line, `IFS` re-splits expansions).
116
+ */
117
+ const SENSITIVE_VARIABLES = new Set([
118
+ "CDPATH",
119
+ "ENV",
120
+ "BASH_ENV",
121
+ "SHELLOPTS",
122
+ "BASHOPTS",
123
+ "IFS",
124
+ "PS1",
125
+ "PS2",
126
+ "PS3",
127
+ "PS4",
128
+ "PROMPT_COMMAND",
129
+ "BASH_XTRACEFD",
130
+ "GLOBIGNORE",
131
+ "EXECIGNORE",
132
+ "BASH_LOADABLES_PATH"
133
+ ]);
134
+ /** Redirection targets that are always fine even though they sit outside the jail. */
135
+ const DEVICE_TARGETS = new Set([
136
+ "/dev/null",
137
+ "/dev/stdout",
138
+ "/dev/stderr"
139
+ ]);
140
+ /** cmd.exe's null device. */
141
+ const CMD_DEVICE_TARGETS = new Set(["nul"]);
142
+ /** Deepest chain of wrappers (`env time nice …`) the policy unwraps. */
143
+ const MAX_WRAPPER_DEPTH = 8;
144
+ /** Most distinct directories a line may `cd` between. */
145
+ const MAX_CD_CANDIDATES = 32;
146
+ /** Longest command shown verbatim in a refusal before it is abbreviated. */
147
+ const MAX_SHOWN_COMMAND = 48;
148
+ /** The dialect shell mode parses on this platform. */
149
+ function shellDialect() {
150
+ return process.platform === "win32" ? "cmd" : "posix";
151
+ }
152
+ /**
153
+ * Reduce an argv's first element to the basename the allow/deny policy is
154
+ * keyed on. `"npm"` → `"npm"`; `"/usr/bin/node"` → `"node"`; `"node.exe"`
155
+ * → `"node"` (the `.exe`/`.cmd`/`.bat`/`.com` Windows extension is
156
+ * stripped). The cmd dialect splits Windows paths too.
157
+ */
158
+ function executableBasename(firstToken, dialect = "posix") {
159
+ return (dialect === "cmd" ? path.win32.basename(firstToken) : path.basename(firstToken)).replace(/\.(exe|cmd|bat|com)$/i, "");
160
+ }
161
+ /** Show a command in a refusal, abbreviated at a word boundary when long. */
162
+ function show(source) {
163
+ const flat = source.replace(/\s+/g, " ").trim();
164
+ if (flat.length <= MAX_SHOWN_COMMAND) return `\`${flat}\``;
165
+ const cut = flat.lastIndexOf(" ", MAX_SHOWN_COMMAND);
166
+ return `\`${flat.slice(0, cut > 0 ? cut : MAX_SHOWN_COMMAND)} …\``;
167
+ }
168
+ function compilePattern(pattern) {
169
+ if (pattern instanceof RegExp) {
170
+ const regex = new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, ""));
171
+ return {
172
+ source: pattern,
173
+ test: (argv, dialect) => regex.test([executableBasename(argv[0] ?? "", dialect), ...argv.slice(1)].join(" "))
174
+ };
175
+ }
176
+ const tokens = tokenizeCommand(pattern);
177
+ if (tokens === null) return {
178
+ source: pattern,
179
+ test: (argv, dialect) => [executableBasename(argv[0] ?? "", dialect), ...argv.slice(1)].join(" ").includes(pattern)
180
+ };
181
+ return {
182
+ source: pattern,
183
+ test: (argv, dialect) => {
184
+ if (argv.length < tokens.length) return false;
185
+ return tokens.every((token, index) => {
186
+ const actual = argv[index] ?? "";
187
+ return index === 0 ? executableBasename(actual, dialect).toLowerCase() === executableBasename(token, dialect).toLowerCase() : actual === token;
188
+ });
189
+ }
190
+ };
191
+ }
192
+ /** Thrown inside the checker to stop at the first refusal. */
193
+ var Refusal = class extends Error {
194
+ constructor(reason, message, executable, refusedPath) {
195
+ super(message);
196
+ this.reason = reason;
197
+ this.executable = executable;
198
+ this.refusedPath = refusedPath;
199
+ }
200
+ };
201
+ /** Per-line checker state. */
202
+ var LineChecker = class {
203
+ constructor(policy, shell, line, dialect) {
204
+ this.policy = policy;
205
+ this.shell = shell;
206
+ this.line = line;
207
+ this.dialect = dialect;
208
+ this.commands = [];
209
+ this.paths = [];
210
+ this.deny = new Set((shell.deny ?? []).map((name) => name.toLowerCase()));
211
+ this.allow = shell.allow ?? [];
212
+ this.patterns = (shell.denyPatterns ?? []).map(compilePattern);
213
+ this.interpreter = shell.interpreter ?? "none";
214
+ this.hasCommandRules = this.deny.size > 0 || this.patterns.length > 0 || shell.allowCommand !== void 0;
215
+ }
216
+ /** Whether argument hooks (`denyPatterns` / `allowCommand`) need literal argv. */
217
+ get hasArgumentHooks() {
218
+ return this.patterns.length > 0 || this.shell.allowCommand !== void 0;
219
+ }
220
+ get shellMode() {
221
+ return this.interpreter === "sh";
222
+ }
223
+ /** Run `denyPatterns` and `allowCommand` on one argv. */
224
+ checkHooks(argv, nested) {
225
+ const shown = show(argv.join(" "));
226
+ 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));
227
+ const hook = this.shell.allowCommand;
228
+ if (hook === void 0) return;
229
+ let verdict;
230
+ try {
231
+ verdict = hook([...argv], {
232
+ command: this.line,
233
+ interpreter: this.interpreter,
234
+ nested
235
+ });
236
+ } catch {
237
+ verdict = false;
238
+ }
239
+ if (verdict === true) return;
240
+ 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));
241
+ }
242
+ /** Whether the policy names `executable` in a non-empty `allow` list. */
243
+ explicitlyAllowed(executable) {
244
+ return this.allow.includes(executable);
245
+ }
246
+ /** Refuse a command runner the policy cannot see into, unless explicitly allowed. */
247
+ checkOpaque(executable, argv) {
248
+ if (this.explicitlyAllowed(executable) || !this.hasCommandRules) return;
249
+ 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);
250
+ }
251
+ /**
252
+ * Check one argv: `deny`, `allow`, `denyPatterns`, `allowCommand`, then
253
+ * follow wrappers and `find -exec` to the command they run. `dynamic[i]`
254
+ * says whether `argv[i]` is decided at run time (shell mode).
255
+ */
256
+ checkArgv(argv, dynamic, depth) {
257
+ const first = argv[0] ?? "";
258
+ const executable = executableBasename(first, this.dialect);
259
+ const key = executable.toLowerCase();
260
+ if (executable === "") throw new Refusal("not-allowed", "The command has no executable name.");
261
+ 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.`);
262
+ if (this.deny.has(key)) throw new Refusal("denied-executable", `\`${executable}\` is denied by the workspace shell policy (in ${show(argv.join(" "))}).`, executable);
263
+ 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);
264
+ this.checkHooks(argv, depth > 0);
265
+ this.commands.push([...argv]);
266
+ 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)) {
267
+ this.checkOpaque(executable, argv);
268
+ return;
269
+ }
270
+ if (this.shellMode && this.dialect === "posix" && ARITHMETIC_SENSITIVE_BUILTINS.has(key)) this.checkArithmeticSensitive(executable, argv, dynamic);
271
+ const ranges = this.unwrap(key, argv);
272
+ if (ranges === void 0) return;
273
+ 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);
274
+ for (const [start, end] of ranges) this.checkArgv(argv.slice(start, end), dynamic.slice(start, end), depth + 1);
275
+ }
276
+ /**
277
+ * Builtins bash may evaluate arguments of as arithmetic (array subscripts
278
+ * like `a[$(cmd)]` run the substitution): arguments must be literal, free
279
+ * of `[`, and must not name a variable that changes how the shell runs.
280
+ */
281
+ checkArithmeticSensitive(executable, argv, dynamic) {
282
+ for (let index = 1; index < argv.length; index++) {
283
+ const argument = argv[index] ?? "";
284
+ 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);
285
+ if (argument.includes("[") && argument !== "[" && argument !== "]") throw new Refusal("unsupported-syntax", `\`${executable}\` arguments with \`[\` (array subscripts, evaluated as arithmetic) are not supported.`, executable);
286
+ const name = argument.split("=")[0] ?? "";
287
+ 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);
288
+ }
289
+ }
290
+ /**
291
+ * The argv ranges a wrapper runs. `undefined` — not a wrapper (or one that
292
+ * runs nothing, like a bare `env`); `null` — a wrapper whose options the
293
+ * policy cannot read (refused).
294
+ */
295
+ unwrap(key, argv) {
296
+ switch (key) {
297
+ case "env": return suffixFrom(argv, unwrapEnv(argv));
298
+ case "nice": return suffixFrom(argv, unwrapNice(argv));
299
+ case "nohup": return suffixFrom(argv, unwrapSimple(argv, []));
300
+ case "time": return suffixFrom(argv, unwrapSimple(argv, ["-p"]));
301
+ case "timeout": return suffixFrom(argv, unwrapTimeout(argv));
302
+ case "command": return argv.slice(1).some((arg) => arg === "-v" || arg === "-V") ? void 0 : suffixFrom(argv, unwrapSimple(argv, ["-p"]));
303
+ case "builtin": return suffixFrom(argv, unwrapSimple(argv, []));
304
+ case "exec": return this.dialect === "posix" ? suffixFrom(argv, unwrapExec(argv)) : void 0;
305
+ case "find": return unwrapFind(argv);
306
+ default: return;
307
+ }
308
+ }
309
+ /** Check a `cd` (shell mode): literal, one operand, inside the jail from every possible cwd. */
310
+ checkCd(words, cwds) {
311
+ const argv = words.map((word) => word.value);
312
+ if (this.deny.has("cd")) throw new Refusal("denied-executable", "`cd` is denied by the workspace shell policy.", "cd");
313
+ this.checkHooks(argv, false);
314
+ this.commands.push(argv);
315
+ const operands = words.slice(1);
316
+ while (this.dialect === "posix" && operands.length > 0 && [
317
+ "-P",
318
+ "-L",
319
+ "--"
320
+ ].includes(operands[0]?.value ?? "")) operands.shift();
321
+ const target = operands[0];
322
+ 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");
323
+ const next = new Set(cwds);
324
+ for (const cwd of cwds) {
325
+ const resolved = this.resolveTarget(cwd, target.value);
326
+ this.assertInJail(resolved, target.value, "cd");
327
+ this.paths.push({
328
+ path: resolved,
329
+ access: "cwd",
330
+ target: target.value
331
+ });
332
+ next.add(resolved);
333
+ }
334
+ if (next.size > MAX_CD_CANDIDATES) throw new Refusal("unsupported-syntax", "The line changes directory too many times.", "cd");
335
+ return [...next];
336
+ }
337
+ /** Check a redirection target from every possible cwd. */
338
+ checkRedirectTarget(word, access, cwds) {
339
+ if (word.dynamic) throw new Refusal("unsupported-syntax", `The redirection target \`${word.raw}\` must be a literal path (no variables, globs or \`~\`).`);
340
+ const value = word.value;
341
+ if (this.dialect === "posix" && DEVICE_TARGETS.has(value) || this.dialect === "cmd" && CMD_DEVICE_TARGETS.has(value.toLowerCase())) return;
342
+ for (const cwd of cwds) {
343
+ const resolved = this.resolveTarget(cwd, value);
344
+ this.assertInJail(resolved, value, "redirect");
345
+ this.paths.push({
346
+ path: resolved,
347
+ access,
348
+ target: value
349
+ });
350
+ }
351
+ }
352
+ resolveTarget(cwd, target) {
353
+ return path.resolve(cwd, target);
354
+ }
355
+ assertInJail(resolved, target, kind) {
356
+ if (isInsideJailLexically(this.policy, resolved)) return;
357
+ 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);
358
+ }
359
+ };
360
+ /**
361
+ * Turn a wrapper's "inner command starts at `start`" into a range; a
362
+ * wrapper that runs nothing (`start === argv.length`) becomes `undefined`.
363
+ */
364
+ function suffixFrom(argv, start) {
365
+ if (start === null) return null;
366
+ return start < argv.length ? [[start, argv.length]] : void 0;
367
+ }
368
+ /** `nohup` / `time` / `command` / `builtin`: skip the listed flags and `--`. */
369
+ function unwrapSimple(argv, flags) {
370
+ let index = 1;
371
+ while (index < argv.length) {
372
+ const arg = argv[index] ?? "";
373
+ if (arg === "--") {
374
+ index++;
375
+ break;
376
+ }
377
+ if (flags.includes(arg)) {
378
+ index++;
379
+ continue;
380
+ }
381
+ if (arg.startsWith("-") && arg !== "-") return null;
382
+ break;
383
+ }
384
+ return index;
385
+ }
386
+ /** `env [-i] [-u NAME] [NAME=value]… command…` */
387
+ function unwrapEnv(argv) {
388
+ let index = 1;
389
+ while (index < argv.length) {
390
+ const arg = argv[index] ?? "";
391
+ if (arg === "-i" || arg === "-" || arg === "--ignore-environment") {
392
+ index++;
393
+ continue;
394
+ }
395
+ if (arg === "-u" || arg === "--unset") {
396
+ index += 2;
397
+ continue;
398
+ }
399
+ if (arg.startsWith("--unset=") || arg.startsWith("-u") && arg.length > 2) {
400
+ index++;
401
+ continue;
402
+ }
403
+ if (arg === "--") {
404
+ index++;
405
+ break;
406
+ }
407
+ if (arg.startsWith("-")) return null;
408
+ break;
409
+ }
410
+ while (index < argv.length && /^[^=]+=/.test(argv[index] ?? "")) index++;
411
+ return index;
412
+ }
413
+ /** `nice [-n N | -N | --adjustment=N] command…` */
414
+ function unwrapNice(argv) {
415
+ let index = 1;
416
+ while (index < argv.length) {
417
+ const arg = argv[index] ?? "";
418
+ if (arg === "-n" || arg === "--adjustment") {
419
+ index += 2;
420
+ continue;
421
+ }
422
+ if (/^-n-?[0-9]+$/.test(arg) || /^--adjustment=-?[0-9]+$/.test(arg) || /^-[0-9]+$/.test(arg)) {
423
+ index++;
424
+ continue;
425
+ }
426
+ if (arg === "--") {
427
+ index++;
428
+ break;
429
+ }
430
+ if (arg.startsWith("-")) return null;
431
+ break;
432
+ }
433
+ return index;
434
+ }
435
+ /** `timeout [options] DURATION command…` */
436
+ function unwrapTimeout(argv) {
437
+ let index = 1;
438
+ while (index < argv.length) {
439
+ const arg = argv[index] ?? "";
440
+ if (arg === "-s" || arg === "--signal" || arg === "-k" || arg === "--kill-after") {
441
+ index += 2;
442
+ continue;
443
+ }
444
+ if (/^-[sk].+$/.test(arg) || arg.startsWith("--signal=") || arg.startsWith("--kill-after=") || arg === "--preserve-status" || arg === "--foreground" || arg === "-v" || arg === "--verbose") {
445
+ index++;
446
+ continue;
447
+ }
448
+ if (arg === "--") {
449
+ index++;
450
+ break;
451
+ }
452
+ if (arg.startsWith("-")) return null;
453
+ break;
454
+ }
455
+ if (index >= argv.length - 1) return null;
456
+ return index + 1;
457
+ }
458
+ /** `exec [-cl] [-a name] command…` (bash). */
459
+ function unwrapExec(argv) {
460
+ let index = 1;
461
+ while (index < argv.length) {
462
+ const arg = argv[index] ?? "";
463
+ if (arg === "-c" || arg === "-l" || arg === "-cl" || arg === "-lc") {
464
+ index++;
465
+ continue;
466
+ }
467
+ if (arg === "-a") {
468
+ index += 2;
469
+ continue;
470
+ }
471
+ if (arg === "--") {
472
+ index++;
473
+ break;
474
+ }
475
+ if (arg.startsWith("-")) return null;
476
+ break;
477
+ }
478
+ return index;
479
+ }
480
+ /** The commands `find -exec / -execdir / -ok / -okdir … ;|+` runs. */
481
+ function unwrapFind(argv) {
482
+ const inner = [];
483
+ for (let index = 1; index < argv.length; index++) {
484
+ const arg = argv[index] ?? "";
485
+ if (arg !== "-exec" && arg !== "-execdir" && arg !== "-ok" && arg !== "-okdir") continue;
486
+ const start = index + 1;
487
+ let end = start;
488
+ while (end < argv.length && argv[end] !== ";" && argv[end] !== "+") end++;
489
+ if (end >= argv.length || end === start) return null;
490
+ inner.push([start, end]);
491
+ index = end;
492
+ }
493
+ return inner.length > 0 ? inner : void 0;
494
+ }
495
+ /** Build the argv-mode refusal for a line that is not a single argv. */
496
+ function argvModeRefusal(command) {
497
+ const analysis = analyzeArgvCommand(command);
498
+ if (analysis.ok) return new Refusal("metacharacter", "The command cannot run as a single argv.");
499
+ if (analysis.type === "empty") return new Refusal("not-allowed", "The command is empty.");
500
+ 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.`);
501
+ const token = analysis.token;
502
+ const suffix = " (run_shell executes a single command without a shell.)";
503
+ const parsed = parseShellLine(command, "posix");
504
+ const segments = parsed.ok ? parsed.segments : [];
505
+ if ((token === "&&" || token === "||" || token === ";" || token === "\n") && segments.length >= 2) {
506
+ const shownOperator = token === "\n" ? "A line break" : `\`${token}\``;
507
+ 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}`);
508
+ const steps = segments.map((segment) => show(segment.source));
509
+ const joiner = token === "||" ? ", and if it fails, " : ", then ";
510
+ return new Refusal("metacharacter", `${shownOperator} is not supported in argv mode: run ${steps.join(joiner)}, as separate commands.${suffix}`);
511
+ }
512
+ if (token === "&" && segments.length >= 1) {
513
+ const steps = segments.map((segment) => show(segment.source));
514
+ 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}`);
515
+ }
516
+ 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}`);
517
+ 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}`);
518
+ 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}`);
519
+ 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}`);
520
+ 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}`);
521
+ if (token === "(" || token === ")") return new Refusal("metacharacter", `\`${token}\` is not supported in argv mode: put it inside quotes to pass it as text.${suffix}`);
522
+ 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}`);
523
+ }
524
+ /** Check a shell-mode (`interpreter: "sh"`) line. */
525
+ function checkShellLine(checker, policy, command, dialect) {
526
+ if (/^[-+]/.test(command)) throw new Refusal("unsupported-syntax", "A command line may not start with `-` or `+`.");
527
+ const parsed = parseShellLine(command, dialect);
528
+ if (!parsed.ok) throw new Refusal(parsed.reason, `${parsed.message} The workspace shell policy only runs lines it can check.`);
529
+ 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.");
530
+ let cwds = [path.resolve(policy.cwd)];
531
+ for (const simple of parsed.commands) cwds = checkSimpleCommand(checker, simple, cwds, dialect);
532
+ }
533
+ /** Check one parsed simple command; returns the possible working directories after it. */
534
+ function checkSimpleCommand(checker, simple, cwds, dialect) {
535
+ for (const assignment of simple.assignments) {
536
+ const name = assignment.assignmentName ?? "";
537
+ 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.`);
538
+ }
539
+ for (const redirect of simple.redirects) if (redirect.type === "read" || redirect.type === "write") checker.checkRedirectTarget(redirect.target, redirect.type, cwds);
540
+ const first = simple.words[0];
541
+ if (first === void 0) return cwds;
542
+ if (!first.dynamic && first.value.includes("=")) throw new Refusal("unsupported-syntax", `\`${first.raw}\` is not a plain command name or \`NAME=value\` assignment.`);
543
+ if (/^[-+]/.test(first.value)) throw new Refusal("unsupported-syntax", `\`${first.raw}\` is not a command name.`);
544
+ if (checker.hasArgumentHooks) {
545
+ const dynamic = simple.words.find((word) => word.dynamic);
546
+ 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).`);
547
+ }
548
+ const executable = first.dynamic ? "" : executableBasename(first.value, dialect).toLowerCase();
549
+ if (executable === "cd") return checker.checkCd(simple.words, cwds);
550
+ if (dialect === "posix" && (executable === "pushd" || executable === "popd")) throw new Refusal("unsupported-syntax", `\`${executable}\` is not supported; use \`cd\`.`);
551
+ checker.checkArgv(simple.words.map((word) => word.dynamic ? word.raw : word.value), simple.words.map((word) => word.dynamic), 0);
552
+ return cwds;
553
+ }
554
+ /**
555
+ * The shell policy's verdict on a command line, without touching the
556
+ * filesystem.
557
+ *
558
+ * - **argv mode** (`interpreter: "none"`, the default): the line must
559
+ * tokenize into one argv; otherwise it is refused with a
560
+ * `"metacharacter"` / `"unbalanced-quotes"` reason and a message that
561
+ * names the operator and suggests the split.
562
+ * - **shell mode** (`interpreter: "sh"`): the line is parsed and every
563
+ * simple command — inside `;`, `&&`, `||`, pipes, subshells, `$( )`,
564
+ * backticks and here-documents — is checked; redirection targets and `cd`
565
+ * directories must stay in the jail (checked lexically here; `exec`
566
+ * re-checks them with symlinks resolved).
567
+ *
568
+ * Every argv goes through `deny` (case-insensitive, wins), `allow`
569
+ * (exhaustive when non-empty), `denyPatterns` and `allowCommand`.
570
+ * Wrappers (`env`, `time`, `timeout`, `nice`, `nohup`, `command`,
571
+ * `builtin`, `exec`) and `find -exec` are followed to the command they run.
572
+ * A runner the policy cannot see into (`bash -c`, `eval`, `xargs`, `sudo`,
573
+ * …) is refused while the policy has deny rules, unless `allow` names it.
574
+ *
575
+ * @example
576
+ * checkShellCommand({ cwd, shell: { allow: ["git"] } }, "git add -A && git commit -m wip");
577
+ * // { allowed: false, reason: "metacharacter", message: "`&&` is not supported in argv mode: run `git add -A`, then `git commit -m wip`, …" }
578
+ */
579
+ function checkShellCommand(policy, command) {
580
+ return checkShellCommandInDialect(policy, command, shellDialect());
581
+ }
582
+ /**
583
+ * {@link checkShellCommand} with the shell-mode dialect chosen explicitly
584
+ * instead of from the platform (used to test the cmd.exe rules anywhere).
585
+ * Argv mode ignores `dialect`.
586
+ */
587
+ function checkShellCommandInDialect(policy, command, dialect) {
588
+ const shell = policy.shell;
589
+ if (!shell) return {
590
+ allowed: false,
591
+ reason: "not-allowed",
592
+ message: "The workspace has no shell policy, so no command may run.",
593
+ commands: [],
594
+ paths: []
595
+ };
596
+ const interpreter = shell.interpreter ?? "none";
597
+ const checker = new LineChecker(policy, shell, command, interpreter === "sh" ? dialect : "posix");
598
+ try {
599
+ if (command.trim() === "") throw new Refusal("not-allowed", "The command is empty.");
600
+ if (interpreter === "sh") checkShellLine(checker, policy, command, dialect);
601
+ else {
602
+ const argv = tokenizeCommand(command);
603
+ if (argv === null) throw argvModeRefusal(command);
604
+ checker.checkArgv(argv, argv.map(() => false), 0);
605
+ }
606
+ return {
607
+ allowed: true,
608
+ commands: checker.commands,
609
+ paths: checker.paths
610
+ };
611
+ } catch (error) {
612
+ if (!(error instanceof Refusal)) throw error;
613
+ return {
614
+ allowed: false,
615
+ reason: error.reason,
616
+ message: error.message,
617
+ ...error.executable !== void 0 ? { executable: error.executable } : {},
618
+ ...error.refusedPath !== void 0 ? { path: error.refusedPath } : {},
619
+ commands: checker.commands,
620
+ paths: checker.paths
621
+ };
622
+ }
623
+ }
624
+
625
+ //#endregion
626
+ export { checkShellCommand };
627
+ //# sourceMappingURL=shell-check.mjs.map