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.
- package/ARCHITECTURE.md +180 -0
- package/README.md +158 -52
- package/agents/goal-api-reviewer.md +0 -2
- package/agents/goal-architect.md +0 -2
- package/agents/goal-commentator.md +0 -2
- package/agents/goal-completion-guard.md +0 -2
- package/agents/goal-coordinator.md +0 -2
- package/agents/goal-data-reviewer.md +0 -2
- package/agents/goal-deep-researcher.md +0 -2
- package/agents/goal-diff-reviewer.md +0 -2
- package/agents/goal-doc-reviewer.md +0 -2
- package/agents/goal-doc-writer.md +0 -2
- package/agents/goal-explorer.md +9 -8
- package/agents/goal-final-auditor.md +0 -2
- package/agents/goal-implementer.md +0 -2
- package/agents/goal-mapper.md +0 -2
- package/agents/goal-ops-reviewer.md +0 -2
- package/agents/goal-perf-reviewer.md +0 -2
- package/agents/goal-planner.md +10 -5
- package/agents/goal-prompt-auditor.md +0 -2
- package/agents/goal-quality-gate.md +0 -2
- package/agents/goal-researcher.md +8 -7
- package/agents/goal-reviewer.md +0 -2
- package/agents/goal-security-reviewer.md +0 -2
- package/agents/goal-test-reviewer.md +0 -2
- package/agents/goal-ux-reviewer.md +0 -2
- package/agents/goal-verifier.md +0 -2
- package/agents/goal-web-researcher.md +0 -2
- package/agents/goal.md +9 -8
- package/package.json +13 -9
- package/plugins/goal-guard/agents.js +132 -0
- package/plugins/goal-guard/completion.js +64 -0
- package/plugins/goal-guard/config.js +87 -0
- package/plugins/goal-guard/events.js +65 -0
- package/plugins/goal-guard/gates.js +85 -0
- package/plugins/goal-guard/logger.js +36 -0
- package/plugins/goal-guard/persistence.js +122 -0
- package/plugins/goal-guard/shell.js +1159 -0
- package/plugins/goal-guard/state.js +182 -0
- package/plugins/goal-guard/summary.js +46 -0
- package/plugins/goal-guard/system.js +43 -0
- package/plugins/goal-guard/tools.js +129 -0
- package/plugins/goal-guard/verdicts.js +87 -0
- package/plugins/goal-guard.js +267 -379
- package/plugins/package.json +3 -0
- package/scripts/install.mjs +170 -36
- package/docs/research-report.md +0 -37
- package/scripts/check-npm-publish-ready.mjs +0 -54
- package/scripts/validate-opencode-config.mjs +0 -82
- package/tests/agents.test.mjs +0 -70
- package/tests/commands.test.mjs +0 -23
- package/tests/helpers.mjs +0 -23
- package/tests/install.test.mjs +0 -64
- 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 };
|