@hncode/hncode 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +3 -16
  2. package/README_CN.md +1 -15
  3. package/bin/hncode +22 -9
  4. package/package.json +3 -2
  5. package/src/agent.js +145 -33
  6. package/src/bootstrap.js +56 -0
  7. package/src/btw-panel.js +224 -0
  8. package/src/cache.js +3 -2
  9. package/src/cell-buffer.js +173 -0
  10. package/src/ci.js +4 -1
  11. package/src/clipboard.js +202 -43
  12. package/src/colors.js +175 -30
  13. package/src/config.js +376 -34
  14. package/src/context-bar.js +237 -0
  15. package/src/doctor.js +320 -0
  16. package/src/experiments.js +122 -0
  17. package/src/file-history.js +112 -0
  18. package/src/file-tree.js +255 -0
  19. package/src/flush-tick.js +56 -0
  20. package/src/fork.js +44 -0
  21. package/src/fx.js +148 -0
  22. package/src/index.js +123 -11
  23. package/src/keybindings.js +229 -0
  24. package/src/llm.js +355 -34
  25. package/src/lsp.js +401 -0
  26. package/src/notebook.js +232 -0
  27. package/src/output-styles.js +183 -0
  28. package/src/patch.js +248 -0
  29. package/src/permissions.js +130 -1
  30. package/src/plan.js +3 -2
  31. package/src/plugin.js +691 -43
  32. package/src/prompt-presets.js +1 -1
  33. package/src/registry-browser.js +317 -0
  34. package/src/remote.js +289 -0
  35. package/src/rollout.js +151 -0
  36. package/src/row-cache.js +143 -0
  37. package/src/schedule.js +294 -0
  38. package/src/session-approvals.js +86 -0
  39. package/src/session.js +178 -8
  40. package/src/shell-safety.js +473 -0
  41. package/src/side-thread.js +240 -0
  42. package/src/skills.js +27 -0
  43. package/src/stream-buffer.js +350 -0
  44. package/src/subagent-control.js +160 -0
  45. package/src/tasks-browser.js +54 -11
  46. package/src/teleport.js +260 -0
  47. package/src/term-caps.js +411 -0
  48. package/src/term.js +283 -8
  49. package/src/tools/agent-control.js +98 -0
  50. package/src/tools/agent-swarm.js +11 -2
  51. package/src/tools/agent.js +35 -1
  52. package/src/tools/bash.js +17 -3
  53. package/src/tools/diagnostics.js +175 -0
  54. package/src/tools/edit.js +25 -10
  55. package/src/tools/grep.js +62 -20
  56. package/src/tools/ignore.js +38 -14
  57. package/src/tools/index.js +10 -0
  58. package/src/tools/notebook.js +113 -0
  59. package/src/tools/read-media-file.js +10 -3
  60. package/src/tools/shell.js +134 -0
  61. package/src/tui.js +16682 -11665
  62. package/src/web-public/app.css +22 -9
  63. package/src/web-public/app.js +107 -54
  64. package/src/web-public/home.css +3 -3
  65. package/src/web-public/login.css +1 -1
