@node9/policy-engine 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -621,9 +621,70 @@ var MESSAGE_FLAGS = /* @__PURE__ */ new Set([
621
621
  ]);
622
622
  var SHELL_INTERPRETERS = /* @__PURE__ */ new Set(["bash", "sh", "zsh", "fish", "dash", "ksh"]);
623
623
  var DOWNLOAD_CMDS = /* @__PURE__ */ new Set(["curl", "wget"]);
624
+ function isCatHeredocOrLit(part) {
625
+ if (!part) return false;
626
+ const t = syntax.NodeType(part);
627
+ if (t === "Lit") return true;
628
+ if (t !== "CmdSubst") return false;
629
+ const stmts = part.Stmts || [];
630
+ if (stmts.length !== 1) return false;
631
+ const stmt = stmts[0];
632
+ const redirs = stmt.Redirs || stmt.Cmd?.Redirs || [];
633
+ const hasHeredoc = redirs.some((r) => r && r.Hdoc);
634
+ if (!hasHeredoc) return false;
635
+ const cmd = stmt.Cmd;
636
+ if (!cmd || syntax.NodeType(cmd) !== "CallExpr") return false;
637
+ const firstArg = cmd.Args?.[0]?.Parts || [];
638
+ if (firstArg.length !== 1 || syntax.NodeType(firstArg[0]) !== "Lit") return false;
639
+ return (firstArg[0].Value || "").toLowerCase() === "cat";
640
+ }
641
+ var NORMALIZE_CACHE_MAX = 5e3;
642
+ var normalizeCache = /* @__PURE__ */ new Map();
643
+ var AST_CACHE_MAX = 5e3;
644
+ var astCache = /* @__PURE__ */ new Map();
645
+ var PARSE_FAIL = /* @__PURE__ */ Symbol("parse-fail");
646
+ function parseShared(command) {
647
+ const cached = astCache.get(command);
648
+ if (cached !== void 0) {
649
+ astCache.delete(command);
650
+ astCache.set(command, cached);
651
+ return cached;
652
+ }
653
+ let parsed;
654
+ try {
655
+ parsed = sharedParser.Parse(command, "cmd");
656
+ } catch {
657
+ parsed = PARSE_FAIL;
658
+ }
659
+ if (astCache.size >= AST_CACHE_MAX) {
660
+ const oldest = astCache.keys().next().value;
661
+ if (oldest !== void 0) astCache.delete(oldest);
662
+ }
663
+ astCache.set(command, parsed);
664
+ return parsed;
665
+ }
666
+ function cachedNormalize(command, compute) {
667
+ const hit = normalizeCache.get(command);
668
+ if (hit !== void 0) {
669
+ normalizeCache.delete(command);
670
+ normalizeCache.set(command, hit);
671
+ return hit;
672
+ }
673
+ const result = compute();
674
+ if (normalizeCache.size >= NORMALIZE_CACHE_MAX) {
675
+ const oldest = normalizeCache.keys().next().value;
676
+ if (oldest !== void 0) normalizeCache.delete(oldest);
677
+ }
678
+ normalizeCache.set(command, result);
679
+ return result;
680
+ }
624
681
  function normalizeCommandForPolicy(command) {
682
+ return cachedNormalize(command, () => normalizeCommandForPolicyImpl(command));
683
+ }
684
+ function normalizeCommandForPolicyImpl(command) {
685
+ const f = parseShared(command);
686
+ if (f === PARSE_FAIL) return command;
625
687
  try {
626
- const f = sharedParser.Parse(command, "cmd");
627
688
  const strips = [];
628
689
  syntax.Walk(f, (node) => {
629
690
  if (!node) return false;
@@ -645,7 +706,11 @@ function normalizeCommandForPolicy(command) {
645
706
  } else if (nt === "DblQuoted") {
646
707
  const innerParts = quotedNode.Parts || [];
647
708
  const allLit = innerParts.length === 0 || innerParts.every((p) => syntax.NodeType(p) === "Lit");
648
- if (allLit) strips.push([next.Pos().Offset(), next.End().Offset()]);
709
+ if (allLit) {
710
+ strips.push([next.Pos().Offset(), next.End().Offset()]);
711
+ } else if (innerParts.every((p) => isCatHeredocOrLit(p))) {
712
+ strips.push([next.Pos().Offset(), next.End().Offset()]);
713
+ }
649
714
  }
650
715
  }
651
716
  return true;
@@ -714,6 +779,242 @@ function detectDangerousShellExec(command) {
714
779
  }
715
780
  }
716
781
  var detectDangerousEval = detectDangerousShellExec;
782
+ var FS_READ_TOOLS = /* @__PURE__ */ new Set([
783
+ "cat",
784
+ "less",
785
+ "head",
786
+ "tail",
787
+ "bat",
788
+ "more",
789
+ "open",
790
+ "print",
791
+ "nano",
792
+ "vim",
793
+ "vi",
794
+ "emacs",
795
+ "code",
796
+ "type"
797
+ ]);
798
+ var FS_OP_PRESCREEN_RE = /(?:^|[\s|;&(`\n])(?:rm|cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type)\b/;
799
+ var HOME_CACHE_ALLOWLIST = [
800
+ ".cache",
801
+ ".npm/_npx",
802
+ ".npm/_cacache",
803
+ ".cargo/registry",
804
+ ".gradle/caches",
805
+ ".gradle/.tmp",
806
+ ".m2/repository",
807
+ ".pnpm-store",
808
+ ".yarn/cache",
809
+ ".yarn/.cache",
810
+ ".cache/pip",
811
+ ".local/share/Trash",
812
+ ".rustup/downloads"
813
+ ];
814
+ var SENSITIVE_PATH_RULES = [
815
+ {
816
+ rule: "shield:project-jail:block-read-ssh",
817
+ reason: "Reading SSH private keys is blocked by project-jail shield",
818
+ match: (p) => /(^|[\\/])\.ssh[\\/]/i.test(p)
819
+ },
820
+ {
821
+ rule: "shield:project-jail:block-read-aws",
822
+ reason: "Reading AWS credentials is blocked by project-jail shield",
823
+ match: (p) => /(^|[\\/])\.aws[\\/]/i.test(p)
824
+ },
825
+ {
826
+ // Mirrors the JSON shield's `.env` pattern (project-jail.json's
827
+ // review-read-env-any-tool) so the AST FS-op path catches the
828
+ // same set the regex shield does — including Next.js / Vite's
829
+ // `.env.<env>.local` double-suffix overrides which are commonly
830
+ // gitignored AND commonly contain real secrets.
831
+ //
832
+ // Intentional non-matches (dev fixtures): .env.example, .env.sample,
833
+ // .env.template, .env.test, .envrc. See shields.test.ts:983-995
834
+ // for the canonical test-asserted contract.
835
+ rule: "shield:project-jail:block-read-env",
836
+ reason: "Reading .env files is blocked by project-jail shield",
837
+ match: (p) => /(?:^|[\\/])\.env(?:\.(?:local|production|staging|development|production\.local|staging\.local|development\.local))?$/i.test(
838
+ p
839
+ )
840
+ },
841
+ {
842
+ // verdict: 'review' (not 'block') is a deliberate design choice
843
+ // documented in commit 29327a8. SSH keys and AWS credentials are
844
+ // cryptographic material with no legitimate read use-case for
845
+ // an AI agent → hard `block`. But .netrc / .npmrc / .docker /
846
+ // .kube / gcloud are CONFIG files that hold tokens AND have
847
+ // legitimate diagnostic reads ("which registry am I configured
848
+ // for", "what cluster am I on"). Hard-blocking those creates
849
+ // friction without much safety win because the review gate
850
+ // still catches genuine exfiltration attempts.
851
+ //
852
+ // The review gate FAILS CLOSED on timeout (daemon.approvalTimeoutMs
853
+ // returns a deny verdict via the orchestrator's timeout branch),
854
+ // so a stuck or unattended approval does NOT silently grant
855
+ // credential access. If the threat model demands strict block,
856
+ // a future per-shield strict-mode toggle is the right fix —
857
+ // not a regex-level upgrade here.
858
+ rule: "shield:project-jail:review-read-credentials",
859
+ reason: "Reading credential files requires approval (project-jail shield)",
860
+ verdict: "review",
861
+ match: (p) => (
862
+ // .kube/config holds Kubernetes cluster credentials and was
863
+ // flagged as missing by the node9-pr-agent review (the comment
864
+ // above mentioned .kube but the regex didn't include it — a
865
+ // textbook code-comment vs code drift). The JSON shield's
866
+ // review-read-credentials-any-tool already had it. Now aligned.
867
+ /(?:credentials\.json|\.netrc|\.npmrc|\.docker[\\/]config\.json|gcloud[\\/]credentials|\.kube[\\/]config)$/i.test(
868
+ p
869
+ )
870
+ )
871
+ }
872
+ ];
873
+ var BASH_TOOL_NAMES = /* @__PURE__ */ new Set([
874
+ "bash",
875
+ "execute_bash",
876
+ "run_shell_command",
877
+ "shell",
878
+ "exec_command"
879
+ ]);
880
+ function isBashTool(toolName) {
881
+ return BASH_TOOL_NAMES.has(toolName.toLowerCase());
882
+ }
883
+ var AST_FS_REGEX_RULES = /* @__PURE__ */ new Set([
884
+ "block-rm-rf-home",
885
+ "shield:project-jail:block-read-ssh",
886
+ "shield:project-jail:block-read-aws",
887
+ "shield:project-jail:block-read-env",
888
+ "shield:project-jail:review-read-credentials"
889
+ ]);
890
+ function isProtectedHomePath(rawPath) {
891
+ let p = rawPath.replace(/^\$HOME[\\/]?|^\$\{HOME\}[\\/]?/, "~/");
892
+ let underHome = false;
893
+ if (p === "~" || p.startsWith("~/") || p.startsWith("~\\")) {
894
+ p = p.replace(/^~[\\/]?/, "");
895
+ underHome = true;
896
+ } else if (/^\/home\/[^/]+/.test(p) || /^\/root(\/|$)/.test(p)) {
897
+ p = p.replace(/^\/home\/[^/]+[\\/]?|^\/root[\\/]?/, "");
898
+ underHome = true;
899
+ }
900
+ if (!underHome) return false;
901
+ if (p === "" || p === "." || p === "./") return true;
902
+ for (const safe of HOME_CACHE_ALLOWLIST) {
903
+ if (p === safe || p.startsWith(safe + "/") || p.startsWith(safe + "\\")) {
904
+ return false;
905
+ }
906
+ }
907
+ return true;
908
+ }
909
+ function extractLiteralArgs(callExpr) {
910
+ const args = callExpr.Args || [];
911
+ if (args.length === 0) return { name: "", flags: [], paths: [] };
912
+ const litFromWord = (w) => {
913
+ const parts = w?.Parts || [];
914
+ let s = "";
915
+ for (const p of parts) {
916
+ const t = syntax.NodeType(p);
917
+ if (t === "Lit") s += (p.Value ?? "").replace(/\\(.)/g, "$1");
918
+ else if (t === "SglQuoted") s += p.Value ?? "";
919
+ else if (t === "DblQuoted") {
920
+ const inner = p.Parts || [];
921
+ if (!inner.every((ip) => syntax.NodeType(ip) === "Lit")) return null;
922
+ s += inner.map((ip) => ip.Value ?? "").join("");
923
+ } else {
924
+ return null;
925
+ }
926
+ }
927
+ return s;
928
+ };
929
+ const name = (litFromWord(args[0]) || "").toLowerCase();
930
+ const flags = [];
931
+ const paths = [];
932
+ for (let i = 1; i < args.length; i++) {
933
+ const v = litFromWord(args[i]);
934
+ if (v === null) continue;
935
+ if (v.startsWith("-")) flags.push(v);
936
+ else paths.push(v);
937
+ }
938
+ return { name, flags, paths };
939
+ }
940
+ var FS_OP_CACHE_MAX = 5e3;
941
+ var fsOpCache = /* @__PURE__ */ new Map();
942
+ function analyzeFsOperation(command) {
943
+ if (!FS_OP_PRESCREEN_RE.test(command)) return null;
944
+ if (fsOpCache.has(command)) {
945
+ const hit = fsOpCache.get(command) ?? null;
946
+ fsOpCache.delete(command);
947
+ fsOpCache.set(command, hit);
948
+ return hit;
949
+ }
950
+ const computed = analyzeFsOperationImpl(command);
951
+ if (fsOpCache.size >= FS_OP_CACHE_MAX) {
952
+ const oldest = fsOpCache.keys().next().value;
953
+ if (oldest !== void 0) fsOpCache.delete(oldest);
954
+ }
955
+ fsOpCache.set(command, computed);
956
+ return computed;
957
+ }
958
+ function analyzeFsOperationImpl(command) {
959
+ const f = parseShared(command);
960
+ if (f === PARSE_FAIL) return null;
961
+ let result = null;
962
+ try {
963
+ syntax.Walk(f, (node) => {
964
+ if (!node || result) return false;
965
+ const n = node;
966
+ if (syntax.NodeType(n) !== "CallExpr") return true;
967
+ const { name, flags, paths } = extractLiteralArgs(n);
968
+ if (!name) return true;
969
+ if (name === "rm") {
970
+ const flagStr = flags.join("").toLowerCase();
971
+ const hasR = /[r]/.test(flagStr) || flags.includes("--recursive");
972
+ const hasF = /[f]/.test(flagStr) || flags.includes("--force");
973
+ if (hasR && hasF) {
974
+ for (const p of paths) {
975
+ if (isProtectedHomePath(p)) {
976
+ result = {
977
+ ruleName: "block-rm-rf-home",
978
+ verdict: "block",
979
+ reason: "Recursive delete of home directory is irreversible",
980
+ path: p
981
+ };
982
+ return false;
983
+ }
984
+ if (p === "/" || /^\/+$/.test(p)) {
985
+ result = {
986
+ ruleName: "block-rm-rf-home",
987
+ verdict: "block",
988
+ reason: "Recursive delete of root is catastrophic",
989
+ path: p
990
+ };
991
+ return false;
992
+ }
993
+ }
994
+ }
995
+ }
996
+ if (FS_READ_TOOLS.has(name)) {
997
+ for (const p of paths) {
998
+ for (const sp of SENSITIVE_PATH_RULES) {
999
+ if (sp.match(p)) {
1000
+ result = {
1001
+ ruleName: sp.rule,
1002
+ verdict: sp.verdict ?? "block",
1003
+ reason: sp.reason,
1004
+ path: p
1005
+ };
1006
+ return false;
1007
+ }
1008
+ }
1009
+ }
1010
+ }
1011
+ return true;
1012
+ });
1013
+ return result;
1014
+ } catch {
1015
+ return null;
1016
+ }
1017
+ }
717
1018
  function analyzeShellCommand(command) {
718
1019
  const actions = [];
719
1020
  const paths = [];
@@ -1153,10 +1454,18 @@ function getNestedValue(obj, path) {
1153
1454
  function evaluateSmartConditions(args, rule) {
1154
1455
  if (!rule.conditions || rule.conditions.length === 0) return true;
1155
1456
  const mode = rule.conditionMode ?? "all";
1457
+ const fieldCache = /* @__PURE__ */ new Map();
1458
+ const resolveField = (field) => {
1459
+ if (fieldCache.has(field)) return fieldCache.get(field) ?? null;
1460
+ const rawVal = getNestedValue(args, field);
1461
+ const rawStr = rawVal !== null && rawVal !== void 0 ? String(rawVal) : null;
1462
+ const stripped = field === "command" && rawStr !== null ? normalizeCommandForPolicy(rawStr) : rawStr;
1463
+ const val = stripped !== null ? stripped.replace(/\s+/g, " ").trim() : null;
1464
+ fieldCache.set(field, val);
1465
+ return val;
1466
+ };
1156
1467
  const results = rule.conditions.map((cond) => {
1157
- const rawVal = getNestedValue(args, cond.field);
1158
- const normalized = rawVal !== null && rawVal !== void 0 ? String(rawVal).replace(/\s+/g, " ").trim() : null;
1159
- const val = cond.field === "command" && normalized !== null ? normalizeCommandForPolicy(normalized) : normalized;
1468
+ const val = resolveField(cond.field);
1160
1469
  switch (cond.op) {
1161
1470
  case "exists":
1162
1471
  return val !== null && val !== "";
@@ -1219,6 +1528,43 @@ function checkDangerousSql(sql) {
1219
1528
  return "UPDATE without WHERE \u2014 updates every row";
1220
1529
  return null;
1221
1530
  }
1531
+ function pipeChainVerdict(command, isTrustedHost) {
1532
+ const pipeAnalysis = analyzePipeChain(command);
1533
+ if (!pipeAnalysis.isPipeline) return null;
1534
+ if (pipeAnalysis.risk !== "critical" && pipeAnalysis.risk !== "high") return null;
1535
+ const sinks = pipeAnalysis.sinkTargets;
1536
+ const allTrusted = sinks.length > 0 && sinks.every((host) => isTrustedHost ? isTrustedHost(host) : false);
1537
+ if (pipeAnalysis.risk === "critical") {
1538
+ if (allTrusted) {
1539
+ return {
1540
+ decision: "review",
1541
+ blockedByLabel: "Node9: Pipe-Chain to Trusted Host (obfuscated)",
1542
+ reason: `Obfuscated pipe to trusted host(s): ${sinks.join(", ")} \u2014 requires approval`,
1543
+ tier: 3
1544
+ };
1545
+ }
1546
+ return {
1547
+ decision: "block",
1548
+ blockedByLabel: "Node9: Pipe-Chain Exfiltration (critical)",
1549
+ reason: `Sensitive file piped through obfuscator to network sink: ${pipeAnalysis.sourceFiles.join(", ")} \u2192 ${sinks.join(", ")}`,
1550
+ tier: 3
1551
+ };
1552
+ }
1553
+ if (allTrusted) {
1554
+ return {
1555
+ decision: "allow",
1556
+ blockedByLabel: "Node9: Pipe-Chain to Trusted Host",
1557
+ reason: `Sensitive file piped to trusted host(s): ${sinks.join(", ")}`,
1558
+ tier: 3
1559
+ };
1560
+ }
1561
+ return {
1562
+ decision: "review",
1563
+ blockedByLabel: "Node9: Pipe-Chain Exfiltration (high)",
1564
+ reason: `Sensitive file piped to network sink: ${pipeAnalysis.sourceFiles.join(", ")} \u2192 ${sinks.join(", ")}`,
1565
+ tier: 3
1566
+ };
1567
+ }
1222
1568
  async function evaluatePolicy(config, toolName, args, context = {}, hooks = {}) {
1223
1569
  const { agent, cwd, activeEnvironment } = context;
1224
1570
  const { checkProvenance, isTrustedHost } = hooks;
@@ -1234,9 +1580,27 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
1234
1580
  }
1235
1581
  }
1236
1582
  if (wouldBeIgnored) return { decision: "allow" };
1583
+ const bashCommand = agent !== "Terminal" && isBashTool(toolName) && args && typeof args === "object" ? typeof args.command === "string" ? args.command : null : null;
1584
+ if (bashCommand !== null) {
1585
+ const pipeVerdict = pipeChainVerdict(bashCommand, isTrustedHost);
1586
+ if (pipeVerdict) return pipeVerdict;
1587
+ const fsVerdict = analyzeFsOperation(bashCommand);
1588
+ if (fsVerdict) {
1589
+ const isShieldRule = fsVerdict.ruleName.startsWith("shield:");
1590
+ const labelPrefix = isShieldRule ? "project-jail (AST)" : "Node9 (AST)";
1591
+ return {
1592
+ decision: fsVerdict.verdict,
1593
+ blockedByLabel: `${labelPrefix}: ${fsVerdict.ruleName}`,
1594
+ reason: fsVerdict.reason,
1595
+ tier: 2,
1596
+ ruleName: fsVerdict.ruleName,
1597
+ ruleDescription: fsVerdict.reason
1598
+ };
1599
+ }
1600
+ }
1237
1601
  if (config.policy.smartRules.length > 0) {
1238
1602
  const matchedRule = config.policy.smartRules.find(
1239
- (rule) => matchesPattern(toolName, rule.tool) && evaluateSmartConditions(args, rule)
1603
+ (rule) => matchesPattern(toolName, rule.tool) && !(bashCommand !== null && rule.name && AST_FS_REGEX_RULES.has(rule.name)) && evaluateSmartConditions(args, rule)
1240
1604
  );
1241
1605
  if (matchedRule) {
1242
1606
  if (matchedRule.verdict === "allow")
@@ -1294,41 +1658,8 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
1294
1658
  tier: 3
1295
1659
  };
1296
1660
  }
1297
- const pipeAnalysis = analyzePipeChain(shellCommand);
1298
- if (pipeAnalysis.isPipeline && (pipeAnalysis.risk === "critical" || pipeAnalysis.risk === "high")) {
1299
- const sinks = pipeAnalysis.sinkTargets;
1300
- const allTrusted = sinks.length > 0 && sinks.every((host) => isTrustedHost ? isTrustedHost(host) : false);
1301
- if (pipeAnalysis.risk === "critical") {
1302
- if (allTrusted) {
1303
- return {
1304
- decision: "review",
1305
- blockedByLabel: "Node9: Pipe-Chain to Trusted Host (obfuscated)",
1306
- reason: `Obfuscated pipe to trusted host(s): ${sinks.join(", ")} \u2014 requires approval`,
1307
- tier: 3
1308
- };
1309
- }
1310
- return {
1311
- decision: "block",
1312
- blockedByLabel: "Node9: Pipe-Chain Exfiltration (critical)",
1313
- reason: `Sensitive file piped through obfuscator to network sink: ${pipeAnalysis.sourceFiles.join(", ")} \u2192 ${sinks.join(", ")}`,
1314
- tier: 3
1315
- };
1316
- }
1317
- if (allTrusted) {
1318
- return {
1319
- decision: "allow",
1320
- blockedByLabel: "Node9: Pipe-Chain to Trusted Host",
1321
- reason: `Sensitive file piped to trusted host(s): ${sinks.join(", ")}`,
1322
- tier: 3
1323
- };
1324
- }
1325
- return {
1326
- decision: "review",
1327
- blockedByLabel: "Node9: Pipe-Chain Exfiltration (high)",
1328
- reason: `Sensitive file piped to network sink: ${pipeAnalysis.sourceFiles.join(", ")} \u2192 ${sinks.join(", ")}`,
1329
- tier: 3
1330
- };
1331
- }
1661
+ const ptVerdict = pipeChainVerdict(shellCommand, isTrustedHost);
1662
+ if (ptVerdict) return ptVerdict;
1332
1663
  const firstToken = analyzed.actions[0] ?? "";
1333
1664
  if (["ssh", "scp", "rsync"].includes(firstToken)) {
1334
1665
  const rawTokens = shellCommand.trim().split(/\s+/);
@@ -2005,7 +2336,7 @@ var project_jail_default = {
2005
2336
  {
2006
2337
  field: "command",
2007
2338
  op: "matches",
2008
- value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type)\\s+.*[\\/\\\\]\\.ssh[\\/\\\\]",
2339
+ value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type)\\s+.*?\\.ssh[\\/\\\\]",
2009
2340
  flags: "i"
2010
2341
  }
2011
2342
  ],
@@ -2019,7 +2350,7 @@ var project_jail_default = {
2019
2350
  {
2020
2351
  field: "command",
2021
2352
  op: "matches",
2022
- value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type)\\s+.*[\\/\\\\]\\.aws[\\/\\\\]",
2353
+ value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type)\\s+.*?\\.aws[\\/\\\\]",
2023
2354
  flags: "i"
2024
2355
  }
2025
2356
  ],
@@ -2041,7 +2372,7 @@ var project_jail_default = {
2041
2372
  reason: "Reading .env files is blocked by project-jail shield"
2042
2373
  },
2043
2374
  {
2044
- name: "shield:project-jail:block-read-credentials",
2375
+ name: "shield:project-jail:review-read-credentials",
2045
2376
  tool: "bash",
2046
2377
  conditions: [
2047
2378
  {
@@ -2051,8 +2382,64 @@ var project_jail_default = {
2051
2382
  flags: "i"
2052
2383
  }
2053
2384
  ],
2385
+ verdict: "review",
2386
+ reason: "Reading credential files requires approval (project-jail shield)"
2387
+ },
2388
+ {
2389
+ name: "shield:project-jail:block-read-ssh-any-tool",
2390
+ tool: "*",
2391
+ conditions: [
2392
+ {
2393
+ field: "file_path",
2394
+ op: "matches",
2395
+ value: "(^|[\\/\\\\])\\.ssh[\\/\\\\]",
2396
+ flags: "i"
2397
+ }
2398
+ ],
2054
2399
  verdict: "block",
2055
- reason: "Reading credential files is blocked by project-jail shield"
2400
+ reason: "Reading SSH private keys is blocked by project-jail shield"
2401
+ },
2402
+ {
2403
+ name: "shield:project-jail:block-read-aws-any-tool",
2404
+ tool: "*",
2405
+ conditions: [
2406
+ {
2407
+ field: "file_path",
2408
+ op: "matches",
2409
+ value: "(^|[\\/\\\\])\\.aws[\\/\\\\]",
2410
+ flags: "i"
2411
+ }
2412
+ ],
2413
+ verdict: "block",
2414
+ reason: "Reading AWS credentials is blocked by project-jail shield"
2415
+ },
2416
+ {
2417
+ name: "shield:project-jail:review-read-env-any-tool",
2418
+ tool: "*",
2419
+ conditions: [
2420
+ {
2421
+ field: "file_path",
2422
+ op: "matches",
2423
+ value: "(^|[\\/\\\\])\\.env(\\.(local|production|staging|development|production\\.local|staging\\.local|development\\.local))?$",
2424
+ flags: "i"
2425
+ }
2426
+ ],
2427
+ verdict: "review",
2428
+ reason: "Reading .env files requires approval (project-jail shield)"
2429
+ },
2430
+ {
2431
+ name: "shield:project-jail:review-read-credentials-any-tool",
2432
+ tool: "*",
2433
+ conditions: [
2434
+ {
2435
+ field: "file_path",
2436
+ op: "matches",
2437
+ value: ".*(credentials\\.json|\\.netrc|\\.npmrc|\\.docker[\\/\\\\]config\\.json|gcloud[\\/\\\\]credentials|\\.kube[\\/\\\\]config)",
2438
+ flags: "i"
2439
+ }
2440
+ ],
2441
+ verdict: "review",
2442
+ reason: "Reading credential files requires approval (project-jail shield)"
2056
2443
  }
2057
2444
  ],
2058
2445
  dangerousWords: []
@@ -2461,18 +2848,408 @@ function summarizeBlast(result, opts = {}) {
2461
2848
  };
2462
2849
  }
2463
2850
 
2851
+ // src/scan/destructive-regex.ts
2852
+ var DESTRUCTIVE_OP_RE = /\brm\s+-[rRf]+\b|\bDROP\s+(TABLE|DATABASE|COLLECTION|SCHEMA)\b|\bTRUNCATE\s+TABLE\b|\bgit\s+push\s+(--force|-f)\b|\bFLUSHALL\b|\bFLUSHDB\b|\bkubectl\s+delete\b|\bhelm\s+uninstall\b/i;
2853
+ var PRIVILEGE_ESCALATION_RE = /\bchmod\s+(0?777|\+x)\b|\bchown\s+root\b/i;
2854
+ var SENSITIVE_PATH_RE = /\.aws\/(credentials|config)\b|\.ssh\/(id_rsa|id_ed25519|id_ecdsa|id_dsa)\b|\.env(\.|$|\b)|\.config\/gcloud\/credentials\.db\b|\.docker\/config\.json\b|\.netrc\b|\.npmrc\b|\.node9\/credentials\.json\b/i;
2855
+ var FILE_TOOLS = /* @__PURE__ */ new Set([
2856
+ "read",
2857
+ "read_file",
2858
+ "edit",
2859
+ "edit_file",
2860
+ "write",
2861
+ "write_file",
2862
+ "multiedit",
2863
+ "grep",
2864
+ "grep_search",
2865
+ "glob",
2866
+ "list_files"
2867
+ ]);
2868
+
2869
+ // src/scan/pii.ts
2870
+ var PII_EMAIL_RE = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/;
2871
+ var PII_SSN_RE = /\b\d{3}-\d{2}-\d{4}\b/;
2872
+ var PII_PHONE_RE = /\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}\b/;
2873
+ var PII_CC_RE = /\b(?:4\d{3}|5[1-5]\d{2}|3[47]\d{2}|6\d{3})[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b/;
2874
+ function detectPii(text) {
2875
+ const found = /* @__PURE__ */ new Set();
2876
+ if (/@/.test(text) && PII_EMAIL_RE.test(text)) found.add("Email");
2877
+ if (/-/.test(text) && PII_SSN_RE.test(text)) found.add("SSN");
2878
+ if (PII_PHONE_RE.test(text)) found.add("Phone");
2879
+ if (PII_CC_RE.test(text)) found.add("Credit Card");
2880
+ return [...found];
2881
+ }
2882
+
2883
+ // src/scan/canonical.ts
2884
+ var LONG_OUTPUT_THRESHOLD_BYTES = 100 * 1024;
2885
+ var CANONICAL_EXTRACTOR_VERSION = "canonical-v4";
2886
+ var CANONICAL_EXTRACTOR_HASH = "64a6a63a27f4646f";
2887
+ var DEDUPE_PREVIEW_LEN = 120;
2888
+ function extractCanonicalFindings(call, ctx) {
2889
+ const out = [];
2890
+ const ts = call.timestamp;
2891
+ const toolNameLower = call.toolName.toLowerCase();
2892
+ const command = typeof call.args.command === "string" ? call.args.command : null;
2893
+ const isBash = isBashTool(call.toolName) && command !== null;
2894
+ if (call.outputBytes !== void 0 && call.outputBytes > LONG_OUTPUT_THRESHOLD_BYTES) {
2895
+ out.push(
2896
+ makeFinding({
2897
+ type: "long-output-redacted",
2898
+ ruleName: "long-output-redacted",
2899
+ verdict: "review",
2900
+ severity: "medium",
2901
+ reason: `Tool output exceeded ${LONG_OUTPUT_THRESHOLD_BYTES} bytes and was redacted`,
2902
+ toolName: call.toolName,
2903
+ ctx,
2904
+ ts,
2905
+ sourceType: "engine"
2906
+ })
2907
+ );
2908
+ }
2909
+ if (ctx.dlpEnabled) {
2910
+ const dlp = scanArgs(call.args);
2911
+ if (dlp) {
2912
+ out.push(
2913
+ makeFinding({
2914
+ type: "dlp",
2915
+ ruleName: `dlp:${dlp.patternName}`,
2916
+ patternName: dlp.patternName,
2917
+ verdict: dlp.severity === "block" ? "block" : "review",
2918
+ severity: dlp.severity === "block" ? "critical" : "medium",
2919
+ reason: `${dlp.patternName} detected in ${dlp.fieldPath}`,
2920
+ toolName: call.toolName,
2921
+ ctx,
2922
+ ts,
2923
+ sourceType: "engine",
2924
+ input: call.args,
2925
+ redactedSample: dlp.redactedSample
2926
+ })
2927
+ );
2928
+ }
2929
+ }
2930
+ for (const value of stringValues(call.args)) {
2931
+ const piiHits = detectPii(value);
2932
+ for (const pattern of piiHits) {
2933
+ out.push(
2934
+ makeFinding({
2935
+ type: "pii",
2936
+ ruleName: `pii:${pattern.toLowerCase().replace(/\s+/g, "-")}`,
2937
+ patternName: pattern,
2938
+ verdict: "review",
2939
+ severity: "medium",
2940
+ reason: `${pattern} pattern detected in tool input`,
2941
+ toolName: call.toolName,
2942
+ ctx,
2943
+ ts,
2944
+ sourceType: "engine"
2945
+ })
2946
+ );
2947
+ }
2948
+ }
2949
+ if (FILE_TOOLS.has(toolNameLower)) {
2950
+ const filePath = typeof call.args.file_path === "string" && call.args.file_path || typeof call.args.path === "string" && call.args.path || typeof call.args.pattern === "string" && call.args.pattern || "";
2951
+ if (filePath && SENSITIVE_PATH_RE.test(filePath)) {
2952
+ out.push(
2953
+ makeFinding({
2954
+ type: "sensitive-file-read",
2955
+ ruleName: "sensitive-file-read",
2956
+ verdict: "review",
2957
+ severity: "critical",
2958
+ reason: `Sensitive file path read via ${call.toolName}`,
2959
+ toolName: call.toolName,
2960
+ ctx,
2961
+ ts,
2962
+ sourceType: "engine",
2963
+ subjectPath: filePath
2964
+ })
2965
+ );
2966
+ }
2967
+ }
2968
+ if (!isBash || command === null) {
2969
+ return out;
2970
+ }
2971
+ const fsVerdict = analyzeFsOperation(command);
2972
+ if (fsVerdict) {
2973
+ const isShield = fsVerdict.ruleName.startsWith("shield:");
2974
+ out.push(
2975
+ makeFinding({
2976
+ type: "ast-fs-op",
2977
+ ruleName: fsVerdict.ruleName,
2978
+ verdict: fsVerdict.verdict,
2979
+ severity: classifyRuleSeverity(fsVerdict.ruleName, fsVerdict.verdict),
2980
+ reason: fsVerdict.reason,
2981
+ toolName: call.toolName,
2982
+ ctx,
2983
+ ts,
2984
+ sourceType: isShield ? "shield" : "engine",
2985
+ shieldLabel: isShield ? "project-jail (AST)" : "Node9 (AST)",
2986
+ subjectPath: fsVerdict.path,
2987
+ input: call.args
2988
+ })
2989
+ );
2990
+ }
2991
+ for (const source of ctx.rules) {
2992
+ const r = source.rule;
2993
+ if (r.verdict === "allow") continue;
2994
+ if (r.tool && !matchesPattern(toolNameLower, r.tool)) continue;
2995
+ if (r.name && AST_FS_REGEX_RULES.has(r.name)) continue;
2996
+ if (!evaluateSmartConditions(call.args, r)) continue;
2997
+ out.push(
2998
+ makeFinding({
2999
+ type: "smart-rule",
3000
+ ruleName: r.name ?? r.tool,
3001
+ verdict: r.verdict === "block" ? "block" : "review",
3002
+ severity: classifyRuleSeverity(r.name ?? r.tool, r.verdict),
3003
+ reason: r.reason ?? `Smart rule ${r.name ?? r.tool} fired`,
3004
+ toolName: call.toolName,
3005
+ ctx,
3006
+ ts,
3007
+ sourceType: source.sourceType,
3008
+ shieldLabel: source.shieldLabel,
3009
+ input: call.args
3010
+ })
3011
+ );
3012
+ break;
3013
+ }
3014
+ const evalVerdict = detectDangerousShellExec(command);
3015
+ if (evalVerdict) {
3016
+ out.push(
3017
+ makeFinding({
3018
+ type: "eval-of-remote",
3019
+ ruleName: "eval-of-remote",
3020
+ verdict: evalVerdict,
3021
+ severity: classifyRuleSeverity("eval-remote", evalVerdict),
3022
+ reason: evalVerdict === "block" ? "Eval of remote download is a near-certain supply-chain attack" : "Eval of dynamic content (variable / subshell) requires approval",
3023
+ toolName: call.toolName,
3024
+ ctx,
3025
+ ts,
3026
+ sourceType: "engine",
3027
+ input: call.args
3028
+ })
3029
+ );
3030
+ }
3031
+ const pipe = analyzePipeChain(command);
3032
+ if (pipe.isPipeline && pipe.risk === "critical") {
3033
+ out.push(
3034
+ makeFinding({
3035
+ type: "pipe-to-shell",
3036
+ ruleName: "pipe-to-shell",
3037
+ verdict: "block",
3038
+ severity: "critical",
3039
+ reason: `Sensitive file piped through obfuscator to network sink: ${pipe.sourceFiles.join(", ")} \u2192 ${pipe.sinkTargets.join(", ")}`,
3040
+ toolName: call.toolName,
3041
+ ctx,
3042
+ ts,
3043
+ sourceType: "engine",
3044
+ input: call.args
3045
+ })
3046
+ );
3047
+ }
3048
+ if (DESTRUCTIVE_OP_RE.test(command)) {
3049
+ out.push(
3050
+ makeFinding({
3051
+ type: "destructive-op",
3052
+ ruleName: "destructive-op",
3053
+ verdict: "review",
3054
+ severity: "high",
3055
+ reason: "Destructive operation pattern detected",
3056
+ toolName: call.toolName,
3057
+ ctx,
3058
+ ts,
3059
+ sourceType: "engine",
3060
+ input: call.args
3061
+ })
3062
+ );
3063
+ }
3064
+ const ast = analyzeShellCommand(command);
3065
+ const sudoVariant = ast.actions.includes("sudo") || ast.actions.includes("su");
3066
+ const chmodVariant = ast.actions.includes("chmod") && (ast.allTokens.includes("777") || ast.allTokens.includes("0777") || ast.allTokens.includes("+x"));
3067
+ const chownVariant = ast.actions.includes("chown") && ast.allTokens.includes("root");
3068
+ if (sudoVariant || chmodVariant || chownVariant) {
3069
+ out.push(
3070
+ makeFinding({
3071
+ type: "privilege-escalation",
3072
+ ruleName: "privilege-escalation",
3073
+ verdict: "review",
3074
+ severity: "high",
3075
+ reason: "Privilege-escalation pattern detected",
3076
+ toolName: call.toolName,
3077
+ ctx,
3078
+ ts,
3079
+ sourceType: "engine",
3080
+ input: call.args
3081
+ })
3082
+ );
3083
+ }
3084
+ return out;
3085
+ }
3086
+ function extractSessionLevelFindings(calls, ctx) {
3087
+ if (!ctx.loopDetection.enabled || calls.length === 0) return [];
3088
+ const out = [];
3089
+ const seenLoopKeys = /* @__PURE__ */ new Set();
3090
+ const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1e3;
3091
+ const windowMs = ctx.loopDetection.windowSeconds <= 0 ? ONE_YEAR_MS : ctx.loopDetection.windowSeconds * 1e3;
3092
+ let records = [];
3093
+ let syntheticTs = 0;
3094
+ for (let i = 0; i < calls.length; i++) {
3095
+ const call = calls[i];
3096
+ const parsed = new Date(call.timestamp).getTime();
3097
+ const now = Number.isFinite(parsed) ? parsed : ++syntheticTs;
3098
+ const verdict = evaluateLoopWindow(
3099
+ records,
3100
+ call.toolName,
3101
+ call.args,
3102
+ ctx.loopDetection.threshold,
3103
+ windowMs,
3104
+ now
3105
+ );
3106
+ records = verdict.nextRecords;
3107
+ if (!verdict.looping) continue;
3108
+ const last = records[records.length - 1];
3109
+ const key = `${last.t}|${last.h}`;
3110
+ if (seenLoopKeys.has(key)) continue;
3111
+ seenLoopKeys.add(key);
3112
+ out.push({
3113
+ type: "loop",
3114
+ ruleName: "loop",
3115
+ verdict: "review",
3116
+ severity: "medium",
3117
+ reason: `Tool called ${verdict.count} times with identical args within window`,
3118
+ toolName: call.toolName,
3119
+ agent: ctx.agent,
3120
+ sessionId: ctx.sessionId,
3121
+ project: ctx.project,
3122
+ lineIndex: call.lineIndex,
3123
+ sourceType: "engine",
3124
+ firstSeenAt: call.timestamp,
3125
+ lastSeenAt: call.timestamp,
3126
+ occurrenceCount: 1,
3127
+ loopCount: verdict.count,
3128
+ loopKind: "loop",
3129
+ commandPreview: previewArgs(call.args, DEDUPE_PREVIEW_LEN),
3130
+ costUsd: verdict.count * COST_PER_LOOP_ITER_USD
3131
+ });
3132
+ }
3133
+ return out;
3134
+ }
3135
+ function dedupeCanonicalFindings(findings) {
3136
+ const merged = /* @__PURE__ */ new Map();
3137
+ for (const f of findings) {
3138
+ const inputPreview = f.input ? previewArgs(f.input, DEDUPE_PREVIEW_LEN) : "";
3139
+ const key = `${f.type}|${f.ruleName}|${inputPreview}|${f.project}|${f.agent}`;
3140
+ const prev = merged.get(key);
3141
+ if (!prev) {
3142
+ merged.set(key, { ...f });
3143
+ continue;
3144
+ }
3145
+ prev.occurrenceCount += f.occurrenceCount;
3146
+ if (f.firstSeenAt && (!prev.firstSeenAt || f.firstSeenAt < prev.firstSeenAt)) {
3147
+ prev.firstSeenAt = f.firstSeenAt;
3148
+ }
3149
+ if (f.lastSeenAt && f.lastSeenAt > prev.lastSeenAt) {
3150
+ prev.lastSeenAt = f.lastSeenAt;
3151
+ }
3152
+ if (f.costUsd !== void 0) {
3153
+ prev.costUsd = (prev.costUsd ?? 0) + f.costUsd;
3154
+ }
3155
+ if (f.loopCount !== void 0) {
3156
+ prev.loopCount = (prev.loopCount ?? 0) + f.loopCount;
3157
+ }
3158
+ }
3159
+ return [...merged.values()];
3160
+ }
3161
+ function toScanFinding(c) {
3162
+ const typeMap = {
3163
+ "smart-rule": null,
3164
+ "ast-fs-op": null,
3165
+ dlp: "dlp",
3166
+ pii: "pii",
3167
+ "sensitive-file-read": "sensitive-file-read",
3168
+ "privilege-escalation": "privilege-escalation",
3169
+ "destructive-op": "destructive-op",
3170
+ "pipe-to-shell": "pipe-to-shell",
3171
+ "eval-of-remote": "eval-of-remote",
3172
+ loop: "loop",
3173
+ "long-output-redacted": "long-output-redacted"
3174
+ };
3175
+ const sfType = typeMap[c.type];
3176
+ if (sfType === null) return null;
3177
+ return {
3178
+ sessionId: c.sessionId,
3179
+ type: sfType,
3180
+ ...c.patternName && { patternName: c.patternName },
3181
+ lineIndex: c.lineIndex
3182
+ };
3183
+ }
3184
+ var TERMINAL_ESCAPE_RE = (
3185
+ // eslint-disable-next-line no-control-regex
3186
+ /\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-_]|[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g
3187
+ );
3188
+ function previewArgs(input, max) {
3189
+ const cmd = input.command ?? input.query ?? input.file_path ?? JSON.stringify(input);
3190
+ const s = String(cmd).replace(TERMINAL_ESCAPE_RE, "").replace(/\s+/g, " ").trim();
3191
+ return s.length > max ? s.slice(0, max - 1) + "\u2026" : s;
3192
+ }
3193
+ function makeFinding(args) {
3194
+ const f = {
3195
+ type: args.type,
3196
+ ruleName: args.ruleName,
3197
+ verdict: args.verdict,
3198
+ severity: args.severity,
3199
+ reason: args.reason,
3200
+ toolName: args.toolName,
3201
+ agent: args.ctx.agent,
3202
+ sessionId: args.ctx.sessionId,
3203
+ project: args.ctx.project,
3204
+ lineIndex: args.ctx.lineIndex,
3205
+ sourceType: args.sourceType,
3206
+ firstSeenAt: args.ts,
3207
+ lastSeenAt: args.ts,
3208
+ occurrenceCount: 1
3209
+ };
3210
+ if (args.shieldLabel) f.shieldLabel = args.shieldLabel;
3211
+ if (args.subjectPath) f.subjectPath = args.subjectPath;
3212
+ if (args.input) f.input = args.input;
3213
+ if (args.patternName) f.patternName = args.patternName;
3214
+ if (args.redactedSample) f.redactedSample = args.redactedSample;
3215
+ return f;
3216
+ }
3217
+ function* stringValues(obj, depth = 0) {
3218
+ if (depth > 6) return;
3219
+ if (typeof obj === "string") {
3220
+ if (obj.length > 0) yield obj;
3221
+ return;
3222
+ }
3223
+ if (!obj || typeof obj !== "object") return;
3224
+ if (Array.isArray(obj)) {
3225
+ for (const v of obj) yield* stringValues(v, depth + 1);
3226
+ return;
3227
+ }
3228
+ for (const v of Object.values(obj)) yield* stringValues(v, depth + 1);
3229
+ }
3230
+
2464
3231
  // src/index.ts
2465
3232
  var ENGINE_VERSION = "1.4.0";
2466
3233
  export {
3234
+ AST_FS_REGEX_RULES,
3235
+ BASH_TOOL_NAMES,
2467
3236
  BUILTIN_SHIELDS,
3237
+ CANONICAL_EXTRACTOR_HASH,
3238
+ CANONICAL_EXTRACTOR_VERSION,
2468
3239
  COST_PER_LOOP_ITER_USD,
3240
+ DESTRUCTIVE_OP_RE,
2469
3241
  DLP_PATTERNS,
2470
3242
  ENGINE_VERSION,
3243
+ FILE_TOOLS,
2471
3244
  FLAGS_WITH_VALUES,
3245
+ LONG_OUTPUT_THRESHOLD_BYTES,
2472
3246
  LOOP_MAX_RECORDS,
2473
3247
  LOOP_THRESHOLD_FOR_WASTE,
3248
+ PRIVILEGE_ESCALATION_RE,
2474
3249
  SCAN_SIGNAL_WEIGHTS,
3250
+ SENSITIVE_PATH_RE,
2475
3251
  SENSITIVE_PATH_REGEXES,
3252
+ analyzeFsOperation,
2476
3253
  analyzePipeChain,
2477
3254
  analyzeShellCommand,
2478
3255
  checkDangerousSql,
@@ -2483,29 +3260,37 @@ export {
2483
3260
  computeBlendedSecurityScore,
2484
3261
  computeScanScore,
2485
3262
  computeSecurityScore,
3263
+ dedupeCanonicalFindings,
2486
3264
  detectDangerousEval,
2487
3265
  detectDangerousShellExec,
3266
+ detectPii,
2488
3267
  evaluateLoopWindow,
2489
3268
  evaluatePolicy,
2490
3269
  evaluateSmartConditions,
2491
3270
  extractAllSshHosts,
3271
+ extractCanonicalFindings,
2492
3272
  extractNetworkTargets,
2493
3273
  extractPositionalArgs,
3274
+ extractSessionLevelFindings,
2494
3275
  getCompiledRegex,
2495
3276
  getNestedValue,
3277
+ isBashTool,
2496
3278
  isIgnoredTool,
3279
+ isProtectedHomePath,
2497
3280
  isShieldVerdict,
2498
3281
  matchSensitivePath,
2499
3282
  matchesPattern,
2500
3283
  narrativeRuleLabel,
2501
3284
  normalizeCommandForPolicy,
2502
3285
  parseAllSshHostsFromCommand,
3286
+ previewArgs,
2503
3287
  redactText,
2504
3288
  scanArgs,
2505
3289
  scanText,
2506
3290
  sensitivePathMatch,
2507
3291
  summarizeBlast,
2508
3292
  summarizeScan,
3293
+ toScanFinding,
2509
3294
  truncateBlastPath,
2510
3295
  validateOverrides,
2511
3296
  validateRegex,