@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.js CHANGED
@@ -52,6 +52,7 @@ __export(src_exports, {
52
52
  LOOP_MAX_RECORDS: () => LOOP_MAX_RECORDS,
53
53
  LOOP_THRESHOLD_FOR_WASTE: () => LOOP_THRESHOLD_FOR_WASTE,
54
54
  NET_BINARIES: () => NET_BINARIES,
55
+ PATTERN_VERB_NAMES: () => PATTERN_VERB_NAMES,
55
56
  PRIVILEGE_ESCALATION_RE: () => PRIVILEGE_ESCALATION_RE,
56
57
  REALTIME_PII_PATTERNS: () => REALTIME_PII_PATTERNS,
57
58
  SCAN_SIGNAL_WEIGHTS: () => SCAN_SIGNAL_WEIGHTS,
@@ -88,6 +89,7 @@ __export(src_exports, {
88
89
  extractSessionLevelFindings: () => extractSessionLevelFindings,
89
90
  extractShellDestTokens: () => extractShellDestTokens,
90
91
  extractShellDestinations: () => extractShellDestinations,
92
+ fileOperandFlagsOf: () => fileOperandFlagsOf,
91
93
  getCompiledRegex: () => getCompiledRegex,
92
94
  getNestedValue: () => getNestedValue,
93
95
  hostMatches: () => hostMatches,
@@ -107,6 +109,7 @@ __export(src_exports, {
107
109
  normalizeIpLiteral: () => normalizeIpLiteral,
108
110
  parseAllSshHostsFromCommand: () => parseAllSshHostsFromCommand,
109
111
  parseDestHost: () => parseDestHost,
112
+ patternShapeOf: () => patternShapeOf,
110
113
  positionedArgs: () => positionedArgs,
111
114
  previewArgs: () => previewArgs,
112
115
  redactText: () => redactText,
@@ -1254,6 +1257,19 @@ function parseShared(command) {
1254
1257
  astCache.set(command, parsed);
1255
1258
  return parsed;
1256
1259
  }
1260
+ function byteOffsetToCharIndex(command) {
1261
+ if (!/[^\u0000-\u007F]/.test(command)) return null;
1262
+ const map = /* @__PURE__ */ new Map();
1263
+ let byte = 0;
1264
+ for (let i = 0; i < command.length; ) {
1265
+ map.set(byte, i);
1266
+ const cp = command.codePointAt(i);
1267
+ byte += cp < 128 ? 1 : cp < 2048 ? 2 : cp < 65536 ? 3 : 4;
1268
+ i += cp > 65535 ? 2 : 1;
1269
+ }
1270
+ map.set(byte, command.length);
1271
+ return (b) => map.get(b) ?? -1;
1272
+ }
1257
1273
  function cachedNormalize(command, compute) {
1258
1274
  const hit = normalizeCache.get(command);
1259
1275
  if (hit !== void 0) {
@@ -1283,6 +1299,8 @@ function normalizeCommandForPolicyImpl(command) {
1283
1299
  const f = parseShared(command);
1284
1300
  if (f === PARSE_FAIL) return { posix: command, separator: command };
1285
1301
  try {
1302
+ const toCharIndex = byteOffsetToCharIndex(command);
1303
+ const at = (byteOffset) => toCharIndex === null ? byteOffset : toCharIndex(byteOffset);
1286
1304
  const strips = [];
1287
1305
  const rewrites = [];
1288
1306
  const quoteOnlyRewrites = [];
@@ -1303,8 +1321,9 @@ function normalizeCommandForPolicyImpl(command) {
1303
1321
  const quotedNode = nextParts[0];
1304
1322
  const nt = syntax.NodeType(quotedNode);
1305
1323
  const markStrip = () => {
1306
- const s = next.Pos().Offset();
1307
- const e = next.End().Offset();
1324
+ const s = at(next.Pos().Offset());
1325
+ const e = at(next.End().Offset());
1326
+ if (s < 0 || e < 0) return;
1308
1327
  strips.push([s, e]);
1309
1328
  msgSpans.add(`${s}:${e}`);
1310
1329
  };
@@ -1321,8 +1340,9 @@ function normalizeCommandForPolicyImpl(command) {
1321
1340
  }
1322
1341
  }
1323
1342
  for (const arg of args) {
1324
- const s = arg.Pos().Offset();
1325
- const e = arg.End().Offset();
1343
+ const s = at(arg.Pos().Offset());
1344
+ const e = at(arg.End().Offset());
1345
+ if (s < 0 || e < 0) continue;
1326
1346
  if (msgSpans.has(`${s}:${e}`)) continue;
1327
1347
  const resolved = resolveWordLiteral(arg);
1328
1348
  if (resolved === null) continue;
@@ -1445,7 +1465,347 @@ var FS_READ_TOOLS = /* @__PURE__ */ new Set([
1445
1465
  "nl",
1446
1466
  "dd"
1447
1467
  ]);
1468
+ var GREP_SHAPE = {
1469
+ takesValue: /* @__PURE__ */ new Set([
1470
+ "-A",
1471
+ "-B",
1472
+ "-C",
1473
+ "-D",
1474
+ "-d",
1475
+ "-e",
1476
+ "-f",
1477
+ "-m",
1478
+ "--after-context",
1479
+ "--before-context",
1480
+ "--binary-files",
1481
+ "--context",
1482
+ "--devices",
1483
+ "--directories",
1484
+ "--exclude",
1485
+ "--exclude-dir",
1486
+ "--exclude-from",
1487
+ "--file",
1488
+ "--include",
1489
+ "--label",
1490
+ "--max-count",
1491
+ "--regexp"
1492
+ ]),
1493
+ noValue: /* @__PURE__ */ new Set([
1494
+ "-E",
1495
+ "-F",
1496
+ "-G",
1497
+ "-P",
1498
+ "-i",
1499
+ "-y",
1500
+ "-v",
1501
+ "-V",
1502
+ "-w",
1503
+ "-x",
1504
+ "-c",
1505
+ "-l",
1506
+ "-L",
1507
+ "-o",
1508
+ "-q",
1509
+ "-s",
1510
+ "-b",
1511
+ "-H",
1512
+ "-h",
1513
+ "-n",
1514
+ "-T",
1515
+ "-Z",
1516
+ "-z",
1517
+ "-R",
1518
+ "-r",
1519
+ "-U",
1520
+ "-u",
1521
+ "-I",
1522
+ "-a",
1523
+ "--basic-regexp",
1524
+ "--binary",
1525
+ "--byte-offset",
1526
+ "--color",
1527
+ "--colour",
1528
+ "--count",
1529
+ "--dereference-recursive",
1530
+ "--extended-regexp",
1531
+ "--files-with-matches",
1532
+ "--files-without-match",
1533
+ "--fixed-strings",
1534
+ "--help",
1535
+ "--ignore-case",
1536
+ "--initial-tab",
1537
+ "--invert-match",
1538
+ "--line-buffered",
1539
+ "--line-number",
1540
+ "--line-regexp",
1541
+ "--no-filename",
1542
+ "--no-group-separator",
1543
+ "--no-ignore-case",
1544
+ "--no-messages",
1545
+ "--null",
1546
+ "--null-data",
1547
+ "--only-matching",
1548
+ "--perl-regexp",
1549
+ "--quiet",
1550
+ "--recursive",
1551
+ "--silent",
1552
+ "--text",
1553
+ "--version",
1554
+ "--with-filename",
1555
+ "--word-regexp"
1556
+ ]),
1557
+ patternFlags: /* @__PURE__ */ new Set(["-e", "--regexp"]),
1558
+ noPatternFlags: /* @__PURE__ */ new Set(["-f", "--file"])
1559
+ };
1560
+ var PATTERN_VERBS = {
1561
+ grep: GREP_SHAPE,
1562
+ // /usr/bin/egrep and /usr/bin/fgrep are 41-byte shell wrappers that exec
1563
+ // `grep -E` and `grep -F`. Read in full, not assumed.
1564
+ egrep: GREP_SHAPE,
1565
+ fgrep: GREP_SHAPE,
1566
+ // ripgrep 14.1.1, complete from its own --help. An earlier comment here said rg
1567
+ // was not installed on the measuring machine; it is, and that stale claim is why
1568
+ // this table shipped incomplete for two rounds, blocking ordinary searches like
1569
+ // `rg --sort-files .env src` (/code-review round 4).
1570
+ rg: {
1571
+ takesValue: /* @__PURE__ */ new Set([
1572
+ "-A",
1573
+ "-B",
1574
+ "-C",
1575
+ "-d",
1576
+ "-E",
1577
+ "-e",
1578
+ "-f",
1579
+ "-g",
1580
+ "-j",
1581
+ "-M",
1582
+ "-m",
1583
+ "-r",
1584
+ "-t",
1585
+ "-T",
1586
+ "--after-context",
1587
+ "--before-context",
1588
+ "--color",
1589
+ "--colors",
1590
+ "--context",
1591
+ "--context-separator",
1592
+ "--dfa-size-limit",
1593
+ "--encoding",
1594
+ "--engine",
1595
+ "--field-context-separator",
1596
+ "--field-match-separator",
1597
+ "--file",
1598
+ "--generate",
1599
+ "--glob",
1600
+ "--hostname-bin",
1601
+ "--hyperlink-format",
1602
+ "--iglob",
1603
+ "--ignore-file",
1604
+ "--max-columns",
1605
+ "--max-count",
1606
+ "--max-depth",
1607
+ // An ALIAS of --max-depth, and it takes a value. The round-5 extraction read
1608
+ // alias lines as plain switches and swept it into noValue, which hard-blocked
1609
+ // `rg --maxdepth 2 .env src` while `--max-depth 2` ran (/code-review round 7).
1610
+ "--maxdepth",
1611
+ "--max-filesize",
1612
+ "--path-separator",
1613
+ "--pre",
1614
+ "--pre-glob",
1615
+ "--regexp",
1616
+ "--regex-size-limit",
1617
+ "--replace",
1618
+ "--sort",
1619
+ "--sortr",
1620
+ "--threads",
1621
+ "--type",
1622
+ "--type-add",
1623
+ "--type-clear",
1624
+ "--type-not"
1625
+ ]),
1626
+ noValue: /* @__PURE__ */ new Set([
1627
+ "-.",
1628
+ "-0",
1629
+ "-a",
1630
+ "-b",
1631
+ "-c",
1632
+ "-F",
1633
+ "-h",
1634
+ "-H",
1635
+ "-i",
1636
+ "-I",
1637
+ "-l",
1638
+ "-L",
1639
+ "-n",
1640
+ "-N",
1641
+ "-o",
1642
+ "-p",
1643
+ "-P",
1644
+ "-q",
1645
+ "-s",
1646
+ "-S",
1647
+ "-u",
1648
+ "-U",
1649
+ "-v",
1650
+ "-V",
1651
+ "-w",
1652
+ "-x",
1653
+ "-z",
1654
+ "--auto-hybrid-regex",
1655
+ "--binary",
1656
+ "--block-buffered",
1657
+ "--byte-offset",
1658
+ "--case-sensitive",
1659
+ "--column",
1660
+ "--count",
1661
+ "--count-matches",
1662
+ "--crlf",
1663
+ "--debug",
1664
+ "--files",
1665
+ "--files-with-matches",
1666
+ "--files-without-match",
1667
+ "--fixed-strings",
1668
+ "--follow",
1669
+ "--glob-case-insensitive",
1670
+ "--heading",
1671
+ "--help",
1672
+ "--hidden",
1673
+ "--ignore-case",
1674
+ "--ignore-file-case-insensitive",
1675
+ "--include-zero",
1676
+ "--invert-match",
1677
+ "--json",
1678
+ "--line-buffered",
1679
+ "--line-number",
1680
+ "--line-regexp",
1681
+ "--max-columns-preview",
1682
+ "--mmap",
1683
+ "--multiline",
1684
+ "--multiline-dotall",
1685
+ "--no-column",
1686
+ "--no-config",
1687
+ "--no-context-separator",
1688
+ "--no-encoding",
1689
+ "--no-filename",
1690
+ "--no-ignore",
1691
+ "--no-ignore-dot",
1692
+ "--no-ignore-exclude",
1693
+ "--no-ignore-files",
1694
+ "--no-ignore-global",
1695
+ "--no-ignore-messages",
1696
+ "--no-ignore-parent",
1697
+ "--no-ignore-vcs",
1698
+ "--no-line-number",
1699
+ "--no-messages",
1700
+ "--no-pcre2-unicode",
1701
+ "--no-pre",
1702
+ "--no-require-git",
1703
+ "--no-unicode",
1704
+ "--null",
1705
+ "--null-data",
1706
+ "--one-file-system",
1707
+ "--only-matching",
1708
+ "--passthru",
1709
+ "--pcre2",
1710
+ "--pcre2-version",
1711
+ "--pretty",
1712
+ "--print0",
1713
+ "--quiet",
1714
+ "--search-zip",
1715
+ "--smart-case",
1716
+ "--sort-files",
1717
+ "--stats",
1718
+ "--stop-on-nonmatch",
1719
+ "--text",
1720
+ "--trace",
1721
+ "--trim",
1722
+ "--type-list",
1723
+ "--unrestricted",
1724
+ "--version",
1725
+ "--vimgrep",
1726
+ "--with-filename",
1727
+ "--word-regexp",
1728
+ // The COMPLETE remainder of `rg --help`, added after /code-review round 5
1729
+ // found 40 documented switches in neither set. The table is now the whole
1730
+ // option list: `rg --help` yields 149 long options, 35 of them value-taking.
1731
+ "--ignore",
1732
+ "--ignore-dot",
1733
+ "--ignore-exclude",
1734
+ "--ignore-files",
1735
+ "--ignore-global",
1736
+ "--ignore-messages",
1737
+ "--ignore-parent",
1738
+ "--ignore-vcs",
1739
+ "--messages",
1740
+ "--no-auto-hybrid-regex",
1741
+ "--no-binary",
1742
+ "--no-block-buffered",
1743
+ "--no-byte-offset",
1744
+ "--no-crlf",
1745
+ "--no-fixed-strings",
1746
+ "--no-follow",
1747
+ "--no-glob-case-insensitive",
1748
+ "--no-heading",
1749
+ "--no-hidden",
1750
+ "--no-ignore-file-case-insensitive",
1751
+ "--no-include-zero",
1752
+ "--no-invert-match",
1753
+ "--no-json",
1754
+ "--no-line-buffered",
1755
+ "--no-max-columns-preview",
1756
+ "--no-mmap",
1757
+ "--no-multiline",
1758
+ "--no-multiline-dotall",
1759
+ "--no-one-file-system",
1760
+ "--no-pcre2",
1761
+ "--no-search-zip",
1762
+ "--no-sort-files",
1763
+ "--no-stats",
1764
+ "--no-text",
1765
+ "--no-trim",
1766
+ "--passthrough",
1767
+ "--pcre2-unicode",
1768
+ "--require-git",
1769
+ "--unicode"
1770
+ ]),
1771
+ patternFlags: /* @__PURE__ */ new Set(["-e", "--regexp"]),
1772
+ // `--files` and `--type-list` list or enumerate without a pattern. Founder
1773
+ // decision 2026-09-13: `rg --files ~/.ssh` stays BLOCKED, which this achieves
1774
+ // by leaving the directory in the judged list.
1775
+ noPatternFlags: /* @__PURE__ */ new Set(["-f", "--file", "--files", "--type-list", "--pcre2-version"])
1776
+ }
1777
+ };
1778
+ var PATTERN_VERB_NAMES = Object.keys(PATTERN_VERBS);
1779
+ var patternShapeOf = (verb) => PATTERN_VERBS[verb];
1780
+ var fileOperandFlagsOf = (verb) => FILE_OPERAND_FLAGS[verb];
1781
+ var GREP_FILE_OPERANDS = /* @__PURE__ */ new Set(["-f", "--file", "--exclude-from", "--include"]);
1782
+ var READER_VALUE_LETTERS = {
1783
+ awk: ["F", "v", "f"],
1784
+ gawk: ["F", "v", "f", "e", "E", "i", "l", "o", "p", "D"],
1785
+ sed: ["e", "f", "i", "l"],
1786
+ sort: ["C", "k", "o", "S", "t", "T"]
1787
+ };
1788
+ var FILE_OPERAND_FLAGS = {
1789
+ grep: GREP_FILE_OPERANDS,
1790
+ egrep: GREP_FILE_OPERANDS,
1791
+ fgrep: GREP_FILE_OPERANDS,
1792
+ // rg and gawk are NOT installed on the measuring machine: these two rows are
1793
+ // from the shipped documentation (ripgrep `-f/--file`, `--ignore-file`; gawk
1794
+ // `-f/--file`) and are marked as such in the spec.
1795
+ // `-g/--glob/--iglob` name which files ripgrep SEARCHES, so an operand naming
1796
+ // a credential makes rg open it -- the same reason grep's `--include` is here.
1797
+ // Measured (/code-review round 3): `rg --hidden -g .env AWS tree` opened .env
1798
+ // and printed its contents.
1799
+ rg: /* @__PURE__ */ new Set(["-f", "--file", "--ignore-file", "-g", "--glob", "--iglob"]),
1800
+ sed: /* @__PURE__ */ new Set(["-f", "--file"]),
1801
+ awk: /* @__PURE__ */ new Set(["-f", "--file"]),
1802
+ gawk: /* @__PURE__ */ new Set(["-f", "--file"]),
1803
+ sort: /* @__PURE__ */ new Set(["--files0-from"])
1804
+ };
1448
1805
  var SCP_VALUE_FLAGS = ["i", "F", "o", "c", "S", "P", "J", "D", "W", "l"];
1806
+ var TAR_VALUE_LETTERS = ["b", "C", "f", "F", "g", "H", "I", "K", "L", "N", "T", "V", "X"];
1807
+ var ZIP_VALUE_LETTERS = ["b", "n", "P", "t", "s", "O", "x", "i"];
1808
+ var RSYNC_VALUE_LETTERS = ["e", "f", "T", "B", "M"];
1449
1809
  var RSYNC_SKIP = [
1450
1810
  "e",
1451
1811
  "--rsh",
@@ -1457,21 +1817,35 @@ var RSYNC_SKIP = [
1457
1817
  "f",
1458
1818
  "--filter"
1459
1819
  ];
1820
+ var CP_VALUE_LETTERS = ["S", "t"];
1821
+ var INSTALL_VALUE_LETTERS = ["S", "t", "g", "m", "o"];
1460
1822
  var COPY_VERBS = {
1461
- cp: { source: "allButLast", targetDirFlag: true },
1462
- mv: { source: "allButLast", targetDirFlag: true },
1463
- install: { source: "allButLast", targetDirFlag: true },
1464
- ln: { source: "first", targetDirFlag: true },
1465
- scp: { source: "allButLast", skipFlags: SCP_VALUE_FLAGS },
1466
- rsync: { source: "allButLast", skipFlags: RSYNC_SKIP },
1823
+ cp: { source: "allButLast", targetDirFlag: true, valueLetters: CP_VALUE_LETTERS },
1824
+ mv: { source: "allButLast", targetDirFlag: true, valueLetters: CP_VALUE_LETTERS },
1825
+ install: { source: "allButLast", targetDirFlag: true, valueLetters: INSTALL_VALUE_LETTERS },
1826
+ ln: { source: "first", targetDirFlag: true, valueLetters: CP_VALUE_LETTERS },
1827
+ scp: { source: "allButLast", skipFlags: SCP_VALUE_FLAGS, valueLetters: SCP_VALUE_FLAGS },
1828
+ rsync: { source: "allButLast", skipFlags: RSYNC_SKIP, valueLetters: RSYNC_VALUE_LETTERS },
1467
1829
  tar: {
1468
1830
  source: "archive",
1469
1831
  archive: "tar",
1470
- skipFlags: ["f", "X", "T", "--file", "--exclude", "--exclude-from", "--files-from"]
1832
+ skipFlags: ["f", "X", "T", "--file", "--exclude", "--exclude-from", "--files-from"],
1833
+ valueLetters: TAR_VALUE_LETTERS
1834
+ },
1835
+ zip: {
1836
+ source: "archive",
1837
+ archive: "zip",
1838
+ skipFlags: ["x", "i", "--exclude", "--include"],
1839
+ valueLetters: ZIP_VALUE_LETTERS
1471
1840
  },
1472
- zip: { source: "archive", archive: "zip", skipFlags: ["x", "i", "--exclude", "--include"] },
1473
1841
  ar: { source: "archive", archive: "ar" },
1474
- "7z": { source: "archive", archive: "7z", skipFlags: ["x", "--exclude"] },
1842
+ // 7z switches are INLINE only (`-mx9`, `-px`, `-x!pat`), so no following word is
1843
+ // ever a switch's operand -- which is why the short `x` is NOT a skipFlag here
1844
+ // and valueLetters is empty. Keeping the short `x` made `7z a out.7z -mx KEY`
1845
+ // drop the credential as an exclusion operand (/code-review round 8), and the
1846
+ // derived "every skipped letter is a value letter" row in jail-copy.spec.ts is
1847
+ // what keeps the two tables honest about it.
1848
+ "7z": { source: "archive", archive: "7z", skipFlags: ["--exclude"], valueLetters: [] },
1475
1849
  gzip: { source: "all" },
1476
1850
  bzip2: { source: "all" },
1477
1851
  xz: { source: "all" },
@@ -2331,7 +2705,7 @@ function analyzeFsOperationImpl(command, depth = 0) {
2331
2705
  return result?.verdict !== "block";
2332
2706
  }
2333
2707
  if (nodeType !== "CallExpr") return true;
2334
- const { name, flags, paths, words } = extractLiteralArgs(n);
2708
+ const { name, flags, paths, words, args } = extractLiteralArgs(n);
2335
2709
  if (!name) return true;
2336
2710
  if (name === "rm") {
2337
2711
  const flagStr = flags.join("").toLowerCase();
@@ -2371,7 +2745,7 @@ function analyzeFsOperationImpl(command, depth = 0) {
2371
2745
  return true;
2372
2746
  }
2373
2747
  }
2374
- const readPaths = FS_READ_TOOLS.has(name) ? paths : wrappedReadPaths(words, name);
2748
+ const readPaths = FS_READ_TOOLS.has(name) ? [...readTargets(name, args, flags, words, 1), ...flagOperandFiles(name, words, 1)] : wrappedReadPaths(words, name);
2375
2749
  if (readPaths) {
2376
2750
  for (const p of readPaths) {
2377
2751
  result = stricter(result, matchSensitivePath2(p));
@@ -2407,9 +2781,22 @@ function flagIs(w, names) {
2407
2781
  const f = flagInfo(w);
2408
2782
  return names.some((n) => n.startsWith("--") ? f.long === n : f.letter === n);
2409
2783
  }
2410
- function operandOf(a, names) {
2784
+ function operandOf(a, names, valueLetters) {
2411
2785
  if (!names || a.afterFlag === null) return false;
2412
- return flagIs(a.afterFlag, names) && flagInfo(a.afterFlag).attached === null;
2786
+ const w = a.afterFlag;
2787
+ if (!w.startsWith("--") && valueLetters) {
2788
+ const letters = w.slice(1);
2789
+ for (let i = 0; i < letters.length; i++) {
2790
+ if (!valueLetters.includes(letters[i])) continue;
2791
+ return i === letters.length - 1 && names.includes(letters[i]);
2792
+ }
2793
+ return false;
2794
+ }
2795
+ if (w.startsWith("--") && !w.includes("=")) {
2796
+ const longs = names.filter((n) => n.startsWith("--"));
2797
+ if (longs.some((n) => n === w || w.length >= 3 && n.startsWith(w))) return true;
2798
+ }
2799
+ return flagIs(w, names) && flagInfo(w).attached === null;
2413
2800
  }
2414
2801
  function resolveCopyShape(words, h) {
2415
2802
  const verb = baseWord(words[h]);
@@ -2434,7 +2821,7 @@ function findStartPoints(words, h) {
2434
2821
  const k = words.findIndex((w, i) => i > h && w !== null && FIND_EXEC_FLAGS.has(w));
2435
2822
  if (k < 0) return { k, starts: [] };
2436
2823
  const firstPredicate = words.findIndex(
2437
- (w, i) => i > h && w !== null && w.startsWith("-") && !FIND_OPTIONS.has(w)
2824
+ (w, i) => i > h && w !== null && w !== "--" && w.startsWith("-") && !FIND_OPTIONS.has(w)
2438
2825
  );
2439
2826
  const end = firstPredicate > h ? firstPredicate : k;
2440
2827
  return { k, starts: positionalAfter(words, h + 1, end) };
@@ -2454,14 +2841,35 @@ function copySourcePaths(words) {
2454
2841
  const { shape, last } = r;
2455
2842
  const args = positionedArgs(words, last + 1);
2456
2843
  const tail = words.slice(last + 1);
2457
- const skipped = (a) => operandOf(a, shape.skipFlags);
2458
- const targetDir = shape.targetDirFlag === true && tail.some((w) => w !== null && w.startsWith("-") && flagIs(w, ["t", "--target-directory"]));
2459
- const targetOperand = (a) => targetDir && operandOf(a, ["t", "--target-directory"]);
2844
+ const copyOptionsEnd = tail.findIndex((w) => w === "--");
2845
+ const copyPastOptions = (a) => copyOptionsEnd >= 0 && a.argv > last + 1 + copyOptionsEnd;
2846
+ const skipped = (a) => !copyPastOptions(a) && operandOf(a, shape.skipFlags, shape.valueLetters);
2847
+ const firstValueLetter = (w) => {
2848
+ const letters = w.slice(1);
2849
+ for (let i = 0; i < letters.length; i++) {
2850
+ if ((shape.valueLetters ?? []).includes(letters[i]))
2851
+ return { letter: letters[i], last: i === letters.length - 1 };
2852
+ }
2853
+ return null;
2854
+ };
2855
+ const isTargetDirFlag = (w) => {
2856
+ if (w.startsWith("--")) {
2857
+ const name = w.includes("=") ? w.slice(0, w.indexOf("=")) : w;
2858
+ return name.length >= 3 && "--target-directory".startsWith(name);
2859
+ }
2860
+ return firstValueLetter(w)?.letter === "t";
2861
+ };
2862
+ const targetDir = shape.targetDirFlag === true && tail.some((w) => w !== null && w.startsWith("-") && isTargetDirFlag(w));
2863
+ const targetTakesNextWord = (w) => {
2864
+ if (w.startsWith("--"))
2865
+ return !w.includes("=") && w.length >= 3 && "--target-directory".startsWith(w);
2866
+ const f = firstValueLetter(w);
2867
+ return f !== null && f.letter === "t" && f.last;
2868
+ };
2869
+ const targetOperand = (a) => targetDir && a.afterFlag !== null && !copyPastOptions(a) && targetTakesNextWord(a.afterFlag);
2460
2870
  const lastOperand = [...tail].reverse().find((w) => w === null || !w.startsWith("-"));
2461
2871
  const dynamicDest = lastOperand === null;
2462
2872
  const destIsLastOperand = (shape.source === "allButLast" || shape.source === "first") && !targetDir && !dynamicDest;
2463
- if (destIsLastOperand && typeof lastOperand === "string" && matchSensitivePath2(lastOperand))
2464
- return [];
2465
2873
  let src;
2466
2874
  switch (shape.source) {
2467
2875
  case "all":
@@ -2471,11 +2879,17 @@ function copySourcePaths(words) {
2471
2879
  src = targetDir ? args : args.slice(0, 1);
2472
2880
  break;
2473
2881
  case "flagOperand": {
2474
- 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);
2882
+ const namesSource = (f) => {
2883
+ const names = shape.sourceFlags ?? [];
2884
+ if (f.long !== null)
2885
+ return names.some(
2886
+ (n) => n.startsWith("--") && (n === f.long || f.long.length >= 3 && n.startsWith(f.long))
2887
+ );
2888
+ return f.letter !== null && names.includes(f.letter);
2889
+ };
2890
+ const inline = tail.filter((w) => w !== null && w.startsWith("-")).map((w) => flagInfo(w)).filter((f) => f.attached !== null && namesSource(f)).map((f) => f.attached);
2475
2891
  return [
2476
- ...args.filter(
2477
- (a) => flagIs(a.afterFlag, shape.sourceFlags ?? []) && flagInfo(a.afterFlag).attached === null
2478
- ).map((a) => a.value),
2892
+ ...args.filter((a) => !copyPastOptions(a) && operandOf(a, shape.sourceFlags, shape.valueLetters)).map((a) => a.value),
2479
2893
  ...inline
2480
2894
  ];
2481
2895
  }
@@ -2486,7 +2900,14 @@ function copySourcePaths(words) {
2486
2900
  src = targetDir || dynamicDest ? args : args.slice(0, -1);
2487
2901
  break;
2488
2902
  }
2489
- return src.filter((a) => !skipped(a) && !targetOperand(a)).map((a) => a.value);
2903
+ const sources = src.filter((a) => !skipped(a) && !targetOperand(a)).map((a) => a.value);
2904
+ if (destIsLastOperand && typeof lastOperand === "string" && matchSensitivePath2(lastOperand)) {
2905
+ const jailedSources = sources.filter((p) => matchSensitivePath2(p));
2906
+ const dirOf = (p) => /[\\/]/.test(p) ? p.replace(/[\\/][^\\/]*$/, "") : "";
2907
+ if (jailedSources.length === 0) return [];
2908
+ if (jailedSources.every((p) => dirOf(p) === dirOf(lastOperand))) return [];
2909
+ }
2910
+ return sources;
2490
2911
  }
2491
2912
  function archiveInputs(kind, args, tail) {
2492
2913
  const first = args[0];
@@ -2498,13 +2919,17 @@ function archiveInputs(kind, args, tail) {
2498
2919
  const writing = /[cruA]/.test(bareKey ? first.value : "") || /(^|\s)-[a-zA-Z]*[cruA]|--create|--append|--update|--concatenate/.test(flagsText);
2499
2920
  if (extracting && !writing) return [];
2500
2921
  void mode;
2501
- let i = 0;
2502
- if (bareKey) {
2503
- i = 1;
2504
- const next = args[1];
2505
- if (first.value.includes("f") && next && next.afterFlag === null) i = 2;
2922
+ if (!bareKey) return args;
2923
+ let i = 1;
2924
+ const fromDirs = [];
2925
+ for (const ch of first.value) {
2926
+ if (!TAR_VALUE_LETTERS.includes(ch)) continue;
2927
+ const operand = args[i];
2928
+ if (!operand || operand.afterFlag !== null) break;
2929
+ i += 1;
2930
+ if (ch === "C") fromDirs.push(operand);
2506
2931
  }
2507
- return args.slice(i);
2932
+ return [...fromDirs, ...args.slice(i)];
2508
2933
  }
2509
2934
  if (kind === "zip") return first && first.afterFlag === "-" ? args : args.slice(1);
2510
2935
  if (kind === "ar") return bareKey ? args.slice(2) : args.slice(1);
@@ -2556,6 +2981,140 @@ function baseWord(w) {
2556
2981
  }
2557
2982
  var isReaderWord = (w) => w !== null && FS_READ_TOOLS.has(baseWord(w));
2558
2983
  var positionalAfter = (words, from, to = words.length) => positionedArgs(words, from, to).map((a) => a.value);
2984
+ function namesFlag(name, candidates, known) {
2985
+ if (candidates.has(name)) return true;
2986
+ if (!name.startsWith("--") || name.length < 3) return false;
2987
+ if (known?.has(name)) return false;
2988
+ for (const c of candidates) if (c.startsWith(name)) return true;
2989
+ return false;
2990
+ }
2991
+ function knownLongFlags(verb) {
2992
+ const out = /* @__PURE__ */ new Set();
2993
+ const shape = PATTERN_VERBS[verb];
2994
+ if (shape) {
2995
+ for (const set of [shape.takesValue, shape.noValue, shape.patternFlags, shape.noPatternFlags])
2996
+ for (const f of set) if (f.startsWith("--")) out.add(f);
2997
+ }
2998
+ for (const f of FILE_OPERAND_FLAGS[verb] ?? []) if (f.startsWith("--")) out.add(f);
2999
+ return out;
3000
+ }
3001
+ function flagNamesOf(token, shape) {
3002
+ if (token.startsWith("--")) {
3003
+ const eq = token.indexOf("=");
3004
+ return [eq > 0 ? token.slice(0, eq) : token];
3005
+ }
3006
+ const out = [];
3007
+ for (const c of token.slice(1)) {
3008
+ const name = `-${c}`;
3009
+ out.push(name);
3010
+ if (shape?.takesValue.has(name)) break;
3011
+ }
3012
+ return out;
3013
+ }
3014
+ var NONE = { kind: "none" };
3015
+ var UNKNOWN = { kind: "unknown" };
3016
+ function flagEffect(token, shape, known) {
3017
+ if (token === "--") return NONE;
3018
+ if (/^-+$/.test(token)) return UNKNOWN;
3019
+ if (/^-\d+$/.test(token)) return NONE;
3020
+ if (token.startsWith("--")) {
3021
+ if (token.includes("=")) return NONE;
3022
+ const takes = namesFlag(token, shape.takesValue, known);
3023
+ const none = namesFlag(token, shape.noValue, known);
3024
+ if (takes && none) return UNKNOWN;
3025
+ if (takes) return { kind: "takes", flag: token };
3026
+ if (none) return NONE;
3027
+ return UNKNOWN;
3028
+ }
3029
+ const letters = token.slice(1);
3030
+ if (!letters) return NONE;
3031
+ for (let i = 0; i < letters.length; i++) {
3032
+ const name = `-${letters[i]}`;
3033
+ if (shape.takesValue.has(name)) {
3034
+ return i === letters.length - 1 ? { kind: "takes", flag: name } : NONE;
3035
+ }
3036
+ if (!shape.noValue.has(name)) return UNKNOWN;
3037
+ }
3038
+ return NONE;
3039
+ }
3040
+ function readTargets(verb, args, flags, words = [], from = 1) {
3041
+ const shape = PATTERN_VERBS[verb];
3042
+ if (!shape) return args.map((a) => a.value);
3043
+ const known = knownLongFlags(verb);
3044
+ const names = flags.flatMap((f) => flagNamesOf(f, shape));
3045
+ const patternElsewhere = names.some(
3046
+ (n) => namesFlag(n, shape.patternFlags, known) || namesFlag(n, shape.noPatternFlags, known)
3047
+ );
3048
+ const excused = /* @__PURE__ */ new Set();
3049
+ const fileFlags = FILE_OPERAND_FLAGS[verb];
3050
+ const optionsEnd = words.findIndex((w, i) => i >= from && w === "--");
3051
+ const pastOptions = (a) => optionsEnd >= 0 && a.argv > optionsEnd;
3052
+ for (const a of args) {
3053
+ if (a.afterFlag === null || pastOptions(a)) continue;
3054
+ const e = flagEffect(a.afterFlag, shape, known);
3055
+ if (e.kind !== "takes") continue;
3056
+ if (fileFlags && namesFlag(e.flag, fileFlags, known)) continue;
3057
+ excused.add(a);
3058
+ }
3059
+ const patternArgv = (() => {
3060
+ for (let i = from; i < words.length; i++) {
3061
+ const w = words[i];
3062
+ if (w === null) return -1;
3063
+ if (w === "--") return i + 1;
3064
+ if (w.startsWith("-")) {
3065
+ const e = flagEffect(w, shape, known);
3066
+ if (e.kind === "takes") i += 1;
3067
+ else if (e.kind === "unknown") return -1;
3068
+ continue;
3069
+ }
3070
+ return i;
3071
+ }
3072
+ return -1;
3073
+ })();
3074
+ if (!patternElsewhere && patternArgv >= 0) {
3075
+ const a = args.find((x) => x.argv === patternArgv);
3076
+ if (a) excused.add(a);
3077
+ }
3078
+ return args.filter((a) => !excused.has(a)).map((a) => a.value);
3079
+ }
3080
+ function flagOperandFiles(verb, words, from) {
3081
+ const flags = FILE_OPERAND_FLAGS[verb];
3082
+ if (!flags) return [];
3083
+ const known = knownLongFlags(verb);
3084
+ const out = [];
3085
+ for (let i = from; i < words.length; i++) {
3086
+ const w = words[i];
3087
+ if (w === null || !w.startsWith("-") || w === "--") continue;
3088
+ if (w.startsWith("--")) {
3089
+ const eq = w.indexOf("=");
3090
+ if (eq <= 0) continue;
3091
+ const name = w.slice(0, eq);
3092
+ const value = w.slice(eq + 1);
3093
+ if (!value) continue;
3094
+ if (namesFlag(name, flags, known)) {
3095
+ out.push(value);
3096
+ continue;
3097
+ }
3098
+ const shape2 = PATTERN_VERBS[verb];
3099
+ if (!shape2) continue;
3100
+ const recognised = namesFlag(name, shape2.takesValue, known) || namesFlag(name, shape2.noValue, known) || namesFlag(name, shape2.patternFlags, known) || namesFlag(name, shape2.noPatternFlags, known);
3101
+ if (!recognised) out.push(value);
3102
+ continue;
3103
+ }
3104
+ const shape = PATTERN_VERBS[verb];
3105
+ const letters = w.slice(1);
3106
+ for (let j = 0; j < letters.length; j++) {
3107
+ const name = `-${letters[j]}`;
3108
+ const isFileFlag = flags.has(name);
3109
+ const argTaking = isFileFlag || (shape?.takesValue.has(name) ?? false) || (READER_VALUE_LETTERS[verb] ?? []).includes(letters[j]);
3110
+ if (!argTaking) continue;
3111
+ const attached = letters.slice(j + 1);
3112
+ if (isFileFlag && attached) out.push(attached);
3113
+ break;
3114
+ }
3115
+ }
3116
+ return out;
3117
+ }
2559
3118
  function wrappedReadPaths(words, name) {
2560
3119
  if (name === "find") {
2561
3120
  const { k, starts } = findStartPoints(words, 0);
@@ -2563,7 +3122,14 @@ function wrappedReadPaths(words, name) {
2563
3122
  }
2564
3123
  if (!COMMAND_WRAPPERS.has(name) && !RUNNER_WRAPPERS.has(name)) return null;
2565
3124
  const h = unwrapCommandHead(words);
2566
- return h > 0 && isReaderWord(words[h] ?? null) ? positionalAfter(words, h + 1) : null;
3125
+ if (h <= 0 || !isReaderWord(words[h] ?? null)) return null;
3126
+ const head = baseWord(words[h]);
3127
+ const rest = words.slice(h + 1);
3128
+ const restFlags = rest.filter((w) => w !== null && w.startsWith("-"));
3129
+ return [
3130
+ ...readTargets(head, positionedArgs(words, h + 1), restFlags, words, h + 1),
3131
+ ...flagOperandFiles(head, words, h + 1)
3132
+ ];
2567
3133
  }
2568
3134
  function literalShellPayload(words, name) {
2569
3135
  const h = COMMAND_WRAPPERS.has(name) || RUNNER_WRAPPERS.has(name) ? unwrapCommandHead(words) : 0;
@@ -5083,8 +5649,8 @@ function matchCanaryArgs(args, values) {
5083
5649
 
5084
5650
  // src/scan/canonical.ts
5085
5651
  var LONG_OUTPUT_THRESHOLD_BYTES = 100 * 1024;
5086
- var CANONICAL_EXTRACTOR_VERSION = "canonical-v14";
5087
- var CANONICAL_EXTRACTOR_HASH = "8b5729fe236a195b";
5652
+ var CANONICAL_EXTRACTOR_VERSION = "canonical-v15";
5653
+ var CANONICAL_EXTRACTOR_HASH = "5dad4c8f07121f6c";
5088
5654
  var DEDUPE_PREVIEW_LEN = 120;
5089
5655
  function extractCanonicalFindings(call, ctx) {
5090
5656
  const out = [];
@@ -5476,6 +6042,7 @@ var ENGINE_VERSION = "1.4.0";
5476
6042
  LOOP_MAX_RECORDS,
5477
6043
  LOOP_THRESHOLD_FOR_WASTE,
5478
6044
  NET_BINARIES,
6045
+ PATTERN_VERB_NAMES,
5479
6046
  PRIVILEGE_ESCALATION_RE,
5480
6047
  REALTIME_PII_PATTERNS,
5481
6048
  SCAN_SIGNAL_WEIGHTS,
@@ -5512,6 +6079,7 @@ var ENGINE_VERSION = "1.4.0";
5512
6079
  extractSessionLevelFindings,
5513
6080
  extractShellDestTokens,
5514
6081
  extractShellDestinations,
6082
+ fileOperandFlagsOf,
5515
6083
  getCompiledRegex,
5516
6084
  getNestedValue,
5517
6085
  hostMatches,
@@ -5531,6 +6099,7 @@ var ENGINE_VERSION = "1.4.0";
5531
6099
  normalizeIpLiteral,
5532
6100
  parseAllSshHostsFromCommand,
5533
6101
  parseDestHost,
6102
+ patternShapeOf,
5534
6103
  positionedArgs,
5535
6104
  previewArgs,
5536
6105
  redactText,