@node9/policy-engine 2.14.0 → 2.14.1

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/index.mjs CHANGED
@@ -1121,6 +1121,19 @@ function parseShared(command) {
1121
1121
  astCache.set(command, parsed);
1122
1122
  return parsed;
1123
1123
  }
1124
+ function byteOffsetToCharIndex(command) {
1125
+ if (!/[^\u0000-\u007F]/.test(command)) return null;
1126
+ const map = /* @__PURE__ */ new Map();
1127
+ let byte = 0;
1128
+ for (let i = 0; i < command.length; ) {
1129
+ map.set(byte, i);
1130
+ const cp = command.codePointAt(i);
1131
+ byte += cp < 128 ? 1 : cp < 2048 ? 2 : cp < 65536 ? 3 : 4;
1132
+ i += cp > 65535 ? 2 : 1;
1133
+ }
1134
+ map.set(byte, command.length);
1135
+ return (b) => map.get(b) ?? -1;
1136
+ }
1124
1137
  function cachedNormalize(command, compute) {
1125
1138
  const hit = normalizeCache.get(command);
1126
1139
  if (hit !== void 0) {
@@ -1150,6 +1163,8 @@ function normalizeCommandForPolicyImpl(command) {
1150
1163
  const f = parseShared(command);
1151
1164
  if (f === PARSE_FAIL) return { posix: command, separator: command };
1152
1165
  try {
1166
+ const toCharIndex = byteOffsetToCharIndex(command);
1167
+ const at = (byteOffset) => toCharIndex === null ? byteOffset : toCharIndex(byteOffset);
1153
1168
  const strips = [];
1154
1169
  const rewrites = [];
1155
1170
  const quoteOnlyRewrites = [];
@@ -1170,8 +1185,9 @@ function normalizeCommandForPolicyImpl(command) {
1170
1185
  const quotedNode = nextParts[0];
1171
1186
  const nt = syntax.NodeType(quotedNode);
1172
1187
  const markStrip = () => {
1173
- const s = next.Pos().Offset();
1174
- const e = next.End().Offset();
1188
+ const s = at(next.Pos().Offset());
1189
+ const e = at(next.End().Offset());
1190
+ if (s < 0 || e < 0) return;
1175
1191
  strips.push([s, e]);
1176
1192
  msgSpans.add(`${s}:${e}`);
1177
1193
  };
@@ -1188,8 +1204,9 @@ function normalizeCommandForPolicyImpl(command) {
1188
1204
  }
1189
1205
  }
1190
1206
  for (const arg of args) {
1191
- const s = arg.Pos().Offset();
1192
- const e = arg.End().Offset();
1207
+ const s = at(arg.Pos().Offset());
1208
+ const e = at(arg.End().Offset());
1209
+ if (s < 0 || e < 0) continue;
1193
1210
  if (msgSpans.has(`${s}:${e}`)) continue;
1194
1211
  const resolved = resolveWordLiteral(arg);
1195
1212
  if (resolved === null) continue;
@@ -1312,7 +1329,347 @@ var FS_READ_TOOLS = /* @__PURE__ */ new Set([
1312
1329
  "nl",
1313
1330
  "dd"
1314
1331
  ]);
1332
+ var GREP_SHAPE = {
1333
+ takesValue: /* @__PURE__ */ new Set([
1334
+ "-A",
1335
+ "-B",
1336
+ "-C",
1337
+ "-D",
1338
+ "-d",
1339
+ "-e",
1340
+ "-f",
1341
+ "-m",
1342
+ "--after-context",
1343
+ "--before-context",
1344
+ "--binary-files",
1345
+ "--context",
1346
+ "--devices",
1347
+ "--directories",
1348
+ "--exclude",
1349
+ "--exclude-dir",
1350
+ "--exclude-from",
1351
+ "--file",
1352
+ "--include",
1353
+ "--label",
1354
+ "--max-count",
1355
+ "--regexp"
1356
+ ]),
1357
+ noValue: /* @__PURE__ */ new Set([
1358
+ "-E",
1359
+ "-F",
1360
+ "-G",
1361
+ "-P",
1362
+ "-i",
1363
+ "-y",
1364
+ "-v",
1365
+ "-V",
1366
+ "-w",
1367
+ "-x",
1368
+ "-c",
1369
+ "-l",
1370
+ "-L",
1371
+ "-o",
1372
+ "-q",
1373
+ "-s",
1374
+ "-b",
1375
+ "-H",
1376
+ "-h",
1377
+ "-n",
1378
+ "-T",
1379
+ "-Z",
1380
+ "-z",
1381
+ "-R",
1382
+ "-r",
1383
+ "-U",
1384
+ "-u",
1385
+ "-I",
1386
+ "-a",
1387
+ "--basic-regexp",
1388
+ "--binary",
1389
+ "--byte-offset",
1390
+ "--color",
1391
+ "--colour",
1392
+ "--count",
1393
+ "--dereference-recursive",
1394
+ "--extended-regexp",
1395
+ "--files-with-matches",
1396
+ "--files-without-match",
1397
+ "--fixed-strings",
1398
+ "--help",
1399
+ "--ignore-case",
1400
+ "--initial-tab",
1401
+ "--invert-match",
1402
+ "--line-buffered",
1403
+ "--line-number",
1404
+ "--line-regexp",
1405
+ "--no-filename",
1406
+ "--no-group-separator",
1407
+ "--no-ignore-case",
1408
+ "--no-messages",
1409
+ "--null",
1410
+ "--null-data",
1411
+ "--only-matching",
1412
+ "--perl-regexp",
1413
+ "--quiet",
1414
+ "--recursive",
1415
+ "--silent",
1416
+ "--text",
1417
+ "--version",
1418
+ "--with-filename",
1419
+ "--word-regexp"
1420
+ ]),
1421
+ patternFlags: /* @__PURE__ */ new Set(["-e", "--regexp"]),
1422
+ noPatternFlags: /* @__PURE__ */ new Set(["-f", "--file"])
1423
+ };
1424
+ var PATTERN_VERBS = {
1425
+ grep: GREP_SHAPE,
1426
+ // /usr/bin/egrep and /usr/bin/fgrep are 41-byte shell wrappers that exec
1427
+ // `grep -E` and `grep -F`. Read in full, not assumed.
1428
+ egrep: GREP_SHAPE,
1429
+ fgrep: GREP_SHAPE,
1430
+ // ripgrep 14.1.1, complete from its own --help. An earlier comment here said rg
1431
+ // was not installed on the measuring machine; it is, and that stale claim is why
1432
+ // this table shipped incomplete for two rounds, blocking ordinary searches like
1433
+ // `rg --sort-files .env src` (/code-review round 4).
1434
+ rg: {
1435
+ takesValue: /* @__PURE__ */ new Set([
1436
+ "-A",
1437
+ "-B",
1438
+ "-C",
1439
+ "-d",
1440
+ "-E",
1441
+ "-e",
1442
+ "-f",
1443
+ "-g",
1444
+ "-j",
1445
+ "-M",
1446
+ "-m",
1447
+ "-r",
1448
+ "-t",
1449
+ "-T",
1450
+ "--after-context",
1451
+ "--before-context",
1452
+ "--color",
1453
+ "--colors",
1454
+ "--context",
1455
+ "--context-separator",
1456
+ "--dfa-size-limit",
1457
+ "--encoding",
1458
+ "--engine",
1459
+ "--field-context-separator",
1460
+ "--field-match-separator",
1461
+ "--file",
1462
+ "--generate",
1463
+ "--glob",
1464
+ "--hostname-bin",
1465
+ "--hyperlink-format",
1466
+ "--iglob",
1467
+ "--ignore-file",
1468
+ "--max-columns",
1469
+ "--max-count",
1470
+ "--max-depth",
1471
+ // An ALIAS of --max-depth, and it takes a value. The round-5 extraction read
1472
+ // alias lines as plain switches and swept it into noValue, which hard-blocked
1473
+ // `rg --maxdepth 2 .env src` while `--max-depth 2` ran (/code-review round 7).
1474
+ "--maxdepth",
1475
+ "--max-filesize",
1476
+ "--path-separator",
1477
+ "--pre",
1478
+ "--pre-glob",
1479
+ "--regexp",
1480
+ "--regex-size-limit",
1481
+ "--replace",
1482
+ "--sort",
1483
+ "--sortr",
1484
+ "--threads",
1485
+ "--type",
1486
+ "--type-add",
1487
+ "--type-clear",
1488
+ "--type-not"
1489
+ ]),
1490
+ noValue: /* @__PURE__ */ new Set([
1491
+ "-.",
1492
+ "-0",
1493
+ "-a",
1494
+ "-b",
1495
+ "-c",
1496
+ "-F",
1497
+ "-h",
1498
+ "-H",
1499
+ "-i",
1500
+ "-I",
1501
+ "-l",
1502
+ "-L",
1503
+ "-n",
1504
+ "-N",
1505
+ "-o",
1506
+ "-p",
1507
+ "-P",
1508
+ "-q",
1509
+ "-s",
1510
+ "-S",
1511
+ "-u",
1512
+ "-U",
1513
+ "-v",
1514
+ "-V",
1515
+ "-w",
1516
+ "-x",
1517
+ "-z",
1518
+ "--auto-hybrid-regex",
1519
+ "--binary",
1520
+ "--block-buffered",
1521
+ "--byte-offset",
1522
+ "--case-sensitive",
1523
+ "--column",
1524
+ "--count",
1525
+ "--count-matches",
1526
+ "--crlf",
1527
+ "--debug",
1528
+ "--files",
1529
+ "--files-with-matches",
1530
+ "--files-without-match",
1531
+ "--fixed-strings",
1532
+ "--follow",
1533
+ "--glob-case-insensitive",
1534
+ "--heading",
1535
+ "--help",
1536
+ "--hidden",
1537
+ "--ignore-case",
1538
+ "--ignore-file-case-insensitive",
1539
+ "--include-zero",
1540
+ "--invert-match",
1541
+ "--json",
1542
+ "--line-buffered",
1543
+ "--line-number",
1544
+ "--line-regexp",
1545
+ "--max-columns-preview",
1546
+ "--mmap",
1547
+ "--multiline",
1548
+ "--multiline-dotall",
1549
+ "--no-column",
1550
+ "--no-config",
1551
+ "--no-context-separator",
1552
+ "--no-encoding",
1553
+ "--no-filename",
1554
+ "--no-ignore",
1555
+ "--no-ignore-dot",
1556
+ "--no-ignore-exclude",
1557
+ "--no-ignore-files",
1558
+ "--no-ignore-global",
1559
+ "--no-ignore-messages",
1560
+ "--no-ignore-parent",
1561
+ "--no-ignore-vcs",
1562
+ "--no-line-number",
1563
+ "--no-messages",
1564
+ "--no-pcre2-unicode",
1565
+ "--no-pre",
1566
+ "--no-require-git",
1567
+ "--no-unicode",
1568
+ "--null",
1569
+ "--null-data",
1570
+ "--one-file-system",
1571
+ "--only-matching",
1572
+ "--passthru",
1573
+ "--pcre2",
1574
+ "--pcre2-version",
1575
+ "--pretty",
1576
+ "--print0",
1577
+ "--quiet",
1578
+ "--search-zip",
1579
+ "--smart-case",
1580
+ "--sort-files",
1581
+ "--stats",
1582
+ "--stop-on-nonmatch",
1583
+ "--text",
1584
+ "--trace",
1585
+ "--trim",
1586
+ "--type-list",
1587
+ "--unrestricted",
1588
+ "--version",
1589
+ "--vimgrep",
1590
+ "--with-filename",
1591
+ "--word-regexp",
1592
+ // The COMPLETE remainder of `rg --help`, added after /code-review round 5
1593
+ // found 40 documented switches in neither set. The table is now the whole
1594
+ // option list: `rg --help` yields 149 long options, 35 of them value-taking.
1595
+ "--ignore",
1596
+ "--ignore-dot",
1597
+ "--ignore-exclude",
1598
+ "--ignore-files",
1599
+ "--ignore-global",
1600
+ "--ignore-messages",
1601
+ "--ignore-parent",
1602
+ "--ignore-vcs",
1603
+ "--messages",
1604
+ "--no-auto-hybrid-regex",
1605
+ "--no-binary",
1606
+ "--no-block-buffered",
1607
+ "--no-byte-offset",
1608
+ "--no-crlf",
1609
+ "--no-fixed-strings",
1610
+ "--no-follow",
1611
+ "--no-glob-case-insensitive",
1612
+ "--no-heading",
1613
+ "--no-hidden",
1614
+ "--no-ignore-file-case-insensitive",
1615
+ "--no-include-zero",
1616
+ "--no-invert-match",
1617
+ "--no-json",
1618
+ "--no-line-buffered",
1619
+ "--no-max-columns-preview",
1620
+ "--no-mmap",
1621
+ "--no-multiline",
1622
+ "--no-multiline-dotall",
1623
+ "--no-one-file-system",
1624
+ "--no-pcre2",
1625
+ "--no-search-zip",
1626
+ "--no-sort-files",
1627
+ "--no-stats",
1628
+ "--no-text",
1629
+ "--no-trim",
1630
+ "--passthrough",
1631
+ "--pcre2-unicode",
1632
+ "--require-git",
1633
+ "--unicode"
1634
+ ]),
1635
+ patternFlags: /* @__PURE__ */ new Set(["-e", "--regexp"]),
1636
+ // `--files` and `--type-list` list or enumerate without a pattern. Founder
1637
+ // decision 2026-09-13: `rg --files ~/.ssh` stays BLOCKED, which this achieves
1638
+ // by leaving the directory in the judged list.
1639
+ noPatternFlags: /* @__PURE__ */ new Set(["-f", "--file", "--files", "--type-list", "--pcre2-version"])
1640
+ }
1641
+ };
1642
+ var PATTERN_VERB_NAMES = Object.keys(PATTERN_VERBS);
1643
+ var patternShapeOf = (verb) => PATTERN_VERBS[verb];
1644
+ var fileOperandFlagsOf = (verb) => FILE_OPERAND_FLAGS[verb];
1645
+ var GREP_FILE_OPERANDS = /* @__PURE__ */ new Set(["-f", "--file", "--exclude-from", "--include"]);
1646
+ var READER_VALUE_LETTERS = {
1647
+ awk: ["F", "v", "f"],
1648
+ gawk: ["F", "v", "f", "e", "E", "i", "l", "o", "p", "D"],
1649
+ sed: ["e", "f", "i", "l"],
1650
+ sort: ["C", "k", "o", "S", "t", "T"]
1651
+ };
1652
+ var FILE_OPERAND_FLAGS = {
1653
+ grep: GREP_FILE_OPERANDS,
1654
+ egrep: GREP_FILE_OPERANDS,
1655
+ fgrep: GREP_FILE_OPERANDS,
1656
+ // rg and gawk are NOT installed on the measuring machine: these two rows are
1657
+ // from the shipped documentation (ripgrep `-f/--file`, `--ignore-file`; gawk
1658
+ // `-f/--file`) and are marked as such in the spec.
1659
+ // `-g/--glob/--iglob` name which files ripgrep SEARCHES, so an operand naming
1660
+ // a credential makes rg open it -- the same reason grep's `--include` is here.
1661
+ // Measured (/code-review round 3): `rg --hidden -g .env AWS tree` opened .env
1662
+ // and printed its contents.
1663
+ rg: /* @__PURE__ */ new Set(["-f", "--file", "--ignore-file", "-g", "--glob", "--iglob"]),
1664
+ sed: /* @__PURE__ */ new Set(["-f", "--file"]),
1665
+ awk: /* @__PURE__ */ new Set(["-f", "--file"]),
1666
+ gawk: /* @__PURE__ */ new Set(["-f", "--file"]),
1667
+ sort: /* @__PURE__ */ new Set(["--files0-from"])
1668
+ };
1315
1669
  var SCP_VALUE_FLAGS = ["i", "F", "o", "c", "S", "P", "J", "D", "W", "l"];
1670
+ var TAR_VALUE_LETTERS = ["b", "C", "f", "F", "g", "H", "I", "K", "L", "N", "T", "V", "X"];
1671
+ var ZIP_VALUE_LETTERS = ["b", "n", "P", "t", "s", "O", "x", "i"];
1672
+ var RSYNC_VALUE_LETTERS = ["e", "f", "T", "B", "M"];
1316
1673
  var RSYNC_SKIP = [
1317
1674
  "e",
1318
1675
  "--rsh",
@@ -1324,21 +1681,35 @@ var RSYNC_SKIP = [
1324
1681
  "f",
1325
1682
  "--filter"
1326
1683
  ];
1684
+ var CP_VALUE_LETTERS = ["S", "t"];
1685
+ var INSTALL_VALUE_LETTERS = ["S", "t", "g", "m", "o"];
1327
1686
  var COPY_VERBS = {
1328
- cp: { source: "allButLast", targetDirFlag: true },
1329
- mv: { source: "allButLast", targetDirFlag: true },
1330
- install: { source: "allButLast", targetDirFlag: true },
1331
- ln: { source: "first", targetDirFlag: true },
1332
- scp: { source: "allButLast", skipFlags: SCP_VALUE_FLAGS },
1333
- rsync: { source: "allButLast", skipFlags: RSYNC_SKIP },
1687
+ cp: { source: "allButLast", targetDirFlag: true, valueLetters: CP_VALUE_LETTERS },
1688
+ mv: { source: "allButLast", targetDirFlag: true, valueLetters: CP_VALUE_LETTERS },
1689
+ install: { source: "allButLast", targetDirFlag: true, valueLetters: INSTALL_VALUE_LETTERS },
1690
+ ln: { source: "first", targetDirFlag: true, valueLetters: CP_VALUE_LETTERS },
1691
+ scp: { source: "allButLast", skipFlags: SCP_VALUE_FLAGS, valueLetters: SCP_VALUE_FLAGS },
1692
+ rsync: { source: "allButLast", skipFlags: RSYNC_SKIP, valueLetters: RSYNC_VALUE_LETTERS },
1334
1693
  tar: {
1335
1694
  source: "archive",
1336
1695
  archive: "tar",
1337
- skipFlags: ["f", "X", "T", "--file", "--exclude", "--exclude-from", "--files-from"]
1696
+ skipFlags: ["f", "X", "T", "--file", "--exclude", "--exclude-from", "--files-from"],
1697
+ valueLetters: TAR_VALUE_LETTERS
1698
+ },
1699
+ zip: {
1700
+ source: "archive",
1701
+ archive: "zip",
1702
+ skipFlags: ["x", "i", "--exclude", "--include"],
1703
+ valueLetters: ZIP_VALUE_LETTERS
1338
1704
  },
1339
- zip: { source: "archive", archive: "zip", skipFlags: ["x", "i", "--exclude", "--include"] },
1340
1705
  ar: { source: "archive", archive: "ar" },
1341
- "7z": { source: "archive", archive: "7z", skipFlags: ["x", "--exclude"] },
1706
+ // 7z switches are INLINE only (`-mx9`, `-px`, `-x!pat`), so no following word is
1707
+ // ever a switch's operand -- which is why the short `x` is NOT a skipFlag here
1708
+ // and valueLetters is empty. Keeping the short `x` made `7z a out.7z -mx KEY`
1709
+ // drop the credential as an exclusion operand (/code-review round 8), and the
1710
+ // derived "every skipped letter is a value letter" row in jail-copy.spec.ts is
1711
+ // what keeps the two tables honest about it.
1712
+ "7z": { source: "archive", archive: "7z", skipFlags: ["--exclude"], valueLetters: [] },
1342
1713
  gzip: { source: "all" },
1343
1714
  bzip2: { source: "all" },
1344
1715
  xz: { source: "all" },
@@ -2198,7 +2569,7 @@ function analyzeFsOperationImpl(command, depth = 0) {
2198
2569
  return result?.verdict !== "block";
2199
2570
  }
2200
2571
  if (nodeType !== "CallExpr") return true;
2201
- const { name, flags, paths, words } = extractLiteralArgs(n);
2572
+ const { name, flags, paths, words, args } = extractLiteralArgs(n);
2202
2573
  if (!name) return true;
2203
2574
  if (name === "rm") {
2204
2575
  const flagStr = flags.join("").toLowerCase();
@@ -2238,7 +2609,7 @@ function analyzeFsOperationImpl(command, depth = 0) {
2238
2609
  return true;
2239
2610
  }
2240
2611
  }
2241
- const readPaths = FS_READ_TOOLS.has(name) ? paths : wrappedReadPaths(words, name);
2612
+ const readPaths = FS_READ_TOOLS.has(name) ? [...readTargets(name, args, flags, words, 1), ...flagOperandFiles(name, words, 1)] : wrappedReadPaths(words, name);
2242
2613
  if (readPaths) {
2243
2614
  for (const p of readPaths) {
2244
2615
  result = stricter(result, matchSensitivePath2(p));
@@ -2274,9 +2645,22 @@ function flagIs(w, names) {
2274
2645
  const f = flagInfo(w);
2275
2646
  return names.some((n) => n.startsWith("--") ? f.long === n : f.letter === n);
2276
2647
  }
2277
- function operandOf(a, names) {
2648
+ function operandOf(a, names, valueLetters) {
2278
2649
  if (!names || a.afterFlag === null) return false;
2279
- return flagIs(a.afterFlag, names) && flagInfo(a.afterFlag).attached === null;
2650
+ const w = a.afterFlag;
2651
+ if (!w.startsWith("--") && valueLetters) {
2652
+ const letters = w.slice(1);
2653
+ for (let i = 0; i < letters.length; i++) {
2654
+ if (!valueLetters.includes(letters[i])) continue;
2655
+ return i === letters.length - 1 && names.includes(letters[i]);
2656
+ }
2657
+ return false;
2658
+ }
2659
+ if (w.startsWith("--") && !w.includes("=")) {
2660
+ const longs = names.filter((n) => n.startsWith("--"));
2661
+ if (longs.some((n) => n === w || w.length >= 3 && n.startsWith(w))) return true;
2662
+ }
2663
+ return flagIs(w, names) && flagInfo(w).attached === null;
2280
2664
  }
2281
2665
  function resolveCopyShape(words, h) {
2282
2666
  const verb = baseWord(words[h]);
@@ -2301,7 +2685,7 @@ function findStartPoints(words, h) {
2301
2685
  const k = words.findIndex((w, i) => i > h && w !== null && FIND_EXEC_FLAGS.has(w));
2302
2686
  if (k < 0) return { k, starts: [] };
2303
2687
  const firstPredicate = words.findIndex(
2304
- (w, i) => i > h && w !== null && w.startsWith("-") && !FIND_OPTIONS.has(w)
2688
+ (w, i) => i > h && w !== null && w !== "--" && w.startsWith("-") && !FIND_OPTIONS.has(w)
2305
2689
  );
2306
2690
  const end = firstPredicate > h ? firstPredicate : k;
2307
2691
  return { k, starts: positionalAfter(words, h + 1, end) };
@@ -2321,14 +2705,35 @@ function copySourcePaths(words) {
2321
2705
  const { shape, last } = r;
2322
2706
  const args = positionedArgs(words, last + 1);
2323
2707
  const tail = words.slice(last + 1);
2324
- const skipped = (a) => operandOf(a, shape.skipFlags);
2325
- const targetDir = shape.targetDirFlag === true && tail.some((w) => w !== null && w.startsWith("-") && flagIs(w, ["t", "--target-directory"]));
2326
- const targetOperand = (a) => targetDir && operandOf(a, ["t", "--target-directory"]);
2708
+ const copyOptionsEnd = tail.findIndex((w) => w === "--");
2709
+ const copyPastOptions = (a) => copyOptionsEnd >= 0 && a.argv > last + 1 + copyOptionsEnd;
2710
+ const skipped = (a) => !copyPastOptions(a) && operandOf(a, shape.skipFlags, shape.valueLetters);
2711
+ const firstValueLetter = (w) => {
2712
+ const letters = w.slice(1);
2713
+ for (let i = 0; i < letters.length; i++) {
2714
+ if ((shape.valueLetters ?? []).includes(letters[i]))
2715
+ return { letter: letters[i], last: i === letters.length - 1 };
2716
+ }
2717
+ return null;
2718
+ };
2719
+ const isTargetDirFlag = (w) => {
2720
+ if (w.startsWith("--")) {
2721
+ const name = w.includes("=") ? w.slice(0, w.indexOf("=")) : w;
2722
+ return name.length >= 3 && "--target-directory".startsWith(name);
2723
+ }
2724
+ return firstValueLetter(w)?.letter === "t";
2725
+ };
2726
+ const targetDir = shape.targetDirFlag === true && tail.some((w) => w !== null && w.startsWith("-") && isTargetDirFlag(w));
2727
+ const targetTakesNextWord = (w) => {
2728
+ if (w.startsWith("--"))
2729
+ return !w.includes("=") && w.length >= 3 && "--target-directory".startsWith(w);
2730
+ const f = firstValueLetter(w);
2731
+ return f !== null && f.letter === "t" && f.last;
2732
+ };
2733
+ const targetOperand = (a) => targetDir && a.afterFlag !== null && !copyPastOptions(a) && targetTakesNextWord(a.afterFlag);
2327
2734
  const lastOperand = [...tail].reverse().find((w) => w === null || !w.startsWith("-"));
2328
2735
  const dynamicDest = lastOperand === null;
2329
2736
  const destIsLastOperand = (shape.source === "allButLast" || shape.source === "first") && !targetDir && !dynamicDest;
2330
- if (destIsLastOperand && typeof lastOperand === "string" && matchSensitivePath2(lastOperand))
2331
- return [];
2332
2737
  let src;
2333
2738
  switch (shape.source) {
2334
2739
  case "all":
@@ -2338,11 +2743,17 @@ function copySourcePaths(words) {
2338
2743
  src = targetDir ? args : args.slice(0, 1);
2339
2744
  break;
2340
2745
  case "flagOperand": {
2341
- const inline = tail.filter((w) => w !== null && w.startsWith("--")).map((w) => flagInfo(w)).filter((f) => f.attached !== null && (shape.sourceFlags ?? []).includes(f.long ?? "")).map((f) => f.attached);
2746
+ const namesSource = (f) => {
2747
+ const names = shape.sourceFlags ?? [];
2748
+ if (f.long !== null)
2749
+ return names.some(
2750
+ (n) => n.startsWith("--") && (n === f.long || f.long.length >= 3 && n.startsWith(f.long))
2751
+ );
2752
+ return f.letter !== null && names.includes(f.letter);
2753
+ };
2754
+ const inline = tail.filter((w) => w !== null && w.startsWith("-")).map((w) => flagInfo(w)).filter((f) => f.attached !== null && namesSource(f)).map((f) => f.attached);
2342
2755
  return [
2343
- ...args.filter(
2344
- (a) => flagIs(a.afterFlag, shape.sourceFlags ?? []) && flagInfo(a.afterFlag).attached === null
2345
- ).map((a) => a.value),
2756
+ ...args.filter((a) => !copyPastOptions(a) && operandOf(a, shape.sourceFlags, shape.valueLetters)).map((a) => a.value),
2346
2757
  ...inline
2347
2758
  ];
2348
2759
  }
@@ -2353,7 +2764,14 @@ function copySourcePaths(words) {
2353
2764
  src = targetDir || dynamicDest ? args : args.slice(0, -1);
2354
2765
  break;
2355
2766
  }
2356
- return src.filter((a) => !skipped(a) && !targetOperand(a)).map((a) => a.value);
2767
+ const sources = src.filter((a) => !skipped(a) && !targetOperand(a)).map((a) => a.value);
2768
+ if (destIsLastOperand && typeof lastOperand === "string" && matchSensitivePath2(lastOperand)) {
2769
+ const jailedSources = sources.filter((p) => matchSensitivePath2(p));
2770
+ const dirOf = (p) => /[\\/]/.test(p) ? p.replace(/[\\/][^\\/]*$/, "") : "";
2771
+ if (jailedSources.length === 0) return [];
2772
+ if (jailedSources.every((p) => dirOf(p) === dirOf(lastOperand))) return [];
2773
+ }
2774
+ return sources;
2357
2775
  }
2358
2776
  function archiveInputs(kind, args, tail) {
2359
2777
  const first = args[0];
@@ -2365,13 +2783,17 @@ function archiveInputs(kind, args, tail) {
2365
2783
  const writing = /[cruA]/.test(bareKey ? first.value : "") || /(^|\s)-[a-zA-Z]*[cruA]|--create|--append|--update|--concatenate/.test(flagsText);
2366
2784
  if (extracting && !writing) return [];
2367
2785
  void mode;
2368
- let i = 0;
2369
- if (bareKey) {
2370
- i = 1;
2371
- const next = args[1];
2372
- if (first.value.includes("f") && next && next.afterFlag === null) i = 2;
2786
+ if (!bareKey) return args;
2787
+ let i = 1;
2788
+ const fromDirs = [];
2789
+ for (const ch of first.value) {
2790
+ if (!TAR_VALUE_LETTERS.includes(ch)) continue;
2791
+ const operand = args[i];
2792
+ if (!operand || operand.afterFlag !== null) break;
2793
+ i += 1;
2794
+ if (ch === "C") fromDirs.push(operand);
2373
2795
  }
2374
- return args.slice(i);
2796
+ return [...fromDirs, ...args.slice(i)];
2375
2797
  }
2376
2798
  if (kind === "zip") return first && first.afterFlag === "-" ? args : args.slice(1);
2377
2799
  if (kind === "ar") return bareKey ? args.slice(2) : args.slice(1);
@@ -2423,6 +2845,140 @@ function baseWord(w) {
2423
2845
  }
2424
2846
  var isReaderWord = (w) => w !== null && FS_READ_TOOLS.has(baseWord(w));
2425
2847
  var positionalAfter = (words, from, to = words.length) => positionedArgs(words, from, to).map((a) => a.value);
2848
+ function namesFlag(name, candidates, known) {
2849
+ if (candidates.has(name)) return true;
2850
+ if (!name.startsWith("--") || name.length < 3) return false;
2851
+ if (known?.has(name)) return false;
2852
+ for (const c of candidates) if (c.startsWith(name)) return true;
2853
+ return false;
2854
+ }
2855
+ function knownLongFlags(verb) {
2856
+ const out = /* @__PURE__ */ new Set();
2857
+ const shape = PATTERN_VERBS[verb];
2858
+ if (shape) {
2859
+ for (const set of [shape.takesValue, shape.noValue, shape.patternFlags, shape.noPatternFlags])
2860
+ for (const f of set) if (f.startsWith("--")) out.add(f);
2861
+ }
2862
+ for (const f of FILE_OPERAND_FLAGS[verb] ?? []) if (f.startsWith("--")) out.add(f);
2863
+ return out;
2864
+ }
2865
+ function flagNamesOf(token, shape) {
2866
+ if (token.startsWith("--")) {
2867
+ const eq = token.indexOf("=");
2868
+ return [eq > 0 ? token.slice(0, eq) : token];
2869
+ }
2870
+ const out = [];
2871
+ for (const c of token.slice(1)) {
2872
+ const name = `-${c}`;
2873
+ out.push(name);
2874
+ if (shape?.takesValue.has(name)) break;
2875
+ }
2876
+ return out;
2877
+ }
2878
+ var NONE = { kind: "none" };
2879
+ var UNKNOWN = { kind: "unknown" };
2880
+ function flagEffect(token, shape, known) {
2881
+ if (token === "--") return NONE;
2882
+ if (/^-+$/.test(token)) return UNKNOWN;
2883
+ if (/^-\d+$/.test(token)) return NONE;
2884
+ if (token.startsWith("--")) {
2885
+ if (token.includes("=")) return NONE;
2886
+ const takes = namesFlag(token, shape.takesValue, known);
2887
+ const none = namesFlag(token, shape.noValue, known);
2888
+ if (takes && none) return UNKNOWN;
2889
+ if (takes) return { kind: "takes", flag: token };
2890
+ if (none) return NONE;
2891
+ return UNKNOWN;
2892
+ }
2893
+ const letters = token.slice(1);
2894
+ if (!letters) return NONE;
2895
+ for (let i = 0; i < letters.length; i++) {
2896
+ const name = `-${letters[i]}`;
2897
+ if (shape.takesValue.has(name)) {
2898
+ return i === letters.length - 1 ? { kind: "takes", flag: name } : NONE;
2899
+ }
2900
+ if (!shape.noValue.has(name)) return UNKNOWN;
2901
+ }
2902
+ return NONE;
2903
+ }
2904
+ function readTargets(verb, args, flags, words = [], from = 1) {
2905
+ const shape = PATTERN_VERBS[verb];
2906
+ if (!shape) return args.map((a) => a.value);
2907
+ const known = knownLongFlags(verb);
2908
+ const names = flags.flatMap((f) => flagNamesOf(f, shape));
2909
+ const patternElsewhere = names.some(
2910
+ (n) => namesFlag(n, shape.patternFlags, known) || namesFlag(n, shape.noPatternFlags, known)
2911
+ );
2912
+ const excused = /* @__PURE__ */ new Set();
2913
+ const fileFlags = FILE_OPERAND_FLAGS[verb];
2914
+ const optionsEnd = words.findIndex((w, i) => i >= from && w === "--");
2915
+ const pastOptions = (a) => optionsEnd >= 0 && a.argv > optionsEnd;
2916
+ for (const a of args) {
2917
+ if (a.afterFlag === null || pastOptions(a)) continue;
2918
+ const e = flagEffect(a.afterFlag, shape, known);
2919
+ if (e.kind !== "takes") continue;
2920
+ if (fileFlags && namesFlag(e.flag, fileFlags, known)) continue;
2921
+ excused.add(a);
2922
+ }
2923
+ const patternArgv = (() => {
2924
+ for (let i = from; i < words.length; i++) {
2925
+ const w = words[i];
2926
+ if (w === null) return -1;
2927
+ if (w === "--") return i + 1;
2928
+ if (w.startsWith("-")) {
2929
+ const e = flagEffect(w, shape, known);
2930
+ if (e.kind === "takes") i += 1;
2931
+ else if (e.kind === "unknown") return -1;
2932
+ continue;
2933
+ }
2934
+ return i;
2935
+ }
2936
+ return -1;
2937
+ })();
2938
+ if (!patternElsewhere && patternArgv >= 0) {
2939
+ const a = args.find((x) => x.argv === patternArgv);
2940
+ if (a) excused.add(a);
2941
+ }
2942
+ return args.filter((a) => !excused.has(a)).map((a) => a.value);
2943
+ }
2944
+ function flagOperandFiles(verb, words, from) {
2945
+ const flags = FILE_OPERAND_FLAGS[verb];
2946
+ if (!flags) return [];
2947
+ const known = knownLongFlags(verb);
2948
+ const out = [];
2949
+ for (let i = from; i < words.length; i++) {
2950
+ const w = words[i];
2951
+ if (w === null || !w.startsWith("-") || w === "--") continue;
2952
+ if (w.startsWith("--")) {
2953
+ const eq = w.indexOf("=");
2954
+ if (eq <= 0) continue;
2955
+ const name = w.slice(0, eq);
2956
+ const value = w.slice(eq + 1);
2957
+ if (!value) continue;
2958
+ if (namesFlag(name, flags, known)) {
2959
+ out.push(value);
2960
+ continue;
2961
+ }
2962
+ const shape2 = PATTERN_VERBS[verb];
2963
+ if (!shape2) continue;
2964
+ const recognised = namesFlag(name, shape2.takesValue, known) || namesFlag(name, shape2.noValue, known) || namesFlag(name, shape2.patternFlags, known) || namesFlag(name, shape2.noPatternFlags, known);
2965
+ if (!recognised) out.push(value);
2966
+ continue;
2967
+ }
2968
+ const shape = PATTERN_VERBS[verb];
2969
+ const letters = w.slice(1);
2970
+ for (let j = 0; j < letters.length; j++) {
2971
+ const name = `-${letters[j]}`;
2972
+ const isFileFlag = flags.has(name);
2973
+ const argTaking = isFileFlag || (shape?.takesValue.has(name) ?? false) || (READER_VALUE_LETTERS[verb] ?? []).includes(letters[j]);
2974
+ if (!argTaking) continue;
2975
+ const attached = letters.slice(j + 1);
2976
+ if (isFileFlag && attached) out.push(attached);
2977
+ break;
2978
+ }
2979
+ }
2980
+ return out;
2981
+ }
2426
2982
  function wrappedReadPaths(words, name) {
2427
2983
  if (name === "find") {
2428
2984
  const { k, starts } = findStartPoints(words, 0);
@@ -2430,7 +2986,14 @@ function wrappedReadPaths(words, name) {
2430
2986
  }
2431
2987
  if (!COMMAND_WRAPPERS.has(name) && !RUNNER_WRAPPERS.has(name)) return null;
2432
2988
  const h = unwrapCommandHead(words);
2433
- return h > 0 && isReaderWord(words[h] ?? null) ? positionalAfter(words, h + 1) : null;
2989
+ if (h <= 0 || !isReaderWord(words[h] ?? null)) return null;
2990
+ const head = baseWord(words[h]);
2991
+ const rest = words.slice(h + 1);
2992
+ const restFlags = rest.filter((w) => w !== null && w.startsWith("-"));
2993
+ return [
2994
+ ...readTargets(head, positionedArgs(words, h + 1), restFlags, words, h + 1),
2995
+ ...flagOperandFiles(head, words, h + 1)
2996
+ ];
2434
2997
  }
2435
2998
  function literalShellPayload(words, name) {
2436
2999
  const h = COMMAND_WRAPPERS.has(name) || RUNNER_WRAPPERS.has(name) ? unwrapCommandHead(words) : 0;
@@ -4950,8 +5513,8 @@ function matchCanaryArgs(args, values) {
4950
5513
 
4951
5514
  // src/scan/canonical.ts
4952
5515
  var LONG_OUTPUT_THRESHOLD_BYTES = 100 * 1024;
4953
- var CANONICAL_EXTRACTOR_VERSION = "canonical-v14";
4954
- var CANONICAL_EXTRACTOR_HASH = "8b5729fe236a195b";
5516
+ var CANONICAL_EXTRACTOR_VERSION = "canonical-v15";
5517
+ var CANONICAL_EXTRACTOR_HASH = "5dad4c8f07121f6c";
4955
5518
  var DEDUPE_PREVIEW_LEN = 120;
4956
5519
  function extractCanonicalFindings(call, ctx) {
4957
5520
  const out = [];
@@ -5342,6 +5905,7 @@ export {
5342
5905
  LOOP_MAX_RECORDS,
5343
5906
  LOOP_THRESHOLD_FOR_WASTE,
5344
5907
  NET_BINARIES,
5908
+ PATTERN_VERB_NAMES,
5345
5909
  PRIVILEGE_ESCALATION_RE,
5346
5910
  REALTIME_PII_PATTERNS,
5347
5911
  SCAN_SIGNAL_WEIGHTS,
@@ -5378,6 +5942,7 @@ export {
5378
5942
  extractSessionLevelFindings,
5379
5943
  extractShellDestTokens,
5380
5944
  extractShellDestinations,
5945
+ fileOperandFlagsOf,
5381
5946
  getCompiledRegex,
5382
5947
  getNestedValue,
5383
5948
  hostMatches,
@@ -5397,6 +5962,7 @@ export {
5397
5962
  normalizeIpLiteral,
5398
5963
  parseAllSshHostsFromCommand,
5399
5964
  parseDestHost,
5965
+ patternShapeOf,
5400
5966
  positionedArgs,
5401
5967
  previewArgs,
5402
5968
  redactText,