@@ -0,0 +1,473 @@
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
+ export function splitSegments(cmd) {
48
+ const s = String(cmd == null ? '' : cmd);
49
+ const segments = [];
50
+ const metacharacters = new Set();
51
+ const redirects = [];
52
+ let current = '';
53
+ let i = 0;
54
+ while (i < s.length) {
55
+ const c = s[i];
56
+ if (c === '\\') { current += s.slice(i, i + 2); i += 2; continue; }
57
+ if (c === "'" || c === '"') {
58
+ // Consume the whole quoted run verbatim. A quote can also OPEN inside a word
59
+ // (`--msg="a b"`); appending keeps the word together for tokenizeWords.
60
+ const quote = c;
61
+ let j = i + 1;
62
+ current += c;
63
+ while (j < s.length) {
64
+ if (quote === '"' && s[j] === '\\' && j + 1 < s.length) { current += s.slice(j, j + 2); j += 2; continue; }
65
+ current += s[j];
66
+ if (s[j] === quote) { j++; break; }
67
+ j++;
68
+ }
69
+ i = j;
70
+ continue;
71
+ }
72
+ const two = s.slice(i, i + 2);
73
+ if (two === '&&' || two === '||' || two === '>>') {
74
+ metacharacters.add(two[0]);
75
+ if (two === '>>') redirects.push(readRedirect(s, i + 2));
76
+ else { segments.push(current); current = ''; }
77
+ i += 2;
78
+ continue;
79
+ }
80
+ if (c === ';' || c === '|' || c === '&' || c === '\n') {
81
+ // All four end a segment. `&` backgrounds it, which still runs it.
82
+ metacharacters.add(c === '\n' ? ';' : c);
83
+ segments.push(current);
84
+ current = '';
85
+ i += 1;
86
+ continue;
87
+ }
88
+ if (c === '>') {
89
+ metacharacters.add('>');
90
+ redirects.push(readRedirect(s, i + 1));
91
+ i += 1;
92
+ continue;
93
+ }
94
+ if (c === '<' || c === '`') { metacharacters.add(c); i += 1; continue; }
95
+ if (c === '$') {
96
+ // `$(…)` / `${…}` can run a command or expand a path. Record the substitution
97
+ // so the read-only test rejects the command, but keep the text for tokenizing.
98
+ if (s[i + 1] === '(') metacharacters.add('$');
99
+ current += c; i += 1; continue;
100
+ }
101
+ if (c === '(' || c === ')') { metacharacters.add(c); current += c; i += 1; continue; }
102
+ current += c;
103
+ i += 1;
104
+ }
105
+ segments.push(current);
106
+ return { segments, metacharacters, redirects };
107
+ }
108
+
109
+ // The token after a redirect operator; may be absent (`cmd >`).
110
+ function readRedirect(s, from) {
111
+ let i = from;
112
+ while (i < s.length && (s[i] === ' ' || s[i] === '\t')) i++;
113
+ let out = '';
114
+ while (i < s.length && !/[\s|;&<>]/.test(s[i])) { out += s[i]; i++; }
115
+ return out.replace(/^['"]+|['"]+$/g, '');
116
+ }
117
+
118
+ /**
119
+ * Split one segment into words, dropping the quotes and keeping a word together
120
+ * across quoted whitespace (`--msg="a b"` is ONE word).
121
+ */
122
+ export function tokenizeWords(segment) {
123
+ const s = String(segment == null ? '' : segment).trim();
124
+ const words = [];
125
+ let cur = '';
126
+ let has = false;
127
+ let i = 0;
128
+ while (i < s.length) {
129
+ const c = s[i];
130
+ if (/\s/.test(c)) { if (has) { words.push(cur); cur = ''; has = false; } i++; continue; }
131
+ has = true;
132
+ if (c === '\\' && i + 1 < s.length) { cur += s[i + 1]; i += 2; continue; }
133
+ if (c === "'" || c === '"') {
134
+ const quote = c;
135
+ let j = i + 1;
136
+ while (j < s.length) {
137
+ if (quote === '"' && s[j] === '\\' && j + 1 < s.length) { cur += s[j + 1]; j += 2; continue; }
138
+ if (s[j] === quote) break;
139
+ cur += s[j]; j++;
140
+ }
141
+ i = j + 1;
142
+ continue;
143
+ }
144
+ cur += c;
145
+ i++;
146
+ }
147
+ if (has) words.push(cur);
148
+ return words;
149
+ }
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // Wrappers
153
+ // ---------------------------------------------------------------------------
154
+
155
+ // Programs whose only job is to launch another program. `sudo rm -rf /` and
156
+ // `rm -rf /` are the same act, so the wrapper has to come off before the real
157
+ // command is judged. The old whole-string regex caught `sudo rm` by accident,
158
+ // but could not catch `sh -c "rm -rf /"` at all.
159
+ const LAUNCH_WRAPPERS = new Set([
160
+ 'sudo', 'doas', 'env', 'command', 'exec', 'nohup', 'builtin', 'nice',
161
+ 'ionice', 'setsid', 'stdbuf', 'time', 'timeout', 'chrt', 'taskset', 'unbuffer',
162
+ ]);
163
+
164
+ // The subset of wrappers that ESCALATE privileges. They are still peeled for the
165
+ // destructive verdict (`sudo rm -rf` is a delete), but they can never count as
166
+ // read-only: `sudo cat f` and `sudo cat /etc/shadow` look identical from here.
167
+ const PRIVILEGE_WRAPPERS = new Set(['sudo', 'doas', 'runas', 'su']);
168
+
169
+ // Options of a wrapper that TAKE A VALUE, so the value is not mistaken for the
170
+ // program being launched.
171
+ const WRAPPER_VALUE_OPTIONS = new Set([
172
+ // sudo / doas
173
+ '-u', '--user', '-g', '--group', '-h', '--host', '-p', '--prompt', '-C', '--close-from',
174
+ '-T', '--command-timeout', '-U', '--other-user', '-r', '--role', '-t', '--type',
175
+ // env
176
+ '--unset', '--chdir', '--split-string', '-S',
177
+ // nice / timeout / taskset / ionice / chrt / stdbuf
178
+ '-n', '--adjustment', '-k', '--kill-after', '-s', '--signal', '-p', '--pid',
179
+ '-c', '--cpu-list', '--class', '--classdata', '--io-class', '-o', '--output',
180
+ '--priority', '--scheduling-policy',
181
+ ]);
182
+
183
+ // Shells. `sh -c "…"` hands the rest of the line to a NEW shell, so its argument
184
+ // is a nested program that needs the same analysis.
185
+ const NESTED_SHELLS = new Set(['sh', 'bash', 'dash', 'zsh', 'ksh', 'ash', 'fish', 'csh', 'tcsh', 'pwsh', 'powershell']);
186
+ // The flags that make a shell's next word a command string.
187
+ const SHELL_COMMAND_FLAGS = new Set(['-c', '-command', '/c', '-cmd']);
188
+
189
+ function basename(word) {
190
+ return String(word || '').replace(/^.*[\\/]/, '').replace(/\.(exe|cmd|bat|com|ps1)$/i, '').toLowerCase();
191
+ }
192
+
193
+ /**
194
+ * Peel launch wrappers off `words`, returning the real program and its arguments.
195
+ * `nested` is a command STRING a wrapper introduced (`sh -c "…"`), which the
196
+ * caller analyzes as a fresh program. `cmd` is '' when the whole segment was a
197
+ * wrapper with nothing to launch.
198
+ */
199
+ function unwrap(words) {
200
+ let i = 0;
201
+ // Bounded, so a crafted chain cannot spin this loop.
202
+ for (let guard = 0; guard < 32 && i < words.length; guard++) {
203
+ // `FOO=bar cmd` — a variable assignment prefix, per POSIX.
204
+ while (i < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i])) i++;
205
+ if (i >= words.length) break;
206
+ const cmd = basename(words[i]);
207
+ const rest = words.slice(i + 1);
208
+
209
+ if (CMD_SHELLS.has(cmd)) {
210
+ // `cmd /c …` — the argument after the flag is the command string.
211
+ const flagIdx = rest.findIndex((w) => SHELL_COMMAND_FLAGS.has(w.toLowerCase()));
212
+ if (flagIdx >= 0) return { cmd: '', args: [], nested: rest.slice(flagIdx + 1).join(' ') };
213
+ return { cmd, args: rest, nested: null };
214
+ }
215
+ if (NESTED_SHELLS.has(cmd)) {
216
+ const flagIdx = rest.findIndex((w) => SHELL_COMMAND_FLAGS.has(w.toLowerCase()));
217
+ // `sh -c "rm -rf /"` -> the string is the program.
218
+ if (flagIdx >= 0) return { cmd: '', args: [], nested: rest.slice(flagIdx + 1).join(' ') };
219
+ // `bash script.sh` runs that file; the command is the shell itself.
220
+ return { cmd, args: rest, nested: null };
221
+ }
222
+ if (LAUNCH_WRAPPERS.has(cmd)) {
223
+ let j = i + 1;
224
+ while (j < words.length && words[j].startsWith('-')) {
225
+ const key = words[j].split('=')[0];
226
+ const inlineValue = words[j].includes('=');
227
+ j += (!inlineValue && WRAPPER_VALUE_OPTIONS.has(key)) ? 2 : 1;
228
+ }
229
+ // `timeout 5 rm -rf /` — the duration is a positional operand of the wrapper.
230
+ while (j < words.length && /^\d+(\.\d+)?[smhd]?$/.test(words[j])) j++;
231
+ if (j >= words.length) break;
232
+ i = j;
233
+ continue;
234
+ }
235
+ return { cmd, args: rest, nested: null };
236
+ }
237
+ return { cmd: '', args: [], nested: null };
238
+ }
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // Destructive shapes
242
+ // ---------------------------------------------------------------------------
243
+
244
+ // Shells that take a command string with /c rather than -c.
245
+ const CMD_SHELLS = new Set(['cmd']);
246
+
247
+ // A program that is destructive whatever it is pointed at. `mkfs.ext4` and the
248
+ // Windows `format` are matched by stem below.
249
+ const ALWAYS_DESTRUCTIVE = new Set([
250
+ 'shutdown', 'reboot', 'halt', 'poweroff', 'diskpart', 'bcdedit',
251
+ 'wipefs', 'shred', 'fdisk', 'cfdisk', 'sgdisk', 'mkswap',
252
+ 'restart-computer', 'stop-computer',
253
+ ]);
254
+
255
+ // Interpreters that run their argument as code. `python -c "shutil.rmtree(…)"`
256
+ // is a delete no command-name rule can see, so it is judged by the text.
257
+ const CODE_INTERPRETERS = new Set(['python', 'python3', 'py', 'node', 'nodejs', 'perl', 'ruby', 'php', 'lua']);
258
+ 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;
259
+
260
+ // Patterns judged against the WHOLE command text, because no single program owns
261
+ // them: a redirect into a block device, a recursive chmod to no permissions, a
262
+ // fork bomb. Kept narrow on purpose — broad patterns here are what produced the
263
+ // false positives described at the top of this file.
264
+ const TEXT_DESTRUCTIVE_RE = [
265
+ /:\(\)\s*\{[^}]*\};\s*:/, // fork bomb
266
+ />\s*\/dev\/(sd|nvme|disk|hd|vd|fd|mmcblk)/, // redirect to a block device
267
+ /\bdd\b[^|;&]*\bof=\/dev\/(sd|nvme|disk|hd|vd|mmcblk)/,
268
+ /\bmkfs(\.[a-z0-9]+)?\b/,
269
+ /\b(bcdedit|vssadmin)\b[^|;&]*\b(delete|revert)\b/,
270
+ /(^|[|;&]\s*)chmod\b[^|;&]*-[rR][^|;&]*\b0{3,4}\b/,
271
+ /(^|[|;&]\s*)chown\b[^|;&]*-[rR]\s+\S+\s+\/(\s|$)/,
272
+ ];
273
+
274
+ // `rm -rf`, `rm -r -f`, `rm --recursive`: recursion deletes a tree in one go, and
275
+ // `-f` stops asking about each file. Either flag alone is enough to be worth a
276
+ // prompt — this keeps the guarantee the old regex had (it matched `-r` and `-f`
277
+ // alike, since `\w*[rf]` allowed an empty `\w*`) while dropping its habit of
278
+ // matching those letters anywhere in the line.
279
+ function rmIsDestructive(args) {
280
+ let recursive = false;
281
+ let force = false;
282
+ for (const a of args) {
283
+ if (!a.startsWith('-') || a === '-') continue; // a path, not a flag
284
+ if (a === '--') break;
285
+ if (a.startsWith('--')) {
286
+ if (a === '--recursive') recursive = true;
287
+ if (a === '--force') force = true;
288
+ continue;
289
+ }
290
+ const letters = a.slice(1); // combined shorts: -rf
291
+ if (/[rR]/.test(letters)) recursive = true;
292
+ if (/f/.test(letters)) force = true;
293
+ }
294
+ return recursive || force;
295
+ }
296
+ /** `mkfs`, `mkfs.ext4`, `mkfs.xfs` — the whole family. */
297
+ function isMkfs(cmd) { return cmd === 'mkfs' || cmd.startsWith('mkfs.'); }
298
+
299
+ // Judge ONE already-unwrapped command. Returns a reason string, or null when the
300
+ // command is not destructive.
301
+ function judgeUnwrapped(cmd, args) {
302
+ if (!cmd) return null;
303
+ if (ALWAYS_DESTRUCTIVE.has(cmd)) return `${cmd} is a system-level destructive command`;
304
+ if (isMkfs(cmd)) return 'mkfs formats a filesystem';
305
+ if (cmd === 'format') return 'format is the Windows disk-format command';
306
+
307
+ switch (cmd) {
308
+ case 'rm': case 'rmdir':
309
+ if (rmIsDestructive(args)) return 'a recursive or forced delete';
310
+ break;
311
+ case 'del': case 'erase':
312
+ // A delete whatever its flags, which is what the old `\bdel\b` matched.
313
+ // Narrowed to the command NAME, so a path containing "del" no longer trips it.
314
+ return 'the Windows delete command';
315
+ case 'remove-item': {
316
+ const joined = args.join(' ').toLowerCase();
317
+ if (/-recurse\b|\s-r\b/.test(joined)) return 'Remove-Item -Recurse';
318
+ break;
319
+ }
320
+ case 'find': case 'fd': case 'gfind':
321
+ if (args.includes('-delete')) return 'find -delete';
322
+ for (const flag of ['-exec', '-execdir', '-ok', '-okdir']) {
323
+ const i = args.indexOf(flag);
324
+ if (i >= 0 && args[i + 1] && /^(rm|shred|del)$/i.test(basename(args[i + 1]))) return `find ${flag} rm`;
325
+ }
326
+ break;
327
+ case 'xargs':
328
+ if (args.some((a) => /^(rm|shred|del)$/i.test(basename(a)))) return 'xargs running a delete';
329
+ break;
330
+ case 'truncate': {
331
+ const i = args.findIndex((a) => a === '-s' || a === '--size');
332
+ if (i >= 0 && args[i + 1] === '0') return 'truncate to zero length';
333
+ break;
334
+ }
335
+ case 'crontab':
336
+ if (args.some((a) => a === '-r' || a === '--remove')) return 'crontab -r removes every scheduled job';
337
+ break;
338
+ case 'taskkill':
339
+ if (args.some((a) => /^\/f$/i.test(a))) return 'taskkill /f';
340
+ break;
341
+ case 'reg': case 'reg.exe': {
342
+ const sub = (args.find((a) => !a.startsWith('-') && !a.startsWith('/')) || '').toLowerCase();
343
+ if (sub === 'delete') return 'reg delete';
344
+ break;
345
+ }
346
+ case 'sc': case 'sc.exe': {
347
+ const sub = (args.find((a) => !a.startsWith('-')) || '').toLowerCase();
348
+ if (sub === 'delete') return 'sc delete';
349
+ break;
350
+ }
351
+ case 'docker':
352
+ if (args.includes('prune')) return 'docker prune';
353
+ break;
354
+ case 'git': {
355
+ const sub = (args.find((a) => !a.startsWith('-')) || '').toLowerCase();
356
+ // A dry run changes nothing, and flagging it is what made `git push
357
+ // --dry-run` prompt in YOLO mode.
358
+ if (args.some((a) => a === '-n' || a === '--dry-run')) break;
359
+ if (sub === 'push') return 'git push';
360
+ if (sub === 'reset' && args.includes('--hard')) return 'git reset --hard';
361
+ if (sub === 'clean') return 'git clean removes untracked files';
362
+ if (sub === 'branch' && args.includes('-D')) return 'git branch -D';
363
+ break;
364
+ }
365
+ case 'npm': case 'yarn': case 'pnpm':
366
+ if (args.includes('publish') && !args.includes('--dry-run')) return 'publishing a package';
367
+ break;
368
+ case 'sh': case 'bash': case 'dash': case 'zsh': case 'ksh': case 'ash':
369
+ case 'pwsh': case 'powershell': case 'cmd':
370
+ // Only reachable as `sh script.sh` (the `-c` form is unwrapped into a nested
371
+ // command). Nothing to judge without reading the script.
372
+ break;
373
+ }
374
+
375
+ if (CODE_INTERPRETERS.has(cmd)) {
376
+ const code = args.filter((a) => !a.startsWith('-')).join(' ');
377
+ if (CODE_DESTRUCTIVE_RE.test(code)) return `${cmd} running a delete`;
378
+ }
379
+ return null;
380
+ }
381
+
382
+ // ---------------------------------------------------------------------------
383
+ // Public API
384
+ // ---------------------------------------------------------------------------
385
+
386
+ const MAX_NESTED_DEPTH = 4;
387
+
388
+ /**
389
+ * Is this command destructive enough that it must never auto-run? Reaching this
390
+ * means the user is asked, even in YOLO mode.
391
+ */
392
+ export function isDestructiveCommand(cmd, depth = 0) {
393
+ const text = String(cmd == null ? '' : cmd);
394
+ if (!text.trim()) return false;
395
+ if (depth >= MAX_NESTED_DEPTH) return true; // too deep to judge: ask
396
+ for (const re of TEXT_DESTRUCTIVE_RE) if (re.test(text)) return true;
397
+ const { segments } = splitSegments(text);
398
+ for (const seg of segments) {
399
+ const words = tokenizeWords(seg);
400
+ if (!words.length) continue;
401
+ const u = unwrap(words);
402
+ if (judgeUnwrapped(u.cmd, u.args)) return true;
403
+ // `sh -c "…"` / `cmd /c …`: the wrapper's own command string is a program.
404
+ if (u.nested && isDestructiveCommand(u.nested, depth + 1)) return true;
405
+ }
406
+ return false;
407
+ }
408
+
409
+ // A read-only command's every segment is a known query/inspection tool. Anything
410
+ // that MUTATES is not on the list, and anything chained, redirected or substituted
411
+ // is rejected outright — a wrapper could hide a write.
412
+ const READ_ONLY_BINS = new Set([
413
+ 'ls', 'dir', 'pwd', 'cd', 'echo', 'cat', 'head', 'tail', 'wc', 'sort', 'uniq',
414
+ 'grep', 'rg', 'ag', 'find', 'fd', 'which', 'where', 'whoami', 'hostname',
415
+ 'date', 'env', 'printenv', 'stat', 'file', 'du', 'df', 'tree', 'type',
416
+ 'jq', 'tr', 'cut', 'diff', 'man', 'help', 'true', 'test',
417
+ 'git', // further restricted to read-only verbs below
418
+ // NOT here on purpose: node/python/npm/npx/make/docker/kubectl/gh — each can
419
+ // execute arbitrary code or mutate state via a flag (`node -e`, `python -c`,
420
+ // `npm install`), so allowing the bare binary would be a hole.
421
+ ]);
422
+
423
+ // `git` verbs that do not write. Anything else is refused.
424
+ const GIT_READ_SUBCMDS = new Set([
425
+ 'status', 'log', 'diff', 'show', 'branch', 'remote', 'describe', 'blame',
426
+ 'rev-parse', 'ls-files', 'ls-tree', 'cat-file', 'shortlog', 'tag', 'config',
427
+ 'reflog', 'whatchanged', 'name-rev', 'grep', 'count-objects', 'symbolic-ref',
428
+ 'show-ref', 'for-each-ref', 'worktree',
429
+ ]);
430
+ // Flags that turn a nominally read-only verb into a write (`git branch -D`,
431
+ // `git tag -d`, `git remote add`, `git config --set`).
432
+ const GIT_WRITE_FLAGS = new Set([
433
+ '-d', '-D', '-m', '-M', '--set', '--unset', '--add', '--edit', '--delete',
434
+ '--amend', '--force', '-f', '--global', '--local', '--system', 'add', 'remove',
435
+ ]);
436
+
437
+ /** Is this command read-only, i.e. safe to run without a prompt in Ask mode? */
438
+ export function isReadOnlyCommand(cmd) {
439
+ const text = String(cmd == null ? '' : cmd).trim();
440
+ if (!text) return false;
441
+ const { segments, metacharacters, redirects } = splitSegments(text);
442
+ // Any unquoted operator, substitution or redirect means the effect is not
443
+ // confined to what the first word suggests.
444
+ if (metacharacters.size) return false;
445
+ if (redirects.length) return false;
446
+ const words = [];
447
+ for (const seg of segments) words.push(...tokenizeWords(seg));
448
+ if (!words.length) return false;
449
+ // Privilege escalation is never "read-only": `sudo cat` reads one file and can
450
+ // also read every file, so it asks. Checked on the RAW first word, because
451
+ // peeling the wrapper would turn `sudo ls` into a plain `ls`.
452
+ if (PRIVILEGE_WRAPPERS.has(basename(words[0]))) return false;
453
+ const u = unwrap(words);
454
+ if (!u.cmd) return false; // a bare `sh -c "…"` is not provable
455
+ if (!READ_ONLY_BINS.has(u.cmd)) return false;
456
+ if (u.cmd === 'git') {
457
+ const sub = (u.args.find((a) => !a.startsWith('-')) || '').toLowerCase();
458
+ if (!sub || !GIT_READ_SUBCMDS.has(sub)) return false;
459
+ // `tag`/`branch`/`remote`/`config`/`worktree` are read-only only WITHOUT a
460
+ // writing flag, so any of those flags refuses the whole command.
461
+ if (u.args.some((a) => GIT_WRITE_FLAGS.has(a))) return false;
462
+ }
463
+ if (u.cmd === 'find' || u.cmd === 'fd') {
464
+ for (const f of ['-delete', '-exec', '-execdir', '-ok', '-okdir']) {
465
+ if (u.args.includes(f)) return false;
466
+ }
467
+ }
468
+ // A bare `env`/`printenv` prints the environment, but `env rm -rf /` launches a
469
+ // program — the wrapper peel above turns that into `rm`, which is not in the list.
470
+ return true;
471
+ }
472
+
473
+ export default { isDestructiveCommand, isReadOnlyCommand, splitSegments, tokenizeWords };