@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.mjs CHANGED
@@ -905,6 +905,19 @@ function parseShared(command) {
905
905
  astCache.set(command, parsed);
906
906
  return parsed;
907
907
  }
908
+ function byteOffsetToCharIndex(command) {
909
+ if (!/[^\u0000-\u007F]/.test(command)) return null;
910
+ const map = /* @__PURE__ */ new Map();
911
+ let byte = 0;
912
+ for (let i = 0; i < command.length; ) {
913
+ map.set(byte, i);
914
+ const cp = command.codePointAt(i);
915
+ byte += cp < 128 ? 1 : cp < 2048 ? 2 : cp < 65536 ? 3 : 4;
916
+ i += cp > 65535 ? 2 : 1;
917
+ }
918
+ map.set(byte, command.length);
919
+ return (b) => map.get(b) ?? -1;
920
+ }
908
921
  function cachedNormalize(command, compute) {
909
922
  const hit = normalizeCache.get(command);
910
923
  if (hit !== void 0) {
@@ -934,6 +947,8 @@ function normalizeCommandForPolicyImpl(command) {
934
947
  const f = parseShared(command);
935
948
  if (f === PARSE_FAIL) return { posix: command, separator: command };
936
949
  try {
950
+ const toCharIndex = byteOffsetToCharIndex(command);
951
+ const at = (byteOffset) => toCharIndex === null ? byteOffset : toCharIndex(byteOffset);
937
952
  const strips = [];
938
953
  const rewrites = [];
939
954
  const quoteOnlyRewrites = [];
@@ -954,8 +969,9 @@ function normalizeCommandForPolicyImpl(command) {
954
969
  const quotedNode = nextParts[0];
955
970
  const nt = syntax.NodeType(quotedNode);
956
971
  const markStrip = () => {
957
- const s = next.Pos().Offset();
958
- const e = next.End().Offset();
972
+ const s = at(next.Pos().Offset());
973
+ const e = at(next.End().Offset());
974
+ if (s < 0 || e < 0) return;
959
975
  strips.push([s, e]);
960
976
  msgSpans.add(`${s}:${e}`);
961
977
  };
@@ -972,8 +988,9 @@ function normalizeCommandForPolicyImpl(command) {
972
988
  }
973
989
  }
974
990
  for (const arg of args) {
975
- const s = arg.Pos().Offset();
976
- const e = arg.End().Offset();
991
+ const s = at(arg.Pos().Offset());
992
+ const e = at(arg.End().Offset());
993
+ if (s < 0 || e < 0) continue;
977
994
  if (msgSpans.has(`${s}:${e}`)) continue;
978
995
  const resolved = resolveWordLiteral(arg);
979
996
  if (resolved === null) continue;
@@ -1302,20 +1319,32 @@ function isProtectedHomePath(rawPath) {
1302
1319
  }
1303
1320
  return true;
1304
1321
  }
1305
- function extractLiteralArgs(callExpr) {
1306
- const args = callExpr.Args || [];
1307
- if (args.length === 0) return { name: "", flags: [], paths: [], words: [] };
1308
- const words = args.map((a) => resolveWordLiteral(a));
1309
- const name = (words[0] ?? "").toLowerCase();
1310
- const flags = [];
1311
- const paths = [];
1312
- for (let i = 1; i < words.length; i++) {
1322
+ function positionedArgs(words, from = 1, to = words.length) {
1323
+ const out = [];
1324
+ let afterFlag = null;
1325
+ for (let i = from; i < to; i++) {
1313
1326
  const v = words[i];
1314
- if (v === null) continue;
1315
- if (v.startsWith("-")) flags.push(v);
1316
- else paths.push(v);
1327
+ if (v === null) {
1328
+ afterFlag = null;
1329
+ continue;
1330
+ }
1331
+ if (v.startsWith("-")) {
1332
+ afterFlag = v;
1333
+ continue;
1334
+ }
1335
+ out.push({ value: v, index: out.length, argv: i, afterFlag });
1336
+ afterFlag = null;
1317
1337
  }
1318
- return { name, flags, paths, words };
1338
+ return out;
1339
+ }
1340
+ function extractLiteralArgs(callExpr) {
1341
+ const rawArgs = callExpr.Args || [];
1342
+ if (rawArgs.length === 0) return { name: "", flags: [], paths: [], words: [], args: [] };
1343
+ const words = rawArgs.map((a) => resolveWordLiteral(a));
1344
+ const name = baseWord(words[0]);
1345
+ const flags = words.slice(1).filter((w) => w !== null && w.startsWith("-"));
1346
+ const args = positionedArgs(words);
1347
+ return { name, flags, paths: args.map((a) => a.value), words, args };
1319
1348
  }
1320
1349
  function resolveWordLiteral(w) {
1321
1350
  const parts = w?.Parts || [];
@@ -1587,7 +1616,7 @@ function analyzeFsOperationImpl(command, depth = 0) {
1587
1616
  return result?.verdict !== "block";
1588
1617
  }
1589
1618
  if (nodeType !== "CallExpr") return true;
1590
- const { name, flags, paths, words } = extractLiteralArgs(n);
1619
+ const { name, flags, paths, words, args } = extractLiteralArgs(n);
1591
1620
  if (!name) return true;
1592
1621
  if (name === "rm") {
1593
1622
  const flagStr = flags.join("").toLowerCase();
@@ -1627,13 +1656,16 @@ function analyzeFsOperationImpl(command, depth = 0) {
1627
1656
  return true;
1628
1657
  }
1629
1658
  }
1630
- const readPaths = FS_READ_TOOLS.has(name) ? paths : wrappedReadPaths(words, name);
1659
+ const readPaths = FS_READ_TOOLS.has(name) ? [...readTargets(name, args, flags, words, 1), ...flagOperandFiles(name, words, 1)] : wrappedReadPaths(words, name);
1631
1660
  if (readPaths) {
1632
1661
  for (const p of readPaths) {
1633
1662
  result = stricter(result, matchSensitivePath2(p));
1634
1663
  if (result?.verdict === "block") return false;
1635
1664
  }
1636
1665
  }
1666
+ for (const p of copySourcePaths(words)) {
1667
+ result = stricter(result, copyVerdictOf(matchSensitivePath2(p)));
1668
+ }
1637
1669
  return true;
1638
1670
  });
1639
1671
  return result;
@@ -1646,6 +1678,184 @@ function stricter(a, b) {
1646
1678
  if (!b) return a;
1647
1679
  return b.verdict === "block" && a.verdict !== "block" ? b : a;
1648
1680
  }
1681
+ function flagInfo(w) {
1682
+ if (w.startsWith("--")) {
1683
+ const eq = w.indexOf("=");
1684
+ return eq < 0 ? { letter: null, long: w, attached: null } : { letter: null, long: w.slice(0, eq), attached: w.slice(eq + 1) };
1685
+ }
1686
+ const m = /^-([a-zA-Z]+)(.*)$/.exec(w);
1687
+ if (!m) return { letter: null, long: null, attached: null };
1688
+ return { letter: m[1][m[1].length - 1], long: null, attached: m[2] === "" ? null : m[2] };
1689
+ }
1690
+ function flagIs(w, names) {
1691
+ if (w === null) return false;
1692
+ const f = flagInfo(w);
1693
+ return names.some((n) => n.startsWith("--") ? f.long === n : f.letter === n);
1694
+ }
1695
+ function operandOf(a, names, valueLetters) {
1696
+ if (!names || a.afterFlag === null) return false;
1697
+ const w = a.afterFlag;
1698
+ if (!w.startsWith("--") && valueLetters) {
1699
+ const letters = w.slice(1);
1700
+ for (let i = 0; i < letters.length; i++) {
1701
+ if (!valueLetters.includes(letters[i])) continue;
1702
+ return i === letters.length - 1 && names.includes(letters[i]);
1703
+ }
1704
+ return false;
1705
+ }
1706
+ if (w.startsWith("--") && !w.includes("=")) {
1707
+ const longs = names.filter((n) => n.startsWith("--"));
1708
+ if (longs.some((n) => n === w || w.length >= 3 && n.startsWith(w))) return true;
1709
+ }
1710
+ return flagIs(w, names) && flagInfo(w).attached === null;
1711
+ }
1712
+ function resolveCopyShape(words, h) {
1713
+ const verb = baseWord(words[h]);
1714
+ if (!verb) return null;
1715
+ const direct = COPY_VERBS[verb];
1716
+ if (direct) return { shape: direct, last: h };
1717
+ const slots = positionedArgs(words, h + 1);
1718
+ for (let i = 0; i < slots.length; i++) {
1719
+ for (let n = 3; n >= 1; n--) {
1720
+ const part = slots.slice(i, i + n);
1721
+ if (part.length < n) continue;
1722
+ const key = [verb, ...part.map((a) => a.value.toLowerCase())].join(" ");
1723
+ const shape = COPY_VERBS[key];
1724
+ if (shape) return { shape, last: part[n - 1].argv };
1725
+ }
1726
+ if (slots[i].afterFlag === null) return null;
1727
+ }
1728
+ return null;
1729
+ }
1730
+ function findStartPoints(words, h) {
1731
+ const k = words.findIndex((w, i) => i > h && w !== null && FIND_EXEC_FLAGS.has(w));
1732
+ if (k < 0) return { k, starts: [] };
1733
+ const firstPredicate = words.findIndex(
1734
+ (w, i) => i > h && w !== null && w !== "--" && w.startsWith("-") && !FIND_OPTIONS.has(w)
1735
+ );
1736
+ const end = firstPredicate > h ? firstPredicate : k;
1737
+ return { k, starts: positionalAfter(words, h + 1, end) };
1738
+ }
1739
+ function copySourcePaths(words) {
1740
+ const h = unwrapCommandHead(words);
1741
+ const fi = words.findIndex((w, i) => i <= h && baseWord(w) === "find");
1742
+ if (fi >= 0) {
1743
+ const { k, starts } = findStartPoints(words, fi);
1744
+ if (k < 0) return [];
1745
+ const action = unwrapCommandHead(words.slice(k + 1));
1746
+ return resolveCopyShape(words.slice(k + 1), action) ? starts : [];
1747
+ }
1748
+ if (!COPY_VERB_HEADS.has(baseWord(words[h]))) return [];
1749
+ const r = resolveCopyShape(words, h);
1750
+ if (!r) return [];
1751
+ const { shape, last } = r;
1752
+ const args = positionedArgs(words, last + 1);
1753
+ const tail = words.slice(last + 1);
1754
+ const copyOptionsEnd = tail.findIndex((w) => w === "--");
1755
+ const copyPastOptions = (a) => copyOptionsEnd >= 0 && a.argv > last + 1 + copyOptionsEnd;
1756
+ const skipped = (a) => !copyPastOptions(a) && operandOf(a, shape.skipFlags, shape.valueLetters);
1757
+ const firstValueLetter = (w) => {
1758
+ const letters = w.slice(1);
1759
+ for (let i = 0; i < letters.length; i++) {
1760
+ if ((shape.valueLetters ?? []).includes(letters[i]))
1761
+ return { letter: letters[i], last: i === letters.length - 1 };
1762
+ }
1763
+ return null;
1764
+ };
1765
+ const isTargetDirFlag = (w) => {
1766
+ if (w.startsWith("--")) {
1767
+ const name = w.includes("=") ? w.slice(0, w.indexOf("=")) : w;
1768
+ return name.length >= 3 && "--target-directory".startsWith(name);
1769
+ }
1770
+ return firstValueLetter(w)?.letter === "t";
1771
+ };
1772
+ const targetDir = shape.targetDirFlag === true && tail.some((w) => w !== null && w.startsWith("-") && isTargetDirFlag(w));
1773
+ const targetTakesNextWord = (w) => {
1774
+ if (w.startsWith("--"))
1775
+ return !w.includes("=") && w.length >= 3 && "--target-directory".startsWith(w);
1776
+ const f = firstValueLetter(w);
1777
+ return f !== null && f.letter === "t" && f.last;
1778
+ };
1779
+ const targetOperand = (a) => targetDir && a.afterFlag !== null && !copyPastOptions(a) && targetTakesNextWord(a.afterFlag);
1780
+ const lastOperand = [...tail].reverse().find((w) => w === null || !w.startsWith("-"));
1781
+ const dynamicDest = lastOperand === null;
1782
+ const destIsLastOperand = (shape.source === "allButLast" || shape.source === "first") && !targetDir && !dynamicDest;
1783
+ let src;
1784
+ switch (shape.source) {
1785
+ case "all":
1786
+ src = args;
1787
+ break;
1788
+ case "first":
1789
+ src = targetDir ? args : args.slice(0, 1);
1790
+ break;
1791
+ case "flagOperand": {
1792
+ const namesSource = (f) => {
1793
+ const names = shape.sourceFlags ?? [];
1794
+ if (f.long !== null)
1795
+ return names.some(
1796
+ (n) => n.startsWith("--") && (n === f.long || f.long.length >= 3 && n.startsWith(f.long))
1797
+ );
1798
+ return f.letter !== null && names.includes(f.letter);
1799
+ };
1800
+ const inline = tail.filter((w) => w !== null && w.startsWith("-")).map((w) => flagInfo(w)).filter((f) => f.attached !== null && namesSource(f)).map((f) => f.attached);
1801
+ return [
1802
+ ...args.filter((a) => !copyPastOptions(a) && operandOf(a, shape.sourceFlags, shape.valueLetters)).map((a) => a.value),
1803
+ ...inline
1804
+ ];
1805
+ }
1806
+ case "archive":
1807
+ src = archiveInputs(shape.archive, args, tail);
1808
+ break;
1809
+ case "allButLast":
1810
+ src = targetDir || dynamicDest ? args : args.slice(0, -1);
1811
+ break;
1812
+ }
1813
+ const sources = src.filter((a) => !skipped(a) && !targetOperand(a)).map((a) => a.value);
1814
+ if (destIsLastOperand && typeof lastOperand === "string" && matchSensitivePath2(lastOperand)) {
1815
+ const jailedSources = sources.filter((p) => matchSensitivePath2(p));
1816
+ const dirOf = (p) => /[\\/]/.test(p) ? p.replace(/[\\/][^\\/]*$/, "") : "";
1817
+ if (jailedSources.length === 0) return [];
1818
+ if (jailedSources.every((p) => dirOf(p) === dirOf(lastOperand))) return [];
1819
+ }
1820
+ return sources;
1821
+ }
1822
+ function archiveInputs(kind, args, tail) {
1823
+ const first = args[0];
1824
+ const bareKey = first && first.afterFlag === null && TAR_MODE_WORD.test(first.value);
1825
+ if (kind === "tar") {
1826
+ const flagsText = tail.filter((w) => w !== null && w.startsWith("-")).join(" ");
1827
+ const mode = (bareKey ? first.value : "") + flagsText;
1828
+ const extracting = /x|t/.test(bareKey ? first.value.replace(/f/g, "") : "") || /(^|\s)-[a-zA-Z]*[xt]|--extract|--list|--get/.test(flagsText);
1829
+ const writing = /[cruA]/.test(bareKey ? first.value : "") || /(^|\s)-[a-zA-Z]*[cruA]|--create|--append|--update|--concatenate/.test(flagsText);
1830
+ if (extracting && !writing) return [];
1831
+ void mode;
1832
+ if (!bareKey) return args;
1833
+ let i = 1;
1834
+ const fromDirs = [];
1835
+ for (const ch of first.value) {
1836
+ if (!TAR_VALUE_LETTERS.includes(ch)) continue;
1837
+ const operand = args[i];
1838
+ if (!operand || operand.afterFlag !== null) break;
1839
+ i += 1;
1840
+ if (ch === "C") fromDirs.push(operand);
1841
+ }
1842
+ return [...fromDirs, ...args.slice(i)];
1843
+ }
1844
+ if (kind === "zip") return first && first.afterFlag === "-" ? args : args.slice(1);
1845
+ if (kind === "ar") return bareKey ? args.slice(2) : args.slice(1);
1846
+ return args.slice(2);
1847
+ }
1848
+ function copyVerdictOf(hit) {
1849
+ if (!hit) return null;
1850
+ const ruleName = COPY_RULE_OF[hit.ruleName];
1851
+ if (!ruleName) return null;
1852
+ return {
1853
+ ruleName,
1854
+ verdict: "review",
1855
+ reason: `Copying ${hit.path} moves a credential out of its jail (project-jail shield)`,
1856
+ path: hit.path
1857
+ };
1858
+ }
1649
1859
  function matchSensitivePath2(p) {
1650
1860
  for (const sp of SENSITIVE_PATH_RULES) {
1651
1861
  if (sp.match(p))
@@ -1653,16 +1863,156 @@ function matchSensitivePath2(p) {
1653
1863
  }
1654
1864
  return null;
1655
1865
  }
1866
+ function baseWord(w) {
1867
+ return (w ?? "").split("/").pop()?.toLowerCase() ?? "";
1868
+ }
1869
+ function namesFlag(name, candidates2, known) {
1870
+ if (candidates2.has(name)) return true;
1871
+ if (!name.startsWith("--") || name.length < 3) return false;
1872
+ if (known?.has(name)) return false;
1873
+ for (const c of candidates2) if (c.startsWith(name)) return true;
1874
+ return false;
1875
+ }
1876
+ function knownLongFlags(verb) {
1877
+ const out = /* @__PURE__ */ new Set();
1878
+ const shape = PATTERN_VERBS[verb];
1879
+ if (shape) {
1880
+ for (const set of [shape.takesValue, shape.noValue, shape.patternFlags, shape.noPatternFlags])
1881
+ for (const f of set) if (f.startsWith("--")) out.add(f);
1882
+ }
1883
+ for (const f of FILE_OPERAND_FLAGS[verb] ?? []) if (f.startsWith("--")) out.add(f);
1884
+ return out;
1885
+ }
1886
+ function flagNamesOf(token, shape) {
1887
+ if (token.startsWith("--")) {
1888
+ const eq = token.indexOf("=");
1889
+ return [eq > 0 ? token.slice(0, eq) : token];
1890
+ }
1891
+ const out = [];
1892
+ for (const c of token.slice(1)) {
1893
+ const name = `-${c}`;
1894
+ out.push(name);
1895
+ if (shape?.takesValue.has(name)) break;
1896
+ }
1897
+ return out;
1898
+ }
1899
+ function flagEffect(token, shape, known) {
1900
+ if (token === "--") return NONE;
1901
+ if (/^-+$/.test(token)) return UNKNOWN;
1902
+ if (/^-\d+$/.test(token)) return NONE;
1903
+ if (token.startsWith("--")) {
1904
+ if (token.includes("=")) return NONE;
1905
+ const takes = namesFlag(token, shape.takesValue, known);
1906
+ const none = namesFlag(token, shape.noValue, known);
1907
+ if (takes && none) return UNKNOWN;
1908
+ if (takes) return { kind: "takes", flag: token };
1909
+ if (none) return NONE;
1910
+ return UNKNOWN;
1911
+ }
1912
+ const letters = token.slice(1);
1913
+ if (!letters) return NONE;
1914
+ for (let i = 0; i < letters.length; i++) {
1915
+ const name = `-${letters[i]}`;
1916
+ if (shape.takesValue.has(name)) {
1917
+ return i === letters.length - 1 ? { kind: "takes", flag: name } : NONE;
1918
+ }
1919
+ if (!shape.noValue.has(name)) return UNKNOWN;
1920
+ }
1921
+ return NONE;
1922
+ }
1923
+ function readTargets(verb, args, flags, words = [], from = 1) {
1924
+ const shape = PATTERN_VERBS[verb];
1925
+ if (!shape) return args.map((a) => a.value);
1926
+ const known = knownLongFlags(verb);
1927
+ const names = flags.flatMap((f) => flagNamesOf(f, shape));
1928
+ const patternElsewhere = names.some(
1929
+ (n) => namesFlag(n, shape.patternFlags, known) || namesFlag(n, shape.noPatternFlags, known)
1930
+ );
1931
+ const excused = /* @__PURE__ */ new Set();
1932
+ const fileFlags = FILE_OPERAND_FLAGS[verb];
1933
+ const optionsEnd = words.findIndex((w, i) => i >= from && w === "--");
1934
+ const pastOptions = (a) => optionsEnd >= 0 && a.argv > optionsEnd;
1935
+ for (const a of args) {
1936
+ if (a.afterFlag === null || pastOptions(a)) continue;
1937
+ const e = flagEffect(a.afterFlag, shape, known);
1938
+ if (e.kind !== "takes") continue;
1939
+ if (fileFlags && namesFlag(e.flag, fileFlags, known)) continue;
1940
+ excused.add(a);
1941
+ }
1942
+ const patternArgv = (() => {
1943
+ for (let i = from; i < words.length; i++) {
1944
+ const w = words[i];
1945
+ if (w === null) return -1;
1946
+ if (w === "--") return i + 1;
1947
+ if (w.startsWith("-")) {
1948
+ const e = flagEffect(w, shape, known);
1949
+ if (e.kind === "takes") i += 1;
1950
+ else if (e.kind === "unknown") return -1;
1951
+ continue;
1952
+ }
1953
+ return i;
1954
+ }
1955
+ return -1;
1956
+ })();
1957
+ if (!patternElsewhere && patternArgv >= 0) {
1958
+ const a = args.find((x) => x.argv === patternArgv);
1959
+ if (a) excused.add(a);
1960
+ }
1961
+ return args.filter((a) => !excused.has(a)).map((a) => a.value);
1962
+ }
1963
+ function flagOperandFiles(verb, words, from) {
1964
+ const flags = FILE_OPERAND_FLAGS[verb];
1965
+ if (!flags) return [];
1966
+ const known = knownLongFlags(verb);
1967
+ const out = [];
1968
+ for (let i = from; i < words.length; i++) {
1969
+ const w = words[i];
1970
+ if (w === null || !w.startsWith("-") || w === "--") continue;
1971
+ if (w.startsWith("--")) {
1972
+ const eq = w.indexOf("=");
1973
+ if (eq <= 0) continue;
1974
+ const name = w.slice(0, eq);
1975
+ const value = w.slice(eq + 1);
1976
+ if (!value) continue;
1977
+ if (namesFlag(name, flags, known)) {
1978
+ out.push(value);
1979
+ continue;
1980
+ }
1981
+ const shape2 = PATTERN_VERBS[verb];
1982
+ if (!shape2) continue;
1983
+ const recognised = namesFlag(name, shape2.takesValue, known) || namesFlag(name, shape2.noValue, known) || namesFlag(name, shape2.patternFlags, known) || namesFlag(name, shape2.noPatternFlags, known);
1984
+ if (!recognised) out.push(value);
1985
+ continue;
1986
+ }
1987
+ const shape = PATTERN_VERBS[verb];
1988
+ const letters = w.slice(1);
1989
+ for (let j = 0; j < letters.length; j++) {
1990
+ const name = `-${letters[j]}`;
1991
+ const isFileFlag = flags.has(name);
1992
+ const argTaking = isFileFlag || (shape?.takesValue.has(name) ?? false) || (READER_VALUE_LETTERS[verb] ?? []).includes(letters[j]);
1993
+ if (!argTaking) continue;
1994
+ const attached = letters.slice(j + 1);
1995
+ if (isFileFlag && attached) out.push(attached);
1996
+ break;
1997
+ }
1998
+ }
1999
+ return out;
2000
+ }
1656
2001
  function wrappedReadPaths(words, name) {
1657
2002
  if (name === "find") {
1658
- const k = words.findIndex((w) => w !== null && FIND_EXEC_FLAGS.has(w));
1659
- if (k < 1 || !isReaderWord(words[k + 1] ?? null)) return null;
1660
- const firstPredicate = words.findIndex((w, i) => i > 0 && w !== null && w.startsWith("-"));
1661
- return positionalAfter(words, 1, firstPredicate > 0 ? firstPredicate : k);
2003
+ const { k, starts } = findStartPoints(words, 0);
2004
+ return k > 0 && isReaderWord(words[k + 1] ?? null) ? starts : null;
1662
2005
  }
1663
2006
  if (!COMMAND_WRAPPERS.has(name) && !RUNNER_WRAPPERS.has(name)) return null;
1664
2007
  const h = unwrapCommandHead(words);
1665
- return h > 0 && isReaderWord(words[h] ?? null) ? positionalAfter(words, h + 1) : null;
2008
+ if (h <= 0 || !isReaderWord(words[h] ?? null)) return null;
2009
+ const head = baseWord(words[h]);
2010
+ const rest = words.slice(h + 1);
2011
+ const restFlags = rest.filter((w) => w !== null && w.startsWith("-"));
2012
+ return [
2013
+ ...readTargets(head, positionedArgs(words, h + 1), restFlags, words, h + 1),
2014
+ ...flagOperandFiles(head, words, h + 1)
2015
+ ];
1666
2016
  }
1667
2017
  function literalShellPayload(words, name) {
1668
2018
  const h = COMMAND_WRAPPERS.has(name) || RUNNER_WRAPPERS.has(name) ? unwrapCommandHead(words) : 0;
@@ -2330,6 +2680,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2330
2680
  const shellShaped = isBashTool(toolName) || inspectsShellCommand(toolName, config.policy.toolInspection);
2331
2681
  const shellShapedCommand = shellShaped ? isBashTool(toolName) && args && typeof args === "object" ? typeof args.command === "string" ? args.command : null : extractShellCommand(toolName, args, config.policy.toolInspection) : null;
2332
2682
  const bashCommand = agent !== "Terminal" ? shellShapedCommand : null;
2683
+ let pendingAstReview;
2333
2684
  if (bashCommand !== null) {
2334
2685
  const pipeVerdict = pipeChainVerdict(
2335
2686
  bashCommand,
@@ -2341,7 +2692,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2341
2692
  if (fsVerdict) {
2342
2693
  const isShieldRule = fsVerdict.ruleName.startsWith("shield:");
2343
2694
  const labelPrefix = isShieldRule ? "project-jail (AST)" : "Node9 (AST)";
2344
- return {
2695
+ const astVerdict = {
2345
2696
  decision: fsVerdict.verdict,
2346
2697
  blockedByLabel: `${labelPrefix}: ${fsVerdict.ruleName}`,
2347
2698
  reason: fsVerdict.reason,
@@ -2349,6 +2700,8 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2349
2700
  ruleName: fsVerdict.ruleName,
2350
2701
  ruleDescription: fsVerdict.reason
2351
2702
  };
2703
+ if (fsVerdict.verdict === "block") return astVerdict;
2704
+ pendingAstReview = astVerdict;
2352
2705
  }
2353
2706
  const sqlAction = resolveCheck(config.policy.commandChecks?.sqlDdl);
2354
2707
  const sqlVerdict = sqlAction === "off" ? null : analyzeSqlDestructive(bashCommand);
@@ -2391,7 +2744,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2391
2744
  const matchedRule = resolvePinned(matches);
2392
2745
  if (matchedRule) {
2393
2746
  if (matchedRule.verdict === "allow")
2394
- return { decision: "allow", ruleName: matchedRule.name ?? matchedRule.tool };
2747
+ return pendingAstReview ?? { decision: "allow", ruleName: matchedRule.name ?? matchedRule.tool };
2395
2748
  return {
2396
2749
  decision: matchedRule.verdict,
2397
2750
  blockedByLabel: `Smart Rule: ${matchedRule.name ?? matchedRule.tool}`,
@@ -2418,6 +2771,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2418
2771
  allTokens = analyzed.allTokens;
2419
2772
  pathTokens = analyzed.paths;
2420
2773
  const candidates2 = [];
2774
+ if (pendingAstReview) candidates2.push(pendingAstReview);
2421
2775
  const evalVerdict = detectDangerousShellExec(shellCommand);
2422
2776
  if (evalVerdict === "block") {
2423
2777
  return {
@@ -2714,6 +3068,12 @@ function classifyRuleSeverity(name, verdict) {
2714
3068
  "read-ssh",
2715
3069
  "read-gcp",
2716
3070
  "read-cred",
3071
+ // Stage 4 (2026-09-11): a copy of a credential file scores like a READ of it --
3072
+ // read-ssh/aws/cred are critical, so copy-ssh/aws/cred are; read-env is high
3073
+ // (below), so copy-env joins the high list, not this one (/code-review).
3074
+ "copy-ssh",
3075
+ "copy-aws",
3076
+ "copy-cred",
2717
3077
  "delete-repo",
2718
3078
  "helm-uninstall",
2719
3079
  "drop-table",
@@ -2726,6 +3086,7 @@ function classifyRuleSeverity(name, verdict) {
2726
3086
  ];
2727
3087
  if (criticalPatterns.some((p) => n.includes(p))) return "critical";
2728
3088
  const highPatterns = [
3089
+ "copy-env",
2729
3090
  "force-push",
2730
3091
  "force_push",
2731
3092
  "git-destructive",
@@ -2743,6 +3104,11 @@ function narrativeRuleLabel(name) {
2743
3104
  const map = {
2744
3105
  "read-aws": "AWS credentials read",
2745
3106
  "read-ssh": "SSH private key read",
3107
+ // Stage 4 copy twins, so `scan --narrative` prints a label, not a raw slug.
3108
+ "copy-ssh": "SSH private key copied out",
3109
+ "copy-aws": "AWS credentials copied out",
3110
+ "copy-env": ".env file copied out",
3111
+ "copy-cred": "credential file copied out",
2746
3112
  "read-gcp": "GCP credentials read",
2747
3113
  "read-cred": "credential file read",
2748
3114
  "delete-repo": "GitHub repository deletion",
@@ -3332,7 +3698,7 @@ function* stringValues(obj, depth = 0) {
3332
3698
  }
3333
3699
  for (const v of Object.values(obj)) yield* stringValues(v, depth + 1);
3334
3700
  }
3335
- var 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;
3701
+ var 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;
3336
3702
  var init_dist = __esm({
3337
3703
  "packages/policy-engine/dist/index.mjs"() {
3338
3704
  "use strict";
@@ -4068,12 +4434,408 @@ var init_dist = __esm({
4068
4434
  "nl",
4069
4435
  "dd"
4070
4436
  ]);
4437
+ GREP_SHAPE = {
4438
+ takesValue: /* @__PURE__ */ new Set([
4439
+ "-A",
4440
+ "-B",
4441
+ "-C",
4442
+ "-D",
4443
+ "-d",
4444
+ "-e",
4445
+ "-f",
4446
+ "-m",
4447
+ "--after-context",
4448
+ "--before-context",
4449
+ "--binary-files",
4450
+ "--context",
4451
+ "--devices",
4452
+ "--directories",
4453
+ "--exclude",
4454
+ "--exclude-dir",
4455
+ "--exclude-from",
4456
+ "--file",
4457
+ "--include",
4458
+ "--label",
4459
+ "--max-count",
4460
+ "--regexp"
4461
+ ]),
4462
+ noValue: /* @__PURE__ */ new Set([
4463
+ "-E",
4464
+ "-F",
4465
+ "-G",
4466
+ "-P",
4467
+ "-i",
4468
+ "-y",
4469
+ "-v",
4470
+ "-V",
4471
+ "-w",
4472
+ "-x",
4473
+ "-c",
4474
+ "-l",
4475
+ "-L",
4476
+ "-o",
4477
+ "-q",
4478
+ "-s",
4479
+ "-b",
4480
+ "-H",
4481
+ "-h",
4482
+ "-n",
4483
+ "-T",
4484
+ "-Z",
4485
+ "-z",
4486
+ "-R",
4487
+ "-r",
4488
+ "-U",
4489
+ "-u",
4490
+ "-I",
4491
+ "-a",
4492
+ "--basic-regexp",
4493
+ "--binary",
4494
+ "--byte-offset",
4495
+ "--color",
4496
+ "--colour",
4497
+ "--count",
4498
+ "--dereference-recursive",
4499
+ "--extended-regexp",
4500
+ "--files-with-matches",
4501
+ "--files-without-match",
4502
+ "--fixed-strings",
4503
+ "--help",
4504
+ "--ignore-case",
4505
+ "--initial-tab",
4506
+ "--invert-match",
4507
+ "--line-buffered",
4508
+ "--line-number",
4509
+ "--line-regexp",
4510
+ "--no-filename",
4511
+ "--no-group-separator",
4512
+ "--no-ignore-case",
4513
+ "--no-messages",
4514
+ "--null",
4515
+ "--null-data",
4516
+ "--only-matching",
4517
+ "--perl-regexp",
4518
+ "--quiet",
4519
+ "--recursive",
4520
+ "--silent",
4521
+ "--text",
4522
+ "--version",
4523
+ "--with-filename",
4524
+ "--word-regexp"
4525
+ ]),
4526
+ patternFlags: /* @__PURE__ */ new Set(["-e", "--regexp"]),
4527
+ noPatternFlags: /* @__PURE__ */ new Set(["-f", "--file"])
4528
+ };
4529
+ PATTERN_VERBS = {
4530
+ grep: GREP_SHAPE,
4531
+ // /usr/bin/egrep and /usr/bin/fgrep are 41-byte shell wrappers that exec
4532
+ // `grep -E` and `grep -F`. Read in full, not assumed.
4533
+ egrep: GREP_SHAPE,
4534
+ fgrep: GREP_SHAPE,
4535
+ // ripgrep 14.1.1, complete from its own --help. An earlier comment here said rg
4536
+ // was not installed on the measuring machine; it is, and that stale claim is why
4537
+ // this table shipped incomplete for two rounds, blocking ordinary searches like
4538
+ // `rg --sort-files .env src` (/code-review round 4).
4539
+ rg: {
4540
+ takesValue: /* @__PURE__ */ new Set([
4541
+ "-A",
4542
+ "-B",
4543
+ "-C",
4544
+ "-d",
4545
+ "-E",
4546
+ "-e",
4547
+ "-f",
4548
+ "-g",
4549
+ "-j",
4550
+ "-M",
4551
+ "-m",
4552
+ "-r",
4553
+ "-t",
4554
+ "-T",
4555
+ "--after-context",
4556
+ "--before-context",
4557
+ "--color",
4558
+ "--colors",
4559
+ "--context",
4560
+ "--context-separator",
4561
+ "--dfa-size-limit",
4562
+ "--encoding",
4563
+ "--engine",
4564
+ "--field-context-separator",
4565
+ "--field-match-separator",
4566
+ "--file",
4567
+ "--generate",
4568
+ "--glob",
4569
+ "--hostname-bin",
4570
+ "--hyperlink-format",
4571
+ "--iglob",
4572
+ "--ignore-file",
4573
+ "--max-columns",
4574
+ "--max-count",
4575
+ "--max-depth",
4576
+ // An ALIAS of --max-depth, and it takes a value. The round-5 extraction read
4577
+ // alias lines as plain switches and swept it into noValue, which hard-blocked
4578
+ // `rg --maxdepth 2 .env src` while `--max-depth 2` ran (/code-review round 7).
4579
+ "--maxdepth",
4580
+ "--max-filesize",
4581
+ "--path-separator",
4582
+ "--pre",
4583
+ "--pre-glob",
4584
+ "--regexp",
4585
+ "--regex-size-limit",
4586
+ "--replace",
4587
+ "--sort",
4588
+ "--sortr",
4589
+ "--threads",
4590
+ "--type",
4591
+ "--type-add",
4592
+ "--type-clear",
4593
+ "--type-not"
4594
+ ]),
4595
+ noValue: /* @__PURE__ */ new Set([
4596
+ "-.",
4597
+ "-0",
4598
+ "-a",
4599
+ "-b",
4600
+ "-c",
4601
+ "-F",
4602
+ "-h",
4603
+ "-H",
4604
+ "-i",
4605
+ "-I",
4606
+ "-l",
4607
+ "-L",
4608
+ "-n",
4609
+ "-N",
4610
+ "-o",
4611
+ "-p",
4612
+ "-P",
4613
+ "-q",
4614
+ "-s",
4615
+ "-S",
4616
+ "-u",
4617
+ "-U",
4618
+ "-v",
4619
+ "-V",
4620
+ "-w",
4621
+ "-x",
4622
+ "-z",
4623
+ "--auto-hybrid-regex",
4624
+ "--binary",
4625
+ "--block-buffered",
4626
+ "--byte-offset",
4627
+ "--case-sensitive",
4628
+ "--column",
4629
+ "--count",
4630
+ "--count-matches",
4631
+ "--crlf",
4632
+ "--debug",
4633
+ "--files",
4634
+ "--files-with-matches",
4635
+ "--files-without-match",
4636
+ "--fixed-strings",
4637
+ "--follow",
4638
+ "--glob-case-insensitive",
4639
+ "--heading",
4640
+ "--help",
4641
+ "--hidden",
4642
+ "--ignore-case",
4643
+ "--ignore-file-case-insensitive",
4644
+ "--include-zero",
4645
+ "--invert-match",
4646
+ "--json",
4647
+ "--line-buffered",
4648
+ "--line-number",
4649
+ "--line-regexp",
4650
+ "--max-columns-preview",
4651
+ "--mmap",
4652
+ "--multiline",
4653
+ "--multiline-dotall",
4654
+ "--no-column",
4655
+ "--no-config",
4656
+ "--no-context-separator",
4657
+ "--no-encoding",
4658
+ "--no-filename",
4659
+ "--no-ignore",
4660
+ "--no-ignore-dot",
4661
+ "--no-ignore-exclude",
4662
+ "--no-ignore-files",
4663
+ "--no-ignore-global",
4664
+ "--no-ignore-messages",
4665
+ "--no-ignore-parent",
4666
+ "--no-ignore-vcs",
4667
+ "--no-line-number",
4668
+ "--no-messages",
4669
+ "--no-pcre2-unicode",
4670
+ "--no-pre",
4671
+ "--no-require-git",
4672
+ "--no-unicode",
4673
+ "--null",
4674
+ "--null-data",
4675
+ "--one-file-system",
4676
+ "--only-matching",
4677
+ "--passthru",
4678
+ "--pcre2",
4679
+ "--pcre2-version",
4680
+ "--pretty",
4681
+ "--print0",
4682
+ "--quiet",
4683
+ "--search-zip",
4684
+ "--smart-case",
4685
+ "--sort-files",
4686
+ "--stats",
4687
+ "--stop-on-nonmatch",
4688
+ "--text",
4689
+ "--trace",
4690
+ "--trim",
4691
+ "--type-list",
4692
+ "--unrestricted",
4693
+ "--version",
4694
+ "--vimgrep",
4695
+ "--with-filename",
4696
+ "--word-regexp",
4697
+ // The COMPLETE remainder of `rg --help`, added after /code-review round 5
4698
+ // found 40 documented switches in neither set. The table is now the whole
4699
+ // option list: `rg --help` yields 149 long options, 35 of them value-taking.
4700
+ "--ignore",
4701
+ "--ignore-dot",
4702
+ "--ignore-exclude",
4703
+ "--ignore-files",
4704
+ "--ignore-global",
4705
+ "--ignore-messages",
4706
+ "--ignore-parent",
4707
+ "--ignore-vcs",
4708
+ "--messages",
4709
+ "--no-auto-hybrid-regex",
4710
+ "--no-binary",
4711
+ "--no-block-buffered",
4712
+ "--no-byte-offset",
4713
+ "--no-crlf",
4714
+ "--no-fixed-strings",
4715
+ "--no-follow",
4716
+ "--no-glob-case-insensitive",
4717
+ "--no-heading",
4718
+ "--no-hidden",
4719
+ "--no-ignore-file-case-insensitive",
4720
+ "--no-include-zero",
4721
+ "--no-invert-match",
4722
+ "--no-json",
4723
+ "--no-line-buffered",
4724
+ "--no-max-columns-preview",
4725
+ "--no-mmap",
4726
+ "--no-multiline",
4727
+ "--no-multiline-dotall",
4728
+ "--no-one-file-system",
4729
+ "--no-pcre2",
4730
+ "--no-search-zip",
4731
+ "--no-sort-files",
4732
+ "--no-stats",
4733
+ "--no-text",
4734
+ "--no-trim",
4735
+ "--passthrough",
4736
+ "--pcre2-unicode",
4737
+ "--require-git",
4738
+ "--unicode"
4739
+ ]),
4740
+ patternFlags: /* @__PURE__ */ new Set(["-e", "--regexp"]),
4741
+ // `--files` and `--type-list` list or enumerate without a pattern. Founder
4742
+ // decision 2026-09-13: `rg --files ~/.ssh` stays BLOCKED, which this achieves
4743
+ // by leaving the directory in the judged list.
4744
+ noPatternFlags: /* @__PURE__ */ new Set(["-f", "--file", "--files", "--type-list", "--pcre2-version"])
4745
+ }
4746
+ };
4747
+ PATTERN_VERB_NAMES = Object.keys(PATTERN_VERBS);
4748
+ GREP_FILE_OPERANDS = /* @__PURE__ */ new Set(["-f", "--file", "--exclude-from", "--include"]);
4749
+ READER_VALUE_LETTERS = {
4750
+ awk: ["F", "v", "f"],
4751
+ gawk: ["F", "v", "f", "e", "E", "i", "l", "o", "p", "D"],
4752
+ sed: ["e", "f", "i", "l"],
4753
+ sort: ["C", "k", "o", "S", "t", "T"]
4754
+ };
4755
+ FILE_OPERAND_FLAGS = {
4756
+ grep: GREP_FILE_OPERANDS,
4757
+ egrep: GREP_FILE_OPERANDS,
4758
+ fgrep: GREP_FILE_OPERANDS,
4759
+ // rg and gawk are NOT installed on the measuring machine: these two rows are
4760
+ // from the shipped documentation (ripgrep `-f/--file`, `--ignore-file`; gawk
4761
+ // `-f/--file`) and are marked as such in the spec.
4762
+ // `-g/--glob/--iglob` name which files ripgrep SEARCHES, so an operand naming
4763
+ // a credential makes rg open it -- the same reason grep's `--include` is here.
4764
+ // Measured (/code-review round 3): `rg --hidden -g .env AWS tree` opened .env
4765
+ // and printed its contents.
4766
+ rg: /* @__PURE__ */ new Set(["-f", "--file", "--ignore-file", "-g", "--glob", "--iglob"]),
4767
+ sed: /* @__PURE__ */ new Set(["-f", "--file"]),
4768
+ awk: /* @__PURE__ */ new Set(["-f", "--file"]),
4769
+ gawk: /* @__PURE__ */ new Set(["-f", "--file"]),
4770
+ sort: /* @__PURE__ */ new Set(["--files0-from"])
4771
+ };
4772
+ SCP_VALUE_FLAGS = ["i", "F", "o", "c", "S", "P", "J", "D", "W", "l"];
4773
+ TAR_VALUE_LETTERS = ["b", "C", "f", "F", "g", "H", "I", "K", "L", "N", "T", "V", "X"];
4774
+ ZIP_VALUE_LETTERS = ["b", "n", "P", "t", "s", "O", "x", "i"];
4775
+ RSYNC_VALUE_LETTERS = ["e", "f", "T", "B", "M"];
4776
+ RSYNC_SKIP = [
4777
+ "e",
4778
+ "--rsh",
4779
+ "--exclude",
4780
+ "--exclude-from",
4781
+ "--include",
4782
+ "--include-from",
4783
+ "--files-from",
4784
+ "f",
4785
+ "--filter"
4786
+ ];
4787
+ CP_VALUE_LETTERS = ["S", "t"];
4788
+ INSTALL_VALUE_LETTERS = ["S", "t", "g", "m", "o"];
4789
+ COPY_VERBS = {
4790
+ cp: { source: "allButLast", targetDirFlag: true, valueLetters: CP_VALUE_LETTERS },
4791
+ mv: { source: "allButLast", targetDirFlag: true, valueLetters: CP_VALUE_LETTERS },
4792
+ install: { source: "allButLast", targetDirFlag: true, valueLetters: INSTALL_VALUE_LETTERS },
4793
+ ln: { source: "first", targetDirFlag: true, valueLetters: CP_VALUE_LETTERS },
4794
+ scp: { source: "allButLast", skipFlags: SCP_VALUE_FLAGS, valueLetters: SCP_VALUE_FLAGS },
4795
+ rsync: { source: "allButLast", skipFlags: RSYNC_SKIP, valueLetters: RSYNC_VALUE_LETTERS },
4796
+ tar: {
4797
+ source: "archive",
4798
+ archive: "tar",
4799
+ skipFlags: ["f", "X", "T", "--file", "--exclude", "--exclude-from", "--files-from"],
4800
+ valueLetters: TAR_VALUE_LETTERS
4801
+ },
4802
+ zip: {
4803
+ source: "archive",
4804
+ archive: "zip",
4805
+ skipFlags: ["x", "i", "--exclude", "--include"],
4806
+ valueLetters: ZIP_VALUE_LETTERS
4807
+ },
4808
+ ar: { source: "archive", archive: "ar" },
4809
+ // 7z switches are INLINE only (`-mx9`, `-px`, `-x!pat`), so no following word is
4810
+ // ever a switch's operand -- which is why the short `x` is NOT a skipFlag here
4811
+ // and valueLetters is empty. Keeping the short `x` made `7z a out.7z -mx KEY`
4812
+ // drop the credential as an exclusion operand (/code-review round 8), and the
4813
+ // derived "every skipped letter is a value letter" row in jail-copy.spec.ts is
4814
+ // what keeps the two tables honest about it.
4815
+ "7z": { source: "archive", archive: "7z", skipFlags: ["--exclude"], valueLetters: [] },
4816
+ gzip: { source: "all" },
4817
+ bzip2: { source: "all" },
4818
+ xz: { source: "all" },
4819
+ "docker cp": { source: "allButLast" },
4820
+ "kubectl cp": { source: "allButLast" },
4821
+ "gsutil cp": { source: "allButLast" },
4822
+ "gsutil rsync": { source: "allButLast" },
4823
+ "rclone copy": { source: "allButLast" },
4824
+ "rclone sync": { source: "allButLast" },
4825
+ "aws s3 cp": { source: "allButLast" },
4826
+ "aws s3 mv": { source: "allButLast" },
4827
+ "aws s3 sync": { source: "allButLast" },
4828
+ "gcloud storage cp": { source: "allButLast" },
4829
+ "az storage blob upload": { source: "flagOperand", sourceFlags: ["f", "--file"] }
4830
+ };
4831
+ TAR_MODE_WORD = /^[a-zA-Z]+$/;
4832
+ COPY_VERB_HEADS = new Set(Object.keys(COPY_VERBS).map((k) => k.split(" ")[0]));
4071
4833
  FS_OP_PRESCREEN_RE = new RegExp(
4072
4834
  // A quote is a separator too: `eval "cat X"` and `sh -c 'cat X'` put the
4073
4835
  // reader right after `"` / `'`, and without these two characters the
4074
4836
  // prescreen rejected every string-wrapped read before the parser ran.
4075
4837
  // Found 2026-09-11 by instrumenting the walk -- no CallExpr was ever visited.
4076
- `(?:^|[\\s|;&("'\`\\n])(?:rm|${[...FS_READ_TOOLS].map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\b|(?<!<)<(?!<)`
4838
+ `(?:^|[\\s|;&("'\`\\n/])(?:rm|${[...FS_READ_TOOLS, ...COPY_VERB_HEADS].map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\b|(?<!<)<(?!<)`
4077
4839
  );
4078
4840
  HOME_CACHE_ALLOWLIST = [
4079
4841
  ".cache",
@@ -4363,8 +5125,17 @@ var init_dist = __esm({
4363
5125
  deriveRedirOp("cat <<X\nX"),
4364
5126
  deriveRedirOp("cat <<-X\nX")
4365
5127
  ]);
4366
- isReaderWord = (w) => w !== null && FS_READ_TOOLS.has(w.split("/").pop()?.toLowerCase() ?? "");
4367
- positionalAfter = (words, from, to = words.length) => words.slice(from, to).filter((w) => w !== null && !w.startsWith("-"));
5128
+ FIND_OPTIONS = /* @__PURE__ */ new Set(["-H", "-L", "-P"]);
5129
+ COPY_RULE_OF = {
5130
+ "shield:project-jail:block-read-ssh": "shield:project-jail:review-copy-ssh",
5131
+ "shield:project-jail:block-read-aws": "shield:project-jail:review-copy-aws",
5132
+ "shield:project-jail:block-read-env": "shield:project-jail:review-copy-env",
5133
+ "shield:project-jail:review-read-credentials": "shield:project-jail:review-copy-credentials"
5134
+ };
5135
+ isReaderWord = (w) => w !== null && FS_READ_TOOLS.has(baseWord(w));
5136
+ positionalAfter = (words, from, to = words.length) => positionedArgs(words, from, to).map((a) => a.value);
5137
+ NONE = { kind: "none" };
5138
+ UNKNOWN = { kind: "unknown" };
4368
5139
  DEFAULT_EGRESS_ALLOWLIST = [
4369
5140
  // node9's own control plane (api, app, dev-api, staging and the apex).
4370
5141
  // Without it, turning egress on asks the user to approve node9 itself.
@@ -5409,7 +6180,7 @@ var init_dist = __esm({
5409
6180
  { view: "separators-stripped", decoder: "separators", stripped: true }
5410
6181
  ];
5411
6182
  LONG_OUTPUT_THRESHOLD_BYTES = 100 * 1024;
5412
- CANONICAL_EXTRACTOR_VERSION = "canonical-v13";
6183
+ CANONICAL_EXTRACTOR_VERSION = "canonical-v15";
5413
6184
  DEDUPE_PREVIEW_LEN = 120;
5414
6185
  TERMINAL_ESCAPE_RE = // eslint-disable-next-line no-control-regex
5415
6186
  /\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-_]|[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g;
@@ -12979,6 +13750,28 @@ function codexSessionCost(model, tokens, request2) {
12979
13750
  const tierMultiplier = request2?.serviceTier === "flex" ? 0.5 : codexModel(model) === "gpt-6-astra" && (request2?.serviceTier === "fast" || request2?.serviceTier === "priority") ? 2 : 1;
12980
13751
  return (((input - cached - written) * pin + cached * pcr + written * (pcw || pin)) * inputMultiplier + tokenNumber(tokens.output) * pout * outputMultiplier) * tierMultiplier;
12981
13752
  }
13753
+ function statAndFirstLine(file) {
13754
+ const CAP = 4 * 1024 * 1024;
13755
+ const CHUNK = 64 * 1024;
13756
+ const fd = fs21.openSync(file, "r");
13757
+ try {
13758
+ const stat = fs21.fstatSync(fd);
13759
+ const limit = Math.min(stat.size, CAP);
13760
+ const parts = [];
13761
+ for (let pos = 0; pos < limit; pos += CHUNK) {
13762
+ const buf = Buffer.alloc(Math.min(CHUNK, limit - pos));
13763
+ const read2 = fs21.readSync(fd, buf, 0, buf.length, pos);
13764
+ if (read2 <= 0) break;
13765
+ const slice = buf.subarray(0, read2);
13766
+ const nl = slice.indexOf(10);
13767
+ parts.push(nl >= 0 ? slice.subarray(0, nl) : slice);
13768
+ if (nl >= 0) break;
13769
+ }
13770
+ return { stat, first: Buffer.concat(parts).toString("utf8") };
13771
+ } finally {
13772
+ fs21.closeSync(fd);
13773
+ }
13774
+ }
12982
13775
  function listCodexSessionFiles(base = codexSessionsDir()) {
12983
13776
  const files = [];
12984
13777
  const walk = (dir) => {
@@ -12996,10 +13789,10 @@ function listCodexSessionFiles(base = codexSessionsDir()) {
12996
13789
  const sessions = /* @__PURE__ */ new Map();
12997
13790
  for (const file of files.sort()) {
12998
13791
  try {
12999
- const stat = fs21.statSync(file);
13792
+ const { stat, first: head } = statAndFirstLine(file);
13000
13793
  let id = "";
13001
13794
  try {
13002
- const first = JSON.parse(fs21.readFileSync(file, "utf8").split("\n", 1)[0]);
13795
+ const first = JSON.parse(head);
13003
13796
  if (first?.type === "session_meta" && typeof first.payload?.id === "string")
13004
13797
  id = first.payload.id;
13005
13798
  } catch {
@@ -59452,7 +60245,11 @@ var BUILTIN_JAIL = [
59452
60245
  "~/.ssh \u2014 SSH private keys",
59453
60246
  "~/.aws \u2014 AWS credentials",
59454
60247
  ".env files",
59455
- "credential files \u2014 credentials.json, .netrc, .npmrc, .docker, .kube, gcloud"
60248
+ "credential files \u2014 credentials.json, .netrc, .npmrc, .docker, .kube, gcloud",
60249
+ // Stage 4 (2026-09-11): reads are blocked; a COPY out of the jail (cp, tar,
60250
+ // scp, rsync, aws s3 cp, ...) is reviewed, because a backup and a theft are
60251
+ // the same command shape.
60252
+ "copies out of the jail (cp, tar, scp, rsync, cloud upload) \u2014 review"
59456
60253
  ];
59457
60254
  function registerJailCommand(program2) {
59458
60255
  const jail = program2.command("jail").description("Grow the credential jail \u2014 block/review reads of your sensitive paths");