@node9/proxy 1.18.2 → 1.19.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/cli.js CHANGED
@@ -444,9 +444,65 @@ function redactText(text) {
444
444
  }
445
445
  return { result, found };
446
446
  }
447
+ function isCatHeredocOrLit(part) {
448
+ if (!part) return false;
449
+ const t = syntax.NodeType(part);
450
+ if (t === "Lit") return true;
451
+ if (t !== "CmdSubst") return false;
452
+ const stmts = part.Stmts || [];
453
+ if (stmts.length !== 1) return false;
454
+ const stmt = stmts[0];
455
+ const redirs = stmt.Redirs || stmt.Cmd?.Redirs || [];
456
+ const hasHeredoc = redirs.some((r) => r && r.Hdoc);
457
+ if (!hasHeredoc) return false;
458
+ const cmd = stmt.Cmd;
459
+ if (!cmd || syntax.NodeType(cmd) !== "CallExpr") return false;
460
+ const firstArg2 = cmd.Args?.[0]?.Parts || [];
461
+ if (firstArg2.length !== 1 || syntax.NodeType(firstArg2[0]) !== "Lit") return false;
462
+ return (firstArg2[0].Value || "").toLowerCase() === "cat";
463
+ }
464
+ function parseShared(command) {
465
+ const cached = astCache.get(command);
466
+ if (cached !== void 0) {
467
+ astCache.delete(command);
468
+ astCache.set(command, cached);
469
+ return cached;
470
+ }
471
+ let parsed;
472
+ try {
473
+ parsed = sharedParser.Parse(command, "cmd");
474
+ } catch {
475
+ parsed = PARSE_FAIL;
476
+ }
477
+ if (astCache.size >= AST_CACHE_MAX) {
478
+ const oldest = astCache.keys().next().value;
479
+ if (oldest !== void 0) astCache.delete(oldest);
480
+ }
481
+ astCache.set(command, parsed);
482
+ return parsed;
483
+ }
484
+ function cachedNormalize(command, compute) {
485
+ const hit = normalizeCache.get(command);
486
+ if (hit !== void 0) {
487
+ normalizeCache.delete(command);
488
+ normalizeCache.set(command, hit);
489
+ return hit;
490
+ }
491
+ const result = compute();
492
+ if (normalizeCache.size >= NORMALIZE_CACHE_MAX) {
493
+ const oldest = normalizeCache.keys().next().value;
494
+ if (oldest !== void 0) normalizeCache.delete(oldest);
495
+ }
496
+ normalizeCache.set(command, result);
497
+ return result;
498
+ }
447
499
  function normalizeCommandForPolicy(command) {
500
+ return cachedNormalize(command, () => normalizeCommandForPolicyImpl(command));
501
+ }
502
+ function normalizeCommandForPolicyImpl(command) {
503
+ const f = parseShared(command);
504
+ if (f === PARSE_FAIL) return command;
448
505
  try {
449
- const f = sharedParser.Parse(command, "cmd");
450
506
  const strips = [];
451
507
  syntax.Walk(f, (node) => {
452
508
  if (!node) return false;
@@ -468,7 +524,11 @@ function normalizeCommandForPolicy(command) {
468
524
  } else if (nt === "DblQuoted") {
469
525
  const innerParts = quotedNode.Parts || [];
470
526
  const allLit = innerParts.length === 0 || innerParts.every((p) => syntax.NodeType(p) === "Lit");
471
- if (allLit) strips.push([next.Pos().Offset(), next.End().Offset()]);
527
+ if (allLit) {
528
+ strips.push([next.Pos().Offset(), next.End().Offset()]);
529
+ } else if (innerParts.every((p) => isCatHeredocOrLit(p))) {
530
+ strips.push([next.Pos().Offset(), next.End().Offset()]);
531
+ }
472
532
  }
473
533
  }
474
534
  return true;
@@ -536,6 +596,130 @@ function detectDangerousShellExec(command) {
536
596
  return null;
537
597
  }
538
598
  }
599
+ function isBashTool(toolName) {
600
+ return BASH_TOOL_NAMES.has(toolName.toLowerCase());
601
+ }
602
+ function isProtectedHomePath(rawPath) {
603
+ let p = rawPath.replace(/^\$HOME[\\/]?|^\$\{HOME\}[\\/]?/, "~/");
604
+ let underHome = false;
605
+ if (p === "~" || p.startsWith("~/") || p.startsWith("~\\")) {
606
+ p = p.replace(/^~[\\/]?/, "");
607
+ underHome = true;
608
+ } else if (/^\/home\/[^/]+/.test(p) || /^\/root(\/|$)/.test(p)) {
609
+ p = p.replace(/^\/home\/[^/]+[\\/]?|^\/root[\\/]?/, "");
610
+ underHome = true;
611
+ }
612
+ if (!underHome) return false;
613
+ if (p === "" || p === "." || p === "./") return true;
614
+ for (const safe of HOME_CACHE_ALLOWLIST) {
615
+ if (p === safe || p.startsWith(safe + "/") || p.startsWith(safe + "\\")) {
616
+ return false;
617
+ }
618
+ }
619
+ return true;
620
+ }
621
+ function extractLiteralArgs(callExpr) {
622
+ const args = callExpr.Args || [];
623
+ if (args.length === 0) return { name: "", flags: [], paths: [] };
624
+ const litFromWord = (w) => {
625
+ const parts = w?.Parts || [];
626
+ let s = "";
627
+ for (const p of parts) {
628
+ const t = syntax.NodeType(p);
629
+ if (t === "Lit") s += (p.Value ?? "").replace(/\\(.)/g, "$1");
630
+ else if (t === "SglQuoted") s += p.Value ?? "";
631
+ else if (t === "DblQuoted") {
632
+ const inner = p.Parts || [];
633
+ if (!inner.every((ip) => syntax.NodeType(ip) === "Lit")) return null;
634
+ s += inner.map((ip) => ip.Value ?? "").join("");
635
+ } else {
636
+ return null;
637
+ }
638
+ }
639
+ return s;
640
+ };
641
+ const name = (litFromWord(args[0]) || "").toLowerCase();
642
+ const flags = [];
643
+ const paths = [];
644
+ for (let i = 1; i < args.length; i++) {
645
+ const v = litFromWord(args[i]);
646
+ if (v === null) continue;
647
+ if (v.startsWith("-")) flags.push(v);
648
+ else paths.push(v);
649
+ }
650
+ return { name, flags, paths };
651
+ }
652
+ function analyzeFsOperation(command) {
653
+ if (!FS_OP_PRESCREEN_RE.test(command)) return null;
654
+ if (fsOpCache.has(command)) {
655
+ const hit = fsOpCache.get(command) ?? null;
656
+ fsOpCache.delete(command);
657
+ fsOpCache.set(command, hit);
658
+ return hit;
659
+ }
660
+ const computed = analyzeFsOperationImpl(command);
661
+ if (fsOpCache.size >= FS_OP_CACHE_MAX) {
662
+ const oldest = fsOpCache.keys().next().value;
663
+ if (oldest !== void 0) fsOpCache.delete(oldest);
664
+ }
665
+ fsOpCache.set(command, computed);
666
+ return computed;
667
+ }
668
+ function analyzeFsOperationImpl(command) {
669
+ const f = parseShared(command);
670
+ if (f === PARSE_FAIL) return null;
671
+ let result = null;
672
+ try {
673
+ syntax.Walk(f, (node) => {
674
+ if (!node || result) return false;
675
+ const n = node;
676
+ if (syntax.NodeType(n) !== "CallExpr") return true;
677
+ const { name, flags, paths } = extractLiteralArgs(n);
678
+ if (!name) return true;
679
+ if (name === "rm") {
680
+ const flagStr = flags.join("").toLowerCase();
681
+ const hasR = /[r]/.test(flagStr) || flags.includes("--recursive");
682
+ const hasF = /[f]/.test(flagStr) || flags.includes("--force");
683
+ if (hasR && hasF) {
684
+ for (const p of paths) {
685
+ if (isProtectedHomePath(p)) {
686
+ result = {
687
+ ruleName: "block-rm-rf-home",
688
+ verdict: "block",
689
+ reason: "Recursive delete of home directory is irreversible",
690
+ path: p
691
+ };
692
+ return false;
693
+ }
694
+ if (p === "/" || /^\/+$/.test(p)) {
695
+ result = {
696
+ ruleName: "block-rm-rf-home",
697
+ verdict: "block",
698
+ reason: "Recursive delete of root is catastrophic",
699
+ path: p
700
+ };
701
+ return false;
702
+ }
703
+ }
704
+ }
705
+ }
706
+ if (FS_READ_TOOLS.has(name)) {
707
+ for (const p of paths) {
708
+ for (const sp of SENSITIVE_PATH_RULES) {
709
+ if (sp.match(p)) {
710
+ result = { ruleName: sp.rule, verdict: "block", reason: sp.reason, path: p };
711
+ return false;
712
+ }
713
+ }
714
+ }
715
+ }
716
+ return true;
717
+ });
718
+ return result;
719
+ } catch {
720
+ return null;
721
+ }
722
+ }
539
723
  function analyzeShellCommand(command) {
540
724
  const actions = [];
541
725
  const paths = [];
@@ -825,10 +1009,18 @@ function getNestedValue(obj, path49) {
825
1009
  function evaluateSmartConditions(args, rule) {
826
1010
  if (!rule.conditions || rule.conditions.length === 0) return true;
827
1011
  const mode = rule.conditionMode ?? "all";
1012
+ const fieldCache = /* @__PURE__ */ new Map();
1013
+ const resolveField = (field) => {
1014
+ if (fieldCache.has(field)) return fieldCache.get(field) ?? null;
1015
+ const rawVal = getNestedValue(args, field);
1016
+ const rawStr = rawVal !== null && rawVal !== void 0 ? String(rawVal) : null;
1017
+ const stripped = field === "command" && rawStr !== null ? normalizeCommandForPolicy(rawStr) : rawStr;
1018
+ const val = stripped !== null ? stripped.replace(/\s+/g, " ").trim() : null;
1019
+ fieldCache.set(field, val);
1020
+ return val;
1021
+ };
828
1022
  const results = rule.conditions.map((cond) => {
829
- const rawVal = getNestedValue(args, cond.field);
830
- const normalized = rawVal !== null && rawVal !== void 0 ? String(rawVal).replace(/\s+/g, " ").trim() : null;
831
- const val = cond.field === "command" && normalized !== null ? normalizeCommandForPolicy(normalized) : normalized;
1023
+ const val = resolveField(cond.field);
832
1024
  switch (cond.op) {
833
1025
  case "exists":
834
1026
  return val !== null && val !== "";
@@ -879,6 +1071,43 @@ function isSqlTool(toolName, toolInspection) {
879
1071
  const fieldName = toolInspection[matchingPattern];
880
1072
  return fieldName === "sql" || fieldName === "query";
881
1073
  }
1074
+ function pipeChainVerdict(command, isTrustedHost2) {
1075
+ const pipeAnalysis = analyzePipeChain(command);
1076
+ if (!pipeAnalysis.isPipeline) return null;
1077
+ if (pipeAnalysis.risk !== "critical" && pipeAnalysis.risk !== "high") return null;
1078
+ const sinks = pipeAnalysis.sinkTargets;
1079
+ const allTrusted = sinks.length > 0 && sinks.every((host) => isTrustedHost2 ? isTrustedHost2(host) : false);
1080
+ if (pipeAnalysis.risk === "critical") {
1081
+ if (allTrusted) {
1082
+ return {
1083
+ decision: "review",
1084
+ blockedByLabel: "Node9: Pipe-Chain to Trusted Host (obfuscated)",
1085
+ reason: `Obfuscated pipe to trusted host(s): ${sinks.join(", ")} \u2014 requires approval`,
1086
+ tier: 3
1087
+ };
1088
+ }
1089
+ return {
1090
+ decision: "block",
1091
+ blockedByLabel: "Node9: Pipe-Chain Exfiltration (critical)",
1092
+ reason: `Sensitive file piped through obfuscator to network sink: ${pipeAnalysis.sourceFiles.join(", ")} \u2192 ${sinks.join(", ")}`,
1093
+ tier: 3
1094
+ };
1095
+ }
1096
+ if (allTrusted) {
1097
+ return {
1098
+ decision: "allow",
1099
+ blockedByLabel: "Node9: Pipe-Chain to Trusted Host",
1100
+ reason: `Sensitive file piped to trusted host(s): ${sinks.join(", ")}`,
1101
+ tier: 3
1102
+ };
1103
+ }
1104
+ return {
1105
+ decision: "review",
1106
+ blockedByLabel: "Node9: Pipe-Chain Exfiltration (high)",
1107
+ reason: `Sensitive file piped to network sink: ${pipeAnalysis.sourceFiles.join(", ")} \u2192 ${sinks.join(", ")}`,
1108
+ tier: 3
1109
+ };
1110
+ }
882
1111
  async function evaluatePolicy(config, toolName, args, context = {}, hooks = {}) {
883
1112
  const { agent, cwd, activeEnvironment } = context;
884
1113
  const { checkProvenance: checkProvenance2, isTrustedHost: isTrustedHost2 } = hooks;
@@ -894,9 +1123,27 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
894
1123
  }
895
1124
  }
896
1125
  if (wouldBeIgnored) return { decision: "allow" };
1126
+ const bashCommand = agent !== "Terminal" && isBashTool(toolName) && args && typeof args === "object" ? typeof args.command === "string" ? args.command : null : null;
1127
+ if (bashCommand !== null) {
1128
+ const pipeVerdict = pipeChainVerdict(bashCommand, isTrustedHost2);
1129
+ if (pipeVerdict) return pipeVerdict;
1130
+ const fsVerdict = analyzeFsOperation(bashCommand);
1131
+ if (fsVerdict) {
1132
+ const isShieldRule = fsVerdict.ruleName.startsWith("shield:");
1133
+ const labelPrefix = isShieldRule ? "project-jail (AST)" : "Node9 (AST)";
1134
+ return {
1135
+ decision: fsVerdict.verdict,
1136
+ blockedByLabel: `${labelPrefix}: ${fsVerdict.ruleName}`,
1137
+ reason: fsVerdict.reason,
1138
+ tier: 2,
1139
+ ruleName: fsVerdict.ruleName,
1140
+ ruleDescription: fsVerdict.reason
1141
+ };
1142
+ }
1143
+ }
897
1144
  if (config.policy.smartRules.length > 0) {
898
1145
  const matchedRule = config.policy.smartRules.find(
899
- (rule) => matchesPattern(toolName, rule.tool) && evaluateSmartConditions(args, rule)
1146
+ (rule) => matchesPattern(toolName, rule.tool) && !(bashCommand !== null && rule.name && AST_FS_REGEX_RULES.has(rule.name)) && evaluateSmartConditions(args, rule)
900
1147
  );
901
1148
  if (matchedRule) {
902
1149
  if (matchedRule.verdict === "allow")
@@ -954,41 +1201,8 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
954
1201
  tier: 3
955
1202
  };
956
1203
  }
957
- const pipeAnalysis = analyzePipeChain(shellCommand);
958
- if (pipeAnalysis.isPipeline && (pipeAnalysis.risk === "critical" || pipeAnalysis.risk === "high")) {
959
- const sinks = pipeAnalysis.sinkTargets;
960
- const allTrusted = sinks.length > 0 && sinks.every((host) => isTrustedHost2 ? isTrustedHost2(host) : false);
961
- if (pipeAnalysis.risk === "critical") {
962
- if (allTrusted) {
963
- return {
964
- decision: "review",
965
- blockedByLabel: "Node9: Pipe-Chain to Trusted Host (obfuscated)",
966
- reason: `Obfuscated pipe to trusted host(s): ${sinks.join(", ")} \u2014 requires approval`,
967
- tier: 3
968
- };
969
- }
970
- return {
971
- decision: "block",
972
- blockedByLabel: "Node9: Pipe-Chain Exfiltration (critical)",
973
- reason: `Sensitive file piped through obfuscator to network sink: ${pipeAnalysis.sourceFiles.join(", ")} \u2192 ${sinks.join(", ")}`,
974
- tier: 3
975
- };
976
- }
977
- if (allTrusted) {
978
- return {
979
- decision: "allow",
980
- blockedByLabel: "Node9: Pipe-Chain to Trusted Host",
981
- reason: `Sensitive file piped to trusted host(s): ${sinks.join(", ")}`,
982
- tier: 3
983
- };
984
- }
985
- return {
986
- decision: "review",
987
- blockedByLabel: "Node9: Pipe-Chain Exfiltration (high)",
988
- reason: `Sensitive file piped to network sink: ${pipeAnalysis.sourceFiles.join(", ")} \u2192 ${sinks.join(", ")}`,
989
- tier: 3
990
- };
991
- }
1204
+ const ptVerdict = pipeChainVerdict(shellCommand, isTrustedHost2);
1205
+ if (ptVerdict) return ptVerdict;
992
1206
  const firstToken = analyzed.actions[0] ?? "";
993
1207
  if (["ssh", "scp", "rsync"].includes(firstToken)) {
994
1208
  const rawTokens = shellCommand.trim().split(/\s+/);
@@ -1310,7 +1524,323 @@ function summarizeBlast(result, opts = {}) {
1310
1524
  }))
1311
1525
  };
1312
1526
  }
1313
- var import_safe_regex2, import_mvdan_sh, import_picomatch, import_safe_regex22, import_safe_regex23, import_crypto2, ASSIGNMENT_CONTEXT_RE, DLP_STOPWORDS, DLP_PATTERNS, DLP_PATTERNS_GLOBAL, SENSITIVE_PATH_PATTERNS, MAX_DEPTH, MAX_STRING_BYTES, MAX_JSON_PARSE_BYTES, syntax, sharedParser, MESSAGE_FLAGS, SHELL_INTERPRETERS, DOWNLOAD_CMDS, SOURCE_COMMANDS, SINK_COMMANDS, OBFUSCATORS, SENSITIVE_PATTERNS, FLAGS_WITH_VALUES, MAX_REGEX_LENGTH, REGEX_CACHE_MAX, regexCache, FORBIDDEN_PATH_SEGMENTS, SQL_DML_KEYWORDS, aws_default, bash_safe_default, docker_default, filesystem_default, github_default, k8s_default, mongodb_default, postgres_default, project_jail_default, redis_default, BUILTIN_SHIELDS, LOOP_MAX_RECORDS, FINDING_TO_SIGNAL, SCAN_SIGNAL_WEIGHTS, LOOP_THRESHOLD_FOR_WASTE, COST_PER_LOOP_ITER_USD;
1527
+ function detectPii(text) {
1528
+ const found = /* @__PURE__ */ new Set();
1529
+ if (/@/.test(text) && PII_EMAIL_RE.test(text)) found.add("Email");
1530
+ if (/-/.test(text) && PII_SSN_RE.test(text)) found.add("SSN");
1531
+ if (PII_PHONE_RE.test(text)) found.add("Phone");
1532
+ if (PII_CC_RE.test(text)) found.add("Credit Card");
1533
+ return [...found];
1534
+ }
1535
+ function extractCanonicalFindings(call, ctx) {
1536
+ const out = [];
1537
+ const ts = call.timestamp;
1538
+ const toolNameLower = call.toolName.toLowerCase();
1539
+ const command = typeof call.args.command === "string" ? call.args.command : null;
1540
+ const isBash = isBashTool(call.toolName) && command !== null;
1541
+ if (call.outputBytes !== void 0 && call.outputBytes > LONG_OUTPUT_THRESHOLD_BYTES) {
1542
+ out.push(
1543
+ makeFinding({
1544
+ type: "long-output-redacted",
1545
+ ruleName: "long-output-redacted",
1546
+ verdict: "review",
1547
+ severity: "medium",
1548
+ reason: `Tool output exceeded ${LONG_OUTPUT_THRESHOLD_BYTES} bytes and was redacted`,
1549
+ toolName: call.toolName,
1550
+ ctx,
1551
+ ts,
1552
+ sourceType: "engine"
1553
+ })
1554
+ );
1555
+ }
1556
+ if (ctx.dlpEnabled) {
1557
+ const dlp = scanArgs(call.args);
1558
+ if (dlp) {
1559
+ out.push(
1560
+ makeFinding({
1561
+ type: "dlp",
1562
+ ruleName: `dlp:${dlp.patternName}`,
1563
+ patternName: dlp.patternName,
1564
+ verdict: dlp.severity === "block" ? "block" : "review",
1565
+ severity: dlp.severity === "block" ? "critical" : "medium",
1566
+ reason: `${dlp.patternName} detected in ${dlp.fieldPath}`,
1567
+ toolName: call.toolName,
1568
+ ctx,
1569
+ ts,
1570
+ sourceType: "engine",
1571
+ input: call.args,
1572
+ redactedSample: dlp.redactedSample
1573
+ })
1574
+ );
1575
+ }
1576
+ }
1577
+ for (const value of stringValues(call.args)) {
1578
+ const piiHits = detectPii(value);
1579
+ for (const pattern of piiHits) {
1580
+ out.push(
1581
+ makeFinding({
1582
+ type: "pii",
1583
+ ruleName: `pii:${pattern.toLowerCase().replace(/\s+/g, "-")}`,
1584
+ patternName: pattern,
1585
+ verdict: "review",
1586
+ severity: "medium",
1587
+ reason: `${pattern} pattern detected in tool input`,
1588
+ toolName: call.toolName,
1589
+ ctx,
1590
+ ts,
1591
+ sourceType: "engine"
1592
+ })
1593
+ );
1594
+ }
1595
+ }
1596
+ if (FILE_TOOLS.has(toolNameLower)) {
1597
+ 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 || "";
1598
+ if (filePath && SENSITIVE_PATH_RE.test(filePath)) {
1599
+ out.push(
1600
+ makeFinding({
1601
+ type: "sensitive-file-read",
1602
+ ruleName: "sensitive-file-read",
1603
+ verdict: "review",
1604
+ severity: "critical",
1605
+ reason: `Sensitive file path read via ${call.toolName}`,
1606
+ toolName: call.toolName,
1607
+ ctx,
1608
+ ts,
1609
+ sourceType: "engine",
1610
+ subjectPath: filePath
1611
+ })
1612
+ );
1613
+ }
1614
+ }
1615
+ if (!isBash || command === null) {
1616
+ return out;
1617
+ }
1618
+ const fsVerdict = analyzeFsOperation(command);
1619
+ if (fsVerdict) {
1620
+ const isShield = fsVerdict.ruleName.startsWith("shield:");
1621
+ out.push(
1622
+ makeFinding({
1623
+ type: "ast-fs-op",
1624
+ ruleName: fsVerdict.ruleName,
1625
+ verdict: fsVerdict.verdict,
1626
+ severity: classifyRuleSeverity(fsVerdict.ruleName, fsVerdict.verdict),
1627
+ reason: fsVerdict.reason,
1628
+ toolName: call.toolName,
1629
+ ctx,
1630
+ ts,
1631
+ sourceType: isShield ? "shield" : "engine",
1632
+ shieldLabel: isShield ? "project-jail (AST)" : "Node9 (AST)",
1633
+ subjectPath: fsVerdict.path,
1634
+ input: call.args
1635
+ })
1636
+ );
1637
+ }
1638
+ for (const source of ctx.rules) {
1639
+ const r = source.rule;
1640
+ if (r.verdict === "allow") continue;
1641
+ if (r.tool && !matchesPattern(toolNameLower, r.tool)) continue;
1642
+ if (r.name && AST_FS_REGEX_RULES.has(r.name)) continue;
1643
+ if (!evaluateSmartConditions(call.args, r)) continue;
1644
+ out.push(
1645
+ makeFinding({
1646
+ type: "smart-rule",
1647
+ ruleName: r.name ?? r.tool,
1648
+ verdict: r.verdict === "block" ? "block" : "review",
1649
+ severity: classifyRuleSeverity(r.name ?? r.tool, r.verdict),
1650
+ reason: r.reason ?? `Smart rule ${r.name ?? r.tool} fired`,
1651
+ toolName: call.toolName,
1652
+ ctx,
1653
+ ts,
1654
+ sourceType: source.sourceType,
1655
+ shieldLabel: source.shieldLabel,
1656
+ input: call.args
1657
+ })
1658
+ );
1659
+ break;
1660
+ }
1661
+ const evalVerdict = detectDangerousShellExec(command);
1662
+ if (evalVerdict) {
1663
+ out.push(
1664
+ makeFinding({
1665
+ type: "eval-of-remote",
1666
+ ruleName: "eval-of-remote",
1667
+ verdict: evalVerdict,
1668
+ severity: classifyRuleSeverity("eval-remote", evalVerdict),
1669
+ reason: evalVerdict === "block" ? "Eval of remote download is a near-certain supply-chain attack" : "Eval of dynamic content (variable / subshell) requires approval",
1670
+ toolName: call.toolName,
1671
+ ctx,
1672
+ ts,
1673
+ sourceType: "engine",
1674
+ input: call.args
1675
+ })
1676
+ );
1677
+ }
1678
+ const pipe = analyzePipeChain(command);
1679
+ if (pipe.isPipeline && pipe.risk === "critical") {
1680
+ out.push(
1681
+ makeFinding({
1682
+ type: "pipe-to-shell",
1683
+ ruleName: "pipe-to-shell",
1684
+ verdict: "block",
1685
+ severity: "critical",
1686
+ reason: `Sensitive file piped through obfuscator to network sink: ${pipe.sourceFiles.join(", ")} \u2192 ${pipe.sinkTargets.join(", ")}`,
1687
+ toolName: call.toolName,
1688
+ ctx,
1689
+ ts,
1690
+ sourceType: "engine",
1691
+ input: call.args
1692
+ })
1693
+ );
1694
+ }
1695
+ if (DESTRUCTIVE_OP_RE.test(command)) {
1696
+ out.push(
1697
+ makeFinding({
1698
+ type: "destructive-op",
1699
+ ruleName: "destructive-op",
1700
+ verdict: "review",
1701
+ severity: "high",
1702
+ reason: "Destructive operation pattern detected",
1703
+ toolName: call.toolName,
1704
+ ctx,
1705
+ ts,
1706
+ sourceType: "engine",
1707
+ input: call.args
1708
+ })
1709
+ );
1710
+ }
1711
+ if (PRIVILEGE_ESCALATION_RE.test(command)) {
1712
+ out.push(
1713
+ makeFinding({
1714
+ type: "privilege-escalation",
1715
+ ruleName: "privilege-escalation",
1716
+ verdict: "review",
1717
+ severity: "high",
1718
+ reason: "Privilege-escalation pattern detected",
1719
+ toolName: call.toolName,
1720
+ ctx,
1721
+ ts,
1722
+ sourceType: "engine",
1723
+ input: call.args
1724
+ })
1725
+ );
1726
+ }
1727
+ return out;
1728
+ }
1729
+ function extractSessionLevelFindings(calls, ctx) {
1730
+ if (!ctx.loopDetection.enabled || calls.length === 0) return [];
1731
+ const out = [];
1732
+ const seenLoopKeys = /* @__PURE__ */ new Set();
1733
+ const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1e3;
1734
+ const windowMs = ctx.loopDetection.windowSeconds <= 0 ? ONE_YEAR_MS : ctx.loopDetection.windowSeconds * 1e3;
1735
+ let records = [];
1736
+ let syntheticTs = 0;
1737
+ for (let i = 0; i < calls.length; i++) {
1738
+ const call = calls[i];
1739
+ const parsed = new Date(call.timestamp).getTime();
1740
+ const now = Number.isFinite(parsed) ? parsed : ++syntheticTs;
1741
+ const verdict = evaluateLoopWindow(
1742
+ records,
1743
+ call.toolName,
1744
+ call.args,
1745
+ ctx.loopDetection.threshold,
1746
+ windowMs,
1747
+ now
1748
+ );
1749
+ records = verdict.nextRecords;
1750
+ if (!verdict.looping) continue;
1751
+ const last = records[records.length - 1];
1752
+ const key = `${last.t}|${last.h}`;
1753
+ if (seenLoopKeys.has(key)) continue;
1754
+ seenLoopKeys.add(key);
1755
+ out.push({
1756
+ type: "loop",
1757
+ ruleName: "loop",
1758
+ verdict: "review",
1759
+ severity: "medium",
1760
+ reason: `Tool called ${verdict.count} times with identical args within window`,
1761
+ toolName: call.toolName,
1762
+ agent: ctx.agent,
1763
+ sessionId: ctx.sessionId,
1764
+ project: ctx.project,
1765
+ lineIndex: call.lineIndex,
1766
+ sourceType: "engine",
1767
+ firstSeenAt: call.timestamp,
1768
+ lastSeenAt: call.timestamp,
1769
+ occurrenceCount: 1,
1770
+ loopCount: verdict.count,
1771
+ loopKind: "loop",
1772
+ commandPreview: previewArgs(call.args, DEDUPE_PREVIEW_LEN),
1773
+ costUsd: verdict.count * COST_PER_LOOP_ITER_USD
1774
+ });
1775
+ }
1776
+ return out;
1777
+ }
1778
+ function toScanFinding(c) {
1779
+ const typeMap = {
1780
+ "smart-rule": null,
1781
+ "ast-fs-op": null,
1782
+ dlp: "dlp",
1783
+ pii: "pii",
1784
+ "sensitive-file-read": "sensitive-file-read",
1785
+ "privilege-escalation": "privilege-escalation",
1786
+ "destructive-op": "destructive-op",
1787
+ "pipe-to-shell": "pipe-to-shell",
1788
+ "eval-of-remote": "eval-of-remote",
1789
+ loop: "loop",
1790
+ "long-output-redacted": "long-output-redacted"
1791
+ };
1792
+ const sfType = typeMap[c.type];
1793
+ if (sfType === null) return null;
1794
+ return {
1795
+ sessionId: c.sessionId,
1796
+ type: sfType,
1797
+ ...c.patternName && { patternName: c.patternName },
1798
+ lineIndex: c.lineIndex
1799
+ };
1800
+ }
1801
+ function previewArgs(input, max) {
1802
+ const cmd = input.command ?? input.query ?? input.file_path ?? JSON.stringify(input);
1803
+ const s = String(cmd).replace(TERMINAL_ESCAPE_RE, "").replace(/\s+/g, " ").trim();
1804
+ return s.length > max ? s.slice(0, max - 1) + "\u2026" : s;
1805
+ }
1806
+ function makeFinding(args) {
1807
+ const f = {
1808
+ type: args.type,
1809
+ ruleName: args.ruleName,
1810
+ verdict: args.verdict,
1811
+ severity: args.severity,
1812
+ reason: args.reason,
1813
+ toolName: args.toolName,
1814
+ agent: args.ctx.agent,
1815
+ sessionId: args.ctx.sessionId,
1816
+ project: args.ctx.project,
1817
+ lineIndex: args.ctx.lineIndex,
1818
+ sourceType: args.sourceType,
1819
+ firstSeenAt: args.ts,
1820
+ lastSeenAt: args.ts,
1821
+ occurrenceCount: 1
1822
+ };
1823
+ if (args.shieldLabel) f.shieldLabel = args.shieldLabel;
1824
+ if (args.subjectPath) f.subjectPath = args.subjectPath;
1825
+ if (args.input) f.input = args.input;
1826
+ if (args.patternName) f.patternName = args.patternName;
1827
+ if (args.redactedSample) f.redactedSample = args.redactedSample;
1828
+ return f;
1829
+ }
1830
+ function* stringValues(obj, depth = 0) {
1831
+ if (depth > 6) return;
1832
+ if (typeof obj === "string") {
1833
+ if (obj.length > 0) yield obj;
1834
+ return;
1835
+ }
1836
+ if (!obj || typeof obj !== "object") return;
1837
+ if (Array.isArray(obj)) {
1838
+ for (const v of obj) yield* stringValues(v, depth + 1);
1839
+ return;
1840
+ }
1841
+ for (const v of Object.values(obj)) yield* stringValues(v, depth + 1);
1842
+ }
1843
+ var import_safe_regex2, import_mvdan_sh, import_picomatch, import_safe_regex22, import_safe_regex23, import_crypto2, ASSIGNMENT_CONTEXT_RE, DLP_STOPWORDS, DLP_PATTERNS, DLP_PATTERNS_GLOBAL, SENSITIVE_PATH_PATTERNS, MAX_DEPTH, MAX_STRING_BYTES, MAX_JSON_PARSE_BYTES, syntax, sharedParser, MESSAGE_FLAGS, SHELL_INTERPRETERS, DOWNLOAD_CMDS, NORMALIZE_CACHE_MAX, normalizeCache, AST_CACHE_MAX, astCache, PARSE_FAIL, FS_READ_TOOLS, FS_OP_PRESCREEN_RE, HOME_CACHE_ALLOWLIST, SENSITIVE_PATH_RULES, BASH_TOOL_NAMES, AST_FS_REGEX_RULES, FS_OP_CACHE_MAX, fsOpCache, SOURCE_COMMANDS, SINK_COMMANDS, OBFUSCATORS, SENSITIVE_PATTERNS, FLAGS_WITH_VALUES, MAX_REGEX_LENGTH, REGEX_CACHE_MAX, regexCache, FORBIDDEN_PATH_SEGMENTS, SQL_DML_KEYWORDS, aws_default, bash_safe_default, docker_default, filesystem_default, github_default, k8s_default, mongodb_default, postgres_default, project_jail_default, redis_default, BUILTIN_SHIELDS, LOOP_MAX_RECORDS, FINDING_TO_SIGNAL, SCAN_SIGNAL_WEIGHTS, LOOP_THRESHOLD_FOR_WASTE, COST_PER_LOOP_ITER_USD, DESTRUCTIVE_OP_RE, PRIVILEGE_ESCALATION_RE, SENSITIVE_PATH_RE, FILE_TOOLS, PII_EMAIL_RE, PII_SSN_RE, PII_PHONE_RE, PII_CC_RE, LONG_OUTPUT_THRESHOLD_BYTES, CANONICAL_EXTRACTOR_VERSION, DEDUPE_PREVIEW_LEN, TERMINAL_ESCAPE_RE;
1314
1844
  var init_dist = __esm({
1315
1845
  "packages/policy-engine/dist/index.mjs"() {
1316
1846
  "use strict";
@@ -1784,6 +2314,83 @@ var init_dist = __esm({
1784
2314
  ]);
1785
2315
  SHELL_INTERPRETERS = /* @__PURE__ */ new Set(["bash", "sh", "zsh", "fish", "dash", "ksh"]);
1786
2316
  DOWNLOAD_CMDS = /* @__PURE__ */ new Set(["curl", "wget"]);
2317
+ NORMALIZE_CACHE_MAX = 5e3;
2318
+ normalizeCache = /* @__PURE__ */ new Map();
2319
+ AST_CACHE_MAX = 5e3;
2320
+ astCache = /* @__PURE__ */ new Map();
2321
+ PARSE_FAIL = /* @__PURE__ */ Symbol("parse-fail");
2322
+ FS_READ_TOOLS = /* @__PURE__ */ new Set([
2323
+ "cat",
2324
+ "less",
2325
+ "head",
2326
+ "tail",
2327
+ "bat",
2328
+ "more",
2329
+ "open",
2330
+ "print",
2331
+ "nano",
2332
+ "vim",
2333
+ "vi",
2334
+ "emacs",
2335
+ "code",
2336
+ "type"
2337
+ ]);
2338
+ FS_OP_PRESCREEN_RE = /(?:^|[\s|;&(`\n])(?:rm|cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type)\b/;
2339
+ HOME_CACHE_ALLOWLIST = [
2340
+ ".cache",
2341
+ ".npm/_npx",
2342
+ ".npm/_cacache",
2343
+ ".cargo/registry",
2344
+ ".gradle/caches",
2345
+ ".gradle/.tmp",
2346
+ ".m2/repository",
2347
+ ".pnpm-store",
2348
+ ".yarn/cache",
2349
+ ".yarn/.cache",
2350
+ ".cache/pip",
2351
+ ".local/share/Trash",
2352
+ ".rustup/downloads"
2353
+ ];
2354
+ SENSITIVE_PATH_RULES = [
2355
+ {
2356
+ rule: "shield:project-jail:block-read-ssh",
2357
+ reason: "Reading SSH private keys is blocked by project-jail shield",
2358
+ match: (p) => /(^|[\\/])\.ssh[\\/]/i.test(p)
2359
+ },
2360
+ {
2361
+ rule: "shield:project-jail:block-read-aws",
2362
+ reason: "Reading AWS credentials is blocked by project-jail shield",
2363
+ match: (p) => /(^|[\\/])\.aws[\\/]/i.test(p)
2364
+ },
2365
+ {
2366
+ rule: "shield:project-jail:block-read-env",
2367
+ reason: "Reading .env files is blocked by project-jail shield",
2368
+ match: (p) => /(?:^|[\\/])\.env(?:\.local|\.production|\.staging)?$/i.test(p)
2369
+ },
2370
+ {
2371
+ rule: "shield:project-jail:block-read-credentials",
2372
+ reason: "Reading credential files is blocked by project-jail shield",
2373
+ match: (p) => /(?:credentials\.json|\.netrc|\.npmrc|\.docker[\\/]config\.json|gcloud[\\/]credentials)$/i.test(
2374
+ p
2375
+ )
2376
+ }
2377
+ ];
2378
+ BASH_TOOL_NAMES = /* @__PURE__ */ new Set([
2379
+ "bash",
2380
+ "execute_bash",
2381
+ "run_shell_command",
2382
+ "shell",
2383
+ "exec_command"
2384
+ ]);
2385
+ AST_FS_REGEX_RULES = /* @__PURE__ */ new Set([
2386
+ "block-rm-rf-home",
2387
+ "shield:project-jail:block-read-ssh",
2388
+ "shield:project-jail:block-read-aws",
2389
+ "shield:project-jail:block-read-env",
2390
+ "shield:project-jail:block-read-credentials"
2391
+ ]);
2392
+ FS_OP_CACHE_MAX = 5e3;
2393
+ fsOpCache = /* @__PURE__ */ new Map();
1787
2394
  SOURCE_COMMANDS = /* @__PURE__ */ new Set([
1788
2395
  "cat",
1789
2396
  "head",
@@ -2638,6 +3245,31 @@ var init_dist = __esm({
2638
3245
  };
2639
3246
  LOOP_THRESHOLD_FOR_WASTE = 3;
2640
3247
  COST_PER_LOOP_ITER_USD = 6e-3;
3248
+ 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;
3249
+ PRIVILEGE_ESCALATION_RE = /\b(sudo|su)\b\s+[a-z]|\bchmod\s+(0?777|\+x)\b|\bchown\s+root\b/i;
3250
+ 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;
3251
+ FILE_TOOLS = /* @__PURE__ */ new Set([
3252
+ "read",
3253
+ "read_file",
3254
+ "edit",
3255
+ "edit_file",
3256
+ "write",
3257
+ "write_file",
3258
+ "multiedit",
3259
+ "grep",
3260
+ "grep_search",
3261
+ "glob",
3262
+ "list_files"
3263
+ ]);
3264
+ PII_EMAIL_RE = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/;
3265
+ PII_SSN_RE = /\b\d{3}-\d{2}-\d{4}\b/;
3266
+ PII_PHONE_RE = /\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}\b/;
3267
+ 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/;
3268
+ LONG_OUTPUT_THRESHOLD_BYTES = 100 * 1024;
3269
+ CANONICAL_EXTRACTOR_VERSION = "canonical-v1";
3270
+ DEDUPE_PREVIEW_LEN = 120;
3271
+ TERMINAL_ESCAPE_RE = // eslint-disable-next-line no-control-regex
3272
+ /\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-_]|[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g;
2641
3273
  }
2642
3274
  });
2643
3275
 
@@ -5559,9 +6191,7 @@ function removeNode9McpServer(servers) {
5559
6191
  return true;
5560
6192
  }
5561
6193
  function printDaemonTip() {
5562
- console.log(
5563
- import_chalk.default.cyan("\n \u{1F4A1} Node9 will protect you automatically using Native OS popups.") + import_chalk.default.white("\n To view your history or manage persistent rules, run:") + import_chalk.default.green("\n node9 daemon --openui")
5564
- );
6194
+ console.log(import_chalk.default.cyan("\n \u{1F4A1} Node9 will protect you automatically using Native OS popups."));
5565
6195
  }
5566
6196
  function fullPathCommand(subcommand) {
5567
6197
  if (process.env.NODE9_TESTING === "1") return `node9 ${subcommand}`;
@@ -6651,7 +7281,8 @@ function buildScanSummary(agents) {
6651
7281
  timestamp: f.timestamp,
6652
7282
  project: f.project,
6653
7283
  sessionId: f.sessionId,
6654
- agent: f.agent
7284
+ agent: f.agent,
7285
+ kind: f.kind
6655
7286
  }))
6656
7287
  );
6657
7288
  const byVerdict = {
@@ -6669,10 +7300,7 @@ function buildScanSummary(agents) {
6669
7300
  costUSD: a.scan.totalCostUSD
6670
7301
  })).filter((s) => s.sessions > 0 || s.findings > 0);
6671
7302
  const sections = buildSections(allFindings);
6672
- const wastedIters = allLoops.reduce(
6673
- (sum, l) => sum + Math.max(0, l.count - LOOP_THRESHOLD_FOR_WASTE),
6674
- 0
6675
- );
7303
+ const wastedIters = allLoops.filter((l) => l.kind !== "long-iteration").reduce((sum, l) => sum + Math.max(0, l.count - LOOP_THRESHOLD_FOR_WASTE), 0);
6676
7304
  const loopWastedUSD = wastedIters * COST_PER_LOOP_ITER_USD;
6677
7305
  return {
6678
7306
  stats,
@@ -7288,24 +7916,76 @@ var init_costSync = __esm({
7288
7916
  // src/daemon/scan-watermark.ts
7289
7917
  var scan_watermark_exports = {};
7290
7918
  __export(scan_watermark_exports, {
7919
+ WATERMARK_SCHEMA_VERSION: () => WATERMARK_SCHEMA_VERSION,
7291
7920
  extractFindingsFromLine: () => extractFindingsFromLine,
7292
7921
  loadWatermark: () => loadWatermark,
7922
+ markUploadComplete: () => markUploadComplete,
7293
7923
  saveWatermark: () => saveWatermark,
7294
7924
  scanDelta: () => scanDelta,
7295
7925
  tickScanWatcher: () => tickScanWatcher
7296
7926
  });
7927
+ function freshWatermark() {
7928
+ return {
7929
+ schemaVersion: WATERMARK_SCHEMA_VERSION,
7930
+ extractorVersion: CANONICAL_EXTRACTOR_VERSION,
7931
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
7932
+ files: {}
7933
+ };
7934
+ }
7297
7935
  function loadWatermark() {
7936
+ let raw;
7298
7937
  try {
7299
- const raw = import_fs16.default.readFileSync(WATERMARK_FILE(), "utf-8");
7300
- const parsed = JSON.parse(raw);
7301
- if (typeof parsed.createdAt === "string" && parsed.files && typeof parsed.files === "object") {
7302
- return parsed;
7303
- }
7938
+ raw = import_fs16.default.readFileSync(WATERMARK_FILE(), "utf-8");
7304
7939
  } catch {
7940
+ return { status: "fresh", wm: freshWatermark() };
7305
7941
  }
7306
- return { createdAt: (/* @__PURE__ */ new Date()).toISOString(), files: {} };
7942
+ let parsed;
7943
+ try {
7944
+ parsed = JSON.parse(raw);
7945
+ } catch {
7946
+ return { status: "fresh", wm: freshWatermark() };
7947
+ }
7948
+ if (typeof parsed.createdAt !== "string" || !parsed.files || typeof parsed.files !== "object") {
7949
+ return { status: "fresh", wm: freshWatermark() };
7950
+ }
7951
+ const fileSchemaVersion = typeof parsed.schemaVersion === "number" ? parsed.schemaVersion : 1;
7952
+ if (fileSchemaVersion > WATERMARK_SCHEMA_VERSION) {
7953
+ const wm2 = {
7954
+ schemaVersion: fileSchemaVersion,
7955
+ extractorVersion: typeof parsed.extractorVersion === "string" ? parsed.extractorVersion : CANONICAL_EXTRACTOR_VERSION,
7956
+ createdAt: parsed.createdAt,
7957
+ files: parsed.files
7958
+ };
7959
+ return { status: "schema-future", wm: wm2 };
7960
+ }
7961
+ const fileExtractorVersion = typeof parsed.extractorVersion === "string" ? parsed.extractorVersion : "";
7962
+ if (fileExtractorVersion !== CANONICAL_EXTRACTOR_VERSION) {
7963
+ const filesIn = parsed.files;
7964
+ const filesOut = {};
7965
+ for (const [k, v] of Object.entries(filesIn)) {
7966
+ filesOut[k] = { scannedTo: 0 };
7967
+ void v;
7968
+ }
7969
+ const wm2 = {
7970
+ schemaVersion: WATERMARK_SCHEMA_VERSION,
7971
+ extractorVersion: CANONICAL_EXTRACTOR_VERSION,
7972
+ pendingResetUploadAs: "totals",
7973
+ createdAt: parsed.createdAt,
7974
+ files: filesOut
7975
+ };
7976
+ return { status: "extractor-stale", wm: wm2 };
7977
+ }
7978
+ const wm = {
7979
+ schemaVersion: WATERMARK_SCHEMA_VERSION,
7980
+ extractorVersion: CANONICAL_EXTRACTOR_VERSION,
7981
+ ...parsed.pendingResetUploadAs === "totals" && { pendingResetUploadAs: "totals" },
7982
+ createdAt: parsed.createdAt,
7983
+ files: parsed.files
7984
+ };
7985
+ return { status: "current", wm };
7307
7986
  }
7308
7987
  function saveWatermark(wm) {
7988
+ if (wm.schemaVersion > WATERMARK_SCHEMA_VERSION) return;
7309
7989
  const target = WATERMARK_FILE();
7310
7990
  const dir = import_path18.default.dirname(target);
7311
7991
  if (!import_fs16.default.existsSync(dir)) import_fs16.default.mkdirSync(dir, { recursive: true });
@@ -7398,6 +8078,16 @@ function extractFindingsFromLine(line, sessionId, lineIndex) {
7398
8078
  }
7399
8079
  }
7400
8080
  }
8081
+ const ctx = {
8082
+ sessionId,
8083
+ lineIndex,
8084
+ project: "",
8085
+ agent: "claude",
8086
+ rules: [],
8087
+ toolInspection: { bash: "command", execute_bash: "command" },
8088
+ dlpEnabled: false
8089
+ // line-level DLP runs above already
8090
+ };
7401
8091
  const message = line.message;
7402
8092
  if (message && typeof message === "object") {
7403
8093
  const content = message.content;
@@ -7408,73 +8098,105 @@ function extractFindingsFromLine(line, sessionId, lineIndex) {
7408
8098
  if (b.type === "tool_result") {
7409
8099
  const c = b.content;
7410
8100
  const len = typeof c === "string" ? c.length : Array.isArray(c) ? JSON.stringify(c).length : 0;
7411
- if (len > LONG_OUTPUT_THRESHOLD_BYTES) {
8101
+ if (len > LONG_OUTPUT_THRESHOLD_BYTES2) {
7412
8102
  findings.push({
7413
8103
  type: "long-output-redacted",
7414
8104
  sessionId,
7415
8105
  lineIndex
7416
8106
  });
7417
8107
  }
8108
+ continue;
7418
8109
  }
7419
8110
  if (b.type !== "tool_use") continue;
7420
- const toolName = typeof b.name === "string" ? b.name.toLowerCase() : "";
7421
- const input = b.input;
7422
- if (FILE_TOOLS.has(toolName)) {
7423
- const filePath = typeof input?.file_path === "string" && input.file_path || typeof input?.path === "string" && input.path || typeof input?.pattern === "string" && input.pattern || "";
7424
- if (filePath && SENSITIVE_PATH_RE.test(filePath)) {
7425
- findings.push({
7426
- type: "sensitive-file-read",
7427
- sessionId,
7428
- lineIndex
7429
- });
7430
- }
7431
- }
7432
- if (toolName !== "bash" && toolName !== "execute_bash") continue;
7433
- const command = input && typeof input.command === "string" ? input.command : "";
7434
- if (!command) continue;
7435
- const verdict = detectDangerousShellExec(command);
7436
- if (verdict) {
7437
- findings.push({ type: "eval-of-remote", sessionId, lineIndex });
7438
- }
7439
- const pipe = analyzePipeChain(command);
7440
- if (pipe.isPipeline && pipe.risk === "critical") {
7441
- findings.push({ type: "pipe-to-shell", sessionId, lineIndex });
7442
- }
7443
- if (DESTRUCTIVE_OP_RE.test(command)) {
7444
- findings.push({ type: "destructive-op", sessionId, lineIndex });
7445
- }
7446
- if (PRIVILEGE_ESCALATION_RE.test(command)) {
7447
- findings.push({
7448
- type: "privilege-escalation",
7449
- sessionId,
7450
- lineIndex
7451
- });
8111
+ const toolName = typeof b.name === "string" ? b.name : "";
8112
+ const input = b.input ?? {};
8113
+ const call = {
8114
+ toolName,
8115
+ args: input,
8116
+ timestamp: typeof obj.timestamp === "string" ? obj.timestamp : ""
8117
+ };
8118
+ const canonical = extractCanonicalFindings(call, ctx);
8119
+ for (const cf of canonical) {
8120
+ const sf = toScanFinding(cf);
8121
+ if (sf) findings.push(sf);
7452
8122
  }
7453
8123
  }
7454
8124
  }
7455
8125
  }
7456
8126
  return findings;
7457
8127
  }
7458
- function detectPii(text) {
7459
- const found = /* @__PURE__ */ new Set();
7460
- if (/@/.test(text) && PII_EMAIL_RE.test(text)) found.add("Email");
7461
- if (/-/.test(text) && PII_SSN_RE.test(text)) found.add("SSN");
7462
- if (PII_PHONE_RE.test(text)) found.add("Phone");
7463
- if (PII_CC_RE.test(text)) found.add("Credit Card");
7464
- return [...found];
8128
+ function markUploadComplete() {
8129
+ const state = loadWatermark();
8130
+ if (state.status === "schema-future") return;
8131
+ if (state.status === "extractor-stale") return;
8132
+ if (!state.wm.pendingResetUploadAs) return;
8133
+ delete state.wm.pendingResetUploadAs;
8134
+ saveWatermark(state.wm);
7465
8135
  }
7466
8136
  async function tickScanWatcher() {
7467
8137
  if (process.env.NODE9_SCAN_DISABLE === "1") {
7468
- return {
7469
- findings: [],
7470
- totalToolCalls: 0,
7471
- toolCallsBySession: {},
7472
- filesScanned: 0,
7473
- filesNew: 0,
7474
- filesSkipped: 0
7475
- };
8138
+ return emptyTick("deltas");
8139
+ }
8140
+ const state = loadWatermark();
8141
+ if (state.status === "schema-future") {
8142
+ if (process.env.NODE9_DEBUG === "1") {
8143
+ process.stderr.write("[node9] watermark schema is from a newer daemon \u2014 skipping tick.\n");
8144
+ }
8145
+ return { ...emptyTick("deltas"), schemaFuture: true };
8146
+ }
8147
+ if (state.status === "extractor-stale") {
8148
+ if (process.env.NODE9_SKIP_WATERMARK_RESET === "1") {
8149
+ const acknowledged = readRawWatermarkPreservingOffsets();
8150
+ if (acknowledged) {
8151
+ saveWatermark(acknowledged);
8152
+ }
8153
+ process.stderr.write(
8154
+ "[node9] Extractor upgrade acknowledged via NODE9_SKIP_WATERMARK_RESET.\n Existing verdicts not refreshed \u2014 run `node9 scan --upload-history`\n to backfill them through the new pipeline.\n"
8155
+ );
8156
+ return runActualTick(loadWatermark().wm);
8157
+ }
8158
+ process.stderr.write(
8159
+ "[node9] Detector upgrade detected \u2014 re-scanning history through the new\n pipeline. Expect a one-time SaaS payload spike on this tick.\n Set NODE9_SKIP_WATERMARK_RESET=1 to skip.\n"
8160
+ );
8161
+ }
8162
+ return runActualTick(state.wm);
8163
+ }
8164
+ function emptyTick(uploadAs) {
8165
+ return {
8166
+ findings: [],
8167
+ totalToolCalls: 0,
8168
+ toolCallsBySession: {},
8169
+ filesScanned: 0,
8170
+ filesNew: 0,
8171
+ filesSkipped: 0,
8172
+ uploadAs,
8173
+ schemaFuture: false
8174
+ };
8175
+ }
8176
+ function readRawWatermarkPreservingOffsets() {
8177
+ let raw;
8178
+ try {
8179
+ raw = import_fs16.default.readFileSync(WATERMARK_FILE(), "utf-8");
8180
+ } catch {
8181
+ return null;
8182
+ }
8183
+ let parsed;
8184
+ try {
8185
+ parsed = JSON.parse(raw);
8186
+ } catch {
8187
+ return null;
7476
8188
  }
7477
- const wm = loadWatermark();
8189
+ if (typeof parsed.createdAt !== "string" || !parsed.files || typeof parsed.files !== "object") {
8190
+ return null;
8191
+ }
8192
+ return {
8193
+ schemaVersion: WATERMARK_SCHEMA_VERSION,
8194
+ extractorVersion: CANONICAL_EXTRACTOR_VERSION,
8195
+ createdAt: parsed.createdAt,
8196
+ files: parsed.files
8197
+ };
8198
+ }
8199
+ async function runActualTick(wm) {
7478
8200
  const watermarkCreatedAt = new Date(wm.createdAt).getTime();
7479
8201
  const findings = [];
7480
8202
  let totalToolCalls = 0;
@@ -7521,10 +8243,20 @@ async function tickScanWatcher() {
7521
8243
  wm.files[filePath] = { scannedTo: newScannedTo };
7522
8244
  filesScanned++;
7523
8245
  }
8246
+ const uploadAs = wm.pendingResetUploadAs === "totals" ? "totals" : "deltas";
7524
8247
  saveWatermark(wm);
7525
- return { findings, totalToolCalls, toolCallsBySession, filesScanned, filesNew, filesSkipped };
8248
+ return {
8249
+ findings,
8250
+ totalToolCalls,
8251
+ toolCallsBySession,
8252
+ filesScanned,
8253
+ filesNew,
8254
+ filesSkipped,
8255
+ uploadAs,
8256
+ schemaFuture: false
8257
+ };
7526
8258
  }
7527
- var import_fs16, import_os15, import_path18, import_readline, PROJECTS_DIR, WATERMARK_FILE, MAX_LINE_BYTES, DESTRUCTIVE_OP_RE, LONG_OUTPUT_THRESHOLD_BYTES, FILE_TOOLS, SENSITIVE_PATH_RE, PII_EMAIL_RE, PII_SSN_RE, PII_PHONE_RE, PII_CC_RE, PRIVILEGE_ESCALATION_RE;
8259
+ var import_fs16, import_os15, import_path18, import_readline, PROJECTS_DIR, WATERMARK_FILE, MAX_LINE_BYTES, WATERMARK_SCHEMA_VERSION, LONG_OUTPUT_THRESHOLD_BYTES2;
7528
8260
  var init_scan_watermark = __esm({
7529
8261
  "src/daemon/scan-watermark.ts"() {
7530
8262
  "use strict";
@@ -7537,27 +8269,8 @@ var init_scan_watermark = __esm({
7537
8269
  PROJECTS_DIR = () => import_path18.default.join(import_os15.default.homedir(), ".claude", "projects");
7538
8270
  WATERMARK_FILE = () => import_path18.default.join(import_os15.default.homedir(), ".node9", "scan-watermark.json");
7539
8271
  MAX_LINE_BYTES = 2 * 1024 * 1024;
7540
- 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;
7541
- LONG_OUTPUT_THRESHOLD_BYTES = 100 * 1024;
7542
- FILE_TOOLS = /* @__PURE__ */ new Set([
7543
- "read",
7544
- "read_file",
7545
- "edit",
7546
- "edit_file",
7547
- "write",
7548
- "write_file",
7549
- "multiedit",
7550
- "grep",
7551
- "grep_search",
7552
- "glob",
7553
- "list_files"
7554
- ]);
7555
- 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;
7556
- PII_EMAIL_RE = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/;
7557
- PII_SSN_RE = /\b\d{3}-\d{2}-\d{4}\b/;
7558
- PII_PHONE_RE = /\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}\b/;
7559
- 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/;
7560
- PRIVILEGE_ESCALATION_RE = /\b(sudo|su)\b\s+[a-z]|\bchmod\s+(0?777|\+x)\b|\bchown\s+root\b/i;
8272
+ WATERMARK_SCHEMA_VERSION = 2;
8273
+ LONG_OUTPUT_THRESHOLD_BYTES2 = LONG_OUTPUT_THRESHOLD_BYTES;
7561
8274
  }
7562
8275
  });
7563
8276
 
@@ -7677,6 +8390,13 @@ async function runUploadHistory(opts) {
7677
8390
  let linesParsed = 0;
7678
8391
  let linesSkipped = 0;
7679
8392
  const dailyEntries = [];
8393
+ const liveLoopCfg = getConfig().policy.loopDetection;
8394
+ const loopCfg = {
8395
+ enabled: liveLoopCfg.enabled,
8396
+ threshold: 3,
8397
+ windowSeconds: 0
8398
+ // "no window" — engine treats this as session-wide
8399
+ };
7680
8400
  for (const { filePath, sessionId, projectDir } of iterateJsonlFiles(cutoffMs)) {
7681
8401
  filesScanned++;
7682
8402
  let content;
@@ -7686,6 +8406,7 @@ async function runUploadHistory(opts) {
7686
8406
  continue;
7687
8407
  }
7688
8408
  let lineIndex = 0;
8409
+ const sessionCalls = [];
7689
8410
  for (const line of content.split("\n")) {
7690
8411
  if (!line.trim()) continue;
7691
8412
  let obj;
@@ -7702,10 +8423,38 @@ async function runUploadHistory(opts) {
7702
8423
  if (obj["type"] === "assistant" && Array.isArray(msg?.content) && msg.content.some((b) => b.type === "tool_use")) {
7703
8424
  totalToolCalls++;
7704
8425
  toolCallsBySession[sessionId] = (toolCallsBySession[sessionId] ?? 0) + 1;
8426
+ const ts = typeof obj.timestamp === "string" ? obj.timestamp : "";
8427
+ for (const block of msg.content) {
8428
+ if (!block || typeof block !== "object") continue;
8429
+ const b = block;
8430
+ if (b.type !== "tool_use") continue;
8431
+ sessionCalls.push({
8432
+ toolName: typeof b.name === "string" ? b.name : "",
8433
+ args: b.input ?? {},
8434
+ timestamp: ts,
8435
+ lineIndex
8436
+ });
8437
+ }
7705
8438
  }
7706
8439
  linesParsed++;
7707
8440
  lineIndex++;
7708
8441
  }
8442
+ if (loopCfg.enabled && sessionCalls.length > 0) {
8443
+ const loops = extractSessionLevelFindings(sessionCalls, {
8444
+ sessionId,
8445
+ project: decodeProjectDirName(projectDir),
8446
+ agent: "claude",
8447
+ loopDetection: {
8448
+ enabled: loopCfg.enabled,
8449
+ threshold: loopCfg.threshold,
8450
+ windowSeconds: loopCfg.windowSeconds
8451
+ }
8452
+ });
8453
+ for (const cf of loops) {
8454
+ const sf = toScanFinding(cf);
8455
+ if (sf) findings.push(sf);
8456
+ }
8457
+ }
7709
8458
  const fallbackWorkingDir = decodeProjectDirName(projectDir);
7710
8459
  const dailyMap = parseJSONLFile(filePath, fallbackWorkingDir);
7711
8460
  for (const entry of dailyMap.values()) {
@@ -7730,7 +8479,8 @@ async function runUploadHistory(opts) {
7730
8479
  const scanUrl = creds.apiUrl.endsWith("/policies/sync") ? creds.apiUrl.replace(/\/policies\/sync$/, "/scan/report") : `${creds.apiUrl.replace(/\/$/, "")}/scan/report`;
7731
8480
  await postJson(scanUrl, creds.apiKey, {
7732
8481
  ...summary,
7733
- sessionTotals
8482
+ sessionTotals,
8483
+ extractorVersion: CANONICAL_EXTRACTOR_VERSION
7734
8484
  });
7735
8485
  console.log(import_chalk3.default.green(` \u2713 Uploaded scanner findings`));
7736
8486
  if (dailyEntries.length > 0) {
@@ -7833,6 +8583,20 @@ function geminiModelPrice(model) {
7833
8583
  if (base.includes("flash")) return GEMINI_PRICING["gemini-2.0-flash"];
7834
8584
  return null;
7835
8585
  }
8586
+ function isNode9SelfOutput(text) {
8587
+ let hits = 0;
8588
+ for (const re of SELF_OUTPUT_MARKERS) {
8589
+ if (re.test(text)) hits++;
8590
+ if (hits >= 2) return true;
8591
+ }
8592
+ return false;
8593
+ }
8594
+ function looksLikeFixtureToken(sample) {
8595
+ for (const re of FIXTURE_TOKEN_PATTERNS) {
8596
+ if (re.test(sample)) return true;
8597
+ }
8598
+ return false;
8599
+ }
7836
8600
  function num(n) {
7837
8601
  return n.toLocaleString();
7838
8602
  }
@@ -7853,7 +8617,7 @@ function fmtTs(ts) {
7853
8617
  }
7854
8618
  }
7855
8619
  function stripTerminalEscapes(s) {
7856
- return s.replace(TERMINAL_ESCAPE_RE, "");
8620
+ return s.replace(TERMINAL_ESCAPE_RE2, "");
7857
8621
  }
7858
8622
  function preview(input, max) {
7859
8623
  const cmd = input.command ?? input.query ?? input.file_path ?? JSON.stringify(input);
@@ -7896,6 +8660,45 @@ function buildRecurringPatternSet(findings) {
7896
8660
  }
7897
8661
  return recurring;
7898
8662
  }
8663
+ function pushFsOpAstFinding(command, toolName, input, timestamp, projLabel, sessionId, agent, result) {
8664
+ const fsVerdict = analyzeFsOperation(command);
8665
+ if (!fsVerdict) return false;
8666
+ const synthRule = {
8667
+ name: fsVerdict.ruleName,
8668
+ tool: "bash",
8669
+ conditions: [],
8670
+ verdict: fsVerdict.verdict,
8671
+ reason: fsVerdict.reason
8672
+ };
8673
+ const isShieldRule = fsVerdict.ruleName.startsWith("shield:");
8674
+ const synthSource = isShieldRule ? {
8675
+ shieldName: "project-jail",
8676
+ shieldLabel: "project-jail (AST)",
8677
+ sourceType: "shield",
8678
+ rule: synthRule
8679
+ } : {
8680
+ shieldName: "",
8681
+ shieldLabel: "default (AST)",
8682
+ sourceType: "default",
8683
+ rule: synthRule
8684
+ };
8685
+ const inputPreview = preview(input, 120);
8686
+ const isDupe = result.findings.some(
8687
+ (f) => f.source.rule.name === synthRule.name && preview(f.input, 120) === inputPreview && f.project === projLabel
8688
+ );
8689
+ if (!isDupe) {
8690
+ result.findings.push({
8691
+ source: synthSource,
8692
+ toolName,
8693
+ input,
8694
+ timestamp,
8695
+ project: projLabel,
8696
+ sessionId,
8697
+ agent
8698
+ });
8699
+ }
8700
+ return true;
8701
+ }
7899
8702
  function isStaleFinding(timestamp, now = Date.now()) {
7900
8703
  if (!timestamp) return false;
7901
8704
  const t = Date.parse(timestamp);
@@ -7929,15 +8732,24 @@ function detectLoops(calls, project, sessionId, agent) {
7929
8732
  const entry = counts.get(key) ?? {
7930
8733
  count: 0,
7931
8734
  timestamp: call.timestamp,
8735
+ firstTs: null,
8736
+ lastTs: null,
7932
8737
  input: call.input,
7933
8738
  toolName: call.toolName
7934
8739
  };
7935
8740
  entry.count++;
8741
+ const t = call.timestamp ? Date.parse(call.timestamp) : NaN;
8742
+ if (!Number.isNaN(t)) {
8743
+ if (entry.firstTs === null || t < entry.firstTs) entry.firstTs = t;
8744
+ if (entry.lastTs === null || t > entry.lastTs) entry.lastTs = t;
8745
+ }
7936
8746
  counts.set(key, entry);
7937
8747
  }
7938
8748
  const findings = [];
7939
8749
  for (const [, entry] of counts) {
7940
8750
  if (entry.count >= LOOP_THRESHOLD) {
8751
+ const span = entry.firstTs !== null && entry.lastTs !== null ? entry.lastTs - entry.firstTs : 0;
8752
+ const kind = span >= LOOP_TIMESPAN_THRESHOLD_MS ? "long-iteration" : "loop";
7941
8753
  findings.push({
7942
8754
  toolName: entry.toolName,
7943
8755
  commandPreview: preview(entry.input, 80),
@@ -7945,7 +8757,8 @@ function detectLoops(calls, project, sessionId, agent) {
7945
8757
  timestamp: entry.timestamp,
7946
8758
  project,
7947
8759
  sessionId,
7948
- agent
8760
+ agent,
8761
+ kind
7949
8762
  });
7950
8763
  }
7951
8764
  }
@@ -8164,8 +8977,10 @@ function scanClaudeHistory(startDate, onProgress, onLine) {
8164
8977
  }
8165
8978
  const resultText = typeof block.content === "string" ? block.content : Array.isArray(block.content) ? block.content.map((c) => c.text ?? "").join("\n") : null;
8166
8979
  if (!resultText) continue;
8980
+ if (isNode9SelfOutput(resultText)) continue;
8167
8981
  const dlpMatch = scanArgs({ text: resultText });
8168
8982
  if (dlpMatch) {
8983
+ if (looksLikeFixtureToken(dlpMatch.redactedSample)) continue;
8169
8984
  if (firstDlpTs === null) firstDlpTs = entry.timestamp ?? null;
8170
8985
  const isDupe = result.dlpFindings.some(
8171
8986
  (f) => f.patternName === dlpMatch.patternName && f.redactedSample === dlpMatch.redactedSample && f.project === projLabel
@@ -8236,11 +9051,26 @@ function scanClaudeHistory(startDate, onProgress, onLine) {
8236
9051
  });
8237
9052
  }
8238
9053
  }
8239
- let ruleMatched = false;
9054
+ let astFsMatched = false;
9055
+ const astRanForBash = toolNameLower === "bash" || toolNameLower === "execute_bash";
9056
+ if (astRanForBash) {
9057
+ astFsMatched = pushFsOpAstFinding(
9058
+ String(input.command ?? ""),
9059
+ toolName,
9060
+ input,
9061
+ entry.timestamp ?? "",
9062
+ projLabel,
9063
+ sessionId,
9064
+ "claude",
9065
+ result
9066
+ );
9067
+ }
9068
+ let ruleMatched = astFsMatched;
8240
9069
  for (const source of ruleSources) {
8241
9070
  const { rule } = source;
8242
9071
  if (rule.verdict === "allow") continue;
8243
9072
  if (rule.tool && !matchesPattern(toolNameLower, rule.tool)) continue;
9073
+ if (astRanForBash && rule.name && AST_FS_REGEX_RULES.has(rule.name)) continue;
8244
9074
  if (!evaluateSmartConditions(input, rule)) continue;
8245
9075
  const inputPreview = preview(input, 120);
8246
9076
  const isDupe = result.findings.some(
@@ -8436,11 +9266,26 @@ function scanGeminiHistory(startDate, onProgress, onLine) {
8436
9266
  });
8437
9267
  }
8438
9268
  }
8439
- let ruleMatched = false;
9269
+ let astFsMatched = false;
9270
+ const astRanForBash = toolNameLower === "run_shell_command" || toolNameLower === "shell";
9271
+ if (astRanForBash) {
9272
+ astFsMatched = pushFsOpAstFinding(
9273
+ String(input.command ?? ""),
9274
+ toolName,
9275
+ input,
9276
+ msg.timestamp ?? "",
9277
+ projLabel,
9278
+ sessionId,
9279
+ "gemini",
9280
+ result
9281
+ );
9282
+ }
9283
+ let ruleMatched = astFsMatched;
8440
9284
  for (const source of ruleSources) {
8441
9285
  const { rule } = source;
8442
9286
  if (rule.verdict === "allow") continue;
8443
9287
  if (rule.tool && !matchesPattern(toolNameLower, rule.tool)) continue;
9288
+ if (astRanForBash && rule.name && AST_FS_REGEX_RULES.has(rule.name)) continue;
8444
9289
  if (!evaluateSmartConditions(input, rule)) continue;
8445
9290
  const inputPreview = preview(input, 120);
8446
9291
  const isDupe = result.findings.some(
@@ -8658,12 +9503,27 @@ function scanCodexHistory(startDate, onProgress, onLine) {
8658
9503
  });
8659
9504
  }
8660
9505
  }
8661
- let ruleMatched = false;
9506
+ let astFsMatched = false;
9507
+ const astRanForBash = toolNameLower === "exec_command" || toolNameLower === "bash";
9508
+ if (astRanForBash) {
9509
+ astFsMatched = pushFsOpAstFinding(
9510
+ String(input["command"] ?? ""),
9511
+ toolName,
9512
+ input,
9513
+ ts,
9514
+ projLabel,
9515
+ sessionId,
9516
+ "codex",
9517
+ result
9518
+ );
9519
+ }
9520
+ let ruleMatched = astFsMatched;
8662
9521
  for (const source of ruleSources) {
8663
9522
  const { rule } = source;
8664
9523
  if (rule.verdict === "allow") continue;
8665
9524
  if (rule.tool && !matchesPattern(toolNameLower === "exec_command" ? "bash" : toolNameLower, rule.tool))
8666
9525
  continue;
9526
+ if (astRanForBash && rule.name && AST_FS_REGEX_RULES.has(rule.name)) continue;
8667
9527
  if (!evaluateSmartConditions(input, rule)) continue;
8668
9528
  const inputPreview = preview(input, 120);
8669
9529
  const isDupe = result.findings.some(
@@ -8863,15 +9723,22 @@ function renderCompactScorecard(input) {
8863
9723
  import_chalk4.default.red("\u{1F6D1} ") + import_chalk4.default.red.bold(String(blockedCount).padEnd(4)) + import_chalk4.default.dim("would have blocked".padEnd(20)) + import_chalk4.default.dim(`(${topBlocked})`)
8864
9724
  );
8865
9725
  }
8866
- if (scan.loopFindings.length > 0) {
8867
- const wastedCalls = scan.loopFindings.reduce((s, l) => s + Math.max(0, l.count - 1), 0);
9726
+ const realLoops = scan.loopFindings.filter((l) => l.kind !== "long-iteration");
9727
+ const longIterations = scan.loopFindings.filter((l) => l.kind === "long-iteration");
9728
+ if (realLoops.length > 0) {
9729
+ const wastedCalls = realLoops.reduce((s, l) => s + Math.max(0, l.count - 1), 0);
8868
9730
  const wastePct = scan.totalToolCalls > 0 ? Math.round(wastedCalls / scan.totalToolCalls * 100) : 0;
8869
9731
  const wasteParts = [];
8870
9732
  if (wastePct > 0) wasteParts.push(`${wastePct}% wasted`);
8871
9733
  if (summary.loopWastedUSD > 0) wasteParts.push("~" + fmtCost(summary.loopWastedUSD));
8872
9734
  const wasteSummary = wasteParts.length ? `(${wasteParts.join(" \xB7 ")})` : "";
8873
9735
  console.log(
8874
- import_chalk4.default.yellow("\u{1F501} ") + import_chalk4.default.yellow.bold(String(scan.loopFindings.length).padEnd(4)) + import_chalk4.default.dim("agent loops".padEnd(20)) + import_chalk4.default.dim(wasteSummary)
9736
+ import_chalk4.default.yellow("\u{1F501} ") + import_chalk4.default.yellow.bold(String(realLoops.length).padEnd(4)) + import_chalk4.default.dim("agent loops".padEnd(20)) + import_chalk4.default.dim(wasteSummary)
9737
+ );
9738
+ }
9739
+ if (longIterations.length > 0) {
9740
+ console.log(
9741
+ import_chalk4.default.dim("\u{1F4C2} ") + import_chalk4.default.dim.bold(String(longIterations.length).padEnd(4)) + import_chalk4.default.dim("long iterations".padEnd(20)) + import_chalk4.default.dim("(deep work \u2014 not waste)")
8875
9742
  );
8876
9743
  }
8877
9744
  if (reviewCount > 0) {
@@ -9024,7 +9891,7 @@ function renderNarrativeScorecard(input) {
9024
9891
  console.log("");
9025
9892
  }
9026
9893
  function registerScanCommand(program2) {
9027
- program2.command("scan").description("Forecast: scan agent history and show what node9 would catch if installed").option("--all", "Scan all history (default: last 30 days)").option("--days <n>", "Scan last N days of history", "30").option("--top <n>", "Max findings to show per rule (default: 5)", "5").option("--drill-down", "Show all findings with full commands and session IDs").option("--compact", "Compact one-screen scorecard \u2014 for screenshots and sharing").option("--narrative", "Severity-grouped report \u2014 for video / dramatic sharing").option(
9894
+ program2.command("scan").description("Forecast: scan agent history and show what node9 would catch if installed").option("--all", "Scan all history (default: last 90 days)").option("--days <n>", "Scan last N days of history", "90").option("--top <n>", "Max findings to show per rule (default: 5)", "5").option("--drill-down", "Show all findings with full commands and session IDs").option("--compact", "Compact one-screen scorecard \u2014 for screenshots and sharing").option("--narrative", "Severity-grouped report \u2014 for video / dramatic sharing").option(
9028
9895
  "--upload-history",
9029
9896
  "Upload aggregate counts from existing JSONL sessions to the SaaS dashboard. Defaults to last 3 months; override with --since. Idempotent (safe to re-run)."
9030
9897
  ).option(
@@ -9043,7 +9910,7 @@ function registerScanCommand(program2) {
9043
9910
  const previewWidth = 70;
9044
9911
  const startDate = options.all ? null : (() => {
9045
9912
  const d = /* @__PURE__ */ new Date();
9046
- d.setDate(d.getDate() - (parseInt(options.days, 10) || 30));
9913
+ d.setDate(d.getDate() - (parseInt(options.days, 10) || 90));
9047
9914
  d.setHours(0, 0, 0, 0);
9048
9915
  return d;
9049
9916
  })();
@@ -9117,7 +9984,7 @@ function registerScanCommand(program2) {
9117
9984
  );
9118
9985
  return;
9119
9986
  }
9120
- const rangeLabel = options.all ? import_chalk4.default.dim("all time") : import_chalk4.default.dim(`last ${options.days ?? 30} days`);
9987
+ const rangeLabel = options.all ? import_chalk4.default.dim("all time") : import_chalk4.default.dim(`last ${options.days ?? 90} days`);
9121
9988
  const dateRange = scan.firstDate && scan.lastDate ? import_chalk4.default.dim(` ${fmtTs(scan.firstDate)} \u2013 ${fmtTs(scan.lastDate)}`) : "";
9122
9989
  const breakdownParts = [];
9123
9990
  if (claudeScan.sessions > 0)
@@ -9403,7 +10270,7 @@ function registerScanCommand(program2) {
9403
10270
  }
9404
10271
  );
9405
10272
  }
9406
- var import_chalk4, import_fs18, import_path20, import_os17, CLAUDE_PRICING, GEMINI_PRICING, CODE_EXTENSIONS, TERMINAL_ESCAPE_RE, LOOP_TOOLS, LOOP_THRESHOLD, STUCK_TOOLS_MIN_WASTE, STUCK_TOOLS_LIMIT, RECURRING_SESSION_THRESHOLD, STALE_AGE_DAYS, DEFAULT_RULE_NAMES, classifyRuleSeverity2, narrativeRuleLabel2;
10273
+ var import_chalk4, import_fs18, import_path20, import_os17, CLAUDE_PRICING, GEMINI_PRICING, CODE_EXTENSIONS, SELF_OUTPUT_MARKERS, FIXTURE_TOKEN_PATTERNS, TERMINAL_ESCAPE_RE2, LOOP_TOOLS, LOOP_THRESHOLD, LOOP_TIMESPAN_THRESHOLD_MS, STUCK_TOOLS_MIN_WASTE, STUCK_TOOLS_LIMIT, RECURRING_SESSION_THRESHOLD, STALE_AGE_DAYS, DEFAULT_RULE_NAMES, classifyRuleSeverity2, narrativeRuleLabel2;
9407
10274
  var init_scan = __esm({
9408
10275
  "src/cli/commands/scan.ts"() {
9409
10276
  "use strict";
@@ -9414,6 +10281,7 @@ var init_scan = __esm({
9414
10281
  init_shields();
9415
10282
  init_config();
9416
10283
  init_policy();
10284
+ init_dist();
9417
10285
  init_dlp();
9418
10286
  init_dist();
9419
10287
  init_scan_summary();
@@ -9466,7 +10334,23 @@ var init_scan = __esm({
9466
10334
  ".vue",
9467
10335
  ".svelte"
9468
10336
  ]);
9469
- TERMINAL_ESCAPE_RE = /\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-_]|[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g;
10337
+ SELF_OUTPUT_MARKERS = [
10338
+ /redactedSample:\s*['"]/,
10339
+ /patternName:\s*['"]/,
10340
+ /\bseverity:\s*['"](?:block|review|allow)['"]/,
10341
+ /NODE9 SECURITY ALERT/
10342
+ ];
10343
+ FIXTURE_TOKEN_PATTERNS = [
10344
+ /(.)\1{5,}/,
10345
+ // 6+ repeated characters (aaaaaa, 000000)
10346
+ /(?:EXAMPLE|FAKE|DUMMY|PLACEHOLDER|XXXXX)/i,
10347
+ /abcdefghijklmn/i,
10348
+ // long alpha sequence — fixture, not entropy
10349
+ /1234567890/,
10350
+ // long digit sequence — fixture, not entropy
10351
+ /qwerty/i
10352
+ ];
10353
+ TERMINAL_ESCAPE_RE2 = /\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-_]|[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g;
9470
10354
  LOOP_TOOLS = /* @__PURE__ */ new Set([
9471
10355
  "bash",
9472
10356
  "execute_bash",
@@ -9478,6 +10362,7 @@ var init_scan = __esm({
9478
10362
  "multiedit"
9479
10363
  ]);
9480
10364
  LOOP_THRESHOLD = 3;
10365
+ LOOP_TIMESPAN_THRESHOLD_MS = 10 * 60 * 1e3;
9481
10366
  STUCK_TOOLS_MIN_WASTE = 5;
9482
10367
  STUCK_TOOLS_LIMIT = 3;
9483
10368
  RECURRING_SESSION_THRESHOLD = 3;
@@ -10004,7 +10889,86 @@ function abandonPending() {
10004
10889
  }, 200);
10005
10890
  }
10006
10891
  }
10892
+ function logActivitySocket(msg) {
10893
+ try {
10894
+ import_fs20.default.appendFileSync(
10895
+ import_path22.default.join(homeDir, ".node9", "hook-debug.log"),
10896
+ `[${(/* @__PURE__ */ new Date()).toISOString()}] [activity-socket] ${msg}
10897
+ `
10898
+ );
10899
+ } catch {
10900
+ }
10901
+ }
10902
+ function shouldRebind(now = Date.now()) {
10903
+ if (activityCircuitTripped) return false;
10904
+ activityRebindAttempts = activityRebindAttempts.filter(
10905
+ (t) => now - t < ACTIVITY_REBIND_WINDOW_MS
10906
+ );
10907
+ activityRebindAttempts.push(now);
10908
+ if (activityRebindAttempts.length > ACTIVITY_REBIND_MAX_ATTEMPTS) {
10909
+ activityCircuitTripped = true;
10910
+ return false;
10911
+ }
10912
+ return true;
10913
+ }
10007
10914
  function startActivitySocket() {
10915
+ bindActivitySocket();
10916
+ if (process.platform !== "win32") {
10917
+ try {
10918
+ activitySocketWatcher = import_fs20.default.watch(import_os18.default.tmpdir(), (eventType, filename) => {
10919
+ if (filename !== import_path22.default.basename(ACTIVITY_SOCKET_PATH2)) return;
10920
+ if (eventType !== "rename") return;
10921
+ if (import_fs20.default.existsSync(ACTIVITY_SOCKET_PATH2)) return;
10922
+ attemptRebind("watch-unlink");
10923
+ });
10924
+ activitySocketWatcher.on("error", (err2) => {
10925
+ logActivitySocket(`watcher error: ${err2.message}`);
10926
+ });
10927
+ activitySocketWatcher.unref();
10928
+ } catch (err2) {
10929
+ logActivitySocket(`failed to start watcher: ${err2.message}`);
10930
+ }
10931
+ }
10932
+ activityHealthInterval = setInterval(() => {
10933
+ if (!import_fs20.default.existsSync(ACTIVITY_SOCKET_PATH2)) attemptRebind("health-probe");
10934
+ }, ACTIVITY_HEALTH_PROBE_MS);
10935
+ activityHealthInterval.unref();
10936
+ process.on("exit", () => {
10937
+ if (activitySocketWatcher) {
10938
+ try {
10939
+ activitySocketWatcher.close();
10940
+ } catch {
10941
+ }
10942
+ }
10943
+ if (activityHealthInterval) clearInterval(activityHealthInterval);
10944
+ try {
10945
+ import_fs20.default.unlinkSync(ACTIVITY_SOCKET_PATH2);
10946
+ } catch {
10947
+ }
10948
+ });
10949
+ }
10950
+ function attemptRebind(reason) {
10951
+ if (!shouldRebind()) {
10952
+ logActivitySocket(
10953
+ `circuit breaker tripped after ${ACTIVITY_REBIND_MAX_ATTEMPTS} attempts in ${ACTIVITY_REBIND_WINDOW_MS}ms \u2014 flight recorder down`
10954
+ );
10955
+ broadcast("flight-recorder-down", {
10956
+ reason: "rebind-loop",
10957
+ message: "Activity socket repeatedly disappearing \u2014 run: node9 daemon restart"
10958
+ });
10959
+ return;
10960
+ }
10961
+ logActivitySocket(`rebinding (reason: ${reason}, attempt ${activityRebindAttempts.length})`);
10962
+ if (activitySocketServer) {
10963
+ try {
10964
+ activitySocketServer.close();
10965
+ } catch {
10966
+ }
10967
+ activitySocketServer = null;
10968
+ }
10969
+ bindActivitySocket();
10970
+ }
10971
+ function bindActivitySocket() {
10008
10972
  try {
10009
10973
  import_fs20.default.unlinkSync(ACTIVITY_SOCKET_PATH2);
10010
10974
  } catch {
@@ -10102,15 +11066,15 @@ function startActivitySocket() {
10102
11066
  socket.on("error", () => {
10103
11067
  });
10104
11068
  });
10105
- unixServer.listen(ACTIVITY_SOCKET_PATH2);
10106
- process.on("exit", () => {
10107
- try {
10108
- import_fs20.default.unlinkSync(ACTIVITY_SOCKET_PATH2);
10109
- } catch {
10110
- }
11069
+ unixServer.on("error", (err2) => {
11070
+ logActivitySocket(`server error: ${err2.message}`);
11071
+ });
11072
+ unixServer.listen(ACTIVITY_SOCKET_PATH2, () => {
11073
+ logActivitySocket(`bound to ${ACTIVITY_SOCKET_PATH2}`);
10111
11074
  });
11075
+ activitySocketServer = unixServer;
10112
11076
  }
10113
- var import_net2, import_fs20, import_path22, import_os18, import_crypto6, homeDir, DAEMON_PID_FILE, DECISIONS_FILE, AUDIT_LOG_FILE, TRUST_FILE2, GLOBAL_CONFIG_FILE, CREDENTIALS_FILE, INSIGHT_COUNTS_FILE, pending, sseClients, suggestionTracker, taintStore, insightCounts, _abandonTimer, _hadBrowserClient, _daemonServer, daemonRejectionHandlerRegistered, AUTO_DENY_MS, TRUST_DURATIONS, autoStarted, ACTIVITY_SOCKET_PATH2, ACTIVITY_RING_SIZE, activityRing, LARGE_RESPONSE_RING_SIZE, largeResponseRing, cachedScanResult, cachedScanTs, SCAN_CACHE_TTL_MS, SECRET_KEY_RE, INPUT_PRICE_PER_1M, OUTPUT_PRICE_PER_1M, BYTES_PER_TOKEN, WRITE_TOOL_NAMES;
11077
+ var import_net2, import_fs20, import_path22, import_os18, import_crypto6, homeDir, DAEMON_PID_FILE, DECISIONS_FILE, AUDIT_LOG_FILE, TRUST_FILE2, GLOBAL_CONFIG_FILE, CREDENTIALS_FILE, INSIGHT_COUNTS_FILE, pending, sseClients, suggestionTracker, taintStore, insightCounts, _abandonTimer, _hadBrowserClient, _daemonServer, daemonRejectionHandlerRegistered, AUTO_DENY_MS, TRUST_DURATIONS, autoStarted, ACTIVITY_SOCKET_PATH2, ACTIVITY_RING_SIZE, activityRing, LARGE_RESPONSE_RING_SIZE, largeResponseRing, cachedScanResult, cachedScanTs, SCAN_CACHE_TTL_MS, SECRET_KEY_RE, INPUT_PRICE_PER_1M, OUTPUT_PRICE_PER_1M, BYTES_PER_TOKEN, WRITE_TOOL_NAMES, ACTIVITY_REBIND_MAX_ATTEMPTS, ACTIVITY_REBIND_WINDOW_MS, ACTIVITY_HEALTH_PROBE_MS, activitySocketServer, activitySocketWatcher, activityHealthInterval, activityRebindAttempts, activityCircuitTripped;
10114
11078
  var init_state2 = __esm({
10115
11079
  "src/daemon/state.ts"() {
10116
11080
  "use strict";
@@ -10171,6 +11135,14 @@ var init_state2 = __esm({
10171
11135
  "notebook_edit",
10172
11136
  "notebookedit"
10173
11137
  ]);
11138
+ ACTIVITY_REBIND_MAX_ATTEMPTS = 5;
11139
+ ACTIVITY_REBIND_WINDOW_MS = 6e4;
11140
+ ACTIVITY_HEALTH_PROBE_MS = 3e4;
11141
+ activitySocketServer = null;
11142
+ activitySocketWatcher = null;
11143
+ activityHealthInterval = null;
11144
+ activityRebindAttempts = [];
11145
+ activityCircuitTripped = false;
10174
11146
  }
10175
11147
  });
10176
11148
 
@@ -10373,16 +11345,19 @@ async function pushBlastSnapshot(creds) {
10373
11345
  async function pushScanSnapshot(creds) {
10374
11346
  try {
10375
11347
  const tick = await tickScanWatcher();
11348
+ if (tick.schemaFuture) return;
10376
11349
  if (tick.findings.length === 0 && tick.totalToolCalls === 0) {
10377
11350
  return;
10378
11351
  }
10379
11352
  const summary = summarizeScan(tick.findings, {
10380
11353
  totalToolCalls: tick.totalToolCalls
10381
11354
  });
10382
- const sessionDeltas = buildSessionDeltas(tick.findings, tick.toolCallsBySession);
11355
+ const perSession = buildSessionDeltas(tick.findings, tick.toolCallsBySession);
10383
11356
  const scanUrl = creds.apiUrl.endsWith("/policies/sync") ? creds.apiUrl.replace(/\/policies\/sync$/, "/scan/report") : null;
10384
11357
  if (!scanUrl) return;
11358
+ const body = tick.uploadAs === "totals" ? { ...summary, sessionTotals: perSession, extractorVersion: CANONICAL_EXTRACTOR_VERSION } : { ...summary, sessionDeltas: perSession, extractorVersion: CANONICAL_EXTRACTOR_VERSION };
10385
11359
  const parsed = new URL(scanUrl);
11360
+ let posted = false;
10386
11361
  await new Promise((resolve) => {
10387
11362
  const req = import_https2.default.request(
10388
11363
  {
@@ -10397,6 +11372,9 @@ async function pushScanSnapshot(creds) {
10397
11372
  timeout: 1e4
10398
11373
  },
10399
11374
  (res) => {
11375
+ if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
11376
+ posted = true;
11377
+ }
10400
11378
  res.resume();
10401
11379
  res.on("end", resolve);
10402
11380
  res.on("error", () => resolve());
@@ -10407,9 +11385,12 @@ async function pushScanSnapshot(creds) {
10407
11385
  req.destroy();
10408
11386
  resolve();
10409
11387
  });
10410
- req.write(JSON.stringify({ ...summary, sessionDeltas }));
11388
+ req.write(JSON.stringify(body));
10411
11389
  req.end();
10412
11390
  });
11391
+ if (posted && tick.uploadAs === "totals") {
11392
+ markUploadComplete();
11393
+ }
10413
11394
  } catch {
10414
11395
  }
10415
11396
  }
@@ -12312,6 +13293,8 @@ async function startTail(options = {}) {
12312
13293
  let initialReplayDone = false;
12313
13294
  const activityPending = /* @__PURE__ */ new Map();
12314
13295
  const orphanedResults = /* @__PURE__ */ new Map();
13296
+ let lastActivityFromDaemon = Date.now();
13297
+ let stallWarned = false;
12315
13298
  const authToken = getInternalToken() ?? "";
12316
13299
  const approvalQueue = [];
12317
13300
  let cardActive = false;
@@ -12538,6 +13521,24 @@ async function startTail(options = {}) {
12538
13521
  console.log(import_chalk29.default.dim("\n\u{1F6F0}\uFE0F Disconnected."));
12539
13522
  process.exit(0);
12540
13523
  });
13524
+ const STALL_THRESHOLD_MS = 6e4;
13525
+ const stallWatchdog = setInterval(() => {
13526
+ if (stallWarned) return;
13527
+ if (Date.now() - lastActivityFromDaemon < STALL_THRESHOLD_MS) return;
13528
+ try {
13529
+ const auditMtime = import_fs44.default.statSync(auditLog).mtimeMs;
13530
+ if (Date.now() - auditMtime >= STALL_THRESHOLD_MS) return;
13531
+ console.log("");
13532
+ console.log(
13533
+ import_chalk29.default.yellow(
13534
+ "\u26A0\uFE0F Tail appears stalled \u2014 hooks are firing but no events are arriving. Try: node9 daemon restart"
13535
+ )
13536
+ );
13537
+ stallWarned = true;
13538
+ } catch {
13539
+ }
13540
+ }, STALL_THRESHOLD_MS / 2);
13541
+ stallWatchdog.unref();
12541
13542
  const sseUrl = `http://127.0.0.1:${port}/events?capabilities=input`;
12542
13543
  const req = import_http2.default.get(
12543
13544
  sseUrl,
@@ -12583,7 +13584,18 @@ async function startTail(options = {}) {
12583
13584
  }
12584
13585
  );
12585
13586
  function handleMessage(event, rawData) {
13587
+ lastActivityFromDaemon = Date.now();
12586
13588
  if (event === "csrf") return;
13589
+ if (event === "flight-recorder-down") {
13590
+ try {
13591
+ const parsed = JSON.parse(rawData);
13592
+ const msg = parsed.message ?? "Flight recorder is down \u2014 run: node9 daemon restart";
13593
+ console.log("");
13594
+ console.log(import_chalk29.default.bgRed.white.bold(` \u26A0\uFE0F ${msg} `));
13595
+ } catch {
13596
+ }
13597
+ return;
13598
+ }
12587
13599
  if (event === "init") {
12588
13600
  try {
12589
13601
  const parsed = JSON.parse(rawData);
@@ -16166,7 +17178,6 @@ function registerInitCommand(program2) {
16166
17178
  console.log(import_chalk15.default.green.bold(`\u{1F6E1}\uFE0F Node9 is protecting ${agentList}!`));
16167
17179
  console.log("");
16168
17180
  console.log(import_chalk15.default.white(" Watch live: ") + import_chalk15.default.cyan("node9 tail"));
16169
- console.log(import_chalk15.default.white(" Local UI: ") + import_chalk15.default.cyan("node9 daemon --openui"));
16170
17181
  console.log("");
16171
17182
  console.log(import_chalk15.default.gray(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
16172
17183
  console.log(