@ai-dossier/core 1.12.1 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/dry-run.js CHANGED
@@ -13,6 +13,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
13
13
  exports.LEVEL_CRITICAL_MIN = exports.LEVEL_HIGH_MIN = exports.LEVEL_MEDIUM_MIN = exports.SCORE_COMMAND_CAP = exports.SCORE_PER_COMMAND = exports.SCORE_DESTRUCTIVE_DECLARATION_CAP = exports.SCORE_PER_DESTRUCTIVE_DECLARATION = exports.SCORE_RISK_FACTOR_CAP = exports.SCORE_PER_RISK_FACTOR = exports.SCORE_BASE = exports.DRY_RUN_DISCLAIMER = exports.DRY_RUN_SCHEMA_VERSION = void 0;
14
14
  exports.levelForScore = levelForScore;
15
15
  exports.analyzeDryRun = analyzeDryRun;
16
+ const dry_run_langs_1 = require("./dry-run-langs");
16
17
  const parser_1 = require("./parser");
17
18
  exports.DRY_RUN_SCHEMA_VERSION = 1;
18
19
  exports.DRY_RUN_DISCLAIMER = "Static preview - derived from the dossier's declared metadata and code blocks; the executing agent may take other actions. The risk score is a heuristic, not a safety guarantee.";
@@ -51,7 +52,72 @@ function levelForScore(score) {
51
52
  return 'low';
52
53
  }
53
54
  const SHELL_LANGS = new Set(['sh', 'bash', 'shell', 'zsh', 'console', 'shell-session', 'terminal']);
