audrey 1.2.2 → 1.3.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 (90) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +25 -1
  3. package/dist/mcp-server/config.d.ts +1 -1
  4. package/dist/mcp-server/config.js +1 -1
  5. package/dist/mcp-server/hooks.d.ts.map +1 -1
  6. package/dist/mcp-server/hooks.js +24 -2
  7. package/dist/mcp-server/hooks.js.map +1 -1
  8. package/dist/mcp-server/index.d.ts.map +1 -1
  9. package/dist/mcp-server/index.js +44 -4
  10. package/dist/mcp-server/index.js.map +1 -1
  11. package/dist/mcp-server/tool-schemas.d.ts +3 -0
  12. package/dist/mcp-server/tool-schemas.d.ts.map +1 -1
  13. package/dist/mcp-server/tool-schemas.js +4 -0
  14. package/dist/mcp-server/tool-schemas.js.map +1 -1
  15. package/dist/src/audrey.d.ts +5 -1
  16. package/dist/src/audrey.d.ts.map +1 -1
  17. package/dist/src/audrey.js +84 -33
  18. package/dist/src/audrey.js.map +1 -1
  19. package/dist/src/autopilot.d.ts.map +1 -1
  20. package/dist/src/autopilot.js +67 -13
  21. package/dist/src/autopilot.js.map +1 -1
  22. package/dist/src/capsule.d.ts +13 -0
  23. package/dist/src/capsule.d.ts.map +1 -1
  24. package/dist/src/capsule.js +85 -7
  25. package/dist/src/capsule.js.map +1 -1
  26. package/dist/src/consolidate.d.ts.map +1 -1
  27. package/dist/src/consolidate.js +20 -14
  28. package/dist/src/consolidate.js.map +1 -1
  29. package/dist/src/controller.d.ts +6 -0
  30. package/dist/src/controller.d.ts.map +1 -1
  31. package/dist/src/controller.js +1 -0
  32. package/dist/src/controller.js.map +1 -1
  33. package/dist/src/db.d.ts +28 -0
  34. package/dist/src/db.d.ts.map +1 -1
  35. package/dist/src/db.js +110 -3
  36. package/dist/src/db.js.map +1 -1
  37. package/dist/src/encode.d.ts +12 -0
  38. package/dist/src/encode.d.ts.map +1 -1
  39. package/dist/src/encode.js +5 -0
  40. package/dist/src/encode.js.map +1 -1
  41. package/dist/src/events.d.ts.map +1 -1
  42. package/dist/src/events.js +11 -2
  43. package/dist/src/events.js.map +1 -1
  44. package/dist/src/export.d.ts.map +1 -1
  45. package/dist/src/export.js +8 -3
  46. package/dist/src/export.js.map +1 -1
  47. package/dist/src/forget.d.ts +18 -1
  48. package/dist/src/forget.d.ts.map +1 -1
  49. package/dist/src/forget.js +162 -9
  50. package/dist/src/forget.js.map +1 -1
  51. package/dist/src/grounding.d.ts.map +1 -1
  52. package/dist/src/grounding.js +16 -3
  53. package/dist/src/grounding.js.map +1 -1
  54. package/dist/src/hybrid-recall.d.ts.map +1 -1
  55. package/dist/src/hybrid-recall.js +10 -6
  56. package/dist/src/hybrid-recall.js.map +1 -1
  57. package/dist/src/import.d.ts +2 -0
  58. package/dist/src/import.d.ts.map +1 -1
  59. package/dist/src/import.js +48 -9
  60. package/dist/src/import.js.map +1 -1
  61. package/dist/src/migrate.d.ts.map +1 -1
  62. package/dist/src/migrate.js +11 -1
  63. package/dist/src/migrate.js.map +1 -1
  64. package/dist/src/preflight.d.ts +3 -1
  65. package/dist/src/preflight.d.ts.map +1 -1
  66. package/dist/src/preflight.js +46 -40
  67. package/dist/src/preflight.js.map +1 -1
  68. package/dist/src/recall.d.ts.map +1 -1
  69. package/dist/src/recall.js +10 -6
  70. package/dist/src/recall.js.map +1 -1
  71. package/dist/src/redact.d.ts.map +1 -1
  72. package/dist/src/redact.js +65 -30
  73. package/dist/src/redact.js.map +1 -1
  74. package/dist/src/routes.d.ts.map +1 -1
  75. package/dist/src/routes.js +5 -1
  76. package/dist/src/routes.js.map +1 -1
  77. package/dist/src/shell-command.d.ts +54 -0
  78. package/dist/src/shell-command.d.ts.map +1 -0
  79. package/dist/src/shell-command.js +1592 -0
  80. package/dist/src/shell-command.js.map +1 -0
  81. package/dist/src/types.d.ts +5 -0
  82. package/dist/src/types.d.ts.map +1 -1
  83. package/dist/src/utils.d.ts +11 -0
  84. package/dist/src/utils.d.ts.map +1 -1
  85. package/dist/src/utils.js +37 -0
  86. package/dist/src/utils.js.map +1 -1
  87. package/dist/src/validate.d.ts.map +1 -1
  88. package/dist/src/validate.js +21 -5
  89. package/dist/src/validate.js.map +1 -1
  90. package/package.json +1 -1
