@node9/policy-engine 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
@@ -36,6 +36,8 @@ __export(src_exports, {
36
36
  CANARY_MIN_LENGTH: () => CANARY_MIN_LENGTH,
37
37
  CANONICAL_EXTRACTOR_HASH: () => CANONICAL_EXTRACTOR_HASH,
38
38
  CANONICAL_EXTRACTOR_VERSION: () => CANONICAL_EXTRACTOR_VERSION,
39
+ COMMAND_WRAPPERS: () => COMMAND_WRAPPERS,
40
+ COPY_VERBS: () => COPY_VERBS,
39
41
  COST_PER_LOOP_ITER_USD: () => COST_PER_LOOP_ITER_USD,
40
42
  DEFAULT_EGRESS_ALLOWLIST: () => DEFAULT_EGRESS_ALLOWLIST,
41
43
  DESTINATION_ARGS: () => DESTINATION_ARGS,
@@ -49,6 +51,7 @@ __export(src_exports, {
49
51
  LONG_OUTPUT_THRESHOLD_BYTES: () => LONG_OUTPUT_THRESHOLD_BYTES,
50
52
  LOOP_MAX_RECORDS: () => LOOP_MAX_RECORDS,
51
53
  LOOP_THRESHOLD_FOR_WASTE: () => LOOP_THRESHOLD_FOR_WASTE,
54
+ NET_BINARIES: () => NET_BINARIES,
52
55
  PRIVILEGE_ESCALATION_RE: () => PRIVILEGE_ESCALATION_RE,
53
56
  REALTIME_PII_PATTERNS: () => REALTIME_PII_PATTERNS,
54
57
  SCAN_SIGNAL_WEIGHTS: () => SCAN_SIGNAL_WEIGHTS,
@@ -104,9 +107,11 @@ __export(src_exports, {
104
107
  normalizeIpLiteral: () => normalizeIpLiteral,
105
108
  parseAllSshHostsFromCommand: () => parseAllSshHostsFromCommand,
106
109
  parseDestHost: () => parseDestHost,
110
+ positionedArgs: () => positionedArgs,
107
111
  previewArgs: () => previewArgs,
108
112
  redactText: () => redactText,
109
113
  resolvePinned: () => resolvePinned,
114
+ sampleCopyCommand: () => sampleCopyCommand,
110
115
  scanArgs: () => scanArgs,
111
116
  scanInjection: () => scanInjection,
112
117
  scanText: () => scanText,
@@ -119,6 +124,7 @@ __export(src_exports, {
119
124
  toScanFinding: () => toScanFinding,
120
125
  toolMatchesRule: () => toolMatchesRule,
121
126
  truncateBlastPath: () => truncateBlastPath,
127
+ unwrapCommandHead: () => unwrapCommandHead,
122
128
  validateOverrides: () => validateOverrides,
123
129
  validateRegex: () => validateRegex,
124
130
  validateShieldDefinition: () => validateShieldDefinition
@@ -844,13 +850,32 @@ var DLP_PATTERNS_GLOBAL = DLP_PATTERNS.map(
844
850
  })
845
851
  );
846
852
  var SENSITIVE_PATH_PATTERNS = [
847
- /[/\\]\.ssh[/\\]/i,
848
- /[/\\]\.aws[/\\]/i,
853
+ /[/\\]\.ssh([/\\]|$)/i,
854
+ /[/\\]\.aws([/\\]|$)/i,
849
855
  /[/\\]\.config[/\\]gcloud[/\\]/i,
850
856
  /[/\\]\.azure[/\\]/i,
851
857
  /[/\\]\.kube[/\\]config$/i,
852
- /[/\\]\.env($|\.)/i,
853
- // .env, .env.local, .env.production — not .envoy
858
+ // ⚠️ ONE SEMANTIC, FOUR COPIES. This is the AST tier's `.env` rule verbatim
859
+ // (shell/index.ts SENSITIVE_PATH_RULES), whose reasoning is documented there:
860
+ // structural suffix chain rather than a hand-written list, `example|sample|
861
+ // template` exempt because a fixture stays a fixture whatever follows, and
862
+ // `.test` anchored because `test` names an ENVIRONMENT -- `.env.test` is the
863
+ // committed template, `.env.test.local` is gitignored and holds real values.
864
+ //
865
+ // It was previously `[/\\]\.env($|\.)` with NO exemptions, so `Read .env.example`
866
+ // blocked while `cat .env.example` allowed: the same file, opposite verdicts,
867
+ // decided only by which tool asked. See src/__tests__/jail-both-doors.test.ts,
868
+ // which is the contract that now holds these copies in step, and stage 5 of
869
+ // doc/credential-jail-architecture.md, which replaces them with one generated
870
+ // source.
871
+ // ⚠️ The `.local` branch comes FIRST and takes no exemption. A fixture stays a
872
+ // fixture whatever follows it -- `.env.example.md` is documentation -- but
873
+ // `.env.example.local` is gitignored by the `.env*.local` convention and holds
874
+ // real values, exactly the reasoning that anchors `(?!\.test$)` rather than
875
+ // using `\b`. Without this branch the fixture exemption also bought a two-step
876
+ // bypass: `cp .env .env.sample`, then read the copy.
877
+ /[/\\]\.env(?![\w-])(?:[\w.-]*\.local$|(?!\.(?:example|sample|template)\b)(?!\.test$)[\w.-]*$)/i,
878
+ // .env + any suffix chain; fixtures exempt unless .local
854
879
  /[/\\]\.git-credentials$/i,
855
880
  /[/\\]\.npmrc$/i,
856
881
  /[/\\]\.docker[/\\]config\.json$/i,
@@ -1409,6 +1434,10 @@ var FS_READ_TOOLS = /* @__PURE__ */ new Set([
1409
1434
  "od",
1410
1435
  "xxd",
1411
1436
  "hexdump",
1437
+ // Emits the file's bytes, re-encoded, so it is a read by the set's own test
1438
+ // ("does it emit file contents"). Absent until 2026-09-10, which is why
1439
+ // `base64 ~/.ssh/id_rsa` printed a private key with no verdict.
1440
+ "base64",
1412
1441
  "strings",
1413
1442
  "sort",
1414
1443
  "uniq",
@@ -1416,8 +1445,56 @@ var FS_READ_TOOLS = /* @__PURE__ */ new Set([
1416
1445
  "nl",
1417
1446
  "dd"
1418
1447
  ]);
1448
+ var SCP_VALUE_FLAGS = ["i", "F", "o", "c", "S", "P", "J", "D", "W", "l"];
1449
+ var RSYNC_SKIP = [
1450
+ "e",
1451
+ "--rsh",
1452
+ "--exclude",
1453
+ "--exclude-from",
1454
+ "--include",
1455
+ "--include-from",
1456
+ "--files-from",
1457
+ "f",
1458
+ "--filter"
1459
+ ];
1460
+ var COPY_VERBS = {
1461
+ cp: { source: "allButLast", targetDirFlag: true },
1462
+ mv: { source: "allButLast", targetDirFlag: true },
1463
+ install: { source: "allButLast", targetDirFlag: true },
1464
+ ln: { source: "first", targetDirFlag: true },
1465
+ scp: { source: "allButLast", skipFlags: SCP_VALUE_FLAGS },
1466
+ rsync: { source: "allButLast", skipFlags: RSYNC_SKIP },
1467
+ tar: {
1468
+ source: "archive",
1469
+ archive: "tar",
1470
+ skipFlags: ["f", "X", "T", "--file", "--exclude", "--exclude-from", "--files-from"]
1471
+ },
1472
+ zip: { source: "archive", archive: "zip", skipFlags: ["x", "i", "--exclude", "--include"] },
1473
+ ar: { source: "archive", archive: "ar" },
1474
+ "7z": { source: "archive", archive: "7z", skipFlags: ["x", "--exclude"] },
1475
+ gzip: { source: "all" },
1476
+ bzip2: { source: "all" },
1477
+ xz: { source: "all" },
1478
+ "docker cp": { source: "allButLast" },
1479
+ "kubectl cp": { source: "allButLast" },
1480
+ "gsutil cp": { source: "allButLast" },
1481
+ "gsutil rsync": { source: "allButLast" },
1482
+ "rclone copy": { source: "allButLast" },
1483
+ "rclone sync": { source: "allButLast" },
1484
+ "aws s3 cp": { source: "allButLast" },
1485
+ "aws s3 mv": { source: "allButLast" },
1486
+ "aws s3 sync": { source: "allButLast" },
1487
+ "gcloud storage cp": { source: "allButLast" },
1488
+ "az storage blob upload": { source: "flagOperand", sourceFlags: ["f", "--file"] }
1489
+ };
1490
+ var TAR_MODE_WORD = /^[a-zA-Z]+$/;
1491
+ var COPY_VERB_HEADS = new Set(Object.keys(COPY_VERBS).map((k) => k.split(" ")[0]));
1419
1492
  var FS_OP_PRESCREEN_RE = new RegExp(
1420
- `(?:^|[\\s|;&(\`\\n])(?:rm|${[...FS_READ_TOOLS].map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\b`
1493
+ // A quote is a separator too: `eval "cat X"` and `sh -c 'cat X'` put the
1494
+ // reader right after `"` / `'`, and without these two characters the
1495
+ // prescreen rejected every string-wrapped read before the parser ran.
1496
+ // Found 2026-09-11 by instrumenting the walk -- no CallExpr was ever visited.
1497
+ `(?:^|[\\s|;&("'\`\\n/])(?:rm|${[...FS_READ_TOOLS, ...COPY_VERB_HEADS].map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\b|(?<!<)<(?!<)`
1421
1498
  );
1422
1499
  var HOME_CACHE_ALLOWLIST = [
1423
1500
  ".cache",
@@ -1438,12 +1515,12 @@ var SENSITIVE_PATH_RULES = [
1438
1515
  {
1439
1516
  rule: "shield:project-jail:block-read-ssh",
1440
1517
  reason: "Reading SSH private keys is blocked by project-jail shield",
1441
- match: (p) => /(^|[\\/])\.ssh[\\/]/i.test(p)
1518
+ match: (p) => /([\\/]\.ssh[\\/]|^\.ssh[\\/]|^(?:[~/]|[A-Za-z]:).*[\\/]\.ssh$)/i.test(p)
1442
1519
  },
1443
1520
  {
1444
1521
  rule: "shield:project-jail:block-read-aws",
1445
1522
  reason: "Reading AWS credentials is blocked by project-jail shield",
1446
- match: (p) => /(^|[\\/])\.aws[\\/]/i.test(p)
1523
+ match: (p) => /([\\/]\.aws[\\/]|^\.aws[\\/]|^(?:[~/]|[A-Za-z]:).*[\\/]\.aws$)/i.test(p)
1447
1524
  },
1448
1525
  {
1449
1526
  // Mirrors the JSON shield's `.env` pattern (project-jail.json's
@@ -1487,7 +1564,9 @@ var SENSITIVE_PATH_RULES = [
1487
1564
  // symmetry — silently exempts every `.env.test.*` file.
1488
1565
  //
1489
1566
  // shields.test.ts:983-995 is the canonical contract; keep both in step.
1490
- match: (p) => /(?:^|[\\/])\.env(?![\w-])(?!\.(?:example|sample|template)\b)(?!\.test$)[\w.-]*$/i.test(p)
1567
+ match: (p) => /(?:^|[\\/])\.env(?![\w-])(?:[\w.-]*\.local$|(?!\.(?:example|sample|template)\b)(?!\.test$)[\w.-]*$)/i.test(
1568
+ p
1569
+ )
1491
1570
  },
1492
1571
  {
1493
1572
  // verdict: 'review' (not 'block') is a deliberate design choice
@@ -1698,15 +1777,14 @@ function listOps() {
1698
1777
  return _listOps;
1699
1778
  }
1700
1779
  var WRAPPER_TAKES_TARGET = /* @__PURE__ */ new Set(["chroot"]);
1780
+ var FIND_EXEC_FLAGS = /* @__PURE__ */ new Set(["-exec", "-execdir", "-ok", "-okdir"]);
1701
1781
  var INTERP_LEADING_TARGET = /* @__PURE__ */ new Set(["su"]);
1702
1782
  function unwrapCommandHead(words) {
1703
1783
  let i = 0;
1704
1784
  while (i < words.length) {
1705
1785
  const head = (words[i] ?? "").toLowerCase().split("/").pop() ?? "";
1706
1786
  if (head === "find") {
1707
- const x = words.findIndex(
1708
- (w, k) => k > i && (w === "-exec" || w === "-execdir" || w === "-ok")
1709
- );
1787
+ const x = words.findIndex((w, k) => k > i && w !== null && FIND_EXEC_FLAGS.has(w));
1710
1788
  if (x < 0) break;
1711
1789
  i = x + 1;
1712
1790
  continue;
@@ -1728,7 +1806,11 @@ function unwrapCommandHead(words) {
1728
1806
  if (t.startsWith("-")) {
1729
1807
  i++;
1730
1808
  const nxt = words[i];
1731
- if (nxt != null && !nxt.startsWith("-") && !INLINE_INTERPRETER.test(nxt.split("/").pop() ?? "") && !COMMAND_WRAPPERS.has(nxt.toLowerCase()) && !RUNNER_WRAPPERS.has(nxt.toLowerCase()))
1809
+ 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`,
1810
+ // `stdbuf -o0 cat X`, `ionice -c3 cat X`. Without this the head was
1811
+ // swallowed and the jail needed a looser fallback whose cost was a
1812
+ // false positive on `sudo echo cat X`.
1813
+ !FS_READ_TOOLS.has(nxt.split("/").pop()?.toLowerCase() ?? ""))
1732
1814
  i++;
1733
1815
  continue;
1734
1816
  }
@@ -1847,38 +1929,43 @@ function isProtectedHomePath(rawPath) {
1847
1929
  }
1848
1930
  return true;
1849
1931
  }
1850
- function extractLiteralArgs(callExpr) {
1851
- const args = callExpr.Args || [];
1852
- if (args.length === 0) return { name: "", flags: [], paths: [] };
1853
- const litFromWord = (w) => {
1854
- const parts = w?.Parts || [];
1855
- let s = "";
1856
- for (const p of parts) {
1857
- const t = syntax.NodeType(p);
1858
- if (t === "Lit") s += (p.Value ?? "").replace(/\\(.)/g, "$1");
1859
- else if (t === "SglQuoted") s += p.Value ?? "";
1860
- else if (t === "DblQuoted") {
1861
- const inner = p.Parts || [];
1862
- if (!inner.every((ip) => syntax.NodeType(ip) === "Lit")) return null;
1863
- s += inner.map((ip) => ip.Value ?? "").join("");
1864
- } else {
1865
- return null;
1866
- }
1932
+ function positionedArgs(words, from = 1, to = words.length) {
1933
+ const out = [];
1934
+ let afterFlag = null;
1935
+ for (let i = from; i < to; i++) {
1936
+ const v = words[i];
1937
+ if (v === null) {
1938
+ afterFlag = null;
1939
+ continue;
1867
1940
  }
1868
- return s;
1869
- };
1870
- const name = (litFromWord(args[0]) || "").toLowerCase();
1871
- const flags = [];
1872
- const paths = [];
1873
- for (let i = 1; i < args.length; i++) {
1874
- const v = litFromWord(args[i]);
1875
- if (v === null) continue;
1876
- if (v.startsWith("-")) flags.push(v);
1877
- else paths.push(v);
1941
+ if (v.startsWith("-")) {
1942
+ afterFlag = v;
1943
+ continue;
1944
+ }
1945
+ out.push({ value: v, index: out.length, argv: i, afterFlag });
1946
+ afterFlag = null;
1878
1947
  }
1879
- return { name, flags, paths };
1948
+ return out;
1880
1949
  }
1881
- var NET_BINARIES = /* @__PURE__ */ new Set(["curl", "wget", "scp", "ssh", "nc", "ncat", "netcat"]);
1950
+ function extractLiteralArgs(callExpr) {
1951
+ const rawArgs = callExpr.Args || [];
1952
+ if (rawArgs.length === 0) return { name: "", flags: [], paths: [], words: [], args: [] };
1953
+ const words = rawArgs.map((a) => resolveWordLiteral(a));
1954
+ const name = baseWord(words[0]);
1955
+ const flags = words.slice(1).filter((w) => w !== null && w.startsWith("-"));
1956
+ const args = positionedArgs(words);
1957
+ return { name, flags, paths: args.map((a) => a.value), words, args };
1958
+ }
1959
+ var NET_BINARIES = /* @__PURE__ */ new Set([
1960
+ "curl",
1961
+ "wget",
1962
+ "scp",
1963
+ "ssh",
1964
+ "nc",
1965
+ "ncat",
1966
+ "netcat",
1967
+ "rsync"
1968
+ ]);
1882
1969
  var VALUE_FLAGS = {
1883
1970
  curl: /* @__PURE__ */ new Set([
1884
1971
  "-d",
@@ -2163,6 +2250,9 @@ function deriveRedirOp(sample) {
2163
2250
  }
2164
2251
  }
2165
2252
  var REDIR_TRUNCATE_OPS = /* @__PURE__ */ new Set([deriveRedirOp(">_f")]);
2253
+ var REDIR_FILE_IN_OPS = new Set(
2254
+ [deriveRedirOp("cat < f"), deriveRedirOp("cat <> f")].filter((op) => op >= 0)
2255
+ );
2166
2256
  var REDIR_HEREDOC_OPS = /* @__PURE__ */ new Set([
2167
2257
  deriveRedirOp("cat <<X\nX"),
2168
2258
  deriveRedirOp("cat <<-X\nX")
@@ -2227,16 +2317,21 @@ function isRmCreatedInCommandCleanup(command) {
2227
2317
  }
2228
2318
  return sawRm && ok;
2229
2319
  }
2230
- function analyzeFsOperationImpl(command) {
2320
+ function analyzeFsOperationImpl(command, depth = 0) {
2231
2321
  const f = parseShared(command);
2232
2322
  if (f === PARSE_FAIL) return null;
2233
2323
  let result = null;
2234
2324
  try {
2235
2325
  syntax.Walk(f, (node) => {
2236
- if (!node || result) return false;
2326
+ if (!node || result?.verdict === "block") return false;
2237
2327
  const n = node;
2238
- if (syntax.NodeType(n) !== "CallExpr") return true;
2239
- const { name, flags, paths } = extractLiteralArgs(n);
2328
+ const nodeType = syntax.NodeType(n);
2329
+ if (nodeType === "Stmt") {
2330
+ result = stricter(result, jailedRedirectRead(n));
2331
+ return result?.verdict !== "block";
2332
+ }
2333
+ if (nodeType !== "CallExpr") return true;
2334
+ const { name, flags, paths, words } = extractLiteralArgs(n);
2240
2335
  if (!name) return true;
2241
2336
  if (name === "rm") {
2242
2337
  const flagStr = flags.join("").toLowerCase();
@@ -2265,21 +2360,27 @@ function analyzeFsOperationImpl(command) {
2265
2360
  }
2266
2361
  }
2267
2362
  }
2268
- if (FS_READ_TOOLS.has(name)) {
2269
- for (const p of paths) {
2270
- for (const sp of SENSITIVE_PATH_RULES) {
2271
- if (sp.match(p)) {
2272
- result = {
2273
- ruleName: sp.rule,
2274
- verdict: sp.verdict ?? "block",
2275
- reason: sp.reason,
2276
- path: p
2277
- };
2278
- return false;
2279
- }
2363
+ if (depth < 1) {
2364
+ const payload = literalShellPayload(words, name);
2365
+ if (payload !== null) {
2366
+ const inner = analyzeFsOperationImpl(payload, depth + 1);
2367
+ if (inner) {
2368
+ result = inner;
2369
+ return false;
2280
2370
  }
2371
+ return true;
2372
+ }
2373
+ }
2374
+ const readPaths = FS_READ_TOOLS.has(name) ? paths : wrappedReadPaths(words, name);
2375
+ if (readPaths) {
2376
+ for (const p of readPaths) {
2377
+ result = stricter(result, matchSensitivePath2(p));
2378
+ if (result?.verdict === "block") return false;
2281
2379
  }
2282
2380
  }
2381
+ for (const p of copySourcePaths(words)) {
2382
+ result = stricter(result, copyVerdictOf(matchSensitivePath2(p)));
2383
+ }
2283
2384
  return true;
2284
2385
  });
2285
2386
  return result;
@@ -2287,6 +2388,209 @@ function analyzeFsOperationImpl(command) {
2287
2388
  return null;
2288
2389
  }
2289
2390
  }
2391
+ function stricter(a, b) {
2392
+ if (!a) return b;
2393
+ if (!b) return a;
2394
+ return b.verdict === "block" && a.verdict !== "block" ? b : a;
2395
+ }
2396
+ function flagInfo(w) {
2397
+ if (w.startsWith("--")) {
2398
+ const eq = w.indexOf("=");
2399
+ return eq < 0 ? { letter: null, long: w, attached: null } : { letter: null, long: w.slice(0, eq), attached: w.slice(eq + 1) };
2400
+ }
2401
+ const m = /^-([a-zA-Z]+)(.*)$/.exec(w);
2402
+ if (!m) return { letter: null, long: null, attached: null };
2403
+ return { letter: m[1][m[1].length - 1], long: null, attached: m[2] === "" ? null : m[2] };
2404
+ }
2405
+ function flagIs(w, names) {
2406
+ if (w === null) return false;
2407
+ const f = flagInfo(w);
2408
+ return names.some((n) => n.startsWith("--") ? f.long === n : f.letter === n);
2409
+ }
2410
+ function operandOf(a, names) {
2411
+ if (!names || a.afterFlag === null) return false;
2412
+ return flagIs(a.afterFlag, names) && flagInfo(a.afterFlag).attached === null;
2413
+ }
2414
+ function resolveCopyShape(words, h) {
2415
+ const verb = baseWord(words[h]);
2416
+ if (!verb) return null;
2417
+ const direct = COPY_VERBS[verb];
2418
+ if (direct) return { shape: direct, last: h };
2419
+ const slots = positionedArgs(words, h + 1);
2420
+ for (let i = 0; i < slots.length; i++) {
2421
+ for (let n = 3; n >= 1; n--) {
2422
+ const part = slots.slice(i, i + n);
2423
+ if (part.length < n) continue;
2424
+ const key = [verb, ...part.map((a) => a.value.toLowerCase())].join(" ");
2425
+ const shape = COPY_VERBS[key];
2426
+ if (shape) return { shape, last: part[n - 1].argv };
2427
+ }
2428
+ if (slots[i].afterFlag === null) return null;
2429
+ }
2430
+ return null;
2431
+ }
2432
+ var FIND_OPTIONS = /* @__PURE__ */ new Set(["-H", "-L", "-P"]);
2433
+ function findStartPoints(words, h) {
2434
+ const k = words.findIndex((w, i) => i > h && w !== null && FIND_EXEC_FLAGS.has(w));
2435
+ if (k < 0) return { k, starts: [] };
2436
+ const firstPredicate = words.findIndex(
2437
+ (w, i) => i > h && w !== null && w.startsWith("-") && !FIND_OPTIONS.has(w)
2438
+ );
2439
+ const end = firstPredicate > h ? firstPredicate : k;
2440
+ return { k, starts: positionalAfter(words, h + 1, end) };
2441
+ }
2442
+ function copySourcePaths(words) {
2443
+ const h = unwrapCommandHead(words);
2444
+ const fi = words.findIndex((w, i) => i <= h && baseWord(w) === "find");
2445
+ if (fi >= 0) {
2446
+ const { k, starts } = findStartPoints(words, fi);
2447
+ if (k < 0) return [];
2448
+ const action = unwrapCommandHead(words.slice(k + 1));
2449
+ return resolveCopyShape(words.slice(k + 1), action) ? starts : [];
2450
+ }
2451
+ if (!COPY_VERB_HEADS.has(baseWord(words[h]))) return [];
2452
+ const r = resolveCopyShape(words, h);
2453
+ if (!r) return [];
2454
+ const { shape, last } = r;
2455
+ const args = positionedArgs(words, last + 1);
2456
+ const tail = words.slice(last + 1);
2457
+ const skipped = (a) => operandOf(a, shape.skipFlags);
2458
+ const targetDir = shape.targetDirFlag === true && tail.some((w) => w !== null && w.startsWith("-") && flagIs(w, ["t", "--target-directory"]));
2459
+ const targetOperand = (a) => targetDir && operandOf(a, ["t", "--target-directory"]);
2460
+ const lastOperand = [...tail].reverse().find((w) => w === null || !w.startsWith("-"));
2461
+ const dynamicDest = lastOperand === null;
2462
+ const destIsLastOperand = (shape.source === "allButLast" || shape.source === "first") && !targetDir && !dynamicDest;
2463
+ if (destIsLastOperand && typeof lastOperand === "string" && matchSensitivePath2(lastOperand))
2464
+ return [];
2465
+ let src;
2466
+ switch (shape.source) {
2467
+ case "all":
2468
+ src = args;
2469
+ break;
2470
+ case "first":
2471
+ src = targetDir ? args : args.slice(0, 1);
2472
+ break;
2473
+ case "flagOperand": {
2474
+ const inline = tail.filter((w) => w !== null && w.startsWith("--")).map((w) => flagInfo(w)).filter((f) => f.attached !== null && (shape.sourceFlags ?? []).includes(f.long ?? "")).map((f) => f.attached);
2475
+ return [
2476
+ ...args.filter(
2477
+ (a) => flagIs(a.afterFlag, shape.sourceFlags ?? []) && flagInfo(a.afterFlag).attached === null
2478
+ ).map((a) => a.value),
2479
+ ...inline
2480
+ ];
2481
+ }
2482
+ case "archive":
2483
+ src = archiveInputs(shape.archive, args, tail);
2484
+ break;
2485
+ case "allButLast":
2486
+ src = targetDir || dynamicDest ? args : args.slice(0, -1);
2487
+ break;
2488
+ }
2489
+ return src.filter((a) => !skipped(a) && !targetOperand(a)).map((a) => a.value);
2490
+ }
2491
+ function archiveInputs(kind, args, tail) {
2492
+ const first = args[0];
2493
+ const bareKey = first && first.afterFlag === null && TAR_MODE_WORD.test(first.value);
2494
+ if (kind === "tar") {
2495
+ const flagsText = tail.filter((w) => w !== null && w.startsWith("-")).join(" ");
2496
+ const mode = (bareKey ? first.value : "") + flagsText;
2497
+ const extracting = /x|t/.test(bareKey ? first.value.replace(/f/g, "") : "") || /(^|\s)-[a-zA-Z]*[xt]|--extract|--list|--get/.test(flagsText);
2498
+ const writing = /[cruA]/.test(bareKey ? first.value : "") || /(^|\s)-[a-zA-Z]*[cruA]|--create|--append|--update|--concatenate/.test(flagsText);
2499
+ if (extracting && !writing) return [];
2500
+ void mode;
2501
+ let i = 0;
2502
+ if (bareKey) {
2503
+ i = 1;
2504
+ const next = args[1];
2505
+ if (first.value.includes("f") && next && next.afterFlag === null) i = 2;
2506
+ }
2507
+ return args.slice(i);
2508
+ }
2509
+ if (kind === "zip") return first && first.afterFlag === "-" ? args : args.slice(1);
2510
+ if (kind === "ar") return bareKey ? args.slice(2) : args.slice(1);
2511
+ return args.slice(2);
2512
+ }
2513
+ function sampleCopyCommand(verb, src) {
2514
+ const shape = COPY_VERBS[verb];
2515
+ if (!shape) throw new Error(`not a copy verb: ${verb}`);
2516
+ const remote = /^(scp|rsync|aws |gsutil|gcloud|rclone|docker|kubectl)/.test(verb);
2517
+ switch (shape.source) {
2518
+ case "first":
2519
+ return `${verb} -s ${src} /tmp/n9-link`;
2520
+ case "all":
2521
+ return `${verb} -c ${src} > /tmp/n9-out`;
2522
+ case "flagOperand":
2523
+ return `${verb} -f ${src} -c n9`;
2524
+ case "archive":
2525
+ return shape.archive === "tar" ? `tar czf /tmp/n9-out.tgz ${src}` : shape.archive === "zip" ? `zip -r /tmp/n9-out.zip ${src}` : shape.archive === "ar" ? `ar rc /tmp/n9-out.a ${src}` : `7z a /tmp/n9-out.7z ${src}`;
2526
+ case "allButLast":
2527
+ return `${verb} ${src} ${remote ? verb.startsWith("scp") || verb === "rsync" ? "user@host.invalid:/tmp/" : verb.startsWith("docker") || verb.startsWith("kubectl") ? "ctr:/tmp/" : "remote:/n9/" : "/tmp/n9-copy"}`;
2528
+ }
2529
+ }
2530
+ var COPY_RULE_OF = {
2531
+ "shield:project-jail:block-read-ssh": "shield:project-jail:review-copy-ssh",
2532
+ "shield:project-jail:block-read-aws": "shield:project-jail:review-copy-aws",
2533
+ "shield:project-jail:block-read-env": "shield:project-jail:review-copy-env",
2534
+ "shield:project-jail:review-read-credentials": "shield:project-jail:review-copy-credentials"
2535
+ };
2536
+ function copyVerdictOf(hit) {
2537
+ if (!hit) return null;
2538
+ const ruleName = COPY_RULE_OF[hit.ruleName];
2539
+ if (!ruleName) return null;
2540
+ return {
2541
+ ruleName,
2542
+ verdict: "review",
2543
+ reason: `Copying ${hit.path} moves a credential out of its jail (project-jail shield)`,
2544
+ path: hit.path
2545
+ };
2546
+ }
2547
+ function matchSensitivePath2(p) {
2548
+ for (const sp of SENSITIVE_PATH_RULES) {
2549
+ if (sp.match(p))
2550
+ return { ruleName: sp.rule, verdict: sp.verdict ?? "block", reason: sp.reason, path: p };
2551
+ }
2552
+ return null;
2553
+ }
2554
+ function baseWord(w) {
2555
+ return (w ?? "").split("/").pop()?.toLowerCase() ?? "";
2556
+ }
2557
+ var isReaderWord = (w) => w !== null && FS_READ_TOOLS.has(baseWord(w));
2558
+ var positionalAfter = (words, from, to = words.length) => positionedArgs(words, from, to).map((a) => a.value);
2559
+ function wrappedReadPaths(words, name) {
2560
+ if (name === "find") {
2561
+ const { k, starts } = findStartPoints(words, 0);
2562
+ return k > 0 && isReaderWord(words[k + 1] ?? null) ? starts : null;
2563
+ }
2564
+ if (!COMMAND_WRAPPERS.has(name) && !RUNNER_WRAPPERS.has(name)) return null;
2565
+ const h = unwrapCommandHead(words);
2566
+ return h > 0 && isReaderWord(words[h] ?? null) ? positionalAfter(words, h + 1) : null;
2567
+ }
2568
+ function literalShellPayload(words, name) {
2569
+ const h = COMMAND_WRAPPERS.has(name) || RUNNER_WRAPPERS.has(name) ? unwrapCommandHead(words) : 0;
2570
+ const head = (words[h] ?? "").split("/").pop()?.toLowerCase() ?? "";
2571
+ if (head === "eval") {
2572
+ const rest = words.slice(h + 1);
2573
+ if (rest.length === 0 || rest.some((w) => w === null)) return null;
2574
+ return rest.join(" ");
2575
+ }
2576
+ if (SHELL_INTERPRETERS.has(head)) {
2577
+ const c = words.findIndex((w, i) => i > h && w !== null && isInlineCodeFlag(head, w));
2578
+ if (c < 0) return null;
2579
+ return words[c + 1] ?? null;
2580
+ }
2581
+ return null;
2582
+ }
2583
+ function jailedRedirectRead(stmt) {
2584
+ const redirs = stmt.Redirs || [];
2585
+ for (const r of redirs) {
2586
+ if (!r || !REDIR_FILE_IN_OPS.has(r.Op)) continue;
2587
+ const p = resolveWordLiteral(r.Word);
2588
+ if (p === null || p === "") continue;
2589
+ const hit = matchSensitivePath2(p);
2590
+ if (hit) return hit;
2591
+ }
2592
+ return null;
2593
+ }
2290
2594
  function analyzeShellCommand(command) {
2291
2595
  const actions = [];
2292
2596
  const paths = [];
@@ -2423,21 +2727,7 @@ function evaluateEgress(dests, policy) {
2423
2727
  }
2424
2728
 
2425
2729
  // src/policy/pipe-chain.ts
2426
- var SOURCE_COMMANDS = /* @__PURE__ */ new Set([
2427
- "cat",
2428
- "head",
2429
- "tail",
2430
- "grep",
2431
- "awk",
2432
- "sed",
2433
- "cut",
2434
- "sort",
2435
- "tee",
2436
- "less",
2437
- "more",
2438
- "strings",
2439
- "xxd"
2440
- ]);
2730
+ var SOURCE_COMMANDS = /* @__PURE__ */ new Set([...FS_READ_TOOLS, "tee"]);
2441
2731
  var SINK_COMMANDS = /* @__PURE__ */ new Set([
2442
2732
  "curl",
2443
2733
  "wget",
@@ -2468,16 +2758,25 @@ var OBFUSCATORS = /* @__PURE__ */ new Set([
2468
2758
  "node"
2469
2759
  ]);
2470
2760
  var SENSITIVE_PATTERNS = [
2471
- /(?:^|\/)\.env(?:\.|$)/i,
2472
- // .env, .env.local, .env.production
2761
+ // Kept in step with the AST tier and dlp/ -- see jail-both-doors.test.ts.
2762
+ /(?:^|\/)\.env(?![\w-])(?:[\w.-]*\.local$|(?!\.(?:example|sample|template)\b)(?!\.test$)[\w.-]*$)/i,
2763
+ // .env chain; fixtures exempt unless .local
2473
2764
  /id_rsa|id_ed25519|id_ecdsa|id_dsa/i,
2474
2765
  // SSH private keys
2475
2766
  /\.pem$|\.key$|\.p12$|\.pfx$/i,
2476
2767
  // certificate files
2477
- /(?:^|\/)\.ssh\//i,
2478
- // ~/.ssh/ directory
2479
- /(?:^|\/)\.aws\/credentials/i,
2480
- // AWS credentials
2768
+ // The `$` half mirrors shell/index.ts's SENSITIVE_PATH_RULES: a file INSIDE
2769
+ // the directory counts wherever it appears, while the directory ITSELF counts
2770
+ // only when the path is ROOTED (`~/.ssh`, `/home/u/.ssh`) -- an unrooted
2771
+ // `config/.ssh` is more likely a search pattern than a read. These are
2772
+ // extracted TOKENS (see `args.some(isSensitivePath)` below), the same input
2773
+ // contract as the shell tier, so the same boundary is the right one.
2774
+ // Without it `grep -r x ~/.ssh | curl -d @-` scored one tier BELOW the
2775
+ // identical pipeline naming a file inside that directory.
2776
+ /(?:^|\/)\.ssh\/|^(?:[~/]|[A-Za-z]:).*\/\.ssh$/i,
2777
+ // ~/.ssh/ and ~/.ssh
2778
+ /(?:^|\/)\.aws\/credentials|^(?:[~/]|[A-Za-z]:).*\/\.aws$/i,
2779
+ // AWS creds + dir
2481
2780
  /(?:^|\/)\.netrc$/i,
2482
2781
  // netrc (stores HTTP credentials)
2483
2782
  /(?:^|\/)(passwd|shadow|sudoers)$/i,
@@ -2511,8 +2810,8 @@ function splitOnPipe(cmd) {
2511
2810
  if (current.trim()) segments2.push(current.trim());
2512
2811
  return segments2.filter(Boolean);
2513
2812
  }
2514
- function positionalTokens(segment) {
2515
- return segment.split(/\s+/).slice(1).filter((t) => !t.startsWith("-") && !t.startsWith("@") && t.length > 0);
2813
+ function positionalTokens(tokens) {
2814
+ return tokens.slice(1).filter((t) => !t.startsWith("-") && !t.startsWith("@") && t.length > 0);
2516
2815
  }
2517
2816
  function analyzePipeChain(command) {
2518
2817
  const segments2 = splitOnPipe(command);
@@ -2535,8 +2834,10 @@ function analyzePipeChain(command) {
2535
2834
  for (const segment of segments2) {
2536
2835
  const tokens = segment.split(/\s+/).filter(Boolean);
2537
2836
  if (tokens.length === 0) continue;
2538
- const binary = tokens[0].toLowerCase();
2539
- const args = positionalTokens(segment);
2837
+ const h = unwrapCommandHead(tokens);
2838
+ const head = h < tokens.length ? h : 0;
2839
+ const binary = tokens[head].toLowerCase();
2840
+ const args = positionalTokens(tokens.slice(head));
2540
2841
  if (SOURCE_COMMANDS.has(binary)) {
2541
2842
  sourceFiles.push(...args);
2542
2843
  if (args.some(isSensitivePath)) hasSensitiveSource = true;
@@ -3146,6 +3447,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
3146
3447
  const shellShaped = isBashTool(toolName) || inspectsShellCommand(toolName, config.policy.toolInspection);
3147
3448
  const shellShapedCommand = shellShaped ? isBashTool(toolName) && args && typeof args === "object" ? typeof args.command === "string" ? args.command : null : extractShellCommand(toolName, args, config.policy.toolInspection) : null;
3148
3449
  const bashCommand = agent !== "Terminal" ? shellShapedCommand : null;
3450
+ let pendingAstReview;
3149
3451
  if (bashCommand !== null) {
3150
3452
  const pipeVerdict = pipeChainVerdict(
3151
3453
  bashCommand,
@@ -3157,7 +3459,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
3157
3459
  if (fsVerdict) {
3158
3460
  const isShieldRule = fsVerdict.ruleName.startsWith("shield:");
3159
3461
  const labelPrefix = isShieldRule ? "project-jail (AST)" : "Node9 (AST)";
3160
- return {
3462
+ const astVerdict = {
3161
3463
  decision: fsVerdict.verdict,
3162
3464
  blockedByLabel: `${labelPrefix}: ${fsVerdict.ruleName}`,
3163
3465
  reason: fsVerdict.reason,
@@ -3165,6 +3467,8 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
3165
3467
  ruleName: fsVerdict.ruleName,
3166
3468
  ruleDescription: fsVerdict.reason
3167
3469
  };
3470
+ if (fsVerdict.verdict === "block") return astVerdict;
3471
+ pendingAstReview = astVerdict;
3168
3472
  }
3169
3473
  const sqlAction = resolveCheck(config.policy.commandChecks?.sqlDdl);
3170
3474
  const sqlVerdict = sqlAction === "off" ? null : analyzeSqlDestructive(bashCommand);
@@ -3207,7 +3511,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
3207
3511
  const matchedRule = resolvePinned(matches);
3208
3512
  if (matchedRule) {
3209
3513
  if (matchedRule.verdict === "allow")
3210
- return { decision: "allow", ruleName: matchedRule.name ?? matchedRule.tool };
3514
+ return pendingAstReview ?? { decision: "allow", ruleName: matchedRule.name ?? matchedRule.tool };
3211
3515
  return {
3212
3516
  decision: matchedRule.verdict,
3213
3517
  blockedByLabel: `Smart Rule: ${matchedRule.name ?? matchedRule.tool}`,
@@ -3234,6 +3538,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
3234
3538
  allTokens = analyzed.allTokens;
3235
3539
  pathTokens = analyzed.paths;
3236
3540
  const candidates = [];
3541
+ if (pendingAstReview) candidates.push(pendingAstReview);
3237
3542
  const evalVerdict = detectDangerousShellExec(shellCommand);
3238
3543
  if (evalVerdict === "block") {
3239
3544
  return {
@@ -3979,7 +4284,7 @@ var project_jail_default = {
3979
4284
  {
3980
4285
  field: "command",
3981
4286
  op: "matches",
3982
- 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[\\/\\\\]",
4287
+ 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[\\/\\\\]",
3983
4288
  flags: "i"
3984
4289
  }
3985
4290
  ],
@@ -3993,7 +4298,7 @@ var project_jail_default = {
3993
4298
  {
3994
4299
  field: "command",
3995
4300
  op: "matches",
3996
- 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[\\/\\\\]",
4301
+ 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[\\/\\\\]",
3997
4302
  flags: "i"
3998
4303
  }
3999
4304
  ],
@@ -4007,7 +4312,7 @@ var project_jail_default = {
4007
4312
  {
4008
4313
  field: "command",
4009
4314
  op: "matches",
4010
- 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|$|[;&|>)<])",
4315
+ 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|$|[;&|>)<])",
4011
4316
  flags: "i"
4012
4317
  }
4013
4318
  ],
@@ -4021,7 +4326,7 @@ var project_jail_default = {
4021
4326
  {
4022
4327
  field: "command",
4023
4328
  op: "matches",
4024
- 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)",
4329
+ 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)",
4025
4330
  flags: "i"
4026
4331
  }
4027
4332
  ],
@@ -4035,7 +4340,7 @@ var project_jail_default = {
4035
4340
  {
4036
4341
  field: "file_path",
4037
4342
  op: "matches",
4038
- value: "(^|[\\/\\\\])\\.ssh[\\/\\\\]",
4343
+ value: "([\\/\\\\]\\.ssh([\\/\\\\]|$)|^\\.ssh[\\/\\\\])",
4039
4344
  flags: "i"
4040
4345
  }
4041
4346
  ],
@@ -4049,7 +4354,7 @@ var project_jail_default = {
4049
4354
  {
4050
4355
  field: "file_path",
4051
4356
  op: "matches",
4052
- value: "(^|[\\/\\\\])\\.aws[\\/\\\\]",
4357
+ value: "([\\/\\\\]\\.aws([\\/\\\\]|$)|^\\.aws[\\/\\\\])",
4053
4358
  flags: "i"
4054
4359
  }
4055
4360
  ],
@@ -4063,7 +4368,7 @@ var project_jail_default = {
4063
4368
  {
4064
4369
  field: "file_path",
4065
4370
  op: "matches",
4066
- value: "(^|[\\/\\\\])\\.env(\\.(local|production|staging|development|production\\.local|staging\\.local|development\\.local))?$",
4371
+ value: "(^|[\\/\\\\])\\.env(?![\\w-])(?:[\\w.-]*\\.local$|(?!\\.(example|sample|template)\\b)(?!\\.test$)[\\w.-]*$)",
4067
4372
  flags: "i"
4068
4373
  }
4069
4374
  ],
@@ -4340,6 +4645,12 @@ function classifyRuleSeverity(name, verdict) {
4340
4645
  "read-ssh",
4341
4646
  "read-gcp",
4342
4647
  "read-cred",
4648
+ // Stage 4 (2026-09-11): a copy of a credential file scores like a READ of it --
4649
+ // read-ssh/aws/cred are critical, so copy-ssh/aws/cred are; read-env is high
4650
+ // (below), so copy-env joins the high list, not this one (/code-review).
4651
+ "copy-ssh",
4652
+ "copy-aws",
4653
+ "copy-cred",
4343
4654
  "delete-repo",
4344
4655
  "helm-uninstall",
4345
4656
  "drop-table",
@@ -4352,6 +4663,7 @@ function classifyRuleSeverity(name, verdict) {
4352
4663
  ];
4353
4664
  if (criticalPatterns.some((p) => n.includes(p))) return "critical";
4354
4665
  const highPatterns = [
4666
+ "copy-env",
4355
4667
  "force-push",
4356
4668
  "force_push",
4357
4669
  "git-destructive",
@@ -4369,6 +4681,11 @@ function narrativeRuleLabel(name) {
4369
4681
  const map = {
4370
4682
  "read-aws": "AWS credentials read",
4371
4683
  "read-ssh": "SSH private key read",
4684
+ // Stage 4 copy twins, so `scan --narrative` prints a label, not a raw slug.
4685
+ "copy-ssh": "SSH private key copied out",
4686
+ "copy-aws": "AWS credentials copied out",
4687
+ "copy-env": ".env file copied out",
4688
+ "copy-cred": "credential file copied out",
4372
4689
  "read-gcp": "GCP credentials read",
4373
4690
  "read-cred": "credential file read",
4374
4691
  "delete-repo": "GitHub repository deletion",
@@ -4506,7 +4823,7 @@ function summarizeBlast(result, opts = {}) {
4506
4823
  // src/scan/destructive-regex.ts
4507
4824
  var DESTRUCTIVE_OP_RE = /\brm\s+-[rRf]+\b|\bDROP\s+(TABLE|DATABASE|COLLECTION|SCHEMA)\b|\bTRUNCATE\s+TABLE\b|\bgit\s+push\s+(--force|-f)\b|\bFLUSHALL\b|\bFLUSHDB\b|\bkubectl\s+delete\b|\bhelm\s+uninstall\b/i;
4508
4825
  var PRIVILEGE_ESCALATION_RE = /\bchmod\s+(0?777|\+x)\b|\bchown\s+root\b/i;
4509
- var SENSITIVE_PATH_RE = /\.aws\/(credentials|config)\b|\.ssh\/(id_rsa|id_ed25519|id_ecdsa|id_dsa)\b|\.env(\.|$|\b)|\.config\/gcloud\/credentials\.db\b|\.docker\/config\.json\b|\.netrc\b|\.npmrc\b|\.node9\/credentials\.json\b/i;
4826
+ var SENSITIVE_PATH_RE = /[\\/]\.aws(?:[\\/]|$)|^\.aws[\\/]|[\\/]\.ssh(?:[\\/]|$)|^\.ssh[\\/]|(?:^|[\\/])\.env(?![\w-])(?:[\w.-]*\.local$|(?!\.(?:example|sample|template)\b)(?!\.test$)[\w.-]*$)|\.config\/gcloud\/credentials\.db\b|\.docker\/config\.json\b|\.netrc\b|\.npmrc\b|\.node9\/credentials\.json\b/i;
4510
4827
  var FILE_TOOLS = /* @__PURE__ */ new Set([
4511
4828
  "read",
4512
4829
  "read_file",
@@ -4766,8 +5083,8 @@ function matchCanaryArgs(args, values) {
4766
5083
 
4767
5084
  // src/scan/canonical.ts
4768
5085
  var LONG_OUTPUT_THRESHOLD_BYTES = 100 * 1024;
4769
- var CANONICAL_EXTRACTOR_VERSION = "canonical-v10";
4770
- var CANONICAL_EXTRACTOR_HASH = "c4edee9dc99eb69a";
5086
+ var CANONICAL_EXTRACTOR_VERSION = "canonical-v14";
5087
+ var CANONICAL_EXTRACTOR_HASH = "8b5729fe236a195b";
4771
5088
  var DEDUPE_PREVIEW_LEN = 120;
4772
5089
  function extractCanonicalFindings(call, ctx) {
4773
5090
  const out = [];
@@ -5143,6 +5460,8 @@ var ENGINE_VERSION = "1.4.0";
5143
5460
  CANARY_MIN_LENGTH,
5144
5461
  CANONICAL_EXTRACTOR_HASH,
5145
5462
  CANONICAL_EXTRACTOR_VERSION,
5463
+ COMMAND_WRAPPERS,
5464
+ COPY_VERBS,
5146
5465
  COST_PER_LOOP_ITER_USD,
5147
5466
  DEFAULT_EGRESS_ALLOWLIST,
5148
5467
  DESTINATION_ARGS,
@@ -5156,6 +5475,7 @@ var ENGINE_VERSION = "1.4.0";
5156
5475
  LONG_OUTPUT_THRESHOLD_BYTES,
5157
5476
  LOOP_MAX_RECORDS,
5158
5477
  LOOP_THRESHOLD_FOR_WASTE,
5478
+ NET_BINARIES,
5159
5479
  PRIVILEGE_ESCALATION_RE,
5160
5480
  REALTIME_PII_PATTERNS,
5161
5481
  SCAN_SIGNAL_WEIGHTS,
@@ -5211,9 +5531,11 @@ var ENGINE_VERSION = "1.4.0";
5211
5531
  normalizeIpLiteral,
5212
5532
  parseAllSshHostsFromCommand,
5213
5533
  parseDestHost,
5534
+ positionedArgs,
5214
5535
  previewArgs,
5215
5536
  redactText,
5216
5537
  resolvePinned,
5538
+ sampleCopyCommand,
5217
5539
  scanArgs,
5218
5540
  scanInjection,
5219
5541
  scanText,
@@ -5226,6 +5548,7 @@ var ENGINE_VERSION = "1.4.0";
5226
5548
  toScanFinding,
5227
5549
  toolMatchesRule,
5228
5550
  truncateBlastPath,
5551
+ unwrapCommandHead,
5229
5552
  validateOverrides,
5230
5553
  validateRegex,
5231
5554
  validateShieldDefinition