@hncode/hncode 0.4.2 → 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.
- package/README.md +1 -15
- package/README_CN.md +1 -15
- package/bin/hncode +22 -9
- package/package.json +3 -2
- package/src/agent.js +145 -33
- package/src/bootstrap.js +56 -0
- package/src/btw-panel.js +224 -0
- package/src/cache.js +3 -2
- package/src/cell-buffer.js +173 -0
- package/src/ci.js +4 -1
- package/src/clipboard.js +202 -43
- package/src/colors.js +175 -30
- package/src/config.js +376 -34
- package/src/context-bar.js +237 -0
- package/src/doctor.js +320 -0
- package/src/experiments.js +122 -0
- package/src/file-history.js +112 -0
- package/src/file-tree.js +255 -0
- package/src/flush-tick.js +56 -0
- package/src/fork.js +44 -0
- package/src/fx.js +148 -0
- package/src/index.js +123 -11
- package/src/keybindings.js +229 -0
- package/src/llm.js +355 -34
- package/src/lsp.js +401 -0
- package/src/notebook.js +232 -0
- package/src/output-styles.js +183 -0
- package/src/patch.js +248 -0
- package/src/permissions.js +130 -1
- package/src/plan.js +3 -2
- package/src/plugin.js +611 -58
- package/src/prompt-presets.js +1 -1
- package/src/registry-browser.js +31 -13
- package/src/remote.js +20 -2
- package/src/rollout.js +151 -0
- package/src/row-cache.js +143 -0
- package/src/schedule.js +294 -0
- package/src/session-approvals.js +86 -0
- package/src/session.js +178 -8
- package/src/shell-safety.js +473 -0
- package/src/side-thread.js +240 -0
- package/src/skills.js +27 -0
- package/src/stream-buffer.js +350 -0
- package/src/subagent-control.js +160 -0
- package/src/teleport.js +260 -0
- package/src/term-caps.js +411 -0
- package/src/term.js +283 -8
- package/src/tools/agent-control.js +98 -0
- package/src/tools/agent-swarm.js +11 -2
- package/src/tools/agent.js +35 -1
- package/src/tools/diagnostics.js +175 -0
- package/src/tools/edit.js +25 -10
- package/src/tools/grep.js +62 -20
- package/src/tools/ignore.js +38 -14
- package/src/tools/index.js +10 -0
- package/src/tools/notebook.js +113 -0
- package/src/tools/read-media-file.js +10 -3
- package/src/tools/shell.js +34 -6
- package/src/tui.js +5219 -792
- package/src/web-public/app.js +7 -0
|
@@ -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 };
|