@@ -0,0 +1,1592 @@
1
+ /**
2
+ * Shell-command classification for Guard and Autopilot capture.
3
+ *
4
+ * "Bash" is one tool name covering everything from `git status` to
5
+ * `rm -rf`. Guard's exact-fingerprint design assumes a failed action is
6
+ * worth not repeating, and Autopilot's failure capture assumes a non-zero
7
+ * exit is a lesson. Both are false for a read-only probe: grep reports
8
+ * "no match" as exit 1, ls reports a missing path as exit 2, test reports
9
+ * false as 1. Recording those as failures filled the episodic store with
10
+ * salience-0.9 log lines and taught Guard to warn on every later command
11
+ * that merely looked similar.
12
+ *
13
+ * Two questions are answered here, both from the command text alone:
14
+ *
15
+ * - Can this command have side effects at all? Decided fail-closed: a
16
+ * command is read-only only when every segment is positively recognised
17
+ * as such. Command substitution, a redirect to anything but /dev/null,
18
+ * `sudo`, `xargs`, `eval`, an unknown verb, a git subcommand outside the
19
+ * read-only list — any of these keeps today's behaviour.
20
+ * - What does it do? A verb signature ("git status", "npm run deploy",
21
+ * "node -e") lets a remembered failure be compared with a proposed
22
+ * action by what they run, not by embedding similarity of their text.
23
+ * Two commands that share a `cd` prefix and a repo path look nearly
24
+ * identical to a sentence embedder while doing unrelated things.
25
+ */
26
+ // Longest operators first so `&&` is not read as `&` twice.
27
+ const SEPARATOR_OPS = ['&&', '||', ';;', '|&', ';', '|', '&', '(', ')'];
28
+ // An unquoted backslash escapes only these. Before anything else it stays
29
+ // literal, so a Windows path like `B:\projects` keeps its shape. Real bash
30
+ // would drop that backslash too; the difference can only make a signature
31
+ // less readable, never classify a command as safer than it is.
32
+ const ESCAPABLE = new Set([...' \t\n"\'$`\\|&;()<>#*?[]~!{}']);
33
+ function tokenize(command) {
34
+ const tokens = [];
35
+ const pendingHeredocs = [];
36
+ let buffer = '';
37
+ let quoted = false;
38
+ let expands = false;
39
+ let hasWord = false;
40
+ let i = 0;
41
+ function flush() {
42
+ if (hasWord)
43
+ tokens.push({ kind: 'word', word: { text: buffer, quoted, expands } });
44
+ buffer = '';
45
+ quoted = false;
46
+ expands = false;
47
+ hasWord = false;
48
+ }
49
+ function skipBalanced(open, close) {
50
+ let depth = 0;
51
+ while (i < command.length) {
52
+ if (command[i] === open)
53
+ depth++;
54
+ else if (command[i] === close && --depth === 0) {
55
+ i++;
56
+ return;
57
+ }
58
+ i++;
59
+ }
60
+ }
61
+ // Reads one word (a redirect target or heredoc delimiter) honouring quotes.
62
+ // A substitution inside it still runs a command, so it is flagged here too.
63
+ function readWord() {
64
+ while (i < command.length && (command[i] === ' ' || command[i] === '\t'))
65
+ i++;
66
+ let text = '';
67
+ let wasQuoted = false;
68
+ let wordExpands = false;
69
+ let found = false;
70
+ while (i < command.length) {
71
+ const ch = command.charAt(i);
72
+ if (ch === "'") {
73
+ const end = command.indexOf("'", i + 1);
74
+ text += command.slice(i + 1, end === -1 ? command.length : end);
75
+ i = end === -1 ? command.length : end + 1;
76
+ wasQuoted = found = true;
77
+ continue;
78
+ }
79
+ if (ch === '"') {
80
+ i++;
81
+ while (i < command.length && command[i] !== '"') {
82
+ const c = command.charAt(i);
83
+ if (c === '\\' && i + 1 < command.length) {
84
+ text += command.charAt(i + 1);
85
+ i += 2;
86
+ continue;
87
+ }
88
+ if (c === '`' || (c === '$' && command[i + 1] === '('))
89
+ tokens.push({ kind: 'subst' });
90
+ text += c;
91
+ i++;
92
+ }
93
+ i++;
94
+ wasQuoted = found = true;
95
+ continue;
96
+ }
97
+ if (ch === '\\' && i + 1 < command.length) {
98
+ text += command.charAt(i + 1);
99
+ i += 2;
100
+ wasQuoted = found = true;
101
+ continue;
102
+ }
103
+ if (/[\s;&|<>()]/.test(ch))
104
+ break;
105
+ if (ch === '`' || (ch === '$' && command[i + 1] === '('))
106
+ tokens.push({ kind: 'subst' });
107
+ if (ch === '$' || ch === '*' || ch === '?' || ch === '[')
108
+ wordExpands = true;
109
+ text += ch;
110
+ found = true;
111
+ i++;
112
+ }
113
+ return found ? { text, quoted: wasQuoted, expands: wordExpands } : undefined;
114
+ }
115
+ // A heredoc body starts on the line after its operator and ends at a line
116
+ // that is exactly the delimiter. Everything between is data, not commands,
117
+ // except that an unquoted delimiter leaves the body subject to expansion,
118
+ // so a `$(...)` or backtick in it runs.
119
+ function skipHeredocBodies() {
120
+ for (const heredoc of pendingHeredocs) {
121
+ while (i < command.length) {
122
+ let end = command.indexOf('\n', i);
123
+ if (end === -1)
124
+ end = command.length;
125
+ let line = command.slice(i, end).replace(/\r$/, '');
126
+ if (heredoc.stripTabs)
127
+ line = line.replace(/^\t+/, '');
128
+ i = Math.min(end + 1, command.length);
129
+ if (line === heredoc.delimiter)
130
+ break;
131
+ if (heredoc.expands && /\$\(|`/.test(line))
132
+ tokens.push({ kind: 'subst' });
133
+ }
134
+ }
135
+ pendingHeredocs.length = 0;
136
+ }
137
+ while (i < command.length) {
138
+ const ch = command.charAt(i);
139
+ const next = i + 1 < command.length ? command.charAt(i + 1) : undefined;
140
+ if (ch === "'") {
141
+ const end = command.indexOf("'", i + 1);
142
+ buffer += command.slice(i + 1, end === -1 ? command.length : end);
143
+ i = end === -1 ? command.length : end + 1;
144
+ quoted = hasWord = true;
145
+ continue;
146
+ }
147
+ if (ch === '"') {
148
+ i++;
149
+ while (i < command.length && command[i] !== '"') {
150
+ const c = command.charAt(i);
151
+ if (c === '\\' && i + 1 < command.length && '$`"\\\n'.includes(command.charAt(i + 1))) {
152
+ buffer += command.charAt(i + 1);
153
+ i += 2;
154
+ continue;
155
+ }
156
+ // Command substitution still expands inside double quotes.
157
+ if (c === '`' || (c === '$' && command[i + 1] === '('))
158
+ tokens.push({ kind: 'subst' });
159
+ buffer += c;
160
+ i++;
161
+ }
162
+ i++;
163
+ quoted = hasWord = true;
164
+ continue;
165
+ }
166
+ if (ch === '\\') {
167
+ if (next === '\n') {
168
+ i += 2;
169
+ continue;
170
+ }
171
+ if (next !== undefined && ESCAPABLE.has(next)) {
172
+ buffer += next;
173
+ i += 2;
174
+ quoted = hasWord = true;
175
+ continue;
176
+ }
177
+ buffer += ch;
178
+ hasWord = true;
179
+ i++;
180
+ continue;
181
+ }
182
+ if (ch === '\n' || ch === '\r') {
183
+ flush();
184
+ i++;
185
+ if (ch === '\r' && next === '\n')
186
+ i++;
187
+ if (pendingHeredocs.length > 0)
188
+ skipHeredocBodies();
189
+ tokens.push({ kind: 'sep', pipe: false });
190
+ continue;
191
+ }
192
+ if (ch === ' ' || ch === '\t') {
193
+ flush();
194
+ i++;
195
+ continue;
196
+ }
197
+ if (ch === '#' && !hasWord) {
198
+ const end = command.indexOf('\n', i);
199
+ i = end === -1 ? command.length : end;
200
+ continue;
201
+ }
202
+ if (ch === '`') {
203
+ flush();
204
+ tokens.push({ kind: 'subst' });
205
+ const end = command.indexOf('`', i + 1);
206
+ i = end === -1 ? command.length : end + 1;
207
+ continue;
208
+ }
209
+ if ((ch === '$' && next === '(') || ((ch === '<' || ch === '>') && next === '(')) {
210
+ flush();
211
+ tokens.push({ kind: 'subst' });
212
+ i++;
213
+ skipBalanced('(', ')');
214
+ continue;
215
+ }
216
+ const fdPrefix = !hasWord && /\d/.test(ch) && (next === '>' || next === '<');
217
+ if (fdPrefix || ch === '<' || ch === '>' || (ch === '&' && next === '>')) {
218
+ flush();
219
+ if (fdPrefix)
220
+ i++;
221
+ const rest = command.slice(i);
222
+ const heredoc = rest.match(/^<<(-?)(?!<)/);
223
+ if (heredoc) {
224
+ i += heredoc[0].length;
225
+ const delimiter = readWord();
226
+ pendingHeredocs.push({
227
+ delimiter: delimiter?.text ?? '',
228
+ stripTabs: heredoc[1] === '-',
229
+ expands: !delimiter?.quoted,
230
+ });
231
+ continue;
232
+ }
233
+ const op = rest.match(/^(?:<<<|&>>|&>|>>|>\||>&|<&|<>|>|<)/)?.[0] ?? ch;
234
+ i += op.length;
235
+ if (op === '>&' || op === '<&') {
236
+ // Descriptor duplication (`2>&1`, `>&2`) touches no file.
237
+ const dup = command.slice(i).match(/^\s*(?:\d+|-)(?![^\s;&|<>()])/);
238
+ if (dup) {
239
+ i += dup[0].length;
240
+ continue;
241
+ }
242
+ }
243
+ tokens.push({ kind: 'redirect', op: op === '>&' ? '>' : op === '<&' ? '<' : op });
244
+ const target = readWord();
245
+ if (target)
246
+ tokens.push({ kind: 'word', word: target });
247
+ continue;
248
+ }
249
+ const separator = SEPARATOR_OPS.find(op => command.startsWith(op, i));
250
+ if (separator) {
251
+ flush();
252
+ tokens.push({ kind: 'sep', pipe: separator === '|' || separator === '|&' });
253
+ i += separator.length;
254
+ continue;
255
+ }
256
+ if ((ch === '{' || ch === '}') && !hasWord && (next === undefined || /[\s;&|]/.test(next))) {
257
+ tokens.push({ kind: 'sep', pipe: false });
258
+ i++;
259
+ continue;
260
+ }
261
+ // `$?`, `$$`, `$#` and `$!` expand to exactly one word; anything else
262
+ // the shell may split into several, and a brace inside a word
263
+ // (`f{1,2}.txt`) expands without any glob character at all.
264
+ if (ch === '$' && !/[?$#!]/.test(next ?? ''))
265
+ expands = true;
266
+ if (ch === '*' || ch === '?' || ch === '[' || ch === '{')
267
+ expands = true;
268
+ buffer += ch;
269
+ hasWord = true;
270
+ i++;
271
+ }
272
+ flush();
273
+ return tokens;
274
+ }
275
+ const DISCARD_TARGETS = new Set(['/dev/null', 'nul', 'nul:']);
276
+ function splitSegments(tokens) {
277
+ const result = [];
278
+ let current = { words: [], writes: false, consumer: false };
279
+ let substitution = false;
280
+ let pendingRedirect;
281
+ function settleRedirect() {
282
+ // A redirect that never got a target is treated as a write.
283
+ if (pendingRedirect?.includes('>'))
284
+ current.writes = true;
285
+ pendingRedirect = undefined;
286
+ }
287
+ for (const token of tokens) {
288
+ if (token.kind === 'subst') {
289
+ substitution = true;
290
+ }
291
+ else if (token.kind === 'sep') {
292
+ settleRedirect();
293
+ if (current.words.length > 0 || current.writes)
294
+ result.push(current);
295
+ current = { words: [], writes: false, consumer: token.pipe };
296
+ }
297
+ else if (token.kind === 'redirect') {
298
+ settleRedirect();
299
+ pendingRedirect = token.op;
300
+ }
301
+ else if (pendingRedirect) {
302
+ if (pendingRedirect.includes('>') && !DISCARD_TARGETS.has(token.word.text.toLowerCase())) {
303
+ current.writes = true;
304
+ }
305
+ pendingRedirect = undefined;
306
+ }
307
+ else {
308
+ current.words.push(token.word);
309
+ }
310
+ }
311
+ settleRedirect();
312
+ if (current.words.length > 0 || current.writes)
313
+ result.push(current);
314
+ return { segments: result, substitution };
315
+ }
316
+ const KEYWORDS = new Set([
317
+ '!',
318
+ 'if',
319
+ 'then',
320
+ 'else',
321
+ 'elif',
322
+ 'fi',
323
+ 'while',
324
+ 'until',
325
+ 'do',
326
+ 'done',
327
+ 'esac',
328
+ ]);
329
+ // Wrappers that run another command without side effects of their own.
330
+ const TRANSPARENT_PREFIXES = new Set(['time', 'nice', 'nohup', 'builtin', 'command', 'env']);
331
+ const WRAPPER_VALUE_OPTIONS = new Set(['-C', '--chdir', '-u', '--unset', '-n', '--adjustment']);
332
+ const WRAPPER_BARE_OPTIONS = new Set(['-i', '--ignore-environment', '-0', '--null', '-']);
333
+ // Wrappers that run arbitrary or privileged commands: never read-only.
334
+ const OPAQUE_PREFIXES = new Set([
335
+ 'sudo',
336
+ 'doas',
337
+ 'runas',
338
+ 'exec',
339
+ 'eval',
340
+ 'xargs',
341
+ 'parallel',
342
+ 'source',
343
+ '.',
344
+ ]);
345
+ const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*(?:\[[^\]]*\])?\+?=/;
346
+ // Verbs that say nothing about what a command does.
347
+ const TRIVIAL_VERBS = new Set([
348
+ 'cd',
349
+ 'pushd',
350
+ 'popd',
351
+ 'echo',
352
+ 'printf',
353
+ 'true',
354
+ 'false',
355
+ ':',
356
+ 'pwd',
357
+ 'set',
358
+ 'shopt',
359
+ 'export',
360
+ 'unset',
361
+ 'local',
362
+ 'declare',
363
+ 'typeset',
364
+ 'readonly',
365
+ 'let',
366
+ 'sleep',
367
+ 'exit',
368
+ 'return',
369
+ 'break',
370
+ 'continue',
371
+ 'shift',
372
+ 'wait',
373
+ 'test',
374
+ '[',
375
+ '[[',
376
+ 'clear',
377
+ ]);
378
+ const SIMPLE_READ_ONLY = new Set([
379
+ ...TRIVIAL_VERBS,
380
+ 'grep',
381
+ 'egrep',
382
+ 'fgrep',
383
+ 'rg',
384
+ 'ag',
385
+ 'ack',
386
+ 'findstr',
387
+ 'ls',
388
+ 'dir',
389
+ 'tree',
390
+ 'cat',
391
+ 'head',
392
+ 'tail',
393
+ 'less',
394
+ 'more',
395
+ 'wc',
396
+ 'stat',
397
+ 'file',
398
+ 'which',
399
+ 'where',
400
+ 'whereis',
401
+ 'type',
402
+ 'basename',
403
+ 'dirname',
404
+ 'realpath',
405
+ 'readlink',
406
+ 'du',
407
+ 'df',
408
+ 'md5sum',
409
+ 'sha1sum',
410
+ 'sha256sum',
411
+ 'sha512sum',
412
+ 'cksum',
413
+ 'cut',
414
+ 'tr',
415
+ 'column',
416
+ 'nl',
417
+ 'tac',
418
+ 'rev',
419
+ 'fold',
420
+ 'paste',
421
+ 'join',
422
+ 'comm',
423
+ 'strings',
424
+ 'hexdump',
425
+ 'xxd',
426
+ 'od',
427
+ 'jq',
428
+ 'diff',
429
+ 'cmp',
430
+ 'seq',
431
+ 'expr',
432
+ 'bc',
433
+ 'uname',
434
+ 'whoami',
435
+ 'id',
436
+ 'groups',
437
+ 'printenv',
438
+ 'nproc',
439
+ 'free',
440
+ 'uptime',
441
+ 'ps',
442
+ 'pgrep',
443
+ 'lsof',
444
+ 'netstat',
445
+ 'ss',
446
+ 'tasklist',
447
+ 'systeminfo',
448
+ 'ver',
449
+ 'getconf',
450
+ 'locale',
451
+ 'tty',
452
+ 'dirs',
453
+ 'jobs',
454
+ 'history',
455
+ 'help',
456
+ ]);
457
+ const FIND_WRITE_FLAGS = new Set([
458
+ '-exec',
459
+ '-execdir',
460
+ '-ok',
461
+ '-okdir',
462
+ '-delete',
463
+ '-fprint',
464
+ '-fprint0',
465
+ '-fprintf',
466
+ '-fls',
467
+ ]);
468
+ const GIT_READ_ONLY = new Set([
469
+ 'status',
470
+ 'log',
471
+ 'diff',
472
+ 'show',
473
+ 'ls-files',
474
+ 'ls-tree',
475
+ 'ls-remote',
476
+ 'rev-parse',
477
+ 'rev-list',
478
+ 'describe',
479
+ 'blame',
480
+ 'annotate',
481
+ 'cat-file',
482
+ 'shortlog',
483
+ 'name-rev',
484
+ 'merge-base',
485
+ 'for-each-ref',
486
+ 'count-objects',
487
+ 'check-ignore',
488
+ 'check-attr',
489
+ 'check-ref-format',
490
+ 'var',
491
+ 'version',
492
+ '--version',
493
+ 'diff-tree',
494
+ 'diff-index',
495
+ 'diff-files',
496
+ 'show-ref',
497
+ 'show-branch',
498
+ 'whatchanged',
499
+ 'cherry',
500
+ 'range-diff',
501
+ 'verify-pack',
502
+ 'verify-commit',
503
+ 'verify-tag',
504
+ ]);
505
+ const GIT_BARE_OPTIONS = new Set([
506
+ '--no-pager',
507
+ '-P',
508
+ '--no-optional-locks',
509
+ '--literal-pathspecs',
510
+ ]);
511
+ const GIT_BRANCH_WRITE_FLAGS = new Set([
512
+ '-d',
513
+ '-D',
514
+ '-m',
515
+ '-M',
516
+ '-c',
517
+ '-C',
518
+ '--delete',
519
+ '--move',
520
+ '--copy',
521
+ '--set-upstream-to',
522
+ '-u',
523
+ '--unset-upstream',
524
+ '--edit-description',
525
+ '-f',
526
+ '--force',
527
+ '--track',
528
+ '--no-track',
529
+ ]);
530
+ const GIT_BRANCH_LIST_FLAGS = new Set([
531
+ '-l',
532
+ '--list',
533
+ '--show-current',
534
+ '--contains',
535
+ '--no-contains',
536
+ '--merged',
537
+ '--no-merged',
538
+ '--points-at',
539
+ ]);
540
+ const GIT_TAG_LIST_FLAGS = new Set(['-l', '--list', '--contains', '--points-at', '-n']);
541
+ const GIT_CONFIG_WRITE_FLAGS = new Set([
542
+ '--unset',
543
+ '--unset-all',
544
+ '--add',
545
+ '--replace-all',
546
+ '--edit',
547
+ '-e',
548
+ '--remove-section',
549
+ '--rename-section',
550
+ ]);
551
+ const NPM_READ_ONLY = new Set([
552
+ 'ls',
553
+ 'list',
554
+ 'll',
555
+ 'la',
556
+ 'view',
557
+ 'info',
558
+ 'show',
559
+ 'v',
560
+ 'outdated',
561
+ 'explain',
562
+ 'why',
563
+ 'ping',
564
+ 'prefix',
565
+ 'root',
566
+ 'bin',
567
+ 'search',
568
+ 's',
569
+ 'se',
570
+ 'find',
571
+ '--version',
572
+ '-v',
573
+ 'whoami',
574
+ 'query',
575
+ 'sbom',
576
+ 'diff',
577
+ ]);
578
+ const NPM_RUN_ALIASES = new Set(['run', 'run-script', 'rum', 'urn']);
579
+ const NPM_TEST_ALIASES = new Set(['test', 't', 'tst']);
580
+ const NPX_BARE_FLAGS = new Set(['-y', '--yes', '--no-install', '--no']);
581
+ const NPX_VALUE_FLAGS = new Set(['-p', '--package', '-c', '--call']);
582
+ const DOCKER_READ_ONLY = new Set([
583
+ 'ps',
584
+ 'images',
585
+ 'inspect',
586
+ 'logs',
587
+ 'version',
588
+ 'info',
589
+ 'top',
590
+ 'port',
591
+ 'diff',
592
+ 'history',
593
+ 'search',
594
+ '--version',
595
+ ]);
596
+ const DOCKER_GROUPS = new Set([
597
+ 'compose',
598
+ 'container',
599
+ 'image',
600
+ 'volume',
601
+ 'network',
602
+ 'system',
603
+ 'context',
604
+ 'buildx',
605
+ 'manifest',
606
+ ]);
607
+ const DOCKER_COMPOSE_VALUE_OPTIONS = new Set([
608
+ '-f',
609
+ '--file',
610
+ '-p',
611
+ '--project-name',
612
+ '--project-directory',
613
+ '--env-file',
614
+ '--profile',
615
+ '--progress',
616
+ '--ansi',
617
+ '--parallel',
618
+ ]);
619
+ const DOCKER_GROUP_READ_ONLY = new Set([
620
+ 'ls',
621
+ 'list',
622
+ 'inspect',
623
+ 'logs',
624
+ 'ps',
625
+ 'top',
626
+ 'port',
627
+ 'df',
628
+ 'config',
629
+ 'version',
630
+ 'images',
631
+ 'info',
632
+ 'show',
633
+ ]);
634
+ const KUBECTL_READ_ONLY = new Set([
635
+ 'get',
636
+ 'describe',
637
+ 'logs',
638
+ 'version',
639
+ 'explain',
640
+ 'api-resources',
641
+ 'api-versions',
642
+ 'top',
643
+ 'cluster-info',
644
+ 'diff',
645
+ 'events',
646
+ ]);
647
+ const KUBECTL_CONFIG_READ_ONLY = new Set([
648
+ 'view',
649
+ 'current-context',
650
+ 'get-contexts',
651
+ 'get-clusters',
652
+ 'get-users',
653
+ ]);
654
+ const GH_READ_ONLY = new Set([
655
+ 'pr view',
656
+ 'pr list',
657
+ 'pr status',
658
+ 'pr checks',
659
+ 'pr diff',
660
+ 'issue view',
661
+ 'issue list',
662
+ 'issue status',
663
+ 'repo view',
664
+ 'repo list',
665
+ 'run view',
666
+ 'run list',
667
+ 'run watch',
668
+ 'release view',
669
+ 'release list',
670
+ 'auth status',
671
+ 'workflow list',
672
+ 'workflow view',
673
+ 'label list',
674
+ 'cache list',
675
+ 'gist list',
676
+ 'gist view',
677
+ 'config get',
678
+ 'config list',
679
+ 'extension list',
680
+ 'secret list',
681
+ 'variable list',
682
+ 'ssh-key list',
683
+ 'gpg-key list',
684
+ 'codespace list',
685
+ 'search repos',
686
+ 'search issues',
687
+ 'search prs',
688
+ 'search code',
689
+ 'search commits',
690
+ ]);
691
+ const PIP_READ_ONLY = new Set([
692
+ 'list',
693
+ 'show',
694
+ 'freeze',
695
+ 'check',
696
+ '--version',
697
+ '-V',
698
+ 'debug',
699
+ 'index',
700
+ ]);
701
+ const CARGO_READ_ONLY = new Set([
702
+ '--version',
703
+ '-V',
704
+ 'metadata',
705
+ 'tree',
706
+ 'search',
707
+ 'pkgid',
708
+ 'locate-project',
709
+ 'read-manifest',
710
+ 'verify-project',
711
+ ]);
712
+ const GO_READ_ONLY = new Set(['version', 'env', 'list', 'doc']);
713
+ const DOTNET_READ_ONLY = new Set(['--version', '--list-sdks', '--list-runtimes', '--info']);
714
+ const INTERPRETERS = new Set([
715
+ 'node',
716
+ 'nodejs',
717
+ 'python',
718
+ 'python3',
719
+ 'py',
720
+ 'deno',
721
+ 'bun',
722
+ 'ruby',
723
+ 'perl',
724
+ 'php',
725
+ 'bash',
726
+ 'sh',
727
+ 'zsh',
728
+ 'pwsh',
729
+ 'powershell',
730
+ 'tsx',
731
+ 'ts-node',
732
+ ]);
733
+ const INTERPRETER_EVAL_FLAGS = new Set([
734
+ '-e',
735
+ '--eval',
736
+ '-p',
737
+ '--print',
738
+ '-c',
739
+ '-C',
740
+ '-r',
741
+ '-Command',
742
+ '-command',
743
+ ]);
744
+ const VERSION_FLAGS = new Set(['--version', '-v', '-V', '-version', '--help', '-h']);
745
+ const SHELLS = new Set(['bash', 'sh', 'zsh', 'pwsh', 'powershell']);
746
+ const SHELL_VERSION_FLAGS = new Set(['--version', '-Version', '--help']);
747
+ // Verbs that a collapsed newline could hide inside another command's
748
+ // argument list. Used only when the caller says the text was flattened.
749
+ const HIDDEN_SIDE_EFFECT_VERBS = new Set([
750
+ 'rm',
751
+ 'rmdir',
752
+ 'mv',
753
+ 'cp',
754
+ 'dd',
755
+ 'tee',
756
+ 'xargs',
757
+ 'sudo',
758
+ 'git',
759
+ 'npm',
760
+ 'npx',
761
+ 'yarn',
762
+ 'pnpm',
763
+ 'node',
764
+ 'python',
765
+ 'python3',
766
+ 'py',
767
+ 'pip',
768
+ 'pip3',
769
+ 'docker',
770
+ 'kubectl',
771
+ 'gh',
772
+ 'curl',
773
+ 'wget',
774
+ 'ssh',
775
+ 'scp',
776
+ 'rsync',
777
+ 'chmod',
778
+ 'chown',
779
+ 'kill',
780
+ 'killall',
781
+ 'taskkill',
782
+ 'mkdir',
783
+ 'touch',
784
+ 'ln',
785
+ 'truncate',
786
+ 'install',
787
+ 'sed',
788
+ 'awk',
789
+ 'bash',
790
+ 'sh',
791
+ 'zsh',
792
+ 'pwsh',
793
+ 'powershell',
794
+ 'cmd',
795
+ 'eval',
796
+ 'exec',
797
+ 'source',
798
+ 'alias',
799
+ 'hash',
800
+ 'enable',
801
+ 'trap',
802
+ 'export',
803
+ 'declare',
804
+ 'typeset',
805
+ 'let',
806
+ 'readonly',
807
+ 'read',
808
+ 'mapfile',
809
+ 'readarray',
810
+ 'printf',
811
+ 'env',
812
+ // Windows-native tools with the same standing as the POSIX ones above.
813
+ 'del',
814
+ 'erase',
815
+ 'rd',
816
+ 'md',
817
+ 'move',
818
+ 'xcopy',
819
+ 'robocopy',
820
+ 'attrib',
821
+ 'icacls',
822
+ 'cacls',
823
+ 'takeown',
824
+ 'cipher',
825
+ 'reg',
826
+ 'regsvr32',
827
+ 'rundll32',
828
+ 'mshta',
829
+ 'wmic',
830
+ 'sc',
831
+ 'net',
832
+ 'schtasks',
833
+ 'vssadmin',
834
+ 'wevtutil',
835
+ 'diskpart',
836
+ 'bcdedit',
837
+ 'certutil',
838
+ 'bitsadmin',
839
+ 'powercfg',
840
+ 'format',
841
+ 'wbadmin',
842
+ 'netsh',
843
+ 'at',
844
+ 'taskkill',
845
+ 'cmd',
846
+ 'powershell',
847
+ 'pwsh',
848
+ 'msiexec',
849
+ 'forfiles',
850
+ ]);
851
+ function normalizeVerb(word) {
852
+ const base = word.text.split(/[\\/]/).pop() ?? word.text;
853
+ return base.replace(/\.(?:exe|cmd|bat|com)$/i, '').toLowerCase();
854
+ }
855
+ function isFlag(arg) {
856
+ return arg.startsWith('-');
857
+ }
858
+ function positional(args) {
859
+ return args.filter(arg => !isFlag(arg));
860
+ }
861
+ function scriptName(arg) {
862
+ return (arg.split(/[\\/]/).pop() ?? arg).toLowerCase();
863
+ }
864
+ // `audrey@1.2.1` -> `audrey`, `@scope/pkg@1.0.0` -> `@scope/pkg`.
865
+ function stripPackageVersion(arg) {
866
+ const at = arg.lastIndexOf('@');
867
+ return at > 0 ? arg.slice(0, at) : arg;
868
+ }
869
+ // Only a directory change and pager/lock suppression are accepted before
870
+ // the subcommand. `-c diff.external=./x` or `--exec-path=/x` would make a
871
+ // read-only subcommand run a program, so any other leading option fails
872
+ // closed.
873
+ function gitArgs(args) {
874
+ const rest = [...args];
875
+ for (;;) {
876
+ const arg = rest[0];
877
+ if (arg === undefined)
878
+ break;
879
+ if (arg === '-C')
880
+ rest.splice(0, 2);
881
+ else if (GIT_BARE_OPTIONS.has(arg))
882
+ rest.shift();
883
+ else if (arg.startsWith('-'))
884
+ return undefined;
885
+ else
886
+ break;
887
+ }
888
+ return rest;
889
+ }
890
+ function gitIsReadOnly(sub, rest) {
891
+ // `--output=<path>` makes log, diff, show and their kind write a file.
892
+ if (rest.some(arg => /^--output(?:=|$)/.test(arg)))
893
+ return false;
894
+ if (GIT_READ_ONLY.has(sub))
895
+ return true;
896
+ switch (sub) {
897
+ case 'grep':
898
+ // `-O` opens the matches in a pager or editor.
899
+ return (!usesShortFlag(rest, 'O') && !rest.some(arg => arg.startsWith('--open-files-in-pager')));
900
+ case 'fsck':
901
+ // `--lost-found` writes recovered objects under .git/lost-found.
902
+ return !rest.some(arg => arg.startsWith('--lost-found'));
903
+ case 'reflog':
904
+ return rest.length === 0 || rest[0] === 'show';
905
+ case 'stash':
906
+ return rest[0] === 'list' || rest[0] === 'show';
907
+ case 'worktree':
908
+ return rest[0] === 'list';
909
+ case 'submodule':
910
+ return rest[0] === 'status' || rest[0] === 'summary';
911
+ case 'notes':
912
+ return rest[0] === 'list' || rest[0] === 'show';
913
+ case 'symbolic-ref':
914
+ return (positional(rest).length <= 1 &&
915
+ !usesShortFlag(rest, 'd') &&
916
+ !rest.some(arg => arg.startsWith('--delete')));
917
+ case 'branch':
918
+ if (usesShortFlag(rest, 'dDmMcCuf') || rest.some(arg => GIT_BRANCH_WRITE_FLAGS.has(arg))) {
919
+ return false;
920
+ }
921
+ return positional(rest).length === 0 || rest.some(arg => GIT_BRANCH_LIST_FLAGS.has(arg));
922
+ case 'remote':
923
+ return rest.length === 0 || rest[0] === '-v' || rest[0] === 'show' || rest[0] === 'get-url';
924
+ case 'tag':
925
+ if (usesShortFlag(rest, 'df') || rest.some(arg => arg === '--delete' || arg === '--force')) {
926
+ return false;
927
+ }
928
+ return (positional(rest).length === 0 ||
929
+ rest.some(arg => GIT_TAG_LIST_FLAGS.has(arg) || /^-n\d*$/.test(arg)));
930
+ case 'config':
931
+ if (usesShortFlag(rest, 'e') || rest.some(arg => GIT_CONFIG_WRITE_FLAGS.has(arg))) {
932
+ return false;
933
+ }
934
+ // `git config user.name` reads; `git config user.name Tyler` writes.
935
+ return positional(rest).length <= 1;
936
+ default:
937
+ return false;
938
+ }
939
+ }
940
+ function npmProfile(verb, args) {
941
+ const sub = args[0];
942
+ // Bare `npm` prints usage; bare `yarn` and `pnpm` install.
943
+ if (sub === undefined)
944
+ return { readOnly: verb === 'npm', signature: verb };
945
+ if (NPM_RUN_ALIASES.has(sub)) {
946
+ const script = positional(args.slice(1))[0];
947
+ return { readOnly: false, signature: script ? `${verb} run ${script}` : `${verb} run` };
948
+ }
949
+ if (NPM_TEST_ALIASES.has(sub))
950
+ return { readOnly: false, signature: `${verb} test` };
951
+ if (sub === 'config') {
952
+ return {
953
+ readOnly: args[1] === 'get' || args[1] === 'list' || args[1] === 'ls',
954
+ signature: `${verb} config`,
955
+ };
956
+ }
957
+ if (sub === 'audit')
958
+ return { readOnly: !args.includes('fix'), signature: `${verb} audit` };
959
+ if (sub === 'pack')
960
+ return { readOnly: args.includes('--dry-run'), signature: `${verb} pack` };
961
+ if (sub === 'version') {
962
+ return { readOnly: positional(args).length === 1, signature: `${verb} version` };
963
+ }
964
+ if (sub === 'dist-tag' || sub === 'owner') {
965
+ return { readOnly: args[1] === 'ls', signature: `${verb} ${sub}` };
966
+ }
967
+ return { readOnly: NPM_READ_ONLY.has(sub), signature: `${verb} ${sub}` };
968
+ }
969
+ function npxProfile(args) {
970
+ if (args.length === 1 && VERSION_FLAGS.has(args[0] ?? ''))
971
+ return { readOnly: true, signature: 'npx' };
972
+ const rest = [...args];
973
+ for (;;) {
974
+ const arg = rest[0];
975
+ if (arg === undefined)
976
+ break;
977
+ if (NPX_VALUE_FLAGS.has(arg))
978
+ rest.splice(0, 2);
979
+ else if (NPX_BARE_FLAGS.has(arg))
980
+ rest.shift();
981
+ else
982
+ break;
983
+ }
984
+ const target = positional(rest)[0];
985
+ return {
986
+ readOnly: false,
987
+ signature: target ? `npx ${stripPackageVersion(target).toLowerCase()}` : 'npx',
988
+ };
989
+ }
990
+ // An interpreter is read-only only when asked for its version or help. A
991
+ // syntax-check flag is not enough: `node --check -r ./x.js file` still loads
992
+ // x.js, and bun ignores `--check` altogether and runs the file.
993
+ function interpreterProfile(verb, args) {
994
+ if (args.length === 0)
995
+ return { readOnly: false, signature: verb };
996
+ // For a shell, `-v` and `-h` are runtime options, not version queries.
997
+ const versionFlags = SHELLS.has(verb) ? SHELL_VERSION_FLAGS : VERSION_FLAGS;
998
+ if (args.length === 1 && versionFlags.has(args[0] ?? '')) {
999
+ return { readOnly: true, signature: verb };
1000
+ }
1001
+ const isNode = verb === 'node' || verb === 'nodejs';
1002
+ if (args.includes('--check') || (isNode && args.includes('-c'))) {
1003
+ return { readOnly: false, signature: `${verb} --check` };
1004
+ }
1005
+ if (args.some(arg => INTERPRETER_EVAL_FLAGS.has(arg) && !(isNode && arg === '-c'))) {
1006
+ return { readOnly: false, signature: `${verb} -e` };
1007
+ }
1008
+ const moduleIndex = args.indexOf('-m');
1009
+ const moduleName = moduleIndex === -1 ? undefined : args[moduleIndex + 1];
1010
+ if (moduleName)
1011
+ return { readOnly: false, signature: `${verb} -m ${moduleName.toLowerCase()}` };
1012
+ const script = positional(args)[0];
1013
+ return { readOnly: false, signature: script ? `${verb} ${scriptName(script)}` : verb };
1014
+ }
1015
+ function dockerProfile(args) {
1016
+ const sub = args[0];
1017
+ if (!sub)
1018
+ return { readOnly: false, signature: 'docker' };
1019
+ if (DOCKER_GROUPS.has(sub)) {
1020
+ // `compose` takes its own options before the nested subcommand. Only
1021
+ // the options known to take a value are skipped with their value, since
1022
+ // `--progress ps down` makes docker read "ps" as the value and run
1023
+ // `down`; any option this table does not know fails closed.
1024
+ const rest = args.slice(1);
1025
+ while (sub === 'compose' && rest[0]?.startsWith('-')) {
1026
+ const option = rest[0];
1027
+ if (DOCKER_COMPOSE_VALUE_OPTIONS.has(option))
1028
+ rest.splice(0, 2);
1029
+ else if (/^--(?:file|project-name|project-directory|env-file|profile|progress|ansi|parallel)=/.test(option))
1030
+ rest.shift();
1031
+ else if (option === '--dry-run' || option === '--compatibility')
1032
+ rest.shift();
1033
+ else
1034
+ return { readOnly: false, signature: 'docker compose' };
1035
+ }
1036
+ const nested = rest[0];
1037
+ // `compose config -o <file>` writes the resolved manifest.
1038
+ const writesOutput = usesShortFlag(rest, 'o') || rest.some(arg => arg.startsWith('--output'));
1039
+ return {
1040
+ readOnly: nested !== undefined && DOCKER_GROUP_READ_ONLY.has(nested) && !writesOutput,
1041
+ signature: nested ? `docker ${sub} ${nested}` : `docker ${sub}`,
1042
+ };
1043
+ }
1044
+ if (sub === 'stats')
1045
+ return { readOnly: args.includes('--no-stream'), signature: 'docker stats' };
1046
+ return { readOnly: DOCKER_READ_ONLY.has(sub), signature: `docker ${sub}` };
1047
+ }
1048
+ function kubectlProfile(args) {
1049
+ const sub = args[0];
1050
+ if (!sub)
1051
+ return { readOnly: false, signature: 'kubectl' };
1052
+ if (sub === 'config') {
1053
+ return { readOnly: KUBECTL_CONFIG_READ_ONLY.has(args[1] ?? ''), signature: 'kubectl config' };
1054
+ }
1055
+ if (sub === 'auth')
1056
+ return { readOnly: args[1] === 'can-i', signature: 'kubectl auth' };
1057
+ return { readOnly: KUBECTL_READ_ONLY.has(sub), signature: `kubectl ${sub}` };
1058
+ }
1059
+ function ghProfile(args) {
1060
+ const sub = args[0];
1061
+ if (!sub)
1062
+ return { readOnly: false, signature: 'gh' };
1063
+ if (sub === '--version' || sub === 'status')
1064
+ return { readOnly: true, signature: `gh ${sub}` };
1065
+ // `--web` opens a browser; `--hostname` sends the token somewhere else.
1066
+ const opensBrowser = usesShortFlag(args, 'w') || args.some(arg => arg.startsWith('--web'));
1067
+ if (sub === 'api') {
1068
+ // Only a plain GET with no request body: `-X`, `--method`, `-f`, `-F`,
1069
+ // `--input` in any spelling, glued or clustered, means a write.
1070
+ const readOnly = !args.some(arg => /^-[^-]*[XfF]/.test(arg) ||
1071
+ arg.startsWith('--method') ||
1072
+ arg.startsWith('--field') ||
1073
+ arg.startsWith('--raw-field') ||
1074
+ arg.startsWith('--input') ||
1075
+ arg.startsWith('--hostname'));
1076
+ return { readOnly, signature: 'gh api' };
1077
+ }
1078
+ const pair = `${sub} ${args[1] ?? ''}`.trim();
1079
+ return { readOnly: !opensBrowser && GH_READ_ONLY.has(pair), signature: `gh ${pair}` };
1080
+ }
1081
+ // Variables whose assignment cannot change what a later command does. The
1082
+ // list is short on purpose: PATH, HOME, GIT_EXTERNAL_DIFF, GIT_CONFIG_*,
1083
+ // NODE_OPTIONS, LD_PRELOAD, LESSOPEN and their kind all make a read-only
1084
+ // verb run something else, and enumerating them is a losing game.
1085
+ const HARMLESS_VARIABLES = new Set([
1086
+ 'LANG',
1087
+ 'LANGUAGE',
1088
+ 'LC_ALL',
1089
+ 'LC_CTYPE',
1090
+ 'LC_COLLATE',
1091
+ 'LC_MESSAGES',
1092
+ 'LC_NUMERIC',
1093
+ 'LC_TIME',
1094
+ 'TZ',
1095
+ 'TERM',
1096
+ 'COLUMNS',
1097
+ 'LINES',
1098
+ 'NO_COLOR',
1099
+ 'FORCE_COLOR',
1100
+ 'CLICOLOR',
1101
+ 'CLICOLOR_FORCE',
1102
+ 'CI',
1103
+ 'MSYS_NO_PATHCONV',
1104
+ 'MSYS2_ARG_CONV_EXCL',
1105
+ 'GIT_TERMINAL_PROMPT',
1106
+ 'GIT_OPTIONAL_LOCKS',
1107
+ 'NODE_NO_WARNINGS',
1108
+ 'PYTHONIOENCODING',
1109
+ 'PYTHONUTF8',
1110
+ 'PYTHONDONTWRITEBYTECODE',
1111
+ ]);
1112
+ function assignmentIsHarmless(text) {
1113
+ const name = text.match(/^[A-Za-z_][A-Za-z0-9_]*/)?.[0];
1114
+ return name !== undefined && HARMLESS_VARIABLES.has(name);
1115
+ }
1116
+ // A verb given by path runs whatever sits at that path. An allow-list of
1117
+ // system directories was tried and dropped: `..` segments, case folding, and
1118
+ // a Windows install whose Program Files is writable to the running user each
1119
+ // turned it into a hole, and agents almost never invoke a read-only tool by
1120
+ // path. A bare name resolves through PATH, which the assignment rule keeps
1121
+ // out of the command's own hands.
1122
+ function isPathQualified(text) {
1123
+ return /[\\/]/.test(text);
1124
+ }
1125
+ // sed is read-only only for scripts built from addresses and printing,
1126
+ // deleting, holding, substituting and transliterating commands. `w`
1127
+ // writes a file, `e` runs a command, `r` and `-f` read files the check
1128
+ // cannot see, and `-i` edits in place; none of those pass.
1129
+ const SED_FLAGS = new Set([
1130
+ '-n',
1131
+ '--quiet',
1132
+ '--silent',
1133
+ '-E',
1134
+ '-r',
1135
+ '--regexp-extended',
1136
+ '-z',
1137
+ '--null-data',
1138
+ '-u',
1139
+ '--unbuffered',
1140
+ '-s',
1141
+ '--separate',
1142
+ '--posix',
1143
+ '--debug',
1144
+ '--sandbox',
1145
+ '--version',
1146
+ '--help',
1147
+ ]);
1148
+ const SED_ADDRESS = String.raw `(?:\d+|\$|\/(?:\\.|[^/\\])*\/)`;
1149
+ const SED_RANGE = `${SED_ADDRESS}(?:,(?:${SED_ADDRESS}|\\+\\d+|~\\d+))?`;
1150
+ // Braces group commands (`/x/{p;q}`); a `{` may follow the address and a
1151
+ // `}` may end a command. Inside a regex address they are ordinary text.
1152
+ const SED_SIMPLE = new RegExp(`^(?:${SED_RANGE})?!?\\s*\\{?\\s*(?:[pPl=DdnNhHgGx]|[qQ]\\d*|l\\s*\\d+)?\\s*\\}?$`);
1153
+ const SED_TRANSFORM = new RegExp(`^(?:${SED_RANGE})?!?\\s*\\{?\\s*([sy])(.)`);
1154
+ function sedScriptIsReadOnly(script) {
1155
+ return script.split(/[;\n]/).every(command => {
1156
+ const text = command.trim();
1157
+ if (SED_SIMPLE.test(text))
1158
+ return true;
1159
+ const transform = text.match(SED_TRANSFORM);
1160
+ if (!transform)
1161
+ return false;
1162
+ const [prefix, kind, delimiter] = [transform[0], transform[1], transform[2] ?? ''];
1163
+ let body = text.slice(prefix.length);
1164
+ // Consume the pattern and the replacement, each closed by the delimiter.
1165
+ for (let closed = 0; closed < 2;) {
1166
+ if (body.length === 0)
1167
+ return false;
1168
+ if (body.startsWith('\\'))
1169
+ body = body.slice(2);
1170
+ else if (body.startsWith(delimiter)) {
1171
+ body = body.slice(1);
1172
+ closed++;
1173
+ }
1174
+ else
1175
+ body = body.slice(1);
1176
+ }
1177
+ return kind === 'y' ? /^\s*\}?$/.test(body) : /^[gpImM0-9]*\s*\}?$/.test(body);
1178
+ });
1179
+ }
1180
+ function sedIsReadOnly(args) {
1181
+ const scripts = [];
1182
+ const files = [];
1183
+ for (let i = 0; i < args.length; i++) {
1184
+ const arg = args[i] ?? '';
1185
+ if (arg === '--') {
1186
+ files.push(...args.slice(i + 1));
1187
+ break;
1188
+ }
1189
+ if (arg === '-e' || arg === '--expression' || /^-[nErzus]*e$/.test(arg)) {
1190
+ const script = args[++i];
1191
+ if (script === undefined)
1192
+ return false;
1193
+ scripts.push(script);
1194
+ continue;
1195
+ }
1196
+ if (arg.startsWith('--expression=')) {
1197
+ scripts.push(arg.slice('--expression='.length));
1198
+ continue;
1199
+ }
1200
+ if (arg.startsWith('-') && arg !== '-') {
1201
+ if (SED_FLAGS.has(arg) || /^-[nErzus]+$/.test(arg))
1202
+ continue;
1203
+ if (arg === '-l' || arg.startsWith('--line-length')) {
1204
+ if (arg === '-l')
1205
+ i++;
1206
+ continue;
1207
+ }
1208
+ return false;
1209
+ }
1210
+ files.push(arg);
1211
+ }
1212
+ if (scripts.length === 0) {
1213
+ const first = files.shift();
1214
+ if (first === undefined)
1215
+ return args.length > 0;
1216
+ scripts.push(first);
1217
+ }
1218
+ return scripts.every(sedScriptIsReadOnly);
1219
+ }
1220
+ // GNU-style short options cluster and glue their values: `-o file`,
1221
+ // `-ofile` and `-nofile` all set `o`. A single-dash argument containing one
1222
+ // of the letters anywhere is treated as setting it; over-matching (`-k2o`)
1223
+ // only fails closed.
1224
+ function usesShortFlag(args, letters) {
1225
+ const pattern = new RegExp(`^-[^-]*[${letters}]`);
1226
+ return args.some(arg => pattern.test(arg));
1227
+ }
1228
+ function subcommandProfile(verb, args, readOnlySet) {
1229
+ const sub = args[0];
1230
+ return {
1231
+ readOnly: sub !== undefined && readOnlySet.has(sub),
1232
+ signature: sub ? `${verb} ${sub}` : verb,
1233
+ };
1234
+ }
1235
+ function profileVerb(verb, args) {
1236
+ switch (verb) {
1237
+ case 'sed':
1238
+ return { readOnly: sedIsReadOnly(args), signature: 'sed' };
1239
+ case 'awk':
1240
+ case 'gawk':
1241
+ case 'mawk':
1242
+ case 'nawk':
1243
+ // A program file cannot be inspected; inline programs must not
1244
+ // redirect, pipe, or call system().
1245
+ return {
1246
+ readOnly: !usesShortFlag(args, 'fE') &&
1247
+ !args.some(arg => arg.startsWith('--file') ||
1248
+ arg.startsWith('--exec') ||
1249
+ arg.includes('>') ||
1250
+ arg.includes('|') ||
1251
+ arg.includes('system')),
1252
+ signature: 'awk',
1253
+ };
1254
+ case 'rg':
1255
+ // `--pre <command>` runs a preprocessor over every file.
1256
+ return { readOnly: !args.some(arg => arg.startsWith('--pre')), signature: 'rg' };
1257
+ case 'find':
1258
+ return { readOnly: !args.some(arg => FIND_WRITE_FLAGS.has(arg)), signature: 'find' };
1259
+ case 'sort':
1260
+ return {
1261
+ readOnly: !usesShortFlag(args, 'o') &&
1262
+ !args.some(arg => arg.startsWith('--output') ||
1263
+ arg.startsWith('--compress-program') ||
1264
+ // Windows' own sort.exe spells its output switch /O.
1265
+ /^\/[oO]/.test(arg)),
1266
+ signature: 'sort',
1267
+ };
1268
+ case 'file':
1269
+ return {
1270
+ readOnly: !usesShortFlag(args, 'C') && !args.some(arg => arg.startsWith('--compile')),
1271
+ signature: 'file',
1272
+ };
1273
+ case 'tree':
1274
+ return { readOnly: !usesShortFlag(args, 'o'), signature: 'tree' };
1275
+ case 'xxd':
1276
+ // A second positional is an output file.
1277
+ return { readOnly: positional(args).length <= 1, signature: 'xxd' };
1278
+ case 'less':
1279
+ case 'more':
1280
+ // `-o` logs the input to a file; a `+` command can run anything.
1281
+ return {
1282
+ readOnly: !usesShortFlag(args, 'oO') &&
1283
+ !args.some(arg => /^--log-file/i.test(arg) || arg.startsWith('+')),
1284
+ signature: verb,
1285
+ };
1286
+ case 'printf':
1287
+ // `printf -v PATH ...` assigns a variable later commands resolve through.
1288
+ return { readOnly: !usesShortFlag(args, 'v'), signature: undefined };
1289
+ case 'history':
1290
+ // `-w` and `-a` write the history list to a file.
1291
+ return { readOnly: !usesShortFlag(args, 'wa'), signature: undefined };
1292
+ case 'jobs':
1293
+ // `jobs -x command` runs the command with job ids substituted.
1294
+ return { readOnly: !usesShortFlag(args, 'x'), signature: undefined };
1295
+ case 'alias':
1296
+ // Defining an alias rewrites what a later line's verb means.
1297
+ return { readOnly: args.every(arg => arg === '-p'), signature: undefined };
1298
+ case 'export':
1299
+ case 'declare':
1300
+ case 'typeset':
1301
+ case 'readonly':
1302
+ case 'local':
1303
+ case 'let':
1304
+ return {
1305
+ readOnly: args.every(arg => !ASSIGNMENT.test(arg) || assignmentIsHarmless(arg)),
1306
+ signature: undefined,
1307
+ };
1308
+ case 'uniq':
1309
+ return { readOnly: positional(args).length <= 1, signature: 'uniq' };
1310
+ case 'yq':
1311
+ return {
1312
+ readOnly: !usesShortFlag(args, 'i') && !args.some(arg => arg.startsWith('--inplace')),
1313
+ signature: 'yq',
1314
+ };
1315
+ case 'date':
1316
+ return {
1317
+ readOnly: !usesShortFlag(args, 's') && !args.some(arg => arg.startsWith('--set')),
1318
+ signature: 'date',
1319
+ };
1320
+ case 'hostname':
1321
+ return { readOnly: positional(args).length === 0, signature: 'hostname' };
1322
+ case 'env':
1323
+ return { readOnly: args.every(isFlag), signature: 'env' };
1324
+ case 'git': {
1325
+ const rest = gitArgs(args);
1326
+ const sub = rest?.[0];
1327
+ if (!rest || !sub)
1328
+ return { readOnly: false, signature: 'git' };
1329
+ return { readOnly: gitIsReadOnly(sub, rest.slice(1)), signature: `git ${sub.toLowerCase()}` };
1330
+ }
1331
+ case 'npm':
1332
+ case 'pnpm':
1333
+ case 'yarn':
1334
+ return npmProfile(verb, args);
1335
+ case 'npx':
1336
+ return npxProfile(args);
1337
+ case 'tsc':
1338
+ return {
1339
+ readOnly: args.some(arg => arg === '--noEmit' || VERSION_FLAGS.has(arg)) &&
1340
+ !usesShortFlag(args, 'b') &&
1341
+ !args.some(arg => arg.startsWith('--build')),
1342
+ signature: 'tsc',
1343
+ };
1344
+ case 'docker':
1345
+ return dockerProfile(args);
1346
+ case 'kubectl':
1347
+ return kubectlProfile(args);
1348
+ case 'gh':
1349
+ return ghProfile(args);
1350
+ case 'cargo':
1351
+ return subcommandProfile(verb, args, CARGO_READ_ONLY);
1352
+ case 'go':
1353
+ // `go env -w` writes the user's Go configuration.
1354
+ if (args[0] === 'env' && usesShortFlag(args, 'wu')) {
1355
+ return { readOnly: false, signature: 'go env' };
1356
+ }
1357
+ return subcommandProfile(verb, args, GO_READ_ONLY);
1358
+ case 'pip':
1359
+ case 'pip3':
1360
+ return subcommandProfile('pip', args, PIP_READ_ONLY);
1361
+ case 'dotnet':
1362
+ return subcommandProfile(verb, args, DOTNET_READ_ONLY);
1363
+ case 'java':
1364
+ return { readOnly: args.length === 1 && VERSION_FLAGS.has(args[0] ?? ''), signature: 'java' };
1365
+ case 'vitest':
1366
+ case 'jest':
1367
+ case 'mocha':
1368
+ case 'pytest': {
1369
+ const target = positional(args)[0];
1370
+ return { readOnly: false, signature: target ? `${verb} ${target.toLowerCase()}` : verb };
1371
+ }
1372
+ default:
1373
+ if (INTERPRETERS.has(verb))
1374
+ return interpreterProfile(verb, args);
1375
+ if (SIMPLE_READ_ONLY.has(verb)) {
1376
+ return { readOnly: true, signature: TRIVIAL_VERBS.has(verb) ? undefined : verb };
1377
+ }
1378
+ // A flag left in verb position (a wrapper option the peeling does not
1379
+ // model) is not a verb and must not become a signature that unrelated
1380
+ // commands would share.
1381
+ return { readOnly: false, signature: verb.startsWith('-') ? undefined : verb };
1382
+ }
1383
+ }
1384
+ function profileSegment(segment, options) {
1385
+ const words = [...segment.words];
1386
+ let readOnly = !segment.writes;
1387
+ if (options.flattened &&
1388
+ words
1389
+ .slice(1)
1390
+ .some(word => !word.quoted &&
1391
+ (HIDDEN_SIDE_EFFECT_VERBS.has(normalizeVerb(word)) ||
1392
+ (ASSIGNMENT.test(word.text) && !assignmentIsHarmless(word.text))))) {
1393
+ readOnly = false;
1394
+ }
1395
+ // Peel shell keywords, leading assignments, and transparent wrappers until
1396
+ // the verb that actually runs is at the front. An assignment is peeled
1397
+ // for signature purposes but keeps its verdict: `PATH=/x grep` and
1398
+ // `GIT_EXTERNAL_DIFF=./x git diff` both run something other than the verb.
1399
+ const peelAssignment = (word) => {
1400
+ if (!assignmentIsHarmless(word.text))
1401
+ readOnly = false;
1402
+ words.shift();
1403
+ };
1404
+ for (;;) {
1405
+ const first = words[0];
1406
+ if (!first)
1407
+ return { readOnly };
1408
+ if (!first.quoted && KEYWORDS.has(first.text)) {
1409
+ words.shift();
1410
+ continue;
1411
+ }
1412
+ // `for f in a b c`, `case $x in`: the remaining words are data.
1413
+ if (!first.quoted && ['for', 'case', 'select', 'function'].includes(first.text)) {
1414
+ return { readOnly };
1415
+ }
1416
+ if (!first.quoted && ASSIGNMENT.test(first.text)) {
1417
+ peelAssignment(first);
1418
+ continue;
1419
+ }
1420
+ // A word given by path runs whatever sits at that path, whether it is
1421
+ // the verb or a wrapper in front of it.
1422
+ if (isPathQualified(first.text))
1423
+ readOnly = false;
1424
+ const verb = normalizeVerb(first);
1425
+ if (OPAQUE_PREFIXES.has(verb))
1426
+ return { readOnly: false, signature: verb };
1427
+ if (verb === 'command' && (words[1]?.text === '-v' || words[1]?.text === '-V')) {
1428
+ return { readOnly, signature: 'command -v' };
1429
+ }
1430
+ if (verb === 'timeout') {
1431
+ words.splice(0, 2);
1432
+ continue;
1433
+ }
1434
+ if (TRANSPARENT_PREFIXES.has(verb) && words.length > 1) {
1435
+ words.shift();
1436
+ // `env -C dir`, `env -u VAR`, `env -i`, `nice -n 5`: options that
1437
+ // only reshape the environment the real verb runs in. Anything else
1438
+ // (`env -S 'cmd'`) is left in verb position and fails closed.
1439
+ if (verb === 'env' || verb === 'nice') {
1440
+ for (;;) {
1441
+ const option = words[0];
1442
+ if (!option || option.quoted || !option.text.startsWith('-'))
1443
+ break;
1444
+ if (WRAPPER_VALUE_OPTIONS.has(option.text))
1445
+ words.splice(0, 2);
1446
+ else if (WRAPPER_BARE_OPTIONS.has(option.text) ||
1447
+ /^--(?:chdir|unset|adjustment)=/.test(option.text) ||
1448
+ /^-\d+$/.test(option.text))
1449
+ words.shift();
1450
+ else
1451
+ break;
1452
+ }
1453
+ }
1454
+ while (words[0] && !words[0].quoted && ASSIGNMENT.test(words[0].text)) {
1455
+ peelAssignment(words[0]);
1456
+ }
1457
+ continue;
1458
+ }
1459
+ break;
1460
+ }
1461
+ const head = words[0];
1462
+ if (!head)
1463
+ return { readOnly };
1464
+ const verb = normalizeVerb(head);
1465
+ // For a verb whose verdict depends on its flags or how many operands it
1466
+ // gets, an argument the shell may expand into several words (`$OPTS`,
1467
+ // `*.txt`) could supply the flag or the second operand the text does not
1468
+ // show: `sort $OPTS f`, `uniq *.txt`, `git config $ARGS`. Pure readers
1469
+ // such as grep, cat and ls keep their globs.
1470
+ if (FLAG_SENSITIVE_VERBS.has(verb) && words.slice(1).some(word => word.expands)) {
1471
+ readOnly = false;
1472
+ }
1473
+ const profile = profileVerb(verb, words.slice(1).map(word => word.text));
1474
+ return { readOnly: readOnly && profile.readOnly, signature: profile.signature };
1475
+ }
1476
+ const FLAG_SENSITIVE_VERBS = new Set([
1477
+ 'sort',
1478
+ 'sed',
1479
+ 'awk',
1480
+ 'gawk',
1481
+ 'mawk',
1482
+ 'nawk',
1483
+ 'tree',
1484
+ 'file',
1485
+ 'less',
1486
+ 'more',
1487
+ 'xxd',
1488
+ 'uniq',
1489
+ 'find',
1490
+ 'rg',
1491
+ 'git',
1492
+ 'gh',
1493
+ 'docker',
1494
+ 'kubectl',
1495
+ 'npm',
1496
+ 'pnpm',
1497
+ 'yarn',
1498
+ 'npx',
1499
+ 'go',
1500
+ 'cargo',
1501
+ 'pip',
1502
+ 'pip3',
1503
+ 'dotnet',
1504
+ 'tsc',
1505
+ 'date',
1506
+ 'hostname',
1507
+ 'yq',
1508
+ 'printf',
1509
+ 'alias',
1510
+ 'export',
1511
+ 'declare',
1512
+ 'typeset',
1513
+ 'readonly',
1514
+ 'local',
1515
+ 'let',
1516
+ ]);
1517
+ // Read-only verbs that, after a pipe, only shape another command's output.
1518
+ // `npm test | grep FAIL` is about npm test; the grep is not what it does.
1519
+ const OUTPUT_FILTERS = new Set([
1520
+ 'grep',
1521
+ 'egrep',
1522
+ 'fgrep',
1523
+ 'rg',
1524
+ 'findstr',
1525
+ 'head',
1526
+ 'tail',
1527
+ 'sort',
1528
+ 'uniq',
1529
+ 'wc',
1530
+ 'cut',
1531
+ 'tr',
1532
+ 'sed',
1533
+ 'awk',
1534
+ 'cat',
1535
+ 'less',
1536
+ 'more',
1537
+ 'column',
1538
+ 'nl',
1539
+ 'tac',
1540
+ 'rev',
1541
+ 'fold',
1542
+ 'jq',
1543
+ 'strings',
1544
+ 'xxd',
1545
+ 'hexdump',
1546
+ 'od',
1547
+ ]);
1548
+ const MAX_COMMAND_CHARS = 20_000;
1549
+ /**
1550
+ * Classifies a shell command. Empty or over-long input counts as an unknown,
1551
+ * side-effecting command.
1552
+ */
1553
+ export function profileShellCommand(command, options = {}) {
1554
+ if (typeof command !== 'string' ||
1555
+ command.trim().length === 0 ||
1556
+ command.length > MAX_COMMAND_CHARS) {
1557
+ return { readOnly: false, signatures: [] };
1558
+ }
1559
+ // Bash removes a backslash-newline before it reads anything else, so `$\`
1560
+ // followed by a newline and `(` is a substitution. Do the same first.
1561
+ const parsed = splitSegments(tokenize(command.replace(/\\\r?\n/g, '')));
1562
+ let readOnly = !parsed.substitution && parsed.segments.length > 0;
1563
+ const signatures = new Set();
1564
+ for (const segment of parsed.segments) {
1565
+ const profile = profileSegment(segment, options);
1566
+ if (!profile.readOnly)
1567
+ readOnly = false;
1568
+ if (!profile.signature)
1569
+ continue;
1570
+ if (segment.consumer && OUTPUT_FILTERS.has(profile.signature))
1571
+ continue;
1572
+ signatures.add(profile.signature);
1573
+ }
1574
+ return { readOnly, signatures: [...signatures].sort() };
1575
+ }
1576
+ /**
1577
+ * Recovers the command from one of Autopilot's failure records, whose
1578
+ * content reads "Tool failure: Bash failed while attempting: input_chars=N
1579
+ * command=<command>. Error: <summary>". Undefined when the record is not
1580
+ * shaped that way (a non-shell tool, or a hand-written memory).
1581
+ */
1582
+ export function commandFromFailureRecord(content) {
1583
+ return content.match(/\bcommand=([\s\S]*?)(?:\. Error: |$)/)?.[1];
1584
+ }
1585
+ export function signaturesOverlap(a, b) {
1586
+ const left = new Set(a);
1587
+ for (const signature of b)
1588
+ if (left.has(signature))
1589
+ return true;
1590
+ return false;
1591
+ }
1592
+ //# sourceMappingURL=shell-command.js.map