opencode-goal-mode 0.1.0 → 0.2.1

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 (54) hide show
  1. package/ARCHITECTURE.md +180 -0
  2. package/README.md +158 -52
  3. package/agents/goal-api-reviewer.md +0 -2
  4. package/agents/goal-architect.md +0 -2
  5. package/agents/goal-commentator.md +0 -2
  6. package/agents/goal-completion-guard.md +0 -2
  7. package/agents/goal-coordinator.md +0 -2
  8. package/agents/goal-data-reviewer.md +0 -2
  9. package/agents/goal-deep-researcher.md +0 -2
  10. package/agents/goal-diff-reviewer.md +0 -2
  11. package/agents/goal-doc-reviewer.md +0 -2
  12. package/agents/goal-doc-writer.md +0 -2
  13. package/agents/goal-explorer.md +9 -8
  14. package/agents/goal-final-auditor.md +0 -2
  15. package/agents/goal-implementer.md +0 -2
  16. package/agents/goal-mapper.md +0 -2
  17. package/agents/goal-ops-reviewer.md +0 -2
  18. package/agents/goal-perf-reviewer.md +0 -2
  19. package/agents/goal-planner.md +10 -5
  20. package/agents/goal-prompt-auditor.md +0 -2
  21. package/agents/goal-quality-gate.md +0 -2
  22. package/agents/goal-researcher.md +8 -7
  23. package/agents/goal-reviewer.md +0 -2
  24. package/agents/goal-security-reviewer.md +0 -2
  25. package/agents/goal-test-reviewer.md +0 -2
  26. package/agents/goal-ux-reviewer.md +0 -2
  27. package/agents/goal-verifier.md +0 -2
  28. package/agents/goal-web-researcher.md +0 -2
  29. package/agents/goal.md +9 -8
  30. package/package.json +13 -9
  31. package/plugins/goal-guard/agents.js +132 -0
  32. package/plugins/goal-guard/completion.js +64 -0
  33. package/plugins/goal-guard/config.js +87 -0
  34. package/plugins/goal-guard/events.js +65 -0
  35. package/plugins/goal-guard/gates.js +85 -0
  36. package/plugins/goal-guard/logger.js +36 -0
  37. package/plugins/goal-guard/persistence.js +122 -0
  38. package/plugins/goal-guard/shell.js +1159 -0
  39. package/plugins/goal-guard/state.js +182 -0
  40. package/plugins/goal-guard/summary.js +46 -0
  41. package/plugins/goal-guard/system.js +43 -0
  42. package/plugins/goal-guard/tools.js +129 -0
  43. package/plugins/goal-guard/verdicts.js +87 -0
  44. package/plugins/goal-guard.js +267 -379
  45. package/plugins/package.json +3 -0
  46. package/scripts/install.mjs +170 -36
  47. package/docs/research-report.md +0 -37
  48. package/scripts/check-npm-publish-ready.mjs +0 -54
  49. package/scripts/validate-opencode-config.mjs +0 -82
  50. package/tests/agents.test.mjs +0 -70
  51. package/tests/commands.test.mjs +0 -23
  52. package/tests/helpers.mjs +0 -23
  53. package/tests/install.test.mjs +0 -64
  54. package/tests/plugin.test.mjs +0 -195
