@hncode/hncode 0.5.1 → 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.
- package/bin/install.cjs +24 -44
- package/bin/preinstall.cjs +0 -14
- package/package.json +2 -2
- package/src/agent-task.js +12 -1
- package/src/agent.js +279 -48
- package/src/cache.js +175 -130
- package/src/cell-buffer.js +5 -0
- package/src/clipboard.js +111 -8
- package/src/context-bar.js +46 -5
- package/src/doctor.js +7 -2
- package/src/git.js +35 -4
- package/src/index.js +29 -5
- package/src/keybindings.js +25 -3
- package/src/llm.js +60 -6
- package/src/mcp.js +419 -384
- package/src/permissions.js +29 -7
- package/src/plugin.js +95 -10
- package/src/registry-browser.js +21 -6
- package/src/remote.js +307 -289
- package/src/session-core.js +203 -0
- package/src/session-host.js +213 -0
- package/src/session-view.js +110 -0
- package/src/settings-schema.js +12 -9
- package/src/shell-safety.js +610 -473
- package/src/swarm-progress.js +348 -293
- package/src/tasks-browser.js +4 -1
- package/src/team/constants.js +20 -0
- package/src/team/context.js +89 -0
- package/src/team/mailbox.js +216 -0
- package/src/team/permission.js +121 -0
- package/src/team/protocol.js +167 -0
- package/src/team/runner.js +462 -0
- package/src/team/spawn.js +305 -0
- package/src/team/store.js +172 -0
- package/src/team/tasks.js +177 -0
- package/src/team/team.js +346 -0
- package/src/team-panel.js +624 -0
- package/src/term-caps.js +457 -410
- package/src/term.js +162 -19
- package/src/tool-result.js +70 -7
- package/src/tools/agent-swarm.js +114 -11
- package/src/tools/agent.js +464 -386
- package/src/tools/bash.js +53 -14
- package/src/tools/diagnostics.js +13 -0
- package/src/tools/edit.js +428 -50
- package/src/tools/git.js +15 -3
- package/src/tools/glob.js +5 -3
- package/src/tools/grep.js +19 -5
- package/src/tools/ignore.js +17 -3
- package/src/tools/index.js +19 -3
- package/src/tools/read.js +2 -1
- package/src/tools/shell.js +145 -134
- package/src/tools/tasks.js +214 -141
- package/src/tools/team.js +411 -0
- package/src/tools/utils.js +12 -3
- package/src/tools/web-search.js +53 -40
- package/src/tui.js +7496 -5366
- package/src/update.js +320 -115
- package/src/web-daemon.js +27 -4
- package/src/web-live.js +250 -0
package/src/shell-safety.js
CHANGED
|
@@ -1,473 +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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if (c ===
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
|
|
155
|
-
//
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
//
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
return
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
let
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
if (
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
//
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
//
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
'
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
//
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
if (
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
*
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
return
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
]);
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
if (
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
+
/**
|
|
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 };
|