@node9/policy-engine 2.14.0 → 2.14.2

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