@node9/policy-engine 1.67.1 → 1.67.2

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) {
@@ -1077,6 +1219,208 @@ function chmodHasOpenPermMode(command) {
1077
1219
  }
1078
1220
  return found;
1079
1221
  }
1222
+ function isShellShapedTool(toolName, toolInspection) {
1223
+ if (isBashTool(toolName)) return true;
1224
+ if (!toolInspection) return false;
1225
+ const pattern = Object.keys(toolInspection).find((p) => matchesPattern(toolName, p));
1226
+ return pattern !== void 0 && toolInspection[pattern] === "command";
1227
+ }
1228
+ function toolMatchesRule(toolName, ruleTool, toolInspection) {
1229
+ if (!ruleTool) return true;
1230
+ if (matchesPattern(toolName, ruleTool)) return true;
1231
+ return isShellShapedTool(toolName, toolInspection) && matchesPattern("bash", ruleTool);
1232
+ }
1233
+ 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;
1234
+ var RUNNER_WRAPPERS = /* @__PURE__ */ new Set([
1235
+ "uv",
1236
+ "uvx",
1237
+ "poetry",
1238
+ "pipenv",
1239
+ "pdm",
1240
+ "rye",
1241
+ "hatch",
1242
+ "conda",
1243
+ "mamba",
1244
+ "micromamba",
1245
+ "npx",
1246
+ "pnpm",
1247
+ "yarn",
1248
+ "bunx",
1249
+ "watch",
1250
+ "strace",
1251
+ "ltrace",
1252
+ "chroot",
1253
+ "unshare",
1254
+ "runuser"
1255
+ ]);
1256
+ function isInlineCodeFlag(interp, w) {
1257
+ const lw = w.toLowerCase();
1258
+ if (lw === "--eval" || lw === "--command" || lw === "--print") return true;
1259
+ if (/^(pwsh|powershell)/.test(interp)) return /^-(c|command|e|ec|enc|encodedcommand)$/i.test(lw);
1260
+ if (!w.startsWith("-") || w.startsWith("--")) return false;
1261
+ const codeLetters = /^(perl|ruby|lua|bun|osascript|rscript|irb)$/.test(interp) ? "e" : /^(node|tsx|ts-node)$/.test(interp) ? "ep" : interp === "php" ? "r" : "c";
1262
+ const body = lw.slice(1);
1263
+ const cutAt = /^(perl|ruby|node|tsx|ts-node)$/.test(interp) ? /[mirw]|[^a-z0-9]/ : /[^a-z0-9]/;
1264
+ const cut = body.search(cutAt);
1265
+ const bundle = cut >= 0 ? body.slice(0, cut) : body;
1266
+ return [...codeLetters].some((l) => bundle.includes(l));
1267
+ }
1268
+ var _redirStdinOps = null;
1269
+ function redirStdinOps() {
1270
+ if (_redirStdinOps) return _redirStdinOps;
1271
+ _redirStdinOps = new Set(
1272
+ [
1273
+ deriveRedirOp("cat <<X\nX"),
1274
+ deriveRedirOp("cat <<-X\nX"),
1275
+ deriveRedirOp("cat < f"),
1276
+ deriveRedirOp("cat <<< x")
1277
+ ].filter((op) => op >= 0)
1278
+ );
1279
+ return _redirStdinOps;
1280
+ }
1281
+ function deriveBinaryOp(sample) {
1282
+ try {
1283
+ const f = sharedParser.Parse(sample, "cmd");
1284
+ let op = -1;
1285
+ syntax.Walk(f, (node) => {
1286
+ const n = node;
1287
+ if (n && syntax.NodeType(n) === "BinaryCmd" && op < 0) op = n.Op;
1288
+ return true;
1289
+ });
1290
+ return op;
1291
+ } catch {
1292
+ return -1;
1293
+ }
1294
+ }
1295
+ var _listOps = null;
1296
+ function listOps() {
1297
+ if (_listOps) return _listOps;
1298
+ _listOps = new Set(
1299
+ [deriveBinaryOp("a && b"), deriveBinaryOp("a || b")].filter((o) => o >= 0)
1300
+ );
1301
+ return _listOps;
1302
+ }
1303
+ var WRAPPER_TAKES_TARGET = /* @__PURE__ */ new Set(["chroot"]);
1304
+ var INTERP_LEADING_TARGET = /* @__PURE__ */ new Set(["su"]);
1305
+ function unwrapCommandHead(words) {
1306
+ let i = 0;
1307
+ while (i < words.length) {
1308
+ const head = (words[i] ?? "").toLowerCase().split("/").pop() ?? "";
1309
+ if (head === "find") {
1310
+ const x = words.findIndex(
1311
+ (w, k) => k > i && (w === "-exec" || w === "-execdir" || w === "-ok")
1312
+ );
1313
+ if (x < 0) break;
1314
+ i = x + 1;
1315
+ continue;
1316
+ }
1317
+ if (!COMMAND_WRAPPERS.has(head) && !RUNNER_WRAPPERS.has(head)) break;
1318
+ i++;
1319
+ let targetConsumed = false;
1320
+ while (i < words.length) {
1321
+ const t = words[i];
1322
+ if (t === null) {
1323
+ i++;
1324
+ continue;
1325
+ }
1326
+ const lt = t.toLowerCase();
1327
+ if (/^[A-Za-z_]\w*=/.test(t)) {
1328
+ i++;
1329
+ continue;
1330
+ }
1331
+ if (t.startsWith("-")) {
1332
+ i++;
1333
+ const nxt = words[i];
1334
+ if (nxt != null && !nxt.startsWith("-") && !INLINE_INTERPRETER.test(nxt.split("/").pop() ?? "") && !COMMAND_WRAPPERS.has(nxt.toLowerCase()) && !RUNNER_WRAPPERS.has(nxt.toLowerCase()))
1335
+ i++;
1336
+ continue;
1337
+ }
1338
+ if (lt === "run" || lt === "exec" || lt === "dlx" || /^\d+(\.\d+)?[smhd]?$/.test(lt)) {
1339
+ i++;
1340
+ continue;
1341
+ }
1342
+ if (!targetConsumed && WRAPPER_TAKES_TARGET.has(head)) {
1343
+ targetConsumed = true;
1344
+ i++;
1345
+ continue;
1346
+ }
1347
+ break;
1348
+ }
1349
+ }
1350
+ return i;
1351
+ }
1352
+ function inlineExecStmt(stmt, pipeFed) {
1353
+ const cmd = stmt?.Cmd;
1354
+ if (!cmd || syntax.NodeType(cmd) !== "CallExpr") return false;
1355
+ const words = (cmd.Args || []).map((a) => resolveWordLiteral(a));
1356
+ if (words.length === 0) return false;
1357
+ const headIdx = unwrapCommandHead(words);
1358
+ const rawHead = words[headIdx];
1359
+ if (rawHead == null) return false;
1360
+ const interp = (rawHead.split("/").pop() ?? "").toLowerCase();
1361
+ if (!INLINE_INTERPRETER.test(interp)) return false;
1362
+ let args = words.slice(headIdx + 1);
1363
+ if (interp === "deno" && (args[0] ?? "").toLowerCase() === "eval") return true;
1364
+ if (INTERP_LEADING_TARGET.has(interp)) {
1365
+ const firstFlag = args.findIndex((a) => a == null || a.startsWith("-"));
1366
+ args = firstFlag >= 0 ? args.slice(firstFlag) : [];
1367
+ }
1368
+ let positionals = 0;
1369
+ let selectedProgram = false;
1370
+ for (const a of args) {
1371
+ if (a == null) {
1372
+ positionals++;
1373
+ selectedProgram = true;
1374
+ continue;
1375
+ }
1376
+ if (!selectedProgram && isInlineCodeFlag(interp, a)) return true;
1377
+ if (a === "-m") {
1378
+ selectedProgram = true;
1379
+ continue;
1380
+ }
1381
+ if (a === "-" && !selectedProgram) return true;
1382
+ if (!a.startsWith("-")) {
1383
+ positionals++;
1384
+ selectedProgram = true;
1385
+ }
1386
+ }
1387
+ const redirs = stmt.Redirs || cmd.Redirs || [];
1388
+ const stdinFed = redirs.some((r) => r && redirStdinOps().has(r.Op));
1389
+ if (positionals === 0 && (stdinFed || pipeFed)) {
1390
+ if (!/^(bash|sh|zsh)$/i.test(interp)) return true;
1391
+ }
1392
+ return false;
1393
+ }
1394
+ function detectInlineExec(command) {
1395
+ const f = parseShared(command);
1396
+ if (f === PARSE_FAIL) {
1397
+ 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(
1398
+ command
1399
+ );
1400
+ }
1401
+ let found = false;
1402
+ try {
1403
+ syntax.Walk(f, (node) => {
1404
+ if (!node || found) return false;
1405
+ const n = node;
1406
+ const t = syntax.NodeType(n);
1407
+ if (t === "BinaryCmd" && n.Y && !listOps().has(n.Op)) {
1408
+ if (inlineExecStmt(n.Y, true)) {
1409
+ found = true;
1410
+ return false;
1411
+ }
1412
+ }
1413
+ if (t === "Stmt" && inlineExecStmt(n, false)) {
1414
+ found = true;
1415
+ return false;
1416
+ }
1417
+ return true;
1418
+ });
1419
+ } catch {
1420
+ return found;
1421
+ }
1422
+ return found;
1423
+ }
1080
1424
  function analyzeChmod777(command) {
1081
1425
  if (!/chmod/i.test(command.replace(/[\\'"]/g, ""))) return null;
1082
1426
  if (!chmodHasOpenPermMode(command)) return null;
@@ -1954,117 +2298,6 @@ function parseAllSshHostsFromCommand(command) {
1954
2298
  return extractAllSshHosts(tokens.slice(1));
1955
2299
  }
1956
2300
 
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
2301
  // src/policy/index.ts
2069
2302
  function resolveCheck(v) {
2070
2303
  return v === "off" || v === "block" ? v : "review";
@@ -2072,41 +2305,6 @@ function resolveCheck(v) {
2072
2305
  function resolveCheckTight(v) {
2073
2306
  return v === "block" ? "block" : "review";
2074
2307
  }
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
2308
  var VERDICT_RANK = {
2111
2309
  allow: 0,
2112
2310
  review: 1,
@@ -2120,6 +2318,12 @@ function resolvePinned(matches) {
2120
2318
  (best, r) => VERDICT_RANK[r.verdict] > VERDICT_RANK[best.verdict] ? r : best
2121
2319
  );
2122
2320
  }
2321
+ function strictestVerdict(candidates) {
2322
+ if (candidates.length === 0) return void 0;
2323
+ return candidates.reduce(
2324
+ (best, c) => VERDICT_RANK[c.decision] > VERDICT_RANK[best.decision] ? c : best
2325
+ );
2326
+ }
2123
2327
  function tokenize2(toolName) {
2124
2328
  return toolName.toLowerCase().split(/[_.\-\s]+/).filter(Boolean);
2125
2329
  }
@@ -2131,6 +2335,11 @@ function extractShellCommand(toolName, args, toolInspection) {
2131
2335
  const value = getNestedValue(args, fieldPath);
2132
2336
  return typeof value === "string" ? value : null;
2133
2337
  }
2338
+ function inspectsShellCommand(toolName, toolInspection) {
2339
+ const patterns = Object.keys(toolInspection);
2340
+ const matchingPattern = patterns.find((p) => matchesPattern(toolName, p));
2341
+ return matchingPattern !== void 0 && toolInspection[matchingPattern] === "command";
2342
+ }
2134
2343
  function isSqlTool(toolName, toolInspection) {
2135
2344
  const patterns = Object.keys(toolInspection);
2136
2345
  const matchingPattern = patterns.find((p) => matchesPattern(toolName, p));
@@ -2202,8 +2411,10 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2202
2411
  };
2203
2412
  }
2204
2413
  }
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;
2414
+ if (wouldBeIgnored && !context.skipIgnoredFastPath) return { decision: "allow" };
2415
+ const shellShaped = isBashTool(toolName) || inspectsShellCommand(toolName, config.policy.toolInspection);
2416
+ const shellShapedCommand = shellShaped ? isBashTool(toolName) && args && typeof args === "object" ? typeof args.command === "string" ? args.command : null : extractShellCommand(toolName, args, config.policy.toolInspection) : null;
2417
+ const bashCommand = agent !== "Terminal" ? shellShapedCommand : null;
2207
2418
  if (bashCommand !== null) {
2208
2419
  const pipeVerdict = pipeChainVerdict(
2209
2420
  bashCommand,
@@ -2253,8 +2464,14 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2253
2464
  }
2254
2465
  if (config.policy.smartRules.length > 0) {
2255
2466
  const rmCleanupWaiver = bashCommand !== null && isRmCreatedInCommandCleanup(bashCommand);
2467
+ const astSuppressed = (rule) => {
2468
+ if (bashCommand === null || !rule.name || !AST_FS_REGEX_RULES.has(rule.name)) return false;
2469
+ 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;
2470
+ if (knob === "off" && rule.pinned) return false;
2471
+ return true;
2472
+ };
2256
2473
  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)
2474
+ (rule) => toolMatchesRule(toolName, rule.tool, config.policy.toolInspection) && !astSuppressed(rule) && !(rmCleanupWaiver && rule.name === "review-rm" && rule.verdict === "review") && evaluateSmartConditions(args, rule)
2258
2475
  );
2259
2476
  const matchedRule = resolvePinned(matches);
2260
2477
  if (matchedRule) {
@@ -2285,15 +2502,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2285
2502
  const analyzed = analyzeShellCommand(shellCommand);
2286
2503
  allTokens = analyzed.allTokens;
2287
2504
  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
- }
2505
+ const candidates = [];
2297
2506
  const evalVerdict = detectDangerousShellExec(shellCommand);
2298
2507
  if (evalVerdict === "block") {
2299
2508
  return {
@@ -2304,24 +2513,33 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
2304
2513
  tier: 3
2305
2514
  };
2306
2515
  }
2516
+ const ptVerdict = pipeChainVerdict(
2517
+ shellCommand,
2518
+ isTrustedHost,
2519
+ resolveCheckTight(config.policy.commandChecks?.pipeChainHigh)
2520
+ );
2521
+ if (ptVerdict?.decision === "allow") return ptVerdict;
2522
+ if (ptVerdict) candidates.push(ptVerdict);
2523
+ const inlineAction = resolveCheck(config.policy.commandChecks?.inlineExec);
2524
+ if (inlineAction !== "off" && detectInlineExec(shellCommand)) {
2525
+ candidates.push({
2526
+ decision: inlineAction === "block" ? "block" : "review",
2527
+ blockedByLabel: "Node9 Standard (Inline Execution)",
2528
+ ruleDescription: "The AI is running code directly from the command line. Review the full script below before allowing it to execute.",
2529
+ tier: 3
2530
+ });
2531
+ }
2307
2532
  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).
2533
+ candidates.push({
2312
2534
  decision: resolveCheckTight(config.policy.commandChecks?.evalDynamic),
2313
2535
  blockedByLabel: "Node9: Eval Dynamic Content",
2314
2536
  reason: "eval of dynamic content (variable or subshell expansion) requires approval",
2315
2537
  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
2538
  tier: 3
2317
- };
2539
+ });
2318
2540
  }
2319
- const ptVerdict = pipeChainVerdict(
2320
- shellCommand,
2321
- isTrustedHost,
2322
- resolveCheckTight(config.policy.commandChecks?.pipeChainHigh)
2323
- );
2324
- if (ptVerdict) return ptVerdict;
2541
+ const builtin = strictestVerdict(candidates);
2542
+ if (builtin) return builtin;
2325
2543
  if (config.policy.egress?.enabled) {
2326
2544
  const dests = extractShellDestinations(shellCommand);
2327
2545
  if (dests.length > 0) {
@@ -3586,15 +3804,15 @@ function detectArgsPii(args) {
3586
3804
 
3587
3805
  // src/scan/canonical.ts
3588
3806
  var LONG_OUTPUT_THRESHOLD_BYTES = 100 * 1024;
3589
- var CANONICAL_EXTRACTOR_VERSION = "canonical-v6";
3590
- var CANONICAL_EXTRACTOR_HASH = "a0e2bb339fe67e19";
3807
+ var CANONICAL_EXTRACTOR_VERSION = "canonical-v8";
3808
+ var CANONICAL_EXTRACTOR_HASH = "80f40f974263b281";
3591
3809
  var DEDUPE_PREVIEW_LEN = 120;
3592
3810
  function extractCanonicalFindings(call, ctx) {
3593
3811
  const out = [];
3594
3812
  const ts = call.timestamp;
3595
3813
  const toolNameLower = call.toolName.toLowerCase();
3596
3814
  const command = typeof call.args.command === "string" ? call.args.command : null;
3597
- const isBash = isBashTool(call.toolName) && command !== null;
3815
+ const isShell = isShellShapedTool(call.toolName, ctx.toolInspection) && command !== null;
3598
3816
  if (call.outputBytes !== void 0 && call.outputBytes > LONG_OUTPUT_THRESHOLD_BYTES) {
3599
3817
  out.push(
3600
3818
  makeFinding({
@@ -3669,7 +3887,7 @@ function extractCanonicalFindings(call, ctx) {
3669
3887
  );
3670
3888
  }
3671
3889
  }
3672
- if (!isBash || command === null) {
3890
+ if (!isShell || command === null) {
3673
3891
  return out;
3674
3892
  }
3675
3893
  const fsVerdict = analyzeFsOperation(command);
@@ -3695,7 +3913,7 @@ function extractCanonicalFindings(call, ctx) {
3695
3913
  for (const source of ctx.rules) {
3696
3914
  const r = source.rule;
3697
3915
  if (r.verdict === "allow") continue;
3698
- if (r.tool && !matchesPattern(toolNameLower, r.tool)) continue;
3916
+ if (!toolMatchesRule(toolNameLower, r.tool, ctx.toolInspection)) continue;
3699
3917
  if (r.name && AST_FS_REGEX_RULES.has(r.name)) continue;
3700
3918
  if (!evaluateSmartConditions(call.args, r)) continue;
3701
3919
  out.push(
@@ -3971,6 +4189,7 @@ export {
3971
4189
  detectArgsPii,
3972
4190
  detectDangerousEval,
3973
4191
  detectDangerousShellExec,
4192
+ detectInlineExec,
3974
4193
  detectPii,
3975
4194
  evaluateEgress,
3976
4195
  evaluateLoopWindow,
@@ -3989,6 +4208,7 @@ export {
3989
4208
  isIgnoredTool,
3990
4209
  isPrivateHost,
3991
4210
  isProtectedHomePath,
4211
+ isShellShapedTool,
3992
4212
  isShieldVerdict,
3993
4213
  matchSensitivePath,
3994
4214
  matchesPattern,
@@ -4006,6 +4226,7 @@ export {
4006
4226
  summarizeBlast,
4007
4227
  summarizeScan,
4008
4228
  toScanFinding,
4229
+ toolMatchesRule,
4009
4230
  truncateBlastPath,
4010
4231
  validateOverrides,
4011
4232
  validateRegex,