@node9/proxy 2.13.0 → 2.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1132,13 +1132,32 @@ var DLP_PATTERNS_GLOBAL = DLP_PATTERNS.map(
1132
1132
  })
1133
1133
  );
1134
1134
  var SENSITIVE_PATH_PATTERNS = [
1135
- /[/\\]\.ssh[/\\]/i,
1136
- /[/\\]\.aws[/\\]/i,
1135
+ /[/\\]\.ssh([/\\]|$)/i,
1136
+ /[/\\]\.aws([/\\]|$)/i,
1137
1137
  /[/\\]\.config[/\\]gcloud[/\\]/i,
1138
1138
  /[/\\]\.azure[/\\]/i,
1139
1139
  /[/\\]\.kube[/\\]config$/i,
1140
- /[/\\]\.env($|\.)/i,
1141
- // .env, .env.local, .env.production — not .envoy
1140
+ // ⚠️ ONE SEMANTIC, FOUR COPIES. This is the AST tier's `.env` rule verbatim
1141
+ // (shell/index.ts SENSITIVE_PATH_RULES), whose reasoning is documented there:
1142
+ // structural suffix chain rather than a hand-written list, `example|sample|
1143
+ // template` exempt because a fixture stays a fixture whatever follows, and
1144
+ // `.test` anchored because `test` names an ENVIRONMENT -- `.env.test` is the
1145
+ // committed template, `.env.test.local` is gitignored and holds real values.
1146
+ //
1147
+ // It was previously `[/\\]\.env($|\.)` with NO exemptions, so `Read .env.example`
1148
+ // blocked while `cat .env.example` allowed: the same file, opposite verdicts,
1149
+ // decided only by which tool asked. See src/__tests__/jail-both-doors.test.ts,
1150
+ // which is the contract that now holds these copies in step, and stage 5 of
1151
+ // doc/credential-jail-architecture.md, which replaces them with one generated
1152
+ // source.
1153
+ // ⚠️ The `.local` branch comes FIRST and takes no exemption. A fixture stays a
1154
+ // fixture whatever follows it -- `.env.example.md` is documentation -- but
1155
+ // `.env.example.local` is gitignored by the `.env*.local` convention and holds
1156
+ // real values, exactly the reasoning that anchors `(?!\.test$)` rather than
1157
+ // using `\b`. Without this branch the fixture exemption also bought a two-step
1158
+ // bypass: `cp .env .env.sample`, then read the copy.
1159
+ /[/\\]\.env(?![\w-])(?:[\w.-]*\.local$|(?!\.(?:example|sample|template)\b)(?!\.test$)[\w.-]*$)/i,
1160
+ // .env + any suffix chain; fixtures exempt unless .local
1142
1161
  /[/\\]\.git-credentials$/i,
1143
1162
  /[/\\]\.npmrc$/i,
1144
1163
  /[/\\]\.docker[/\\]config\.json$/i,
@@ -1640,6 +1659,10 @@ var FS_READ_TOOLS = /* @__PURE__ */ new Set([
1640
1659
  "od",
1641
1660
  "xxd",
1642
1661
  "hexdump",
1662
+ // Emits the file's bytes, re-encoded, so it is a read by the set's own test
1663
+ // ("does it emit file contents"). Absent until 2026-09-10, which is why
1664
+ // `base64 ~/.ssh/id_rsa` printed a private key with no verdict.
1665
+ "base64",
1643
1666
  "strings",
1644
1667
  "sort",
1645
1668
  "uniq",
@@ -1647,8 +1670,56 @@ var FS_READ_TOOLS = /* @__PURE__ */ new Set([
1647
1670
  "nl",
1648
1671
  "dd"
1649
1672
  ]);
1673
+ var SCP_VALUE_FLAGS = ["i", "F", "o", "c", "S", "P", "J", "D", "W", "l"];
1674
+ var RSYNC_SKIP = [
1675
+ "e",
1676
+ "--rsh",
1677
+ "--exclude",
1678
+ "--exclude-from",
1679
+ "--include",
1680
+ "--include-from",
1681
+ "--files-from",
1682
+ "f",
1683
+ "--filter"
1684
+ ];
1685
+ var COPY_VERBS = {
1686
+ cp: { source: "allButLast", targetDirFlag: true },
1687
+ mv: { source: "allButLast", targetDirFlag: true },
1688
+ install: { source: "allButLast", targetDirFlag: true },
1689
+ ln: { source: "first", targetDirFlag: true },
1690
+ scp: { source: "allButLast", skipFlags: SCP_VALUE_FLAGS },
1691
+ rsync: { source: "allButLast", skipFlags: RSYNC_SKIP },
1692
+ tar: {
1693
+ source: "archive",
1694
+ archive: "tar",
1695
+ skipFlags: ["f", "X", "T", "--file", "--exclude", "--exclude-from", "--files-from"]
1696
+ },
1697
+ zip: { source: "archive", archive: "zip", skipFlags: ["x", "i", "--exclude", "--include"] },
1698
+ ar: { source: "archive", archive: "ar" },
1699
+ "7z": { source: "archive", archive: "7z", skipFlags: ["x", "--exclude"] },
1700
+ gzip: { source: "all" },
1701
+ bzip2: { source: "all" },
1702
+ xz: { source: "all" },
1703
+ "docker cp": { source: "allButLast" },
1704
+ "kubectl cp": { source: "allButLast" },
1705
+ "gsutil cp": { source: "allButLast" },
1706
+ "gsutil rsync": { source: "allButLast" },
1707
+ "rclone copy": { source: "allButLast" },
1708
+ "rclone sync": { source: "allButLast" },
1709
+ "aws s3 cp": { source: "allButLast" },
1710
+ "aws s3 mv": { source: "allButLast" },
1711
+ "aws s3 sync": { source: "allButLast" },
1712
+ "gcloud storage cp": { source: "allButLast" },
1713
+ "az storage blob upload": { source: "flagOperand", sourceFlags: ["f", "--file"] }
1714
+ };
1715
+ var TAR_MODE_WORD = /^[a-zA-Z]+$/;
1716
+ var COPY_VERB_HEADS = new Set(Object.keys(COPY_VERBS).map((k) => k.split(" ")[0]));
1650
1717
  var FS_OP_PRESCREEN_RE = new RegExp(
1651
- `(?:^|[\\s|;&(\`\\n])(?:rm|${[...FS_READ_TOOLS].map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\b`
1718
+ // A quote is a separator too: `eval "cat X"` and `sh -c 'cat X'` put the
1719
+ // reader right after `"` / `'`, and without these two characters the
1720
+ // prescreen rejected every string-wrapped read before the parser ran.
1721
+ // Found 2026-09-11 by instrumenting the walk -- no CallExpr was ever visited.
1722
+ `(?:^|[\\s|;&("'\`\\n/])(?:rm|${[...FS_READ_TOOLS, ...COPY_VERB_HEADS].map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\b|(?<!<)<(?!<)`
1652
1723
  );
1653
1724
  var HOME_CACHE_ALLOWLIST = [
1654
1725
  ".cache",
@@ -1669,12 +1740,12 @@ var SENSITIVE_PATH_RULES = [
1669
1740
  {
1670
1741
  rule: "shield:project-jail:block-read-ssh",
1671
1742
  reason: "Reading SSH private keys is blocked by project-jail shield",
1672
- match: (p) => /(^|[\\/])\.ssh[\\/]/i.test(p)
1743
+ match: (p) => /([\\/]\.ssh[\\/]|^\.ssh[\\/]|^(?:[~/]|[A-Za-z]:).*[\\/]\.ssh$)/i.test(p)
1673
1744
  },
1674
1745
  {
1675
1746
  rule: "shield:project-jail:block-read-aws",
1676
1747
  reason: "Reading AWS credentials is blocked by project-jail shield",
1677
- match: (p) => /(^|[\\/])\.aws[\\/]/i.test(p)
1748
+ match: (p) => /([\\/]\.aws[\\/]|^\.aws[\\/]|^(?:[~/]|[A-Za-z]:).*[\\/]\.aws$)/i.test(p)
1678
1749
  },
1679
1750
  {
1680
1751
  // Mirrors the JSON shield's `.env` pattern (project-jail.json's
@@ -1718,7 +1789,9 @@ var SENSITIVE_PATH_RULES = [
1718
1789
  // symmetry — silently exempts every `.env.test.*` file.
1719
1790
  //
1720
1791
  // shields.test.ts:983-995 is the canonical contract; keep both in step.
1721
- match: (p) => /(?:^|[\\/])\.env(?![\w-])(?!\.(?:example|sample|template)\b)(?!\.test$)[\w.-]*$/i.test(p)
1792
+ match: (p) => /(?:^|[\\/])\.env(?![\w-])(?:[\w.-]*\.local$|(?!\.(?:example|sample|template)\b)(?!\.test$)[\w.-]*$)/i.test(
1793
+ p
1794
+ )
1722
1795
  },
1723
1796
  {
1724
1797
  // verdict: 'review' (not 'block') is a deliberate design choice
@@ -1929,15 +2002,14 @@ function listOps() {
1929
2002
  return _listOps;
1930
2003
  }
1931
2004
  var WRAPPER_TAKES_TARGET = /* @__PURE__ */ new Set(["chroot"]);
2005
+ var FIND_EXEC_FLAGS = /* @__PURE__ */ new Set(["-exec", "-execdir", "-ok", "-okdir"]);
1932
2006
  var INTERP_LEADING_TARGET = /* @__PURE__ */ new Set(["su"]);
1933
2007
  function unwrapCommandHead(words) {
1934
2008
  let i = 0;
1935
2009
  while (i < words.length) {
1936
2010
  const head = (words[i] ?? "").toLowerCase().split("/").pop() ?? "";
1937
2011
  if (head === "find") {
1938
- const x = words.findIndex(
1939
- (w, k) => k > i && (w === "-exec" || w === "-execdir" || w === "-ok")
1940
- );
2012
+ const x = words.findIndex((w, k) => k > i && w !== null && FIND_EXEC_FLAGS.has(w));
1941
2013
  if (x < 0) break;
1942
2014
  i = x + 1;
1943
2015
  continue;
@@ -1959,7 +2031,11 @@ function unwrapCommandHead(words) {
1959
2031
  if (t.startsWith("-")) {
1960
2032
  i++;
1961
2033
  const nxt = words[i];
1962
- if (nxt != null && !nxt.startsWith("-") && !INLINE_INTERPRETER.test(nxt.split("/").pop() ?? "") && !COMMAND_WRAPPERS.has(nxt.toLowerCase()) && !RUNNER_WRAPPERS.has(nxt.toLowerCase()))
2034
+ if (nxt != null && !nxt.startsWith("-") && !INLINE_INTERPRETER.test(nxt.split("/").pop() ?? "") && !COMMAND_WRAPPERS.has(nxt.toLowerCase()) && !RUNNER_WRAPPERS.has(nxt.toLowerCase()) && // A reader is a command, never a flag's operand: `env - cat X`,
2035
+ // `stdbuf -o0 cat X`, `ionice -c3 cat X`. Without this the head was
2036
+ // swallowed and the jail needed a looser fallback whose cost was a
2037
+ // false positive on `sudo echo cat X`.
2038
+ !FS_READ_TOOLS.has(nxt.split("/").pop()?.toLowerCase() ?? ""))
1963
2039
  i++;
1964
2040
  continue;
1965
2041
  }
@@ -2078,38 +2154,43 @@ function isProtectedHomePath(rawPath) {
2078
2154
  }
2079
2155
  return true;
2080
2156
  }
2081
- function extractLiteralArgs(callExpr) {
2082
- const args = callExpr.Args || [];
2083
- if (args.length === 0) return { name: "", flags: [], paths: [] };
2084
- const litFromWord = (w) => {
2085
- const parts = w?.Parts || [];
2086
- let s = "";
2087
- for (const p of parts) {
2088
- const t = syntax.NodeType(p);
2089
- if (t === "Lit") s += (p.Value ?? "").replace(/\\(.)/g, "$1");
2090
- else if (t === "SglQuoted") s += p.Value ?? "";
2091
- else if (t === "DblQuoted") {
2092
- const inner = p.Parts || [];
2093
- if (!inner.every((ip) => syntax.NodeType(ip) === "Lit")) return null;
2094
- s += inner.map((ip) => ip.Value ?? "").join("");
2095
- } else {
2096
- return null;
2097
- }
2157
+ function positionedArgs(words, from = 1, to = words.length) {
2158
+ const out = [];
2159
+ let afterFlag = null;
2160
+ for (let i = from; i < to; i++) {
2161
+ const v = words[i];
2162
+ if (v === null) {
2163
+ afterFlag = null;
2164
+ continue;
2098
2165
  }
2099
- return s;
2100
- };
2101
- const name = (litFromWord(args[0]) || "").toLowerCase();
2102
- const flags = [];
2103
- const paths = [];
2104
- for (let i = 1; i < args.length; i++) {
2105
- const v = litFromWord(args[i]);
2106
- if (v === null) continue;
2107
- if (v.startsWith("-")) flags.push(v);
2108
- else paths.push(v);
2166
+ if (v.startsWith("-")) {
2167
+ afterFlag = v;
2168
+ continue;
2169
+ }
2170
+ out.push({ value: v, index: out.length, argv: i, afterFlag });
2171
+ afterFlag = null;
2109
2172
  }
2110
- return { name, flags, paths };
2173
+ return out;
2111
2174
  }
2112
- var NET_BINARIES = /* @__PURE__ */ new Set(["curl", "wget", "scp", "ssh", "nc", "ncat", "netcat"]);
2175
+ function extractLiteralArgs(callExpr) {
2176
+ const rawArgs = callExpr.Args || [];
2177
+ if (rawArgs.length === 0) return { name: "", flags: [], paths: [], words: [], args: [] };
2178
+ const words = rawArgs.map((a) => resolveWordLiteral(a));
2179
+ const name = baseWord(words[0]);
2180
+ const flags = words.slice(1).filter((w) => w !== null && w.startsWith("-"));
2181
+ const args = positionedArgs(words);
2182
+ return { name, flags, paths: args.map((a) => a.value), words, args };
2183
+ }
2184
+ var NET_BINARIES = /* @__PURE__ */ new Set([
2185
+ "curl",
2186
+ "wget",
2187
+ "scp",
2188
+ "ssh",
2189
+ "nc",
2190
+ "ncat",
2191
+ "netcat",
2192
+ "rsync"
2193
+ ]);
2113
2194
  var VALUE_FLAGS = {
2114
2195
  curl: /* @__PURE__ */ new Set([
2115
2196
  "-d",
@@ -2394,6 +2475,9 @@ function deriveRedirOp(sample) {
2394
2475
  }
2395
2476
  }
2396
2477
  var REDIR_TRUNCATE_OPS = /* @__PURE__ */ new Set([deriveRedirOp(">_f")]);
2478
+ var REDIR_FILE_IN_OPS = new Set(
2479
+ [deriveRedirOp("cat < f"), deriveRedirOp("cat <> f")].filter((op) => op >= 0)
2480
+ );
2397
2481
  var REDIR_HEREDOC_OPS = /* @__PURE__ */ new Set([
2398
2482
  deriveRedirOp("cat <<X\nX"),
2399
2483
  deriveRedirOp("cat <<-X\nX")
@@ -2458,16 +2542,21 @@ function isRmCreatedInCommandCleanup(command) {
2458
2542
  }
2459
2543
  return sawRm && ok;
2460
2544
  }
2461
- function analyzeFsOperationImpl(command) {
2545
+ function analyzeFsOperationImpl(command, depth = 0) {
2462
2546
  const f = parseShared(command);
2463
2547
  if (f === PARSE_FAIL) return null;
2464
2548
  let result = null;
2465
2549
  try {
2466
2550
  syntax.Walk(f, (node) => {
2467
- if (!node || result) return false;
2551
+ if (!node || result?.verdict === "block") return false;
2468
2552
  const n = node;
2469
- if (syntax.NodeType(n) !== "CallExpr") return true;
2470
- const { name, flags, paths } = extractLiteralArgs(n);
2553
+ const nodeType = syntax.NodeType(n);
2554
+ if (nodeType === "Stmt") {
2555
+ result = stricter(result, jailedRedirectRead(n));
2556
+ return result?.verdict !== "block";
2557
+ }
2558
+ if (nodeType !== "CallExpr") return true;
2559
+ const { name, flags, paths, words } = extractLiteralArgs(n);
2471
2560
  if (!name) return true;
2472
2561
  if (name === "rm") {
2473
2562
  const flagStr = flags.join("").toLowerCase();
@@ -2496,21 +2585,27 @@ function analyzeFsOperationImpl(command) {
2496
2585
  }
2497
2586
  }
2498
2587
  }
2499
- if (FS_READ_TOOLS.has(name)) {
2500
- for (const p of paths) {
2501
- for (const sp of SENSITIVE_PATH_RULES) {
2502
- if (sp.match(p)) {
2503
- result = {
2504
- ruleName: sp.rule,
2505
- verdict: sp.verdict ?? "block",
2506
- reason: sp.reason,
2507
- path: p
2508
- };
2509
- return false;
2510
- }
2588
+ if (depth < 1) {
2589
+ const payload = literalShellPayload(words, name);
2590
+ if (payload !== null) {
2591
+ const inner = analyzeFsOperationImpl(payload, depth + 1);
2592
+ if (inner) {
2593
+ result = inner;
2594
+ return false;
2511
2595
  }
2596
+ return true;
2512
2597
  }
2513
2598
  }
2599
+ const readPaths = FS_READ_TOOLS.has(name) ? paths : wrappedReadPaths(words, name);
2600
+ if (readPaths) {
2601
+ for (const p of readPaths) {
2602
+ result = stricter(result, matchSensitivePath2(p));
2603
+ if (result?.verdict === "block") return false;
2604
+ }
2605
+ }
2606
+ for (const p of copySourcePaths(words)) {
2607
+ result = stricter(result, copyVerdictOf(matchSensitivePath2(p)));
2608
+ }
2514
2609
  return true;
2515
2610
  });
2516
2611
  return result;
@@ -2518,6 +2613,192 @@ function analyzeFsOperationImpl(command) {
2518
2613
  return null;
2519
2614
  }
2520
2615
  }
2616
+ function stricter(a, b) {
2617
+ if (!a) return b;
2618
+ if (!b) return a;
2619
+ return b.verdict === "block" && a.verdict !== "block" ? b : a;
2620
+ }
2621
+ function flagInfo(w) {
2622
+ if (w.startsWith("--")) {
2623
+ const eq = w.indexOf("=");
2624
+ return eq < 0 ? { letter: null, long: w, attached: null } : { letter: null, long: w.slice(0, eq), attached: w.slice(eq + 1) };
2625
+ }
2626
+ const m = /^-([a-zA-Z]+)(.*)$/.exec(w);
2627
+ if (!m) return { letter: null, long: null, attached: null };
2628
+ return { letter: m[1][m[1].length - 1], long: null, attached: m[2] === "" ? null : m[2] };
2629
+ }
2630
+ function flagIs(w, names) {
2631
+ if (w === null) return false;
2632
+ const f = flagInfo(w);
2633
+ return names.some((n) => n.startsWith("--") ? f.long === n : f.letter === n);
2634
+ }
2635
+ function operandOf(a, names) {
2636
+ if (!names || a.afterFlag === null) return false;
2637
+ return flagIs(a.afterFlag, names) && flagInfo(a.afterFlag).attached === null;
2638
+ }
2639
+ function resolveCopyShape(words, h) {
2640
+ const verb = baseWord(words[h]);
2641
+ if (!verb) return null;
2642
+ const direct = COPY_VERBS[verb];
2643
+ if (direct) return { shape: direct, last: h };
2644
+ const slots = positionedArgs(words, h + 1);
2645
+ for (let i = 0; i < slots.length; i++) {
2646
+ for (let n = 3; n >= 1; n--) {
2647
+ const part = slots.slice(i, i + n);
2648
+ if (part.length < n) continue;
2649
+ const key = [verb, ...part.map((a) => a.value.toLowerCase())].join(" ");
2650
+ const shape = COPY_VERBS[key];
2651
+ if (shape) return { shape, last: part[n - 1].argv };
2652
+ }
2653
+ if (slots[i].afterFlag === null) return null;
2654
+ }
2655
+ return null;
2656
+ }
2657
+ var FIND_OPTIONS = /* @__PURE__ */ new Set(["-H", "-L", "-P"]);
2658
+ function findStartPoints(words, h) {
2659
+ const k = words.findIndex((w, i) => i > h && w !== null && FIND_EXEC_FLAGS.has(w));
2660
+ if (k < 0) return { k, starts: [] };
2661
+ const firstPredicate = words.findIndex(
2662
+ (w, i) => i > h && w !== null && w.startsWith("-") && !FIND_OPTIONS.has(w)
2663
+ );
2664
+ const end = firstPredicate > h ? firstPredicate : k;
2665
+ return { k, starts: positionalAfter(words, h + 1, end) };
2666
+ }
2667
+ function copySourcePaths(words) {
2668
+ const h = unwrapCommandHead(words);
2669
+ const fi = words.findIndex((w, i) => i <= h && baseWord(w) === "find");
2670
+ if (fi >= 0) {
2671
+ const { k, starts } = findStartPoints(words, fi);
2672
+ if (k < 0) return [];
2673
+ const action = unwrapCommandHead(words.slice(k + 1));
2674
+ return resolveCopyShape(words.slice(k + 1), action) ? starts : [];
2675
+ }
2676
+ if (!COPY_VERB_HEADS.has(baseWord(words[h]))) return [];
2677
+ const r = resolveCopyShape(words, h);
2678
+ if (!r) return [];
2679
+ const { shape, last } = r;
2680
+ const args = positionedArgs(words, last + 1);
2681
+ const tail = words.slice(last + 1);
2682
+ const skipped = (a) => operandOf(a, shape.skipFlags);
2683
+ const targetDir = shape.targetDirFlag === true && tail.some((w) => w !== null && w.startsWith("-") && flagIs(w, ["t", "--target-directory"]));
2684
+ const targetOperand = (a) => targetDir && operandOf(a, ["t", "--target-directory"]);
2685
+ const lastOperand = [...tail].reverse().find((w) => w === null || !w.startsWith("-"));
2686
+ const dynamicDest = lastOperand === null;
2687
+ const destIsLastOperand = (shape.source === "allButLast" || shape.source === "first") && !targetDir && !dynamicDest;
2688
+ if (destIsLastOperand && typeof lastOperand === "string" && matchSensitivePath2(lastOperand))
2689
+ return [];
2690
+ let src;
2691
+ switch (shape.source) {
2692
+ case "all":
2693
+ src = args;
2694
+ break;
2695
+ case "first":
2696
+ src = targetDir ? args : args.slice(0, 1);
2697
+ break;
2698
+ case "flagOperand": {
2699
+ const inline = tail.filter((w) => w !== null && w.startsWith("--")).map((w) => flagInfo(w)).filter((f) => f.attached !== null && (shape.sourceFlags ?? []).includes(f.long ?? "")).map((f) => f.attached);
2700
+ return [
2701
+ ...args.filter(
2702
+ (a) => flagIs(a.afterFlag, shape.sourceFlags ?? []) && flagInfo(a.afterFlag).attached === null
2703
+ ).map((a) => a.value),
2704
+ ...inline
2705
+ ];
2706
+ }
2707
+ case "archive":
2708
+ src = archiveInputs(shape.archive, args, tail);
2709
+ break;
2710
+ case "allButLast":
2711
+ src = targetDir || dynamicDest ? args : args.slice(0, -1);
2712
+ break;
2713
+ }
2714
+ return src.filter((a) => !skipped(a) && !targetOperand(a)).map((a) => a.value);
2715
+ }
2716
+ function archiveInputs(kind, args, tail) {
2717
+ const first = args[0];
2718
+ const bareKey = first && first.afterFlag === null && TAR_MODE_WORD.test(first.value);
2719
+ if (kind === "tar") {
2720
+ const flagsText = tail.filter((w) => w !== null && w.startsWith("-")).join(" ");
2721
+ const mode = (bareKey ? first.value : "") + flagsText;
2722
+ const extracting = /x|t/.test(bareKey ? first.value.replace(/f/g, "") : "") || /(^|\s)-[a-zA-Z]*[xt]|--extract|--list|--get/.test(flagsText);
2723
+ const writing = /[cruA]/.test(bareKey ? first.value : "") || /(^|\s)-[a-zA-Z]*[cruA]|--create|--append|--update|--concatenate/.test(flagsText);
2724
+ if (extracting && !writing) return [];
2725
+ void mode;
2726
+ let i = 0;
2727
+ if (bareKey) {
2728
+ i = 1;
2729
+ const next = args[1];
2730
+ if (first.value.includes("f") && next && next.afterFlag === null) i = 2;
2731
+ }
2732
+ return args.slice(i);
2733
+ }
2734
+ if (kind === "zip") return first && first.afterFlag === "-" ? args : args.slice(1);
2735
+ if (kind === "ar") return bareKey ? args.slice(2) : args.slice(1);
2736
+ return args.slice(2);
2737
+ }
2738
+ var COPY_RULE_OF = {
2739
+ "shield:project-jail:block-read-ssh": "shield:project-jail:review-copy-ssh",
2740
+ "shield:project-jail:block-read-aws": "shield:project-jail:review-copy-aws",
2741
+ "shield:project-jail:block-read-env": "shield:project-jail:review-copy-env",
2742
+ "shield:project-jail:review-read-credentials": "shield:project-jail:review-copy-credentials"
2743
+ };
2744
+ function copyVerdictOf(hit) {
2745
+ if (!hit) return null;
2746
+ const ruleName = COPY_RULE_OF[hit.ruleName];
2747
+ if (!ruleName) return null;
2748
+ return {
2749
+ ruleName,
2750
+ verdict: "review",
2751
+ reason: `Copying ${hit.path} moves a credential out of its jail (project-jail shield)`,
2752
+ path: hit.path
2753
+ };
2754
+ }
2755
+ function matchSensitivePath2(p) {
2756
+ for (const sp of SENSITIVE_PATH_RULES) {
2757
+ if (sp.match(p))
2758
+ return { ruleName: sp.rule, verdict: sp.verdict ?? "block", reason: sp.reason, path: p };
2759
+ }
2760
+ return null;
2761
+ }
2762
+ function baseWord(w) {
2763
+ return (w ?? "").split("/").pop()?.toLowerCase() ?? "";
2764
+ }
2765
+ var isReaderWord = (w) => w !== null && FS_READ_TOOLS.has(baseWord(w));
2766
+ var positionalAfter = (words, from, to = words.length) => positionedArgs(words, from, to).map((a) => a.value);
2767
+ function wrappedReadPaths(words, name) {
2768
+ if (name === "find") {
2769
+ const { k, starts } = findStartPoints(words, 0);
2770
+ return k > 0 && isReaderWord(words[k + 1] ?? null) ? starts : null;
2771
+ }
2772
+ if (!COMMAND_WRAPPERS.has(name) && !RUNNER_WRAPPERS.has(name)) return null;
2773
+ const h = unwrapCommandHead(words);
2774
+ return h > 0 && isReaderWord(words[h] ?? null) ? positionalAfter(words, h + 1) : null;
2775
+ }
2776
+ function literalShellPayload(words, name) {
2777
+ const h = COMMAND_WRAPPERS.has(name) || RUNNER_WRAPPERS.has(name) ? unwrapCommandHead(words) : 0;
2778
+ const head = (words[h] ?? "").split("/").pop()?.toLowerCase() ?? "";
2779
+ if (head === "eval") {
2780
+ const rest = words.slice(h + 1);
2781
+ if (rest.length === 0 || rest.some((w) => w === null)) return null;
2782
+ return rest.join(" ");
2783
+ }
2784
+ if (SHELL_INTERPRETERS.has(head)) {
2785
+ const c = words.findIndex((w, i) => i > h && w !== null && isInlineCodeFlag(head, w));
2786
+ if (c < 0) return null;
2787
+ return words[c + 1] ?? null;
2788
+ }
2789
+ return null;
2790
+ }
2791
+ function jailedRedirectRead(stmt) {
2792
+ const redirs = stmt.Redirs || [];
2793
+ for (const r of redirs) {
2794
+ if (!r || !REDIR_FILE_IN_OPS.has(r.Op)) continue;
2795
+ const p = resolveWordLiteral(r.Word);
2796
+ if (p === null || p === "") continue;
2797
+ const hit = matchSensitivePath2(p);
2798
+ if (hit) return hit;
2799
+ }
2800
+ return null;
2801
+ }
2521
2802
  function analyzeShellCommand(command) {
2522
2803
  const actions = [];
2523
2804
  const paths = [];
@@ -2650,21 +2931,7 @@ function evaluateEgress(dests, policy) {
2650
2931
  }
2651
2932
  return review;
2652
2933
  }
2653
- var SOURCE_COMMANDS = /* @__PURE__ */ new Set([
2654
- "cat",
2655
- "head",
2656
- "tail",
2657
- "grep",
2658
- "awk",
2659
- "sed",
2660
- "cut",
2661
- "sort",
2662
- "tee",
2663
- "less",
2664
- "more",
2665
- "strings",
2666
- "xxd"
2667
- ]);
2934
+ var SOURCE_COMMANDS = /* @__PURE__ */ new Set([...FS_READ_TOOLS, "tee"]);
2668
2935
  var SINK_COMMANDS = /* @__PURE__ */ new Set([
2669
2936
  "curl",
2670
2937
  "wget",
@@ -2695,16 +2962,25 @@ var OBFUSCATORS = /* @__PURE__ */ new Set([
2695
2962
  "node"
2696
2963
  ]);
2697
2964
  var SENSITIVE_PATTERNS = [
2698
- /(?:^|\/)\.env(?:\.|$)/i,
2699
- // .env, .env.local, .env.production
2965
+ // Kept in step with the AST tier and dlp/ -- see jail-both-doors.test.ts.
2966
+ /(?:^|\/)\.env(?![\w-])(?:[\w.-]*\.local$|(?!\.(?:example|sample|template)\b)(?!\.test$)[\w.-]*$)/i,
2967
+ // .env chain; fixtures exempt unless .local
2700
2968
  /id_rsa|id_ed25519|id_ecdsa|id_dsa/i,
2701
2969
  // SSH private keys
2702
2970
  /\.pem$|\.key$|\.p12$|\.pfx$/i,
2703
2971
  // certificate files
2704
- /(?:^|\/)\.ssh\//i,
2705
- // ~/.ssh/ directory
2706
- /(?:^|\/)\.aws\/credentials/i,
2707
- // AWS credentials
2972
+ // The `$` half mirrors shell/index.ts's SENSITIVE_PATH_RULES: a file INSIDE
2973
+ // the directory counts wherever it appears, while the directory ITSELF counts
2974
+ // only when the path is ROOTED (`~/.ssh`, `/home/u/.ssh`) -- an unrooted
2975
+ // `config/.ssh` is more likely a search pattern than a read. These are
2976
+ // extracted TOKENS (see `args.some(isSensitivePath)` below), the same input
2977
+ // contract as the shell tier, so the same boundary is the right one.
2978
+ // Without it `grep -r x ~/.ssh | curl -d @-` scored one tier BELOW the
2979
+ // identical pipeline naming a file inside that directory.
2980
+ /(?:^|\/)\.ssh\/|^(?:[~/]|[A-Za-z]:).*\/\.ssh$/i,
2981
+ // ~/.ssh/ and ~/.ssh
2982
+ /(?:^|\/)\.aws\/credentials|^(?:[~/]|[A-Za-z]:).*\/\.aws$/i,
2983
+ // AWS creds + dir
2708
2984
  /(?:^|\/)\.netrc$/i,
2709
2985
  // netrc (stores HTTP credentials)
2710
2986
  /(?:^|\/)(passwd|shadow|sudoers)$/i,
@@ -2738,8 +3014,8 @@ function splitOnPipe(cmd) {
2738
3014
  if (current.trim()) segments2.push(current.trim());
2739
3015
  return segments2.filter(Boolean);
2740
3016
  }
2741
- function positionalTokens(segment) {
2742
- return segment.split(/\s+/).slice(1).filter((t) => !t.startsWith("-") && !t.startsWith("@") && t.length > 0);
3017
+ function positionalTokens(tokens) {
3018
+ return tokens.slice(1).filter((t) => !t.startsWith("-") && !t.startsWith("@") && t.length > 0);
2743
3019
  }
2744
3020
  function analyzePipeChain(command) {
2745
3021
  const segments2 = splitOnPipe(command);
@@ -2762,8 +3038,10 @@ function analyzePipeChain(command) {
2762
3038
  for (const segment of segments2) {
2763
3039
  const tokens = segment.split(/\s+/).filter(Boolean);
2764
3040
  if (tokens.length === 0) continue;
2765
- const binary = tokens[0].toLowerCase();
2766
- const args = positionalTokens(segment);
3041
+ const h = unwrapCommandHead(tokens);
3042
+ const head = h < tokens.length ? h : 0;
3043
+ const binary = tokens[head].toLowerCase();
3044
+ const args = positionalTokens(tokens.slice(head));
2767
3045
  if (SOURCE_COMMANDS.has(binary)) {
2768
3046
  sourceFiles.push(...args);
2769
3047
  if (args.some(isSensitivePath)) hasSensitiveSource = true;
@@ -3350,6 +3628,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
3350
3628
  const shellShaped = isBashTool(toolName) || inspectsShellCommand(toolName, config.policy.toolInspection);
3351
3629
  const shellShapedCommand = shellShaped ? isBashTool(toolName) && args && typeof args === "object" ? typeof args.command === "string" ? args.command : null : extractShellCommand(toolName, args, config.policy.toolInspection) : null;
3352
3630
  const bashCommand = agent !== "Terminal" ? shellShapedCommand : null;
3631
+ let pendingAstReview;
3353
3632
  if (bashCommand !== null) {
3354
3633
  const pipeVerdict = pipeChainVerdict(
3355
3634
  bashCommand,
@@ -3361,7 +3640,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
3361
3640
  if (fsVerdict) {
3362
3641
  const isShieldRule = fsVerdict.ruleName.startsWith("shield:");
3363
3642
  const labelPrefix = isShieldRule ? "project-jail (AST)" : "Node9 (AST)";
3364
- return {
3643
+ const astVerdict = {
3365
3644
  decision: fsVerdict.verdict,
3366
3645
  blockedByLabel: `${labelPrefix}: ${fsVerdict.ruleName}`,
3367
3646
  reason: fsVerdict.reason,
@@ -3369,6 +3648,8 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
3369
3648
  ruleName: fsVerdict.ruleName,
3370
3649
  ruleDescription: fsVerdict.reason
3371
3650
  };
3651
+ if (fsVerdict.verdict === "block") return astVerdict;
3652
+ pendingAstReview = astVerdict;
3372
3653
  }
3373
3654
  const sqlAction = resolveCheck(config.policy.commandChecks?.sqlDdl);
3374
3655
  const sqlVerdict = sqlAction === "off" ? null : analyzeSqlDestructive(bashCommand);
@@ -3411,7 +3692,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
3411
3692
  const matchedRule = resolvePinned(matches);
3412
3693
  if (matchedRule) {
3413
3694
  if (matchedRule.verdict === "allow")
3414
- return { decision: "allow", ruleName: matchedRule.name ?? matchedRule.tool };
3695
+ return pendingAstReview ?? { decision: "allow", ruleName: matchedRule.name ?? matchedRule.tool };
3415
3696
  return {
3416
3697
  decision: matchedRule.verdict,
3417
3698
  blockedByLabel: `Smart Rule: ${matchedRule.name ?? matchedRule.tool}`,
@@ -3438,6 +3719,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
3438
3719
  allTokens = analyzed.allTokens;
3439
3720
  pathTokens = analyzed.paths;
3440
3721
  const candidates = [];
3722
+ if (pendingAstReview) candidates.push(pendingAstReview);
3441
3723
  const evalVerdict = detectDangerousShellExec(shellCommand);
3442
3724
  if (evalVerdict === "block") {
3443
3725
  return {
@@ -4162,7 +4444,7 @@ var project_jail_default = {
4162
4444
  {
4163
4445
  field: "command",
4164
4446
  op: "matches",
4165
- value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type|grep|egrep|fgrep|rg|ag|ack|awk|gawk|sed|cut|tr|jq|yq|od|xxd|hexdump|strings|sort|uniq|tac|nl|dd)\\s+.*?\\.ssh[\\/\\\\]",
4447
+ value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type|grep|egrep|fgrep|rg|ag|ack|awk|gawk|sed|cut|tr|jq|yq|od|xxd|hexdump|strings|base64|sort|uniq|tac|nl|dd)\\s+.*?\\.ssh[\\/\\\\]",
4166
4448
  flags: "i"
4167
4449
  }
4168
4450
  ],
@@ -4176,7 +4458,7 @@ var project_jail_default = {
4176
4458
  {
4177
4459
  field: "command",
4178
4460
  op: "matches",
4179
- value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type|grep|egrep|fgrep|rg|ag|ack|awk|gawk|sed|cut|tr|jq|yq|od|xxd|hexdump|strings|sort|uniq|tac|nl|dd)\\s+.*?\\.aws[\\/\\\\]",
4461
+ value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type|grep|egrep|fgrep|rg|ag|ack|awk|gawk|sed|cut|tr|jq|yq|od|xxd|hexdump|strings|base64|sort|uniq|tac|nl|dd)\\s+.*?\\.aws[\\/\\\\]",
4180
4462
  flags: "i"
4181
4463
  }
4182
4464
  ],
@@ -4190,7 +4472,7 @@ var project_jail_default = {
4190
4472
  {
4191
4473
  field: "command",
4192
4474
  op: "matches",
4193
- value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type|grep|egrep|fgrep|rg|ag|ack|awk|gawk|sed|cut|tr|jq|yq|od|xxd|hexdump|strings|sort|uniq|tac|nl|dd)\\s+.*?\\.env(\\.(local|production|staging|development|production\\.local|staging\\.local|development\\.local))?(?=\\s|$|[;&|>)<])",
4475
+ value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type|grep|egrep|fgrep|rg|ag|ack|awk|gawk|sed|cut|tr|jq|yq|od|xxd|hexdump|strings|base64|sort|uniq|tac|nl|dd)\\s+.*?\\.env(\\.(local|production|staging|development|production\\.local|staging\\.local|development\\.local))?(?=\\s|$|[;&|>)<])",
4194
4476
  flags: "i"
4195
4477
  }
4196
4478
  ],
@@ -4204,7 +4486,7 @@ var project_jail_default = {
4204
4486
  {
4205
4487
  field: "command",
4206
4488
  op: "matches",
4207
- value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type|grep|egrep|fgrep|rg|ag|ack|awk|gawk|sed|cut|tr|jq|yq|od|xxd|hexdump|strings|sort|uniq|tac|nl|dd)\\s+.*(credentials\\.json|\\.netrc|\\.npmrc|\\.docker[\\/\\\\]config\\.json|gcloud[\\/\\\\]credentials)",
4489
+ value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type|grep|egrep|fgrep|rg|ag|ack|awk|gawk|sed|cut|tr|jq|yq|od|xxd|hexdump|strings|base64|sort|uniq|tac|nl|dd)\\s+.*(credentials\\.json|\\.netrc|\\.npmrc|\\.docker[\\/\\\\]config\\.json|gcloud[\\/\\\\]credentials)",
4208
4490
  flags: "i"
4209
4491
  }
4210
4492
  ],
@@ -4218,7 +4500,7 @@ var project_jail_default = {
4218
4500
  {
4219
4501
  field: "file_path",
4220
4502
  op: "matches",
4221
- value: "(^|[\\/\\\\])\\.ssh[\\/\\\\]",
4503
+ value: "([\\/\\\\]\\.ssh([\\/\\\\]|$)|^\\.ssh[\\/\\\\])",
4222
4504
  flags: "i"
4223
4505
  }
4224
4506
  ],
@@ -4232,7 +4514,7 @@ var project_jail_default = {
4232
4514
  {
4233
4515
  field: "file_path",
4234
4516
  op: "matches",
4235
- value: "(^|[\\/\\\\])\\.aws[\\/\\\\]",
4517
+ value: "([\\/\\\\]\\.aws([\\/\\\\]|$)|^\\.aws[\\/\\\\])",
4236
4518
  flags: "i"
4237
4519
  }
4238
4520
  ],
@@ -4246,7 +4528,7 @@ var project_jail_default = {
4246
4528
  {
4247
4529
  field: "file_path",
4248
4530
  op: "matches",
4249
- value: "(^|[\\/\\\\])\\.env(\\.(local|production|staging|development|production\\.local|staging\\.local|development\\.local))?$",
4531
+ value: "(^|[\\/\\\\])\\.env(?![\\w-])(?:[\\w.-]*\\.local$|(?!\\.(example|sample|template)\\b)(?!\\.test$)[\\w.-]*$)",
4250
4532
  flags: "i"
4251
4533
  }
4252
4534
  ],
@@ -7023,12 +7305,13 @@ function extractFilePaths(toolName, args) {
7023
7305
  }
7024
7306
  return paths.filter(Boolean);
7025
7307
  }
7308
+ var NETWORK_COMMAND_RE = new RegExp(`(?<![.\\w-])(${[...NET_BINARIES].join("|")})\\b`);
7026
7309
  function isNetworkTool(toolName, args) {
7027
7310
  const t = toolName.toLowerCase();
7028
7311
  if (t === "bash" || t === "shell" || t === "run_shell_command" || t === "terminal.execute") {
7029
7312
  const a = args;
7030
7313
  const cmd = typeof a?.command === "string" ? a.command : typeof a?.cmd === "string" ? a.cmd : "";
7031
- return /\b(curl|wget|scp|rsync|nc|ncat|netcat|ssh)\b/.test(cmd);
7314
+ return NETWORK_COMMAND_RE.test(cmd);
7032
7315
  }
7033
7316
  return false;
7034
7317
  }