@@ -0,0 +1,1159 @@
1
+ /**
2
+ * Quote-aware shell command analyzer.
3
+ *
4
+ * The previous implementation matched raw regular expressions against the whole
5
+ * command string. That approach is trivially bypassed: command substitution
6
+ * (`$(rm -rf /)`), pipes into a shell (`curl x | sh`), `bash -c "..."`,
7
+ * absolute paths (`/bin/rm`), env-assignment prefixes (`FOO=bar rm ...`),
8
+ * `git -C <dir> reset` and newline separators all evade boundary-anchored
9
+ * regexes, while legitimate commands such as `git checkout -b feature` get
10
+ * flagged as destructive.
11
+ *
12
+ * This module instead lexes the command with awareness of quoting, separators,
13
+ * command substitution and shell wrappers, decomposes it into the individual
14
+ * simple commands that will actually run, and classifies each one by its
15
+ * resolved binary. Wrappers (`sudo`, `xargs`, `env`, `bash -c`, `eval`, ...)
16
+ * and substitutions are recursed into, so a dangerous command cannot hide
17
+ * behind one.
18
+ *
19
+ * Classification produces four independent signals:
20
+ * - destructive irreversible data/branch/disk loss; blocked before execution
21
+ * - mutating writes to the tree/working state; marks the session dirty
22
+ * - verification a test/build/lint/typecheck command; counts as evidence
23
+ * - networkExec pipes untrusted network output into a shell (also destructive)
24
+ *
25
+ * A single command may carry several signals (e.g. `npm run build` is
26
+ * verification but not mutating; `tee x && rm -rf y` is mutating and destructive).
27
+ */
28
+
29
+ const MAX_DEPTH = 6;
30
+
31
+ /** Shell wrappers whose *next* argument is the command that actually runs. */
32
+ const SIMPLE_WRAPPERS = new Set([
33
+ "sudo",
34
+ "command",
35
+ "builtin",
36
+ "nice",
37
+ "nohup",
38
+ "ionice",
39
+ "setsid",
40
+ "stdbuf",
41
+ "time",
42
+ "nocorrect",
43
+ "doas",
44
+ "exec",
45
+ "caffeinate",
46
+ "proxychains",
47
+ "proxychains4",
48
+ ]);
49
+
50
+ /** Interpreters that execute a command string passed via `-c`/`/c`. */
51
+ const DASH_C_INTERPRETERS = new Set(["sh", "bash", "zsh", "dash", "ksh", "ash", "fish"]);
52
+
53
+ /** Shells that execute whatever is piped into them on stdin. */
54
+ const STDIN_SHELLS = new Set(["sh", "bash", "zsh", "dash", "ksh", "ash"]);
55
+
56
+ /**
57
+ * Wrapper options that consume the FOLLOWING token as their value, so the value
58
+ * is not mistaken for the wrapped command (e.g. `sudo -u root rm -rf /` — `root`
59
+ * is the value of `-u`, not the command).
60
+ */
61
+ const WRAPPER_VALUE_OPTS = {
62
+ sudo: new Set(["-u", "-g", "-U", "-C", "-p", "-r", "-T", "-h", "--user", "--group", "--prompt", "--role", "--type", "--host", "--close-from", "--other-user"]),
63
+ doas: new Set(["-u", "-C"]),
64
+ nice: new Set(["-n", "--adjustment"]),
65
+ ionice: new Set(["-c", "-n", "-p"]),
66
+ stdbuf: new Set(["-i", "-o", "-e"]),
67
+ timeout: new Set(["-s", "--signal", "-k", "--kill-after"]),
68
+ };
69
+
70
+ /** Commands that fetch remote content; piping them into a shell is remote code execution. */
71
+ const NETWORK_FETCHERS = new Set(["curl", "wget", "fetch", "http", "https", "aria2c"]);
72
+
73
+ const SEPARATORS = new Set([";", "\n", "&&", "||", "|", "|&", "&"]);
74
+
75
+ /**
76
+ * Lex a command string into tokens, respecting single/double quotes and
77
+ * backslash escapes, capturing `$( … )` and backtick substitutions as nested
78
+ * strings, and emitting separator/redirection operators.
79
+ *
80
+ * @param {string} input
81
+ * @returns {Array<{type: "word"|"op"|"subst", value: string}>}
82
+ */
83
+ function lex(input) {
84
+ const tokens = [];
85
+ let word = "";
86
+ let wordActive = false;
87
+ const pushWord = () => {
88
+ if (wordActive) {
89
+ tokens.push({ type: "word", value: word });
90
+ word = "";
91
+ wordActive = false;
92
+ }
93
+ };
94
+ const pushOp = (value) => {
95
+ pushWord();
96
+ tokens.push({ type: "op", value });
97
+ };
98
+
99
+ let i = 0;
100
+ const n = input.length;
101
+ while (i < n) {
102
+ const c = input[i];
103
+
104
+ // Backslash escape (outside single quotes, which we handle below).
105
+ if (c === "\\") {
106
+ if (i + 1 < n) {
107
+ const next = input[i + 1];
108
+ // A backslash-newline is a line continuation: it disappears.
109
+ if (next !== "\n") {
110
+ word += next;
111
+ wordActive = true;
112
+ }
113
+ i += 2;
114
+ continue;
115
+ }
116
+ i += 1;
117
+ continue;
118
+ }
119
+
120
+ if (c === "'") {
121
+ // Single quotes: everything literal until the next single quote.
122
+ wordActive = true;
123
+ i += 1;
124
+ while (i < n && input[i] !== "'") {
125
+ word += input[i];
126
+ i += 1;
127
+ }
128
+ i += 1; // skip closing quote (or end of string)
129
+ continue;
130
+ }
131
+
132
+ if (c === '"') {
133
+ // Double quotes: literal except for substitutions and escapes.
134
+ wordActive = true;
135
+ i += 1;
136
+ while (i < n && input[i] !== '"') {
137
+ if (input[i] === "\\" && i + 1 < n) {
138
+ word += input[i + 1];
139
+ i += 2;
140
+ continue;
141
+ }
142
+ if (input[i] === "$" && input[i + 1] === "(") {
143
+ const [inner, next] = readBalanced(input, i + 2, "(", ")");
144
+ tokens.push({ type: "subst", value: inner });
145
+ i = next;
146
+ continue;
147
+ }
148
+ if (input[i] === "`") {
149
+ const [inner, next] = readBacktick(input, i + 1);
150
+ tokens.push({ type: "subst", value: inner });
151
+ i = next;
152
+ continue;
153
+ }
154
+ word += input[i];
155
+ i += 1;
156
+ }
157
+ i += 1; // skip closing quote
158
+ continue;
159
+ }
160
+
161
+ // Command substitution $( … )
162
+ if (c === "$" && input[i + 1] === "(") {
163
+ const [inner, next] = readBalanced(input, i + 2, "(", ")");
164
+ tokens.push({ type: "subst", value: inner });
165
+ i = next;
166
+ continue;
167
+ }
168
+
169
+ // Backtick substitution
170
+ if (c === "`") {
171
+ const [inner, next] = readBacktick(input, i + 1);
172
+ tokens.push({ type: "subst", value: inner });
173
+ i = next;
174
+ continue;
175
+ }
176
+
177
+ // Process substitution / grouping parens: treat the content as a nested command.
178
+ if ((c === "<" || c === ">") && input[i + 1] === "(") {
179
+ const [inner, next] = readBalanced(input, i + 2, "(", ")");
180
+ tokens.push({ type: "subst", value: inner });
181
+ i = next;
182
+ continue;
183
+ }
184
+
185
+ // Two-character operators.
186
+ const two = input.slice(i, i + 2);
187
+ if (two === "&&" || two === "||" || two === ">>" || two === "|&" || two === "2>" || two === "&>") {
188
+ pushOp(two);
189
+ i += 2;
190
+ continue;
191
+ }
192
+
193
+ // Single-character operators / separators.
194
+ if (c === ";" || c === "|" || c === "&" || c === "\n" || c === "<" || c === ">") {
195
+ pushOp(c);
196
+ i += 1;
197
+ continue;
198
+ }
199
+
200
+ // Subshell / grouping parens become separators around their content.
201
+ if (c === "(" || c === ")" || c === "{" || c === "}") {
202
+ pushOp(c);
203
+ i += 1;
204
+ continue;
205
+ }
206
+
207
+ // A '#' at a word boundary starts a comment that runs to end of line.
208
+ if (c === "#" && !wordActive) {
209
+ while (i < n && input[i] !== "\n") i += 1;
210
+ continue;
211
+ }
212
+
213
+ if (c === " " || c === "\t" || c === "\r") {
214
+ pushWord();
215
+ i += 1;
216
+ continue;
217
+ }
218
+
219
+ word += c;
220
+ wordActive = true;
221
+ i += 1;
222
+ }
223
+ pushWord();
224
+ return tokens;
225
+ }
226
+
227
+ /** Read a balanced region given an opening delimiter already consumed. */
228
+ function readBalanced(input, start, open, close) {
229
+ let depth = 1;
230
+ let i = start;
231
+ let out = "";
232
+ while (i < input.length && depth > 0) {
233
+ const c = input[i];
234
+ if (c === "\\" && i + 1 < input.length) {
235
+ out += c + input[i + 1];
236
+ i += 2;
237
+ continue;
238
+ }
239
+ if (c === open) depth += 1;
240
+ else if (c === close) {
241
+ depth -= 1;
242
+ if (depth === 0) {
243
+ i += 1;
244
+ break;
245
+ }
246
+ }
247
+ out += c;
248
+ i += 1;
249
+ }
250
+ return [out, i];
251
+ }
252
+
253
+ /** Read until the next unescaped backtick. */
254
+ function readBacktick(input, start) {
255
+ let i = start;
256
+ let out = "";
257
+ while (i < input.length && input[i] !== "`") {
258
+ if (input[i] === "\\" && i + 1 < input.length) {
259
+ out += input[i + 1];
260
+ i += 2;
261
+ continue;
262
+ }
263
+ out += input[i];
264
+ i += 1;
265
+ }
266
+ return [out, i + 1];
267
+ }
268
+
269
+ /**
270
+ * Split a token stream into pipelines (groups of simple commands separated by
271
+ * pipes) and simple commands (separated by `;`, `&&`, `||`, newlines, `&`).
272
+ * Returns an array of pipelines, each a list of simple commands, each simple
273
+ * command a list of `{ words, redirects }`.
274
+ */
275
+ function structure(tokens) {
276
+ const pipelines = [];
277
+ let pipeline = [];
278
+ let words = [];
279
+ const redirects = [];
280
+ const substs = [];
281
+
282
+ const flushCommand = () => {
283
+ if (words.length || redirects.length) {
284
+ pipeline.push({ words: words.slice(), redirects: redirects.slice() });
285
+ }
286
+ words = [];
287
+ redirects.length = 0;
288
+ };
289
+ const flushPipeline = () => {
290
+ flushCommand();
291
+ if (pipeline.length) pipelines.push(pipeline.slice());
292
+ pipeline = [];
293
+ };
294
+
295
+ for (let i = 0; i < tokens.length; i += 1) {
296
+ const t = tokens[i];
297
+ if (t.type === "subst") {
298
+ substs.push(t.value);
299
+ continue;
300
+ }
301
+ if (t.type === "word") {
302
+ words.push(t.value);
303
+ continue;
304
+ }
305
+ // operator
306
+ if (t.value === "|" || t.value === "|&") {
307
+ flushCommand();
308
+ continue;
309
+ }
310
+ if (SEPARATORS.has(t.value)) {
311
+ flushPipeline();
312
+ continue;
313
+ }
314
+ if (t.value === "(" || t.value === ")" || t.value === "{" || t.value === "}") {
315
+ flushPipeline();
316
+ continue;
317
+ }
318
+ if (t.value === ">" || t.value === ">>" || t.value === "<" || t.value === "2>" || t.value === "&>") {
319
+ // The following word is the redirect target.
320
+ const target = tokens[i + 1];
321
+ if (target && target.type === "word") {
322
+ redirects.push({ op: t.value, target: target.value });
323
+ i += 1;
324
+ } else {
325
+ redirects.push({ op: t.value, target: "" });
326
+ }
327
+ continue;
328
+ }
329
+ }
330
+ flushPipeline();
331
+ return { pipelines, substs };
332
+ }
333
+
334
+ const ENV_ASSIGN = /^[A-Za-z_][A-Za-z0-9_]*=/;
335
+
336
+ /** Resolve the effective binary name for a command word (strip path, drop env assigns). */
337
+ function baseName(word) {
338
+ if (!word) return "";
339
+ // Strip a trailing path separator artefacts and resolve basename.
340
+ const cleaned = word.replace(/\/+$/, "");
341
+ const idx = cleaned.lastIndexOf("/");
342
+ return idx >= 0 ? cleaned.slice(idx + 1) : cleaned;
343
+ }
344
+
345
+ /** Does an argument list contain any of the given flags (exact or bundled short flags)? */
346
+ function hasFlag(args, flags) {
347
+ for (const arg of args) {
348
+ if (flags.includes(arg)) return true;
349
+ // Bundled short flags like -rf contain -r and -f.
350
+ if (/^-[a-zA-Z]+$/.test(arg)) {
351
+ for (const f of flags) {
352
+ if (f.length === 2 && f[0] === "-" && arg.includes(f[1])) return true;
353
+ }
354
+ }
355
+ }
356
+ return false;
357
+ }
358
+
359
+ function nonFlagArgs(args) {
360
+ return args.filter((a) => !a.startsWith("-"));
361
+ }
362
+
363
+ /** Index of the first non-option token for a wrapper, honoring value-taking options. */
364
+ function skipWrapperOptions(bin, args) {
365
+ const valueOpts = WRAPPER_VALUE_OPTS[bin] || new Set();
366
+ let j = 0;
367
+ while (j < args.length) {
368
+ const a = args[j];
369
+ if (a === "--") {
370
+ j += 1;
371
+ break;
372
+ }
373
+ if (a.startsWith("-")) {
374
+ // `--opt=value` carries its own value; otherwise a value-taking option
375
+ // consumes the next token.
376
+ if (!a.includes("=") && valueOpts.has(a)) j += 1;
377
+ j += 1;
378
+ continue;
379
+ }
380
+ if (/^\d+$/.test(a) && valueOpts.size === 0) {
381
+ // Bare numeric option value for wrappers with no declared value-opts.
382
+ j += 1;
383
+ continue;
384
+ }
385
+ break;
386
+ }
387
+ return j;
388
+ }
389
+
390
+ /** Decode `$'...'` ANSI-C quoting into a plain single-quoted literal so the
391
+ * lexer cannot be evaded by hex/octal/escape-encoded command names. */
392
+ function decodeAnsiCQuotes(input) {
393
+ return input.replace(/\$'((?:[^'\\]|\\.)*)'/g, (_m, body) => {
394
+ const decoded = body
395
+ .replace(/\\x([0-9a-fA-F]{1,2})/g, (_s, h) => String.fromCharCode(parseInt(h, 16)))
396
+ .replace(/\\0([0-7]{1,3})/g, (_s, o) => String.fromCharCode(parseInt(o, 8)))
397
+ .replace(/\\([0-7]{1,3})/g, (_s, o) => String.fromCharCode(parseInt(o, 8)))
398
+ .replace(/\\n/g, "\n")
399
+ .replace(/\\t/g, "\t")
400
+ .replace(/\\r/g, "\r")
401
+ .replace(/\\\\/g, "\\")
402
+ .replace(/\\'/g, "'")
403
+ .replace(/\\"/g, '"');
404
+ return `'${decoded.replace(/'/g, "'\\''")}'`;
405
+ });
406
+ }
407
+
408
+ const PACKAGE_MANAGERS = new Set(["npm", "pnpm", "yarn", "bun", "pip", "pip3", "pipenv", "poetry", "cargo", "go", "gem", "bundle", "composer", "apt", "apt-get", "brew", "gradle", "mvn", "dotnet", "deno"]);
409
+ const PKG_MUTATING_SUBCMDS = new Set(["install", "i", "ci", "add", "remove", "rm", "uninstall", "update", "upgrade", "link", "unlink", "prune", "dedupe", "rebuild", "get", "tidy"]);
410
+ const PKG_SCRIPT_RUNNERS = new Set(["run", "run-script", "exec"]);
411
+
412
+ const TEST_SCRIPT_WORDS = new Set(["test", "tests", "validate", "check", "lint", "typecheck", "type-check", "tsc", "build", "unit", "integration", "e2e", "coverage", "ci", "verify", "spec"]);
413
+ const DIRECT_TEST_BINS = new Set(["jest", "mocha", "vitest", "ava", "tap", "tape", "playwright", "cypress", "pytest", "phpunit", "rspec", "nyc", "karma", "jasmine", "tox", "nose", "nosetests"]);
414
+
415
+ const FORMATTERS = new Set(["prettier", "eslint", "black", "ruff", "gofmt", "goimports", "rustfmt", "clang-format", "autopep8", "isort", "standard", "biome", "dprint", "yapf", "stylelint"]);
416
+
417
+ const MUTATING_BINS = new Set(["mkdir", "rmdir", "touch", "ln", "mv", "cp", "tee", "install", "patch", "rsync", "rename", "chmod", "chown", "chgrp", "git-apply"]);
418
+ const DESTRUCTIVE_BINS = new Set(["shred", "mkfs", "fdisk", "parted", "wipefs", "sgdisk", "blkdiscard", "unlink"]);
419
+
420
+ /**
421
+ * Classify a single already-split simple command (array of words).
422
+ * @returns {{ destructive: boolean, mutating: boolean, verification: boolean, networkExec: boolean, reasons: string[] }}
423
+ */
424
+ function classifyCommand(words, redirects, depth, acc, pipelineCmds, indexInPipeline) {
425
+ // Strip leading environment assignments (FOO=bar cmd).
426
+ let idx = 0;
427
+ while (idx < words.length && ENV_ASSIGN.test(words[idx])) idx += 1;
428
+ const head = words[idx];
429
+ if (head === undefined) {
430
+ // Pure assignment or redirect-only command. Redirects can still mutate.
431
+ applyRedirects(redirects, acc);
432
+ return;
433
+ }
434
+ const bin = baseName(head);
435
+ const args = words.slice(idx + 1);
436
+
437
+ // Redirections to a real file mutate the tree regardless of the command.
438
+ applyRedirects(redirects, acc);
439
+
440
+ // env VAR=val cmd ...
441
+ if (bin === "env") {
442
+ let j = 0;
443
+ while (j < args.length && (ENV_ASSIGN.test(args[j]) || args[j] === "-i" || args[j].startsWith("--"))) j += 1;
444
+ if (j < args.length) return classifyCommand(args.slice(j), [], depth + 1, acc, pipelineCmds, indexInPipeline);
445
+ return;
446
+ }
447
+
448
+ // Simple wrappers: skip the wrapper's own options — including the values of
449
+ // value-taking options (e.g. `sudo -u root cmd`, `nice -n 10 cmd`) — then the
450
+ // next token is the real command.
451
+ if (SIMPLE_WRAPPERS.has(bin)) {
452
+ const j = skipWrapperOptions(bin, args);
453
+ if (j < args.length) return classifyCommand(args.slice(j), [], depth + 1, acc, pipelineCmds, indexInPipeline);
454
+ return;
455
+ }
456
+
457
+ // timeout [opts] DURATION cmd... — skip value-aware options, then the
458
+ // duration token, then classify the remainder.
459
+ if (bin === "timeout") {
460
+ let j = skipWrapperOptions("timeout", args);
461
+ j += 1; // duration token
462
+ if (j < args.length) return classifyCommand(args.slice(j), [], depth + 1, acc, pipelineCmds, indexInPipeline);
463
+ return;
464
+ }
465
+
466
+ // xargs [opts] CMD ... — the trailing command is what runs (possibly many times).
467
+ if (bin === "xargs") {
468
+ let j = 0;
469
+ while (j < args.length && args[j].startsWith("-")) {
470
+ // -I {}, -n N, -P N, -d X take a value
471
+ if (["-I", "-n", "-P", "-d", "-E", "-s", "-L"].includes(args[j])) j += 1;
472
+ j += 1;
473
+ }
474
+ if (j < args.length) return classifyCommand(args.slice(j), [], depth + 1, acc, pipelineCmds, indexInPipeline);
475
+ return;
476
+ }
477
+
478
+ // Interpreters running a command string: sh -c "...", python -c "...", node -e "...", perl -e, ruby -e.
479
+ if (DASH_C_INTERPRETERS.has(bin)) {
480
+ const ci = args.findIndex((a) => a === "-c");
481
+ if (ci >= 0 && args[ci + 1] !== undefined) {
482
+ analyzeInto(args[ci + 1], depth + 1, acc);
483
+ return;
484
+ }
485
+ // A bare shell at the END of a pipeline executes its piped stdin.
486
+ handlePipedShell(bin, args, pipelineCmds, indexInPipeline, depth, acc);
487
+ return;
488
+ }
489
+
490
+ if (bin === "eval") {
491
+ if (args.length) analyzeInto(args.join(" "), depth + 1, acc);
492
+ return;
493
+ }
494
+
495
+ if (bin === "node" || bin === "nodejs" || bin === "deno") {
496
+ classifyInterpreterScript(bin, args, depth, acc);
497
+ // deno also has subcommands; fall through handled in classifyInterpreterScript
498
+ if (bin === "deno") classifyDeno(args, acc);
499
+ return;
500
+ }
501
+ if (bin === "python" || bin === "python3" || bin === "python2") {
502
+ classifyPython(args, depth, acc);
503
+ return;
504
+ }
505
+ if (bin === "perl" || bin === "ruby") {
506
+ classifyPerlRuby(bin, args, depth, acc);
507
+ return;
508
+ }
509
+ if (bin === "awk" || bin === "gawk" || bin === "mawk") {
510
+ classifyAwk(args, depth, acc);
511
+ return;
512
+ }
513
+
514
+ // rm. Recursive/force/wildcard/root deletions are irreversible (blocked); a
515
+ // plain single- or multi-file rm only marks the session dirty (the host's own
516
+ // `rm *` permission rule decides whether to prompt).
517
+ if (bin === "rm") {
518
+ const recursive = hasFlag(args, ["-r", "-R", "--recursive"]);
519
+ const force = hasFlag(args, ["-f", "--force"]);
520
+ const targets = nonFlagArgs(args);
521
+ const wildcard = targets.some((t) => /[*?]/.test(t) || t === "/" || t === "~" || t.endsWith("/*"));
522
+ if (recursive || force || wildcard) {
523
+ acc.destructive = true;
524
+ acc.reasons.push(`rm with ${recursive ? "recursive " : ""}${force ? "force " : ""}deletion`.replace(/\s+/g, " ").trim());
525
+ } else {
526
+ acc.mutating = true;
527
+ acc.reasons.push("rm file deletion");
528
+ }
529
+ return;
530
+ }
531
+
532
+ // git
533
+ if (bin === "git") {
534
+ classifyGit(args, depth, acc);
535
+ return;
536
+ }
537
+
538
+ // dd of=/dev/...
539
+ if (bin === "dd") {
540
+ if (args.some((a) => /^of=\/dev\//.test(a))) {
541
+ acc.destructive = true;
542
+ acc.reasons.push("dd writing to a device");
543
+ } else if (args.some((a) => /^of=/.test(a))) {
544
+ acc.mutating = true;
545
+ acc.reasons.push("dd writing to a file");
546
+ }
547
+ return;
548
+ }
549
+
550
+ // find ... -delete / -exec rm / -execdir rm
551
+ if (bin === "find" || bin === "fd" || bin === "fdfind") {
552
+ if (hasFlag(args, ["-delete"]) || args.includes("-delete")) {
553
+ acc.destructive = true;
554
+ acc.reasons.push("find -delete");
555
+ }
556
+ const execIdx = args.findIndex((a) => a === "-exec" || a === "-execdir" || a === "-x" || a === "--exec");
557
+ if (execIdx >= 0 && args[execIdx + 1] !== undefined) {
558
+ const rest = [];
559
+ for (let k = execIdx + 1; k < args.length; k += 1) {
560
+ if (args[k] === ";" || args[k] === "+" || args[k] === "\\;") break;
561
+ rest.push(args[k]);
562
+ }
563
+ // An -exec body runs once per match, so even a single-target rm deletes a
564
+ // whole match set: treat any rm under -exec as destructive.
565
+ if (rest.length && baseName(rest[0]) === "rm") {
566
+ acc.destructive = true;
567
+ acc.reasons.push("find -exec rm over a match set");
568
+ } else if (rest.length) {
569
+ classifyCommand(rest, [], depth + 1, acc, [], 0);
570
+ }
571
+ }
572
+ return;
573
+ }
574
+
575
+ // truncate (data loss)
576
+ if (bin === "truncate") {
577
+ acc.destructive = true;
578
+ acc.reasons.push("truncate");
579
+ return;
580
+ }
581
+
582
+ // sed -i / perl -pi handled in MUTATING via flags
583
+ if (bin === "sed" || bin === "gsed") {
584
+ if (hasFlag(args, ["-i", "--in-place"]) || args.some((a) => a.startsWith("-i"))) {
585
+ acc.mutating = true;
586
+ acc.reasons.push("sed in-place edit");
587
+ }
588
+ return;
589
+ }
590
+
591
+ // chmod/chown recursive on a system path → destructive; otherwise mutating.
592
+ // The first non-flag operand is the mode/owner; the rest are paths.
593
+ if (bin === "chmod" || bin === "chown" || bin === "chgrp") {
594
+ const paths = nonFlagArgs(args).slice(1);
595
+ const systemPath = (t) => t === "/" || t === "~" || /^\/(etc|usr|bin|sbin|boot|var|lib|lib64|sys|root|dev|proc)\b/.test(t);
596
+ if (hasFlag(args, ["-R", "--recursive"]) && paths.some(systemPath)) {
597
+ acc.destructive = true;
598
+ acc.reasons.push(`${bin} -R on a system path`);
599
+ } else {
600
+ acc.mutating = true;
601
+ acc.reasons.push(bin);
602
+ }
603
+ return;
604
+ }
605
+
606
+ // Destructive disk/file utilities.
607
+ if (DESTRUCTIVE_BINS.has(bin)) {
608
+ acc.destructive = true;
609
+ acc.reasons.push(bin);
610
+ return;
611
+ }
612
+
613
+ // Package managers.
614
+ if (PACKAGE_MANAGERS.has(bin)) {
615
+ classifyPackageManager(bin, args, acc);
616
+ return;
617
+ }
618
+
619
+ // npx / bunx run an arbitrary binary directly.
620
+ if (bin === "npx" || bin === "pnpx" || bin === "bunx") {
621
+ classifyRunner(args, acc);
622
+ return;
623
+ }
624
+
625
+ // Formatters / linters run directly.
626
+ if (FORMATTERS.has(bin)) {
627
+ if (hasFlag(args, ["-w", "--write", "--fix", "-i", "--in-place"])) {
628
+ acc.mutating = true;
629
+ acc.reasons.push(`${bin} --write/--fix`);
630
+ } else {
631
+ acc.verification = true; // check-only lint counts as verification evidence
632
+ }
633
+ return;
634
+ }
635
+
636
+ // Direct test binaries.
637
+ if (DIRECT_TEST_BINS.has(bin)) {
638
+ acc.verification = true;
639
+ return;
640
+ }
641
+
642
+ // make <target>. Signal writes are monotonic (OR-accumulated): never assign
643
+ // false, which would clobber a `true` set by an earlier command in the chain.
644
+ if (bin === "make" || bin === "gmake") {
645
+ const target = nonFlagArgs(args)[0] || "";
646
+ if (TEST_SCRIPT_WORDS.has(target)) acc.verification = true;
647
+ if (["install", "clean", "distclean", "uninstall"].includes(target)) {
648
+ acc.mutating = true;
649
+ acc.reasons.push(`make ${target}`);
650
+ }
651
+ return;
652
+ }
653
+
654
+ // node --test
655
+ // handled in classifyInterpreterScript
656
+
657
+ // Known mutating file utilities.
658
+ if (MUTATING_BINS.has(bin)) {
659
+ acc.mutating = true;
660
+ acc.reasons.push(bin);
661
+ return;
662
+ }
663
+
664
+ // rimraf / trash / del CLIs
665
+ if (bin === "rimraf" || bin === "trash" || bin === "del-cli") {
666
+ acc.destructive = true;
667
+ acc.reasons.push(bin);
668
+ return;
669
+ }
670
+
671
+ // Fork bomb pattern (rarely tokenizes, but guard anyway).
672
+ // (handled at string level in analyze())
673
+ }
674
+
675
+ /** find/handle a shell that runs piped stdin: `<source> | sh`. */
676
+ function handlePipedShell(shellBin, args, pipelineCmds, indexInPipeline, depth, acc) {
677
+ if (!STDIN_SHELLS.has(shellBin)) return;
678
+ if (args.some((a) => a === "-c")) return; // handled elsewhere
679
+ // Look at the upstream command in the same pipeline.
680
+ if (!pipelineCmds || indexInPipeline <= 0) return;
681
+ for (let k = indexInPipeline - 1; k >= 0; k -= 1) {
682
+ const upstream = pipelineCmds[k];
683
+ const uhead = baseName((upstream.words || []).find((w) => !ENV_ASSIGN.test(w)) || "");
684
+ if (NETWORK_FETCHERS.has(uhead)) {
685
+ // Remote code execution. Kept distinct from `destructive` so it can be
686
+ // toggled independently via config.blockNetworkExec.
687
+ acc.networkExec = true;
688
+ acc.reasons.push(`piping ${uhead} into ${shellBin}`);
689
+ return;
690
+ }
691
+ if (uhead === "echo" || uhead === "printf") {
692
+ const literal = echoCommandLiteral(uhead, upstream.words || []);
693
+ if (literal) analyzeInto(literal, depth + 1, acc);
694
+ return;
695
+ }
696
+ }
697
+ }
698
+
699
+ /** Classify the binary an ad-hoc runner (npx/bunx/pnpm dlx/yarn dlx) executes. */
700
+ function classifyRunner(args, acc) {
701
+ // Skip runner flags and the optional `-p pkg` / `--package pkg` selectors.
702
+ let i = 0;
703
+ while (i < args.length && args[i].startsWith("-")) {
704
+ if (["-p", "--package", "-c", "--call"].includes(args[i])) i += 1;
705
+ i += 1;
706
+ }
707
+ const target = args.slice(i).find((a) => !a.startsWith("-"));
708
+ if (!target) return;
709
+ const tbin = baseName(target);
710
+ if (tbin === "rimraf" || tbin === "del" || tbin === "del-cli" || tbin === "trash") {
711
+ acc.destructive = true;
712
+ acc.reasons.push(`${tbin} via runner`);
713
+ return;
714
+ }
715
+ if (FORMATTERS.has(tbin)) {
716
+ if (hasFlag(args, ["-w", "--write", "--fix", "-i"])) {
717
+ acc.mutating = true;
718
+ acc.reasons.push(`${tbin} --write/--fix`);
719
+ } else {
720
+ acc.verification = true;
721
+ }
722
+ return;
723
+ }
724
+ if (DIRECT_TEST_BINS.has(tbin)) acc.verification = true;
725
+ }
726
+
727
+ function classifyInterpreterScript(bin, args, depth, acc) {
728
+ if (args.includes("--test") || args.includes("--test-only")) {
729
+ acc.verification = true;
730
+ }
731
+ const ei = args.findIndex((a) => a === "-e" || a === "--eval" || a === "-p" || a === "--print");
732
+ if (ei >= 0 && args[ei + 1] !== undefined) {
733
+ inspectScriptString(args[ei + 1], depth, acc);
734
+ }
735
+ }
736
+
737
+ function classifyDeno(args, acc) {
738
+ const sub = args.find((a) => !a.startsWith("-"));
739
+ if (sub === "test" || sub === "lint" || sub === "check") acc.verification = true;
740
+ if (sub === "install" || sub === "cache" || sub === "add") {
741
+ acc.mutating = true;
742
+ acc.reasons.push(`deno ${sub}`);
743
+ }
744
+ }
745
+
746
+ function classifyPython(args, depth, acc) {
747
+ const mi = args.findIndex((a) => a === "-m");
748
+ if (mi >= 0) {
749
+ const mod = args[mi + 1];
750
+ if (mod === "pytest" || mod === "unittest" || mod === "nose" || mod === "tox") acc.verification = true;
751
+ if (mod === "pip") {
752
+ const sub = args.slice(mi + 2).find((a) => !a.startsWith("-"));
753
+ if (sub && PKG_MUTATING_SUBCMDS.has(sub)) {
754
+ acc.mutating = true;
755
+ acc.reasons.push(`pip ${sub}`);
756
+ }
757
+ }
758
+ }
759
+ const ci = args.findIndex((a) => a === "-c");
760
+ if (ci >= 0 && args[ci + 1] !== undefined) inspectScriptString(args[ci + 1], depth, acc);
761
+ }
762
+
763
+ function classifyPerlRuby(bin, args, depth, acc) {
764
+ if (bin === "perl" && args.some((a) => /^-.*i/.test(a) && /^-.*p/.test(a))) {
765
+ acc.mutating = true;
766
+ acc.reasons.push("perl -pi in-place");
767
+ }
768
+ const ei = args.findIndex((a) => a === "-e" || a === "-E");
769
+ if (ei >= 0 && args[ei + 1] !== undefined) inspectScriptString(args[ei + 1], depth, acc);
770
+ }
771
+
772
+ function classifyAwk(args, depth, acc) {
773
+ const program = args.find((a) => !a.startsWith("-"));
774
+ if (program) {
775
+ const m = program.match(/system\s*\(\s*["']([^"']*)["']\s*\)/);
776
+ if (m) analyzeInto(m[1], depth + 1, acc);
777
+ if (/print\b[^>]*>[^>]/.test(program)) {
778
+ acc.mutating = true;
779
+ acc.reasons.push("awk redirect to file");
780
+ }
781
+ }
782
+ }
783
+
784
+ /**
785
+ * Heuristic inspection of an interpreter script string for filesystem effects.
786
+ * Note: alternatives are NOT wrapped in a single `\b…\b` because several
787
+ * dot-prefixed members (`.rmSync`, `.write_text`) would never match after a
788
+ * non-word character such as `)`.
789
+ */
790
+ const SCRIPT_DELETE_RE = /(os\.remove|os\.unlink|os\.rmdir|shutil\.rmtree|\.rmSync\b|\.rmdirSync\b|fs\.rm\(|fs\.rmSync|fs\.unlink|\.unlink\(|rimraf)/;
791
+ const SCRIPT_WRITE_RE = /(writeFile|appendFile|copyFile|mkdir|createWriteStream|\.write_text|\.write_bytes|shutil\.copy|shutil\.move|open\s*\([^)]*['"][wax]\+?['"])/;
792
+
793
+ // Exec sinks must be CALL forms (an immediately following `(`), so a bare word
794
+ // such as "system"/"popen" or a reference like `child_process.exec` without a
795
+ // call is not treated as a shell-out. This prevents over-blocking benign
796
+ // diagnostics like `python -c 'print(platform.system())'`.
797
+ const EXEC_SINK_RE = /(?:os\.system|os\.popen|subprocess\.(?:run|call|Popen|check_output|check_call)|child_process\.\w+|\.execSync|\.execFileSync|\.exec|\.execFile|\.spawnSync|\.spawn|\bexecSync|\bexecFileSync|\bspawnSync|\bexecvp?\b)\s*\(/g;
798
+
799
+ /** Extract the shell command an exec sink runs (handles a string or an argv list). */
800
+ function extractExecCommand(code) {
801
+ // Perl/Ruby backticks: `cmd`.
802
+ const bt = code.match(/`([^`]+)`/);
803
+ if (bt) return bt[1];
804
+ EXEC_SINK_RE.lastIndex = 0;
805
+ const m = EXEC_SINK_RE.exec(code);
806
+ if (!m) return null;
807
+ const [region] = readBalanced(code, m.index + m[0].length, "(", ")");
808
+ const quoted = [...region.matchAll(/["']([^"']*)["']/g)].map((q) => q[1]).filter(Boolean);
809
+ if (!quoted.length) return null;
810
+ // argv list (`["rm","-rf","/"]`) → join; single string → use as-is.
811
+ return /^\s*\[/.test(region) ? quoted.join(" ") : quoted[0];
812
+ }
813
+
814
+ function inspectScriptString(code, depth, acc) {
815
+ // Interpreter that shells out: pull the command out of the exec sink's own
816
+ // argument region (so unrelated quoted strings elsewhere are ignored). When no
817
+ // literal command can be extracted (e.g. a dynamic variable), fail OPEN — do
818
+ // not blanket-block. The host's own permission rules still apply, and
819
+ // false-blocking benign one-liners is worse than this rare miss.
820
+ const execCmd = extractExecCommand(code);
821
+ if (execCmd) {
822
+ analyzeInto(execCmd, (depth || 0) + 1, acc);
823
+ return;
824
+ }
825
+ if (SCRIPT_DELETE_RE.test(code)) {
826
+ acc.destructive = true;
827
+ acc.reasons.push("interpreter filesystem deletion");
828
+ return;
829
+ }
830
+ if (SCRIPT_WRITE_RE.test(code)) {
831
+ acc.mutating = true;
832
+ acc.reasons.push("interpreter filesystem write");
833
+ }
834
+ }
835
+
836
+ function classifyPackageManager(bin, args, acc) {
837
+ const positionals = args.filter((a) => !a.startsWith("-"));
838
+ const sub = positionals[0];
839
+ if (!sub) return;
840
+
841
+ // go test / cargo test
842
+ if ((bin === "go" || bin === "cargo" || bin === "dotnet" || bin === "gradle" || bin === "mvn") && (sub === "test" || sub === "vet" || sub === "check" || sub === "build")) {
843
+ acc.verification = sub === "test" || sub === "vet" || sub === "check";
844
+ if (bin === "go" && (sub === "get" || sub === "install" || sub === "mod")) {
845
+ acc.mutating = true;
846
+ acc.reasons.push(`go ${sub}`);
847
+ }
848
+ if ((bin === "cargo" || bin === "dotnet") && (sub === "install" || sub === "add" || sub === "update")) {
849
+ acc.mutating = true;
850
+ acc.reasons.push(`${bin} ${sub}`);
851
+ }
852
+ return;
853
+ }
854
+
855
+ // pnpm dlx / yarn dlx / bun x — run an arbitrary fetched binary.
856
+ if (sub === "dlx" || (bin === "bun" && sub === "x")) {
857
+ classifyRunner(args.slice(args.indexOf(sub) + 1), acc);
858
+ return;
859
+ }
860
+
861
+ // npm/pnpm/yarn/bun
862
+ if (PKG_MUTATING_SUBCMDS.has(sub)) {
863
+ acc.mutating = true;
864
+ acc.reasons.push(`${bin} ${sub}`);
865
+ return;
866
+ }
867
+ if (sub === "test" || sub === "t") {
868
+ acc.verification = true;
869
+ return;
870
+ }
871
+ if (PKG_SCRIPT_RUNNERS.has(sub)) {
872
+ const script = positionals[1];
873
+ if (script && TEST_SCRIPT_WORDS.has(baseName(script))) acc.verification = true;
874
+ if (script && /^(format|fix|lint:fix|lint-fix)$/.test(script)) {
875
+ acc.mutating = true;
876
+ acc.reasons.push(`${bin} run ${script}`);
877
+ }
878
+ return;
879
+ }
880
+ // bun test / bun run handled above; bun <file> executes a script
881
+ if (bin === "bun" && sub === "test") acc.verification = true;
882
+ }
883
+
884
+ function classifyGit(args, depth, acc) {
885
+ // Skip leading global options: -C <dir>, -c key=val, --git-dir=, etc.
886
+ // A `-c` config override can weaponize git: `git -c alias.x='!rm -rf /' x`
887
+ // or `git -c core.pager='!cmd' log` runs an embedded shell command.
888
+ let i = 0;
889
+ while (i < args.length && args[i].startsWith("-")) {
890
+ if (args[i] === "-C") i += 2;
891
+ else if (args[i] === "-c") {
892
+ const kv = args[i + 1] || "";
893
+ const val = kv.slice(kv.indexOf("=") + 1);
894
+ if (val.startsWith("!")) analyzeInto(val.slice(1), (depth || 0) + 1, acc);
895
+ i += 2;
896
+ } else i += 1;
897
+ }
898
+ const sub = args[i];
899
+ const rest = args.slice(i + 1);
900
+ if (!sub) return;
901
+
902
+ switch (sub) {
903
+ case "config": {
904
+ // `git config alias.x '!rm -rf /'` (or core.pager etc.) stores a shell
905
+ // command that runs on later invocation; analyze the embedded command.
906
+ const shellVal = rest.find((a) => a.startsWith("!"));
907
+ if (shellVal) analyzeInto(shellVal.slice(1), (depth || 0) + 1, acc);
908
+ // Read-only queries change nothing and must not dirty the session.
909
+ const readOnly = rest.some((a) => /^(--get|--get-all|--get-regexp|--get-urlmatch|--list|-l)$/.test(a));
910
+ if (!readOnly) {
911
+ acc.mutating = true;
912
+ acc.reasons.push("git config");
913
+ }
914
+ return;
915
+ }
916
+ case "reflog":
917
+ if (rest[0] === "expire" || rest[0] === "delete") {
918
+ acc.destructive = true;
919
+ acc.reasons.push(`git reflog ${rest[0]}`);
920
+ }
921
+ return;
922
+ case "gc":
923
+ if (rest.some((a) => a === "--prune=now" || a.startsWith("--prune=") || a === "--aggressive")) {
924
+ acc.destructive = true;
925
+ acc.reasons.push("git gc --prune");
926
+ }
927
+ return;
928
+ case "filter-branch":
929
+ case "filter-repo":
930
+ acc.destructive = true;
931
+ acc.reasons.push(`git ${sub}`);
932
+ return;
933
+ case "worktree":
934
+ if (rest[0] === "remove" || rest[0] === "prune") {
935
+ acc.destructive = true;
936
+ acc.reasons.push(`git worktree ${rest[0]}`);
937
+ } else {
938
+ acc.mutating = true;
939
+ acc.reasons.push("git worktree");
940
+ }
941
+ return;
942
+ case "remote":
943
+ if (rest[0] === "remove" || rest[0] === "rm" || rest[0] === "prune") {
944
+ acc.mutating = true;
945
+ acc.reasons.push(`git remote ${rest[0]}`);
946
+ }
947
+ return;
948
+ case "notes":
949
+ if (rest[0] === "prune" || rest[0] === "remove") {
950
+ acc.mutating = true;
951
+ acc.reasons.push(`git notes ${rest[0]}`);
952
+ }
953
+ return;
954
+ case "reset":
955
+ if (hasFlag(rest, ["--hard", "--merge", "--keep"]) || rest.some((a) => a === "--hard")) {
956
+ acc.destructive = true;
957
+ acc.reasons.push("git reset --hard");
958
+ } else {
959
+ acc.mutating = true;
960
+ acc.reasons.push("git reset");
961
+ }
962
+ return;
963
+ case "clean":
964
+ if (hasFlag(rest, ["-f", "--force", "-d", "-x", "-X"])) {
965
+ acc.destructive = true;
966
+ acc.reasons.push("git clean -f");
967
+ }
968
+ return;
969
+ case "checkout":
970
+ // Destructive when explicitly discarding changes: `--` pathspec, -f/--force, or `.`.
971
+ // KNOWN HEURISTIC GAP: `git checkout <file>` (a bare pathspec) also discards
972
+ // that file's uncommitted edits, but a bare arg is indistinguishable from a
973
+ // branch/ref switch (`git checkout main`) without repo state, so we do not
974
+ // block it to avoid false-positiving the far more common branch switch. Use
975
+ // `git restore`/`git checkout -- <file>` to make the intent explicit.
976
+ if (rest.includes("--") || hasFlag(rest, ["-f", "--force"]) || rest.includes(".")) {
977
+ acc.destructive = true;
978
+ acc.reasons.push("git checkout discarding changes");
979
+ }
980
+ return;
981
+ case "switch":
982
+ if (hasFlag(rest, ["-f", "--force", "--discard-changes"])) {
983
+ acc.destructive = true;
984
+ acc.reasons.push("git switch --discard-changes");
985
+ }
986
+ return;
987
+ case "restore":
988
+ acc.destructive = true;
989
+ acc.reasons.push("git restore discards worktree changes");
990
+ return;
991
+ case "branch":
992
+ if (hasFlag(rest, ["-D", "-d"]) || rest.includes("-D") || rest.includes("-d") || rest.includes("--delete")) {
993
+ acc.destructive = true;
994
+ acc.reasons.push("git branch delete");
995
+ }
996
+ return;
997
+ case "push":
998
+ if (hasFlag(rest, ["-f", "--force"]) || rest.some((a) => a === "--force-with-lease" || a.startsWith("--force-with-lease") || a === "--delete" || a === "-d")) {
999
+ acc.destructive = true;
1000
+ acc.reasons.push("git push --force/--delete");
1001
+ } else {
1002
+ acc.mutating = true;
1003
+ acc.reasons.push("git push");
1004
+ }
1005
+ return;
1006
+ case "update-ref":
1007
+ if (rest.includes("-d")) {
1008
+ acc.destructive = true;
1009
+ acc.reasons.push("git update-ref -d");
1010
+ }
1011
+ return;
1012
+ case "rm":
1013
+ acc.destructive = true;
1014
+ acc.reasons.push("git rm");
1015
+ return;
1016
+ case "stash":
1017
+ if (rest[0] === "drop" || rest[0] === "clear" || rest[0] === "pop") {
1018
+ acc.mutating = true;
1019
+ acc.reasons.push(`git stash ${rest[0]}`);
1020
+ }
1021
+ return;
1022
+ case "add":
1023
+ case "commit":
1024
+ case "merge":
1025
+ case "rebase":
1026
+ case "cherry-pick":
1027
+ case "revert":
1028
+ case "apply":
1029
+ case "am":
1030
+ case "mv":
1031
+ case "tag":
1032
+ case "pull":
1033
+ case "fetch":
1034
+ acc.mutating = true;
1035
+ acc.reasons.push(`git ${sub}`);
1036
+ return;
1037
+ default:
1038
+ return;
1039
+ }
1040
+ }
1041
+
1042
+ /** Redirections to anything other than /dev/null write to the filesystem. */
1043
+ function applyRedirects(redirects, acc) {
1044
+ for (const r of redirects) {
1045
+ if ((r.op === ">" || r.op === ">>" || r.op === "&>" || r.op === "2>") && r.target && !/^\/dev\/(null|stdout|stderr|tty|fd)/.test(r.target) && r.target !== "&1" && r.target !== "&2") {
1046
+ acc.mutating = true;
1047
+ acc.reasons.push(`redirect ${r.op} ${r.target}`);
1048
+ }
1049
+ }
1050
+ }
1051
+
1052
+ /** Does any top-level command in these pipelines invoke a bare shell interpreter? */
1053
+ function hasBareShell(pipelines) {
1054
+ for (const pipeline of pipelines) {
1055
+ for (const cmd of pipeline) {
1056
+ let k = 0;
1057
+ const words = cmd.words || [];
1058
+ while (k < words.length && ENV_ASSIGN.test(words[k])) k += 1;
1059
+ const head = baseName(words[k] || "");
1060
+ if (STDIN_SHELLS.has(head) && !words.includes("-c")) return true;
1061
+ }
1062
+ }
1063
+ return false;
1064
+ }
1065
+
1066
+ /**
1067
+ * Extract the literal text emitted by an `echo`/`printf` word list, dropping
1068
+ * only echo's own leading flags (-n/-e/-E) and printf's format string — NOT the
1069
+ * inner command's flags (so `echo rm -rf x` yields `rm -rf x`, not `rm x`).
1070
+ */
1071
+ function echoCommandLiteral(head, words) {
1072
+ let parts = words.slice(1);
1073
+ if (head === "echo") {
1074
+ while (parts.length && /^-[neE]+$/.test(parts[0])) parts = parts.slice(1);
1075
+ } else if (head === "printf" && parts.length && /%/.test(parts[0])) {
1076
+ parts = parts.slice(1);
1077
+ }
1078
+ return parts.join(" ") || null;
1079
+ }
1080
+
1081
+ /** If a substitution is `echo X`/`printf X`, return X (a candidate script). */
1082
+ function echoLiteralOf(substString) {
1083
+ const { pipelines } = structure(lex(substString));
1084
+ if (pipelines.length !== 1 || pipelines[0].length !== 1) return null;
1085
+ const words = pipelines[0][0].words || [];
1086
+ const head = baseName(words[0] || "");
1087
+ if (head !== "echo" && head !== "printf") return null;
1088
+ return echoCommandLiteral(head, words);
1089
+ }
1090
+
1091
+ /** Analyze a command string, merging results into `acc`. */
1092
+ function analyzeInto(rawInput, depth, acc) {
1093
+ if (depth > MAX_DEPTH || typeof rawInput !== "string" || rawInput.length === 0) return;
1094
+ const input = decodeAnsiCQuotes(rawInput);
1095
+
1096
+ // Fork-bomb detection at the raw level.
1097
+ if (/:\s*\(\s*\)\s*\{[^}]*\|\s*:\s*&[^}]*\}\s*;\s*:/.test(input) || /\}\s*;\s*:\s*$/.test(input.replace(/\s+/g, " "))) {
1098
+ if (/:\(\)\{.*:\|:.*\}/.test(input.replace(/\s+/g, ""))) {
1099
+ acc.destructive = true;
1100
+ acc.reasons.push("fork bomb");
1101
+ }
1102
+ }
1103
+
1104
+ const tokens = lex(input);
1105
+ const { pipelines, substs } = structure(tokens);
1106
+ for (const pipeline of pipelines) {
1107
+ pipeline.forEach((cmd, indexInPipeline) => {
1108
+ classifyCommand(cmd.words, cmd.redirects, depth, acc, pipeline, indexInPipeline);
1109
+ });
1110
+ }
1111
+ // When a bare shell interpreter is present (e.g. `bash <(echo rm -rf /)`), an
1112
+ // echoed substitution is a script fed to that shell — analyze its literal as code.
1113
+ const shellPresent = hasBareShell(pipelines);
1114
+ for (const s of substs) {
1115
+ if (shellPresent) {
1116
+ const literal = echoLiteralOf(s);
1117
+ if (literal) {
1118
+ analyzeInto(literal, depth + 1, acc);
1119
+ continue;
1120
+ }
1121
+ }
1122
+ analyzeInto(s, depth + 1, acc);
1123
+ }
1124
+ }
1125
+
1126
+ /**
1127
+ * Analyze a shell command string and return its classification.
1128
+ * @param {string} command
1129
+ * @returns {{ destructive: boolean, mutating: boolean, verification: boolean, networkExec: boolean, reasons: string[] }}
1130
+ */
1131
+ export function analyzeCommand(command) {
1132
+ const acc = { destructive: false, mutating: false, verification: false, networkExec: false, reasons: [] };
1133
+ const input = typeof command === "string" ? command.trim() : "";
1134
+ if (!input) return acc;
1135
+ try {
1136
+ analyzeInto(input, 0, acc);
1137
+ } catch {
1138
+ // A parser failure must never crash a tool call; fall back to "unknown but
1139
+ // not blocked" so the host's own permission rules still apply.
1140
+ }
1141
+ return acc;
1142
+ }
1143
+
1144
+ /** Back-compat helpers preserving the previous public surface. */
1145
+ export function looksLikeDestructiveBash(command) {
1146
+ const a = analyzeCommand(command);
1147
+ return a.destructive;
1148
+ }
1149
+
1150
+ export function looksLikeMutatingBash(command) {
1151
+ const a = analyzeCommand(command);
1152
+ return a.destructive || a.mutating;
1153
+ }
1154
+
1155
+ export function isVerification(command) {
1156
+ return analyzeCommand(command).verification;
1157
+ }
1158
+
1159
+ export const __shellTest = { lex, structure, baseName, classifyGit, classifyPackageManager };