@hncode/hncode 0.5.2 → 0.5.4

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.
@@ -1,562 +1,610 @@
1
- // Which shell commands are read-only, and which are destructive enough to always
2
- // ask about. Both answers are load-bearing:
3
- //
4
- // * `isReadOnlyCommand` lets a harmless command (`git status`) run without a
5
- // prompt in Ask mode — a wrong "yes" runs something the user never approved.
6
- // * `isDestructiveCommand` is what still asks in YOLO mode — a wrong "no" runs
7
- // `dd of=/dev/sda` silently.
8
- //
9
- // Both used to be a single regex tested against the WHOLE command string, which
10
- // failed in both directions at once:
11
- //
12
- // `\bformat\b` matched the WORD, so `npm run format`, `grep -rn format src/`
13
- // and `dotnet format` all looked destructive — and in YOLO mode those prompt.
14
- // `>\s*/dev/` matched `/dev/null`, the most common redirect in the language.
15
- // `\bgit\s+push\b` matched `git push --dry-run`, and `\bgit\s+clean\b` matched
16
- // `git clean -n`.
17
- //
18
- // Meanwhile `dd of=/dev/sda`, `find -delete`, `truncate -s 0`, `chmod -R 000 /`,
19
- // `crontab -r`, `docker system prune -af`, `reg delete` and `taskkill /f` were
20
- // not in the regex at all, so YOLO ran them without a word.
21
- //
22
- // So this module does what kimi's bash parser does, minus the parser: it SPLITS
23
- // the command into the segments a shell would execute, TOKENIZES each one, peels
24
- // off the wrappers that hide the real program (`sudo`, `env`, `timeout`,
25
- // `sh -c "…"`), and only then asks what the real program is. A rule about `format`
26
- // can therefore mean the COMMAND `format`, not the word appearing in an argument.
27
- //
28
- // It is deliberately not a full shell parser. It knows the constructs that hide a
29
- // destructive command, and errs toward "destructive" (ask) when it cannot tell.
30
- // See test/shell-safety.test.mjs for the behaviours that must hold.
31
-
32
- // ---------------------------------------------------------------------------
33
- // Lexing
34
- // ---------------------------------------------------------------------------
35
-
36
- /**
37
- * Split a command on the control operators a shell would act on, respecting
38
- * quotes. Only UNQUOTED operators split: in `grep "a|b" src/` the pipe is data,
39
- * and treating it as a pipe is what made the old metacharacter test reject a
40
- * read-only command.
41
- *
42
- * Returns { segments, metacharacters, redirects }:
43
- * segments — one entry per command the shell would run
44
- * metacharacters — unquoted operator characters seen (for the read-only test)
45
- * redirects — unquoted redirect targets, e.g. ['/dev/null']
46
- */
47
- /**
48
- * Index of the `)` closing the `(` at `open`, or -1.
49
- *
50
- * Depth-counted so a nested `$(a $(b))` closes at the right place, and quote-aware so a `)`
51
- * inside a string argument does not end the substitution early.
52
- */
53
- function matchingParen(s, open) {
54
- let depth = 0;
55
- for (let i = open; i < s.length; i++) {
56
- const c = s[i];
57
- if (c === '\\') { i++; continue; }
58
- if (c === "'" || c === '"') {
59
- const quote = c;
60
- for (i++; i < s.length; i++) {
61
- if (quote === '"' && s[i] === '\\') { i++; continue; }
62
- if (s[i] === quote) break;
63
- }
64
- continue;
65
- }
66
- if (c === '(') depth++;
67
- else if (c === ')') { depth--; if (depth === 0) return i; }
68
- }
69
- return -1;
70
- }
71
-
72
- export function splitSegments(cmd) {
73
- const s = String(cmd == null ? '' : cmd);
74
- const segments = [];
75
- const metacharacters = new Set();
76
- const redirects = [];
77
- let current = '';
78
- let i = 0;
79
- while (i < s.length) {
80
- const c = s[i];
81
- if (c === '\\') { current += s.slice(i, i + 2); i += 2; continue; }
82
- if (c === "'" || c === '"') {
83
- // Consume the whole quoted run verbatim. A quote can also OPEN inside a word
84
- // (`--msg="a b"`); appending keeps the word together for tokenizeWords.
85
- const quote = c;
86
- let j = i + 1;
87
- current += c;
88
- while (j < s.length) {
89
- if (quote === '"' && s[j] === '\\' && j + 1 < s.length) { current += s.slice(j, j + 2); j += 2; continue; }
90
- current += s[j];
91
- if (s[j] === quote) { j++; break; }
92
- j++;
93
- }
94
- i = j;
95
- continue;
96
- }
97
- const two = s.slice(i, i + 2);
98
- if (two === '&&' || two === '||' || two === '>>') {
99
- metacharacters.add(two[0]);
100
- if (two === '>>') redirects.push(readRedirect(s, i + 2));
101
- else { segments.push(current); current = ''; }
102
- i += 2;
103
- continue;
104
- }
105
- if (c === ';' || c === '|' || c === '&' || c === '\n') {
106
- // All four end a segment. `&` backgrounds it, which still runs it.
107
- metacharacters.add(c === '\n' ? ';' : c);
108
- segments.push(current);
109
- current = '';
110
- i += 1;
111
- continue;
112
- }
113
- if (c === '>') {
114
- metacharacters.add('>');
115
- redirects.push(readRedirect(s, i + 1));
116
- i += 1;
117
- continue;
118
- }
119
- if (c === '<') { metacharacters.add(c); i += 1; continue; }
120
- // A backtick substitution is the same hazard as `$(…)`: it runs a command and splices the
121
- // result into this one. Handled as a pair so the inner command is judged too.
122
- if (c === '`') {
123
- metacharacters.add(c);
124
- const close = s.indexOf('`', i + 1);
125
- if (close > 0) {
126
- const inner = s.slice(i + 1, close);
127
- if (inner.trim()) { segments.push(current); segments.push(inner); current = ''; }
128
- current += s.slice(i, close + 1);
129
- i = close + 1;
130
- continue;
131
- }
132
- i += 1; continue;
133
- }
134
- if (c === '$') {
135
- // `$(…)` runs a command and its OUTPUT is spliced into this one. The text has to stay
136
- // in the segment for tokenizing, but the substitution is also a command in its own
137
- // right — so it is emitted as a segment of its own. Without that, `npm test $(rm -rf /)`
138
- // was a single segment that the `Bash(npm test *)` prefix rule matched, and
139
- // isDestructiveCommand never saw `rm` at all.
140
- if (s[i + 1] === '(') {
141
- metacharacters.add('$');
142
- const close = matchingParen(s, i + 1);
143
- if (close > 0) {
144
- const inner = s.slice(i + 2, close);
145
- if (inner.trim()) {
146
- // The outer text so far, then the substitution itself as its own command.
147
- segments.push(current);
148
- segments.push(inner);
149
- current = '';
150
- }
151
- // The outer command continues with the substitution text in place, so what follows
152
- // it stays attached to the same command line.
153
- current += s.slice(i, close + 1);
154
- i = close + 1;
155
- continue;
156
- }
157
- }
158
- current += c; i += 1; continue;
159
- }
160
- if (c === '(' || c === ')') { metacharacters.add(c); current += c; i += 1; continue; }
161
- current += c;
162
- i += 1;
163
- }
164
- segments.push(current);
165
- return { segments, metacharacters, redirects };
166
- }
167
-
168
- // The token after a redirect operator; may be absent (`cmd >`).
169
- function readRedirect(s, from) {
170
- let i = from;
171
- while (i < s.length && (s[i] === ' ' || s[i] === '\t')) i++;
172
- let out = '';
173
- while (i < s.length && !/[\s|;&<>]/.test(s[i])) { out += s[i]; i++; }
174
- return out.replace(/^['"]+|['"]+$/g, '');
175
- }
176
-
177
- /**
178
- * Split one segment into words, dropping the quotes and keeping a word together
179
- * across quoted whitespace (`--msg="a b"` is ONE word).
180
- */
181
- export function tokenizeWords(segment) {
182
- const s = String(segment == null ? '' : segment).trim();
183
- const words = [];
184
- let cur = '';
185
- let has = false;
186
- let i = 0;
187
- while (i < s.length) {
188
- const c = s[i];
189
- if (/\s/.test(c)) { if (has) { words.push(cur); cur = ''; has = false; } i++; continue; }
190
- has = true;
191
- if (c === '\\' && i + 1 < s.length) { cur += s[i + 1]; i += 2; continue; }
192
- if (c === "'" || c === '"') {
193
- const quote = c;
194
- let j = i + 1;
195
- while (j < s.length) {
196
- if (quote === '"' && s[j] === '\\' && j + 1 < s.length) { cur += s[j + 1]; j += 2; continue; }
197
- if (s[j] === quote) break;
198
- cur += s[j]; j++;
199
- }
200
- i = j + 1;
201
- continue;
202
- }
203
- cur += c;
204
- i++;
205
- }
206
- if (has) words.push(cur);
207
- return words;
208
- }
209
-
210
- // ---------------------------------------------------------------------------
211
- // Wrappers
212
- // ---------------------------------------------------------------------------
213
-
214
- // Programs whose only job is to launch another program. `sudo rm -rf /` and
215
- // `rm -rf /` are the same act, so the wrapper has to come off before the real
216
- // command is judged. The old whole-string regex caught `sudo rm` by accident,
217
- // but could not catch `sh -c "rm -rf /"` at all.
218
- const LAUNCH_WRAPPERS = new Set([
219
- 'sudo', 'doas', 'env', 'command', 'exec', 'nohup', 'builtin', 'nice',
220
- 'ionice', 'setsid', 'stdbuf', 'time', 'timeout', 'chrt', 'taskset', 'unbuffer',
221
- ]);
222
-
223
- // The subset of wrappers that ESCALATE privileges. They are still peeled for the
224
- // destructive verdict (`sudo rm -rf` is a delete), but they can never count as
225
- // read-only: `sudo cat f` and `sudo cat /etc/shadow` look identical from here.
226
- const PRIVILEGE_WRAPPERS = new Set(['sudo', 'doas', 'runas', 'su']);
227
-
228
- // Options of a wrapper that TAKE A VALUE, so the value is not mistaken for the
229
- // program being launched.
230
- const WRAPPER_VALUE_OPTIONS = new Set([
231
- // sudo / doas
232
- '-u', '--user', '-g', '--group', '-h', '--host', '-p', '--prompt', '-C', '--close-from',
233
- '-T', '--command-timeout', '-U', '--other-user', '-r', '--role', '-t', '--type',
234
- // env
235
- '--unset', '--chdir', '--split-string', '-S',
236
- // nice / timeout / taskset / ionice / chrt / stdbuf
237
- '-n', '--adjustment', '-k', '--kill-after', '-s', '--signal', '-p', '--pid',
238
- '-c', '--cpu-list', '--class', '--classdata', '--io-class', '-o', '--output',
239
- '--priority', '--scheduling-policy',
240
- ]);
241
-
242
- // Shells. `sh -c "…"` hands the rest of the line to a NEW shell, so its argument
243
- // is a nested program that needs the same analysis.
244
- const NESTED_SHELLS = new Set(['sh', 'bash', 'dash', 'zsh', 'ksh', 'ash', 'fish', 'csh', 'tcsh', 'pwsh', 'powershell']);
245
- // The flags that make a shell's next word a command string.
246
- const SHELL_COMMAND_FLAGS = new Set(['-c', '-command', '/c', '-cmd']);
247
-
248
- function basename(word) {
249
- return String(word || '').replace(/^.*[\\/]/, '').replace(/\.(exe|cmd|bat|com|ps1)$/i, '').toLowerCase();
250
- }
251
-
252
- /**
253
- * Peel launch wrappers off `words`, returning the real program and its arguments.
254
- * `nested` is a command STRING a wrapper introduced (`sh -c "…"`), which the
255
- * caller analyzes as a fresh program. `cmd` is '' when the whole segment was a
256
- * wrapper with nothing to launch.
257
- */
258
- function unwrap(words) {
259
- let i = 0;
260
- // Bounded, so a crafted chain cannot spin this loop.
261
- for (let guard = 0; guard < 32 && i < words.length; guard++) {
262
- // `FOO=bar cmd` — a variable assignment prefix, per POSIX.
263
- while (i < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i])) i++;
264
- if (i >= words.length) break;
265
- const cmd = basename(words[i]);
266
- const rest = words.slice(i + 1);
267
-
268
- if (CMD_SHELLS.has(cmd)) {
269
- // `cmd /c …` — the argument after the flag is the command string.
270
- const flagIdx = rest.findIndex((w) => SHELL_COMMAND_FLAGS.has(w.toLowerCase()));
271
- if (flagIdx >= 0) return { cmd: '', args: [], nested: rest.slice(flagIdx + 1).join(' ') };
272
- return { cmd, args: rest, nested: null };
273
- }
274
- if (NESTED_SHELLS.has(cmd)) {
275
- const flagIdx = rest.findIndex((w) => SHELL_COMMAND_FLAGS.has(w.toLowerCase()));
276
- // `sh -c "rm -rf /"` -> the string is the program.
277
- if (flagIdx >= 0) return { cmd: '', args: [], nested: rest.slice(flagIdx + 1).join(' ') };
278
- // `bash script.sh` runs that file; the command is the shell itself.
279
- return { cmd, args: rest, nested: null };
280
- }
281
- if (LAUNCH_WRAPPERS.has(cmd)) {
282
- let j = i + 1;
283
- while (j < words.length && words[j].startsWith('-')) {
284
- const key = words[j].split('=')[0];
285
- const inlineValue = words[j].includes('=');
286
- j += (!inlineValue && WRAPPER_VALUE_OPTIONS.has(key)) ? 2 : 1;
287
- }
288
- // `timeout 5 rm -rf /` — the duration is a positional operand of the wrapper.
289
- while (j < words.length && /^\d+(\.\d+)?[smhd]?$/.test(words[j])) j++;
290
- if (j >= words.length) break;
291
- i = j;
292
- continue;
293
- }
294
- return { cmd, args: rest, nested: null };
295
- }
296
- return { cmd: '', args: [], nested: null };
297
- }
298
-
299
- // ---------------------------------------------------------------------------
300
- // Destructive shapes
301
- // ---------------------------------------------------------------------------
302
-
303
- // Shells that take a command string with /c rather than -c.
304
- const CMD_SHELLS = new Set(['cmd']);
305
-
306
- // A program that is destructive whatever it is pointed at. `mkfs.ext4` and the
307
- // Windows `format` are matched by stem below.
308
- const ALWAYS_DESTRUCTIVE = new Set([
309
- 'shutdown', 'reboot', 'halt', 'poweroff', 'diskpart', 'bcdedit',
310
- 'wipefs', 'shred', 'fdisk', 'cfdisk', 'sgdisk', 'mkswap',
311
- 'restart-computer', 'stop-computer',
312
- ]);
313
-
314
- // Interpreters that run their argument as code. `python -c "shutil.rmtree(…)"`
315
- // is a delete no command-name rule can see, so it is judged by the text.
316
- const CODE_INTERPRETERS = new Set(['python', 'python3', 'py', 'node', 'nodejs', 'perl', 'ruby', 'php', 'lua']);
317
- const CODE_DESTRUCTIVE_RE = /\b(rmtree|unlink|rmdir|remove_tree|shutil\.rm|fs\.rm|rmSync|unlinkSync|rmdirSync|rmRemove|FileUtils\.rm|system\s*\(\s*['"]rm)|subprocess\.(run|call|Popen)[^;]{0,40}\brm\b/i;
318
-
319
- // Patterns judged against the WHOLE command text, because no single program owns
320
- // them: a redirect into a block device, a recursive chmod to no permissions, a
321
- // fork bomb. Kept narrow on purpose — broad patterns here are what produced the
322
- // false positives described at the top of this file.
323
- const TEXT_DESTRUCTIVE_RE = [
324
- /:\(\)\s*\{[^}]*\};\s*:/, // fork bomb
325
- />\s*\/dev\/(sd|nvme|disk|hd|vd|fd|mmcblk)/, // redirect to a block device
326
- /\bdd\b[^|;&]*\bof=\/dev\/(sd|nvme|disk|hd|vd|mmcblk)/,
327
- /\bmkfs(\.[a-z0-9]+)?\b/,
328
- /\b(bcdedit|vssadmin)\b[^|;&]*\b(delete|revert)\b/,
329
- /(^|[|;&]\s*)chmod\b[^|;&]*-[rR][^|;&]*\b0{3,4}\b/,
330
- /(^|[|;&]\s*)chown\b[^|;&]*-[rR]\s+\S+\s+\/(\s|$)/,
331
- ];
332
-
333
- // `rm -rf`, `rm -r -f`, `rm --recursive`: recursion deletes a tree in one go, and
334
- // `-f` stops asking about each file. Either flag alone is enough to be worth a
335
- // prompt — this keeps the guarantee the old regex had (it matched `-r` and `-f`
336
- // alike, since `\w*[rf]` allowed an empty `\w*`) while dropping its habit of
337
- // matching those letters anywhere in the line.
338
- function rmIsDestructive(args) {
339
- let recursive = false;
340
- let force = false;
341
- for (const a of args) {
342
- if (!a.startsWith('-') || a === '-') continue; // a path, not a flag
343
- if (a === '--') break;
344
- if (a.startsWith('--')) {
345
- if (a === '--recursive') recursive = true;
346
- if (a === '--force') force = true;
347
- continue;
348
- }
349
- const letters = a.slice(1); // combined shorts: -rf
350
- if (/[rR]/.test(letters)) recursive = true;
351
- if (/f/.test(letters)) force = true;
352
- }
353
- return recursive || force;
354
- }
355
- /** `mkfs`, `mkfs.ext4`, `mkfs.xfs` — the whole family. */
356
- function isMkfs(cmd) { return cmd === 'mkfs' || cmd.startsWith('mkfs.'); }
357
-
358
- /**
359
- * The git SUBCOMMAND from an argv tail.
360
- *
361
- * The obvious `args.find(a => !a.startsWith('-'))` returns the first non-flag WORD, which
362
- * for `git -C /repo push` is `/repo` — the VALUE of `-C`. Every subcommand rule then missed
363
- * and `git -C x push`, `git -c core.pager=x reset --hard`, `git -c foo=bar clean` all
364
- * passed the destructive check. Three flag shapes have to be stepped over:
365
- *
366
- * -C <path> / -c <name=value> a separate value in the NEXT word
367
- * --git-dir=<path> the value is inside the SAME word
368
- * -p / --paginate no value at all
369
- *
370
- * Only the flags git treats as value-taking are listed, so a bare `-x` is still skipped as
371
- * a plain flag and cannot swallow the subcommand beside it.
372
- */
373
- function gitSubcommand(args) {
374
- const TAKES_VALUE = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path']);
375
- for (let i = 0; i < args.length; i++) {
376
- const a = args[i];
377
- if (!a.startsWith('-')) return a.toLowerCase();
378
- // `--git-dir=/x` carries its own value, so the next word is the subcommand.
379
- if (a.includes('=')) continue;
380
- if (TAKES_VALUE.has(a)) i++; // step over the value
381
- }
382
- return '';
383
- }
384
- // Judge ONE already-unwrapped command. Returns a reason string, or null when the
385
- // command is not destructive.
386
- function judgeUnwrapped(cmd, args) {
387
- if (!cmd) return null;
388
- if (ALWAYS_DESTRUCTIVE.has(cmd)) return `${cmd} is a system-level destructive command`;
389
- if (isMkfs(cmd)) return 'mkfs formats a filesystem';
390
- if (cmd === 'format') return 'format is the Windows disk-format command';
391
-
392
- switch (cmd) {
393
- case 'rm': case 'rmdir':
394
- if (rmIsDestructive(args)) return 'a recursive or forced delete';
395
- break;
396
- case 'del': case 'erase':
397
- // A delete whatever its flags, which is what the old `\bdel\b` matched.
398
- // Narrowed to the command NAME, so a path containing "del" no longer trips it.
399
- return 'the Windows delete command';
400
- case 'remove-item': {
401
- const joined = args.join(' ').toLowerCase();
402
- if (/-recurse\b|\s-r\b/.test(joined)) return 'Remove-Item -Recurse';
403
- break;
404
- }
405
- case 'find': case 'fd': case 'gfind':
406
- if (args.includes('-delete')) return 'find -delete';
407
- for (const flag of ['-exec', '-execdir', '-ok', '-okdir']) {
408
- const i = args.indexOf(flag);
409
- if (i >= 0 && args[i + 1] && /^(rm|shred|del)$/i.test(basename(args[i + 1]))) return `find ${flag} rm`;
410
- }
411
- break;
412
- case 'xargs':
413
- if (args.some((a) => /^(rm|shred|del)$/i.test(basename(a)))) return 'xargs running a delete';
414
- break;
415
- case 'truncate': {
416
- const i = args.findIndex((a) => a === '-s' || a === '--size');
417
- if (i >= 0 && args[i + 1] === '0') return 'truncate to zero length';
418
- break;
419
- }
420
- case 'crontab':
421
- if (args.some((a) => a === '-r' || a === '--remove')) return 'crontab -r removes every scheduled job';
422
- break;
423
- case 'taskkill':
424
- if (args.some((a) => /^\/f$/i.test(a))) return 'taskkill /f';
425
- break;
426
- case 'reg': case 'reg.exe': {
427
- const sub = (args.find((a) => !a.startsWith('-') && !a.startsWith('/')) || '').toLowerCase();
428
- if (sub === 'delete') return 'reg delete';
429
- break;
430
- }
431
- case 'sc': case 'sc.exe': {
432
- const sub = (args.find((a) => !a.startsWith('-')) || '').toLowerCase();
433
- if (sub === 'delete') return 'sc delete';
434
- break;
435
- }
436
- case 'docker':
437
- if (args.includes('prune')) return 'docker prune';
438
- break;
439
- case 'git': {
440
- const sub = gitSubcommand(args);
441
- // A dry run changes nothing, and flagging it is what made `git push
442
- // --dry-run` prompt in YOLO mode.
443
- if (args.some((a) => a === '-n' || a === '--dry-run')) break;
444
- if (sub === 'push') return 'git push';
445
- if (sub === 'reset' && args.includes('--hard')) return 'git reset --hard';
446
- if (sub === 'clean') return 'git clean removes untracked files';
447
- if (sub === 'branch' && args.includes('-D')) return 'git branch -D';
448
- break;
449
- }
450
- case 'npm': case 'yarn': case 'pnpm':
451
- if (args.includes('publish') && !args.includes('--dry-run')) return 'publishing a package';
452
- break;
453
- case 'sh': case 'bash': case 'dash': case 'zsh': case 'ksh': case 'ash':
454
- case 'pwsh': case 'powershell': case 'cmd':
455
- // Only reachable as `sh script.sh` (the `-c` form is unwrapped into a nested
456
- // command). Nothing to judge without reading the script.
457
- break;
458
- }
459
-
460
- if (CODE_INTERPRETERS.has(cmd)) {
461
- const code = args.filter((a) => !a.startsWith('-')).join(' ');
462
- if (CODE_DESTRUCTIVE_RE.test(code)) return `${cmd} running a delete`;
463
- }
464
- return null;
465
- }
466
-
467
- // ---------------------------------------------------------------------------
468
- // Public API
469
- // ---------------------------------------------------------------------------
470
-
471
- const MAX_NESTED_DEPTH = 4;
472
-
473
- /**
474
- * Is this command destructive enough that it must never auto-run? Reaching this
475
- * means the user is asked, even in YOLO mode.
476
- */
477
- export function isDestructiveCommand(cmd, depth = 0) {
478
- const text = String(cmd == null ? '' : cmd);
479
- if (!text.trim()) return false;
480
- if (depth >= MAX_NESTED_DEPTH) return true; // too deep to judge: ask
481
- for (const re of TEXT_DESTRUCTIVE_RE) if (re.test(text)) return true;
482
- const { segments } = splitSegments(text);
483
- for (const seg of segments) {
484
- const words = tokenizeWords(seg);
485
- if (!words.length) continue;
486
- const u = unwrap(words);
487
- if (judgeUnwrapped(u.cmd, u.args)) return true;
488
- // `sh -c "…"` / `cmd /c …`: the wrapper's own command string is a program.
489
- if (u.nested && isDestructiveCommand(u.nested, depth + 1)) return true;
490
- }
491
- return false;
492
- }
493
-
494
- // A read-only command's every segment is a known query/inspection tool. Anything
495
- // that MUTATES is not on the list, and anything chained, redirected or substituted
496
- // is rejected outright — a wrapper could hide a write.
497
- const READ_ONLY_BINS = new Set([
498
- 'ls', 'dir', 'pwd', 'cd', 'echo', 'cat', 'head', 'tail', 'wc', 'sort', 'uniq',
499
- 'grep', 'rg', 'ag', 'find', 'fd', 'which', 'where', 'whoami', 'hostname',
500
- 'date', 'env', 'printenv', 'stat', 'file', 'du', 'df', 'tree', 'type',
501
- 'jq', 'tr', 'cut', 'diff', 'man', 'help', 'true', 'test',
502
- 'git', // further restricted to read-only verbs below
503
- // NOT here on purpose: node/python/npm/npx/make/docker/kubectl/gh — each can
504
- // execute arbitrary code or mutate state via a flag (`node -e`, `python -c`,
505
- // `npm install`), so allowing the bare binary would be a hole.
506
- ]);
507
-
508
- // `git` verbs that do not write. Anything else is refused.
509
- const GIT_READ_SUBCMDS = new Set([
510
- 'status', 'log', 'diff', 'show', 'branch', 'remote', 'describe', 'blame',
511
- 'rev-parse', 'ls-files', 'ls-tree', 'cat-file', 'shortlog', 'tag', 'config',
512
- 'reflog', 'whatchanged', 'name-rev', 'grep', 'count-objects', 'symbolic-ref',
513
- 'show-ref', 'for-each-ref', 'worktree',
514
- ]);
515
- // Flags that turn a nominally read-only verb into a write (`git branch -D`,
516
- // `git tag -d`, `git remote add`, `git config --set`).
517
- const GIT_WRITE_FLAGS = new Set([
518
- '-d', '-D', '-m', '-M', '--set', '--unset', '--add', '--edit', '--delete',
519
- '--amend', '--force', '-f', '--global', '--local', '--system', 'add', 'remove',
520
- ]);
521
-
522
- /** Is this command read-only, i.e. safe to run without a prompt in Ask mode? */
523
- export function isReadOnlyCommand(cmd) {
524
- const text = String(cmd == null ? '' : cmd).trim();
525
- if (!text) return false;
526
- const { segments, metacharacters, redirects } = splitSegments(text);
527
- // Any unquoted operator, substitution or redirect means the effect is not
528
- // confined to what the first word suggests.
529
- if (metacharacters.size) return false;
530
- if (redirects.length) return false;
531
- const words = [];
532
- for (const seg of segments) words.push(...tokenizeWords(seg));
533
- if (!words.length) return false;
534
- // Privilege escalation is never "read-only": `sudo cat` reads one file and can
535
- // also read every file, so it asks. Checked on the RAW first word, because
536
- // peeling the wrapper would turn `sudo ls` into a plain `ls`.
537
- if (PRIVILEGE_WRAPPERS.has(basename(words[0]))) return false;
538
- const u = unwrap(words);
539
- if (!u.cmd) return false; // a bare `sh -c "…"` is not provable
540
- if (!READ_ONLY_BINS.has(u.cmd)) return false;
541
- if (u.cmd === 'git') {
542
- // The shared extractor, not `args.find(a => !a.startsWith('-'))`: that form returns the
543
- // VALUE of `-C`/`-c` (`git -C /repo status` yielded `/repo`), so the verb was never read
544
- // and an ordinary read-only git command was refused. The destructive check learned this
545
- // first; the two judged the same command differently until they shared one function.
546
- const sub = gitSubcommand(u.args);
547
- if (!sub || !GIT_READ_SUBCMDS.has(sub)) return false;
548
- // `tag`/`branch`/`remote`/`config`/`worktree` are read-only only WITHOUT a
549
- // writing flag, so any of those flags refuses the whole command.
550
- if (u.args.some((a) => GIT_WRITE_FLAGS.has(a))) return false;
551
- }
552
- if (u.cmd === 'find' || u.cmd === 'fd') {
553
- for (const f of ['-delete', '-exec', '-execdir', '-ok', '-okdir']) {
554
- if (u.args.includes(f)) return false;
555
- }
556
- }
557
- // A bare `env`/`printenv` prints the environment, but `env rm -rf /` launches a
558
- // program — the wrapper peel above turns that into `rm`, which is not in the list.
559
- return true;
560
- }
561
-
562
- export default { isDestructiveCommand, isReadOnlyCommand, splitSegments, tokenizeWords };
1
+ // Which shell commands are read-only, and which are destructive enough to always
2
+ // ask about. Both answers are load-bearing:
3
+ //
4
+ // * `isReadOnlyCommand` lets a harmless command (`git status`) run without a
5
+ // prompt in Ask mode — a wrong "yes" runs something the user never approved.
6
+ // * `isDestructiveCommand` is what still asks in YOLO mode — a wrong "no" runs
7
+ // `dd of=/dev/sda` silently.
8
+ //
9
+ // Both used to be a single regex tested against the WHOLE command string, which
10
+ // failed in both directions at once:
11
+ //
12
+ // `\bformat\b` matched the WORD, so `npm run format`, `grep -rn format src/`
13
+ // and `dotnet format` all looked destructive — and in YOLO mode those prompt.
14
+ // `>\s*/dev/` matched `/dev/null`, the most common redirect in the language.
15
+ // `\bgit\s+push\b` matched `git push --dry-run`, and `\bgit\s+clean\b` matched
16
+ // `git clean -n`.
17
+ //
18
+ // Meanwhile `dd of=/dev/sda`, `find -delete`, `truncate -s 0`, `chmod -R 000 /`,
19
+ // `crontab -r`, `docker system prune -af`, `reg delete` and `taskkill /f` were
20
+ // not in the regex at all, so YOLO ran them without a word.
21
+ //
22
+ // So this module does what kimi's bash parser does, minus the parser: it SPLITS
23
+ // the command into the segments a shell would execute, TOKENIZES each one, peels
24
+ // off the wrappers that hide the real program (`sudo`, `env`, `timeout`,
25
+ // `sh -c "…"`), and only then asks what the real program is. A rule about `format`
26
+ // can therefore mean the COMMAND `format`, not the word appearing in an argument.
27
+ //
28
+ // It is deliberately not a full shell parser. It knows the constructs that hide a
29
+ // destructive command, and errs toward "destructive" (ask) when it cannot tell.
30
+ // See test/shell-safety.test.mjs for the behaviours that must hold.
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Lexing
34
+ // ---------------------------------------------------------------------------
35
+
36
+ /**
37
+ * Split a command on the control operators a shell would act on, respecting
38
+ * quotes. Only UNQUOTED operators split: in `grep "a|b" src/` the pipe is data,
39
+ * and treating it as a pipe is what made the old metacharacter test reject a
40
+ * read-only command.
41
+ *
42
+ * Returns { segments, metacharacters, redirects }:
43
+ * segments — one entry per command the shell would run
44
+ * metacharacters — unquoted operator characters seen (for the read-only test)
45
+ * redirects — unquoted redirect targets, e.g. ['/dev/null']
46
+ */
47
+ /**
48
+ * Index of the `)` closing the `(` at `open`, or -1.
49
+ *
50
+ * Depth-counted so a nested `$(a $(b))` closes at the right place, and quote-aware so a `)`
51
+ * inside a string argument does not end the substitution early.
52
+ */
53
+ function matchingParen(s, open) {
54
+ let depth = 0;
55
+ for (let i = open; i < s.length; i++) {
56
+ const c = s[i];
57
+ if (c === '\\') { i++; continue; }
58
+ if (c === "'" || c === '"') {
59
+ const quote = c;
60
+ for (i++; i < s.length; i++) {
61
+ if (quote === '"' && s[i] === '\\') { i++; continue; }
62
+ if (s[i] === quote) break;
63
+ }
64
+ continue;
65
+ }
66
+ if (c === '(') depth++;
67
+ else if (c === ')') { depth--; if (depth === 0) return i; }
68
+ }
69
+ return -1;
70
+ }
71
+
72
+ export function splitSegments(cmd) {
73
+ const s = String(cmd == null ? '' : cmd);
74
+ const segments = [];
75
+ const metacharacters = new Set();
76
+ const redirects = [];
77
+ let current = '';
78
+ let i = 0;
79
+ while (i < s.length) {
80
+ const c = s[i];
81
+ if (c === '\\') { current += s.slice(i, i + 2); i += 2; continue; }
82
+ if (c === "'" || c === '"') {
83
+ // Consume the whole quoted run verbatim. A quote can also OPEN inside a word
84
+ // (`--msg="a b"`); appending keeps the word together for tokenizeWords.
85
+ const quote = c;
86
+ let j = i + 1;
87
+ current += c;
88
+ while (j < s.length) {
89
+ if (quote === '"' && s[j] === '\\' && j + 1 < s.length) { current += s.slice(j, j + 2); j += 2; continue; }
90
+ current += s[j];
91
+ if (s[j] === quote) { j++; break; }
92
+ j++;
93
+ }
94
+ i = j;
95
+ continue;
96
+ }
97
+ const two = s.slice(i, i + 2);
98
+ if (two === '&&' || two === '||' || two === '>>') {
99
+ metacharacters.add(two[0]);
100
+ if (two === '>>') redirects.push(readRedirect(s, i + 2));
101
+ else { segments.push(current); current = ''; }
102
+ i += 2;
103
+ continue;
104
+ }
105
+ if (c === ';' || c === '|' || c === '&' || c === '\n') {
106
+ // All four end a segment. `&` backgrounds it, which still runs it.
107
+ metacharacters.add(c === '\n' ? ';' : c);
108
+ segments.push(current);
109
+ current = '';
110
+ i += 1;
111
+ continue;
112
+ }
113
+ // Process substitution `<(…)` / `>(…)`: the shell RUNS the inner command with
114
+ // its output/input on a file descriptor. Like `$(…)`, the inner command is
115
+ // judged as a segment of its own — otherwise `cat <(rm -rf /)` was one
116
+ // segment whose first word is `cat`.
117
+ if ((c === '<' || c === '>') && s[i + 1] === '(') {
118
+ metacharacters.add(c);
119
+ const close = matchingParen(s, i + 1);
120
+ if (close > 0) {
121
+ const inner = s.slice(i + 2, close);
122
+ if (inner.trim()) { segments.push(current); segments.push(inner); current = ''; }
123
+ current += s.slice(i, close + 1);
124
+ i = close + 1;
125
+ continue;
126
+ }
127
+ // Unclosed: falls through to the plain operator handling below.
128
+ }
129
+ if (c === '>') {
130
+ metacharacters.add('>');
131
+ redirects.push(readRedirect(s, i + 1));
132
+ i += 1;
133
+ continue;
134
+ }
135
+ if (c === '<') { metacharacters.add(c); i += 1; continue; }
136
+ // A backtick substitution is the same hazard as `$(…)`: it runs a command and splices the
137
+ // result into this one. Handled as a pair so the inner command is judged too.
138
+ if (c === '`') {
139
+ metacharacters.add(c);
140
+ const close = s.indexOf('`', i + 1);
141
+ if (close > 0) {
142
+ const inner = s.slice(i + 1, close);
143
+ if (inner.trim()) { segments.push(current); segments.push(inner); current = ''; }
144
+ current += s.slice(i, close + 1);
145
+ i = close + 1;
146
+ continue;
147
+ }
148
+ i += 1; continue;
149
+ }
150
+ if (c === '$') {
151
+ // `$(…)` runs a command and its OUTPUT is spliced into this one. The text has to stay
152
+ // in the segment for tokenizing, but the substitution is also a command in its own
153
+ // right — so it is emitted as a segment of its own. Without that, `npm test $(rm -rf /)`
154
+ // was a single segment that the `Bash(npm test *)` prefix rule matched, and
155
+ // isDestructiveCommand never saw `rm` at all.
156
+ if (s[i + 1] === '(') {
157
+ metacharacters.add('$');
158
+ const close = matchingParen(s, i + 1);
159
+ if (close > 0) {
160
+ const inner = s.slice(i + 2, close);
161
+ if (inner.trim()) {
162
+ // The outer text so far, then the substitution itself as its own command.
163
+ segments.push(current);
164
+ segments.push(inner);
165
+ current = '';
166
+ }
167
+ // The outer command continues with the substitution text in place, so what follows
168
+ // it stays attached to the same command line.
169
+ current += s.slice(i, close + 1);
170
+ i = close + 1;
171
+ continue;
172
+ }
173
+ }
174
+ current += c; i += 1; continue;
175
+ }
176
+ if (c === '(' || c === ')') { metacharacters.add(c); current += c; i += 1; continue; }
177
+ current += c;
178
+ i += 1;
179
+ }
180
+ segments.push(current);
181
+ return { segments, metacharacters, redirects };
182
+ }
183
+
184
+ // The token after a redirect operator; may be absent (`cmd >`).
185
+ function readRedirect(s, from) {
186
+ let i = from;
187
+ while (i < s.length && (s[i] === ' ' || s[i] === '\t')) i++;
188
+ let out = '';
189
+ while (i < s.length && !/[\s|;&<>]/.test(s[i])) { out += s[i]; i++; }
190
+ return out.replace(/^['"]+|['"]+$/g, '');
191
+ }
192
+
193
+ /**
194
+ * Split one segment into words, dropping the quotes and keeping a word together
195
+ * across quoted whitespace (`--msg="a b"` is ONE word).
196
+ */
197
+ export function tokenizeWords(segment) {
198
+ const s = String(segment == null ? '' : segment).trim();
199
+ const words = [];
200
+ let cur = '';
201
+ let has = false;
202
+ let i = 0;
203
+ while (i < s.length) {
204
+ const c = s[i];
205
+ if (/\s/.test(c)) { if (has) { words.push(cur); cur = ''; has = false; } i++; continue; }
206
+ has = true;
207
+ if (c === '\\' && i + 1 < s.length) { cur += s[i + 1]; i += 2; continue; }
208
+ if (c === "'" || c === '"') {
209
+ const quote = c;
210
+ let j = i + 1;
211
+ while (j < s.length) {
212
+ if (quote === '"' && s[j] === '\\' && j + 1 < s.length) { cur += s[j + 1]; j += 2; continue; }
213
+ if (s[j] === quote) break;
214
+ cur += s[j]; j++;
215
+ }
216
+ i = j + 1;
217
+ continue;
218
+ }
219
+ cur += c;
220
+ i++;
221
+ }
222
+ if (has) words.push(cur);
223
+ return words;
224
+ }
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // Wrappers
228
+ // ---------------------------------------------------------------------------
229
+
230
+ // Programs whose only job is to launch another program. `sudo rm -rf /` and
231
+ // `rm -rf /` are the same act, so the wrapper has to come off before the real
232
+ // command is judged. The old whole-string regex caught `sudo rm` by accident,
233
+ // but could not catch `sh -c "rm -rf /"` at all.
234
+ const LAUNCH_WRAPPERS = new Set([
235
+ 'sudo', 'doas', 'env', 'command', 'exec', 'nohup', 'builtin', 'nice',
236
+ 'ionice', 'setsid', 'stdbuf', 'time', 'timeout', 'chrt', 'taskset', 'unbuffer',
237
+ ]);
238
+
239
+ // The subset of wrappers that ESCALATE privileges. They are still peeled for the
240
+ // destructive verdict (`sudo rm -rf` is a delete), but they can never count as
241
+ // read-only: `sudo cat f` and `sudo cat /etc/shadow` look identical from here.
242
+ const PRIVILEGE_WRAPPERS = new Set(['sudo', 'doas', 'runas', 'su']);
243
+
244
+ // Options of a wrapper that TAKE A VALUE, so the value is not mistaken for the
245
+ // program being launched.
246
+ const WRAPPER_VALUE_OPTIONS = new Set([
247
+ // sudo / doas
248
+ '-u', '--user', '-g', '--group', '-h', '--host', '-p', '--prompt', '-C', '--close-from',
249
+ '-T', '--command-timeout', '-U', '--other-user', '-r', '--role', '-t', '--type',
250
+ // env
251
+ '--unset', '--chdir', '--split-string', '-S',
252
+ // nice / timeout / taskset / ionice / chrt / stdbuf
253
+ '-n', '--adjustment', '-k', '--kill-after', '-s', '--signal', '-p', '--pid',
254
+ '-c', '--cpu-list', '--class', '--classdata', '--io-class', '-o', '--output',
255
+ '--priority', '--scheduling-policy',
256
+ ]);
257
+
258
+ // Shells. `sh -c "…"` hands the rest of the line to a NEW shell, so its argument
259
+ // is a nested program that needs the same analysis.
260
+ const NESTED_SHELLS = new Set(['sh', 'bash', 'dash', 'zsh', 'ksh', 'ash', 'fish', 'csh', 'tcsh', 'pwsh', 'powershell']);
261
+ // The flags that make a shell's next word a command string. `/k` runs the string
262
+ // exactly like `/c` — it only keeps the shell open afterwards — so leaving it out
263
+ // made `cmd /k <anything>` invisible to every nested-shell rule.
264
+ const SHELL_COMMAND_FLAGS = new Set(['-c', '-command', '/c', '/k', '-cmd']);
265
+
266
+ function basename(word) {
267
+ return String(word || '').replace(/^.*[\\/]/, '').replace(/\.(exe|cmd|bat|com|ps1)$/i, '').toLowerCase();
268
+ }
269
+
270
+ /**
271
+ * Peel launch wrappers off `words`, returning the real program and its arguments.
272
+ * `nested` is a command STRING a wrapper introduced (`sh -c "…"`), which the
273
+ * caller analyzes as a fresh program. `cmd` is '' when the whole segment was a
274
+ * wrapper with nothing to launch.
275
+ */
276
+ function unwrap(words) {
277
+ let i = 0;
278
+ // Bounded, so a crafted chain cannot spin this loop.
279
+ for (let guard = 0; guard < 32 && i < words.length; guard++) {
280
+ // `FOO=bar cmd` — a variable assignment prefix, per POSIX.
281
+ while (i < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i])) i++;
282
+ if (i >= words.length) break;
283
+ const cmd = basename(words[i]);
284
+ const rest = words.slice(i + 1);
285
+
286
+ if (CMD_SHELLS.has(cmd)) {
287
+ // `cmd /c …` — the argument after the flag is the command string.
288
+ const flagIdx = rest.findIndex((w) => SHELL_COMMAND_FLAGS.has(w.toLowerCase()));
289
+ if (flagIdx >= 0) return { cmd: '', args: [], nested: rest.slice(flagIdx + 1).join(' ') };
290
+ return { cmd, args: rest, nested: null };
291
+ }
292
+ if (NESTED_SHELLS.has(cmd)) {
293
+ const flagIdx = rest.findIndex((w) => SHELL_COMMAND_FLAGS.has(w.toLowerCase()));
294
+ // `sh -c "rm -rf /"` -> the string is the program.
295
+ if (flagIdx >= 0) return { cmd: '', args: [], nested: rest.slice(flagIdx + 1).join(' ') };
296
+ // `bash script.sh` runs that file; the command is the shell itself.
297
+ return { cmd, args: rest, nested: null };
298
+ }
299
+ if (LAUNCH_WRAPPERS.has(cmd)) {
300
+ let j = i + 1;
301
+ while (j < words.length && words[j].startsWith('-')) {
302
+ const key = words[j].split('=')[0];
303
+ const inlineValue = words[j].includes('=');
304
+ j += (!inlineValue && WRAPPER_VALUE_OPTIONS.has(key)) ? 2 : 1;
305
+ }
306
+ // `timeout 5 rm -rf /` — the duration is a positional operand of the wrapper.
307
+ while (j < words.length && /^\d+(\.\d+)?[smhd]?$/.test(words[j])) j++;
308
+ if (j >= words.length) break;
309
+ i = j;
310
+ continue;
311
+ }
312
+ return { cmd, args: rest, nested: null };
313
+ }
314
+ return { cmd: '', args: [], nested: null };
315
+ }
316
+
317
+ // ---------------------------------------------------------------------------
318
+ // Destructive shapes
319
+ // ---------------------------------------------------------------------------
320
+
321
+ // Shells that take a command string with /c rather than -c.
322
+ const CMD_SHELLS = new Set(['cmd']);
323
+
324
+ // A program that is destructive whatever it is pointed at. `mkfs.ext4` and the
325
+ // Windows `format` are matched by stem below.
326
+ const ALWAYS_DESTRUCTIVE = new Set([
327
+ 'shutdown', 'reboot', 'halt', 'poweroff', 'diskpart', 'bcdedit',
328
+ 'wipefs', 'shred', 'fdisk', 'cfdisk', 'sgdisk', 'mkswap',
329
+ 'restart-computer', 'stop-computer',
330
+ ]);
331
+
332
+ // Interpreters that run their argument as code. `python -c "shutil.rmtree(…)"`
333
+ // is a delete no command-name rule can see, so it is judged by the text.
334
+ const CODE_INTERPRETERS = new Set(['python', 'python3', 'py', 'node', 'nodejs', 'perl', 'ruby', 'php', 'lua']);
335
+ const CODE_DESTRUCTIVE_RE = /\b(rmtree|unlink|rmdir|remove_tree|shutil\.rm|fs\.rm|rmSync|unlinkSync|rmdirSync|rmRemove|FileUtils\.rm|system\s*\(\s*['"]rm)|subprocess\.(run|call|Popen)[^;]{0,40}\brm\b/i;
336
+
337
+ // Patterns judged against the WHOLE command text, because no single program owns
338
+ // them: a redirect into a block device, a recursive chmod to no permissions, a
339
+ // fork bomb. Kept narrow on purpose — broad patterns here are what produced the
340
+ // false positives described at the top of this file.
341
+ const TEXT_DESTRUCTIVE_RE = [
342
+ /:\(\)\s*\{[^}]*\};\s*:/, // fork bomb
343
+ />\s*\/dev\/(sd|nvme|disk|hd|vd|fd|mmcblk)/, // redirect to a block device
344
+ /\bdd\b[^|;&]*\bof=\/dev\/(sd|nvme|disk|hd|vd|mmcblk)/,
345
+ /\bmkfs(\.[a-z0-9]+)?\b/,
346
+ /\b(bcdedit|vssadmin)\b[^|;&]*\b(delete|revert)\b/,
347
+ /(^|[|;&]\s*)chmod\b[^|;&]*-[rR][^|;&]*\b0{3,4}\b/,
348
+ /(^|[|;&]\s*)chown\b[^|;&]*-[rR]\s+\S+\s+\/(\s|$)/,
349
+ ];
350
+
351
+ // `rm -rf`, `rm -r -f`, `rm --recursive`: recursion deletes a tree in one go, and
352
+ // `-f` stops asking about each file. Either flag alone is enough to be worth a
353
+ // prompt — this keeps the guarantee the old regex had (it matched `-r` and `-f`
354
+ // alike, since `\w*[rf]` allowed an empty `\w*`) while dropping its habit of
355
+ // matching those letters anywhere in the line.
356
+ function rmIsDestructive(args) {
357
+ let recursive = false;
358
+ let force = false;
359
+ for (const a of args) {
360
+ if (!a.startsWith('-') || a === '-') continue; // a path, not a flag
361
+ if (a === '--') break;
362
+ if (a.startsWith('--')) {
363
+ if (a === '--recursive') recursive = true;
364
+ if (a === '--force') force = true;
365
+ continue;
366
+ }
367
+ const letters = a.slice(1); // combined shorts: -rf
368
+ if (/[rR]/.test(letters)) recursive = true;
369
+ if (/f/.test(letters)) force = true;
370
+ }
371
+ return recursive || force;
372
+ }
373
+ /** `mkfs`, `mkfs.ext4`, `mkfs.xfs` — the whole family. */
374
+ function isMkfs(cmd) { return cmd === 'mkfs' || cmd.startsWith('mkfs.'); }
375
+
376
+ /**
377
+ * The git SUBCOMMAND from an argv tail.
378
+ *
379
+ * The obvious `args.find(a => !a.startsWith('-'))` returns the first non-flag WORD, which
380
+ * for `git -C /repo push` is `/repo` — the VALUE of `-C`. Every subcommand rule then missed
381
+ * and `git -C x push`, `git -c core.pager=x reset --hard`, `git -c foo=bar clean` all
382
+ * passed the destructive check. Three flag shapes have to be stepped over:
383
+ *
384
+ * -C <path> / -c <name=value> a separate value in the NEXT word
385
+ * --git-dir=<path> the value is inside the SAME word
386
+ * -p / --paginate no value at all
387
+ *
388
+ * Only the flags git treats as value-taking are listed, so a bare `-x` is still skipped as
389
+ * a plain flag and cannot swallow the subcommand beside it.
390
+ */
391
+ function gitSubcommand(args) {
392
+ const TAKES_VALUE = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path']);
393
+ for (let i = 0; i < args.length; i++) {
394
+ const a = args[i];
395
+ if (!a.startsWith('-')) return a.toLowerCase();
396
+ // `--git-dir=/x` carries its own value, so the next word is the subcommand.
397
+ if (a.includes('=')) continue;
398
+ if (TAKES_VALUE.has(a)) i++; // step over the value
399
+ }
400
+ return '';
401
+ }
402
+ // Judge ONE already-unwrapped command. Returns a reason string, or null when the
403
+ // command is not destructive.
404
+ function judgeUnwrapped(cmd, args) {
405
+ if (!cmd) return null;
406
+ if (ALWAYS_DESTRUCTIVE.has(cmd)) return `${cmd} is a system-level destructive command`;
407
+ if (isMkfs(cmd)) return 'mkfs formats a filesystem';
408
+ if (cmd === 'format') return 'format is the Windows disk-format command';
409
+
410
+ switch (cmd) {
411
+ case 'rm': case 'rmdir':
412
+ if (rmIsDestructive(args)) return 'a recursive or forced delete';
413
+ break;
414
+ case 'del': case 'erase':
415
+ // A delete whatever its flags, which is what the old `\bdel\b` matched.
416
+ // Narrowed to the command NAME, so a path containing "del" no longer trips it.
417
+ return 'the Windows delete command';
418
+ case 'remove-item': {
419
+ const joined = args.join(' ').toLowerCase();
420
+ if (/-recurse\b|\s-r\b/.test(joined)) return 'Remove-Item -Recurse';
421
+ break;
422
+ }
423
+ case 'find': case 'fd': case 'gfind':
424
+ if (args.includes('-delete')) return 'find -delete';
425
+ for (const flag of ['-exec', '-execdir', '-ok', '-okdir']) {
426
+ const i = args.indexOf(flag);
427
+ if (i >= 0 && args[i + 1] && /^(rm|shred|del)$/i.test(basename(args[i + 1]))) return `find ${flag} rm`;
428
+ }
429
+ break;
430
+ case 'xargs':
431
+ if (args.some((a) => /^(rm|shred|del)$/i.test(basename(a)))) return 'xargs running a delete';
432
+ break;
433
+ case 'truncate': {
434
+ const i = args.findIndex((a) => a === '-s' || a === '--size');
435
+ if (i >= 0 && args[i + 1] === '0') return 'truncate to zero length';
436
+ break;
437
+ }
438
+ case 'crontab':
439
+ if (args.some((a) => a === '-r' || a === '--remove')) return 'crontab -r removes every scheduled job';
440
+ break;
441
+ case 'taskkill':
442
+ if (args.some((a) => /^\/f$/i.test(a))) return 'taskkill /f';
443
+ break;
444
+ case 'reg': case 'reg.exe': {
445
+ const sub = (args.find((a) => !a.startsWith('-') && !a.startsWith('/')) || '').toLowerCase();
446
+ if (sub === 'delete') return 'reg delete';
447
+ break;
448
+ }
449
+ case 'sc': case 'sc.exe': {
450
+ const sub = (args.find((a) => !a.startsWith('-')) || '').toLowerCase();
451
+ if (sub === 'delete') return 'sc delete';
452
+ break;
453
+ }
454
+ case 'docker':
455
+ if (args.includes('prune')) return 'docker prune';
456
+ break;
457
+ case 'git': {
458
+ const sub = gitSubcommand(args);
459
+ // A dry run changes nothing, and flagging it is what made `git push
460
+ // --dry-run` prompt in YOLO mode.
461
+ if (args.some((a) => a === '-n' || a === '--dry-run')) break;
462
+ if (sub === 'push') return 'git push';
463
+ if (sub === 'reset' && args.includes('--hard')) return 'git reset --hard';
464
+ if (sub === 'clean') return 'git clean removes untracked files';
465
+ if (sub === 'branch' && args.includes('-D')) return 'git branch -D';
466
+ break;
467
+ }
468
+ case 'npm': case 'yarn': case 'pnpm':
469
+ if (args.includes('publish') && !args.includes('--dry-run')) return 'publishing a package';
470
+ break;
471
+ case 'pwsh': case 'powershell':
472
+ // PowerShell abbreviates parameter names, so `-EncodedCommand` arrives as any
473
+ // prefix of itself (-e, -ec, -enc, -encoded…). The payload is base64 code this
474
+ // module cannot decode or judge — a "harmless script run" verdict on it would
475
+ // be a guess, so it asks.
476
+ if (args.some((a) => /^-(?:e|ec|enc|encoded\w*)$/i.test(a))) {
477
+ return 'an EncodedCommand payload cannot be analyzed';
478
+ }
479
+ break;
480
+ case 'sh': case 'bash': case 'dash': case 'zsh': case 'ksh': case 'ash':
481
+ case 'cmd':
482
+ // Only reachable as `sh script.sh` (the `-c` form is unwrapped into a nested
483
+ // command). Nothing to judge without reading the script.
484
+ break;
485
+ }
486
+
487
+ if (CODE_INTERPRETERS.has(cmd)) {
488
+ // `--eval=code` bundles the payload INTO the flag word, so dropping every
489
+ // `-…` argument dropped exactly the code this scan exists to see. The value
490
+ // of a bundled flag is split back out before the filter.
491
+ const code = args
492
+ .map((a) => (a.startsWith('-') && a.includes('=') ? a.slice(a.indexOf('=') + 1) : a))
493
+ .filter((a) => !a.startsWith('-'))
494
+ .join(' ');
495
+ if (CODE_DESTRUCTIVE_RE.test(code)) return `${cmd} running a delete`;
496
+ }
497
+ return null;
498
+ }
499
+
500
+ // ---------------------------------------------------------------------------
501
+ // Public API
502
+ // ---------------------------------------------------------------------------
503
+
504
+ const MAX_NESTED_DEPTH = 4;
505
+
506
+ /**
507
+ * Is this command destructive enough that it must never auto-run? Reaching this
508
+ * means the user is asked, even in YOLO mode.
509
+ */
510
+ export function isDestructiveCommand(cmd, depth = 0) {
511
+ const text = String(cmd == null ? '' : cmd);
512
+ if (!text.trim()) return false;
513
+ if (depth >= MAX_NESTED_DEPTH) return true; // too deep to judge: ask
514
+ for (const re of TEXT_DESTRUCTIVE_RE) if (re.test(text)) return true;
515
+ const { segments } = splitSegments(text);
516
+ for (const seg of segments) {
517
+ const words = tokenizeWords(seg);
518
+ if (!words.length) continue;
519
+ const u = unwrap(words);
520
+ if (judgeUnwrapped(u.cmd, u.args)) return true;
521
+ // `sh -c "…"` / `cmd /c …`: the wrapper's own command string is a program.
522
+ if (u.nested && isDestructiveCommand(u.nested, depth + 1)) return true;
523
+ }
524
+ return false;
525
+ }
526
+
527
+ // A read-only command's every segment is a known query/inspection tool. Anything
528
+ // that MUTATES is not on the list, and anything chained, redirected or substituted
529
+ // is rejected outright — a wrapper could hide a write.
530
+ const READ_ONLY_BINS = new Set([
531
+ 'ls', 'dir', 'pwd', 'cd', 'echo', 'cat', 'head', 'tail', 'wc', 'sort', 'uniq',
532
+ 'grep', 'rg', 'ag', 'find', 'fd', 'which', 'where', 'whoami', 'hostname',
533
+ 'date', 'env', 'printenv', 'stat', 'file', 'du', 'df', 'tree', 'type',
534
+ 'jq', 'tr', 'cut', 'diff', 'man', 'help', 'true', 'test',
535
+ 'git', // further restricted to read-only verbs below
536
+ // NOT here on purpose: node/python/npm/npx/make/docker/kubectl/gh — each can
537
+ // execute arbitrary code or mutate state via a flag (`node -e`, `python -c`,
538
+ // `npm install`), so allowing the bare binary would be a hole.
539
+ ]);
540
+
541
+ // `git` verbs that do not write. Anything else is refused.
542
+ const GIT_READ_SUBCMDS = new Set([
543
+ 'status', 'log', 'diff', 'show', 'branch', 'remote', 'describe', 'blame',
544
+ 'rev-parse', 'ls-files', 'ls-tree', 'cat-file', 'shortlog', 'tag', 'config',
545
+ 'reflog', 'whatchanged', 'name-rev', 'grep', 'count-objects', 'symbolic-ref',
546
+ 'show-ref', 'for-each-ref', 'worktree',
547
+ ]);
548
+ // Flags that turn a nominally read-only verb into a write (`git branch -D`,
549
+ // `git tag -d`, `git remote add`, `git config --set`).
550
+ const GIT_WRITE_FLAGS = new Set([
551
+ '-d', '-D', '-m', '-M', '--set', '--unset', '--add', '--edit', '--delete',
552
+ '--amend', '--force', '-f', '--global', '--local', '--system', 'add', 'remove',
553
+ ]);
554
+
555
+ // Environment assignments that may prefix a read-only command. Locale/display
556
+ // variables only — they change FORMATTING, not what the program is. Anything
557
+ // else refuses: `LD_PRELOAD=/tmp/x.so ls` executes code before `ls` even starts,
558
+ // and `PATH=`/`BASH_ENV=`/`NODE_OPTIONS=` swap or inject into the program itself.
559
+ const READ_ONLY_ENV = new Set([
560
+ 'LANG', 'LC_ALL', 'LC_CTYPE', 'LC_COLLATE', 'LC_MESSAGES', 'LC_NUMERIC', 'LC_TIME',
561
+ 'NO_COLOR', 'COLUMNS', 'LINES', 'PAGER', 'GIT_PAGER', 'TERM',
562
+ ]);
563
+
564
+ /** Is this command read-only, i.e. safe to run without a prompt in Ask mode? */
565
+ export function isReadOnlyCommand(cmd) {
566
+ const text = String(cmd == null ? '' : cmd).trim();
567
+ if (!text) return false;
568
+ const { segments, metacharacters, redirects } = splitSegments(text);
569
+ // Any unquoted operator, substitution or redirect means the effect is not
570
+ // confined to what the first word suggests.
571
+ if (metacharacters.size) return false;
572
+ if (redirects.length) return false;
573
+ const words = [];
574
+ for (const seg of segments) words.push(...tokenizeWords(seg));
575
+ if (!words.length) return false;
576
+ // `VAR=value cmd` — the wrapper peel strips assignments happily, so they must
577
+ // be vetted BEFORE the peel, against the whitelist above.
578
+ for (const w of words) {
579
+ if (!/^[A-Za-z_][A-Za-z0-9_]*=/.test(w)) break;
580
+ if (!READ_ONLY_ENV.has(w.slice(0, w.indexOf('=')))) return false;
581
+ }
582
+ // Privilege escalation is never "read-only": `sudo cat` reads one file and can
583
+ // also read every file, so it asks. Checked on the RAW first word, because
584
+ // peeling the wrapper would turn `sudo ls` into a plain `ls`.
585
+ if (PRIVILEGE_WRAPPERS.has(basename(words[0]))) return false;
586
+ const u = unwrap(words);
587
+ if (!u.cmd) return false; // a bare `sh -c "…"` is not provable
588
+ if (!READ_ONLY_BINS.has(u.cmd)) return false;
589
+ if (u.cmd === 'git') {
590
+ // The shared extractor, not `args.find(a => !a.startsWith('-'))`: that form returns the
591
+ // VALUE of `-C`/`-c` (`git -C /repo status` yielded `/repo`), so the verb was never read
592
+ // and an ordinary read-only git command was refused. The destructive check learned this
593
+ // first; the two judged the same command differently until they shared one function.
594
+ const sub = gitSubcommand(u.args);
595
+ if (!sub || !GIT_READ_SUBCMDS.has(sub)) return false;
596
+ // `tag`/`branch`/`remote`/`config`/`worktree` are read-only only WITHOUT a
597
+ // writing flag, so any of those flags refuses the whole command.
598
+ if (u.args.some((a) => GIT_WRITE_FLAGS.has(a))) return false;
599
+ }
600
+ if (u.cmd === 'find' || u.cmd === 'fd') {
601
+ for (const f of ['-delete', '-exec', '-execdir', '-ok', '-okdir']) {
602
+ if (u.args.includes(f)) return false;
603
+ }
604
+ }
605
+ // A bare `env`/`printenv` prints the environment, but `env rm -rf /` launches a
606
+ // program — the wrapper peel above turns that into `rm`, which is not in the list.
607
+ return true;
608
+ }
609
+
610
+ export default { isDestructiveCommand, isReadOnlyCommand, splitSegments, tokenizeWords };