54
- const SCRIPT_LANGS = new Set(['js', 'javascript', 'ts', 'typescript', 'mjs', 'node']);
55
+ const CODE_LANGS = {
56
+ js: 'js',
57
+ javascript: 'js',
58
+ ts: 'js',
59
+ typescript: 'js',
60
+ mjs: 'js',
61
+ cjs: 'js',
62
+ node: 'js',
63
+ jsx: 'js',
64
+ tsx: 'js',
65
+ python: 'python',
66
+ python3: 'python',
67
+ py: 'python',
68
+ py3: 'python',
69
+ powershell: 'powershell',
70
+ pwsh: 'powershell',
71
+ ps1: 'powershell',
72
+ ps: 'powershell',
73
+ posh: 'powershell',
74
+ bat: 'powershell',
75
+ batch: 'powershell',
76
+ cmd: 'powershell',
77
+ dockerfile: 'dockerfile',
78
+ containerfile: 'dockerfile',
79
+ ruby: 'ruby',
80
+ rb: 'ruby',
81
+ perl: 'perl',
82
+ pl: 'perl',
83
+ sql: 'sql',
84
+ psql: 'sql',
85
+ mysql: 'sql',
86
+ pgsql: 'sql',
87
+ sqlite: 'sql',
88
+ yaml: 'yaml',
89
+ yml: 'yaml',
90
+ };
91
+ /** Fence languages that hold data / prose / diagrams, not something an agent executes. */
92
+ const INERT_LANGS = new Set([
93
+ 'json',
94
+ 'jsonc',
95
+ 'json5',
96
+ 'toml',
97
+ 'ini',
98
+ 'xml',
99
+ 'html',
100
+ 'css',
101
+ 'md',
102
+ 'markdown',
103
+ 'text',
104
+ 'txt',
105
+ 'plaintext',
106
+ 'diff',
107
+ 'patch',
108
+ 'mermaid',
109
+ 'csv',
110
+ 'http',
111
+ 'env',
112
+ 'dotenv',
113
+ 'output',
114
+ 'log',
115
+ 'graphql',
116
+ 'gql',
117
+ 'svg',
118
+ 'tree',
119
+ 'math',
120
+ ]);
55
121
  function extractFences(body) {
56
122
  const fences = [];
57
123
  const lines = body.split('\n');
@@ -102,13 +168,271 @@ function endsInsideQuote(s) {
102
168
  }
103
169
  /** Longest quoted span joined into one logical line before giving up (stray apostrophes in prose). */
104
170
  const MAX_QUOTED_LINES = 40;
171
+ /** Blank `((...))` / `$((...))` arithmetic so a `<<` shift inside it is not read as a heredoc. */
172
+ function blankArithmetic(s) {
173
+ const chars = s.split('');
174
+ let i = 0;
175
+ while (i < chars.length - 1) {
176
+ if (chars[i] === '(' && chars[i + 1] === '(') {
177
+ let depth = 0;
178
+ let j = i;
179
+ for (; j < chars.length; j++) {
180
+ if (chars[j] === '(')
181
+ depth++;
182
+ else if (chars[j] === ')' && --depth === 0)
183
+ break;
184
+ }
185
+ if (j >= chars.length)
186
+ break;
187
+ for (let k = i; k <= j; k++)
188
+ chars[k] = ' ';
189
+ i = j + 1;
190
+ }
191
+ else {
192
+ i++;
193
+ }
194
+ }
195
+ return chars.join('');
196
+ }
197
+ const SQL_CLIENTS = new Set([
198
+ 'psql',
199
+ 'mysql',
200
+ 'mariadb',
201
+ 'sqlite3',
202
+ 'mongosh',
203
+ 'mongo',
204
+ 'redis-cli',
205
+ ]);
206
+ /** Tools that run a remote / contained command line, so `tool ... <<EOF` feeds that shell. */
207
+ const EXEC_HOSTS = new Set([
208
+ 'docker',
209
+ 'podman',
210
+ 'kubectl',
211
+ 'nsenter',
212
+ 'chroot',
213
+ 'lxc',
214
+ 'incus',
215
+ 'fly',
216
+ 'flyctl',
217
+ 'oc',
218
+ 'su',
219
+ ]);
220
+ function interpKind(exe) {
221
+ const e = exe.toLowerCase();
222
+ if (/^(?:sh|bash|zsh|dash|ksh|ash|fish|csh|tcsh|mksh|rbash)$/.test(e))
223
+ return 'shell';
224
+ if (/^python[\d.]*$/.test(e))
225
+ return 'python';
226
+ if (/^(?:node|nodejs|deno|bun|tsx|ts-node)$/.test(e))
227
+ return 'js';
228
+ if (/^ruby[\d.]*$/.test(e))
229
+ return 'ruby';
230
+ if (/^perl[\d.]*$/.test(e))
231
+ return 'perl';
232
+ if (/^(?:pwsh|powershell)(?:\.exe)?$/.test(e))
233
+ return 'powershell';
234
+ if (/^(?:php[\d.]*|lua[\d.]*|rscript|osascript|tclsh|expect|julia|groovy)$/.test(e))
235
+ return 'other';
236
+ return null;
237
+ }
238
+ const CODE_FLAG = {
239
+ shell: /^-[A-Za-z]*c$/,
240
+ python: /^-c$/,
241
+ js: /^(?:-e|--eval|-p|--print)$/,
242
+ ruby: /^-[eE]$/,
243
+ perl: /^-[eE]$/,
244
+ powershell: /^-(?:c|command|e|enc|encodedcommand)$/i,
245
+ other: /^-r$/,
246
+ };
247
+ const v = (...f) => new Set(f);
248
+ const WRAPPERS = {
249
+ sudo: {
250
+ valued: v('-u', '-g', '-h', '-p', '-C', '-r', '-t', '-U', '-D', '-R', '--user', '--group', '--host', '--prompt', '--role', '--type', '--chdir', '--close-from'),
251
+ },
252
+ doas: { valued: v('-u', '-C') },
253
+ runuser: { valued: v('-u', '-g', '-l', '-c') },
254
+ env: { valued: v('-u', '-C', '--unset', '--chdir') },
255
+ nohup: { valued: v() },
256
+ time: { valued: v('-f', '-o', '--format', '--output') },
257
+ exec: { valued: v('-a') },
258
+ command: { valued: v(), noExec: v('-v', '-V') },
259
+ builtin: { valued: v() },
260
+ busybox: { valued: v() },
261
+ nice: { valued: v('-n', '--adjustment') },
262
+ ionice: { valued: v('-c', '-n', '-p', '-P', '-u') },
263
+ timeout: { valued: v('-s', '-k', '--signal', '--kill-after'), positional: 1 },
264
+ stdbuf: { valued: v('-i', '-o', '-e') },
265
+ setsid: { valued: v() },
266
+ fakeroot: { valued: v() },
267
+ proxychains: { valued: v('-f') },
268
+ proxychains4: { valued: v('-f') },
269
+ torsocks: { valued: v() },
270
+ unbuffer: { valued: v() },
271
+ chronic: { valued: v() },
272
+ unshare: { valued: v() },
273
+ chroot: { valued: v(), positional: 1 },
274
+ flock: { valued: v('-w', '-E', '-c'), positional: 1 },
275
+ watch: { valued: v('-n', '--interval') },
276
+ xargs: {
277
+ valued: v('-a', '-d', '-E', '-I', '-L', '-n', '-P', '-s', '--arg-file', '--delimiter', '--eof', '--replace', '--max-lines', '--max-args', '--max-procs', '--max-chars'),
278
+ },
279
+ parallel: { valued: v('-j', '-n', '-N', '-a', '-S', '--jobs', '--max-args'), stopAt: ':::' },
280
+ };
281
+ /** Tokens of the command wrapped by `sudo` / `env` / `xargs` / ..., or null when tokens[0] is no wrapper. */
282
+ function unwrap(tokens) {
283
+ const exe = tokens[0]?.replace(/^.*\//, '');
284
+ const spec = exe ? WRAPPERS[exe] : undefined;
285
+ if (!spec)
286
+ return null;
287
+ let i = 1;
288
+ let positional = spec.positional ?? 0;
289
+ while (i < tokens.length) {
290
+ const t = tokens[i];
291
+ if (spec.noExec?.has(t))
292
+ return [];
293
+ if (t === '--') {
294
+ i++;
295
+ break;
296
+ }
297
+ if (t.length > 1 && t.startsWith('-')) {
298
+ const combined = /^-[A-Za-z]{2,}$/.test(t) && spec.valued.has(`-${t[t.length - 1]}`);
299
+ i += spec.valued.has(t) || combined ? 2 : 1;
300
+ continue;
301
+ }
302
+ if (exe === 'env' && /^[A-Za-z_][A-Za-z0-9_]*=/.test(t)) {
303
+ i++;
304
+ continue;
305
+ }
306
+ if (positional > 0) {
307
+ positional--;
308
+ i++;
309
+ continue;
310
+ }
311
+ break;
312
+ }
313
+ let inner = tokens.slice(i);
314
+ if (spec.stopAt) {
315
+ const stop = inner.findIndex((t) => t === spec.stopAt || t === '::::');
316
+ if (stop >= 0)
317
+ inner = inner.slice(0, stop);
318
+ }
319
+ return inner;
320
+ }
321
+ /** Unwrap wrapper chains (`sudo -E env nohup bash -`) down to the real command. */
322
+ function unwrapAll(tokens) {
323
+ let cur = tokens;
324
+ for (let n = 0; n < 8; n++) {
325
+ const inner = unwrap(cur);
326
+ if (!inner)
327
+ break;
328
+ cur = inner;
329
+ }
330
+ return cur;
331
+ }
332
+ /** The interpreter a command feeds from stdin (`bash`, `sudo -E bash -`, `python3 -`), or null. */
333
+ function stdinTargetOf(tokensIn) {
334
+ const tokens = unwrapAll(tokensIn);
335
+ if (tokens.length === 0)
336
+ return null;
337
+ const first = tokens[0].replace(/^.*\//, '');
338
+ if ((first === 'source' || first === '.') && tokens[1] === '/dev/stdin')
339
+ return 'shell';
340
+ const ik = interpKind(first);
341
+ if (!ik)
342
+ return null;
343
+ const target = ik === 'other' ? 'shell' : ik;
344
+ const args = [];
345
+ for (let i = 1; i < tokens.length; i++) {
346
+ // Redirections (`< file`, `> out`, `2>&1`) are not arguments.
347
+ if (/^\d*[<>]/.test(tokens[i])) {
348
+ if (/^\d*[<>]+$/.test(tokens[i]))
349
+ i++;
350
+ continue;
351
+ }
352
+ args.push(tokens[i]);
353
+ }
354
+ const codeIdx = args.findIndex((a) => CODE_FLAG[ik].test(a));
355
+ if (codeIdx >= 0) {
356
+ // `pwsh -Command -`, or `xargs sh -c` (the wrapper supplies the code): the program comes from stdin.
357
+ return args[codeIdx + 1] === undefined || args[codeIdx + 1] === '-' ? target : null;
358
+ }
359
+ if (args.some((a) => a === '-s' || a === '-' || a === '/dev/stdin' || a === '/proc/self/fd/0'))
360
+ return target;
361
+ return args.every((a) => a.startsWith('-')) ? target : null;
362
+ }
363
+ /** What a heredoc/pipe fed into this command line ends up executing, if anything. */
364
+ function execTargetOf(cmdText) {
365
+ const tokens = unwrapAll(stripAssignments(tokenize(cmdText)));
366
+ if (tokens.length === 0)
367
+ return null;
368
+ const exe = tokens[0].replace(/^.*\//, '');
369
+ const direct = stdinTargetOf(tokens);
370
+ if (direct)
371
+ return direct;
372
+ if (SQL_CLIENTS.has(exe))
373
+ return 'sql';
374
+ if (exe === 'ssh')
375
+ return 'shell';
376
+ if (/^(?:g?make|gmake)$/.test(exe) &&
377
+ tokens.some((t, i) => i > 0 && (t === '/dev/stdin' || t === '-') && /^-\w*f$|^--file$/.test(tokens[i - 1])))
378
+ return 'shell';
379
+ if (EXEC_HOSTS.has(exe)) {
380
+ for (const t of tokens.slice(1)) {
381
+ const ik = interpKind(t.replace(/^.*\//, ''));
382
+ if (ik && ik !== 'other')
383
+ return ik;
384
+ }
385
+ }
386
+ return null;
387
+ }
388
+ function findHeredocs(text) {
389
+ const masked = maskQuotes(blankArithmetic(text));
390
+ const out = [];
391
+ for (let i = masked.indexOf('<<'); i !== -1; i = masked.indexOf('<<', i + 2)) {
392
+ if (masked[i + 2] === '<' || masked[i - 1] === '<')
393
+ continue; // here-string `<<<`
394
+ const m = /^-?\s*(?:'([A-Za-z_]\w*)'|"([A-Za-z_]\w*)"|\\?([A-Za-z_]\w*))/.exec(text.slice(i + 2, i + 202));
395
+ if (!m)
396
+ continue;
397
+ out.push({ id: (m[1] ?? m[2] ?? m[3]), index: i, end: i + 2 + m[0].length });
398
+ }
399
+ return out;
400
+ }
401
+ /** Decide whether the heredoc opened at `h` feeds an interpreter (owner command or a later pipe stage). */
402
+ function heredocExec(text, masked, h) {
403
+ let s = h.index;
404
+ while (s > 0 && !';|&(`'.includes(masked[s - 1]))
405
+ s--;
406
+ const owner = execTargetOf(text.slice(s, h.index));
407
+ if (owner)
408
+ return owner;
409
+ let e = h.end;
410
+ while (e < masked.length && !';&)'.includes(masked[e]))
411
+ e++;
412
+ const tailMasked = masked.slice(h.end, e);
413
+ const tail = text.slice(h.end, e);
414
+ let from = 0;
415
+ for (let p = tailMasked.indexOf('|'); p !== -1; p = tailMasked.indexOf('|', from)) {
416
+ let next = tailMasked.indexOf('|', p + 1);
417
+ if (next === p + 1)
418
+ next = tailMasked.indexOf('|', p + 2);
419
+ const seg = tail.slice(p + 1, next === -1 ? undefined : next).replace(/^[|&]+/, '');
420
+ from = p + 1;
421
+ const t = execTargetOf(seg);
422
+ if (t)
423
+ return t;
424
+ }
425
+ return null;
426
+ }
105
427
  /** Join `\`-continued lines and multi-line quoted strings, keeping the number of the first physical line. */
106
428
  function logicalLines(fence) {
107
429
  const out = [];
108
430
  let acc = '';
109
431
  let accLine = 0;
110
432
  let accCount = 0;
111
- fence.lines.forEach((raw, idx) => {
433
+ const lines = fence.lines;
434
+ for (let idx = 0; idx < lines.length; idx++) {
435
+ const raw = lines[idx];
112
436
  const lineNo = fence.startLine + idx;
113
437
  if (!acc) {
114
438
  accLine = lineNo;
@@ -116,19 +440,41 @@ function logicalLines(fence) {
116
440
  }
117
441
  accCount++;
118
442
  if (/\\\s*$/.test(raw)) {
119
- acc += `${raw.replace(/\\\s*$/, '')} `;
120
- return;
443
+ acc += raw.replace(/\\\s*$/, '');
444
+ continue;
121
445
  }
122
446
  const joined = acc + raw;
123
447
  if (endsInsideQuote(joined) && accCount < MAX_QUOTED_LINES) {
124
- acc = `${joined} `;
125
- return;
448
+ acc = `${joined}\n`;
449
+ continue;
126
450
  }
127
- out.push({ text: joined.trim(), line: accLine });
451
+ const text = joined.trim();
128
452
  acc = '';
129
- });
453
+ const heredocs = [];
454
+ if (text && !text.startsWith('#')) {
455
+ const found = findHeredocs(text);
456
+ const masked = found.length > 0 ? maskQuotes(blankArithmetic(text)) : '';
457
+ for (const h of found) {
458
+ // A heredoc already closed inside a joined multi-line quote needs no body.
459
+ if (new RegExp(`\\b${h.id}\\b`).test(text.slice(h.end)))
460
+ continue;
461
+ const exec = heredocs.length >= MAX_HEREDOCS_PER_LINE ? 'shell' : heredocExec(text, masked, h);
462
+ const body = [];
463
+ const bodyLine = fence.startLine + idx + 1;
464
+ let j = idx + 1;
465
+ for (; j < lines.length; j++) {
466
+ if (lines[j].trim() === h.id)
467
+ break;
468
+ body.push(lines[j]);
469
+ }
470
+ heredocs.push({ exec, body, bodyLine });
471
+ idx = Math.min(j, lines.length - 1);
472
+ }
473
+ }
474
+ out.push({ text, line: accLine, heredocs });
475
+ }
130
476
  if (acc.trim())
131
- out.push({ text: acc.trim(), line: accLine });
477
+ out.push({ text: acc.trim(), line: accLine, heredocs: [] });
132
478
  return out;
133
479
  }
134
480
  /** Replace quoted string contents with spaces so operators inside quotes are ignored. */
@@ -158,20 +504,101 @@ function maskQuotes(input) {
158
504
  }
159
505
  return out;
160
506
  }
161
- /** Split a shell line into simple commands on `&&`, `||`, `;`, `|` (quote-aware). */
162
- function splitCommands(line) {
163
- const masked = maskQuotes(line);
164
- const parts = [];
507
+ /** Split a shell line into simple commands on `&&`, `||`, `;`, `|`, `&`, `$(`, backticks, parens (quote-aware). */
508
+ /** Blank `[[ ... ]]` test expressions: `&&` / `||` / `<` inside them are operators, not command separators. */
509
+ function blankTests(s) {
510
+ let out = s;
511
+ let from = 0;
512
+ for (let n = 0; n < 200; n++) {
513
+ const open = out.indexOf('[[', from);
514
+ if (open < 0)
515
+ break;
516
+ const close = out.indexOf(']]', open + 2);
517
+ if (close < 0)
518
+ break;
519
+ out = out.slice(0, open + 2) + ' '.repeat(close - open - 2) + out.slice(close);
520
+ from = close + 2;
521
+ }
522
+ return out;
523
+ }
524
+ function splitSegments(line) {
525
+ const masked = blankTests(maskQuotes(line));
526
+ const out = [];
165
527
  let last = 0;
166
- const re = /&&|\|\||;|\||\$\(|`|\(|\)/g;
528
+ let sep = '';
529
+ let group = 0;
530
+ const re = /&&|\|\||\|&|\||;|(?<![<>&\d])&(?![>&\d-])|\$\(|`|(?<!=)\(|\)/g;
531
+ const push = (end) => {
532
+ const text = line.slice(last, end).trim();
533
+ if (text)
534
+ out.push({ text, sep, group });
535
+ };
167
536
  let m = re.exec(masked);
168
537
  while (m !== null) {
169
- parts.push(line.slice(last, m.index));
538
+ push(m.index);
170
539
  last = m.index + m[0].length;
540
+ sep = m[0];
541
+ if (sep !== '|' && sep !== '|&')
542
+ group++;
171
543
  m = re.exec(masked);
172
544
  }
173
- parts.push(line.slice(last));
174
- return parts.map((p) => p.trim()).filter(Boolean);
545
+ push(line.length);
546
+ return out;
547
+ }
548
+ /** Index of the `)` matching the `(` at `open` (naive depth count), or text.length. */
549
+ function matchParen(text, open) {
550
+ let depth = 0;
551
+ for (let i = open; i < text.length; i++) {
552
+ if (text[i] === '(')
553
+ depth++;
554
+ else if (text[i] === ')' && --depth === 0)
555
+ return i;
556
+ }
557
+ return text.length;
558
+ }
559
+ /** `$(...)` and backtick command substitutions inside double quotes, which quote-masking hides. */
560
+ function dqSubstitutions(text) {
561
+ const out = [];
562
+ let quote = null;
563
+ let i = 0;
564
+ while (i < text.length && out.length < 50) {
565
+ const c = text[i];
566
+ if (quote === "'") {
567
+ if (c === "'")
568
+ quote = null;
569
+ i++;
570
+ }
571
+ else if (c === '\\') {
572
+ i += 2;
573
+ }
574
+ else if (quote === '"') {
575
+ if (c === '"') {
576
+ quote = null;
577
+ i++;
578
+ }
579
+ else if (c === '$' && text[i + 1] === '(' && text[i + 2] !== '(') {
580
+ const end = matchParen(text, i + 1);
581
+ out.push(text.slice(i + 2, end));
582
+ i = end + 1;
583
+ }
584
+ else if (c === '`') {
585
+ const end = text.indexOf('`', i + 1);
586
+ if (end < 0)
587
+ break;
588
+ out.push(text.slice(i + 1, end));
589
+ i = end + 1;
590
+ }
591
+ else {
592
+ i++;
593
+ }
594
+ }
595
+ else {
596
+ if (c === '"' || c === "'")
597
+ quote = c;
598
+ i++;
599
+ }
600
+ }
601
+ return out;
175
602
  }
176
603
  function tokenize(cmd) {
177
604
  const tokens = [];
@@ -211,6 +638,19 @@ const SHELL_KEYWORDS = new Set([
211
638
  ')',
212
639
  '!',
213
640
  ]);
641
+ /** Keywords that introduce a command on the same segment (`then rm -rf ~`, `do rm $f`). */
642
+ const LEADING_KEYWORDS = new Set([
643
+ 'then',
644
+ 'do',
645
+ 'else',
646
+ 'elif',
647
+ 'if',
648
+ 'while',
649
+ 'until',
650
+ '!',
651
+ '{',
652
+ 'coproc',
653
+ ]);
214
654
  const READ_ONLY = new Set([
215
655
  'ls',
216
656
  'cat',
@@ -232,7 +672,6 @@ const READ_ONLY = new Set([
232
672
  'tr',
233
673
  'jq',
234
674
  'yq',
235
- 'awk',
236
675
  'diff',
237
676
  'pwd',
238
677
  'cd',
@@ -246,7 +685,6 @@ const READ_ONLY = new Set([
246
685
  'realpath',
247
686
  'stat',
248
687
  'file',
249
- 'env',
250
688
  'export',
251
689
  'set',
252
690
  'unset',
@@ -254,7 +692,6 @@ const READ_ONLY = new Set([
254
692
  'true',
255
693
  'false',
256
694
  'sleep',
257
- 'xargs',
258
695
  'tree',
259
696
  'du',
260
697
  'df',
@@ -263,8 +700,6 @@ const READ_ONLY = new Set([
263
700
  'uname',
264
701
  'id',
265
702
  'nproc',
266
- 'source',
267
- '.',
268
703
  'local',
269
704
  'declare',
270
705
  'shift',
@@ -300,6 +735,22 @@ const REMOTE_TOOLS = new Set([
300
735
  'dig',
301
736
  'nslookup',
302
737
  ]);
738
+ /** Commands whose stdout is content fetched from elsewhere (a pipe from them into a shell is remote code execution). */
739
+ const FETCH_TOOLS = new Set([
740
+ 'curl',
741
+ 'wget',
742
+ 'fetch',
743
+ 'aria2c',
744
+ 'nc',
745
+ 'ncat',
746
+ 'socat',
747
+ 'ssh',
748
+ 'lynx',
749
+ 'w3m',
750
+ 'http',
751
+ 'https',
752
+ 'xh',
753
+ ]);
303
754
  const PACKAGE_MANAGERS = new Set(['npm', 'pnpm', 'yarn', 'npx', 'pnpx', 'pip', 'pip3', 'cargo']);
304
755
  const LOCAL_WRITE_TOOLS = new Set([
305
756
  'mkdir',
@@ -334,7 +785,25 @@ const LOCAL_WRITE_TOOLS = new Set([
334
785
  'ai-dossier',
335
786
  'dossier',
336
787
  ]);
337
- const DESTRUCTIVE_VERB = /^(delete|destroy|terminate|rm|remove|drop|purge|prune|uninstall|deregister)$/;
788
+ /** Always-destructive executables (disk / system level). */
789
+ const DESTRUCTIVE_TOOLS = new Set([
790
+ 'shred',
791
+ 'wipefs',
792
+ 'fdisk',
793
+ 'sfdisk',
794
+ 'parted',
795
+ 'sgdisk',
796
+ 'mkswap',
797
+ 'shutdown',
798
+ 'reboot',
799
+ 'halt',
800
+ 'poweroff',
801
+ 'userdel',
802
+ 'groupdel',
803
+ 'killall',
804
+ ]);
805
+ const DESTRUCTIVE_VERB = /^(?:delete|destroy|terminate|rm|rb|remove|drop|purge|prune|uninstall|deregister|revoke|unpublish|wipe|erase|truncate|flush)(?:-[a-z0-9-]+)?$/i;
806
+ const DB_DESTRUCTIVE = /\bdrop\s+(?:table|database|schema|user|index|collection)\b|\btruncate\b|\bdelete\s+from\b|dropDatabase|\.drop\s*\(|\bflush(?:all|db)\b|deleteMany\s*\(\s*\{\s*\}\s*\)/i;
338
807
  const GH_READ_VERBS = new Set([
339
808
  'view',
340
809
  'list',
@@ -376,6 +845,16 @@ const GIT_READ = new Set([
376
845
  'range-diff',
377
846
  ]);
378
847
  const GIT_REMOTE_READ = new Set(['fetch', 'pull', 'clone', 'ls-remote']);
848
+ /** Options whose value is a shell command (`tar --to-command=...`, `git --upload-pack=...`). */
849
+ const CMD_VALUED_OPTION = /^--(?:to-command|checkpoint-action|use-compress-program|upload-pack|receive-pack|rsh|ssh-command|editor|pager|exec)=(.+)$/;
850
+ /** Tokens that plausibly start a command line run inside `ssh` / `docker exec` / `kubectl exec`. */
851
+ const EXEC_HINT = /^(?:rm|rmdir|shred|dd|mkfs\S*|sh|bash|zsh|dash|ash|python[\d.]*|node|perl|ruby|curl|wget|git|kill|pkill|chmod|chown|truncate|mv|find|sudo|env|xargs|eval|nohup|systemctl|service|apt|apt-get|yum|dnf|apk|npm|pnpm|yarn|docker|kubectl|psql|mysql|mongosh|redis-cli|crontab|iptables|tar|cp)$/;
852
+ function stripAssignments(tokens) {
853
+ let i = 0;
854
+ while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i]))
855
+ i++;
856
+ return tokens.slice(i);
857
+ }
379
858
  function stripEnvAssignments(tokens) {
380
859
  let i = 0;
381
860
  while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i]))
@@ -391,40 +870,128 @@ function hostOf(url) {
391
870
  function nonFlags(tokens) {
392
871
  return tokens.filter((t) => !t.startsWith('-'));
393
872
  }
873
+ const FETCH_SUBST = /(?:\$\(|`|<\()\s*(?:[\w./-]+\/)?(?:curl|wget|fetch|aria2c)\b|(?:\$\(|`)[^)`]*\b(?:base64\s+(?:-d|-D|--decode)|xxd\s+-r)\b/;
874
+ const REMOTE_EXEC_REASON = 'remote code execution: fetched content is executed';
394
875
  /** Classify a single simple command (already split from pipelines). */
395
876
  function classifyCommand(cmd, rawLine) {
396
- const tokens = stripEnvAssignments(tokenize(cmd));
877
+ let tokens = stripAssignments(tokenize(cmd));
878
+ while (tokens.length > 0 && LEADING_KEYWORDS.has(tokens[0]))
879
+ tokens = tokens.slice(1);
880
+ if (tokens[0] === 'function') {
881
+ const brace = tokens.indexOf('{');
882
+ tokens = brace >= 0 ? tokens.slice(brace + 1) : [];
883
+ }
397
884
  if (tokens.length === 0)
398
885
  return null;
399
- const exe = tokens[0].replace(/^.*\//, '');
400
- const args = tokens.slice(1);
401
- if (SHELL_KEYWORDS.has(exe) || exe.startsWith('#'))
886
+ if (/\s/.test(tokens[0]))
887
+ tokens = tokenize(tokens.join(' '));
888
+ if (tokens.length === 0)
889
+ return null;
890
+ // `\rm`, `r\m`: backslashes in the command name are only quoting.
891
+ tokens = [tokens[0].replace(/\\(.)/g, '$1'), ...tokens.slice(1)];
892
+ const exe0 = tokens[0].replace(/^.*\//, '');
893
+ if (SHELL_KEYWORDS.has(exe0) || exe0.startsWith('#'))
402
894
  return null;
403
895
  // Pure variable assignment (`X=$(...)`) has no executable of its own.
404
896
  if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0]))
405
897
  return null;
898
+ // Classify what the wrapper (`sudo`, `env`, `xargs`, ...) really runs.
899
+ let inner = tokens;
900
+ for (let n = 0; n < 8; n++) {
901
+ const u = unwrap(inner);
902
+ if (!u)
903
+ break;
904
+ inner = u;
905
+ while (inner.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(inner[0]))
906
+ inner = inner.slice(1);
907
+ if (inner.length > 0 && /\s/.test(inner[0]))
908
+ inner = tokenize(inner.join(' '));
909
+ if (inner.length > 0)
910
+ inner = [inner[0].replace(/\\(.)/g, '$1'), ...inner.slice(1)];
911
+ }
912
+ if (inner.length === 0) {
913
+ return { kind: 'read', recognized: true, files: [], nested: [], inner, stdinTarget: null };
914
+ }
915
+ const exe = inner[0].replace(/^.*\//, '');
916
+ const args = inner.slice(1);
406
917
  const files = [];
918
+ const nested = [];
407
919
  let kind = 'read';
408
920
  let recognized = true;
409
921
  let network;
922
+ let reason;
923
+ let execFile;
924
+ let opaque;
925
+ const opq = (why) => {
926
+ opaque ??= why;
927
+ };
410
928
  const bump = (k) => {
411
929
  const order = ['read', 'local_write', 'remote', 'destructive'];
412
930
  if (order.indexOf(k) > order.indexOf(kind))
413
931
  kind = k;
414
932
  };
415
- // Output redirection (`> file`, `>> file`), ignoring quoted operators and /dev/*.
933
+ const destructive = (why) => {
934
+ bump('destructive');
935
+ if (why && !reason)
936
+ reason = why;
937
+ };
938
+ // Output redirection (`> file`, `>> file`), ignoring quoted operators and /dev/null-style sinks.
416
939
  const masked = maskQuotes(rawLine);
417
940
  const redir = /(?:^|[^<>&\d-])\d?>>?\s*([^\s|&;<>()]+)/g;
418
941
  let rm = redir.exec(masked);
419
942
  while (rm !== null) {
420
943
  // Recover the unmasked target from the original line.
421
944
  const target = rawLine.slice(rm.index + rm[0].length - rm[1].length, rm.index + rm[0].length);
422
- if (!target.startsWith('/dev/') && !target.startsWith('&')) {
945
+ if (/^\/dev\/(?:sd|hd|nvme|vd|xvd|disk|mmcblk|mapper)/.test(target)) {
946
+ files.push({ path: target, operation: 'write' });
947
+ destructive('overwrites a block device');
948
+ }
949
+ else if (!target.startsWith('/dev/') && !target.startsWith('&')) {
423
950
  files.push({ path: target.replace(/^["']|["']$/g, ''), operation: 'write' });
424
951
  bump('local_write');
425
952
  }
426
953
  rm = redir.exec(masked);
427
954
  }
955
+ // Command word we cannot resolve statically: `$X -rf ~`, `${X}`, `$'\x72m'`, `{rm,-rf,~}`, `rm${IFS}-rf`.
956
+ if (/^\$|^\{[^}]*,/.test(inner[0]) || /\$\{?IFS\b/.test(cmd)) {
957
+ opq('command name comes from an expansion (variable, ANSI-C quote, brace expansion or $IFS)');
958
+ }
959
+ const inRedir = /(?:^|[^<>&\d])<\s*([^\s|&;<>()]+)/.exec(masked);
960
+ const inputFile = inRedir
961
+ ? rawLine.slice(inRedir.index + inRedir[0].length - inRedir[1].length, inRedir.index + inRedir[0].length)
962
+ : undefined;
963
+ for (let ai = 0; ai < args.length; ai++) {
964
+ const a = args[ai];
965
+ const m = CMD_VALUED_OPTION.exec(a);
966
+ if (m)
967
+ nested.push({ text: m[1].replace(/^exec=/, ''), lang: 'shell' });
968
+ else if (/^--(?:to-command|checkpoint-action|use-compress-program|upload-pack|receive-pack|rsh|ssh-command|editor|pager|exec)$/.test(a) ||
969
+ (exe === 'rsync' && a === '-e')) {
970
+ if (args[ai + 1])
971
+ nested.push({ text: args[ai + 1].replace(/^exec=/, ''), lang: 'shell' });
972
+ }
973
+ else if (exe === 'ssh' || exe === 'scp' || exe === 'sftp') {
974
+ const om = /^-o(?:\s*(.*))?$/.exec(a);
975
+ const val = om ? om[1] || args[ai + 1] || '' : '';
976
+ const pm = /^(?:ProxyCommand|LocalCommand)\s*[= ]\s*(.+)$/i.exec(val);
977
+ if (pm)
978
+ nested.push({ text: pm[1], lang: 'shell' });
979
+ }
980
+ }
981
+ const nestedFrom = (text, lang = 'shell') => {
982
+ if (text.trim())
983
+ nested.push({ text, lang });
984
+ };
985
+ /** First token of an exec host's trailing command line (`ssh h "rm -rf /"`, `kubectl exec p -- rm -rf /`). */
986
+ const nestedAfterHost = () => {
987
+ const dd = args.indexOf('--');
988
+ let start = dd >= 0 ? dd + 1 : -1;
989
+ if (start < 0) {
990
+ start = args.findIndex((a, i) => i > 0 || dd < 0 ? EXEC_HINT.test(a.split(/\s+/)[0].replace(/^.*\//, '')) : false);
991
+ }
992
+ if (start >= 0)
993
+ nestedFrom(args.slice(start).join(' '));
994
+ };
428
995
  switch (exe) {
429
996
  case 'rm':
430
997
  case 'rmdir':
@@ -432,13 +999,18 @@ function classifyCommand(cmd, rawLine) {
432
999
  case 'shred':
433
1000
  for (const p of nonFlags(args))
434
1001
  files.push({ path: p, operation: 'delete' });
435
- bump('destructive');
1002
+ destructive();
436
1003
  break;
437
- case 'mv':
438
- for (const p of nonFlags(args))
1004
+ case 'mv': {
1005
+ const paths = nonFlags(args);
1006
+ for (const p of paths)
439
1007
  files.push({ path: p, operation: 'move' });
440
- bump('local_write');
1008
+ if (paths.length > 1 && paths[paths.length - 1] === '/dev/null')
1009
+ destructive();
1010
+ else
1011
+ bump('local_write');
441
1012
  break;
1013
+ }
442
1014
  case 'tee':
443
1015
  for (const p of nonFlags(args))
444
1016
  files.push({ path: p, operation: 'write' });
@@ -459,6 +1031,42 @@ function classifyCommand(cmd, rawLine) {
459
1031
  files.push({ path: p, operation: 'write' });
460
1032
  bump('local_write');
461
1033
  break;
1034
+ case 'dd': {
1035
+ const of = args.find((a) => a.startsWith('of='));
1036
+ if (of) {
1037
+ files.push({ path: of.slice(3), operation: 'write' });
1038
+ if (of.slice(3).startsWith('/dev/'))
1039
+ destructive('overwrites a block device');
1040
+ else
1041
+ bump('local_write');
1042
+ }
1043
+ else
1044
+ bump('local_write');
1045
+ break;
1046
+ }
1047
+ case 'truncate':
1048
+ if (args.some((a) => a === '-s0' || a === '--size=0' || a === '0'))
1049
+ destructive();
1050
+ else
1051
+ bump('local_write');
1052
+ break;
1053
+ case 'chmod':
1054
+ case 'chown':
1055
+ case 'chgrp': {
1056
+ const recursive = args.some((a) => a === '--recursive' || /^-[A-Za-z]*R[A-Za-z]*$/.test(a));
1057
+ const targets = nonFlags(args).slice(1);
1058
+ if (recursive && targets.some((t) => /^(?:\/|~|\$HOME|\/\*|\*|\.|\.\.)$/.test(t)))
1059
+ destructive('recursive permission change on a broad path');
1060
+ else
1061
+ bump('local_write');
1062
+ break;
1063
+ }
1064
+ case 'crontab':
1065
+ if (args.includes('-r'))
1066
+ destructive();
1067
+ else
1068
+ bump('local_write');
1069
+ break;
462
1070
  case 'sed':
463
1071
  if (args.some((a) => /^-[a-zA-Z]*i/.test(a) || a === '--in-place')) {
464
1072
  const paths = nonFlags(args);
@@ -466,19 +1074,110 @@ function classifyCommand(cmd, rawLine) {
466
1074
  files.push({ path: paths[paths.length - 1], operation: 'write' });
467
1075
  bump('local_write');
468
1076
  }
1077
+ for (const a of args) {
1078
+ const em = /(?:^|;)\s*(?:\d+|\$)?\s*e\s+(\S.*)$/.exec(a.slice(0, 2000));
1079
+ if (em) {
1080
+ nestedFrom(em[1]);
1081
+ bump('local_write');
1082
+ }
1083
+ const m = /(?:^|;)\s*s(.)(?:(?!\1).){0,500}\1((?:(?!\1).){0,500})\1[gpiImM0-9]*e/.exec(a.slice(0, 2000));
1084
+ if (m) {
1085
+ nestedFrom(m[2]);
1086
+ bump('local_write');
1087
+ }
1088
+ }
1089
+ break;
1090
+ case 'awk':
1091
+ case 'gawk':
1092
+ case 'mawk':
1093
+ case 'nawk': {
1094
+ const prog = args.join(' ').slice(0, 20000);
1095
+ let found = false;
1096
+ for (const m of prog.matchAll(/system\s*\(\s*"((?:[^"\\]|\\.)*)"/g)) {
1097
+ nestedFrom(m[1].replace(/\\(.)/g, '$1'));
1098
+ found = true;
1099
+ }
1100
+ for (const m of prog.matchAll(/\|\s*"((?:[^"\\]|\\.)*)"|"((?:[^"\\]|\\.)*)"\s*\|\s*getline/g)) {
1101
+ const target = (m[1] ?? m[2]).replace(/\\(.)/g, '$1');
1102
+ nestedFrom(target);
1103
+ if (interpKind(target.trim().split(/\s+/)[0]) === 'shell')
1104
+ opq('awk output is piped into a shell');
1105
+ found = true;
1106
+ }
1107
+ if (found || /\bsystem\s*\(|\|\s*getline|print[^;}]*\|\s*[^;}\s]/.test(prog))
1108
+ bump('local_write');
1109
+ break;
1110
+ }
1111
+ case 'eval': {
1112
+ const code = args.join(' ');
1113
+ nestedFrom(code);
1114
+ bump('local_write');
1115
+ if (/\$|`/.test(code))
1116
+ opq('eval of a string built from expansions');
1117
+ if (FETCH_SUBST.test(code))
1118
+ destructive(REMOTE_EXEC_REASON);
1119
+ break;
1120
+ }
1121
+ case 'alias':
1122
+ if (args.some((a) => a.includes('=')))
1123
+ opq('alias definition (later use is not tracked)');
1124
+ break;
1125
+ case 'source':
1126
+ case '.':
1127
+ bump('local_write');
1128
+ if (args[0])
1129
+ execFile = args[0];
1130
+ if (args[0] && /[$`<]/.test(args[0]))
1131
+ opq('sourced path is not a literal');
1132
+ break;
1133
+ case 'trap': {
1134
+ const action = nonFlags(args)[0];
1135
+ if (action && action !== '-')
1136
+ nestedFrom(action);
469
1137
  break;
470
- case 'find':
1138
+ }
1139
+ case 'su': {
1140
+ const ci = args.findIndex((a) => a === '-c' || a === '--command');
1141
+ if (ci >= 0 && args[ci + 1])
1142
+ nestedFrom(args[ci + 1]);
1143
+ bump('local_write');
1144
+ break;
1145
+ }
1146
+ case 'find': {
1147
+ const ei = args.findIndex((a) => a === '-exec' || a === '-execdir' || a === '-ok' || a === '-okdir');
471
1148
  if (args.includes('-delete'))
472
- bump('destructive');
473
- else if (args.includes('-exec'))
1149
+ destructive();
1150
+ if (ei >= 0) {
1151
+ let end = args.findIndex((a, i) => i > ei && (a === ';' || a === ';' || a === '+'));
1152
+ if (end < 0)
1153
+ end = args.length;
1154
+ nestedFrom(args.slice(ei + 1, end).join(' '));
474
1155
  bump('local_write');
1156
+ }
475
1157
  break;
1158
+ }
476
1159
  case 'git': {
477
- const sub = args.find((a) => !a.startsWith('-')) ?? '';
478
- const rest = args.slice(args.indexOf(sub) + 1);
1160
+ let a = args;
1161
+ while (a.length > 0 && a[0].startsWith('-')) {
1162
+ if (a[0] === '-c' && a[1]) {
1163
+ const m = /^(?:alias\.[\w-]+|core\.(?:sshCommand|pager|editor|fsmonitor))=(.+)$/.exec(a[1]);
1164
+ if (m)
1165
+ nestedFrom(m[1].replace(/^!/, ''));
1166
+ if (/^core\.hooksPath=/i.test(a[1]))
1167
+ opq('git hooksPath runs arbitrary hooks');
1168
+ }
1169
+ a = ['-C', '-c', '--git-dir', '--work-tree', '--namespace'].includes(a[0])
1170
+ ? a.slice(2)
1171
+ : a.slice(1);
1172
+ }
1173
+ const sub = a[0] ?? '';
1174
+ const rest = a.slice(1);
479
1175
  if (sub === 'push') {
480
- const forced = rest.some((a) => a === '--force' || a === '-f' || a.startsWith('--force-with-lease'));
481
- const deleting = rest.includes('--delete') || rest.some((a) => /^:\S/.test(a));
1176
+ const forced = rest.some((x) => x === '--mirror' ||
1177
+ x.startsWith('--force') ||
1178
+ /^-[A-Za-z]*f[A-Za-z]*$/.test(x) ||
1179
+ /^\+\S/.test(x));
1180
+ const deleting = rest.includes('--delete') || rest.includes('-d') || rest.some((x) => /^:\S/.test(x));
482
1181
  network = { tool: 'git', target: 'push', mutates: true };
483
1182
  bump(forced || deleting ? 'destructive' : 'remote');
484
1183
  }
@@ -488,20 +1187,40 @@ function classifyCommand(cmd, rawLine) {
488
1187
  if (sub !== 'ls-remote')
489
1188
  bump('local_write');
490
1189
  }
1190
+ else if (sub === 'remote' && ['rm', 'remove', 'prune'].includes(rest[0])) {
1191
+ destructive();
1192
+ }
491
1193
  else if (GIT_READ.has(sub)) {
492
1194
  // read-only (git config/remote may write, but bare invocations here are inspection)
493
1195
  }
494
1196
  else if ((sub === 'reset' && rest.includes('--hard')) ||
495
1197
  sub === 'clean' ||
496
- (sub === 'branch' && rest.some((a) => a === '-D' || a === '--delete')) ||
497
- (sub === 'checkout' && rest.includes('--') && rest.includes('.')) ||
1198
+ sub === 'rm' ||
1199
+ sub === 'filter-branch' ||
1200
+ sub === 'filter-repo' ||
1201
+ (sub === 'reflog' && (rest[0] === 'expire' || rest[0] === 'delete')) ||
1202
+ (sub === 'gc' && rest.some((x) => x.startsWith('--prune'))) ||
1203
+ (sub === 'update-ref' && rest.includes('-d')) ||
1204
+ (sub === 'tag' && rest.some((x) => x === '-d' || x === '--delete')) ||
1205
+ (sub === 'branch' && rest.some((x) => x === '-D' || x === '-d' || x === '--delete')) ||
1206
+ (sub === 'checkout' &&
1207
+ (rest.includes('-f') || rest.includes('--force') || rest.includes('.'))) ||
1208
+ (sub === 'restore' && (rest.includes('.') || rest.includes('--worktree'))) ||
1209
+ (sub === 'switch' &&
1210
+ rest.some((x) => x === '-f' || x === '--force' || x === '--discard-changes')) ||
1211
+ (sub === 'submodule' && rest[0] === 'deinit') ||
498
1212
  (sub === 'worktree' && rest[0] === 'remove') ||
499
1213
  (sub === 'stash' && (rest[0] === 'drop' || rest[0] === 'clear'))) {
500
- bump('destructive');
1214
+ destructive();
501
1215
  }
502
1216
  else {
503
1217
  bump('local_write');
504
1218
  }
1219
+ if (sub === 'rebase') {
1220
+ const xi = rest.findIndex((x) => x === '--exec' || x === '-x');
1221
+ if (xi >= 0 && rest[xi + 1])
1222
+ nestedFrom(rest[xi + 1]);
1223
+ }
505
1224
  break;
506
1225
  }
507
1226
  case 'gh': {
@@ -510,10 +1229,10 @@ function classifyCommand(cmd, rawLine) {
510
1229
  const verb = words[1] ?? '';
511
1230
  if (group === 'api') {
512
1231
  const method = (() => {
513
- const i = args.findIndex((a) => a === '-X' || a === '--method');
1232
+ const i = args.findIndex((x) => x === '-X' || x === '--method');
514
1233
  return i >= 0 ? (args[i + 1] ?? '').toUpperCase() : '';
515
1234
  })();
516
- const hasBody = args.some((a) => a === '-f' || a === '-F' || a === '--field' || a === '--raw-field' || a === '--input');
1235
+ const hasBody = args.some((x) => x === '-f' || x === '-F' || x === '--field' || x === '--raw-field' || x === '--input');
517
1236
  const mutates = (method !== '' && method !== 'GET') || (method === '' && hasBody);
518
1237
  network = { tool: 'gh', target: `api ${words[1] ?? ''}`.trim(), mutates };
519
1238
  bump(method === 'DELETE' ? 'destructive' : 'remote');
@@ -527,41 +1246,105 @@ function classifyCommand(cmd, rawLine) {
527
1246
  }
528
1247
  case 'curl':
529
1248
  case 'wget': {
530
- const url = args.find((a) => /^[a-z][a-z0-9+.-]*:\/\//i.test(a)) ?? '';
1249
+ const url = args.find((x) => /^[a-z][a-z0-9+.-]*:\/\//i.test(x)) ?? '';
531
1250
  const method = (() => {
532
- const i = args.findIndex((a) => a === '-X' || a === '--request');
1251
+ const i = args.findIndex((x) => x === '-X' || x === '--request');
533
1252
  return i >= 0 ? (args[i + 1] ?? '').toUpperCase() : '';
534
1253
  })();
535
- const hasBody = args.some((a) => a === '-d' ||
536
- a === '--data' ||
537
- a === '--data-raw' ||
538
- a === '--json' ||
539
- a === '-F' ||
540
- a === '-T');
1254
+ const hasBody = args.some((x) => x === '-d' ||
1255
+ x === '--data' ||
1256
+ x === '--data-raw' ||
1257
+ x === '--json' ||
1258
+ x === '-F' ||
1259
+ x === '-T');
541
1260
  const mutates = (method !== '' && method !== 'GET' && method !== 'HEAD') || hasBody;
542
1261
  network = { tool: exe, target: hostOf(url) ?? (url || 'unknown host'), mutates };
543
1262
  bump(method === 'DELETE' ? 'destructive' : 'remote');
544
- const oi = args.findIndex((a) => a === '-o' || a === '--output' || a === '-O' || a === '--output-document');
545
- if (oi >= 0 && args[oi + 1]) {
1263
+ const oi = args.findIndex((x) => exe === 'curl' ? x === '-o' || x === '--output' : x === '-O' || x === '--output-document');
1264
+ if (oi >= 0 && args[oi + 1] && args[oi + 1] !== '-') {
546
1265
  files.push({ path: args[oi + 1], operation: 'write' });
547
1266
  bump('remote');
548
1267
  }
1268
+ else if (url &&
1269
+ (exe === 'wget'
1270
+ ? oi < 0
1271
+ : args.some((x) => x === '-O' || x === '--remote-name' || /^-[A-Za-z]*O[A-Za-z]*$/.test(x)))) {
1272
+ // `wget URL` / `curl -O URL` save under the URL's last path segment.
1273
+ const name = url
1274
+ .replace(/[?#].*$/, '')
1275
+ .replace(/\/+$/, '')
1276
+ .replace(/^.*\//, '');
1277
+ if (name && !/^[a-z][a-z0-9+.-]*:$/i.test(name)) {
1278
+ files.push({ path: name, operation: 'write' });
1279
+ bump('remote');
1280
+ }
1281
+ }
549
1282
  break;
550
1283
  }
551
1284
  default: {
552
- if (REMOTE_TOOLS.has(exe)) {
1285
+ const ik = interpKind(exe);
1286
+ if (ik) {
1287
+ bump('local_write');
1288
+ const codeIdx = args.findIndex((x) => CODE_FLAG[ik].test(x));
1289
+ if (codeIdx >= 0 && args[codeIdx + 1] !== undefined) {
1290
+ if (ik === 'powershell') {
1291
+ if (/^-(?:e|enc|encodedcommand)$/i.test(args[codeIdx]))
1292
+ destructive('encoded PowerShell command (opaque payload)');
1293
+ else
1294
+ nestedFrom(args.slice(codeIdx + 1).join(' '), 'powershell');
1295
+ }
1296
+ else if (ik !== 'other') {
1297
+ const code = args[codeIdx + 1];
1298
+ nestedFrom(code, ik);
1299
+ if (FETCH_SUBST.test(code))
1300
+ destructive(REMOTE_EXEC_REASON);
1301
+ if (ik === 'shell' && /^\s*["']?\$\{?\w/.test(code))
1302
+ opq('script string comes from a variable');
1303
+ }
1304
+ else {
1305
+ opq(`${exe} script string is not analysed`);
1306
+ }
1307
+ }
1308
+ else {
1309
+ const script = args.find((x) => !x.startsWith('-') && x !== '-');
1310
+ if (script)
1311
+ execFile = script;
1312
+ }
1313
+ }
1314
+ else if (/^mkfs(?:\..+)?$/.test(exe) || DESTRUCTIVE_TOOLS.has(exe)) {
1315
+ destructive();
1316
+ }
1317
+ else if (REMOTE_TOOLS.has(exe)) {
553
1318
  const words = nonFlags(args);
554
- const destructive = words.some((w) => DESTRUCTIVE_VERB.test(w));
555
- const mutates = destructive ||
1319
+ const isDestructive = words.some((w) => w.split(':').some((part) => DESTRUCTIVE_VERB.test(part))) ||
1320
+ (exe === 'rsync' && args.some((x) => /^--(?:delete|remove-source-files)/.test(x))) ||
1321
+ (['psql', 'mysql', 'mongosh', 'mongo', 'redis-cli'].includes(exe) &&
1322
+ args.some((x) => DB_DESTRUCTIVE.test(x.slice(0, 5000)))) ||
1323
+ (exe === 'aws' && words[0] === 's3' && (words[1] === 'rm' || words[1] === 'rb'));
1324
+ const mutates = isDestructive ||
556
1325
  words.some((w) => /^(apply|create|patch|scale|put|push|deploy|update|run|start|stop|cp|sync|replace|set|annotate|label|rollout|exec|copy|upgrade|install)$/.test(w));
557
1326
  network = { tool: exe, target: words.slice(0, 2).join(' ') || exe, mutates };
558
- bump(destructive ? 'destructive' : 'remote');
1327
+ bump(isDestructive ? 'destructive' : 'remote');
1328
+ if (exe === 'ssh' ||
1329
+ (exe === 'kubectl' && words[0] === 'exec') ||
1330
+ exe === 'fly' ||
1331
+ exe === 'flyctl') {
1332
+ const quoted = args[args.length - 1];
1333
+ if (exe === 'ssh' && quoted && /\s/.test(quoted))
1334
+ nestedFrom(quoted);
1335
+ else
1336
+ nestedAfterHost();
1337
+ }
559
1338
  }
560
1339
  else if (PACKAGE_MANAGERS.has(exe)) {
561
- const sub = nonFlags(args)[0] ?? '';
562
- if (sub === 'publish' || (exe === 'cargo' && sub === 'publish')) {
563
- network = { tool: exe, target: 'publish', mutates: true };
564
- bump('destructive');
1340
+ const words = nonFlags(args);
1341
+ const pub = ['run', 'run-script', 'exec', 'test'].includes(words[0] ?? '')
1342
+ ? undefined
1343
+ : words.slice(0, 3).find((w) => w === 'publish' || w === 'unpublish');
1344
+ const sub = pub ?? words[0] ?? '';
1345
+ if (pub) {
1346
+ network = { tool: exe, target: sub, mutates: true };
1347
+ destructive();
565
1348
  }
566
1349
  else if (/^(install|i|add|ci|update|upgrade|dlx|exec)$/.test(sub) ||
567
1350
  exe === 'npx' ||
@@ -570,31 +1353,43 @@ function classifyCommand(cmd, rawLine) {
570
1353
  bump('remote');
571
1354
  bump('local_write');
572
1355
  }
573
- else if (/^(run|test|build|lint|check|start|dev)$/.test(sub) || sub === '') {
574
- bump('local_write');
575
- }
576
1356
  else {
577
1357
  bump('local_write');
578
1358
  }
579
1359
  }
580
- else if (exe === 'docker' || exe === 'podman') {
1360
+ else if ((exe === 'twine' && args.includes('upload')) ||
1361
+ (exe === 'gem' && args.includes('push')) ||
1362
+ (['poetry', 'uv', 'flit', 'hatch'].includes(exe) && args.includes('publish'))) {
1363
+ network = { tool: exe, target: 'publish', mutates: true };
1364
+ destructive();
1365
+ }
1366
+ else if (exe === 'docker' || exe === 'podman' || exe === 'docker-compose') {
581
1367
  const sub = nonFlags(args)[0] ?? '';
582
1368
  if (sub === 'push' || sub === 'pull' || sub === 'login') {
583
1369
  network = { tool: exe, target: sub, mutates: sub === 'push' };
584
1370
  bump('remote');
585
1371
  }
586
1372
  else if (/^(rm|rmi|prune|kill|system)$/.test(sub)) {
587
- bump('destructive');
1373
+ destructive();
1374
+ }
1375
+ else if ((sub === 'down' || (sub === 'compose' && nonFlags(args)[1] === 'down')) &&
1376
+ args.some((x) => x === '-v' || x === '--volumes')) {
1377
+ destructive();
588
1378
  }
589
1379
  else {
590
1380
  bump('local_write');
591
1381
  }
1382
+ if (sub === 'exec' || sub === 'run')
1383
+ nestedAfterHost();
592
1384
  }
593
1385
  else if (exe === 'ai-dossier' || exe === 'dossier') {
594
1386
  const sub = nonFlags(args)[0] ?? '';
595
1387
  if (sub === 'publish' || sub === 'login') {
596
1388
  network = { tool: exe, target: sub, mutates: sub === 'publish' };
597
- bump('remote');
1389
+ if (sub === 'publish')
1390
+ destructive();
1391
+ else
1392
+ bump('remote');
598
1393
  }
599
1394
  else if (sub === 'run' || sub === 'pull' || sub === 'get') {
600
1395
  network = { tool: exe, target: sub, mutates: false };
@@ -614,11 +1409,33 @@ function classifyCommand(cmd, rawLine) {
614
1409
  else {
615
1410
  recognized = false;
616
1411
  bump('local_write');
1412
+ if (inner[0].includes('/') && !/^\/(?:usr\/)?s?bin\//.test(inner[0]))
1413
+ execFile = inner[0];
617
1414
  }
618
1415
  }
619
1416
  }
620
- // `curl ... | sh` style: handled by the caller, which sees the whole pipeline.
621
- return { kind, recognized, files: files.filter((f) => f.path.trim() !== ''), network };
1417
+ // Here-string into an interpreter (`bash <<< "rm -rf ~"`): the string is the program.
1418
+ const hs = inner.findIndex((t) => t.startsWith('<<<'));
1419
+ if (hs >= 0) {
1420
+ const target = execTargetOf(inner.slice(0, hs).join(' '));
1421
+ const text = inner[hs].length > 3 ? inner[hs].slice(3) : (inner[hs + 1] ?? '');
1422
+ if (target && text)
1423
+ nested.push({ text, lang: target });
1424
+ }
1425
+ const stdinTarget = stdinTargetOf(inner);
1426
+ return {
1427
+ kind,
1428
+ recognized,
1429
+ files: files.filter((f) => f.path.trim() !== ''),
1430
+ network,
1431
+ nested,
1432
+ reason,
1433
+ inner,
1434
+ stdinTarget,
1435
+ execFile,
1436
+ opaque,
1437
+ inputFile,
1438
+ };
622
1439
  }
623
1440
  const KNOWN_TOOL_NAMES = new Set([
624
1441
  ...REMOTE_TOOLS,
@@ -658,83 +1475,280 @@ const IGNORED_ENV = new Set([
658
1475
  'EUID',
659
1476
  ]);
660
1477
  // --- analysis --------------------------------------------------------------
661
- function analyzeDryRun(input) {
662
- const { frontmatter, body } = typeof input === 'string' ? (0, parser_1.parseDossierContent)(input) : input;
663
- const commands = [];
664
- const files = [];
665
- const network = [];
666
- const env = new Map();
667
- // Variables the dossier assigns itself are internal state, not external inputs.
668
- const assigned = new Set();
669
- for (const fence of extractFences(body)) {
670
- const isShell = SHELL_LANGS.has(fence.lang);
671
- const isScript = SCRIPT_LANGS.has(fence.lang);
672
- const isBare = fence.lang === '';
673
- if (!isShell && !isScript && !isBare)
674
- continue;
675
- let heredocEnd = null;
676
- for (const { text, line } of logicalLines(fence)) {
677
- if (heredocEnd !== null) {
678
- if (text === heredocEnd)
679
- heredocEnd = null;
680
- continue; // heredoc bodies are data (PR bodies, file contents), not commands
681
- }
682
- const heredoc = text.match(/<<-?\s*['"]?([A-Za-z_][A-Za-z0-9_]*)['"]?/);
683
- // A heredoc already closed inside a joined multi-line quote needs no skipping.
684
- if (heredoc &&
685
- !new RegExp(`\\b${heredoc[1]}\\b`).test(text.slice((heredoc.index ?? 0) + heredoc[0].length))) {
686
- heredocEnd = heredoc[1];
687
- }
688
- if (!text || text.startsWith('#'))
689
- continue;
690
- if (isScript) {
691
- for (const m of text.matchAll(ENV_NODE)) {
692
- const name = m[1] ?? m[2];
693
- if (name && !env.has(name))
694
- env.set(name, { name, line });
1478
+ /** Maximum recursion into `sh -c` strings, heredoc bodies and embedded code. */
1479
+ const MAX_DEPTH = 6;
1480
+ /** Maximum number of nested analyses per dossier (bounds pathological self-nesting input). */
1481
+ const MAX_NESTED = 20000;
1482
+ /** Heredocs analysed per line; any beyond this are conservatively treated as shell code. */
1483
+ const MAX_HEREDOCS_PER_LINE = 16;
1484
+ /**
1485
+ * Nested content we refuse to analyse (too deep / too much) is NOT assumed safe:
1486
+ * it is recorded as a destructive finding so the level cannot be lowered by padding or nesting.
1487
+ */
1488
+ function giveUp(ctx, line, why) {
1489
+ if (ctx.gaveUp.has(why))
1490
+ return;
1491
+ ctx.gaveUp.add(why);
1492
+ ctx.commands.push({
1493
+ command: `(${why})`,
1494
+ kind: 'destructive',
1495
+ recognized: false,
1496
+ line,
1497
+ reason: why,
1498
+ });
1499
+ }
1500
+ const normPath = (p) => p.replace(/^\.\//, '');
1501
+ const isFetch = (c) => !!c &&
1502
+ (FETCH_TOOLS.has(c.inner[0]?.replace(/^.*\//, '') ?? '') ||
1503
+ (c.inner[0] === 'gh' && c.inner[1] === 'api') ||
1504
+ (interpKind(c.inner[0]?.replace(/^.*\//, '') ?? '') !== null &&
1505
+ /urlopen|requests\.|urllib|fetch\s*\(|https?:\/\//.test(c.inner.slice(1).join(' '))));
1506
+ function isDecoder(c) {
1507
+ if (!c)
1508
+ return false;
1509
+ const exe = c.inner[0]?.replace(/^.*\//, '') ?? '';
1510
+ const args = c.inner.slice(1);
1511
+ if (exe === 'base64')
1512
+ return args.some((a) => a === '-d' || a === '-D' || a === '--decode');
1513
+ if (exe === 'xxd')
1514
+ return args.includes('-r');
1515
+ if (exe === 'openssl')
1516
+ return args.includes('-d') || args.includes('-base64') || args.includes('-a');
1517
+ if (exe === 'gzip')
1518
+ return args.some((a) => a === '-d' || a === '--decompress');
1519
+ if (exe === 'unzip')
1520
+ return args.includes('-p');
1521
+ return /^(?:zcat|gzcat|gunzip|bunzip2|xzcat|unxz|uudecode|rev)$/.test(exe);
1522
+ }
1523
+ const isExecSink = (c) => {
1524
+ const exe = c.inner[0]?.replace(/^.*\//, '') ?? '';
1525
+ return exe === 'eval' || exe === 'source' || exe === '.' || interpKind(exe) !== null;
1526
+ };
1527
+ function pushHits(hits, ctx, depth) {
1528
+ for (const h of hits) {
1529
+ if (h.command) {
1530
+ ctx.commands.push({
1531
+ command: h.command,
1532
+ kind: h.kind,
1533
+ recognized: true,
1534
+ line: h.line,
1535
+ ...(h.reason ? { reason: h.reason } : {}),
1536
+ });
1537
+ if (h.file)
1538
+ ctx.files.push({ ...h.file, command: h.command, line: h.line });
1539
+ if (h.network)
1540
+ ctx.network.push({ ...h.network, command: h.command, line: h.line });
1541
+ }
1542
+ for (const s of h.shell ?? [])
1543
+ analyzeShellText(s, h.line, ctx, depth + 1);
1544
+ }
1545
+ }
1546
+ function analyzeNested(n, line, ctx, depth) {
1547
+ if (n.lang === 'shell') {
1548
+ analyzeShellText(n.text, line, ctx, depth + 1);
1549
+ return;
1550
+ }
1551
+ if (depth + 1 > MAX_DEPTH || ctx.budget-- <= 0) {
1552
+ giveUp(ctx, line, 'nested content too deep or too large to analyse');
1553
+ return;
1554
+ }
1555
+ const lang = n.lang === 'sql'
1556
+ ? 'sql'
1557
+ : n.lang === 'js'
1558
+ ? 'js'
1559
+ : n.lang === 'powershell'
1560
+ ? 'powershell'
1561
+ : n.lang;
1562
+ pushHits((0, dry_run_langs_1.extractCodeHits)(lang, n.text.split('\n'), line), ctx, depth + 1);
1563
+ }
1564
+ /** Analyse one logical shell line (already stripped of prompts) and everything it embeds. */
1565
+ function analyzeShellText(clean, line, ctx, depth) {
1566
+ if (depth > MAX_DEPTH || (depth > 0 && ctx.budget-- <= 0)) {
1567
+ giveUp(ctx, line, 'nested content too deep or too large to analyse');
1568
+ return;
1569
+ }
1570
+ for (const sub of dqSubstitutions(clean)) {
1571
+ processShellFence({ lang: 'sh', startLine: line, lines: sub.split('\n') }, false, ctx, depth + 1);
1572
+ }
1573
+ const segs = splitSegments(clean);
1574
+ const infos = segs.map((s) => classifyCommand(s.text, s.text));
1575
+ // What earlier stages of the same pipeline are (incrementally, so long pipelines stay linear).
1576
+ let curGroup = -1;
1577
+ let acc = { any: false, fetch: false, decode: false, echoes: [] };
1578
+ segs.forEach((seg, i) => {
1579
+ const c = infos[i];
1580
+ if (seg.group !== curGroup) {
1581
+ curGroup = seg.group;
1582
+ acc = { any: false, fetch: false, decode: false, echoes: [] };
1583
+ }
1584
+ const prev = { ...acc, echoes: acc.echoes.slice() };
1585
+ if (c) {
1586
+ acc.any = true;
1587
+ acc.fetch ||= isFetch(c);
1588
+ acc.decode ||= isDecoder(c);
1589
+ const pe = c.inner[0]?.replace(/^.*\//, '');
1590
+ if ((pe === 'echo' || pe === 'printf') && acc.echoes.length < 8)
1591
+ acc.echoes.push(c);
1592
+ }
1593
+ if (!c)
1594
+ return;
1595
+ // Unknown words after a pipe are almost always prose (`a|b|c` alternatives), not tools.
1596
+ if (i > 0 && !c.recognized && c.files.length === 0 && !c.opaque)
1597
+ return;
1598
+ let kind = c.kind;
1599
+ let reason = c.reason;
1600
+ if (c.opaque) {
1601
+ kind = 'destructive';
1602
+ reason ??= `not analysable: ${c.opaque}`;
1603
+ }
1604
+ const remoteExec = (why) => {
1605
+ kind = 'destructive';
1606
+ reason ??= why;
1607
+ };
1608
+ if (c.stdinTarget && prev.any) {
1609
+ if (prev.fetch) {
1610
+ remoteExec('remote code execution: fetched content piped into an interpreter');
1611
+ }
1612
+ else if (prev.decode) {
1613
+ remoteExec('decoded payload piped into an interpreter');
1614
+ }
1615
+ else {
1616
+ for (const p of prev.echoes) {
1617
+ const pe = p.inner[0]?.replace(/^.*\//, '');
1618
+ let words = nonFlags(p.inner.slice(1));
1619
+ if (pe === 'printf' && words[0]?.includes('%'))
1620
+ words = words.slice(1);
1621
+ if (words.length > 0)
1622
+ analyzeNested({ text: words.join(' '), lang: c.stdinTarget }, line, ctx, depth);
695
1623
  }
696
- continue;
697
1624
  }
698
- // Strip a leading `$ ` prompt used in console-style blocks.
699
- const clean = text.replace(/^\$\s+/, '');
700
- if (isBare &&
701
- !KNOWN_TOOL_NAMES.has(stripEnvAssignments(tokenize(clean))[0]?.replace(/^.*\//, '') ?? '')) {
702
- // Unlabeled fences are often templates (PR bodies, commit messages); only trust known tools.
1625
+ }
1626
+ // `bash <(curl ...)`, `eval $(curl ...)`, `sh -c $(curl ...)`: fetched content feeds an exec sink.
1627
+ const next = segs[i + 1];
1628
+ const nextInfo = infos[i + 1];
1629
+ if (next &&
1630
+ nextInfo &&
1631
+ isExecSink(c) &&
1632
+ (isFetch(nextInfo) || isDecoder(nextInfo)) &&
1633
+ (next.sep === '$(' || next.sep === '`' || (next.sep === '(' && /<\s*$/.test(seg.text)))) {
1634
+ remoteExec('remote code execution: fetched content is executed');
1635
+ }
1636
+ if (isFetch(c) || (prev.fetch && c.inner[0] === 'tee'))
1637
+ for (const f of c.files)
1638
+ ctx.downloaded.add(normPath(f.path));
1639
+ if (c.stdinTarget && c.inputFile && ctx.downloaded.has(normPath(c.inputFile))) {
1640
+ remoteExec('executes a file that was downloaded earlier in the dossier');
1641
+ }
1642
+ if (c.execFile && ctx.downloaded.has(normPath(c.execFile))) {
1643
+ remoteExec('executes a file that was downloaded earlier in the dossier');
1644
+ }
1645
+ ctx.commands.push({
1646
+ command: seg.text,
1647
+ kind,
1648
+ recognized: c.recognized,
1649
+ line,
1650
+ ...(reason ? { reason } : {}),
1651
+ });
1652
+ for (const f of c.files)
1653
+ ctx.files.push({ ...f, command: seg.text, line });
1654
+ if (c.network)
1655
+ ctx.network.push({ ...c.network, command: seg.text, line });
1656
+ for (const n of c.nested)
1657
+ analyzeNested(n, line, ctx, depth);
1658
+ });
1659
+ }
1660
+ function scanEnvShell(clean, line, ctx) {
1661
+ for (const m of clean.matchAll(/(?:^|[\s;&(])([A-Za-z_][A-Za-z0-9_]*)=/g))
1662
+ ctx.assigned.add(m[1]);
1663
+ for (const m of clean.matchAll(/\b(?:for|read(?:\s+-\w+)*|local|declare|export)\s+([A-Za-z_][A-Za-z0-9_]*)/g))
1664
+ ctx.assigned.add(m[1]);
1665
+ const envText = clean.replace(/'[^']*'/g, (q) => ' '.repeat(q.length));
1666
+ for (const m of envText.matchAll(ENV_SHELL)) {
1667
+ const name = m[1] ?? m[2];
1668
+ if (name && !ctx.assigned.has(name) && !IGNORED_ENV.has(name) && !ctx.env.has(name)) {
1669
+ ctx.env.set(name, { name, line });
1670
+ }
1671
+ }
1672
+ }
1673
+ function processShellFence(fence, isBare, ctx, depth) {
1674
+ if (depth > MAX_DEPTH) {
1675
+ giveUp(ctx, fence.startLine, 'nested content too deep or too large to analyse');
1676
+ return;
1677
+ }
1678
+ for (const { text, line, heredocs } of logicalLines(fence)) {
1679
+ if (!text || text.startsWith('#'))
1680
+ continue;
1681
+ // Strip a leading `$ ` prompt used in console-style blocks.
1682
+ const clean = text.replace(/^\$\s+/, '');
1683
+ // Unlabeled fences are often templates (PR bodies, commit messages); only trust known tools.
1684
+ const skip = isBare &&
1685
+ !KNOWN_TOOL_NAMES.has(stripEnvAssignments(tokenize(clean))[0]?.replace(/^.*\//, '') ?? '');
1686
+ if (skip)
1687
+ continue;
1688
+ scanEnvShell(clean, line, ctx);
1689
+ analyzeShellText(clean, line, ctx, depth);
1690
+ // A heredoc fed to a shell / interpreter is code; one fed to `cat > file` / `tee` is data.
1691
+ for (const h of heredocs) {
1692
+ if (!h.exec || h.body.length === 0)
703
1693
  continue;
1694
+ if (h.exec === 'shell') {
1695
+ processShellFence({ lang: 'sh', startLine: h.bodyLine, lines: h.body }, false, ctx, depth + 1);
704
1696
  }
705
- for (const m of clean.matchAll(/(?:^|[\s;&(])([A-Za-z_][A-Za-z0-9_]*)=/g))
706
- assigned.add(m[1]);
707
- for (const m of clean.matchAll(/\b(?:for|read(?:\s+-\w+)*|local|declare|export)\s+([A-Za-z_][A-Za-z0-9_]*)/g))
708
- assigned.add(m[1]);
709
- const envText = clean.replace(/'[^']*'/g, (q) => ' '.repeat(q.length));
710
- for (const m of envText.matchAll(ENV_SHELL)) {
711
- const name = m[1] ?? m[2];
712
- if (name && !assigned.has(name) && !IGNORED_ENV.has(name) && !env.has(name)) {
713
- env.set(name, { name, line });
714
- }
1697
+ else if (ctx.budget-- > 0) {
1698
+ pushHits((0, dry_run_langs_1.extractCodeHits)(h.exec, h.body, h.bodyLine), ctx, depth + 1);
715
1699
  }
716
- const parts = splitCommands(clean);
717
- const exes = parts.map((p) => stripEnvAssignments(tokenize(p))[0]?.replace(/^.*\//, '') ?? '');
718
- const pipesToShell = /\|\s*(sudo\s+)?(sh|bash|zsh)\b/.test(maskQuotes(clean)) && /\b(curl|wget)\b/.test(clean);
719
- parts.forEach((part, i) => {
720
- // Redirections are attached to the segment that owns them.
721
- const c = classifyCommand(part, part);
722
- if (!c)
723
- return;
724
- // Unknown words after a pipe are almost always prose (`a|b|c` alternatives), not tools.
725
- if (i > 0 && !c.recognized && c.files.length === 0)
726
- return;
727
- let kind = c.kind;
728
- if (pipesToShell && (exes[i] === 'sh' || exes[i] === 'bash' || exes[i] === 'zsh'))
729
- kind = 'destructive';
730
- commands.push({ command: part, kind, recognized: c.recognized, line });
731
- for (const f of c.files)
732
- files.push({ ...f, command: part, line });
733
- if (c.network)
734
- network.push({ ...c.network, command: part, line });
735
- });
736
1700
  }
737
1701
  }
1702
+ }
1703
+ function processFence(fence, ctx) {
1704
+ const lang = fence.lang;
1705
+ if (SHELL_LANGS.has(lang) || lang === '') {
1706
+ processShellFence(fence, lang === '', ctx, 0);
1707
+ return;
1708
+ }
1709
+ let code = CODE_LANGS[lang];
1710
+ if (lang === 'docker') {
1711
+ // ```docker holds either a Dockerfile or `docker ...` shell commands.
1712
+ const isDockerfile = fence.lines.some((l) => /^\s*(?:FROM|RUN|COPY|ADD|ENV|WORKDIR|CMD|ENTRYPOINT|ARG)\s/.test(l));
1713
+ if (!isDockerfile) {
1714
+ processShellFence(fence, false, ctx, 0);
1715
+ return;
1716
+ }
1717
+ code = 'dockerfile';
1718
+ }
1719
+ if (!code) {
1720
+ if (!INERT_LANGS.has(lang))
1721
+ ctx.unanalyzed.push({ lang, line: fence.startLine });
1722
+ return;
1723
+ }
1724
+ pushHits((0, dry_run_langs_1.extractCodeHits)(code, fence.lines, fence.startLine), ctx, 0);
1725
+ if (code === 'js') {
1726
+ fence.lines.forEach((text, idx) => {
1727
+ for (const m of text.matchAll(ENV_NODE)) {
1728
+ const name = m[1] ?? m[2];
1729
+ if (name && !ctx.env.has(name))
1730
+ ctx.env.set(name, { name, line: fence.startLine + idx });
1731
+ }
1732
+ });
1733
+ }
1734
+ }
1735
+ const LEVEL_RANK = { low: 0, medium: 1, high: 2, critical: 3 };
1736
+ function analyzeDryRun(input) {
1737
+ const { frontmatter, body } = typeof input === 'string' ? (0, parser_1.parseDossierContent)(input) : input;
1738
+ const ctx = {
1739
+ commands: [],
1740
+ files: [],
1741
+ network: [],
1742
+ env: new Map(),
1743
+ assigned: new Set(),
1744
+ downloaded: new Set(),
1745
+ unanalyzed: [],
1746
+ budget: MAX_NESTED,
1747
+ gaveUp: new Set(),
1748
+ };
1749
+ for (const fence of extractFences(body))
1750
+ processFence(fence, ctx);
1751
+ const { commands, files, network } = ctx;
738
1752
  // Score.
739
1753
  const breakdown = [];
740
1754
  const riskLevel = frontmatter.risk_level ?? null;
@@ -752,16 +1766,35 @@ function analyzeDryRun(input) {
752
1766
  component: `${destructive.length} declared destructive_operations`,
753
1767
  points: destPts,
754
1768
  });
1769
+ let observedPts = 0;
755
1770
  for (const kind of ['local_write', 'remote', 'destructive']) {
756
1771
  const n = commands.filter((c) => c.kind === kind).length;
757
1772
  const pts = Math.min(n * exports.SCORE_PER_COMMAND[kind], exports.SCORE_COMMAND_CAP[kind]);
1773
+ observedPts += pts;
758
1774
  if (pts > 0)
759
1775
  breakdown.push({
760
1776
  component: `${n} ${kind} command${n === 1 ? '' : 's'} in code blocks`,
761
1777
  points: pts,
762
1778
  });
763
1779
  }
1780
+ // Observed behaviour outranks the dossier's self-declared risk: a destructive command
1781
+ // floors the level at high no matter what risk_level the dossier declares.
1782
+ const hasDestructive = commands.some((c) => c.kind === 'destructive');
1783
+ if (hasDestructive) {
1784
+ const total = breakdown.reduce((s, b) => s + b.points, 0);
1785
+ if (total < exports.LEVEL_HIGH_MIN) {
1786
+ breakdown.push({
1787
+ component: 'floor: destructive command observed (raised to high regardless of declared risk)',
1788
+ points: exports.LEVEL_HIGH_MIN - total,
1789
+ });
1790
+ }
1791
+ observedPts = Math.max(observedPts, exports.LEVEL_HIGH_MIN);
1792
+ }
764
1793
  const risk_score = Math.min(100, breakdown.reduce((s, b) => s + b.points, 0));
1794
+ const level = levelForScore(risk_score);
1795
+ const observedLevel = levelForScore(Math.min(100, observedPts));
1796
+ const declaredRank = riskLevel && riskLevel in LEVEL_RANK ? LEVEL_RANK[riskLevel] : 0;
1797
+ const mismatch = LEVEL_RANK[observedLevel] > declaredRank;
765
1798
  return {
766
1799
  schema_version: exports.DRY_RUN_SCHEMA_VERSION,
767
1800
  static_preview: true,
@@ -780,10 +1813,19 @@ function analyzeDryRun(input) {
780
1813
  files,
781
1814
  commands,
782
1815
  network,
783
- env: [...env.values()],
1816
+ env: [...ctx.env.values()],
784
1817
  risk_score,
785
- level: levelForScore(risk_score),
1818
+ level,
786
1819
  score_breakdown: breakdown,
1820
+ declared_vs_observed: {
1821
+ declared_level: riskLevel,
1822
+ observed_level: observedLevel,
1823
+ mismatch,
1824
+ note: mismatch
1825
+ ? `Code blocks show ${observedLevel}-risk behaviour but the dossier declares ${riskLevel ?? 'no risk_level'}.`
1826
+ : 'Observed behaviour is consistent with the declared risk level.',
1827
+ },
1828
+ unanalyzed_fences: ctx.unanalyzed,
787
1829
  };
788
1830
  }
789
1831
  //# sourceMappingURL=dry-run.js.map