@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.js CHANGED
@@ -1162,13 +1162,32 @@ var DLP_PATTERNS_GLOBAL = DLP_PATTERNS.map(
1162
1162
  })
1163
1163
  );
1164
1164
  var SENSITIVE_PATH_PATTERNS = [
1165
- /[/\\]\.ssh[/\\]/i,
1166
- /[/\\]\.aws[/\\]/i,
1165
+ /[/\\]\.ssh([/\\]|$)/i,
1166
+ /[/\\]\.aws([/\\]|$)/i,
1167
1167
  /[/\\]\.config[/\\]gcloud[/\\]/i,
1168
1168
  /[/\\]\.azure[/\\]/i,
1169
1169
  /[/\\]\.kube[/\\]config$/i,
1170
- /[/\\]\.env($|\.)/i,
1171
- // .env, .env.local, .env.production — not .envoy
1170
+ // ⚠️ ONE SEMANTIC, FOUR COPIES. This is the AST tier's `.env` rule verbatim
1171
+ // (shell/index.ts SENSITIVE_PATH_RULES), whose reasoning is documented there:
1172
+ // structural suffix chain rather than a hand-written list, `example|sample|
1173
+ // template` exempt because a fixture stays a fixture whatever follows, and
1174
+ // `.test` anchored because `test` names an ENVIRONMENT -- `.env.test` is the
1175
+ // committed template, `.env.test.local` is gitignored and holds real values.
1176
+ //
1177
+ // It was previously `[/\\]\.env($|\.)` with NO exemptions, so `Read .env.example`
1178
+ // blocked while `cat .env.example` allowed: the same file, opposite verdicts,
1179
+ // decided only by which tool asked. See src/__tests__/jail-both-doors.test.ts,
1180
+ // which is the contract that now holds these copies in step, and stage 5 of
1181
+ // doc/credential-jail-architecture.md, which replaces them with one generated
1182
+ // source.
1183
+ // ⚠️ The `.local` branch comes FIRST and takes no exemption. A fixture stays a
1184
+ // fixture whatever follows it -- `.env.example.md` is documentation -- but
1185
+ // `.env.example.local` is gitignored by the `.env*.local` convention and holds
1186
+ // real values, exactly the reasoning that anchors `(?!\.test$)` rather than
1187
+ // using `\b`. Without this branch the fixture exemption also bought a two-step
1188
+ // bypass: `cp .env .env.sample`, then read the copy.
1189
+ /[/\\]\.env(?![\w-])(?:[\w.-]*\.local$|(?!\.(?:example|sample|template)\b)(?!\.test$)[\w.-]*$)/i,
1190
+ // .env + any suffix chain; fixtures exempt unless .local
1172
1191
  /[/\\]\.git-credentials$/i,
1173
1192
  /[/\\]\.npmrc$/i,
1174
1193
  /[/\\]\.docker[/\\]config\.json$/i,
@@ -1670,6 +1689,10 @@ var FS_READ_TOOLS = /* @__PURE__ */ new Set([
1670
1689
  "od",
1671
1690
  "xxd",
1672
1691
  "hexdump",
1692
+ // Emits the file's bytes, re-encoded, so it is a read by the set's own test
1693
+ // ("does it emit file contents"). Absent until 2026-09-10, which is why
1694
+ // `base64 ~/.ssh/id_rsa` printed a private key with no verdict.
1695
+ "base64",
1673
1696
  "strings",
1674
1697
  "sort",
1675
1698
  "uniq",
@@ -1677,8 +1700,56 @@ var FS_READ_TOOLS = /* @__PURE__ */ new Set([
1677
1700
  "nl",
1678
1701
  "dd"
1679
1702
  ]);
1703
+ var SCP_VALUE_FLAGS = ["i", "F", "o", "c", "S", "P", "J", "D", "W", "l"];
1704
+ var RSYNC_SKIP = [
1705
+ "e",
1706
+ "--rsh",
1707
+ "--exclude",
1708
+ "--exclude-from",
1709
+ "--include",
1710
+ "--include-from",
1711
+ "--files-from",
1712
+ "f",
1713
+ "--filter"
1714
+ ];
1715
+ var COPY_VERBS = {
1716
+ cp: { source: "allButLast", targetDirFlag: true },
1717
+ mv: { source: "allButLast", targetDirFlag: true },
1718
+ install: { source: "allButLast", targetDirFlag: true },
1719
+ ln: { source: "first", targetDirFlag: true },
1720
+ scp: { source: "allButLast", skipFlags: SCP_VALUE_FLAGS },
1721
+ rsync: { source: "allButLast", skipFlags: RSYNC_SKIP },
1722
+ tar: {
1723
+ source: "archive",
1724
+ archive: "tar",
1725
+ skipFlags: ["f", "X", "T", "--file", "--exclude", "--exclude-from", "--files-from"]
1726
+ },
1727
+ zip: { source: "archive", archive: "zip", skipFlags: ["x", "i", "--exclude", "--include"] },
1728
+ ar: { source: "archive", archive: "ar" },
1729
+ "7z": { source: "archive", archive: "7z", skipFlags: ["x", "--exclude"] },
1730
+ gzip: { source: "all" },
1731
+ bzip2: { source: "all" },
1732
+ xz: { source: "all" },
1733
+ "docker cp": { source: "allButLast" },
1734
+ "kubectl cp": { source: "allButLast" },
1735
+ "gsutil cp": { source: "allButLast" },
1736
+ "gsutil rsync": { source: "allButLast" },
1737
+ "rclone copy": { source: "allButLast" },
1738
+ "rclone sync": { source: "allButLast" },
1739
+ "aws s3 cp": { source: "allButLast" },
1740
+ "aws s3 mv": { source: "allButLast" },
1741
+ "aws s3 sync": { source: "allButLast" },
1742
+ "gcloud storage cp": { source: "allButLast" },
1743
+ "az storage blob upload": { source: "flagOperand", sourceFlags: ["f", "--file"] }
1744
+ };
1745
+ var TAR_MODE_WORD = /^[a-zA-Z]+$/;
1746
+ var COPY_VERB_HEADS = new Set(Object.keys(COPY_VERBS).map((k) => k.split(" ")[0]));
1680
1747
  var FS_OP_PRESCREEN_RE = new RegExp(
1681
- `(?:^|[\\s|;&(\`\\n])(?:rm|${[...FS_READ_TOOLS].map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\b`
1748
+ // A quote is a separator too: `eval "cat X"` and `sh -c 'cat X'` put the
1749
+ // reader right after `"` / `'`, and without these two characters the
1750
+ // prescreen rejected every string-wrapped read before the parser ran.
1751
+ // Found 2026-09-11 by instrumenting the walk -- no CallExpr was ever visited.
1752
+ `(?:^|[\\s|;&("'\`\\n/])(?:rm|${[...FS_READ_TOOLS, ...COPY_VERB_HEADS].map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\b|(?<!<)<(?!<)`
1682
1753
  );
1683
1754
  var HOME_CACHE_ALLOWLIST = [
1684
1755
  ".cache",
@@ -1699,12 +1770,12 @@ var SENSITIVE_PATH_RULES = [
1699
1770
  {
1700
1771
  rule: "shield:project-jail:block-read-ssh",
1701
1772
  reason: "Reading SSH private keys is blocked by project-jail shield",
1702
- match: (p) => /(^|[\\/])\.ssh[\\/]/i.test(p)
1773
+ match: (p) => /([\\/]\.ssh[\\/]|^\.ssh[\\/]|^(?:[~/]|[A-Za-z]:).*[\\/]\.ssh$)/i.test(p)
1703
1774
  },
1704
1775
  {
1705
1776
  rule: "shield:project-jail:block-read-aws",
1706
1777
  reason: "Reading AWS credentials is blocked by project-jail shield",
1707
- match: (p) => /(^|[\\/])\.aws[\\/]/i.test(p)
1778
+ match: (p) => /([\\/]\.aws[\\/]|^\.aws[\\/]|^(?:[~/]|[A-Za-z]:).*[\\/]\.aws$)/i.test(p)
1708
1779
  },
1709
1780
  {
1710
1781
  // Mirrors the JSON shield's `.env` pattern (project-jail.json's
@@ -1748,7 +1819,9 @@ var SENSITIVE_PATH_RULES = [
1748
1819
  // symmetry — silently exempts every `.env.test.*` file.
1749
1820
  //
1750
1821
  // shields.test.ts:983-995 is the canonical contract; keep both in step.
1751
- match: (p) => /(?:^|[\\/])\.env(?![\w-])(?!\.(?:example|sample|template)\b)(?!\.test$)[\w.-]*$/i.test(p)
1822
+ match: (p) => /(?:^|[\\/])\.env(?![\w-])(?:[\w.-]*\.local$|(?!\.(?:example|sample|template)\b)(?!\.test$)[\w.-]*$)/i.test(
1823
+ p
1824
+ )
1752
1825
  },
1753
1826
  {
1754
1827
  // verdict: 'review' (not 'block') is a deliberate design choice
@@ -1959,15 +2032,14 @@ function listOps() {
1959
2032
  return _listOps;
1960
2033
  }
1961
2034
  var WRAPPER_TAKES_TARGET = /* @__PURE__ */ new Set(["chroot"]);
2035
+ var FIND_EXEC_FLAGS = /* @__PURE__ */ new Set(["-exec", "-execdir", "-ok", "-okdir"]);
1962
2036
  var INTERP_LEADING_TARGET = /* @__PURE__ */ new Set(["su"]);
1963
2037
  function unwrapCommandHead(words) {
1964
2038
  let i = 0;
1965
2039
  while (i < words.length) {
1966
2040
  const head = (words[i] ?? "").toLowerCase().split("/").pop() ?? "";
1967
2041
  if (head === "find") {
1968
- const x = words.findIndex(
1969
- (w, k) => k > i && (w === "-exec" || w === "-execdir" || w === "-ok")
1970
- );
2042
+ const x = words.findIndex((w, k) => k > i && w !== null && FIND_EXEC_FLAGS.has(w));
1971
2043
  if (x < 0) break;
1972
2044
  i = x + 1;
1973
2045
  continue;
@@ -1989,7 +2061,11 @@ function unwrapCommandHead(words) {
1989
2061
  if (t.startsWith("-")) {
1990
2062
  i++;
1991
2063
  const nxt = words[i];
1992
- if (nxt != null && !nxt.startsWith("-") && !INLINE_INTERPRETER.test(nxt.split("/").pop() ?? "") && !COMMAND_WRAPPERS.has(nxt.toLowerCase()) && !RUNNER_WRAPPERS.has(nxt.toLowerCase()))
2064
+ 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`,
2065
+ // `stdbuf -o0 cat X`, `ionice -c3 cat X`. Without this the head was
2066
+ // swallowed and the jail needed a looser fallback whose cost was a
2067
+ // false positive on `sudo echo cat X`.
2068
+ !FS_READ_TOOLS.has(nxt.split("/").pop()?.toLowerCase() ?? ""))
1993
2069
  i++;
1994
2070
  continue;
1995
2071
  }
@@ -2108,38 +2184,43 @@ function isProtectedHomePath(rawPath) {
2108
2184
  }
2109
2185
  return true;
2110
2186
  }
2111
- function extractLiteralArgs(callExpr) {
2112
- const args = callExpr.Args || [];
2113
- if (args.length === 0) return { name: "", flags: [], paths: [] };
2114
- const litFromWord = (w) => {
2115
- const parts = w?.Parts || [];
2116
- let s = "";
2117
- for (const p of parts) {
2118
- const t = syntax.NodeType(p);
2119
- if (t === "Lit") s += (p.Value ?? "").replace(/\\(.)/g, "$1");
2120
- else if (t === "SglQuoted") s += p.Value ?? "";
2121
- else if (t === "DblQuoted") {
2122
- const inner = p.Parts || [];
2123
- if (!inner.every((ip) => syntax.NodeType(ip) === "Lit")) return null;
2124
- s += inner.map((ip) => ip.Value ?? "").join("");
2125
- } else {
2126
- return null;
2127
- }
2187
+ function positionedArgs(words, from = 1, to = words.length) {
2188
+ const out = [];
2189
+ let afterFlag = null;
2190
+ for (let i = from; i < to; i++) {
2191
+ const v = words[i];
2192
+ if (v === null) {
2193
+ afterFlag = null;
2194
+ continue;
2128
2195
  }
2129
- return s;
2130
- };
2131
- const name = (litFromWord(args[0]) || "").toLowerCase();
2132
- const flags = [];
2133
- const paths = [];
2134
- for (let i = 1; i < args.length; i++) {
2135
- const v = litFromWord(args[i]);
2136
- if (v === null) continue;
2137
- if (v.startsWith("-")) flags.push(v);
2138
- else paths.push(v);
2196
+ if (v.startsWith("-")) {
2197
+ afterFlag = v;
2198
+ continue;
2199
+ }
2200
+ out.push({ value: v, index: out.length, argv: i, afterFlag });
2201
+ afterFlag = null;
2139
2202
  }
2140
- return { name, flags, paths };
2203
+ return out;
2141
2204
  }
2142
- var NET_BINARIES = /* @__PURE__ */ new Set(["curl", "wget", "scp", "ssh", "nc", "ncat", "netcat"]);
2205
+ function extractLiteralArgs(callExpr) {
2206
+ const rawArgs = callExpr.Args || [];
2207
+ if (rawArgs.length === 0) return { name: "", flags: [], paths: [], words: [], args: [] };
2208
+ const words = rawArgs.map((a) => resolveWordLiteral(a));
2209
+ const name = baseWord(words[0]);
2210
+ const flags = words.slice(1).filter((w) => w !== null && w.startsWith("-"));
2211
+ const args = positionedArgs(words);
2212
+ return { name, flags, paths: args.map((a) => a.value), words, args };
2213
+ }
2214
+ var NET_BINARIES = /* @__PURE__ */ new Set([
2215
+ "curl",
2216
+ "wget",
2217
+ "scp",
2218
+ "ssh",
2219
+ "nc",
2220
+ "ncat",
2221
+ "netcat",
2222
+ "rsync"
2223
+ ]);
2143
2224
  var VALUE_FLAGS = {
2144
2225
  curl: /* @__PURE__ */ new Set([
2145
2226
  "-d",
@@ -2424,6 +2505,9 @@ function deriveRedirOp(sample) {
2424
2505
  }
2425
2506
  }
2426
2507
  var REDIR_TRUNCATE_OPS = /* @__PURE__ */ new Set([deriveRedirOp(">_f")]);
2508
+ var REDIR_FILE_IN_OPS = new Set(
2509
+ [deriveRedirOp("cat < f"), deriveRedirOp("cat <> f")].filter((op) => op >= 0)
2510
+ );
2427
2511
  var REDIR_HEREDOC_OPS = /* @__PURE__ */ new Set([
2428
2512
  deriveRedirOp("cat <<X\nX"),
2429
2513
  deriveRedirOp("cat <<-X\nX")
@@ -2488,16 +2572,21 @@ function isRmCreatedInCommandCleanup(command) {
2488
2572
  }
2489
2573
  return sawRm && ok;
2490
2574
  }
2491
- function analyzeFsOperationImpl(command) {
2575
+ function analyzeFsOperationImpl(command, depth = 0) {
2492
2576
  const f = parseShared(command);
2493
2577
  if (f === PARSE_FAIL) return null;
2494
2578
  let result = null;
2495
2579
  try {
2496
2580
  syntax.Walk(f, (node) => {
2497
- if (!node || result) return false;
2581
+ if (!node || result?.verdict === "block") return false;
2498
2582
  const n = node;
2499
- if (syntax.NodeType(n) !== "CallExpr") return true;
2500
- const { name, flags, paths } = extractLiteralArgs(n);
2583
+ const nodeType = syntax.NodeType(n);
2584
+ if (nodeType === "Stmt") {
2585
+ result = stricter(result, jailedRedirectRead(n));
2586
+ return result?.verdict !== "block";
2587
+ }
2588
+ if (nodeType !== "CallExpr") return true;
2589
+ const { name, flags, paths, words } = extractLiteralArgs(n);
2501
2590
  if (!name) return true;
2502
2591
  if (name === "rm") {
2503
2592
  const flagStr = flags.join("").toLowerCase();
@@ -2526,21 +2615,27 @@ function analyzeFsOperationImpl(command) {
2526
2615
  }
2527
2616
  }
2528
2617
  }
2529
- if (FS_READ_TOOLS.has(name)) {
2530
- for (const p of paths) {
2531
- for (const sp of SENSITIVE_PATH_RULES) {
2532
- if (sp.match(p)) {
2533
- result = {
2534
- ruleName: sp.rule,
2535
- verdict: sp.verdict ?? "block",
2536
- reason: sp.reason,
2537
- path: p
2538
- };
2539
- return false;
2540
- }
2618
+ if (depth < 1) {
2619
+ const payload = literalShellPayload(words, name);
2620
+ if (payload !== null) {
2621
+ const inner = analyzeFsOperationImpl(payload, depth + 1);
2622
+ if (inner) {
2623
+ result = inner;
2624
+ return false;
2541
2625
  }
2626
+ return true;
2542
2627
  }
2543
2628
  }
2629
+ const readPaths = FS_READ_TOOLS.has(name) ? paths : wrappedReadPaths(words, name);
2630
+ if (readPaths) {
2631
+ for (const p of readPaths) {
2632
+ result = stricter(result, matchSensitivePath2(p));
2633
+ if (result?.verdict === "block") return false;
2634
+ }
2635
+ }
2636
+ for (const p of copySourcePaths(words)) {
2637
+ result = stricter(result, copyVerdictOf(matchSensitivePath2(p)));
2638
+ }
2544
2639
  return true;
2545
2640
  });
2546
2641
  return result;
@@ -2548,6 +2643,192 @@ function analyzeFsOperationImpl(command) {
2548
2643
  return null;
2549
2644
  }
2550
2645
  }
2646
+ function stricter(a, b) {
2647
+ if (!a) return b;
2648
+ if (!b) return a;
2649
+ return b.verdict === "block" && a.verdict !== "block" ? b : a;
2650
+ }
2651
+ function flagInfo(w) {
2652
+ if (w.startsWith("--")) {
2653
+ const eq = w.indexOf("=");
2654
+ return eq < 0 ? { letter: null, long: w, attached: null } : { letter: null, long: w.slice(0, eq), attached: w.slice(eq + 1) };
2655
+ }
2656
+ const m = /^-([a-zA-Z]+)(.*)$/.exec(w);
2657
+ if (!m) return { letter: null, long: null, attached: null };
2658
+ return { letter: m[1][m[1].length - 1], long: null, attached: m[2] === "" ? null : m[2] };
2659
+ }
2660
+ function flagIs(w, names) {
2661
+ if (w === null) return false;
2662
+ const f = flagInfo(w);
2663
+ return names.some((n) => n.startsWith("--") ? f.long === n : f.letter === n);
2664
+ }
2665
+ function operandOf(a, names) {
2666
+ if (!names || a.afterFlag === null) return false;
2667
+ return flagIs(a.afterFlag, names) && flagInfo(a.afterFlag).attached === null;
2668
+ }
2669
+ function resolveCopyShape(words, h) {
2670
+ const verb = baseWord(words[h]);
2671
+ if (!verb) return null;
2672
+ const direct = COPY_VERBS[verb];
2673
+ if (direct) return { shape: direct, last: h };
2674
+ const slots = positionedArgs(words, h + 1);
2675
+ for (let i = 0; i < slots.length; i++) {
2676
+ for (let n = 3; n >= 1; n--) {
2677
+ const part = slots.slice(i, i + n);
2678
+ if (part.length < n) continue;
2679
+ const key = [verb, ...part.map((a) => a.value.toLowerCase())].join(" ");
2680
+ const shape = COPY_VERBS[key];
2681
+ if (shape) return { shape, last: part[n - 1].argv };
2682
+ }
2683
+ if (slots[i].afterFlag === null) return null;
2684
+ }
2685
+ return null;
2686
+ }
2687
+ var FIND_OPTIONS = /* @__PURE__ */ new Set(["-H", "-L", "-P"]);
2688
+ function findStartPoints(words, h) {
2689
+ const k = words.findIndex((w, i) => i > h && w !== null && FIND_EXEC_FLAGS.has(w));
2690
+ if (k < 0) return { k, starts: [] };
2691
+ const firstPredicate = words.findIndex(
2692
+ (w, i) => i > h && w !== null && w.startsWith("-") && !FIND_OPTIONS.has(w)
2693
+ );
2694
+ const end = firstPredicate > h ? firstPredicate : k;
2695
+ return { k, starts: positionalAfter(words, h + 1, end) };
2696
+ }
2697
+ function copySourcePaths(words) {
2698
+ const h = unwrapCommandHead(words);
2699
+ const fi = words.findIndex((w, i) => i <= h && baseWord(w) === "find");
2700
+ if (fi >= 0) {
2701
+ const { k, starts } = findStartPoints(words, fi);
2702
+ if (k < 0) return [];
2703
+ const action = unwrapCommandHead(words.slice(k + 1));
2704
+ return resolveCopyShape(words.slice(k + 1), action) ? starts : [];
2705
+ }
2706
+ if (!COPY_VERB_HEADS.has(baseWord(words[h]))) return [];
2707
+ const r = resolveCopyShape(words, h);
2708
+ if (!r) return [];
2709
+ const { shape, last } = r;
2710
+ const args = positionedArgs(words, last + 1);
2711
+ const tail = words.slice(last + 1);
2712
+ const skipped = (a) => operandOf(a, shape.skipFlags);
2713
+ const targetDir = shape.targetDirFlag === true && tail.some((w) => w !== null && w.startsWith("-") && flagIs(w, ["t", "--target-directory"]));
2714
+ const targetOperand = (a) => targetDir && operandOf(a, ["t", "--target-directory"]);
2715
+ const lastOperand = [...tail].reverse().find((w) => w === null || !w.startsWith("-"));
2716
+ const dynamicDest = lastOperand === null;
2717
+ const destIsLastOperand = (shape.source === "allButLast" || shape.source === "first") && !targetDir && !dynamicDest;
2718
+ if (destIsLastOperand && typeof lastOperand === "string" && matchSensitivePath2(lastOperand))
2719
+ return [];
2720
+ let src;
2721
+ switch (shape.source) {
2722
+ case "all":
2723
+ src = args;
2724
+ break;
2725
+ case "first":
2726
+ src = targetDir ? args : args.slice(0, 1);
2727
+ break;
2728
+ case "flagOperand": {
2729
+ 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);
2730
+ return [
2731
+ ...args.filter(
2732
+ (a) => flagIs(a.afterFlag, shape.sourceFlags ?? []) && flagInfo(a.afterFlag).attached === null
2733
+ ).map((a) => a.value),
2734
+ ...inline
2735
+ ];
2736
+ }
2737
+ case "archive":
2738
+ src = archiveInputs(shape.archive, args, tail);
2739
+ break;
2740
+ case "allButLast":
2741
+ src = targetDir || dynamicDest ? args : args.slice(0, -1);
2742
+ break;
2743
+ }
2744
+ return src.filter((a) => !skipped(a) && !targetOperand(a)).map((a) => a.value);
2745
+ }
2746
+ function archiveInputs(kind, args, tail) {
2747
+ const first = args[0];
2748
+ const bareKey = first && first.afterFlag === null && TAR_MODE_WORD.test(first.value);
2749
+ if (kind === "tar") {
2750
+ const flagsText = tail.filter((w) => w !== null && w.startsWith("-")).join(" ");
2751
+ const mode = (bareKey ? first.value : "") + flagsText;
2752
+ const extracting = /x|t/.test(bareKey ? first.value.replace(/f/g, "") : "") || /(^|\s)-[a-zA-Z]*[xt]|--extract|--list|--get/.test(flagsText);
2753
+ const writing = /[cruA]/.test(bareKey ? first.value : "") || /(^|\s)-[a-zA-Z]*[cruA]|--create|--append|--update|--concatenate/.test(flagsText);
2754
+ if (extracting && !writing) return [];
2755
+ void mode;
2756
+ let i = 0;
2757
+ if (bareKey) {
2758
+ i = 1;
2759
+ const next = args[1];
2760
+ if (first.value.includes("f") && next && next.afterFlag === null) i = 2;
2761
+ }
2762
+ return args.slice(i);
2763
+ }
2764
+ if (kind === "zip") return first && first.afterFlag === "-" ? args : args.slice(1);
2765
+ if (kind === "ar") return bareKey ? args.slice(2) : args.slice(1);
2766
+ return args.slice(2);
2767
+ }
2768
+ var COPY_RULE_OF = {
2769
+ "shield:project-jail:block-read-ssh": "shield:project-jail:review-copy-ssh",
2770
+ "shield:project-jail:block-read-aws": "shield:project-jail:review-copy-aws",
2771
+ "shield:project-jail:block-read-env": "shield:project-jail:review-copy-env",
2772
+ "shield:project-jail:review-read-credentials": "shield:project-jail:review-copy-credentials"
2773
+ };
2774
+ function copyVerdictOf(hit) {
2775
+ if (!hit) return null;
2776
+ const ruleName = COPY_RULE_OF[hit.ruleName];
2777
+ if (!ruleName) return null;
2778
+ return {
2779
+ ruleName,
2780
+ verdict: "review",
2781
+ reason: `Copying ${hit.path} moves a credential out of its jail (project-jail shield)`,
2782
+ path: hit.path
2783
+ };
2784
+ }
2785
+ function matchSensitivePath2(p) {
2786
+ for (const sp of SENSITIVE_PATH_RULES) {
2787
+ if (sp.match(p))
2788
+ return { ruleName: sp.rule, verdict: sp.verdict ?? "block", reason: sp.reason, path: p };
2789
+ }
2790
+ return null;
2791
+ }
2792
+ function baseWord(w) {
2793
+ return (w ?? "").split("/").pop()?.toLowerCase() ?? "";
2794
+ }
2795
+ var isReaderWord = (w) => w !== null && FS_READ_TOOLS.has(baseWord(w));
2796
+ var positionalAfter = (words, from, to = words.length) => positionedArgs(words, from, to).map((a) => a.value);
2797
+ function wrappedReadPaths(words, name) {
2798
+ if (name === "find") {
2799
+ const { k, starts } = findStartPoints(words, 0);
2800
+ return k > 0 && isReaderWord(words[k + 1] ?? null) ? starts : null;
2801
+ }
2802
+ if (!COMMAND_WRAPPERS.has(name) && !RUNNER_WRAPPERS.has(name)) return null;
2803
+ const h = unwrapCommandHead(words);
2804
+ return h > 0 && isReaderWord(words[h] ?? null) ? positionalAfter(words, h + 1) : null;
2805
+ }
2806
+ function literalShellPayload(words, name) {
2807
+ const h = COMMAND_WRAPPERS.has(name) || RUNNER_WRAPPERS.has(name) ? unwrapCommandHead(words) : 0;
2808
+ const head = (words[h] ?? "").split("/").pop()?.toLowerCase() ?? "";
2809
+ if (head === "eval") {
2810
+ const rest = words.slice(h + 1);
2811
+ if (rest.length === 0 || rest.some((w) => w === null)) return null;
2812
+ return rest.join(" ");
2813
+ }
2814
+ if (SHELL_INTERPRETERS.has(head)) {
2815
+ const c = words.findIndex((w, i) => i > h && w !== null && isInlineCodeFlag(head, w));
2816
+ if (c < 0) return null;
2817
+ return words[c + 1] ?? null;
2818
+ }
2819
+ return null;
2820
+ }
2821
+ function jailedRedirectRead(stmt) {
2822
+ const redirs = stmt.Redirs || [];
2823
+ for (const r of redirs) {
2824
+ if (!r || !REDIR_FILE_IN_OPS.has(r.Op)) continue;
2825
+ const p = resolveWordLiteral(r.Word);
2826
+ if (p === null || p === "") continue;
2827
+ const hit = matchSensitivePath2(p);
2828
+ if (hit) return hit;
2829
+ }
2830
+ return null;
2831
+ }
2551
2832
  function analyzeShellCommand(command) {
2552
2833
  const actions = [];
2553
2834
  const paths = [];
@@ -2680,21 +2961,7 @@ function evaluateEgress(dests, policy) {
2680
2961
  }
2681
2962
  return review;
2682
2963
  }
2683
- var SOURCE_COMMANDS = /* @__PURE__ */ new Set([
2684
- "cat",
2685
- "head",
2686
- "tail",
2687
- "grep",
2688
- "awk",
2689
- "sed",
2690
- "cut",
2691
- "sort",
2692
- "tee",
2693
- "less",
2694
- "more",
2695
- "strings",
2696
- "xxd"
2697
- ]);
2964
+ var SOURCE_COMMANDS = /* @__PURE__ */ new Set([...FS_READ_TOOLS, "tee"]);
2698
2965
  var SINK_COMMANDS = /* @__PURE__ */ new Set([
2699
2966
  "curl",
2700
2967
  "wget",
@@ -2725,16 +2992,25 @@ var OBFUSCATORS = /* @__PURE__ */ new Set([
2725
2992
  "node"
2726
2993
  ]);
2727
2994
  var SENSITIVE_PATTERNS = [
2728
- /(?:^|\/)\.env(?:\.|$)/i,
2729
- // .env, .env.local, .env.production
2995
+ // Kept in step with the AST tier and dlp/ -- see jail-both-doors.test.ts.
2996
+ /(?:^|\/)\.env(?![\w-])(?:[\w.-]*\.local$|(?!\.(?:example|sample|template)\b)(?!\.test$)[\w.-]*$)/i,
2997
+ // .env chain; fixtures exempt unless .local
2730
2998
  /id_rsa|id_ed25519|id_ecdsa|id_dsa/i,
2731
2999
  // SSH private keys
2732
3000
  /\.pem$|\.key$|\.p12$|\.pfx$/i,
2733
3001
  // certificate files
2734
- /(?:^|\/)\.ssh\//i,
2735
- // ~/.ssh/ directory
2736
- /(?:^|\/)\.aws\/credentials/i,
2737
- // AWS credentials
3002
+ // The `$` half mirrors shell/index.ts's SENSITIVE_PATH_RULES: a file INSIDE
3003
+ // the directory counts wherever it appears, while the directory ITSELF counts
3004
+ // only when the path is ROOTED (`~/.ssh`, `/home/u/.ssh`) -- an unrooted
3005
+ // `config/.ssh` is more likely a search pattern than a read. These are
3006
+ // extracted TOKENS (see `args.some(isSensitivePath)` below), the same input
3007
+ // contract as the shell tier, so the same boundary is the right one.
3008
+ // Without it `grep -r x ~/.ssh | curl -d @-` scored one tier BELOW the
3009
+ // identical pipeline naming a file inside that directory.
3010
+ /(?:^|\/)\.ssh\/|^(?:[~/]|[A-Za-z]:).*\/\.ssh$/i,
3011
+ // ~/.ssh/ and ~/.ssh
3012
+ /(?:^|\/)\.aws\/credentials|^(?:[~/]|[A-Za-z]:).*\/\.aws$/i,
3013
+ // AWS creds + dir
2738
3014
  /(?:^|\/)\.netrc$/i,
2739
3015
  // netrc (stores HTTP credentials)
2740
3016
  /(?:^|\/)(passwd|shadow|sudoers)$/i,
@@ -2768,8 +3044,8 @@ function splitOnPipe(cmd) {
2768
3044
  if (current.trim()) segments2.push(current.trim());
2769
3045
  return segments2.filter(Boolean);
2770
3046
  }
2771
- function positionalTokens(segment) {
2772
- return segment.split(/\s+/).slice(1).filter((t) => !t.startsWith("-") && !t.startsWith("@") && t.length > 0);
3047
+ function positionalTokens(tokens) {
3048
+ return tokens.slice(1).filter((t) => !t.startsWith("-") && !t.startsWith("@") && t.length > 0);
2773
3049
  }
2774
3050
  function analyzePipeChain(command) {
2775
3051
  const segments2 = splitOnPipe(command);
@@ -2792,8 +3068,10 @@ function analyzePipeChain(command) {
2792
3068
  for (const segment of segments2) {
2793
3069
  const tokens = segment.split(/\s+/).filter(Boolean);
2794
3070
  if (tokens.length === 0) continue;
2795
- const binary = tokens[0].toLowerCase();
2796
- const args = positionalTokens(segment);
3071
+ const h = unwrapCommandHead(tokens);
3072
+ const head = h < tokens.length ? h : 0;
3073
+ const binary = tokens[head].toLowerCase();
3074
+ const args = positionalTokens(tokens.slice(head));
2797
3075
  if (SOURCE_COMMANDS.has(binary)) {
2798
3076
  sourceFiles.push(...args);
2799
3077
  if (args.some(isSensitivePath)) hasSensitiveSource = true;
@@ -3380,6 +3658,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
3380
3658
  const shellShaped = isBashTool(toolName) || inspectsShellCommand(toolName, config.policy.toolInspection);
3381
3659
  const shellShapedCommand = shellShaped ? isBashTool(toolName) && args && typeof args === "object" ? typeof args.command === "string" ? args.command : null : extractShellCommand(toolName, args, config.policy.toolInspection) : null;
3382
3660
  const bashCommand = agent !== "Terminal" ? shellShapedCommand : null;
3661
+ let pendingAstReview;
3383
3662
  if (bashCommand !== null) {
3384
3663
  const pipeVerdict = pipeChainVerdict(
3385
3664
  bashCommand,
@@ -3391,7 +3670,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
3391
3670
  if (fsVerdict) {
3392
3671
  const isShieldRule = fsVerdict.ruleName.startsWith("shield:");
3393
3672
  const labelPrefix = isShieldRule ? "project-jail (AST)" : "Node9 (AST)";
3394
- return {
3673
+ const astVerdict = {
3395
3674
  decision: fsVerdict.verdict,
3396
3675
  blockedByLabel: `${labelPrefix}: ${fsVerdict.ruleName}`,
3397
3676
  reason: fsVerdict.reason,
@@ -3399,6 +3678,8 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
3399
3678
  ruleName: fsVerdict.ruleName,
3400
3679
  ruleDescription: fsVerdict.reason
3401
3680
  };
3681
+ if (fsVerdict.verdict === "block") return astVerdict;
3682
+ pendingAstReview = astVerdict;
3402
3683
  }
3403
3684
  const sqlAction = resolveCheck(config.policy.commandChecks?.sqlDdl);
3404
3685
  const sqlVerdict = sqlAction === "off" ? null : analyzeSqlDestructive(bashCommand);
@@ -3441,7 +3722,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
3441
3722
  const matchedRule = resolvePinned(matches);
3442
3723
  if (matchedRule) {
3443
3724
  if (matchedRule.verdict === "allow")
3444
- return { decision: "allow", ruleName: matchedRule.name ?? matchedRule.tool };
3725
+ return pendingAstReview ?? { decision: "allow", ruleName: matchedRule.name ?? matchedRule.tool };
3445
3726
  return {
3446
3727
  decision: matchedRule.verdict,
3447
3728
  blockedByLabel: `Smart Rule: ${matchedRule.name ?? matchedRule.tool}`,
@@ -3468,6 +3749,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
3468
3749
  allTokens = analyzed.allTokens;
3469
3750
  pathTokens = analyzed.paths;
3470
3751
  const candidates = [];
3752
+ if (pendingAstReview) candidates.push(pendingAstReview);
3471
3753
  const evalVerdict = detectDangerousShellExec(shellCommand);
3472
3754
  if (evalVerdict === "block") {
3473
3755
  return {
@@ -4192,7 +4474,7 @@ var project_jail_default = {
4192
4474
  {
4193
4475
  field: "command",
4194
4476
  op: "matches",
4195
- 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[\\/\\\\]",
4477
+ 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[\\/\\\\]",
4196
4478
  flags: "i"
4197
4479
  }
4198
4480
  ],
@@ -4206,7 +4488,7 @@ var project_jail_default = {
4206
4488
  {
4207
4489
  field: "command",
4208
4490
  op: "matches",
4209
- 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[\\/\\\\]",
4491
+ 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[\\/\\\\]",
4210
4492
  flags: "i"
4211
4493
  }
4212
4494
  ],
@@ -4220,7 +4502,7 @@ var project_jail_default = {
4220
4502
  {
4221
4503
  field: "command",
4222
4504
  op: "matches",
4223
- 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|$|[;&|>)<])",
4505
+ 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|$|[;&|>)<])",
4224
4506
  flags: "i"
4225
4507
  }
4226
4508
  ],
@@ -4234,7 +4516,7 @@ var project_jail_default = {
4234
4516
  {
4235
4517
  field: "command",
4236
4518
  op: "matches",
4237
- 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)",
4519
+ 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)",
4238
4520
  flags: "i"
4239
4521
  }
4240
4522
  ],
@@ -4248,7 +4530,7 @@ var project_jail_default = {
4248
4530
  {
4249
4531
  field: "file_path",
4250
4532
  op: "matches",
4251
- value: "(^|[\\/\\\\])\\.ssh[\\/\\\\]",
4533
+ value: "([\\/\\\\]\\.ssh([\\/\\\\]|$)|^\\.ssh[\\/\\\\])",
4252
4534
  flags: "i"
4253
4535
  }
4254
4536
  ],
@@ -4262,7 +4544,7 @@ var project_jail_default = {
4262
4544
  {
4263
4545
  field: "file_path",
4264
4546
  op: "matches",
4265
- value: "(^|[\\/\\\\])\\.aws[\\/\\\\]",
4547
+ value: "([\\/\\\\]\\.aws([\\/\\\\]|$)|^\\.aws[\\/\\\\])",
4266
4548
  flags: "i"
4267
4549
  }
4268
4550
  ],
@@ -4276,7 +4558,7 @@ var project_jail_default = {
4276
4558
  {
4277
4559
  field: "file_path",
4278
4560
  op: "matches",
4279
- value: "(^|[\\/\\\\])\\.env(\\.(local|production|staging|development|production\\.local|staging\\.local|development\\.local))?$",
4561
+ value: "(^|[\\/\\\\])\\.env(?![\\w-])(?:[\\w.-]*\\.local$|(?!\\.(example|sample|template)\\b)(?!\\.test$)[\\w.-]*$)",
4280
4562
  flags: "i"
4281
4563
  }
4282
4564
  ],
@@ -7053,12 +7335,13 @@ function extractFilePaths(toolName, args) {
7053
7335
  }
7054
7336
  return paths.filter(Boolean);
7055
7337
  }
7338
+ var NETWORK_COMMAND_RE = new RegExp(`(?<![.\\w-])(${[...NET_BINARIES].join("|")})\\b`);
7056
7339
  function isNetworkTool(toolName, args) {
7057
7340
  const t = toolName.toLowerCase();
7058
7341
  if (t === "bash" || t === "shell" || t === "run_shell_command" || t === "terminal.execute") {
7059
7342
  const a = args;
7060
7343
  const cmd = typeof a?.command === "string" ? a.command : typeof a?.cmd === "string" ? a.cmd : "";
7061
- return /\b(curl|wget|scp|rsync|nc|ncat|netcat|ssh)\b/.test(cmd);
7344
+ return NETWORK_COMMAND_RE.test(cmd);
7062
7345
  }
7063
7346
  return false;
7064
7347
  }