@node9/proxy 2.13.1 → 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/cli.js CHANGED
@@ -894,6 +894,19 @@ function parseShared(command) {
894
894
  astCache.set(command, parsed);
895
895
  return parsed;
896
896
  }
897
+ function byteOffsetToCharIndex(command) {
898
+ if (!/[^\u0000-\u007F]/.test(command)) return null;
899
+ const map = /* @__PURE__ */ new Map();
900
+ let byte = 0;
901
+ for (let i = 0; i < command.length; ) {
902
+ map.set(byte, i);
903
+ const cp = command.codePointAt(i);
904
+ byte += cp < 128 ? 1 : cp < 2048 ? 2 : cp < 65536 ? 3 : 4;
905
+ i += cp > 65535 ? 2 : 1;
906
+ }
907
+ map.set(byte, command.length);
908
+ return (b) => map.get(b) ?? -1;
909
+ }
897
910
  function cachedNormalize(command, compute) {
898
911
  const hit = normalizeCache.get(command);
899
912
  if (hit !== void 0) {
@@ -923,6 +936,8 @@ function normalizeCommandForPolicyImpl(command) {
923
936
  const f = parseShared(command);
924
937
  if (f === PARSE_FAIL) return { posix: command, separator: command };
925
938
  try {
939
+ const toCharIndex = byteOffsetToCharIndex(command);
940
+ const at = (byteOffset) => toCharIndex === null ? byteOffset : toCharIndex(byteOffset);
926
941
  const strips = [];
927
942
  const rewrites = [];
928
943
  const quoteOnlyRewrites = [];
@@ -943,8 +958,9 @@ function normalizeCommandForPolicyImpl(command) {
943
958
  const quotedNode = nextParts[0];
944
959
  const nt = syntax.NodeType(quotedNode);
945
960
  const markStrip = () => {
946
- const s = next.Pos().Offset();
947
- const e = next.End().Offset();
961
+ const s = at(next.Pos().Offset());
962
+ const e = at(next.End().Offset());
963
+ if (s < 0 || e < 0) return;
948
964
  strips.push([s, e]);
949
965
  msgSpans.add(`${s}:${e}`);
950
966
  };
@@ -961,8 +977,9 @@ function normalizeCommandForPolicyImpl(command) {
961
977
  }
962
978
  }
963
979
  for (const arg of args) {
964
- const s = arg.Pos().Offset();
965
- const e = arg.End().Offset();
980
+ const s = at(arg.Pos().Offset());
981
+ const e = at(arg.End().Offset());
982
+ if (s < 0 || e < 0) continue;
966
983
  if (msgSpans.has(`${s}:${e}`)) continue;
967
984
  const resolved = resolveWordLiteral(arg);
968
985
  if (resolved === null) continue;
@@ -1291,20 +1308,32 @@ function isProtectedHomePath(rawPath) {
1291
1308
  }
1292
1309
  return true;
1293
1310
  }
1294
- function extractLiteralArgs(callExpr) {
1295
- const args = callExpr.Args || [];
1296
- if (args.length === 0) return { name: "", flags: [], paths: [], words: [] };
1297
- const words = args.map((a) => resolveWordLiteral(a));
1298
- const name = (words[0] ?? "").toLowerCase();
1299
- const flags = [];
1300
- const paths = [];
1301
- for (let i = 1; i < words.length; i++) {
1311
+ function positionedArgs(words, from = 1, to = words.length) {
1312
+ const out = [];
1313
+ let afterFlag = null;
1314
+ for (let i = from; i < to; i++) {
1302
1315
  const v = words[i];
1303
- if (v === null) continue;
1304
- if (v.startsWith("-")) flags.push(v);
1305
- else paths.push(v);
1316
+ if (v === null) {
1317
+ afterFlag = null;
1318
+ continue;
1319
+ }
1320
+ if (v.startsWith("-")) {
1321
+ afterFlag = v;
1322
+ continue;
1323
+ }
1324
+ out.push({ value: v, index: out.length, argv: i, afterFlag });
1325
+ afterFlag = null;
1306
1326
  }
1307
- return { name, flags, paths, words };
1327
+ return out;
1328
+ }
1329
+ function extractLiteralArgs(callExpr) {
1330
+ const rawArgs = callExpr.Args || [];
1331
+ if (rawArgs.length === 0) return { name: "", flags: [], paths: [], words: [], args: [] };
1332
+ const words = rawArgs.map((a) => resolveWordLiteral(a));
1333
+ const name = baseWord(words[0]);
1334
+ const flags = words.slice(1).filter((w) => w !== null && w.startsWith("-"));
1335
+ const args = positionedArgs(words);
1336
+ return { name, flags, paths: args.map((a) => a.value), words, args };
1308
1337
  }
1309
1338
  function resolveWordLiteral(w) {
1310
1339
  const parts = w?.Parts || [];
@@ -1576,7 +1605,7 @@ function analyzeFsOperationImpl(command, depth = 0) {
1576
1605
  return result?.verdict !== "block";
1577
1606
  }
1578
1607
  if (nodeType !== "CallExpr") return true;
1579
- const { name, flags, paths, words } = extractLiteralArgs(n);
1608
+ const { name, flags, paths, words, args } = extractLiteralArgs(n);
1580
1609
  if (!name) return true;
1581
1610
  if (name === "rm") {
1582
1611
  const flagStr = flags.join("").toLowerCase();
@@ -1616,13 +1645,16 @@ function analyzeFsOperationImpl(command, depth = 0) {
1616
1645
  return true;
1617
1646
  }
1618
1647
  }
1619
- const readPaths = FS_READ_TOOLS.has(name) ? paths : wrappedReadPaths(words, name);
1648
+ const readPaths = FS_READ_TOOLS.has(name) ? [...readTargets(name, args, flags, words, 1), ...flagOperandFiles(name, words, 1)] : wrappedReadPaths(words, name);
1620
1649
  if (readPaths) {
1621
1650
  for (const p of readPaths) {
1622
1651
  result = stricter(result, matchSensitivePath2(p));
1623
1652
  if (result?.verdict === "block") return false;
1624
1653
  }
1625
1654
  }
1655
+ for (const p of copySourcePaths(words)) {
1656
+ result = stricter(result, copyVerdictOf(matchSensitivePath2(p)));
1657
+ }
1626
1658
  return true;
1627
1659
  });
1628
1660
  return result;
@@ -1635,6 +1667,184 @@ function stricter(a, b) {
1635
1667
  if (!b) return a;
1636
1668
  return b.verdict === "block" && a.verdict !== "block" ? b : a;
1637
1669
  }
1670
+ function flagInfo(w) {
1671
+ if (w.startsWith("--")) {
1672
+ const eq = w.indexOf("=");
1673
+ return eq < 0 ? { letter: null, long: w, attached: null } : { letter: null, long: w.slice(0, eq), attached: w.slice(eq + 1) };
1674
+ }
1675
+ const m = /^-([a-zA-Z]+)(.*)$/.exec(w);
1676
+ if (!m) return { letter: null, long: null, attached: null };
1677
+ return { letter: m[1][m[1].length - 1], long: null, attached: m[2] === "" ? null : m[2] };
1678
+ }
1679
+ function flagIs(w, names) {
1680
+ if (w === null) return false;
1681
+ const f = flagInfo(w);
1682
+ return names.some((n) => n.startsWith("--") ? f.long === n : f.letter === n);
1683
+ }
1684
+ function operandOf(a, names, valueLetters) {
1685
+ if (!names || a.afterFlag === null) return false;
1686
+ const w = a.afterFlag;
1687
+ if (!w.startsWith("--") && valueLetters) {
1688
+ const letters = w.slice(1);
1689
+ for (let i = 0; i < letters.length; i++) {
1690
+ if (!valueLetters.includes(letters[i])) continue;
1691
+ return i === letters.length - 1 && names.includes(letters[i]);
1692
+ }
1693
+ return false;
1694
+ }
1695
+ if (w.startsWith("--") && !w.includes("=")) {
1696
+ const longs = names.filter((n) => n.startsWith("--"));
1697
+ if (longs.some((n) => n === w || w.length >= 3 && n.startsWith(w))) return true;
1698
+ }
1699
+ return flagIs(w, names) && flagInfo(w).attached === null;
1700
+ }
1701
+ function resolveCopyShape(words, h) {
1702
+ const verb = baseWord(words[h]);
1703
+ if (!verb) return null;
1704
+ const direct = COPY_VERBS[verb];
1705
+ if (direct) return { shape: direct, last: h };
1706
+ const slots = positionedArgs(words, h + 1);
1707
+ for (let i = 0; i < slots.length; i++) {
1708
+ for (let n = 3; n >= 1; n--) {
1709
+ const part = slots.slice(i, i + n);
1710
+ if (part.length < n) continue;
1711
+ const key = [verb, ...part.map((a) => a.value.toLowerCase())].join(" ");
1712
+ const shape = COPY_VERBS[key];
1713
+ if (shape) return { shape, last: part[n - 1].argv };
1714
+ }
1715
+ if (slots[i].afterFlag === null) return null;
1716
+ }
1717
+ return null;
1718
+ }
1719
+ function findStartPoints(words, h) {
1720
+ const k = words.findIndex((w, i) => i > h && w !== null && FIND_EXEC_FLAGS.has(w));
1721
+ if (k < 0) return { k, starts: [] };
1722
+ const firstPredicate = words.findIndex(
1723
+ (w, i) => i > h && w !== null && w !== "--" && w.startsWith("-") && !FIND_OPTIONS.has(w)
1724
+ );
1725
+ const end = firstPredicate > h ? firstPredicate : k;
1726
+ return { k, starts: positionalAfter(words, h + 1, end) };
1727
+ }
1728
+ function copySourcePaths(words) {
1729
+ const h = unwrapCommandHead(words);
1730
+ const fi = words.findIndex((w, i) => i <= h && baseWord(w) === "find");
1731
+ if (fi >= 0) {
1732
+ const { k, starts } = findStartPoints(words, fi);
1733
+ if (k < 0) return [];
1734
+ const action = unwrapCommandHead(words.slice(k + 1));
1735
+ return resolveCopyShape(words.slice(k + 1), action) ? starts : [];
1736
+ }
1737
+ if (!COPY_VERB_HEADS.has(baseWord(words[h]))) return [];
1738
+ const r = resolveCopyShape(words, h);
1739
+ if (!r) return [];
1740
+ const { shape, last } = r;
1741
+ const args = positionedArgs(words, last + 1);
1742
+ const tail = words.slice(last + 1);
1743
+ const copyOptionsEnd = tail.findIndex((w) => w === "--");
1744
+ const copyPastOptions = (a) => copyOptionsEnd >= 0 && a.argv > last + 1 + copyOptionsEnd;
1745
+ const skipped = (a) => !copyPastOptions(a) && operandOf(a, shape.skipFlags, shape.valueLetters);
1746
+ const firstValueLetter = (w) => {
1747
+ const letters = w.slice(1);
1748
+ for (let i = 0; i < letters.length; i++) {
1749
+ if ((shape.valueLetters ?? []).includes(letters[i]))
1750
+ return { letter: letters[i], last: i === letters.length - 1 };
1751
+ }
1752
+ return null;
1753
+ };
1754
+ const isTargetDirFlag = (w) => {
1755
+ if (w.startsWith("--")) {
1756
+ const name = w.includes("=") ? w.slice(0, w.indexOf("=")) : w;
1757
+ return name.length >= 3 && "--target-directory".startsWith(name);
1758
+ }
1759
+ return firstValueLetter(w)?.letter === "t";
1760
+ };
1761
+ const targetDir = shape.targetDirFlag === true && tail.some((w) => w !== null && w.startsWith("-") && isTargetDirFlag(w));
1762
+ const targetTakesNextWord = (w) => {
1763
+ if (w.startsWith("--"))
1764
+ return !w.includes("=") && w.length >= 3 && "--target-directory".startsWith(w);
1765
+ const f = firstValueLetter(w);
1766
+ return f !== null && f.letter === "t" && f.last;
1767
+ };
1768
+ const targetOperand = (a) => targetDir && a.afterFlag !== null && !copyPastOptions(a) && targetTakesNextWord(a.afterFlag);
1769
+ const lastOperand = [...tail].reverse().find((w) => w === null || !w.startsWith("-"));
1770
+ const dynamicDest = lastOperand === null;
1771
+ const destIsLastOperand = (shape.source === "allButLast" || shape.source === "first") && !targetDir && !dynamicDest;
1772
+ let src;
1773
+ switch (shape.source) {
1774
+ case "all":
1775
+ src = args;
1776
+ break;
1777
+ case "first":
1778
+ src = targetDir ? args : args.slice(0, 1);
1779
+ break;
1780
+ case "flagOperand": {
1781
+ const namesSource = (f) => {
1782
+ const names = shape.sourceFlags ?? [];
1783
+ if (f.long !== null)
1784
+ return names.some(
1785
+ (n) => n.startsWith("--") && (n === f.long || f.long.length >= 3 && n.startsWith(f.long))
1786
+ );
1787
+ return f.letter !== null && names.includes(f.letter);
1788
+ };
1789
+ const inline = tail.filter((w) => w !== null && w.startsWith("-")).map((w) => flagInfo(w)).filter((f) => f.attached !== null && namesSource(f)).map((f) => f.attached);
1790
+ return [
1791
+ ...args.filter((a) => !copyPastOptions(a) && operandOf(a, shape.sourceFlags, shape.valueLetters)).map((a) => a.value),
1792
+ ...inline
1793
+ ];
1794
+ }
1795
+ case "archive":
1796
+ src = archiveInputs(shape.archive, args, tail);
1797
+ break;
1798
+ case "allButLast":
1799
+ src = targetDir || dynamicDest ? args : args.slice(0, -1);
1800
+ break;
1801
+ }
1802
+ const sources = src.filter((a) => !skipped(a) && !targetOperand(a)).map((a) => a.value);
1803
+ if (destIsLastOperand && typeof lastOperand === "string" && matchSensitivePath2(lastOperand)) {
1804
+ const jailedSources = sources.filter((p) => matchSensitivePath2(p));
1805
+ const dirOf = (p) => /[\\/]/.test(p) ? p.replace(/[\\/][^\\/]*$/, "") : "";
1806
+ if (jailedSources.length === 0) return [];
1807
+ if (jailedSources.every((p) => dirOf(p) === dirOf(lastOperand))) return [];
1808
+ }
1809
+ return sources;
1810
+ }
1811
+ function archiveInputs(kind, args, tail) {
1812
+ const first = args[0];
1813
+ const bareKey = first && first.afterFlag === null && TAR_MODE_WORD.test(first.value);
1814
+ if (kind === "tar") {
1815
+ const flagsText = tail.filter((w) => w !== null && w.startsWith("-")).join(" ");
1816
+ const mode = (bareKey ? first.value : "") + flagsText;
1817
+ const extracting = /x|t/.test(bareKey ? first.value.replace(/f/g, "") : "") || /(^|\s)-[a-zA-Z]*[xt]|--extract|--list|--get/.test(flagsText);
1818
+ const writing = /[cruA]/.test(bareKey ? first.value : "") || /(^|\s)-[a-zA-Z]*[cruA]|--create|--append|--update|--concatenate/.test(flagsText);
1819
+ if (extracting && !writing) return [];
1820
+ void mode;
1821
+ if (!bareKey) return args;
1822
+ let i = 1;
1823
+ const fromDirs = [];
1824
+ for (const ch of first.value) {
1825
+ if (!TAR_VALUE_LETTERS.includes(ch)) continue;
1826
+ const operand = args[i];
1827
+ if (!operand || operand.afterFlag !== null) break;
1828
+ i += 1;
1829
+ if (ch === "C") fromDirs.push(operand);
1830
+ }
1831
+ return [...fromDirs, ...args.slice(i)];
1832
+ }
1833
+ if (kind === "zip") return first && first.afterFlag === "-" ? args : args.slice(1);
1834
+ if (kind === "ar") return bareKey ? args.slice(2) : args.slice(1);
1835
+ return args.slice(2);
1836
+ }
1837
+ function copyVerdictOf(hit) {
1838
+ if (!hit) return null;
1839
+ const ruleName = COPY_RULE_OF[hit.ruleName];
1840
+ if (!ruleName) return null;
1841
+ return {
1842
+ ruleName,
1843
+ verdict: "review",
1844
+ reason: `Copying ${hit.path} moves a credential out of its jail (project-jail shield)`,
1845
+ path: hit.path
1846
+ };
1847
+ }
1638
1848
  function matchSensitivePath2(p) {
1639
1849
  for (const sp of SENSITIVE_PATH_RULES) {
1640
1850
  if (sp.match(p))
@@ -1642,16 +1852,156 @@ function matchSensitivePath2(p) {
1642
1852
  }
1643
1853
  return null;
1644
1854
  }
1855
+ function baseWord(w) {
1856
+ return (w ?? "").split("/").pop()?.toLowerCase() ?? "";
1857
+ }
1858
+ function namesFlag(name, candidates2, known) {
1859
+ if (candidates2.has(name)) return true;
1860
+ if (!name.startsWith("--") || name.length < 3) return false;
1861
+ if (known?.has(name)) return false;
1862
+ for (const c of candidates2) if (c.startsWith(name)) return true;
1863
+ return false;
1864
+ }
1865
+ function knownLongFlags(verb) {
1866
+ const out = /* @__PURE__ */ new Set();
1867
+ const shape = PATTERN_VERBS[verb];
1868
+ if (shape) {
1869
+ for (const set of [shape.takesValue, shape.noValue, shape.patternFlags, shape.noPatternFlags])
1870
+ for (const f of set) if (f.startsWith("--")) out.add(f);
1871
+ }
1872
+ for (const f of FILE_OPERAND_FLAGS[verb] ?? []) if (f.startsWith("--")) out.add(f);
1873
+ return out;
1874
+ }
1875
+ function flagNamesOf(token, shape) {
1876
+ if (token.startsWith("--")) {
1877
+ const eq = token.indexOf("=");
1878
+ return [eq > 0 ? token.slice(0, eq) : token];
1879
+ }
1880
+ const out = [];
1881
+ for (const c of token.slice(1)) {
1882
+ const name = `-${c}`;
1883
+ out.push(name);
1884
+ if (shape?.takesValue.has(name)) break;
1885
+ }
1886
+ return out;
1887
+ }
1888
+ function flagEffect(token, shape, known) {
1889
+ if (token === "--") return NONE;
1890
+ if (/^-+$/.test(token)) return UNKNOWN;
1891
+ if (/^-\d+$/.test(token)) return NONE;
1892
+ if (token.startsWith("--")) {
1893
+ if (token.includes("=")) return NONE;
1894
+ const takes = namesFlag(token, shape.takesValue, known);
1895
+ const none = namesFlag(token, shape.noValue, known);
1896
+ if (takes && none) return UNKNOWN;
1897
+ if (takes) return { kind: "takes", flag: token };
1898
+ if (none) return NONE;
1899
+ return UNKNOWN;
1900
+ }
1901
+ const letters = token.slice(1);
1902
+ if (!letters) return NONE;
1903
+ for (let i = 0; i < letters.length; i++) {
1904
+ const name = `-${letters[i]}`;
1905
+ if (shape.takesValue.has(name)) {
1906
+ return i === letters.length - 1 ? { kind: "takes", flag: name } : NONE;
1907
+ }
1908
+ if (!shape.noValue.has(name)) return UNKNOWN;
1909
+ }
1910
+ return NONE;
1911
+ }
1912
+ function readTargets(verb, args, flags, words = [], from = 1) {
1913
+ const shape = PATTERN_VERBS[verb];
1914
+ if (!shape) return args.map((a) => a.value);
1915
+ const known = knownLongFlags(verb);
1916
+ const names = flags.flatMap((f) => flagNamesOf(f, shape));
1917
+ const patternElsewhere = names.some(
1918
+ (n) => namesFlag(n, shape.patternFlags, known) || namesFlag(n, shape.noPatternFlags, known)
1919
+ );
1920
+ const excused = /* @__PURE__ */ new Set();
1921
+ const fileFlags = FILE_OPERAND_FLAGS[verb];
1922
+ const optionsEnd = words.findIndex((w, i) => i >= from && w === "--");
1923
+ const pastOptions = (a) => optionsEnd >= 0 && a.argv > optionsEnd;
1924
+ for (const a of args) {
1925
+ if (a.afterFlag === null || pastOptions(a)) continue;
1926
+ const e = flagEffect(a.afterFlag, shape, known);
1927
+ if (e.kind !== "takes") continue;
1928
+ if (fileFlags && namesFlag(e.flag, fileFlags, known)) continue;
1929
+ excused.add(a);
1930
+ }
1931
+ const patternArgv = (() => {
1932
+ for (let i = from; i < words.length; i++) {
1933
+ const w = words[i];
1934
+ if (w === null) return -1;
1935
+ if (w === "--") return i + 1;
1936
+ if (w.startsWith("-")) {
1937
+ const e = flagEffect(w, shape, known);
1938
+ if (e.kind === "takes") i += 1;
1939
+ else if (e.kind === "unknown") return -1;
1940
+ continue;
1941
+ }
1942
+ return i;
1943
+ }
1944
+ return -1;
1945
+ })();
1946
+ if (!patternElsewhere && patternArgv >= 0) {
1947
+ const a = args.find((x) => x.argv === patternArgv);
1948
+ if (a) excused.add(a);
1949
+ }
1950
+ return args.filter((a) => !excused.has(a)).map((a) => a.value);
1951
+ }
1952
+ function flagOperandFiles(verb, words, from) {
1953
+ const flags = FILE_OPERAND_FLAGS[verb];
1954
+ if (!flags) return [];
1955
+ const known = knownLongFlags(verb);
1956
+ const out = [];
1957
+ for (let i = from; i < words.length; i++) {
1958
+ const w = words[i];
1959
+ if (w === null || !w.startsWith("-") || w === "--") continue;
1960
+ if (w.startsWith("--")) {
1961
+ const eq = w.indexOf("=");
1962
+ if (eq <= 0) continue;
1963
+ const name = w.slice(0, eq);
1964
+ const value = w.slice(eq + 1);
1965
+ if (!value) continue;
1966
+ if (namesFlag(name, flags, known)) {
1967
+ out.push(value);
1968
+ continue;
1969
+ }
1970
+ const shape2 = PATTERN_VERBS[verb];
1971
+ if (!shape2) continue;
1972
+ const recognised = namesFlag(name, shape2.takesValue, known) || namesFlag(name, shape2.noValue, known) || namesFlag(name, shape2.patternFlags, known) || namesFlag(name, shape2.noPatternFlags, known);
1973
+ if (!recognised) out.push(value);
1974
+ continue;
1975
+ }
1976
+ const shape = PATTERN_VERBS[verb];
1977
+ const letters = w.slice(1);
1978
+ for (let j = 0; j < letters.length; j++) {
1979
+ const name = `-${letters[j]}`;
1980
+ const isFileFlag = flags.has(name);
1981
+ const argTaking = isFileFlag || (shape?.takesValue.has(name) ?? false) || (READER_VALUE_LETTERS[verb] ?? []).includes(letters[j]);
1982
+ if (!argTaking) continue;
1983
+ const attached = letters.slice(j + 1);
1984
+ if (isFileFlag && attached) out.push(attached);
1985
+ break;
1986
+ }
1987
+ }
1988
+ return out;
1989
+ }
1645
1990
  function wrappedReadPaths(words, name) {
1646
1991
  if (name === "find") {
1647
- const k = words.findIndex((w) => w !== null && FIND_EXEC_FLAGS.has(w));
1648
- if (k < 1 || !isReaderWord(words[k + 1] ?? null)) return null;
1649
- const firstPredicate = words.findIndex((w, i) => i > 0 && w !== null && w.startsWith("-"));
1650
- return positionalAfter(words, 1, firstPredicate > 0 ? firstPredicate : k);
1992
+ const { k, starts } = findStartPoints(words, 0);
1993
+ return k > 0 && isReaderWord(words[k + 1] ?? null) ? starts : null;
1651
1994
  }
1652
1995
  if (!COMMAND_WRAPPERS.has(name) && !RUNNER_WRAPPERS.has(name)) return null;
1653
1996
  const h = unwrapCommandHead(words);
1654
- return h > 0 && isReaderWord(words[h] ?? null) ? positionalAfter(words, h + 1) : null;
1997
+ if (h <= 0 || !isReaderWord(words[h] ?? null)) return null;
1998
+ const head = baseWord(words[h]);
1999
+ const rest = words.slice(h + 1);
2000
+ const restFlags = rest.filter((w) => w !== null && w.startsWith("-"));
2001
+ return [
2002
+ ...readTargets(head, positionedArgs(words, h + 1), restFlags, words, h + 1),
2003
+ ...flagOperandFiles(head, words, h + 1)
2004
+ ];
1655
2005
  }
1656
2006
  function literalShellPayload(words, name) {
1657
2007
  const h = COMMAND_WRAPPERS.has(name) || RUNNER_WRAPPERS.has(name) ? unwrapCommandHead(words) : 0;
@@ -2319,6 +2669,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2319
2669
  const shellShaped = isBashTool(toolName) || inspectsShellCommand(toolName, config.policy.toolInspection);
2320
2670
  const shellShapedCommand = shellShaped ? isBashTool(toolName) && args && typeof args === "object" ? typeof args.command === "string" ? args.command : null : extractShellCommand(toolName, args, config.policy.toolInspection) : null;
2321
2671
  const bashCommand = agent !== "Terminal" ? shellShapedCommand : null;
2672
+ let pendingAstReview;
2322
2673
  if (bashCommand !== null) {
2323
2674
  const pipeVerdict = pipeChainVerdict(
2324
2675
  bashCommand,
@@ -2330,7 +2681,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2330
2681
  if (fsVerdict) {
2331
2682
  const isShieldRule = fsVerdict.ruleName.startsWith("shield:");
2332
2683
  const labelPrefix = isShieldRule ? "project-jail (AST)" : "Node9 (AST)";
2333
- return {
2684
+ const astVerdict = {
2334
2685
  decision: fsVerdict.verdict,
2335
2686
  blockedByLabel: `${labelPrefix}: ${fsVerdict.ruleName}`,
2336
2687
  reason: fsVerdict.reason,
@@ -2338,6 +2689,8 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2338
2689
  ruleName: fsVerdict.ruleName,
2339
2690
  ruleDescription: fsVerdict.reason
2340
2691
  };
2692
+ if (fsVerdict.verdict === "block") return astVerdict;
2693
+ pendingAstReview = astVerdict;
2341
2694
  }
2342
2695
  const sqlAction = resolveCheck(config.policy.commandChecks?.sqlDdl);
2343
2696
  const sqlVerdict = sqlAction === "off" ? null : analyzeSqlDestructive(bashCommand);
@@ -2380,7 +2733,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2380
2733
  const matchedRule = resolvePinned(matches);
2381
2734
  if (matchedRule) {
2382
2735
  if (matchedRule.verdict === "allow")
2383
- return { decision: "allow", ruleName: matchedRule.name ?? matchedRule.tool };
2736
+ return pendingAstReview ?? { decision: "allow", ruleName: matchedRule.name ?? matchedRule.tool };
2384
2737
  return {
2385
2738
  decision: matchedRule.verdict,
2386
2739
  blockedByLabel: `Smart Rule: ${matchedRule.name ?? matchedRule.tool}`,
@@ -2407,6 +2760,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2407
2760
  allTokens = analyzed.allTokens;
2408
2761
  pathTokens = analyzed.paths;
2409
2762
  const candidates2 = [];
2763
+ if (pendingAstReview) candidates2.push(pendingAstReview);
2410
2764
  const evalVerdict = detectDangerousShellExec(shellCommand);
2411
2765
  if (evalVerdict === "block") {
2412
2766
  return {
@@ -2703,6 +3057,12 @@ function classifyRuleSeverity(name, verdict) {
2703
3057
  "read-ssh",
2704
3058
  "read-gcp",
2705
3059
  "read-cred",
3060
+ // Stage 4 (2026-09-11): a copy of a credential file scores like a READ of it --
3061
+ // read-ssh/aws/cred are critical, so copy-ssh/aws/cred are; read-env is high
3062
+ // (below), so copy-env joins the high list, not this one (/code-review).
3063
+ "copy-ssh",
3064
+ "copy-aws",
3065
+ "copy-cred",
2706
3066
  "delete-repo",
2707
3067
  "helm-uninstall",
2708
3068
  "drop-table",
@@ -2715,6 +3075,7 @@ function classifyRuleSeverity(name, verdict) {
2715
3075
  ];
2716
3076
  if (criticalPatterns.some((p) => n.includes(p))) return "critical";
2717
3077
  const highPatterns = [
3078
+ "copy-env",
2718
3079
  "force-push",
2719
3080
  "force_push",
2720
3081
  "git-destructive",
@@ -2732,6 +3093,11 @@ function narrativeRuleLabel(name) {
2732
3093
  const map = {
2733
3094
  "read-aws": "AWS credentials read",
2734
3095
  "read-ssh": "SSH private key read",
3096
+ // Stage 4 copy twins, so `scan --narrative` prints a label, not a raw slug.
3097
+ "copy-ssh": "SSH private key copied out",
3098
+ "copy-aws": "AWS credentials copied out",
3099
+ "copy-env": ".env file copied out",
3100
+ "copy-cred": "credential file copied out",
2735
3101
  "read-gcp": "GCP credentials read",
2736
3102
  "read-cred": "credential file read",
2737
3103
  "delete-repo": "GitHub repository deletion",
@@ -3321,7 +3687,7 @@ function* stringValues(obj, depth = 0) {
3321
3687
  }
3322
3688
  for (const v of Object.values(obj)) yield* stringValues(v, depth + 1);
3323
3689
  }
3324
- var import_safe_regex2, import_crypto3, import_mvdan_sh, import_picomatch, import_safe_regex22, import_safe_regex23, import_crypto4, IBAN_LENGTH, B58, B58_INDEX, XPRV_VERSIONS, MAX, UNTRUSTED_TOOLS, SIGNALS, ASSIGNMENT_CONTEXT_RE, DLP_STOPWORDS, DLP_PATTERNS, DLP_PATTERNS_GLOBAL, SENSITIVE_PATH_PATTERNS, MAX_DEPTH, MAX_STRING_BYTES, MAX_JSON_PARSE_BYTES, DLP_SCAN_LIMITS, MAX_REGEX_LENGTH, REGEX_CACHE_MAX, regexCache, FORBIDDEN_PATH_SEGMENTS, syntax, sharedParser, MESSAGE_FLAGS, SHELL_INTERPRETERS, DOWNLOAD_CMDS, NORMALIZE_CACHE_MAX, normalizeCache, AST_CACHE_MAX, astCache, PARSE_FAIL, FS_READ_TOOLS, FS_OP_PRESCREEN_RE, HOME_CACHE_ALLOWLIST, SENSITIVE_PATH_RULES, BASH_TOOL_NAMES, AST_FS_REGEX_RULES, SQL_DB_CLIS, SQL_DDL_RE, CHMOD_OPEN_PERM_TOKENS, COMMAND_WRAPPERS, INLINE_INTERPRETER, RUNNER_WRAPPERS, _redirStdinOps, _listOps, WRAPPER_TAKES_TARGET, FIND_EXEC_FLAGS, INTERP_LEADING_TARGET, NET_BINARIES, VALUE_FLAGS, FS_OP_CACHE_MAX, fsOpCache, stripDotSlash, REDIR_TRUNCATE_OPS, REDIR_FILE_IN_OPS, REDIR_HEREDOC_OPS, isReaderWord, positionalAfter, DEFAULT_EGRESS_ALLOWLIST, SOURCE_COMMANDS, SINK_COMMANDS, OBFUSCATORS, SENSITIVE_PATTERNS, FLAGS_WITH_VALUES, SSRF_MAX_HOST, METADATA_ADDRESSES, STRICT_TIERS, METADATA_HOSTNAMES, v4Octets, TIER_REASON, DESTINATION_ARGS, VERDICT_RANK, SQL_DML_KEYWORDS, aws_default, bash_safe_default, docker_default, filesystem_default, github_default, k8s_default, mongodb_default, postgres_default, project_jail_default, redis_default, BUILTIN_SHIELDS, LOOP_MAX_RECORDS, FINDING_TO_SIGNAL, SCAN_SIGNAL_WEIGHTS, LOOP_THRESHOLD_FOR_WASTE, DESTRUCTIVE_OP_RE, SENSITIVE_PATH_RE, FILE_TOOLS, PII_EMAIL_RE, PII_SSN_RE, PII_PHONE_RE, PII_CC16_RE, PII_CC15_RE, PII_IBAN_RE, REALTIME_PII_PATTERNS, MAX_PII_SCAN_BYTES, CANARY_MIN_LENGTH, MAX_TEXT, MAX_DEPTH2, MAX_JSON_PARSE, URL_DEPTH, B64_DEPTH, MIN_SEGMENT, SEPARATORS, stripSeparators, looksText, CANARY_DECODERS, VIEWS, LONG_OUTPUT_THRESHOLD_BYTES, CANONICAL_EXTRACTOR_VERSION, DEDUPE_PREVIEW_LEN, TERMINAL_ESCAPE_RE, ENGINE_VERSION;
3690
+ var import_safe_regex2, import_crypto3, import_mvdan_sh, import_picomatch, import_safe_regex22, import_safe_regex23, import_crypto4, IBAN_LENGTH, B58, B58_INDEX, XPRV_VERSIONS, MAX, UNTRUSTED_TOOLS, SIGNALS, ASSIGNMENT_CONTEXT_RE, DLP_STOPWORDS, DLP_PATTERNS, DLP_PATTERNS_GLOBAL, SENSITIVE_PATH_PATTERNS, MAX_DEPTH, MAX_STRING_BYTES, MAX_JSON_PARSE_BYTES, DLP_SCAN_LIMITS, MAX_REGEX_LENGTH, REGEX_CACHE_MAX, regexCache, FORBIDDEN_PATH_SEGMENTS, syntax, sharedParser, MESSAGE_FLAGS, SHELL_INTERPRETERS, DOWNLOAD_CMDS, NORMALIZE_CACHE_MAX, normalizeCache, AST_CACHE_MAX, astCache, PARSE_FAIL, FS_READ_TOOLS, GREP_SHAPE, PATTERN_VERBS, PATTERN_VERB_NAMES, GREP_FILE_OPERANDS, READER_VALUE_LETTERS, FILE_OPERAND_FLAGS, SCP_VALUE_FLAGS, TAR_VALUE_LETTERS, ZIP_VALUE_LETTERS, RSYNC_VALUE_LETTERS, RSYNC_SKIP, CP_VALUE_LETTERS, INSTALL_VALUE_LETTERS, COPY_VERBS, TAR_MODE_WORD, COPY_VERB_HEADS, FS_OP_PRESCREEN_RE, HOME_CACHE_ALLOWLIST, SENSITIVE_PATH_RULES, BASH_TOOL_NAMES, AST_FS_REGEX_RULES, SQL_DB_CLIS, SQL_DDL_RE, CHMOD_OPEN_PERM_TOKENS, COMMAND_WRAPPERS, INLINE_INTERPRETER, RUNNER_WRAPPERS, _redirStdinOps, _listOps, WRAPPER_TAKES_TARGET, FIND_EXEC_FLAGS, INTERP_LEADING_TARGET, NET_BINARIES, VALUE_FLAGS, FS_OP_CACHE_MAX, fsOpCache, stripDotSlash, REDIR_TRUNCATE_OPS, REDIR_FILE_IN_OPS, REDIR_HEREDOC_OPS, FIND_OPTIONS, COPY_RULE_OF, isReaderWord, positionalAfter, NONE, UNKNOWN, DEFAULT_EGRESS_ALLOWLIST, SOURCE_COMMANDS, SINK_COMMANDS, OBFUSCATORS, SENSITIVE_PATTERNS, FLAGS_WITH_VALUES, SSRF_MAX_HOST, METADATA_ADDRESSES, STRICT_TIERS, METADATA_HOSTNAMES, v4Octets, TIER_REASON, DESTINATION_ARGS, VERDICT_RANK, SQL_DML_KEYWORDS, aws_default, bash_safe_default, docker_default, filesystem_default, github_default, k8s_default, mongodb_default, postgres_default, project_jail_default, redis_default, BUILTIN_SHIELDS, LOOP_MAX_RECORDS, FINDING_TO_SIGNAL, SCAN_SIGNAL_WEIGHTS, LOOP_THRESHOLD_FOR_WASTE, DESTRUCTIVE_OP_RE, SENSITIVE_PATH_RE, FILE_TOOLS, PII_EMAIL_RE, PII_SSN_RE, PII_PHONE_RE, PII_CC16_RE, PII_CC15_RE, PII_IBAN_RE, REALTIME_PII_PATTERNS, MAX_PII_SCAN_BYTES, CANARY_MIN_LENGTH, MAX_TEXT, MAX_DEPTH2, MAX_JSON_PARSE, URL_DEPTH, B64_DEPTH, MIN_SEGMENT, SEPARATORS, stripSeparators, looksText, CANARY_DECODERS, VIEWS, LONG_OUTPUT_THRESHOLD_BYTES, CANONICAL_EXTRACTOR_VERSION, DEDUPE_PREVIEW_LEN, TERMINAL_ESCAPE_RE, ENGINE_VERSION;
3325
3691
  var init_dist = __esm({
3326
3692
  "packages/policy-engine/dist/index.mjs"() {
3327
3693
  "use strict";
@@ -4064,12 +4430,408 @@ var init_dist = __esm({
4064
4430
  "nl",
4065
4431
  "dd"
4066
4432
  ]);
4433
+ GREP_SHAPE = {
4434
+ takesValue: /* @__PURE__ */ new Set([
4435
+ "-A",
4436
+ "-B",
4437
+ "-C",
4438
+ "-D",
4439
+ "-d",
4440
+ "-e",
4441
+ "-f",
4442
+ "-m",
4443
+ "--after-context",
4444
+ "--before-context",
4445
+ "--binary-files",
4446
+ "--context",
4447
+ "--devices",
4448
+ "--directories",
4449
+ "--exclude",
4450
+ "--exclude-dir",
4451
+ "--exclude-from",
4452
+ "--file",
4453
+ "--include",
4454
+ "--label",
4455
+ "--max-count",
4456
+ "--regexp"
4457
+ ]),
4458
+ noValue: /* @__PURE__ */ new Set([
4459
+ "-E",
4460
+ "-F",
4461
+ "-G",
4462
+ "-P",
4463
+ "-i",
4464
+ "-y",
4465
+ "-v",
4466
+ "-V",
4467
+ "-w",
4468
+ "-x",
4469
+ "-c",
4470
+ "-l",
4471
+ "-L",
4472
+ "-o",
4473
+ "-q",
4474
+ "-s",
4475
+ "-b",
4476
+ "-H",
4477
+ "-h",
4478
+ "-n",
4479
+ "-T",
4480
+ "-Z",
4481
+ "-z",
4482
+ "-R",
4483
+ "-r",
4484
+ "-U",
4485
+ "-u",
4486
+ "-I",
4487
+ "-a",
4488
+ "--basic-regexp",
4489
+ "--binary",
4490
+ "--byte-offset",
4491
+ "--color",
4492
+ "--colour",
4493
+ "--count",
4494
+ "--dereference-recursive",
4495
+ "--extended-regexp",
4496
+ "--files-with-matches",
4497
+ "--files-without-match",
4498
+ "--fixed-strings",
4499
+ "--help",
4500
+ "--ignore-case",
4501
+ "--initial-tab",
4502
+ "--invert-match",
4503
+ "--line-buffered",
4504
+ "--line-number",
4505
+ "--line-regexp",
4506
+ "--no-filename",
4507
+ "--no-group-separator",
4508
+ "--no-ignore-case",
4509
+ "--no-messages",
4510
+ "--null",
4511
+ "--null-data",
4512
+ "--only-matching",
4513
+ "--perl-regexp",
4514
+ "--quiet",
4515
+ "--recursive",
4516
+ "--silent",
4517
+ "--text",
4518
+ "--version",
4519
+ "--with-filename",
4520
+ "--word-regexp"
4521
+ ]),
4522
+ patternFlags: /* @__PURE__ */ new Set(["-e", "--regexp"]),
4523
+ noPatternFlags: /* @__PURE__ */ new Set(["-f", "--file"])
4524
+ };
4525
+ PATTERN_VERBS = {
4526
+ grep: GREP_SHAPE,
4527
+ // /usr/bin/egrep and /usr/bin/fgrep are 41-byte shell wrappers that exec
4528
+ // `grep -E` and `grep -F`. Read in full, not assumed.
4529
+ egrep: GREP_SHAPE,
4530
+ fgrep: GREP_SHAPE,
4531
+ // ripgrep 14.1.1, complete from its own --help. An earlier comment here said rg
4532
+ // was not installed on the measuring machine; it is, and that stale claim is why
4533
+ // this table shipped incomplete for two rounds, blocking ordinary searches like
4534
+ // `rg --sort-files .env src` (/code-review round 4).
4535
+ rg: {
4536
+ takesValue: /* @__PURE__ */ new Set([
4537
+ "-A",
4538
+ "-B",
4539
+ "-C",
4540
+ "-d",
4541
+ "-E",
4542
+ "-e",
4543
+ "-f",
4544
+ "-g",
4545
+ "-j",
4546
+ "-M",
4547
+ "-m",
4548
+ "-r",
4549
+ "-t",
4550
+ "-T",
4551
+ "--after-context",
4552
+ "--before-context",
4553
+ "--color",
4554
+ "--colors",
4555
+ "--context",
4556
+ "--context-separator",
4557
+ "--dfa-size-limit",
4558
+ "--encoding",
4559
+ "--engine",
4560
+ "--field-context-separator",
4561
+ "--field-match-separator",
4562
+ "--file",
4563
+ "--generate",
4564
+ "--glob",
4565
+ "--hostname-bin",
4566
+ "--hyperlink-format",
4567
+ "--iglob",
4568
+ "--ignore-file",
4569
+ "--max-columns",
4570
+ "--max-count",
4571
+ "--max-depth",
4572
+ // An ALIAS of --max-depth, and it takes a value. The round-5 extraction read
4573
+ // alias lines as plain switches and swept it into noValue, which hard-blocked
4574
+ // `rg --maxdepth 2 .env src` while `--max-depth 2` ran (/code-review round 7).
4575
+ "--maxdepth",
4576
+ "--max-filesize",
4577
+ "--path-separator",
4578
+ "--pre",
4579
+ "--pre-glob",
4580
+ "--regexp",
4581
+ "--regex-size-limit",
4582
+ "--replace",
4583
+ "--sort",
4584
+ "--sortr",
4585
+ "--threads",
4586
+ "--type",
4587
+ "--type-add",
4588
+ "--type-clear",
4589
+ "--type-not"
4590
+ ]),
4591
+ noValue: /* @__PURE__ */ new Set([
4592
+ "-.",
4593
+ "-0",
4594
+ "-a",
4595
+ "-b",
4596
+ "-c",
4597
+ "-F",
4598
+ "-h",
4599
+ "-H",
4600
+ "-i",
4601
+ "-I",
4602
+ "-l",
4603
+ "-L",
4604
+ "-n",
4605
+ "-N",
4606
+ "-o",
4607
+ "-p",
4608
+ "-P",
4609
+ "-q",
4610
+ "-s",
4611
+ "-S",
4612
+ "-u",
4613
+ "-U",
4614
+ "-v",
4615
+ "-V",
4616
+ "-w",
4617
+ "-x",
4618
+ "-z",
4619
+ "--auto-hybrid-regex",
4620
+ "--binary",
4621
+ "--block-buffered",
4622
+ "--byte-offset",
4623
+ "--case-sensitive",
4624
+ "--column",
4625
+ "--count",
4626
+ "--count-matches",
4627
+ "--crlf",
4628
+ "--debug",
4629
+ "--files",
4630
+ "--files-with-matches",
4631
+ "--files-without-match",
4632
+ "--fixed-strings",
4633
+ "--follow",
4634
+ "--glob-case-insensitive",
4635
+ "--heading",
4636
+ "--help",
4637
+ "--hidden",
4638
+ "--ignore-case",
4639
+ "--ignore-file-case-insensitive",
4640
+ "--include-zero",
4641
+ "--invert-match",
4642
+ "--json",
4643
+ "--line-buffered",
4644
+ "--line-number",
4645
+ "--line-regexp",
4646
+ "--max-columns-preview",
4647
+ "--mmap",
4648
+ "--multiline",
4649
+ "--multiline-dotall",
4650
+ "--no-column",
4651
+ "--no-config",
4652
+ "--no-context-separator",
4653
+ "--no-encoding",
4654
+ "--no-filename",
4655
+ "--no-ignore",
4656
+ "--no-ignore-dot",
4657
+ "--no-ignore-exclude",
4658
+ "--no-ignore-files",
4659
+ "--no-ignore-global",
4660
+ "--no-ignore-messages",
4661
+ "--no-ignore-parent",
4662
+ "--no-ignore-vcs",
4663
+ "--no-line-number",
4664
+ "--no-messages",
4665
+ "--no-pcre2-unicode",
4666
+ "--no-pre",
4667
+ "--no-require-git",
4668
+ "--no-unicode",
4669
+ "--null",
4670
+ "--null-data",
4671
+ "--one-file-system",
4672
+ "--only-matching",
4673
+ "--passthru",
4674
+ "--pcre2",
4675
+ "--pcre2-version",
4676
+ "--pretty",
4677
+ "--print0",
4678
+ "--quiet",
4679
+ "--search-zip",
4680
+ "--smart-case",
4681
+ "--sort-files",
4682
+ "--stats",
4683
+ "--stop-on-nonmatch",
4684
+ "--text",
4685
+ "--trace",
4686
+ "--trim",
4687
+ "--type-list",
4688
+ "--unrestricted",
4689
+ "--version",
4690
+ "--vimgrep",
4691
+ "--with-filename",
4692
+ "--word-regexp",
4693
+ // The COMPLETE remainder of `rg --help`, added after /code-review round 5
4694
+ // found 40 documented switches in neither set. The table is now the whole
4695
+ // option list: `rg --help` yields 149 long options, 35 of them value-taking.
4696
+ "--ignore",
4697
+ "--ignore-dot",
4698
+ "--ignore-exclude",
4699
+ "--ignore-files",
4700
+ "--ignore-global",
4701
+ "--ignore-messages",
4702
+ "--ignore-parent",
4703
+ "--ignore-vcs",
4704
+ "--messages",
4705
+ "--no-auto-hybrid-regex",
4706
+ "--no-binary",
4707
+ "--no-block-buffered",
4708
+ "--no-byte-offset",
4709
+ "--no-crlf",
4710
+ "--no-fixed-strings",
4711
+ "--no-follow",
4712
+ "--no-glob-case-insensitive",
4713
+ "--no-heading",
4714
+ "--no-hidden",
4715
+ "--no-ignore-file-case-insensitive",
4716
+ "--no-include-zero",
4717
+ "--no-invert-match",
4718
+ "--no-json",
4719
+ "--no-line-buffered",
4720
+ "--no-max-columns-preview",
4721
+ "--no-mmap",
4722
+ "--no-multiline",
4723
+ "--no-multiline-dotall",
4724
+ "--no-one-file-system",
4725
+ "--no-pcre2",
4726
+ "--no-search-zip",
4727
+ "--no-sort-files",
4728
+ "--no-stats",
4729
+ "--no-text",
4730
+ "--no-trim",
4731
+ "--passthrough",
4732
+ "--pcre2-unicode",
4733
+ "--require-git",
4734
+ "--unicode"
4735
+ ]),
4736
+ patternFlags: /* @__PURE__ */ new Set(["-e", "--regexp"]),
4737
+ // `--files` and `--type-list` list or enumerate without a pattern. Founder
4738
+ // decision 2026-09-13: `rg --files ~/.ssh` stays BLOCKED, which this achieves
4739
+ // by leaving the directory in the judged list.
4740
+ noPatternFlags: /* @__PURE__ */ new Set(["-f", "--file", "--files", "--type-list", "--pcre2-version"])
4741
+ }
4742
+ };
4743
+ PATTERN_VERB_NAMES = Object.keys(PATTERN_VERBS);
4744
+ GREP_FILE_OPERANDS = /* @__PURE__ */ new Set(["-f", "--file", "--exclude-from", "--include"]);
4745
+ READER_VALUE_LETTERS = {
4746
+ awk: ["F", "v", "f"],
4747
+ gawk: ["F", "v", "f", "e", "E", "i", "l", "o", "p", "D"],
4748
+ sed: ["e", "f", "i", "l"],
4749
+ sort: ["C", "k", "o", "S", "t", "T"]
4750
+ };
4751
+ FILE_OPERAND_FLAGS = {
4752
+ grep: GREP_FILE_OPERANDS,
4753
+ egrep: GREP_FILE_OPERANDS,
4754
+ fgrep: GREP_FILE_OPERANDS,
4755
+ // rg and gawk are NOT installed on the measuring machine: these two rows are
4756
+ // from the shipped documentation (ripgrep `-f/--file`, `--ignore-file`; gawk
4757
+ // `-f/--file`) and are marked as such in the spec.
4758
+ // `-g/--glob/--iglob` name which files ripgrep SEARCHES, so an operand naming
4759
+ // a credential makes rg open it -- the same reason grep's `--include` is here.
4760
+ // Measured (/code-review round 3): `rg --hidden -g .env AWS tree` opened .env
4761
+ // and printed its contents.
4762
+ rg: /* @__PURE__ */ new Set(["-f", "--file", "--ignore-file", "-g", "--glob", "--iglob"]),
4763
+ sed: /* @__PURE__ */ new Set(["-f", "--file"]),
4764
+ awk: /* @__PURE__ */ new Set(["-f", "--file"]),
4765
+ gawk: /* @__PURE__ */ new Set(["-f", "--file"]),
4766
+ sort: /* @__PURE__ */ new Set(["--files0-from"])
4767
+ };
4768
+ SCP_VALUE_FLAGS = ["i", "F", "o", "c", "S", "P", "J", "D", "W", "l"];
4769
+ TAR_VALUE_LETTERS = ["b", "C", "f", "F", "g", "H", "I", "K", "L", "N", "T", "V", "X"];
4770
+ ZIP_VALUE_LETTERS = ["b", "n", "P", "t", "s", "O", "x", "i"];
4771
+ RSYNC_VALUE_LETTERS = ["e", "f", "T", "B", "M"];
4772
+ RSYNC_SKIP = [
4773
+ "e",
4774
+ "--rsh",
4775
+ "--exclude",
4776
+ "--exclude-from",
4777
+ "--include",
4778
+ "--include-from",
4779
+ "--files-from",
4780
+ "f",
4781
+ "--filter"
4782
+ ];
4783
+ CP_VALUE_LETTERS = ["S", "t"];
4784
+ INSTALL_VALUE_LETTERS = ["S", "t", "g", "m", "o"];
4785
+ COPY_VERBS = {
4786
+ cp: { source: "allButLast", targetDirFlag: true, valueLetters: CP_VALUE_LETTERS },
4787
+ mv: { source: "allButLast", targetDirFlag: true, valueLetters: CP_VALUE_LETTERS },
4788
+ install: { source: "allButLast", targetDirFlag: true, valueLetters: INSTALL_VALUE_LETTERS },
4789
+ ln: { source: "first", targetDirFlag: true, valueLetters: CP_VALUE_LETTERS },
4790
+ scp: { source: "allButLast", skipFlags: SCP_VALUE_FLAGS, valueLetters: SCP_VALUE_FLAGS },
4791
+ rsync: { source: "allButLast", skipFlags: RSYNC_SKIP, valueLetters: RSYNC_VALUE_LETTERS },
4792
+ tar: {
4793
+ source: "archive",
4794
+ archive: "tar",
4795
+ skipFlags: ["f", "X", "T", "--file", "--exclude", "--exclude-from", "--files-from"],
4796
+ valueLetters: TAR_VALUE_LETTERS
4797
+ },
4798
+ zip: {
4799
+ source: "archive",
4800
+ archive: "zip",
4801
+ skipFlags: ["x", "i", "--exclude", "--include"],
4802
+ valueLetters: ZIP_VALUE_LETTERS
4803
+ },
4804
+ ar: { source: "archive", archive: "ar" },
4805
+ // 7z switches are INLINE only (`-mx9`, `-px`, `-x!pat`), so no following word is
4806
+ // ever a switch's operand -- which is why the short `x` is NOT a skipFlag here
4807
+ // and valueLetters is empty. Keeping the short `x` made `7z a out.7z -mx KEY`
4808
+ // drop the credential as an exclusion operand (/code-review round 8), and the
4809
+ // derived "every skipped letter is a value letter" row in jail-copy.spec.ts is
4810
+ // what keeps the two tables honest about it.
4811
+ "7z": { source: "archive", archive: "7z", skipFlags: ["--exclude"], valueLetters: [] },
4812
+ gzip: { source: "all" },
4813
+ bzip2: { source: "all" },
4814
+ xz: { source: "all" },
4815
+ "docker cp": { source: "allButLast" },
4816
+ "kubectl cp": { source: "allButLast" },
4817
+ "gsutil cp": { source: "allButLast" },
4818
+ "gsutil rsync": { source: "allButLast" },
4819
+ "rclone copy": { source: "allButLast" },
4820
+ "rclone sync": { source: "allButLast" },
4821
+ "aws s3 cp": { source: "allButLast" },
4822
+ "aws s3 mv": { source: "allButLast" },
4823
+ "aws s3 sync": { source: "allButLast" },
4824
+ "gcloud storage cp": { source: "allButLast" },
4825
+ "az storage blob upload": { source: "flagOperand", sourceFlags: ["f", "--file"] }
4826
+ };
4827
+ TAR_MODE_WORD = /^[a-zA-Z]+$/;
4828
+ COPY_VERB_HEADS = new Set(Object.keys(COPY_VERBS).map((k) => k.split(" ")[0]));
4067
4829
  FS_OP_PRESCREEN_RE = new RegExp(
4068
4830
  // A quote is a separator too: `eval "cat X"` and `sh -c 'cat X'` put the
4069
4831
  // reader right after `"` / `'`, and without these two characters the
4070
4832
  // prescreen rejected every string-wrapped read before the parser ran.
4071
4833
  // Found 2026-09-11 by instrumenting the walk -- no CallExpr was ever visited.
4072
- `(?:^|[\\s|;&("'\`\\n])(?:rm|${[...FS_READ_TOOLS].map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\b|(?<!<)<(?!<)`
4834
+ `(?:^|[\\s|;&("'\`\\n/])(?:rm|${[...FS_READ_TOOLS, ...COPY_VERB_HEADS].map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\b|(?<!<)<(?!<)`
4073
4835
  );
4074
4836
  HOME_CACHE_ALLOWLIST = [
4075
4837
  ".cache",
@@ -4359,8 +5121,17 @@ var init_dist = __esm({
4359
5121
  deriveRedirOp("cat <<X\nX"),
4360
5122
  deriveRedirOp("cat <<-X\nX")
4361
5123
  ]);
4362
- isReaderWord = (w) => w !== null && FS_READ_TOOLS.has(w.split("/").pop()?.toLowerCase() ?? "");
4363
- positionalAfter = (words, from, to = words.length) => words.slice(from, to).filter((w) => w !== null && !w.startsWith("-"));
5124
+ FIND_OPTIONS = /* @__PURE__ */ new Set(["-H", "-L", "-P"]);
5125
+ COPY_RULE_OF = {
5126
+ "shield:project-jail:block-read-ssh": "shield:project-jail:review-copy-ssh",
5127
+ "shield:project-jail:block-read-aws": "shield:project-jail:review-copy-aws",
5128
+ "shield:project-jail:block-read-env": "shield:project-jail:review-copy-env",
5129
+ "shield:project-jail:review-read-credentials": "shield:project-jail:review-copy-credentials"
5130
+ };
5131
+ isReaderWord = (w) => w !== null && FS_READ_TOOLS.has(baseWord(w));
5132
+ positionalAfter = (words, from, to = words.length) => positionedArgs(words, from, to).map((a) => a.value);
5133
+ NONE = { kind: "none" };
5134
+ UNKNOWN = { kind: "unknown" };
4364
5135
  DEFAULT_EGRESS_ALLOWLIST = [
4365
5136
  // node9's own control plane (api, app, dev-api, staging and the apex).
4366
5137
  // Without it, turning egress on asks the user to approve node9 itself.
@@ -5405,7 +6176,7 @@ var init_dist = __esm({
5405
6176
  { view: "separators-stripped", decoder: "separators", stripped: true }
5406
6177
  ];
5407
6178
  LONG_OUTPUT_THRESHOLD_BYTES = 100 * 1024;
5408
- CANONICAL_EXTRACTOR_VERSION = "canonical-v13";
6179
+ CANONICAL_EXTRACTOR_VERSION = "canonical-v15";
5409
6180
  DEDUPE_PREVIEW_LEN = 120;
5410
6181
  TERMINAL_ESCAPE_RE = // eslint-disable-next-line no-control-regex
5411
6182
  /\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-_]|[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g;
@@ -12975,6 +13746,28 @@ function codexSessionCost(model, tokens, request2) {
12975
13746
  const tierMultiplier = request2?.serviceTier === "flex" ? 0.5 : codexModel(model) === "gpt-6-astra" && (request2?.serviceTier === "fast" || request2?.serviceTier === "priority") ? 2 : 1;
12976
13747
  return (((input - cached - written) * pin + cached * pcr + written * (pcw || pin)) * inputMultiplier + tokenNumber(tokens.output) * pout * outputMultiplier) * tierMultiplier;
12977
13748
  }
13749
+ function statAndFirstLine(file) {
13750
+ const CAP = 4 * 1024 * 1024;
13751
+ const CHUNK = 64 * 1024;
13752
+ const fd = import_fs20.default.openSync(file, "r");
13753
+ try {
13754
+ const stat = import_fs20.default.fstatSync(fd);
13755
+ const limit = Math.min(stat.size, CAP);
13756
+ const parts = [];
13757
+ for (let pos = 0; pos < limit; pos += CHUNK) {
13758
+ const buf = Buffer.alloc(Math.min(CHUNK, limit - pos));
13759
+ const read2 = import_fs20.default.readSync(fd, buf, 0, buf.length, pos);
13760
+ if (read2 <= 0) break;
13761
+ const slice = buf.subarray(0, read2);
13762
+ const nl = slice.indexOf(10);
13763
+ parts.push(nl >= 0 ? slice.subarray(0, nl) : slice);
13764
+ if (nl >= 0) break;
13765
+ }
13766
+ return { stat, first: Buffer.concat(parts).toString("utf8") };
13767
+ } finally {
13768
+ import_fs20.default.closeSync(fd);
13769
+ }
13770
+ }
12978
13771
  function listCodexSessionFiles(base = codexSessionsDir()) {
12979
13772
  const files = [];
12980
13773
  const walk = (dir) => {
@@ -12992,10 +13785,10 @@ function listCodexSessionFiles(base = codexSessionsDir()) {
12992
13785
  const sessions = /* @__PURE__ */ new Map();
12993
13786
  for (const file of files.sort()) {
12994
13787
  try {
12995
- const stat = import_fs20.default.statSync(file);
13788
+ const { stat, first: head } = statAndFirstLine(file);
12996
13789
  let id = "";
12997
13790
  try {
12998
- const first = JSON.parse(import_fs20.default.readFileSync(file, "utf8").split("\n", 1)[0]);
13791
+ const first = JSON.parse(head);
12999
13792
  if (first?.type === "session_meta" && typeof first.payload?.id === "string")
13000
13793
  id = first.payload.id;
13001
13794
  } catch {
@@ -59460,7 +60253,11 @@ var BUILTIN_JAIL = [
59460
60253
  "~/.ssh \u2014 SSH private keys",
59461
60254
  "~/.aws \u2014 AWS credentials",
59462
60255
  ".env files",
59463
- "credential files \u2014 credentials.json, .netrc, .npmrc, .docker, .kube, gcloud"
60256
+ "credential files \u2014 credentials.json, .netrc, .npmrc, .docker, .kube, gcloud",
60257
+ // Stage 4 (2026-09-11): reads are blocked; a COPY out of the jail (cp, tar,
60258
+ // scp, rsync, aws s3 cp, ...) is reviewed, because a backup and a theft are
60259
+ // the same command shape.
60260
+ "copies out of the jail (cp, tar, scp, rsync, cloud upload) \u2014 review"
59464
60261
  ];
59465
60262
  function registerJailCommand(program2) {
59466
60263
  const jail = program2.command("jail").description("Grow the credential jail \u2014 block/review reads of your sensitive paths");