@node9/policy-engine 1.67.1 → 1.67.3

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
@@ -66,6 +66,7 @@ __export(src_exports, {
66
66
  detectArgsPii: () => detectArgsPii,
67
67
  detectDangerousEval: () => detectDangerousEval,
68
68
  detectDangerousShellExec: () => detectDangerousShellExec,
69
+ detectInlineExec: () => detectInlineExec,
69
70
  detectPii: () => detectPii,
70
71
  evaluateEgress: () => evaluateEgress,
71
72
  evaluateLoopWindow: () => evaluateLoopWindow,
@@ -84,6 +85,7 @@ __export(src_exports, {
84
85
  isIgnoredTool: () => isIgnoredTool,
85
86
  isPrivateHost: () => isPrivateHost,
86
87
  isProtectedHomePath: () => isProtectedHomePath,
88
+ isShellShapedTool: () => isShellShapedTool,
87
89
  isShieldVerdict: () => isShieldVerdict,
88
90
  matchSensitivePath: () => matchSensitivePath,
89
91
  matchesPattern: () => matchesPattern,
@@ -101,6 +103,7 @@ __export(src_exports, {
101
103
  summarizeBlast: () => summarizeBlast,
102
104
  summarizeScan: () => summarizeScan,
103
105
  toScanFinding: () => toScanFinding,
106
+ toolMatchesRule: () => toolMatchesRule,
104
107
  truncateBlastPath: () => truncateBlastPath,
105
108
  validateOverrides: () => validateOverrides,
106
109
  validateRegex: () => validateRegex,
@@ -806,6 +809,139 @@ function redactText(text) {
806
809
 
807
810
  // src/shell/index.ts
808
811
  var import_mvdan_sh = __toESM(require("mvdan-sh"));
812
+
813
+ // src/rules/index.ts
814
+ var import_picomatch = __toESM(require("picomatch"));
815
+
816
+ // src/utils/regex.ts
817
+ var import_safe_regex22 = __toESM(require("safe-regex2"));
818
+ var MAX_REGEX_LENGTH = 256;
819
+ var REGEX_CACHE_MAX = 500;
820
+ var regexCache = /* @__PURE__ */ new Map();
821
+ function validateRegex(pattern) {
822
+ if (!pattern) return "Pattern is required";
823
+ if (pattern.length > MAX_REGEX_LENGTH) return `Pattern exceeds max length of ${MAX_REGEX_LENGTH}`;
824
+ try {
825
+ new RegExp(pattern);
826
+ } catch (e) {
827
+ return `Invalid regex syntax: ${e.message}`;
828
+ }
829
+ if (/\\\d+[*+{]/.test(pattern)) return "Quantified backreferences are forbidden (ReDoS risk)";
830
+ if (!(0, import_safe_regex22.default)(pattern)) return "Pattern rejected: potential ReDoS vulnerability detected";
831
+ return null;
832
+ }
833
+ function getCompiledRegex(pattern, flags = "") {
834
+ if (flags && !/^[gimsuy]+$/.test(flags)) return null;
835
+ const key = `${pattern}\0${flags}`;
836
+ if (regexCache.has(key)) {
837
+ const cached = regexCache.get(key);
838
+ regexCache.delete(key);
839
+ regexCache.set(key, cached);
840
+ return cached;
841
+ }
842
+ if (validateRegex(pattern) !== null) return null;
843
+ try {
844
+ const re = new RegExp(pattern, flags);
845
+ if (regexCache.size >= REGEX_CACHE_MAX) {
846
+ const oldest = regexCache.keys().next().value;
847
+ if (oldest) regexCache.delete(oldest);
848
+ }
849
+ regexCache.set(key, re);
850
+ return re;
851
+ } catch {
852
+ return null;
853
+ }
854
+ }
855
+
856
+ // src/rules/index.ts
857
+ function matchesPattern(text, patterns) {
858
+ const p = Array.isArray(patterns) ? patterns : [patterns];
859
+ if (p.length === 0) return false;
860
+ const isMatch = (0, import_picomatch.default)(p, { nocase: true, dot: true });
861
+ const target = text.toLowerCase();
862
+ const directMatch = isMatch(target);
863
+ if (directMatch) return true;
864
+ const withoutDotSlash = text.replace(/^\.\//, "");
865
+ return isMatch(withoutDotSlash) || isMatch(`./${withoutDotSlash}`);
866
+ }
867
+ var FORBIDDEN_PATH_SEGMENTS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
868
+ function getNestedValue(obj, path) {
869
+ if (!obj || typeof obj !== "object") return null;
870
+ const segments = path.split(".");
871
+ for (const seg of segments) {
872
+ if (FORBIDDEN_PATH_SEGMENTS.has(seg)) return null;
873
+ }
874
+ return segments.reduce((prev, curr) => prev?.[curr], obj);
875
+ }
876
+ function evaluateSmartConditions(args, rule) {
877
+ if (!rule.conditions || rule.conditions.length === 0) return true;
878
+ const mode = rule.conditionMode ?? "all";
879
+ const fieldCache = /* @__PURE__ */ new Map();
880
+ const resolveField = (field) => {
881
+ if (fieldCache.has(field)) return fieldCache.get(field) ?? null;
882
+ const rawVal = getNestedValue(args, field);
883
+ const rawStr = rawVal !== null && rawVal !== void 0 ? String(rawVal) : null;
884
+ const stripped = field === "command" && rawStr !== null ? normalizeCommandForPolicy(rawStr) : rawStr;
885
+ const val = stripped !== null ? stripped.replace(/\s+/g, " ").trim() : null;
886
+ fieldCache.set(field, val);
887
+ return val;
888
+ };
889
+ const readingsCache = /* @__PURE__ */ new Map();
890
+ const resolveFieldReadings = (field) => {
891
+ const cached = readingsCache.get(field);
892
+ if (cached) return cached;
893
+ const primary = resolveField(field);
894
+ if (primary === null) {
895
+ readingsCache.set(field, []);
896
+ return [];
897
+ }
898
+ let out = [primary];
899
+ if (field === "command") {
900
+ const raw = getNestedValue(args, field);
901
+ if (typeof raw === "string") {
902
+ const collapsed = commandReadings(raw).map((r) => r.replace(/\s+/g, " ").trim());
903
+ out = [.../* @__PURE__ */ new Set([primary, ...collapsed])];
904
+ }
905
+ }
906
+ readingsCache.set(field, out);
907
+ return out;
908
+ };
909
+ const results = rule.conditions.map((cond) => {
910
+ const val = resolveField(cond.field);
911
+ switch (cond.op) {
912
+ case "exists":
913
+ return val !== null && val !== "";
914
+ case "notExists":
915
+ return val === null || val === "";
916
+ case "contains":
917
+ return val !== null && cond.value ? val.includes(cond.value) : false;
918
+ case "notContains":
919
+ return val !== null && cond.value ? !val.includes(cond.value) : true;
920
+ case "matches": {
921
+ if (val === null || !cond.value) return false;
922
+ const reM = getCompiledRegex(cond.value, cond.flags ?? "");
923
+ if (!reM) return false;
924
+ return resolveFieldReadings(cond.field).some((v) => reM.test(v));
925
+ }
926
+ case "notMatches": {
927
+ if (!cond.value) return false;
928
+ if (val === null) return true;
929
+ const reN = getCompiledRegex(cond.value, cond.flags ?? "");
930
+ if (!reN) return false;
931
+ return !resolveFieldReadings(cond.field).some((v) => reN.test(v));
932
+ }
933
+ case "matchesGlob":
934
+ return val !== null && cond.value ? import_picomatch.default.isMatch(val, cond.value) : false;
935
+ case "notMatchesGlob":
936
+ return val !== null && cond.value ? !import_picomatch.default.isMatch(val, cond.value) : false;
937
+ default:
938
+ return false;
939
+ }
940
+ });
941
+ return mode === "any" ? results.some((r) => r) : results.every((r) => r);
942
+ }
943
+
944
+ // src/shell/index.ts
809
945
  var { syntax } = import_mvdan_sh.default;
810
946
  var sharedParser = syntax.NewParser();
811
947
  var MESSAGE_FLAGS = /* @__PURE__ */ new Set([
@@ -878,14 +1014,22 @@ function cachedNormalize(command, compute) {
878
1014
  return result;
879
1015
  }
880
1016
  function normalizeCommandForPolicy(command) {
1017
+ return commandReadingsImpl(command).posix;
1018
+ }
1019
+ function commandReadings(command) {
1020
+ const r = commandReadingsImpl(command);
1021
+ return r.separator === r.posix ? [r.posix] : [r.posix, r.separator];
1022
+ }
1023
+ function commandReadingsImpl(command) {
881
1024
  return cachedNormalize(command, () => normalizeCommandForPolicyImpl(command));
882
1025
  }
883
1026
  function normalizeCommandForPolicyImpl(command) {
884
1027
  const f = parseShared(command);
885
- if (f === PARSE_FAIL) return command;
1028
+ if (f === PARSE_FAIL) return { posix: command, separator: command };
886
1029
  try {
887
1030
  const strips = [];
888
1031
  const rewrites = [];
1032
+ const quoteOnlyRewrites = [];
889
1033
  const msgSpans = /* @__PURE__ */ new Set();
890
1034
  syntax.Walk(f, (node) => {
891
1035
  if (!node) return false;
@@ -930,22 +1074,23 @@ function normalizeCommandForPolicyImpl(command) {
930
1074
  if (resolved === source) continue;
931
1075
  if (resolved === "" || /\s/.test(resolved)) continue;
932
1076
  rewrites.push([s, e, resolved]);
1077
+ const quoteOnly = source.replace(/['"]/g, "");
1078
+ if (quoteOnly !== source) quoteOnlyRewrites.push([s, e, quoteOnly]);
933
1079
  }
934
1080
  return true;
935
1081
  });
936
- const edits = [
937
- ...strips.map(([s, e]) => [s, e, '""']),
938
- ...rewrites
939
- ];
940
- if (edits.length === 0) return command;
941
- edits.sort((a, b) => b[0] - a[0]);
942
- let result = command;
943
- for (const [s, e, rep] of edits) {
944
- result = result.slice(0, s) + rep + result.slice(e);
945
- }
946
- return result;
1082
+ const stripEdits = strips.map(([s, e]) => [s, e, '""']);
1083
+ const apply = (extra) => {
1084
+ const edits = [...stripEdits, ...extra];
1085
+ if (edits.length === 0) return command;
1086
+ edits.sort((a, b) => b[0] - a[0]);
1087
+ let out = command;
1088
+ for (const [s, e, rep] of edits) out = out.slice(0, s) + rep + out.slice(e);
1089
+ return out;
1090
+ };
1091
+ return { posix: apply(rewrites), separator: apply(quoteOnlyRewrites) };
947
1092
  } catch {
948
- return command;
1093
+ return { posix: command, separator: command };
949
1094
  }
950
1095
  }
951
1096
  function scanArgsForDynamicExec(args, startIdx) {
@@ -1015,9 +1160,34 @@ var FS_READ_TOOLS = /* @__PURE__ */ new Set([
1015
1160
  "vi",
1016
1161
  "emacs",
1017
1162
  "code",
1018
- "type"
1163
+ "type",
1164
+ // — the 22 that were missing —
1165
+ "grep",
1166
+ "egrep",
1167
+ "fgrep",
1168
+ "rg",
1169
+ "ag",
1170
+ "ack",
1171
+ "awk",
1172
+ "gawk",
1173
+ "sed",
1174
+ "cut",
1175
+ "tr",
1176
+ "jq",
1177
+ "yq",
1178
+ "od",
1179
+ "xxd",
1180
+ "hexdump",
1181
+ "strings",
1182
+ "sort",
1183
+ "uniq",
1184
+ "tac",
1185
+ "nl",
1186
+ "dd"
1019
1187
  ]);
1020
- var FS_OP_PRESCREEN_RE = /(?:^|[\s|;&(`\n])(?:rm|cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type)\b/;
1188
+ var FS_OP_PRESCREEN_RE = new RegExp(
1189
+ `(?:^|[\\s|;&(\`\\n])(?:rm|${[...FS_READ_TOOLS].map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\b`
1190
+ );
1021
1191
  var HOME_CACHE_ALLOWLIST = [
1022
1192
  ".cache",
1023
1193
  ".npm/_npx",
@@ -1056,9 +1226,37 @@ var SENSITIVE_PATH_RULES = [
1056
1226
  // for the canonical test-asserted contract.
1057
1227
  rule: "shield:project-jail:block-read-env",
1058
1228
  reason: "Reading .env files is blocked by project-jail shield",
1059
- match: (p) => /(?:^|[\\/])\.env(?:\.(?:local|production|staging|development|production\.local|staging\.local|development\.local))?$/i.test(
1060
- p
1061
- )
1229
+ // Structural, not a list. The previous form enumerated seven suffixes and
1230
+ // anchored on `$`, so `.env.prod`, `.env.ci` and `.env.local.bak` — all
1231
+ // gitignored, all routinely holding real secrets — were never covered. A
1232
+ // hand-written list of what to protect is only ever as complete as the day
1233
+ // it was typed; this says "`.env` plus any suffix chain" and then names the
1234
+ // exceptions, which is the direction that fails safe.
1235
+ //
1236
+ // \.env the segment itself
1237
+ // (?![\w-]) a boundary, so `.environment` and `.envrc` are NOT .env
1238
+ // files. Without it a flat suffix class swallows both.
1239
+ // (?![\w-]) a boundary, so `.environment` and `.envrc` are NOT .env
1240
+ // [\w.-]*$ any suffix chain. Flat class, no nested quantifier —
1241
+ // `(\.[\w-]+)*` reads the same but is rejected by
1242
+ // safe-regex2, and this pattern runs on the hook hot path.
1243
+ //
1244
+ // The two exclusions are NOT the same shape, because the words do not mean
1245
+ // the same thing:
1246
+ //
1247
+ // (?!\.(?:example|sample|template)\b) — "this file is a fixture", and it
1248
+ // stays a fixture whatever follows, so `.env.example.md` is allowed too.
1249
+ // These are checked into git by convention: already public, so blocking
1250
+ // them buys nothing and costs the most common legitimate agent read.
1251
+ //
1252
+ // (?!\.test$) — anchored, because `test` names an ENVIRONMENT, not a
1253
+ // fixture. `.env.test` is the committed template and stays allowed, but
1254
+ // `.env.test.local` is gitignored by the `.env*.local` convention and
1255
+ // holds real values, so it must block. Using `\b` here — the obvious
1256
+ // symmetry — silently exempts every `.env.test.*` file.
1257
+ //
1258
+ // shields.test.ts:983-995 is the canonical contract; keep both in step.
1259
+ match: (p) => /(?:^|[\\/])\.env(?![\w-])(?!\.(?:example|sample|template)\b)(?!\.test$)[\w.-]*$/i.test(p)
1062
1260
  },
1063
1261
  {
1064
1262
  // verdict: 'review' (not 'block') is a deliberate design choice
@@ -1187,6 +1385,208 @@ function chmodHasOpenPermMode(command) {
1187
1385
  }
1188
1386
  return found;
1189
1387
  }
1388
+ function isShellShapedTool(toolName, toolInspection) {
1389
+ if (isBashTool(toolName)) return true;
1390
+ if (!toolInspection) return false;
1391
+ const pattern = Object.keys(toolInspection).find((p) => matchesPattern(toolName, p));
1392
+ return pattern !== void 0 && toolInspection[pattern] === "command";
1393
+ }
1394
+ function toolMatchesRule(toolName, ruleTool, toolInspection) {
1395
+ if (!ruleTool) return true;
1396
+ if (matchesPattern(toolName, ruleTool)) return true;
1397
+ return isShellShapedTool(toolName, toolInspection) && matchesPattern("bash", ruleTool);
1398
+ }
1399
+ var INLINE_INTERPRETER = /^(python[\d.]*|perl|ruby|node|tsx|ts-node|php|lua|deno|bun|pwsh|powershell(?:\.exe)?|osascript|rscript|irb|bash|sh|zsh|script|su)$/i;
1400
+ var RUNNER_WRAPPERS = /* @__PURE__ */ new Set([
1401
+ "uv",
1402
+ "uvx",
1403
+ "poetry",
1404
+ "pipenv",
1405
+ "pdm",
1406
+ "rye",
1407
+ "hatch",
1408
+ "conda",
1409
+ "mamba",
1410
+ "micromamba",
1411
+ "npx",
1412
+ "pnpm",
1413
+ "yarn",
1414
+ "bunx",
1415
+ "watch",
1416
+ "strace",
1417
+ "ltrace",
1418
+ "chroot",
1419
+ "unshare",
1420
+ "runuser"
1421
+ ]);
1422
+ function isInlineCodeFlag(interp, w) {
1423
+ const lw = w.toLowerCase();
1424
+ if (lw === "--eval" || lw === "--command" || lw === "--print") return true;
1425
+ if (/^(pwsh|powershell)/.test(interp)) return /^-(c|command|e|ec|enc|encodedcommand)$/i.test(lw);
1426
+ if (!w.startsWith("-") || w.startsWith("--")) return false;
1427
+ const codeLetters = /^(perl|ruby|lua|bun|osascript|rscript|irb)$/.test(interp) ? "e" : /^(node|tsx|ts-node)$/.test(interp) ? "ep" : interp === "php" ? "r" : "c";
1428
+ const body = lw.slice(1);
1429
+ const cutAt = /^(perl|ruby|node|tsx|ts-node)$/.test(interp) ? /[mirw]|[^a-z0-9]/ : /[^a-z0-9]/;
1430
+ const cut = body.search(cutAt);
1431
+ const bundle = cut >= 0 ? body.slice(0, cut) : body;
1432
+ return [...codeLetters].some((l) => bundle.includes(l));
1433
+ }
1434
+ var _redirStdinOps = null;
1435
+ function redirStdinOps() {
1436
+ if (_redirStdinOps) return _redirStdinOps;
1437
+ _redirStdinOps = new Set(
1438
+ [
1439
+ deriveRedirOp("cat <<X\nX"),
1440
+ deriveRedirOp("cat <<-X\nX"),
1441
+ deriveRedirOp("cat < f"),
1442
+ deriveRedirOp("cat <<< x")
1443
+ ].filter((op) => op >= 0)
1444
+ );
1445
+ return _redirStdinOps;
1446
+ }
1447
+ function deriveBinaryOp(sample) {
1448
+ try {
1449
+ const f = sharedParser.Parse(sample, "cmd");
1450
+ let op = -1;
1451
+ syntax.Walk(f, (node) => {
1452
+ const n = node;
1453
+ if (n && syntax.NodeType(n) === "BinaryCmd" && op < 0) op = n.Op;
1454
+ return true;
1455
+ });
1456
+ return op;
1457
+ } catch {
1458
+ return -1;
1459
+ }
1460
+ }
1461
+ var _listOps = null;
1462
+ function listOps() {
1463
+ if (_listOps) return _listOps;
1464
+ _listOps = new Set(
1465
+ [deriveBinaryOp("a && b"), deriveBinaryOp("a || b")].filter((o) => o >= 0)
1466
+ );
1467
+ return _listOps;
1468
+ }
1469
+ var WRAPPER_TAKES_TARGET = /* @__PURE__ */ new Set(["chroot"]);
1470
+ var INTERP_LEADING_TARGET = /* @__PURE__ */ new Set(["su"]);
1471
+ function unwrapCommandHead(words) {
1472
+ let i = 0;
1473
+ while (i < words.length) {
1474
+ const head = (words[i] ?? "").toLowerCase().split("/").pop() ?? "";
1475
+ if (head === "find") {
1476
+ const x = words.findIndex(
1477
+ (w, k) => k > i && (w === "-exec" || w === "-execdir" || w === "-ok")
1478
+ );
1479
+ if (x < 0) break;
1480
+ i = x + 1;
1481
+ continue;
1482
+ }
1483
+ if (!COMMAND_WRAPPERS.has(head) && !RUNNER_WRAPPERS.has(head)) break;
1484
+ i++;
1485
+ let targetConsumed = false;
1486
+ while (i < words.length) {
1487
+ const t = words[i];
1488
+ if (t === null) {
1489
+ i++;
1490
+ continue;
1491
+ }
1492
+ const lt = t.toLowerCase();
1493
+ if (/^[A-Za-z_]\w*=/.test(t)) {
1494
+ i++;
1495
+ continue;
1496
+ }
1497
+ if (t.startsWith("-")) {
1498
+ i++;
1499
+ const nxt = words[i];
1500
+ if (nxt != null && !nxt.startsWith("-") && !INLINE_INTERPRETER.test(nxt.split("/").pop() ?? "") && !COMMAND_WRAPPERS.has(nxt.toLowerCase()) && !RUNNER_WRAPPERS.has(nxt.toLowerCase()))
1501
+ i++;
1502
+ continue;
1503
+ }
1504
+ if (lt === "run" || lt === "exec" || lt === "dlx" || /^\d+(\.\d+)?[smhd]?$/.test(lt)) {
1505
+ i++;
1506
+ continue;
1507
+ }
1508
+ if (!targetConsumed && WRAPPER_TAKES_TARGET.has(head)) {
1509
+ targetConsumed = true;
1510
+ i++;
1511
+ continue;
1512
+ }
1513
+ break;
1514
+ }
1515
+ }
1516
+ return i;
1517
+ }
1518
+ function inlineExecStmt(stmt, pipeFed) {
1519
+ const cmd = stmt?.Cmd;
1520
+ if (!cmd || syntax.NodeType(cmd) !== "CallExpr") return false;
1521
+ const words = (cmd.Args || []).map((a) => resolveWordLiteral(a));
1522
+ if (words.length === 0) return false;
1523
+ const headIdx = unwrapCommandHead(words);
1524
+ const rawHead = words[headIdx];
1525
+ if (rawHead == null) return false;
1526
+ const interp = (rawHead.split("/").pop() ?? "").toLowerCase();
1527
+ if (!INLINE_INTERPRETER.test(interp)) return false;
1528
+ let args = words.slice(headIdx + 1);
1529
+ if (interp === "deno" && (args[0] ?? "").toLowerCase() === "eval") return true;
1530
+ if (INTERP_LEADING_TARGET.has(interp)) {
1531
+ const firstFlag = args.findIndex((a) => a == null || a.startsWith("-"));
1532
+ args = firstFlag >= 0 ? args.slice(firstFlag) : [];
1533
+ }
1534
+ let positionals = 0;
1535
+ let selectedProgram = false;
1536
+ for (const a of args) {
1537
+ if (a == null) {
1538
+ positionals++;
1539
+ selectedProgram = true;
1540
+ continue;
1541
+ }
1542
+ if (!selectedProgram && isInlineCodeFlag(interp, a)) return true;
1543
+ if (a === "-m") {
1544
+ selectedProgram = true;
1545
+ continue;
1546
+ }
1547
+ if (a === "-" && !selectedProgram) return true;
1548
+ if (!a.startsWith("-")) {
1549
+ positionals++;
1550
+ selectedProgram = true;
1551
+ }
1552
+ }
1553
+ const redirs = stmt.Redirs || cmd.Redirs || [];
1554
+ const stdinFed = redirs.some((r) => r && redirStdinOps().has(r.Op));
1555
+ if (positionals === 0 && (stdinFed || pipeFed)) {
1556
+ if (!/^(bash|sh|zsh)$/i.test(interp)) return true;
1557
+ }
1558
+ return false;
1559
+ }
1560
+ function detectInlineExec(command) {
1561
+ const f = parseShared(command);
1562
+ if (f === PARSE_FAIL) {
1563
+ return /(^|[|;&]|&&)\s*(?:[\w./-]*\/)?(python[\d.]*|perl|ruby|node|php|lua|deno|bun|pwsh|osascript|rscript|bash|sh|zsh)\s+-{1,2}[a-z]*[ceEr]/i.test(
1564
+ command
1565
+ );
1566
+ }
1567
+ let found = false;
1568
+ try {
1569
+ syntax.Walk(f, (node) => {
1570
+ if (!node || found) return false;
1571
+ const n = node;
1572
+ const t = syntax.NodeType(n);
1573
+ if (t === "BinaryCmd" && n.Y && !listOps().has(n.Op)) {
1574
+ if (inlineExecStmt(n.Y, true)) {
1575
+ found = true;
1576
+ return false;
1577
+ }
1578
+ }
1579
+ if (t === "Stmt" && inlineExecStmt(n, false)) {
1580
+ found = true;
1581
+ return false;
1582
+ }
1583
+ return true;
1584
+ });
1585
+ } catch {
1586
+ return found;
1587
+ }
1588
+ return found;
1589
+ }
1190
1590
  function analyzeChmod777(command) {
1191
1591
  if (!/chmod/i.test(command.replace(/[\\'"]/g, ""))) return null;
1192
1592
  if (!chmodHasOpenPermMode(command)) return null;
@@ -2064,117 +2464,6 @@ function parseAllSshHostsFromCommand(command) {
2064
2464
  return extractAllSshHosts(tokens.slice(1));
2065
2465
  }
2066
2466
 
2067
- // src/rules/index.ts
2068
- var import_picomatch = __toESM(require("picomatch"));
2069
-
2070
- // src/utils/regex.ts
2071
- var import_safe_regex22 = __toESM(require("safe-regex2"));
2072
- var MAX_REGEX_LENGTH = 100;
2073
- var REGEX_CACHE_MAX = 500;
2074
- var regexCache = /* @__PURE__ */ new Map();
2075
- function validateRegex(pattern) {
2076
- if (!pattern) return "Pattern is required";
2077
- if (pattern.length > MAX_REGEX_LENGTH) return `Pattern exceeds max length of ${MAX_REGEX_LENGTH}`;
2078
- try {
2079
- new RegExp(pattern);
2080
- } catch (e) {
2081
- return `Invalid regex syntax: ${e.message}`;
2082
- }
2083
- if (/\\\d+[*+{]/.test(pattern)) return "Quantified backreferences are forbidden (ReDoS risk)";
2084
- if (!(0, import_safe_regex22.default)(pattern)) return "Pattern rejected: potential ReDoS vulnerability detected";
2085
- return null;
2086
- }
2087
- function getCompiledRegex(pattern, flags = "") {
2088
- if (flags && !/^[gimsuy]+$/.test(flags)) return null;
2089
- const key = `${pattern}\0${flags}`;
2090
- if (regexCache.has(key)) {
2091
- const cached = regexCache.get(key);
2092
- regexCache.delete(key);
2093
- regexCache.set(key, cached);
2094
- return cached;
2095
- }
2096
- if (validateRegex(pattern) !== null) return null;
2097
- try {
2098
- const re = new RegExp(pattern, flags);
2099
- if (regexCache.size >= REGEX_CACHE_MAX) {
2100
- const oldest = regexCache.keys().next().value;
2101
- if (oldest) regexCache.delete(oldest);
2102
- }
2103
- regexCache.set(key, re);
2104
- return re;
2105
- } catch {
2106
- return null;
2107
- }
2108
- }
2109
-
2110
- // src/rules/index.ts
2111
- function matchesPattern(text, patterns) {
2112
- const p = Array.isArray(patterns) ? patterns : [patterns];
2113
- if (p.length === 0) return false;
2114
- const isMatch = (0, import_picomatch.default)(p, { nocase: true, dot: true });
2115
- const target = text.toLowerCase();
2116
- const directMatch = isMatch(target);
2117
- if (directMatch) return true;
2118
- const withoutDotSlash = text.replace(/^\.\//, "");
2119
- return isMatch(withoutDotSlash) || isMatch(`./${withoutDotSlash}`);
2120
- }
2121
- var FORBIDDEN_PATH_SEGMENTS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
2122
- function getNestedValue(obj, path) {
2123
- if (!obj || typeof obj !== "object") return null;
2124
- const segments = path.split(".");
2125
- for (const seg of segments) {
2126
- if (FORBIDDEN_PATH_SEGMENTS.has(seg)) return null;
2127
- }
2128
- return segments.reduce((prev, curr) => prev?.[curr], obj);
2129
- }
2130
- function evaluateSmartConditions(args, rule) {
2131
- if (!rule.conditions || rule.conditions.length === 0) return true;
2132
- const mode = rule.conditionMode ?? "all";
2133
- const fieldCache = /* @__PURE__ */ new Map();
2134
- const resolveField = (field) => {
2135
- if (fieldCache.has(field)) return fieldCache.get(field) ?? null;
2136
- const rawVal = getNestedValue(args, field);
2137
- const rawStr = rawVal !== null && rawVal !== void 0 ? String(rawVal) : null;
2138
- const stripped = field === "command" && rawStr !== null ? normalizeCommandForPolicy(rawStr) : rawStr;
2139
- const val = stripped !== null ? stripped.replace(/\s+/g, " ").trim() : null;
2140
- fieldCache.set(field, val);
2141
- return val;
2142
- };
2143
- const results = rule.conditions.map((cond) => {
2144
- const val = resolveField(cond.field);
2145
- switch (cond.op) {
2146
- case "exists":
2147
- return val !== null && val !== "";
2148
- case "notExists":
2149
- return val === null || val === "";
2150
- case "contains":
2151
- return val !== null && cond.value ? val.includes(cond.value) : false;
2152
- case "notContains":
2153
- return val !== null && cond.value ? !val.includes(cond.value) : true;
2154
- case "matches": {
2155
- if (val === null || !cond.value) return false;
2156
- const reM = getCompiledRegex(cond.value, cond.flags ?? "");
2157
- if (!reM) return false;
2158
- return reM.test(val);
2159
- }
2160
- case "notMatches": {
2161
- if (!cond.value) return false;
2162
- if (val === null) return true;
2163
- const reN = getCompiledRegex(cond.value, cond.flags ?? "");
2164
- if (!reN) return false;
2165
- return !reN.test(val);
2166
- }
2167
- case "matchesGlob":
2168
- return val !== null && cond.value ? import_picomatch.default.isMatch(val, cond.value) : false;
2169
- case "notMatchesGlob":
2170
- return val !== null && cond.value ? !import_picomatch.default.isMatch(val, cond.value) : false;
2171
- default:
2172
- return false;
2173
- }
2174
- });
2175
- return mode === "any" ? results.some((r) => r) : results.every((r) => r);
2176
- }
2177
-
2178
2467
  // src/policy/index.ts
2179
2468
  function resolveCheck(v) {
2180
2469
  return v === "off" || v === "block" ? v : "review";
@@ -2182,41 +2471,6 @@ function resolveCheck(v) {
2182
2471
  function resolveCheckTight(v) {
2183
2472
  return v === "block" ? "block" : "review";
2184
2473
  }
2185
- var INLINE_INTERP = /^(python[\d.]*|bash|sh|zsh|perl|ruby|node|php|lua)$/i;
2186
- var INLINE_SHELL = /^(bash|sh|zsh)$/i;
2187
- function detectInlineExec(command) {
2188
- const pipeFed = command.includes("|");
2189
- const segments = command.split(/\|\||&&|;|\||\n|\$\(|`|\(/);
2190
- for (const rawSeg of segments) {
2191
- const tokens = rawSeg.trim().split(/\s+/).filter(Boolean);
2192
- let i = 0;
2193
- while (i < tokens.length && /^[A-Za-z_]\w*=/.test(tokens[i])) i++;
2194
- if (i >= tokens.length) continue;
2195
- const base = tokens[i].split("/").pop() ?? tokens[i];
2196
- if (!INLINE_INTERP.test(base)) continue;
2197
- const args = tokens.slice(i + 1);
2198
- let hadRedirect = false;
2199
- const positionals = [];
2200
- for (let j = 0; j < args.length; j++) {
2201
- const a = args[j];
2202
- if (a === "-") return true;
2203
- if (a.startsWith("<")) {
2204
- hadRedirect = true;
2205
- if (a === "<" || a === "<<") j++;
2206
- continue;
2207
- }
2208
- if (a.startsWith("-")) {
2209
- if (/^-(c|e|eval)$/i.test(a)) return true;
2210
- continue;
2211
- }
2212
- positionals.push(a);
2213
- }
2214
- if (positionals.length === 0 && (hadRedirect || pipeFed) && !INLINE_SHELL.test(base)) {
2215
- return true;
2216
- }
2217
- }
2218
- return false;
2219
- }
2220
2474
  var VERDICT_RANK = {
2221
2475
  allow: 0,
2222
2476
  review: 1,
@@ -2230,6 +2484,12 @@ function resolvePinned(matches) {
2230
2484
  (best, r) => VERDICT_RANK[r.verdict] > VERDICT_RANK[best.verdict] ? r : best
2231
2485
  );
2232
2486
  }
2487
+ function strictestVerdict(candidates) {
2488
+ if (candidates.length === 0) return void 0;
2489
+ return candidates.reduce(
2490
+ (best, c) => VERDICT_RANK[c.decision] > VERDICT_RANK[best.decision] ? c : best
2491
+ );
2492
+ }
2233
2493
  function tokenize2(toolName) {
2234
2494
  return toolName.toLowerCase().split(/[_.\-\s]+/).filter(Boolean);
2235
2495
  }
@@ -2241,6 +2501,11 @@ function extractShellCommand(toolName, args, toolInspection) {
2241
2501
  const value = getNestedValue(args, fieldPath);
2242
2502
  return typeof value === "string" ? value : null;
2243
2503
  }
2504
+ function inspectsShellCommand(toolName, toolInspection) {
2505
+ const patterns = Object.keys(toolInspection);
2506
+ const matchingPattern = patterns.find((p) => matchesPattern(toolName, p));
2507
+ return matchingPattern !== void 0 && toolInspection[matchingPattern] === "command";
2508
+ }
2244
2509
  function isSqlTool(toolName, toolInspection) {
2245
2510
  const patterns = Object.keys(toolInspection);
2246
2511
  const matchingPattern = patterns.find((p) => matchesPattern(toolName, p));
@@ -2312,8 +2577,10 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2312
2577
  };
2313
2578
  }
2314
2579
  }
2315
- if (wouldBeIgnored) return { decision: "allow" };
2316
- const bashCommand = agent !== "Terminal" && isBashTool(toolName) && args && typeof args === "object" ? typeof args.command === "string" ? args.command : null : null;
2580
+ if (wouldBeIgnored && !context.skipIgnoredFastPath) return { decision: "allow" };
2581
+ const shellShaped = isBashTool(toolName) || inspectsShellCommand(toolName, config.policy.toolInspection);
2582
+ const shellShapedCommand = shellShaped ? isBashTool(toolName) && args && typeof args === "object" ? typeof args.command === "string" ? args.command : null : extractShellCommand(toolName, args, config.policy.toolInspection) : null;
2583
+ const bashCommand = agent !== "Terminal" ? shellShapedCommand : null;
2317
2584
  if (bashCommand !== null) {
2318
2585
  const pipeVerdict = pipeChainVerdict(
2319
2586
  bashCommand,
@@ -2363,8 +2630,14 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2363
2630
  }
2364
2631
  if (config.policy.smartRules.length > 0) {
2365
2632
  const rmCleanupWaiver = bashCommand !== null && isRmCreatedInCommandCleanup(bashCommand);
2633
+ const astSuppressed = (rule) => {
2634
+ if (bashCommand === null || !rule.name || !AST_FS_REGEX_RULES.has(rule.name)) return false;
2635
+ const knob = rule.name === "review-drop-truncate-shell" ? resolveCheck(config.policy.commandChecks?.sqlDdl) : rule.name === "shield:filesystem:review-chmod-777" ? resolveCheck(config.policy.commandChecks?.chmod) : void 0;
2636
+ if (knob === "off" && rule.pinned) return false;
2637
+ return true;
2638
+ };
2366
2639
  const matches = config.policy.smartRules.filter(
2367
- (rule) => matchesPattern(toolName, rule.tool) && !(bashCommand !== null && rule.name && AST_FS_REGEX_RULES.has(rule.name)) && !(rmCleanupWaiver && rule.name === "review-rm" && rule.verdict === "review") && evaluateSmartConditions(args, rule)
2640
+ (rule) => toolMatchesRule(toolName, rule.tool, config.policy.toolInspection) && !astSuppressed(rule) && !(rmCleanupWaiver && rule.name === "review-rm" && rule.verdict === "review") && evaluateSmartConditions(args, rule)
2368
2641
  );
2369
2642
  const matchedRule = resolvePinned(matches);
2370
2643
  if (matchedRule) {
@@ -2395,15 +2668,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2395
2668
  const analyzed = analyzeShellCommand(shellCommand);
2396
2669
  allTokens = analyzed.allTokens;
2397
2670
  pathTokens = analyzed.paths;
2398
- const inlineAction = resolveCheck(config.policy.commandChecks?.inlineExec);
2399
- if (inlineAction !== "off" && detectInlineExec(shellCommand)) {
2400
- return {
2401
- decision: inlineAction === "block" ? "block" : "review",
2402
- blockedByLabel: "Node9 Standard (Inline Execution)",
2403
- ruleDescription: "The AI is running code directly from the command line. Review the full script below before allowing it to execute.",
2404
- tier: 3
2405
- };
2406
- }
2671
+ const candidates = [];
2407
2672
  const evalVerdict = detectDangerousShellExec(shellCommand);
2408
2673
  if (evalVerdict === "block") {
2409
2674
  return {
@@ -2414,24 +2679,33 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2414
2679
  tier: 3
2415
2680
  };
2416
2681
  }
2682
+ const ptVerdict = pipeChainVerdict(
2683
+ shellCommand,
2684
+ isTrustedHost,
2685
+ resolveCheckTight(config.policy.commandChecks?.pipeChainHigh)
2686
+ );
2687
+ if (ptVerdict?.decision === "allow") return ptVerdict;
2688
+ if (ptVerdict) candidates.push(ptVerdict);
2689
+ const inlineAction = resolveCheck(config.policy.commandChecks?.inlineExec);
2690
+ if (inlineAction !== "off" && detectInlineExec(shellCommand)) {
2691
+ candidates.push({
2692
+ decision: inlineAction === "block" ? "block" : "review",
2693
+ blockedByLabel: "Node9 Standard (Inline Execution)",
2694
+ ruleDescription: "The AI is running code directly from the command line. Review the full script below before allowing it to execute.",
2695
+ tier: 3
2696
+ });
2697
+ }
2417
2698
  if (evalVerdict === "review") {
2418
- return {
2419
- // Class B tighten-only: commandChecks.evalDynamic may upgrade to
2420
- // block but can never turn this off (eval-remote above is Class A —
2421
- // no knob at all).
2699
+ candidates.push({
2422
2700
  decision: resolveCheckTight(config.policy.commandChecks?.evalDynamic),
2423
2701
  blockedByLabel: "Node9: Eval Dynamic Content",
2424
2702
  reason: "eval of dynamic content (variable or subshell expansion) requires approval",
2425
2703
  ruleDescription: "The AI is running a command that includes a variable or subshell expansion. The actual command executed at runtime may differ from what is shown here.",
2426
2704
  tier: 3
2427
- };
2705
+ });
2428
2706
  }
2429
- const ptVerdict = pipeChainVerdict(
2430
- shellCommand,
2431
- isTrustedHost,
2432
- resolveCheckTight(config.policy.commandChecks?.pipeChainHigh)
2433
- );
2434
- if (ptVerdict) return ptVerdict;
2707
+ const builtin = strictestVerdict(candidates);
2708
+ if (builtin) return builtin;
2435
2709
  if (config.policy.egress?.enabled) {
2436
2710
  const dests = extractShellDestinations(shellCommand);
2437
2711
  if (dests.length > 0) {
@@ -3696,15 +3970,15 @@ function detectArgsPii(args) {
3696
3970
 
3697
3971
  // src/scan/canonical.ts
3698
3972
  var LONG_OUTPUT_THRESHOLD_BYTES = 100 * 1024;
3699
- var CANONICAL_EXTRACTOR_VERSION = "canonical-v6";
3700
- var CANONICAL_EXTRACTOR_HASH = "a0e2bb339fe67e19";
3973
+ var CANONICAL_EXTRACTOR_VERSION = "canonical-v9";
3974
+ var CANONICAL_EXTRACTOR_HASH = "4ebf40dfe1d7c0a1";
3701
3975
  var DEDUPE_PREVIEW_LEN = 120;
3702
3976
  function extractCanonicalFindings(call, ctx) {
3703
3977
  const out = [];
3704
3978
  const ts = call.timestamp;
3705
3979
  const toolNameLower = call.toolName.toLowerCase();
3706
3980
  const command = typeof call.args.command === "string" ? call.args.command : null;
3707
- const isBash = isBashTool(call.toolName) && command !== null;
3981
+ const isShell = isShellShapedTool(call.toolName, ctx.toolInspection) && command !== null;
3708
3982
  if (call.outputBytes !== void 0 && call.outputBytes > LONG_OUTPUT_THRESHOLD_BYTES) {
3709
3983
  out.push(
3710
3984
  makeFinding({
@@ -3779,7 +4053,7 @@ function extractCanonicalFindings(call, ctx) {
3779
4053
  );
3780
4054
  }
3781
4055
  }
3782
- if (!isBash || command === null) {
4056
+ if (!isShell || command === null) {
3783
4057
  return out;
3784
4058
  }
3785
4059
  const fsVerdict = analyzeFsOperation(command);
@@ -3805,7 +4079,7 @@ function extractCanonicalFindings(call, ctx) {
3805
4079
  for (const source of ctx.rules) {
3806
4080
  const r = source.rule;
3807
4081
  if (r.verdict === "allow") continue;
3808
- if (r.tool && !matchesPattern(toolNameLower, r.tool)) continue;
4082
+ if (!toolMatchesRule(toolNameLower, r.tool, ctx.toolInspection)) continue;
3809
4083
  if (r.name && AST_FS_REGEX_RULES.has(r.name)) continue;
3810
4084
  if (!evaluateSmartConditions(call.args, r)) continue;
3811
4085
  out.push(
@@ -4082,6 +4356,7 @@ var ENGINE_VERSION = "1.4.0";
4082
4356
  detectArgsPii,
4083
4357
  detectDangerousEval,
4084
4358
  detectDangerousShellExec,
4359
+ detectInlineExec,
4085
4360
  detectPii,
4086
4361
  evaluateEgress,
4087
4362
  evaluateLoopWindow,
@@ -4100,6 +4375,7 @@ var ENGINE_VERSION = "1.4.0";
4100
4375
  isIgnoredTool,
4101
4376
  isPrivateHost,
4102
4377
  isProtectedHomePath,
4378
+ isShellShapedTool,
4103
4379
  isShieldVerdict,
4104
4380
  matchSensitivePath,
4105
4381
  matchesPattern,
@@ -4117,6 +4393,7 @@ var ENGINE_VERSION = "1.4.0";
4117
4393
  summarizeBlast,
4118
4394
  summarizeScan,
4119
4395
  toScanFinding,
4396
+ toolMatchesRule,
4120
4397
  truncateBlastPath,
4121
4398
  validateOverrides,
4122
4399
  validateRegex,