@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.mjs CHANGED
@@ -696,6 +696,139 @@ function redactText(text) {
696
696
 
697
697
  // src/shell/index.ts
698
698
  import mvdanSh from "mvdan-sh";
699
+
700
+ // src/rules/index.ts
701
+ import pm from "picomatch";
702
+
703
+ // src/utils/regex.ts
704
+ import safeRegex2 from "safe-regex2";
705
+ var MAX_REGEX_LENGTH = 256;
706
+ var REGEX_CACHE_MAX = 500;
707
+ var regexCache = /* @__PURE__ */ new Map();
708
+ function validateRegex(pattern) {
709
+ if (!pattern) return "Pattern is required";
710
+ if (pattern.length > MAX_REGEX_LENGTH) return `Pattern exceeds max length of ${MAX_REGEX_LENGTH}`;
711
+ try {
712
+ new RegExp(pattern);
713
+ } catch (e) {
714
+ return `Invalid regex syntax: ${e.message}`;
715
+ }
716
+ if (/\\\d+[*+{]/.test(pattern)) return "Quantified backreferences are forbidden (ReDoS risk)";
717
+ if (!safeRegex2(pattern)) return "Pattern rejected: potential ReDoS vulnerability detected";
718
+ return null;
719
+ }
720
+ function getCompiledRegex(pattern, flags = "") {
721
+ if (flags && !/^[gimsuy]+$/.test(flags)) return null;
722
+ const key = `${pattern}\0${flags}`;
723
+ if (regexCache.has(key)) {
724
+ const cached = regexCache.get(key);
725
+ regexCache.delete(key);
726
+ regexCache.set(key, cached);
727
+ return cached;
728
+ }
729
+ if (validateRegex(pattern) !== null) return null;
730
+ try {
731
+ const re = new RegExp(pattern, flags);
732
+ if (regexCache.size >= REGEX_CACHE_MAX) {
733
+ const oldest = regexCache.keys().next().value;
734
+ if (oldest) regexCache.delete(oldest);
735
+ }
736
+ regexCache.set(key, re);
737
+ return re;
738
+ } catch {
739
+ return null;
740
+ }
741
+ }
742
+
743
+ // src/rules/index.ts
744
+ function matchesPattern(text, patterns) {
745
+ const p = Array.isArray(patterns) ? patterns : [patterns];
746
+ if (p.length === 0) return false;
747
+ const isMatch = pm(p, { nocase: true, dot: true });
748
+ const target = text.toLowerCase();
749
+ const directMatch = isMatch(target);
750
+ if (directMatch) return true;
751
+ const withoutDotSlash = text.replace(/^\.\//, "");
752
+ return isMatch(withoutDotSlash) || isMatch(`./${withoutDotSlash}`);
753
+ }
754
+ var FORBIDDEN_PATH_SEGMENTS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
755
+ function getNestedValue(obj, path) {
756
+ if (!obj || typeof obj !== "object") return null;
757
+ const segments = path.split(".");
758
+ for (const seg of segments) {
759
+ if (FORBIDDEN_PATH_SEGMENTS.has(seg)) return null;
760
+ }
761
+ return segments.reduce((prev, curr) => prev?.[curr], obj);
762
+ }
763
+ function evaluateSmartConditions(args, rule) {
764
+ if (!rule.conditions || rule.conditions.length === 0) return true;
765
+ const mode = rule.conditionMode ?? "all";
766
+ const fieldCache = /* @__PURE__ */ new Map();
767
+ const resolveField = (field) => {
768
+ if (fieldCache.has(field)) return fieldCache.get(field) ?? null;
769
+ const rawVal = getNestedValue(args, field);
770
+ const rawStr = rawVal !== null && rawVal !== void 0 ? String(rawVal) : null;
771
+ const stripped = field === "command" && rawStr !== null ? normalizeCommandForPolicy(rawStr) : rawStr;
772
+ const val = stripped !== null ? stripped.replace(/\s+/g, " ").trim() : null;
773
+ fieldCache.set(field, val);
774
+ return val;
775
+ };
776
+ const readingsCache = /* @__PURE__ */ new Map();
777
+ const resolveFieldReadings = (field) => {
778
+ const cached = readingsCache.get(field);
779
+ if (cached) return cached;
780
+ const primary = resolveField(field);
781
+ if (primary === null) {
782
+ readingsCache.set(field, []);
783
+ return [];
784
+ }
785
+ let out = [primary];
786
+ if (field === "command") {
787
+ const raw = getNestedValue(args, field);
788
+ if (typeof raw === "string") {
789
+ const collapsed = commandReadings(raw).map((r) => r.replace(/\s+/g, " ").trim());
790
+ out = [.../* @__PURE__ */ new Set([primary, ...collapsed])];
791
+ }
792
+ }
793
+ readingsCache.set(field, out);
794
+ return out;
795
+ };
796
+ const results = rule.conditions.map((cond) => {
797
+ const val = resolveField(cond.field);
798
+ switch (cond.op) {
799
+ case "exists":
800
+ return val !== null && val !== "";
801
+ case "notExists":
802
+ return val === null || val === "";
803
+ case "contains":
804
+ return val !== null && cond.value ? val.includes(cond.value) : false;
805
+ case "notContains":
806
+ return val !== null && cond.value ? !val.includes(cond.value) : true;
807
+ case "matches": {
808
+ if (val === null || !cond.value) return false;
809
+ const reM = getCompiledRegex(cond.value, cond.flags ?? "");
810
+ if (!reM) return false;
811
+ return resolveFieldReadings(cond.field).some((v) => reM.test(v));
812
+ }
813
+ case "notMatches": {
814
+ if (!cond.value) return false;
815
+ if (val === null) return true;
816
+ const reN = getCompiledRegex(cond.value, cond.flags ?? "");
817
+ if (!reN) return false;
818
+ return !resolveFieldReadings(cond.field).some((v) => reN.test(v));
819
+ }
820
+ case "matchesGlob":
821
+ return val !== null && cond.value ? pm.isMatch(val, cond.value) : false;
822
+ case "notMatchesGlob":
823
+ return val !== null && cond.value ? !pm.isMatch(val, cond.value) : false;
824
+ default:
825
+ return false;
826
+ }
827
+ });
828
+ return mode === "any" ? results.some((r) => r) : results.every((r) => r);
829
+ }
830
+
831
+ // src/shell/index.ts
699
832
  var { syntax } = mvdanSh;
700
833
  var sharedParser = syntax.NewParser();
701
834
  var MESSAGE_FLAGS = /* @__PURE__ */ new Set([
@@ -768,14 +901,22 @@ function cachedNormalize(command, compute) {
768
901
  return result;
769
902
  }
770
903
  function normalizeCommandForPolicy(command) {
904
+ return commandReadingsImpl(command).posix;
905
+ }
906
+ function commandReadings(command) {
907
+ const r = commandReadingsImpl(command);
908
+ return r.separator === r.posix ? [r.posix] : [r.posix, r.separator];
909
+ }
910
+ function commandReadingsImpl(command) {
771
911
  return cachedNormalize(command, () => normalizeCommandForPolicyImpl(command));
772
912
  }
773
913
  function normalizeCommandForPolicyImpl(command) {
774
914
  const f = parseShared(command);
775
- if (f === PARSE_FAIL) return command;
915
+ if (f === PARSE_FAIL) return { posix: command, separator: command };
776
916
  try {
777
917
  const strips = [];
778
918
  const rewrites = [];
919
+ const quoteOnlyRewrites = [];
779
920
  const msgSpans = /* @__PURE__ */ new Set();
780
921
  syntax.Walk(f, (node) => {
781
922
  if (!node) return false;
@@ -820,22 +961,23 @@ function normalizeCommandForPolicyImpl(command) {
820
961
  if (resolved === source) continue;
821
962
  if (resolved === "" || /\s/.test(resolved)) continue;
822
963
  rewrites.push([s, e, resolved]);
964
+ const quoteOnly = source.replace(/['"]/g, "");
965
+ if (quoteOnly !== source) quoteOnlyRewrites.push([s, e, quoteOnly]);
823
966
  }
824
967
  return true;
825
968
  });
826
- const edits = [
827
- ...strips.map(([s, e]) => [s, e, '""']),
828
- ...rewrites
829
- ];
830
- if (edits.length === 0) return command;
831
- edits.sort((a, b) => b[0] - a[0]);
832
- let result = command;
833
- for (const [s, e, rep] of edits) {
834
- result = result.slice(0, s) + rep + result.slice(e);
835
- }
836
- return result;
969
+ const stripEdits = strips.map(([s, e]) => [s, e, '""']);
970
+ const apply = (extra) => {
971
+ const edits = [...stripEdits, ...extra];
972
+ if (edits.length === 0) return command;
973
+ edits.sort((a, b) => b[0] - a[0]);
974
+ let out = command;
975
+ for (const [s, e, rep] of edits) out = out.slice(0, s) + rep + out.slice(e);
976
+ return out;
977
+ };
978
+ return { posix: apply(rewrites), separator: apply(quoteOnlyRewrites) };
837
979
  } catch {
838
- return command;
980
+ return { posix: command, separator: command };
839
981
  }
840
982
  }
841
983
  function scanArgsForDynamicExec(args, startIdx) {
@@ -905,9 +1047,34 @@ var FS_READ_TOOLS = /* @__PURE__ */ new Set([
905
1047
  "vi",
906
1048
  "emacs",
907
1049
  "code",
908
- "type"
1050
+ "type",
1051
+ // — the 22 that were missing —
1052
+ "grep",
1053
+ "egrep",
1054
+ "fgrep",
1055
+ "rg",
1056
+ "ag",
1057
+ "ack",
1058
+ "awk",
1059
+ "gawk",
1060
+ "sed",
1061
+ "cut",
1062
+ "tr",
1063
+ "jq",
1064
+ "yq",
1065
+ "od",
1066
+ "xxd",
1067
+ "hexdump",
1068
+ "strings",
1069
+ "sort",
1070
+ "uniq",
1071
+ "tac",
1072
+ "nl",
1073
+ "dd"
909
1074
  ]);
910
- var FS_OP_PRESCREEN_RE = /(?:^|[\s|;&(`\n])(?:rm|cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type)\b/;
1075
+ var FS_OP_PRESCREEN_RE = new RegExp(
1076
+ `(?:^|[\\s|;&(\`\\n])(?:rm|${[...FS_READ_TOOLS].map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\b`
1077
+ );
911
1078
  var HOME_CACHE_ALLOWLIST = [
912
1079
  ".cache",
913
1080
  ".npm/_npx",
@@ -946,9 +1113,37 @@ var SENSITIVE_PATH_RULES = [
946
1113
  // for the canonical test-asserted contract.
947
1114
  rule: "shield:project-jail:block-read-env",
948
1115
  reason: "Reading .env files is blocked by project-jail shield",
949
- match: (p) => /(?:^|[\\/])\.env(?:\.(?:local|production|staging|development|production\.local|staging\.local|development\.local))?$/i.test(
950
- p
951
- )
1116
+ // Structural, not a list. The previous form enumerated seven suffixes and
1117
+ // anchored on `$`, so `.env.prod`, `.env.ci` and `.env.local.bak` — all
1118
+ // gitignored, all routinely holding real secrets — were never covered. A
1119
+ // hand-written list of what to protect is only ever as complete as the day
1120
+ // it was typed; this says "`.env` plus any suffix chain" and then names the
1121
+ // exceptions, which is the direction that fails safe.
1122
+ //
1123
+ // \.env the segment itself
1124
+ // (?![\w-]) a boundary, so `.environment` and `.envrc` are NOT .env
1125
+ // files. Without it a flat suffix class swallows both.
1126
+ // (?![\w-]) a boundary, so `.environment` and `.envrc` are NOT .env
1127
+ // [\w.-]*$ any suffix chain. Flat class, no nested quantifier —
1128
+ // `(\.[\w-]+)*` reads the same but is rejected by
1129
+ // safe-regex2, and this pattern runs on the hook hot path.
1130
+ //
1131
+ // The two exclusions are NOT the same shape, because the words do not mean
1132
+ // the same thing:
1133
+ //
1134
+ // (?!\.(?:example|sample|template)\b) — "this file is a fixture", and it
1135
+ // stays a fixture whatever follows, so `.env.example.md` is allowed too.
1136
+ // These are checked into git by convention: already public, so blocking
1137
+ // them buys nothing and costs the most common legitimate agent read.
1138
+ //
1139
+ // (?!\.test$) — anchored, because `test` names an ENVIRONMENT, not a
1140
+ // fixture. `.env.test` is the committed template and stays allowed, but
1141
+ // `.env.test.local` is gitignored by the `.env*.local` convention and
1142
+ // holds real values, so it must block. Using `\b` here — the obvious
1143
+ // symmetry — silently exempts every `.env.test.*` file.
1144
+ //
1145
+ // shields.test.ts:983-995 is the canonical contract; keep both in step.
1146
+ match: (p) => /(?:^|[\\/])\.env(?![\w-])(?!\.(?:example|sample|template)\b)(?!\.test$)[\w.-]*$/i.test(p)
952
1147
  },
953
1148
  {
954
1149
  // verdict: 'review' (not 'block') is a deliberate design choice
@@ -1077,6 +1272,208 @@ function chmodHasOpenPermMode(command) {
1077
1272
  }
1078
1273
  return found;
1079
1274
  }
1275
+ function isShellShapedTool(toolName, toolInspection) {
1276
+ if (isBashTool(toolName)) return true;
1277
+ if (!toolInspection) return false;
1278
+ const pattern = Object.keys(toolInspection).find((p) => matchesPattern(toolName, p));
1279
+ return pattern !== void 0 && toolInspection[pattern] === "command";
1280
+ }
1281
+ function toolMatchesRule(toolName, ruleTool, toolInspection) {
1282
+ if (!ruleTool) return true;
1283
+ if (matchesPattern(toolName, ruleTool)) return true;
1284
+ return isShellShapedTool(toolName, toolInspection) && matchesPattern("bash", ruleTool);
1285
+ }
1286
+ 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;
1287
+ var RUNNER_WRAPPERS = /* @__PURE__ */ new Set([
1288
+ "uv",
1289
+ "uvx",
1290
+ "poetry",
1291
+ "pipenv",
1292
+ "pdm",
1293
+ "rye",
1294
+ "hatch",
1295
+ "conda",
1296
+ "mamba",
1297
+ "micromamba",
1298
+ "npx",
1299
+ "pnpm",
1300
+ "yarn",
1301
+ "bunx",
1302
+ "watch",
1303
+ "strace",
1304
+ "ltrace",
1305
+ "chroot",
1306
+ "unshare",
1307
+ "runuser"
1308
+ ]);
1309
+ function isInlineCodeFlag(interp, w) {
1310
+ const lw = w.toLowerCase();
1311
+ if (lw === "--eval" || lw === "--command" || lw === "--print") return true;
1312
+ if (/^(pwsh|powershell)/.test(interp)) return /^-(c|command|e|ec|enc|encodedcommand)$/i.test(lw);
1313
+ if (!w.startsWith("-") || w.startsWith("--")) return false;
1314
+ const codeLetters = /^(perl|ruby|lua|bun|osascript|rscript|irb)$/.test(interp) ? "e" : /^(node|tsx|ts-node)$/.test(interp) ? "ep" : interp === "php" ? "r" : "c";
1315
+ const body = lw.slice(1);
1316
+ const cutAt = /^(perl|ruby|node|tsx|ts-node)$/.test(interp) ? /[mirw]|[^a-z0-9]/ : /[^a-z0-9]/;
1317
+ const cut = body.search(cutAt);
1318
+ const bundle = cut >= 0 ? body.slice(0, cut) : body;
1319
+ return [...codeLetters].some((l) => bundle.includes(l));
1320
+ }
1321
+ var _redirStdinOps = null;
1322
+ function redirStdinOps() {
1323
+ if (_redirStdinOps) return _redirStdinOps;
1324
+ _redirStdinOps = new Set(
1325
+ [
1326
+ deriveRedirOp("cat <<X\nX"),
1327
+ deriveRedirOp("cat <<-X\nX"),
1328
+ deriveRedirOp("cat < f"),
1329
+ deriveRedirOp("cat <<< x")
1330
+ ].filter((op) => op >= 0)
1331
+ );
1332
+ return _redirStdinOps;
1333
+ }
1334
+ function deriveBinaryOp(sample) {
1335
+ try {
1336
+ const f = sharedParser.Parse(sample, "cmd");
1337
+ let op = -1;
1338
+ syntax.Walk(f, (node) => {
1339
+ const n = node;
1340
+ if (n && syntax.NodeType(n) === "BinaryCmd" && op < 0) op = n.Op;
1341
+ return true;
1342
+ });
1343
+ return op;
1344
+ } catch {
1345
+ return -1;
1346
+ }
1347
+ }
1348
+ var _listOps = null;
1349
+ function listOps() {
1350
+ if (_listOps) return _listOps;
1351
+ _listOps = new Set(
1352
+ [deriveBinaryOp("a && b"), deriveBinaryOp("a || b")].filter((o) => o >= 0)
1353
+ );
1354
+ return _listOps;
1355
+ }
1356
+ var WRAPPER_TAKES_TARGET = /* @__PURE__ */ new Set(["chroot"]);
1357
+ var INTERP_LEADING_TARGET = /* @__PURE__ */ new Set(["su"]);
1358
+ function unwrapCommandHead(words) {
1359
+ let i = 0;
1360
+ while (i < words.length) {
1361
+ const head = (words[i] ?? "").toLowerCase().split("/").pop() ?? "";
1362
+ if (head === "find") {
1363
+ const x = words.findIndex(
1364
+ (w, k) => k > i && (w === "-exec" || w === "-execdir" || w === "-ok")
1365
+ );
1366
+ if (x < 0) break;
1367
+ i = x + 1;
1368
+ continue;
1369
+ }
1370
+ if (!COMMAND_WRAPPERS.has(head) && !RUNNER_WRAPPERS.has(head)) break;
1371
+ i++;
1372
+ let targetConsumed = false;
1373
+ while (i < words.length) {
1374
+ const t = words[i];
1375
+ if (t === null) {
1376
+ i++;
1377
+ continue;
1378
+ }
1379
+ const lt = t.toLowerCase();
1380
+ if (/^[A-Za-z_]\w*=/.test(t)) {
1381
+ i++;
1382
+ continue;
1383
+ }
1384
+ if (t.startsWith("-")) {
1385
+ i++;
1386
+ const nxt = words[i];
1387
+ if (nxt != null && !nxt.startsWith("-") && !INLINE_INTERPRETER.test(nxt.split("/").pop() ?? "") && !COMMAND_WRAPPERS.has(nxt.toLowerCase()) && !RUNNER_WRAPPERS.has(nxt.toLowerCase()))
1388
+ i++;
1389
+ continue;
1390
+ }
1391
+ if (lt === "run" || lt === "exec" || lt === "dlx" || /^\d+(\.\d+)?[smhd]?$/.test(lt)) {
1392
+ i++;
1393
+ continue;
1394
+ }
1395
+ if (!targetConsumed && WRAPPER_TAKES_TARGET.has(head)) {
1396
+ targetConsumed = true;
1397
+ i++;
1398
+ continue;
1399
+ }
1400
+ break;
1401
+ }
1402
+ }
1403
+ return i;
1404
+ }
1405
+ function inlineExecStmt(stmt, pipeFed) {
1406
+ const cmd = stmt?.Cmd;
1407
+ if (!cmd || syntax.NodeType(cmd) !== "CallExpr") return false;
1408
+ const words = (cmd.Args || []).map((a) => resolveWordLiteral(a));
1409
+ if (words.length === 0) return false;
1410
+ const headIdx = unwrapCommandHead(words);
1411
+ const rawHead = words[headIdx];
1412
+ if (rawHead == null) return false;
1413
+ const interp = (rawHead.split("/").pop() ?? "").toLowerCase();
1414
+ if (!INLINE_INTERPRETER.test(interp)) return false;
1415
+ let args = words.slice(headIdx + 1);
1416
+ if (interp === "deno" && (args[0] ?? "").toLowerCase() === "eval") return true;
1417
+ if (INTERP_LEADING_TARGET.has(interp)) {
1418
+ const firstFlag = args.findIndex((a) => a == null || a.startsWith("-"));
1419
+ args = firstFlag >= 0 ? args.slice(firstFlag) : [];
1420
+ }
1421
+ let positionals = 0;
1422
+ let selectedProgram = false;
1423
+ for (const a of args) {
1424
+ if (a == null) {
1425
+ positionals++;
1426
+ selectedProgram = true;
1427
+ continue;
1428
+ }
1429
+ if (!selectedProgram && isInlineCodeFlag(interp, a)) return true;
1430
+ if (a === "-m") {
1431
+ selectedProgram = true;
1432
+ continue;
1433
+ }
1434
+ if (a === "-" && !selectedProgram) return true;
1435
+ if (!a.startsWith("-")) {
1436
+ positionals++;
1437
+ selectedProgram = true;
1438
+ }
1439
+ }
1440
+ const redirs = stmt.Redirs || cmd.Redirs || [];
1441
+ const stdinFed = redirs.some((r) => r && redirStdinOps().has(r.Op));
1442
+ if (positionals === 0 && (stdinFed || pipeFed)) {
1443
+ if (!/^(bash|sh|zsh)$/i.test(interp)) return true;
1444
+ }
1445
+ return false;
1446
+ }
1447
+ function detectInlineExec(command) {
1448
+ const f = parseShared(command);
1449
+ if (f === PARSE_FAIL) {
1450
+ 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(
1451
+ command
1452
+ );
1453
+ }
1454
+ let found = false;
1455
+ try {
1456
+ syntax.Walk(f, (node) => {
1457
+ if (!node || found) return false;
1458
+ const n = node;
1459
+ const t = syntax.NodeType(n);
1460
+ if (t === "BinaryCmd" && n.Y && !listOps().has(n.Op)) {
1461
+ if (inlineExecStmt(n.Y, true)) {
1462
+ found = true;
1463
+ return false;
1464
+ }
1465
+ }
1466
+ if (t === "Stmt" && inlineExecStmt(n, false)) {
1467
+ found = true;
1468
+ return false;
1469
+ }
1470
+ return true;
1471
+ });
1472
+ } catch {
1473
+ return found;
1474
+ }
1475
+ return found;
1476
+ }
1080
1477
  function analyzeChmod777(command) {
1081
1478
  if (!/chmod/i.test(command.replace(/[\\'"]/g, ""))) return null;
1082
1479
  if (!chmodHasOpenPermMode(command)) return null;
@@ -1954,117 +2351,6 @@ function parseAllSshHostsFromCommand(command) {
1954
2351
  return extractAllSshHosts(tokens.slice(1));
1955
2352
  }
1956
2353
 
1957
- // src/rules/index.ts
1958
- import pm from "picomatch";
1959
-
1960
- // src/utils/regex.ts
1961
- import safeRegex2 from "safe-regex2";
1962
- var MAX_REGEX_LENGTH = 100;
1963
- var REGEX_CACHE_MAX = 500;
1964
- var regexCache = /* @__PURE__ */ new Map();
1965
- function validateRegex(pattern) {
1966
- if (!pattern) return "Pattern is required";
1967
- if (pattern.length > MAX_REGEX_LENGTH) return `Pattern exceeds max length of ${MAX_REGEX_LENGTH}`;
1968
- try {
1969
- new RegExp(pattern);
1970
- } catch (e) {
1971
- return `Invalid regex syntax: ${e.message}`;
1972
- }
1973
- if (/\\\d+[*+{]/.test(pattern)) return "Quantified backreferences are forbidden (ReDoS risk)";
1974
- if (!safeRegex2(pattern)) return "Pattern rejected: potential ReDoS vulnerability detected";
1975
- return null;
1976
- }
1977
- function getCompiledRegex(pattern, flags = "") {
1978
- if (flags && !/^[gimsuy]+$/.test(flags)) return null;
1979
- const key = `${pattern}\0${flags}`;
1980
- if (regexCache.has(key)) {
1981
- const cached = regexCache.get(key);
1982
- regexCache.delete(key);
1983
- regexCache.set(key, cached);
1984
- return cached;
1985
- }
1986
- if (validateRegex(pattern) !== null) return null;
1987
- try {
1988
- const re = new RegExp(pattern, flags);
1989
- if (regexCache.size >= REGEX_CACHE_MAX) {
1990
- const oldest = regexCache.keys().next().value;
1991
- if (oldest) regexCache.delete(oldest);
1992
- }
1993
- regexCache.set(key, re);
1994
- return re;
1995
- } catch {
1996
- return null;
1997
- }
1998
- }
1999
-
2000
- // src/rules/index.ts
2001
- function matchesPattern(text, patterns) {
2002
- const p = Array.isArray(patterns) ? patterns : [patterns];
2003
- if (p.length === 0) return false;
2004
- const isMatch = pm(p, { nocase: true, dot: true });
2005
- const target = text.toLowerCase();
2006
- const directMatch = isMatch(target);
2007
- if (directMatch) return true;
2008
- const withoutDotSlash = text.replace(/^\.\//, "");
2009
- return isMatch(withoutDotSlash) || isMatch(`./${withoutDotSlash}`);
2010
- }
2011
- var FORBIDDEN_PATH_SEGMENTS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
2012
- function getNestedValue(obj, path) {
2013
- if (!obj || typeof obj !== "object") return null;
2014
- const segments = path.split(".");
2015
- for (const seg of segments) {
2016
- if (FORBIDDEN_PATH_SEGMENTS.has(seg)) return null;
2017
- }
2018
- return segments.reduce((prev, curr) => prev?.[curr], obj);
2019
- }
2020
- function evaluateSmartConditions(args, rule) {
2021
- if (!rule.conditions || rule.conditions.length === 0) return true;
2022
- const mode = rule.conditionMode ?? "all";
2023
- const fieldCache = /* @__PURE__ */ new Map();
2024
- const resolveField = (field) => {
2025
- if (fieldCache.has(field)) return fieldCache.get(field) ?? null;
2026
- const rawVal = getNestedValue(args, field);
2027
- const rawStr = rawVal !== null && rawVal !== void 0 ? String(rawVal) : null;
2028
- const stripped = field === "command" && rawStr !== null ? normalizeCommandForPolicy(rawStr) : rawStr;
2029
- const val = stripped !== null ? stripped.replace(/\s+/g, " ").trim() : null;
2030
- fieldCache.set(field, val);
2031
- return val;
2032
- };
2033
- const results = rule.conditions.map((cond) => {
2034
- const val = resolveField(cond.field);
2035
- switch (cond.op) {
2036
- case "exists":
2037
- return val !== null && val !== "";
2038
- case "notExists":
2039
- return val === null || val === "";
2040
- case "contains":
2041
- return val !== null && cond.value ? val.includes(cond.value) : false;
2042
- case "notContains":
2043
- return val !== null && cond.value ? !val.includes(cond.value) : true;
2044
- case "matches": {
2045
- if (val === null || !cond.value) return false;
2046
- const reM = getCompiledRegex(cond.value, cond.flags ?? "");
2047
- if (!reM) return false;
2048
- return reM.test(val);
2049
- }
2050
- case "notMatches": {
2051
- if (!cond.value) return false;
2052
- if (val === null) return true;
2053
- const reN = getCompiledRegex(cond.value, cond.flags ?? "");
2054
- if (!reN) return false;
2055
- return !reN.test(val);
2056
- }
2057
- case "matchesGlob":
2058
- return val !== null && cond.value ? pm.isMatch(val, cond.value) : false;
2059
- case "notMatchesGlob":
2060
- return val !== null && cond.value ? !pm.isMatch(val, cond.value) : false;
2061
- default:
2062
- return false;
2063
- }
2064
- });
2065
- return mode === "any" ? results.some((r) => r) : results.every((r) => r);
2066
- }
2067
-
2068
2354
  // src/policy/index.ts
2069
2355
  function resolveCheck(v) {
2070
2356
  return v === "off" || v === "block" ? v : "review";
@@ -2072,41 +2358,6 @@ function resolveCheck(v) {
2072
2358
  function resolveCheckTight(v) {
2073
2359
  return v === "block" ? "block" : "review";
2074
2360
  }
2075
- var INLINE_INTERP = /^(python[\d.]*|bash|sh|zsh|perl|ruby|node|php|lua)$/i;
2076
- var INLINE_SHELL = /^(bash|sh|zsh)$/i;
2077
- function detectInlineExec(command) {
2078
- const pipeFed = command.includes("|");
2079
- const segments = command.split(/\|\||&&|;|\||\n|\$\(|`|\(/);
2080
- for (const rawSeg of segments) {
2081
- const tokens = rawSeg.trim().split(/\s+/).filter(Boolean);
2082
- let i = 0;
2083
- while (i < tokens.length && /^[A-Za-z_]\w*=/.test(tokens[i])) i++;
2084
- if (i >= tokens.length) continue;
2085
- const base = tokens[i].split("/").pop() ?? tokens[i];
2086
- if (!INLINE_INTERP.test(base)) continue;
2087
- const args = tokens.slice(i + 1);
2088
- let hadRedirect = false;
2089
- const positionals = [];
2090
- for (let j = 0; j < args.length; j++) {
2091
- const a = args[j];
2092
- if (a === "-") return true;
2093
- if (a.startsWith("<")) {
2094
- hadRedirect = true;
2095
- if (a === "<" || a === "<<") j++;
2096
- continue;
2097
- }
2098
- if (a.startsWith("-")) {
2099
- if (/^-(c|e|eval)$/i.test(a)) return true;
2100
- continue;
2101
- }
2102
- positionals.push(a);
2103
- }
2104
- if (positionals.length === 0 && (hadRedirect || pipeFed) && !INLINE_SHELL.test(base)) {
2105
- return true;
2106
- }
2107
- }
2108
- return false;
2109
- }
2110
2361
  var VERDICT_RANK = {
2111
2362
  allow: 0,
2112
2363
  review: 1,
@@ -2120,6 +2371,12 @@ function resolvePinned(matches) {
2120
2371
  (best, r) => VERDICT_RANK[r.verdict] > VERDICT_RANK[best.verdict] ? r : best
2121
2372
  );
2122
2373
  }
2374
+ function strictestVerdict(candidates) {
2375
+ if (candidates.length === 0) return void 0;
2376
+ return candidates.reduce(
2377
+ (best, c) => VERDICT_RANK[c.decision] > VERDICT_RANK[best.decision] ? c : best
2378
+ );
2379
+ }
2123
2380
  function tokenize2(toolName) {
2124
2381
  return toolName.toLowerCase().split(/[_.\-\s]+/).filter(Boolean);
2125
2382
  }
@@ -2131,6 +2388,11 @@ function extractShellCommand(toolName, args, toolInspection) {
2131
2388
  const value = getNestedValue(args, fieldPath);
2132
2389
  return typeof value === "string" ? value : null;
2133
2390
  }
2391
+ function inspectsShellCommand(toolName, toolInspection) {
2392
+ const patterns = Object.keys(toolInspection);
2393
+ const matchingPattern = patterns.find((p) => matchesPattern(toolName, p));
2394
+ return matchingPattern !== void 0 && toolInspection[matchingPattern] === "command";
2395
+ }
2134
2396
  function isSqlTool(toolName, toolInspection) {
2135
2397
  const patterns = Object.keys(toolInspection);
2136
2398
  const matchingPattern = patterns.find((p) => matchesPattern(toolName, p));
@@ -2202,8 +2464,10 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2202
2464
  };
2203
2465
  }
2204
2466
  }
2205
- if (wouldBeIgnored) return { decision: "allow" };
2206
- const bashCommand = agent !== "Terminal" && isBashTool(toolName) && args && typeof args === "object" ? typeof args.command === "string" ? args.command : null : null;
2467
+ if (wouldBeIgnored && !context.skipIgnoredFastPath) return { decision: "allow" };
2468
+ const shellShaped = isBashTool(toolName) || inspectsShellCommand(toolName, config.policy.toolInspection);
2469
+ const shellShapedCommand = shellShaped ? isBashTool(toolName) && args && typeof args === "object" ? typeof args.command === "string" ? args.command : null : extractShellCommand(toolName, args, config.policy.toolInspection) : null;
2470
+ const bashCommand = agent !== "Terminal" ? shellShapedCommand : null;
2207
2471
  if (bashCommand !== null) {
2208
2472
  const pipeVerdict = pipeChainVerdict(
2209
2473
  bashCommand,
@@ -2253,8 +2517,14 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2253
2517
  }
2254
2518
  if (config.policy.smartRules.length > 0) {
2255
2519
  const rmCleanupWaiver = bashCommand !== null && isRmCreatedInCommandCleanup(bashCommand);
2520
+ const astSuppressed = (rule) => {
2521
+ if (bashCommand === null || !rule.name || !AST_FS_REGEX_RULES.has(rule.name)) return false;
2522
+ 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;
2523
+ if (knob === "off" && rule.pinned) return false;
2524
+ return true;
2525
+ };
2256
2526
  const matches = config.policy.smartRules.filter(
2257
- (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)
2527
+ (rule) => toolMatchesRule(toolName, rule.tool, config.policy.toolInspection) && !astSuppressed(rule) && !(rmCleanupWaiver && rule.name === "review-rm" && rule.verdict === "review") && evaluateSmartConditions(args, rule)
2258
2528
  );
2259
2529
  const matchedRule = resolvePinned(matches);
2260
2530
  if (matchedRule) {
@@ -2285,15 +2555,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2285
2555
  const analyzed = analyzeShellCommand(shellCommand);
2286
2556
  allTokens = analyzed.allTokens;
2287
2557
  pathTokens = analyzed.paths;
2288
- const inlineAction = resolveCheck(config.policy.commandChecks?.inlineExec);
2289
- if (inlineAction !== "off" && detectInlineExec(shellCommand)) {
2290
- return {
2291
- decision: inlineAction === "block" ? "block" : "review",
2292
- blockedByLabel: "Node9 Standard (Inline Execution)",
2293
- ruleDescription: "The AI is running code directly from the command line. Review the full script below before allowing it to execute.",
2294
- tier: 3
2295
- };
2296
- }
2558
+ const candidates = [];
2297
2559
  const evalVerdict = detectDangerousShellExec(shellCommand);
2298
2560
  if (evalVerdict === "block") {
2299
2561
  return {
@@ -2304,24 +2566,33 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2304
2566
  tier: 3
2305
2567
  };
2306
2568
  }
2569
+ const ptVerdict = pipeChainVerdict(
2570
+ shellCommand,
2571
+ isTrustedHost,
2572
+ resolveCheckTight(config.policy.commandChecks?.pipeChainHigh)
2573
+ );
2574
+ if (ptVerdict?.decision === "allow") return ptVerdict;
2575
+ if (ptVerdict) candidates.push(ptVerdict);
2576
+ const inlineAction = resolveCheck(config.policy.commandChecks?.inlineExec);
2577
+ if (inlineAction !== "off" && detectInlineExec(shellCommand)) {
2578
+ candidates.push({
2579
+ decision: inlineAction === "block" ? "block" : "review",
2580
+ blockedByLabel: "Node9 Standard (Inline Execution)",
2581
+ ruleDescription: "The AI is running code directly from the command line. Review the full script below before allowing it to execute.",
2582
+ tier: 3
2583
+ });
2584
+ }
2307
2585
  if (evalVerdict === "review") {
2308
- return {
2309
- // Class B tighten-only: commandChecks.evalDynamic may upgrade to
2310
- // block but can never turn this off (eval-remote above is Class A —
2311
- // no knob at all).
2586
+ candidates.push({
2312
2587
  decision: resolveCheckTight(config.policy.commandChecks?.evalDynamic),
2313
2588
  blockedByLabel: "Node9: Eval Dynamic Content",
2314
2589
  reason: "eval of dynamic content (variable or subshell expansion) requires approval",
2315
2590
  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.",
2316
2591
  tier: 3
2317
- };
2592
+ });
2318
2593
  }
2319
- const ptVerdict = pipeChainVerdict(
2320
- shellCommand,
2321
- isTrustedHost,
2322
- resolveCheckTight(config.policy.commandChecks?.pipeChainHigh)
2323
- );
2324
- if (ptVerdict) return ptVerdict;
2594
+ const builtin = strictestVerdict(candidates);
2595
+ if (builtin) return builtin;
2325
2596
  if (config.policy.egress?.enabled) {
2326
2597
  const dests = extractShellDestinations(shellCommand);
2327
2598
  if (dests.length > 0) {
@@ -3586,15 +3857,15 @@ function detectArgsPii(args) {
3586
3857
 
3587
3858
  // src/scan/canonical.ts
3588
3859
  var LONG_OUTPUT_THRESHOLD_BYTES = 100 * 1024;
3589
- var CANONICAL_EXTRACTOR_VERSION = "canonical-v6";
3590
- var CANONICAL_EXTRACTOR_HASH = "a0e2bb339fe67e19";
3860
+ var CANONICAL_EXTRACTOR_VERSION = "canonical-v9";
3861
+ var CANONICAL_EXTRACTOR_HASH = "4ebf40dfe1d7c0a1";
3591
3862
  var DEDUPE_PREVIEW_LEN = 120;
3592
3863
  function extractCanonicalFindings(call, ctx) {
3593
3864
  const out = [];
3594
3865
  const ts = call.timestamp;
3595
3866
  const toolNameLower = call.toolName.toLowerCase();
3596
3867
  const command = typeof call.args.command === "string" ? call.args.command : null;
3597
- const isBash = isBashTool(call.toolName) && command !== null;
3868
+ const isShell = isShellShapedTool(call.toolName, ctx.toolInspection) && command !== null;
3598
3869
  if (call.outputBytes !== void 0 && call.outputBytes > LONG_OUTPUT_THRESHOLD_BYTES) {
3599
3870
  out.push(
3600
3871
  makeFinding({
@@ -3669,7 +3940,7 @@ function extractCanonicalFindings(call, ctx) {
3669
3940
  );
3670
3941
  }
3671
3942
  }
3672
- if (!isBash || command === null) {
3943
+ if (!isShell || command === null) {
3673
3944
  return out;
3674
3945
  }
3675
3946
  const fsVerdict = analyzeFsOperation(command);
@@ -3695,7 +3966,7 @@ function extractCanonicalFindings(call, ctx) {
3695
3966
  for (const source of ctx.rules) {
3696
3967
  const r = source.rule;
3697
3968
  if (r.verdict === "allow") continue;
3698
- if (r.tool && !matchesPattern(toolNameLower, r.tool)) continue;
3969
+ if (!toolMatchesRule(toolNameLower, r.tool, ctx.toolInspection)) continue;
3699
3970
  if (r.name && AST_FS_REGEX_RULES.has(r.name)) continue;
3700
3971
  if (!evaluateSmartConditions(call.args, r)) continue;
3701
3972
  out.push(
@@ -3971,6 +4242,7 @@ export {
3971
4242
  detectArgsPii,
3972
4243
  detectDangerousEval,
3973
4244
  detectDangerousShellExec,
4245
+ detectInlineExec,
3974
4246
  detectPii,
3975
4247
  evaluateEgress,
3976
4248
  evaluateLoopWindow,
@@ -3989,6 +4261,7 @@ export {
3989
4261
  isIgnoredTool,
3990
4262
  isPrivateHost,
3991
4263
  isProtectedHomePath,
4264
+ isShellShapedTool,
3992
4265
  isShieldVerdict,
3993
4266
  matchSensitivePath,
3994
4267
  matchesPattern,
@@ -4006,6 +4279,7 @@ export {
4006
4279
  summarizeBlast,
4007
4280
  summarizeScan,
4008
4281
  toScanFinding,
4282
+ toolMatchesRule,
4009
4283
  truncateBlastPath,
4010
4284
  validateOverrides,
4011
4285
  validateRegex,