@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.mjs CHANGED
@@ -428,9 +428,65 @@ function redactText(text) {
428
428
  }
429
429
  return { result, found };
430
430
  }
431
+ function isCatHeredocOrLit(part) {
432
+ if (!part) return false;
433
+ const t = syntax.NodeType(part);
434
+ if (t === "Lit") return true;
435
+ if (t !== "CmdSubst") return false;
436
+ const stmts = part.Stmts || [];
437
+ if (stmts.length !== 1) return false;
438
+ const stmt = stmts[0];
439
+ const redirs = stmt.Redirs || stmt.Cmd?.Redirs || [];
440
+ const hasHeredoc = redirs.some((r) => r && r.Hdoc);
441
+ if (!hasHeredoc) return false;
442
+ const cmd = stmt.Cmd;
443
+ if (!cmd || syntax.NodeType(cmd) !== "CallExpr") return false;
444
+ const firstArg2 = cmd.Args?.[0]?.Parts || [];
445
+ if (firstArg2.length !== 1 || syntax.NodeType(firstArg2[0]) !== "Lit") return false;
446
+ return (firstArg2[0].Value || "").toLowerCase() === "cat";
447
+ }
448
+ function parseShared(command) {
449
+ const cached = astCache.get(command);
450
+ if (cached !== void 0) {
451
+ astCache.delete(command);
452
+ astCache.set(command, cached);
453
+ return cached;
454
+ }
455
+ let parsed;
456
+ try {
457
+ parsed = sharedParser.Parse(command, "cmd");
458
+ } catch {
459
+ parsed = PARSE_FAIL;
460
+ }
461
+ if (astCache.size >= AST_CACHE_MAX) {
462
+ const oldest = astCache.keys().next().value;
463
+ if (oldest !== void 0) astCache.delete(oldest);
464
+ }
465
+ astCache.set(command, parsed);
466
+ return parsed;
467
+ }
468
+ function cachedNormalize(command, compute) {
469
+ const hit = normalizeCache.get(command);
470
+ if (hit !== void 0) {
471
+ normalizeCache.delete(command);
472
+ normalizeCache.set(command, hit);
473
+ return hit;
474
+ }
475
+ const result = compute();
476
+ if (normalizeCache.size >= NORMALIZE_CACHE_MAX) {
477
+ const oldest = normalizeCache.keys().next().value;
478
+ if (oldest !== void 0) normalizeCache.delete(oldest);
479
+ }
480
+ normalizeCache.set(command, result);
481
+ return result;
482
+ }
431
483
  function normalizeCommandForPolicy(command) {
484
+ return cachedNormalize(command, () => normalizeCommandForPolicyImpl(command));
485
+ }
486
+ function normalizeCommandForPolicyImpl(command) {
487
+ const f = parseShared(command);
488
+ if (f === PARSE_FAIL) return command;
432
489
  try {
433
- const f = sharedParser.Parse(command, "cmd");
434
490
  const strips = [];
435
491
  syntax.Walk(f, (node) => {
436
492
  if (!node) return false;
@@ -452,7 +508,11 @@ function normalizeCommandForPolicy(command) {
452
508
  } else if (nt === "DblQuoted") {
453
509
  const innerParts = quotedNode.Parts || [];
454
510
  const allLit = innerParts.length === 0 || innerParts.every((p) => syntax.NodeType(p) === "Lit");
455
- if (allLit) strips.push([next.Pos().Offset(), next.End().Offset()]);
511
+ if (allLit) {
512
+ strips.push([next.Pos().Offset(), next.End().Offset()]);
513
+ } else if (innerParts.every((p) => isCatHeredocOrLit(p))) {
514
+ strips.push([next.Pos().Offset(), next.End().Offset()]);
515
+ }
456
516
  }
457
517
  }
458
518
  return true;
@@ -520,6 +580,130 @@ function detectDangerousShellExec(command) {
520
580
  return null;
521
581
  }
522
582
  }
583
+ function isBashTool(toolName) {
584
+ return BASH_TOOL_NAMES.has(toolName.toLowerCase());
585
+ }
586
+ function isProtectedHomePath(rawPath) {
587
+ let p = rawPath.replace(/^\$HOME[\\/]?|^\$\{HOME\}[\\/]?/, "~/");
588
+ let underHome = false;
589
+ if (p === "~" || p.startsWith("~/") || p.startsWith("~\\")) {
590
+ p = p.replace(/^~[\\/]?/, "");
591
+ underHome = true;
592
+ } else if (/^\/home\/[^/]+/.test(p) || /^\/root(\/|$)/.test(p)) {
593
+ p = p.replace(/^\/home\/[^/]+[\\/]?|^\/root[\\/]?/, "");
594
+ underHome = true;
595
+ }
596
+ if (!underHome) return false;
597
+ if (p === "" || p === "." || p === "./") return true;
598
+ for (const safe of HOME_CACHE_ALLOWLIST) {
599
+ if (p === safe || p.startsWith(safe + "/") || p.startsWith(safe + "\\")) {
600
+ return false;
601
+ }
602
+ }
603
+ return true;
604
+ }
605
+ function extractLiteralArgs(callExpr) {
606
+ const args = callExpr.Args || [];
607
+ if (args.length === 0) return { name: "", flags: [], paths: [] };
608
+ const litFromWord = (w) => {
609
+ const parts = w?.Parts || [];
610
+ let s = "";
611
+ for (const p of parts) {
612
+ const t = syntax.NodeType(p);
613
+ if (t === "Lit") s += (p.Value ?? "").replace(/\\(.)/g, "$1");
614
+ else if (t === "SglQuoted") s += p.Value ?? "";
615
+ else if (t === "DblQuoted") {
616
+ const inner = p.Parts || [];
617
+ if (!inner.every((ip) => syntax.NodeType(ip) === "Lit")) return null;
618
+ s += inner.map((ip) => ip.Value ?? "").join("");
619
+ } else {
620
+ return null;
621
+ }
622
+ }
623
+ return s;
624
+ };
625
+ const name = (litFromWord(args[0]) || "").toLowerCase();
626
+ const flags = [];
627
+ const paths = [];
628
+ for (let i = 1; i < args.length; i++) {
629
+ const v = litFromWord(args[i]);
630
+ if (v === null) continue;
631
+ if (v.startsWith("-")) flags.push(v);
632
+ else paths.push(v);
633
+ }
634
+ return { name, flags, paths };
635
+ }
636
+ function analyzeFsOperation(command) {
637
+ if (!FS_OP_PRESCREEN_RE.test(command)) return null;
638
+ if (fsOpCache.has(command)) {
639
+ const hit = fsOpCache.get(command) ?? null;
640
+ fsOpCache.delete(command);
641
+ fsOpCache.set(command, hit);
642
+ return hit;
643
+ }
644
+ const computed = analyzeFsOperationImpl(command);
645
+ if (fsOpCache.size >= FS_OP_CACHE_MAX) {
646
+ const oldest = fsOpCache.keys().next().value;
647
+ if (oldest !== void 0) fsOpCache.delete(oldest);
648
+ }
649
+ fsOpCache.set(command, computed);
650
+ return computed;
651
+ }
652
+ function analyzeFsOperationImpl(command) {
653
+ const f = parseShared(command);
654
+ if (f === PARSE_FAIL) return null;
655
+ let result = null;
656
+ try {
657
+ syntax.Walk(f, (node) => {
658
+ if (!node || result) return false;
659
+ const n = node;
660
+ if (syntax.NodeType(n) !== "CallExpr") return true;
661
+ const { name, flags, paths } = extractLiteralArgs(n);
662
+ if (!name) return true;
663
+ if (name === "rm") {
664
+ const flagStr = flags.join("").toLowerCase();
665
+ const hasR = /[r]/.test(flagStr) || flags.includes("--recursive");
666
+ const hasF = /[f]/.test(flagStr) || flags.includes("--force");
667
+ if (hasR && hasF) {
668
+ for (const p of paths) {
669
+ if (isProtectedHomePath(p)) {
670
+ result = {
671
+ ruleName: "block-rm-rf-home",
672
+ verdict: "block",
673
+ reason: "Recursive delete of home directory is irreversible",
674
+ path: p
675
+ };
676
+ return false;
677
+ }
678
+ if (p === "/" || /^\/+$/.test(p)) {
679
+ result = {
680
+ ruleName: "block-rm-rf-home",
681
+ verdict: "block",
682
+ reason: "Recursive delete of root is catastrophic",
683
+ path: p
684
+ };
685
+ return false;
686
+ }
687
+ }
688
+ }
689
+ }
690
+ if (FS_READ_TOOLS.has(name)) {
691
+ for (const p of paths) {
692
+ for (const sp of SENSITIVE_PATH_RULES) {
693
+ if (sp.match(p)) {
694
+ result = { ruleName: sp.rule, verdict: "block", reason: sp.reason, path: p };
695
+ return false;
696
+ }
697
+ }
698
+ }
699
+ }
700
+ return true;
701
+ });
702
+ return result;
703
+ } catch {
704
+ return null;
705
+ }
706
+ }
523
707
  function analyzeShellCommand(command) {
524
708
  const actions = [];
525
709
  const paths = [];
@@ -809,10 +993,18 @@ function getNestedValue(obj, path49) {
809
993
  function evaluateSmartConditions(args, rule) {
810
994
  if (!rule.conditions || rule.conditions.length === 0) return true;
811
995
  const mode = rule.conditionMode ?? "all";
996
+ const fieldCache = /* @__PURE__ */ new Map();
997
+ const resolveField = (field) => {
998
+ if (fieldCache.has(field)) return fieldCache.get(field) ?? null;
999
+ const rawVal = getNestedValue(args, field);
1000
+ const rawStr = rawVal !== null && rawVal !== void 0 ? String(rawVal) : null;
1001
+ const stripped = field === "command" && rawStr !== null ? normalizeCommandForPolicy(rawStr) : rawStr;
1002
+ const val = stripped !== null ? stripped.replace(/\s+/g, " ").trim() : null;
1003
+ fieldCache.set(field, val);
1004
+ return val;
1005
+ };
812
1006
  const results = rule.conditions.map((cond) => {
813
- const rawVal = getNestedValue(args, cond.field);
814
- const normalized = rawVal !== null && rawVal !== void 0 ? String(rawVal).replace(/\s+/g, " ").trim() : null;
815
- const val = cond.field === "command" && normalized !== null ? normalizeCommandForPolicy(normalized) : normalized;
1007
+ const val = resolveField(cond.field);
816
1008
  switch (cond.op) {
817
1009
  case "exists":
818
1010
  return val !== null && val !== "";
@@ -863,6 +1055,43 @@ function isSqlTool(toolName, toolInspection) {
863
1055
  const fieldName = toolInspection[matchingPattern];
864
1056
  return fieldName === "sql" || fieldName === "query";
865
1057
  }
1058
+ function pipeChainVerdict(command, isTrustedHost2) {
1059
+ const pipeAnalysis = analyzePipeChain(command);
1060
+ if (!pipeAnalysis.isPipeline) return null;
1061
+ if (pipeAnalysis.risk !== "critical" && pipeAnalysis.risk !== "high") return null;
1062
+ const sinks = pipeAnalysis.sinkTargets;
1063
+ const allTrusted = sinks.length > 0 && sinks.every((host) => isTrustedHost2 ? isTrustedHost2(host) : false);
1064
+ if (pipeAnalysis.risk === "critical") {
1065
+ if (allTrusted) {
1066
+ return {
1067
+ decision: "review",
1068
+ blockedByLabel: "Node9: Pipe-Chain to Trusted Host (obfuscated)",
1069
+ reason: `Obfuscated pipe to trusted host(s): ${sinks.join(", ")} \u2014 requires approval`,
1070
+ tier: 3
1071
+ };
1072
+ }
1073
+ return {
1074
+ decision: "block",
1075
+ blockedByLabel: "Node9: Pipe-Chain Exfiltration (critical)",
1076
+ reason: `Sensitive file piped through obfuscator to network sink: ${pipeAnalysis.sourceFiles.join(", ")} \u2192 ${sinks.join(", ")}`,
1077
+ tier: 3
1078
+ };
1079
+ }
1080
+ if (allTrusted) {
1081
+ return {
1082
+ decision: "allow",
1083
+ blockedByLabel: "Node9: Pipe-Chain to Trusted Host",
1084
+ reason: `Sensitive file piped to trusted host(s): ${sinks.join(", ")}`,
1085
+ tier: 3
1086
+ };
1087
+ }
1088
+ return {
1089
+ decision: "review",
1090
+ blockedByLabel: "Node9: Pipe-Chain Exfiltration (high)",
1091
+ reason: `Sensitive file piped to network sink: ${pipeAnalysis.sourceFiles.join(", ")} \u2192 ${sinks.join(", ")}`,
1092
+ tier: 3
1093
+ };
1094
+ }
866
1095
  async function evaluatePolicy(config, toolName, args, context = {}, hooks = {}) {
867
1096
  const { agent, cwd, activeEnvironment } = context;
868
1097
  const { checkProvenance: checkProvenance2, isTrustedHost: isTrustedHost2 } = hooks;
@@ -878,9 +1107,27 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
878
1107
  }
879
1108
  }
880
1109
  if (wouldBeIgnored) return { decision: "allow" };
1110
+ const bashCommand = agent !== "Terminal" && isBashTool(toolName) && args && typeof args === "object" ? typeof args.command === "string" ? args.command : null : null;
1111
+ if (bashCommand !== null) {
1112
+ const pipeVerdict = pipeChainVerdict(bashCommand, isTrustedHost2);
1113
+ if (pipeVerdict) return pipeVerdict;
1114
+ const fsVerdict = analyzeFsOperation(bashCommand);
1115
+ if (fsVerdict) {
1116
+ const isShieldRule = fsVerdict.ruleName.startsWith("shield:");
1117
+ const labelPrefix = isShieldRule ? "project-jail (AST)" : "Node9 (AST)";
1118
+ return {
1119
+ decision: fsVerdict.verdict,
1120
+ blockedByLabel: `${labelPrefix}: ${fsVerdict.ruleName}`,
1121
+ reason: fsVerdict.reason,
1122
+ tier: 2,
1123
+ ruleName: fsVerdict.ruleName,
1124
+ ruleDescription: fsVerdict.reason
1125
+ };
1126
+ }
1127
+ }
881
1128
  if (config.policy.smartRules.length > 0) {
882
1129
  const matchedRule = config.policy.smartRules.find(
883
- (rule) => matchesPattern(toolName, rule.tool) && evaluateSmartConditions(args, rule)
1130
+ (rule) => matchesPattern(toolName, rule.tool) && !(bashCommand !== null && rule.name && AST_FS_REGEX_RULES.has(rule.name)) && evaluateSmartConditions(args, rule)
884
1131
  );
885
1132
  if (matchedRule) {
886
1133
  if (matchedRule.verdict === "allow")
@@ -938,41 +1185,8 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
938
1185
  tier: 3
939
1186
  };
940
1187
  }
941
- const pipeAnalysis = analyzePipeChain(shellCommand);
942
- if (pipeAnalysis.isPipeline && (pipeAnalysis.risk === "critical" || pipeAnalysis.risk === "high")) {
943
- const sinks = pipeAnalysis.sinkTargets;
944
- const allTrusted = sinks.length > 0 && sinks.every((host) => isTrustedHost2 ? isTrustedHost2(host) : false);
945
- if (pipeAnalysis.risk === "critical") {
946
- if (allTrusted) {
947
- return {
948
- decision: "review",
949
- blockedByLabel: "Node9: Pipe-Chain to Trusted Host (obfuscated)",
950
- reason: `Obfuscated pipe to trusted host(s): ${sinks.join(", ")} \u2014 requires approval`,
951
- tier: 3
952
- };
953
- }
954
- return {
955
- decision: "block",
956
- blockedByLabel: "Node9: Pipe-Chain Exfiltration (critical)",
957
- reason: `Sensitive file piped through obfuscator to network sink: ${pipeAnalysis.sourceFiles.join(", ")} \u2192 ${sinks.join(", ")}`,
958
- tier: 3
959
- };
960
- }
961
- if (allTrusted) {
962
- return {
963
- decision: "allow",
964
- blockedByLabel: "Node9: Pipe-Chain to Trusted Host",
965
- reason: `Sensitive file piped to trusted host(s): ${sinks.join(", ")}`,
966
- tier: 3
967
- };
968
- }
969
- return {
970
- decision: "review",
971
- blockedByLabel: "Node9: Pipe-Chain Exfiltration (high)",
972
- reason: `Sensitive file piped to network sink: ${pipeAnalysis.sourceFiles.join(", ")} \u2192 ${sinks.join(", ")}`,
973
- tier: 3
974
- };
975
- }
1188
+ const ptVerdict = pipeChainVerdict(shellCommand, isTrustedHost2);
1189
+ if (ptVerdict) return ptVerdict;
976
1190
  const firstToken = analyzed.actions[0] ?? "";
977
1191
  if (["ssh", "scp", "rsync"].includes(firstToken)) {
978
1192
  const rawTokens = shellCommand.trim().split(/\s+/);
@@ -1294,7 +1508,323 @@ function summarizeBlast(result, opts = {}) {
1294
1508
  }))
1295
1509
  };
1296
1510
  }
1297
- var 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;
1511
+ function detectPii(text) {
1512
+ const found = /* @__PURE__ */ new Set();
1513
+ if (/@/.test(text) && PII_EMAIL_RE.test(text)) found.add("Email");
1514
+ if (/-/.test(text) && PII_SSN_RE.test(text)) found.add("SSN");
1515
+ if (PII_PHONE_RE.test(text)) found.add("Phone");
1516
+ if (PII_CC_RE.test(text)) found.add("Credit Card");
1517
+ return [...found];
1518
+ }
1519
+ function extractCanonicalFindings(call, ctx) {
1520
+ const out = [];
1521
+ const ts = call.timestamp;
1522
+ const toolNameLower = call.toolName.toLowerCase();
1523
+ const command = typeof call.args.command === "string" ? call.args.command : null;
1524
+ const isBash = isBashTool(call.toolName) && command !== null;
1525
+ if (call.outputBytes !== void 0 && call.outputBytes > LONG_OUTPUT_THRESHOLD_BYTES) {
1526
+ out.push(
1527
+ makeFinding({
1528
+ type: "long-output-redacted",
1529
+ ruleName: "long-output-redacted",
1530
+ verdict: "review",
1531
+ severity: "medium",
1532
+ reason: `Tool output exceeded ${LONG_OUTPUT_THRESHOLD_BYTES} bytes and was redacted`,
1533
+ toolName: call.toolName,
1534
+ ctx,
1535
+ ts,
1536
+ sourceType: "engine"
1537
+ })
1538
+ );
1539
+ }
1540
+ if (ctx.dlpEnabled) {
1541
+ const dlp = scanArgs(call.args);
1542
+ if (dlp) {
1543
+ out.push(
1544
+ makeFinding({
1545
+ type: "dlp",
1546
+ ruleName: `dlp:${dlp.patternName}`,
1547
+ patternName: dlp.patternName,
1548
+ verdict: dlp.severity === "block" ? "block" : "review",
1549
+ severity: dlp.severity === "block" ? "critical" : "medium",
1550
+ reason: `${dlp.patternName} detected in ${dlp.fieldPath}`,
1551
+ toolName: call.toolName,
1552
+ ctx,
1553
+ ts,
1554
+ sourceType: "engine",
1555
+ input: call.args,
1556
+ redactedSample: dlp.redactedSample
1557
+ })
1558
+ );
1559
+ }
1560
+ }
1561
+ for (const value of stringValues(call.args)) {
1562
+ const piiHits = detectPii(value);
1563
+ for (const pattern of piiHits) {
1564
+ out.push(
1565
+ makeFinding({
1566
+ type: "pii",
1567
+ ruleName: `pii:${pattern.toLowerCase().replace(/\s+/g, "-")}`,
1568
+ patternName: pattern,
1569
+ verdict: "review",
1570
+ severity: "medium",
1571
+ reason: `${pattern} pattern detected in tool input`,
1572
+ toolName: call.toolName,
1573
+ ctx,
1574
+ ts,
1575
+ sourceType: "engine"
1576
+ })
1577
+ );
1578
+ }
1579
+ }
1580
+ if (FILE_TOOLS.has(toolNameLower)) {
1581
+ 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 || "";
1582
+ if (filePath && SENSITIVE_PATH_RE.test(filePath)) {
1583
+ out.push(
1584
+ makeFinding({
1585
+ type: "sensitive-file-read",
1586
+ ruleName: "sensitive-file-read",
1587
+ verdict: "review",
1588
+ severity: "critical",
1589
+ reason: `Sensitive file path read via ${call.toolName}`,
1590
+ toolName: call.toolName,
1591
+ ctx,
1592
+ ts,
1593
+ sourceType: "engine",
1594
+ subjectPath: filePath
1595
+ })
1596
+ );
1597
+ }
1598
+ }
1599
+ if (!isBash || command === null) {
1600
+ return out;
1601
+ }
1602
+ const fsVerdict = analyzeFsOperation(command);
1603
+ if (fsVerdict) {
1604
+ const isShield = fsVerdict.ruleName.startsWith("shield:");
1605
+ out.push(
1606
+ makeFinding({
1607
+ type: "ast-fs-op",
1608
+ ruleName: fsVerdict.ruleName,
1609
+ verdict: fsVerdict.verdict,
1610
+ severity: classifyRuleSeverity(fsVerdict.ruleName, fsVerdict.verdict),
1611
+ reason: fsVerdict.reason,
1612
+ toolName: call.toolName,
1613
+ ctx,
1614
+ ts,
1615
+ sourceType: isShield ? "shield" : "engine",
1616
+ shieldLabel: isShield ? "project-jail (AST)" : "Node9 (AST)",
1617
+ subjectPath: fsVerdict.path,
1618
+ input: call.args
1619
+ })
1620
+ );
1621
+ }
1622
+ for (const source of ctx.rules) {
1623
+ const r = source.rule;
1624
+ if (r.verdict === "allow") continue;
1625
+ if (r.tool && !matchesPattern(toolNameLower, r.tool)) continue;
1626
+ if (r.name && AST_FS_REGEX_RULES.has(r.name)) continue;
1627
+ if (!evaluateSmartConditions(call.args, r)) continue;
1628
+ out.push(
1629
+ makeFinding({
1630
+ type: "smart-rule",
1631
+ ruleName: r.name ?? r.tool,
1632
+ verdict: r.verdict === "block" ? "block" : "review",
1633
+ severity: classifyRuleSeverity(r.name ?? r.tool, r.verdict),
1634
+ reason: r.reason ?? `Smart rule ${r.name ?? r.tool} fired`,
1635
+ toolName: call.toolName,
1636
+ ctx,
1637
+ ts,
1638
+ sourceType: source.sourceType,
1639
+ shieldLabel: source.shieldLabel,
1640
+ input: call.args
1641
+ })
1642
+ );
1643
+ break;
1644
+ }
1645
+ const evalVerdict = detectDangerousShellExec(command);
1646
+ if (evalVerdict) {
1647
+ out.push(
1648
+ makeFinding({
1649
+ type: "eval-of-remote",
1650
+ ruleName: "eval-of-remote",
1651
+ verdict: evalVerdict,
1652
+ severity: classifyRuleSeverity("eval-remote", evalVerdict),
1653
+ reason: evalVerdict === "block" ? "Eval of remote download is a near-certain supply-chain attack" : "Eval of dynamic content (variable / subshell) requires approval",
1654
+ toolName: call.toolName,
1655
+ ctx,
1656
+ ts,
1657
+ sourceType: "engine",
1658
+ input: call.args
1659
+ })
1660
+ );
1661
+ }
1662
+ const pipe = analyzePipeChain(command);
1663
+ if (pipe.isPipeline && pipe.risk === "critical") {
1664
+ out.push(
1665
+ makeFinding({
1666
+ type: "pipe-to-shell",
1667
+ ruleName: "pipe-to-shell",
1668
+ verdict: "block",
1669
+ severity: "critical",
1670
+ reason: `Sensitive file piped through obfuscator to network sink: ${pipe.sourceFiles.join(", ")} \u2192 ${pipe.sinkTargets.join(", ")}`,
1671
+ toolName: call.toolName,
1672
+ ctx,
1673
+ ts,
1674
+ sourceType: "engine",
1675
+ input: call.args
1676
+ })
1677
+ );
1678
+ }
1679
+ if (DESTRUCTIVE_OP_RE.test(command)) {
1680
+ out.push(
1681
+ makeFinding({
1682
+ type: "destructive-op",
1683
+ ruleName: "destructive-op",
1684
+ verdict: "review",
1685
+ severity: "high",
1686
+ reason: "Destructive operation pattern detected",
1687
+ toolName: call.toolName,
1688
+ ctx,
1689
+ ts,
1690
+ sourceType: "engine",
1691
+ input: call.args
1692
+ })
1693
+ );
1694
+ }
1695
+ if (PRIVILEGE_ESCALATION_RE.test(command)) {
1696
+ out.push(
1697
+ makeFinding({
1698
+ type: "privilege-escalation",
1699
+ ruleName: "privilege-escalation",
1700
+ verdict: "review",
1701
+ severity: "high",
1702
+ reason: "Privilege-escalation pattern detected",
1703
+ toolName: call.toolName,
1704
+ ctx,
1705
+ ts,
1706
+ sourceType: "engine",
1707
+ input: call.args
1708
+ })
1709
+ );
1710
+ }
1711
+ return out;
1712
+ }
1713
+ function extractSessionLevelFindings(calls, ctx) {
1714
+ if (!ctx.loopDetection.enabled || calls.length === 0) return [];
1715
+ const out = [];
1716
+ const seenLoopKeys = /* @__PURE__ */ new Set();
1717
+ const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1e3;
1718
+ const windowMs = ctx.loopDetection.windowSeconds <= 0 ? ONE_YEAR_MS : ctx.loopDetection.windowSeconds * 1e3;
1719
+ let records = [];
1720
+ let syntheticTs = 0;
1721
+ for (let i = 0; i < calls.length; i++) {
1722
+ const call = calls[i];
1723
+ const parsed = new Date(call.timestamp).getTime();
1724
+ const now = Number.isFinite(parsed) ? parsed : ++syntheticTs;
1725
+ const verdict = evaluateLoopWindow(
1726
+ records,
1727
+ call.toolName,
1728
+ call.args,
1729
+ ctx.loopDetection.threshold,
1730
+ windowMs,
1731
+ now
1732
+ );
1733
+ records = verdict.nextRecords;
1734
+ if (!verdict.looping) continue;
1735
+ const last = records[records.length - 1];
1736
+ const key = `${last.t}|${last.h}`;
1737
+ if (seenLoopKeys.has(key)) continue;
1738
+ seenLoopKeys.add(key);
1739
+ out.push({
1740
+ type: "loop",
1741
+ ruleName: "loop",
1742
+ verdict: "review",
1743
+ severity: "medium",
1744
+ reason: `Tool called ${verdict.count} times with identical args within window`,
1745
+ toolName: call.toolName,
1746
+ agent: ctx.agent,
1747
+ sessionId: ctx.sessionId,
1748
+ project: ctx.project,
1749
+ lineIndex: call.lineIndex,
1750
+ sourceType: "engine",
1751
+ firstSeenAt: call.timestamp,
1752
+ lastSeenAt: call.timestamp,
1753
+ occurrenceCount: 1,
1754
+ loopCount: verdict.count,
1755
+ loopKind: "loop",
1756
+ commandPreview: previewArgs(call.args, DEDUPE_PREVIEW_LEN),
1757
+ costUsd: verdict.count * COST_PER_LOOP_ITER_USD
1758
+ });
1759
+ }
1760
+ return out;
1761
+ }
1762
+ function toScanFinding(c) {
1763
+ const typeMap = {
1764
+ "smart-rule": null,
1765
+ "ast-fs-op": null,
1766
+ dlp: "dlp",
1767
+ pii: "pii",
1768
+ "sensitive-file-read": "sensitive-file-read",
1769
+ "privilege-escalation": "privilege-escalation",
1770
+ "destructive-op": "destructive-op",
1771
+ "pipe-to-shell": "pipe-to-shell",
1772
+ "eval-of-remote": "eval-of-remote",
1773
+ loop: "loop",
1774
+ "long-output-redacted": "long-output-redacted"
1775
+ };
1776
+ const sfType = typeMap[c.type];
1777
+ if (sfType === null) return null;
1778
+ return {
1779
+ sessionId: c.sessionId,
1780
+ type: sfType,
1781
+ ...c.patternName && { patternName: c.patternName },
1782
+ lineIndex: c.lineIndex
1783
+ };
1784
+ }
1785
+ function previewArgs(input, max) {
1786
+ const cmd = input.command ?? input.query ?? input.file_path ?? JSON.stringify(input);
1787
+ const s = String(cmd).replace(TERMINAL_ESCAPE_RE, "").replace(/\s+/g, " ").trim();
1788
+ return s.length > max ? s.slice(0, max - 1) + "\u2026" : s;
1789
+ }
1790
+ function makeFinding(args) {
1791
+ const f = {
1792
+ type: args.type,
1793
+ ruleName: args.ruleName,
1794
+ verdict: args.verdict,
1795
+ severity: args.severity,
1796
+ reason: args.reason,
1797
+ toolName: args.toolName,
1798
+ agent: args.ctx.agent,
1799
+ sessionId: args.ctx.sessionId,
1800
+ project: args.ctx.project,
1801
+ lineIndex: args.ctx.lineIndex,
1802
+ sourceType: args.sourceType,
1803
+ firstSeenAt: args.ts,
1804
+ lastSeenAt: args.ts,
1805
+ occurrenceCount: 1
1806
+ };
1807
+ if (args.shieldLabel) f.shieldLabel = args.shieldLabel;
1808
+ if (args.subjectPath) f.subjectPath = args.subjectPath;
1809
+ if (args.input) f.input = args.input;
1810
+ if (args.patternName) f.patternName = args.patternName;
1811
+ if (args.redactedSample) f.redactedSample = args.redactedSample;
1812
+ return f;
1813
+ }
1814
+ function* stringValues(obj, depth = 0) {
1815
+ if (depth > 6) return;
1816
+ if (typeof obj === "string") {
1817
+ if (obj.length > 0) yield obj;
1818
+ return;
1819
+ }
1820
+ if (!obj || typeof obj !== "object") return;
1821
+ if (Array.isArray(obj)) {
1822
+ for (const v of obj) yield* stringValues(v, depth + 1);
1823
+ return;
1824
+ }
1825
+ for (const v of Object.values(obj)) yield* stringValues(v, depth + 1);
1826
+ }
1827
+ var 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;
1298
1828
  var init_dist = __esm({
1299
1829
  "packages/policy-engine/dist/index.mjs"() {
1300
1830
  "use strict";
@@ -1762,6 +2292,83 @@ var init_dist = __esm({
1762
2292
  ]);
1763
2293
  SHELL_INTERPRETERS = /* @__PURE__ */ new Set(["bash", "sh", "zsh", "fish", "dash", "ksh"]);
1764
2294
  DOWNLOAD_CMDS = /* @__PURE__ */ new Set(["curl", "wget"]);
2295
+ NORMALIZE_CACHE_MAX = 5e3;
2296
+ normalizeCache = /* @__PURE__ */ new Map();
2297
+ AST_CACHE_MAX = 5e3;
2298
+ astCache = /* @__PURE__ */ new Map();
2299
+ PARSE_FAIL = /* @__PURE__ */ Symbol("parse-fail");
2300
+ FS_READ_TOOLS = /* @__PURE__ */ new Set([
2301
+ "cat",
2302
+ "less",
2303
+ "head",
2304
+ "tail",
2305
+ "bat",
2306
+ "more",
2307
+ "open",
2308
+ "print",
2309
+ "nano",
2310
+ "vim",
2311
+ "vi",
2312
+ "emacs",
2313
+ "code",
2314
+ "type"
2315
+ ]);
2316
+ FS_OP_PRESCREEN_RE = /(?:^|[\s|;&(`\n])(?:rm|cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type)\b/;
2317
+ HOME_CACHE_ALLOWLIST = [
2318
+ ".cache",
2319
+ ".npm/_npx",
2320
+ ".npm/_cacache",
2321
+ ".cargo/registry",
2322
+ ".gradle/caches",
2323
+ ".gradle/.tmp",
2324
+ ".m2/repository",
2325
+ ".pnpm-store",
2326
+ ".yarn/cache",
2327
+ ".yarn/.cache",
2328
+ ".cache/pip",
2329
+ ".local/share/Trash",
2330
+ ".rustup/downloads"
2331
+ ];
2332
+ SENSITIVE_PATH_RULES = [
2333
+ {
2334
+ rule: "shield:project-jail:block-read-ssh",
2335
+ reason: "Reading SSH private keys is blocked by project-jail shield",
2336
+ match: (p) => /(^|[\\/])\.ssh[\\/]/i.test(p)
2337
+ },
2338
+ {
2339
+ rule: "shield:project-jail:block-read-aws",
2340
+ reason: "Reading AWS credentials is blocked by project-jail shield",
2341
+ match: (p) => /(^|[\\/])\.aws[\\/]/i.test(p)
2342
+ },
2343
+ {
2344
+ rule: "shield:project-jail:block-read-env",
2345
+ reason: "Reading .env files is blocked by project-jail shield",
2346
+ match: (p) => /(?:^|[\\/])\.env(?:\.local|\.production|\.staging)?$/i.test(p)
2347
+ },
2348
+ {
2349
+ rule: "shield:project-jail:block-read-credentials",
2350
+ reason: "Reading credential files is blocked by project-jail shield",
2351
+ match: (p) => /(?:credentials\.json|\.netrc|\.npmrc|\.docker[\\/]config\.json|gcloud[\\/]credentials)$/i.test(
2352
+ p
2353
+ )
2354
+ }
2355
+ ];
2356
+ BASH_TOOL_NAMES = /* @__PURE__ */ new Set([
2357
+ "bash",
2358
+ "execute_bash",
2359
+ "run_shell_command",
2360
+ "shell",
2361
+ "exec_command"
2362
+ ]);
2363
+ AST_FS_REGEX_RULES = /* @__PURE__ */ new Set([
2364
+ "block-rm-rf-home",
2365
+ "shield:project-jail:block-read-ssh",
2366
+ "shield:project-jail:block-read-aws",
2367
+ "shield:project-jail:block-read-env",
2368
+ "shield:project-jail:block-read-credentials"
2369
+ ]);
2370
+ FS_OP_CACHE_MAX = 5e3;
2371
+ fsOpCache = /* @__PURE__ */ new Map();
1765
2372
  SOURCE_COMMANDS = /* @__PURE__ */ new Set([
1766
2373
  "cat",
1767
2374
  "head",
@@ -2616,6 +3223,31 @@ var init_dist = __esm({
2616
3223
  };
2617
3224
  LOOP_THRESHOLD_FOR_WASTE = 3;
2618
3225
  COST_PER_LOOP_ITER_USD = 6e-3;
3226
+ 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;
3227
+ PRIVILEGE_ESCALATION_RE = /\b(sudo|su)\b\s+[a-z]|\bchmod\s+(0?777|\+x)\b|\bchown\s+root\b/i;
3228
+ 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;
3229
+ FILE_TOOLS = /* @__PURE__ */ new Set([
3230
+ "read",
3231
+ "read_file",
3232
+ "edit",
3233
+ "edit_file",
3234
+ "write",
3235
+ "write_file",
3236
+ "multiedit",
3237
+ "grep",
3238
+ "grep_search",
3239
+ "glob",
3240
+ "list_files"
3241
+ ]);
3242
+ PII_EMAIL_RE = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/;
3243
+ PII_SSN_RE = /\b\d{3}-\d{2}-\d{4}\b/;
3244
+ PII_PHONE_RE = /\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}\b/;
3245
+ 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/;
3246
+ LONG_OUTPUT_THRESHOLD_BYTES = 100 * 1024;
3247
+ CANONICAL_EXTRACTOR_VERSION = "canonical-v1";
3248
+ DEDUPE_PREVIEW_LEN = 120;
3249
+ TERMINAL_ESCAPE_RE = // eslint-disable-next-line no-control-regex
3250
+ /\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-_]|[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g;
2619
3251
  }
2620
3252
  });
2621
3253
 
@@ -5541,9 +6173,7 @@ function removeNode9McpServer(servers) {
5541
6173
  return true;
5542
6174
  }
5543
6175
  function printDaemonTip() {
5544
- console.log(
5545
- chalk.cyan("\n \u{1F4A1} Node9 will protect you automatically using Native OS popups.") + chalk.white("\n To view your history or manage persistent rules, run:") + chalk.green("\n node9 daemon --openui")
5546
- );
6176
+ console.log(chalk.cyan("\n \u{1F4A1} Node9 will protect you automatically using Native OS popups."));
5547
6177
  }
5548
6178
  function fullPathCommand(subcommand) {
5549
6179
  if (process.env.NODE9_TESTING === "1") return `node9 ${subcommand}`;
@@ -6627,7 +7257,8 @@ function buildScanSummary(agents) {
6627
7257
  timestamp: f.timestamp,
6628
7258
  project: f.project,
6629
7259
  sessionId: f.sessionId,
6630
- agent: f.agent
7260
+ agent: f.agent,
7261
+ kind: f.kind
6631
7262
  }))
6632
7263
  );
6633
7264
  const byVerdict = {
@@ -6645,10 +7276,7 @@ function buildScanSummary(agents) {
6645
7276
  costUSD: a.scan.totalCostUSD
6646
7277
  })).filter((s) => s.sessions > 0 || s.findings > 0);
6647
7278
  const sections = buildSections(allFindings);
6648
- const wastedIters = allLoops.reduce(
6649
- (sum, l) => sum + Math.max(0, l.count - LOOP_THRESHOLD_FOR_WASTE),
6650
- 0
6651
- );
7279
+ const wastedIters = allLoops.filter((l) => l.kind !== "long-iteration").reduce((sum, l) => sum + Math.max(0, l.count - LOOP_THRESHOLD_FOR_WASTE), 0);
6652
7280
  const loopWastedUSD = wastedIters * COST_PER_LOOP_ITER_USD;
6653
7281
  return {
6654
7282
  stats,
@@ -7263,8 +7891,10 @@ var init_costSync = __esm({
7263
7891
  // src/daemon/scan-watermark.ts
7264
7892
  var scan_watermark_exports = {};
7265
7893
  __export(scan_watermark_exports, {
7894
+ WATERMARK_SCHEMA_VERSION: () => WATERMARK_SCHEMA_VERSION,
7266
7895
  extractFindingsFromLine: () => extractFindingsFromLine,
7267
7896
  loadWatermark: () => loadWatermark,
7897
+ markUploadComplete: () => markUploadComplete,
7268
7898
  saveWatermark: () => saveWatermark,
7269
7899
  scanDelta: () => scanDelta,
7270
7900
  tickScanWatcher: () => tickScanWatcher
@@ -7273,18 +7903,68 @@ import fs16 from "fs";
7273
7903
  import os15 from "os";
7274
7904
  import path18 from "path";
7275
7905
  import readline from "readline";
7906
+ function freshWatermark() {
7907
+ return {
7908
+ schemaVersion: WATERMARK_SCHEMA_VERSION,
7909
+ extractorVersion: CANONICAL_EXTRACTOR_VERSION,
7910
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
7911
+ files: {}
7912
+ };
7913
+ }
7276
7914
  function loadWatermark() {
7915
+ let raw;
7277
7916
  try {
7278
- const raw = fs16.readFileSync(WATERMARK_FILE(), "utf-8");
7279
- const parsed = JSON.parse(raw);
7280
- if (typeof parsed.createdAt === "string" && parsed.files && typeof parsed.files === "object") {
7281
- return parsed;
7282
- }
7917
+ raw = fs16.readFileSync(WATERMARK_FILE(), "utf-8");
7283
7918
  } catch {
7919
+ return { status: "fresh", wm: freshWatermark() };
7284
7920
  }
7285
- return { createdAt: (/* @__PURE__ */ new Date()).toISOString(), files: {} };
7921
+ let parsed;
7922
+ try {
7923
+ parsed = JSON.parse(raw);
7924
+ } catch {
7925
+ return { status: "fresh", wm: freshWatermark() };
7926
+ }
7927
+ if (typeof parsed.createdAt !== "string" || !parsed.files || typeof parsed.files !== "object") {
7928
+ return { status: "fresh", wm: freshWatermark() };
7929
+ }
7930
+ const fileSchemaVersion = typeof parsed.schemaVersion === "number" ? parsed.schemaVersion : 1;
7931
+ if (fileSchemaVersion > WATERMARK_SCHEMA_VERSION) {
7932
+ const wm2 = {
7933
+ schemaVersion: fileSchemaVersion,
7934
+ extractorVersion: typeof parsed.extractorVersion === "string" ? parsed.extractorVersion : CANONICAL_EXTRACTOR_VERSION,
7935
+ createdAt: parsed.createdAt,
7936
+ files: parsed.files
7937
+ };
7938
+ return { status: "schema-future", wm: wm2 };
7939
+ }
7940
+ const fileExtractorVersion = typeof parsed.extractorVersion === "string" ? parsed.extractorVersion : "";
7941
+ if (fileExtractorVersion !== CANONICAL_EXTRACTOR_VERSION) {
7942
+ const filesIn = parsed.files;
7943
+ const filesOut = {};
7944
+ for (const [k, v] of Object.entries(filesIn)) {
7945
+ filesOut[k] = { scannedTo: 0 };
7946
+ void v;
7947
+ }
7948
+ const wm2 = {
7949
+ schemaVersion: WATERMARK_SCHEMA_VERSION,
7950
+ extractorVersion: CANONICAL_EXTRACTOR_VERSION,
7951
+ pendingResetUploadAs: "totals",
7952
+ createdAt: parsed.createdAt,
7953
+ files: filesOut
7954
+ };
7955
+ return { status: "extractor-stale", wm: wm2 };
7956
+ }
7957
+ const wm = {
7958
+ schemaVersion: WATERMARK_SCHEMA_VERSION,
7959
+ extractorVersion: CANONICAL_EXTRACTOR_VERSION,
7960
+ ...parsed.pendingResetUploadAs === "totals" && { pendingResetUploadAs: "totals" },
7961
+ createdAt: parsed.createdAt,
7962
+ files: parsed.files
7963
+ };
7964
+ return { status: "current", wm };
7286
7965
  }
7287
7966
  function saveWatermark(wm) {
7967
+ if (wm.schemaVersion > WATERMARK_SCHEMA_VERSION) return;
7288
7968
  const target = WATERMARK_FILE();
7289
7969
  const dir = path18.dirname(target);
7290
7970
  if (!fs16.existsSync(dir)) fs16.mkdirSync(dir, { recursive: true });
@@ -7377,6 +8057,16 @@ function extractFindingsFromLine(line, sessionId, lineIndex) {
7377
8057
  }
7378
8058
  }
7379
8059
  }
8060
+ const ctx = {
8061
+ sessionId,
8062
+ lineIndex,
8063
+ project: "",
8064
+ agent: "claude",
8065
+ rules: [],
8066
+ toolInspection: { bash: "command", execute_bash: "command" },
8067
+ dlpEnabled: false
8068
+ // line-level DLP runs above already
8069
+ };
7380
8070
  const message = line.message;
7381
8071
  if (message && typeof message === "object") {
7382
8072
  const content = message.content;
@@ -7387,73 +8077,105 @@ function extractFindingsFromLine(line, sessionId, lineIndex) {
7387
8077
  if (b.type === "tool_result") {
7388
8078
  const c = b.content;
7389
8079
  const len = typeof c === "string" ? c.length : Array.isArray(c) ? JSON.stringify(c).length : 0;
7390
- if (len > LONG_OUTPUT_THRESHOLD_BYTES) {
8080
+ if (len > LONG_OUTPUT_THRESHOLD_BYTES2) {
7391
8081
  findings.push({
7392
8082
  type: "long-output-redacted",
7393
8083
  sessionId,
7394
8084
  lineIndex
7395
8085
  });
7396
8086
  }
8087
+ continue;
7397
8088
  }
7398
8089
  if (b.type !== "tool_use") continue;
7399
- const toolName = typeof b.name === "string" ? b.name.toLowerCase() : "";
7400
- const input = b.input;
7401
- if (FILE_TOOLS.has(toolName)) {
7402
- const filePath = typeof input?.file_path === "string" && input.file_path || typeof input?.path === "string" && input.path || typeof input?.pattern === "string" && input.pattern || "";
7403
- if (filePath && SENSITIVE_PATH_RE.test(filePath)) {
7404
- findings.push({
7405
- type: "sensitive-file-read",
7406
- sessionId,
7407
- lineIndex
7408
- });
7409
- }
7410
- }
7411
- if (toolName !== "bash" && toolName !== "execute_bash") continue;
7412
- const command = input && typeof input.command === "string" ? input.command : "";
7413
- if (!command) continue;
7414
- const verdict = detectDangerousShellExec(command);
7415
- if (verdict) {
7416
- findings.push({ type: "eval-of-remote", sessionId, lineIndex });
7417
- }
7418
- const pipe = analyzePipeChain(command);
7419
- if (pipe.isPipeline && pipe.risk === "critical") {
7420
- findings.push({ type: "pipe-to-shell", sessionId, lineIndex });
7421
- }
7422
- if (DESTRUCTIVE_OP_RE.test(command)) {
7423
- findings.push({ type: "destructive-op", sessionId, lineIndex });
7424
- }
7425
- if (PRIVILEGE_ESCALATION_RE.test(command)) {
7426
- findings.push({
7427
- type: "privilege-escalation",
7428
- sessionId,
7429
- lineIndex
7430
- });
8090
+ const toolName = typeof b.name === "string" ? b.name : "";
8091
+ const input = b.input ?? {};
8092
+ const call = {
8093
+ toolName,
8094
+ args: input,
8095
+ timestamp: typeof obj.timestamp === "string" ? obj.timestamp : ""
8096
+ };
8097
+ const canonical = extractCanonicalFindings(call, ctx);
8098
+ for (const cf of canonical) {
8099
+ const sf = toScanFinding(cf);
8100
+ if (sf) findings.push(sf);
7431
8101
  }
7432
8102
  }
7433
8103
  }
7434
8104
  }
7435
8105
  return findings;
7436
8106
  }
7437
- function detectPii(text) {
7438
- const found = /* @__PURE__ */ new Set();
7439
- if (/@/.test(text) && PII_EMAIL_RE.test(text)) found.add("Email");
7440
- if (/-/.test(text) && PII_SSN_RE.test(text)) found.add("SSN");
7441
- if (PII_PHONE_RE.test(text)) found.add("Phone");
7442
- if (PII_CC_RE.test(text)) found.add("Credit Card");
7443
- return [...found];
8107
+ function markUploadComplete() {
8108
+ const state = loadWatermark();
8109
+ if (state.status === "schema-future") return;
8110
+ if (state.status === "extractor-stale") return;
8111
+ if (!state.wm.pendingResetUploadAs) return;
8112
+ delete state.wm.pendingResetUploadAs;
8113
+ saveWatermark(state.wm);
7444
8114
  }
7445
8115
  async function tickScanWatcher() {
7446
8116
  if (process.env.NODE9_SCAN_DISABLE === "1") {
7447
- return {
7448
- findings: [],
7449
- totalToolCalls: 0,
7450
- toolCallsBySession: {},
7451
- filesScanned: 0,
7452
- filesNew: 0,
7453
- filesSkipped: 0
7454
- };
8117
+ return emptyTick("deltas");
8118
+ }
8119
+ const state = loadWatermark();
8120
+ if (state.status === "schema-future") {
8121
+ if (process.env.NODE9_DEBUG === "1") {
8122
+ process.stderr.write("[node9] watermark schema is from a newer daemon \u2014 skipping tick.\n");
8123
+ }
8124
+ return { ...emptyTick("deltas"), schemaFuture: true };
8125
+ }
8126
+ if (state.status === "extractor-stale") {
8127
+ if (process.env.NODE9_SKIP_WATERMARK_RESET === "1") {
8128
+ const acknowledged = readRawWatermarkPreservingOffsets();
8129
+ if (acknowledged) {
8130
+ saveWatermark(acknowledged);
8131
+ }
8132
+ process.stderr.write(
8133
+ "[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"
8134
+ );
8135
+ return runActualTick(loadWatermark().wm);
8136
+ }
8137
+ process.stderr.write(
8138
+ "[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"
8139
+ );
8140
+ }
8141
+ return runActualTick(state.wm);
8142
+ }
8143
+ function emptyTick(uploadAs) {
8144
+ return {
8145
+ findings: [],
8146
+ totalToolCalls: 0,
8147
+ toolCallsBySession: {},
8148
+ filesScanned: 0,
8149
+ filesNew: 0,
8150
+ filesSkipped: 0,
8151
+ uploadAs,
8152
+ schemaFuture: false
8153
+ };
8154
+ }
8155
+ function readRawWatermarkPreservingOffsets() {
8156
+ let raw;
8157
+ try {
8158
+ raw = fs16.readFileSync(WATERMARK_FILE(), "utf-8");
8159
+ } catch {
8160
+ return null;
8161
+ }
8162
+ let parsed;
8163
+ try {
8164
+ parsed = JSON.parse(raw);
8165
+ } catch {
8166
+ return null;
7455
8167
  }
7456
- const wm = loadWatermark();
8168
+ if (typeof parsed.createdAt !== "string" || !parsed.files || typeof parsed.files !== "object") {
8169
+ return null;
8170
+ }
8171
+ return {
8172
+ schemaVersion: WATERMARK_SCHEMA_VERSION,
8173
+ extractorVersion: CANONICAL_EXTRACTOR_VERSION,
8174
+ createdAt: parsed.createdAt,
8175
+ files: parsed.files
8176
+ };
8177
+ }
8178
+ async function runActualTick(wm) {
7457
8179
  const watermarkCreatedAt = new Date(wm.createdAt).getTime();
7458
8180
  const findings = [];
7459
8181
  let totalToolCalls = 0;
@@ -7500,10 +8222,20 @@ async function tickScanWatcher() {
7500
8222
  wm.files[filePath] = { scannedTo: newScannedTo };
7501
8223
  filesScanned++;
7502
8224
  }
8225
+ const uploadAs = wm.pendingResetUploadAs === "totals" ? "totals" : "deltas";
7503
8226
  saveWatermark(wm);
7504
- return { findings, totalToolCalls, toolCallsBySession, filesScanned, filesNew, filesSkipped };
8227
+ return {
8228
+ findings,
8229
+ totalToolCalls,
8230
+ toolCallsBySession,
8231
+ filesScanned,
8232
+ filesNew,
8233
+ filesSkipped,
8234
+ uploadAs,
8235
+ schemaFuture: false
8236
+ };
7505
8237
  }
7506
- var 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;
8238
+ var PROJECTS_DIR, WATERMARK_FILE, MAX_LINE_BYTES, WATERMARK_SCHEMA_VERSION, LONG_OUTPUT_THRESHOLD_BYTES2;
7507
8239
  var init_scan_watermark = __esm({
7508
8240
  "src/daemon/scan-watermark.ts"() {
7509
8241
  "use strict";
@@ -7512,27 +8244,8 @@ var init_scan_watermark = __esm({
7512
8244
  PROJECTS_DIR = () => path18.join(os15.homedir(), ".claude", "projects");
7513
8245
  WATERMARK_FILE = () => path18.join(os15.homedir(), ".node9", "scan-watermark.json");
7514
8246
  MAX_LINE_BYTES = 2 * 1024 * 1024;
7515
- 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;
7516
- LONG_OUTPUT_THRESHOLD_BYTES = 100 * 1024;
7517
- FILE_TOOLS = /* @__PURE__ */ new Set([
7518
- "read",
7519
- "read_file",
7520
- "edit",
7521
- "edit_file",
7522
- "write",
7523
- "write_file",
7524
- "multiedit",
7525
- "grep",
7526
- "grep_search",
7527
- "glob",
7528
- "list_files"
7529
- ]);
7530
- 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;
7531
- PII_EMAIL_RE = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/;
7532
- PII_SSN_RE = /\b\d{3}-\d{2}-\d{4}\b/;
7533
- PII_PHONE_RE = /\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}\b/;
7534
- 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/;
7535
- PRIVILEGE_ESCALATION_RE = /\b(sudo|su)\b\s+[a-z]|\bchmod\s+(0?777|\+x)\b|\bchown\s+root\b/i;
8247
+ WATERMARK_SCHEMA_VERSION = 2;
8248
+ LONG_OUTPUT_THRESHOLD_BYTES2 = LONG_OUTPUT_THRESHOLD_BYTES;
7536
8249
  }
7537
8250
  });
7538
8251
 
@@ -7657,6 +8370,13 @@ async function runUploadHistory(opts) {
7657
8370
  let linesParsed = 0;
7658
8371
  let linesSkipped = 0;
7659
8372
  const dailyEntries = [];
8373
+ const liveLoopCfg = getConfig().policy.loopDetection;
8374
+ const loopCfg = {
8375
+ enabled: liveLoopCfg.enabled,
8376
+ threshold: 3,
8377
+ windowSeconds: 0
8378
+ // "no window" — engine treats this as session-wide
8379
+ };
7660
8380
  for (const { filePath, sessionId, projectDir } of iterateJsonlFiles(cutoffMs)) {
7661
8381
  filesScanned++;
7662
8382
  let content;
@@ -7666,6 +8386,7 @@ async function runUploadHistory(opts) {
7666
8386
  continue;
7667
8387
  }
7668
8388
  let lineIndex = 0;
8389
+ const sessionCalls = [];
7669
8390
  for (const line of content.split("\n")) {
7670
8391
  if (!line.trim()) continue;
7671
8392
  let obj;
@@ -7682,10 +8403,38 @@ async function runUploadHistory(opts) {
7682
8403
  if (obj["type"] === "assistant" && Array.isArray(msg?.content) && msg.content.some((b) => b.type === "tool_use")) {
7683
8404
  totalToolCalls++;
7684
8405
  toolCallsBySession[sessionId] = (toolCallsBySession[sessionId] ?? 0) + 1;
8406
+ const ts = typeof obj.timestamp === "string" ? obj.timestamp : "";
8407
+ for (const block of msg.content) {
8408
+ if (!block || typeof block !== "object") continue;
8409
+ const b = block;
8410
+ if (b.type !== "tool_use") continue;
8411
+ sessionCalls.push({
8412
+ toolName: typeof b.name === "string" ? b.name : "",
8413
+ args: b.input ?? {},
8414
+ timestamp: ts,
8415
+ lineIndex
8416
+ });
8417
+ }
7685
8418
  }
7686
8419
  linesParsed++;
7687
8420
  lineIndex++;
7688
8421
  }
8422
+ if (loopCfg.enabled && sessionCalls.length > 0) {
8423
+ const loops = extractSessionLevelFindings(sessionCalls, {
8424
+ sessionId,
8425
+ project: decodeProjectDirName(projectDir),
8426
+ agent: "claude",
8427
+ loopDetection: {
8428
+ enabled: loopCfg.enabled,
8429
+ threshold: loopCfg.threshold,
8430
+ windowSeconds: loopCfg.windowSeconds
8431
+ }
8432
+ });
8433
+ for (const cf of loops) {
8434
+ const sf = toScanFinding(cf);
8435
+ if (sf) findings.push(sf);
8436
+ }
8437
+ }
7689
8438
  const fallbackWorkingDir = decodeProjectDirName(projectDir);
7690
8439
  const dailyMap = parseJSONLFile(filePath, fallbackWorkingDir);
7691
8440
  for (const entry of dailyMap.values()) {
@@ -7710,7 +8459,8 @@ async function runUploadHistory(opts) {
7710
8459
  const scanUrl = creds.apiUrl.endsWith("/policies/sync") ? creds.apiUrl.replace(/\/policies\/sync$/, "/scan/report") : `${creds.apiUrl.replace(/\/$/, "")}/scan/report`;
7711
8460
  await postJson(scanUrl, creds.apiKey, {
7712
8461
  ...summary,
7713
- sessionTotals
8462
+ sessionTotals,
8463
+ extractorVersion: CANONICAL_EXTRACTOR_VERSION
7714
8464
  });
7715
8465
  console.log(chalk3.green(` \u2713 Uploaded scanner findings`));
7716
8466
  if (dailyEntries.length > 0) {
@@ -7812,6 +8562,20 @@ function geminiModelPrice(model) {
7812
8562
  if (base.includes("flash")) return GEMINI_PRICING["gemini-2.0-flash"];
7813
8563
  return null;
7814
8564
  }
8565
+ function isNode9SelfOutput(text) {
8566
+ let hits = 0;
8567
+ for (const re of SELF_OUTPUT_MARKERS) {
8568
+ if (re.test(text)) hits++;
8569
+ if (hits >= 2) return true;
8570
+ }
8571
+ return false;
8572
+ }
8573
+ function looksLikeFixtureToken(sample) {
8574
+ for (const re of FIXTURE_TOKEN_PATTERNS) {
8575
+ if (re.test(sample)) return true;
8576
+ }
8577
+ return false;
8578
+ }
7815
8579
  function num(n) {
7816
8580
  return n.toLocaleString();
7817
8581
  }
@@ -7832,7 +8596,7 @@ function fmtTs(ts) {
7832
8596
  }
7833
8597
  }
7834
8598
  function stripTerminalEscapes(s) {
7835
- return s.replace(TERMINAL_ESCAPE_RE, "");
8599
+ return s.replace(TERMINAL_ESCAPE_RE2, "");
7836
8600
  }
7837
8601
  function preview(input, max) {
7838
8602
  const cmd = input.command ?? input.query ?? input.file_path ?? JSON.stringify(input);
@@ -7875,6 +8639,45 @@ function buildRecurringPatternSet(findings) {
7875
8639
  }
7876
8640
  return recurring;
7877
8641
  }
8642
+ function pushFsOpAstFinding(command, toolName, input, timestamp, projLabel, sessionId, agent, result) {
8643
+ const fsVerdict = analyzeFsOperation(command);
8644
+ if (!fsVerdict) return false;
8645
+ const synthRule = {
8646
+ name: fsVerdict.ruleName,
8647
+ tool: "bash",
8648
+ conditions: [],
8649
+ verdict: fsVerdict.verdict,
8650
+ reason: fsVerdict.reason
8651
+ };
8652
+ const isShieldRule = fsVerdict.ruleName.startsWith("shield:");
8653
+ const synthSource = isShieldRule ? {
8654
+ shieldName: "project-jail",
8655
+ shieldLabel: "project-jail (AST)",
8656
+ sourceType: "shield",
8657
+ rule: synthRule
8658
+ } : {
8659
+ shieldName: "",
8660
+ shieldLabel: "default (AST)",
8661
+ sourceType: "default",
8662
+ rule: synthRule
8663
+ };
8664
+ const inputPreview = preview(input, 120);
8665
+ const isDupe = result.findings.some(
8666
+ (f) => f.source.rule.name === synthRule.name && preview(f.input, 120) === inputPreview && f.project === projLabel
8667
+ );
8668
+ if (!isDupe) {
8669
+ result.findings.push({
8670
+ source: synthSource,
8671
+ toolName,
8672
+ input,
8673
+ timestamp,
8674
+ project: projLabel,
8675
+ sessionId,
8676
+ agent
8677
+ });
8678
+ }
8679
+ return true;
8680
+ }
7878
8681
  function isStaleFinding(timestamp, now = Date.now()) {
7879
8682
  if (!timestamp) return false;
7880
8683
  const t = Date.parse(timestamp);
@@ -7908,15 +8711,24 @@ function detectLoops(calls, project, sessionId, agent) {
7908
8711
  const entry = counts.get(key) ?? {
7909
8712
  count: 0,
7910
8713
  timestamp: call.timestamp,
8714
+ firstTs: null,
8715
+ lastTs: null,
7911
8716
  input: call.input,
7912
8717
  toolName: call.toolName
7913
8718
  };
7914
8719
  entry.count++;
8720
+ const t = call.timestamp ? Date.parse(call.timestamp) : NaN;
8721
+ if (!Number.isNaN(t)) {
8722
+ if (entry.firstTs === null || t < entry.firstTs) entry.firstTs = t;
8723
+ if (entry.lastTs === null || t > entry.lastTs) entry.lastTs = t;
8724
+ }
7915
8725
  counts.set(key, entry);
7916
8726
  }
7917
8727
  const findings = [];
7918
8728
  for (const [, entry] of counts) {
7919
8729
  if (entry.count >= LOOP_THRESHOLD) {
8730
+ const span = entry.firstTs !== null && entry.lastTs !== null ? entry.lastTs - entry.firstTs : 0;
8731
+ const kind = span >= LOOP_TIMESPAN_THRESHOLD_MS ? "long-iteration" : "loop";
7920
8732
  findings.push({
7921
8733
  toolName: entry.toolName,
7922
8734
  commandPreview: preview(entry.input, 80),
@@ -7924,7 +8736,8 @@ function detectLoops(calls, project, sessionId, agent) {
7924
8736
  timestamp: entry.timestamp,
7925
8737
  project,
7926
8738
  sessionId,
7927
- agent
8739
+ agent,
8740
+ kind
7928
8741
  });
7929
8742
  }
7930
8743
  }
@@ -8143,8 +8956,10 @@ function scanClaudeHistory(startDate, onProgress, onLine) {
8143
8956
  }
8144
8957
  const resultText = typeof block.content === "string" ? block.content : Array.isArray(block.content) ? block.content.map((c) => c.text ?? "").join("\n") : null;
8145
8958
  if (!resultText) continue;
8959
+ if (isNode9SelfOutput(resultText)) continue;
8146
8960
  const dlpMatch = scanArgs({ text: resultText });
8147
8961
  if (dlpMatch) {
8962
+ if (looksLikeFixtureToken(dlpMatch.redactedSample)) continue;
8148
8963
  if (firstDlpTs === null) firstDlpTs = entry.timestamp ?? null;
8149
8964
  const isDupe = result.dlpFindings.some(
8150
8965
  (f) => f.patternName === dlpMatch.patternName && f.redactedSample === dlpMatch.redactedSample && f.project === projLabel
@@ -8215,11 +9030,26 @@ function scanClaudeHistory(startDate, onProgress, onLine) {
8215
9030
  });
8216
9031
  }
8217
9032
  }
8218
- let ruleMatched = false;
9033
+ let astFsMatched = false;
9034
+ const astRanForBash = toolNameLower === "bash" || toolNameLower === "execute_bash";
9035
+ if (astRanForBash) {
9036
+ astFsMatched = pushFsOpAstFinding(
9037
+ String(input.command ?? ""),
9038
+ toolName,
9039
+ input,
9040
+ entry.timestamp ?? "",
9041
+ projLabel,
9042
+ sessionId,
9043
+ "claude",
9044
+ result
9045
+ );
9046
+ }
9047
+ let ruleMatched = astFsMatched;
8219
9048
  for (const source of ruleSources) {
8220
9049
  const { rule } = source;
8221
9050
  if (rule.verdict === "allow") continue;
8222
9051
  if (rule.tool && !matchesPattern(toolNameLower, rule.tool)) continue;
9052
+ if (astRanForBash && rule.name && AST_FS_REGEX_RULES.has(rule.name)) continue;
8223
9053
  if (!evaluateSmartConditions(input, rule)) continue;
8224
9054
  const inputPreview = preview(input, 120);
8225
9055
  const isDupe = result.findings.some(
@@ -8415,11 +9245,26 @@ function scanGeminiHistory(startDate, onProgress, onLine) {
8415
9245
  });
8416
9246
  }
8417
9247
  }
8418
- let ruleMatched = false;
9248
+ let astFsMatched = false;
9249
+ const astRanForBash = toolNameLower === "run_shell_command" || toolNameLower === "shell";
9250
+ if (astRanForBash) {
9251
+ astFsMatched = pushFsOpAstFinding(
9252
+ String(input.command ?? ""),
9253
+ toolName,
9254
+ input,
9255
+ msg.timestamp ?? "",
9256
+ projLabel,
9257
+ sessionId,
9258
+ "gemini",
9259
+ result
9260
+ );
9261
+ }
9262
+ let ruleMatched = astFsMatched;
8419
9263
  for (const source of ruleSources) {
8420
9264
  const { rule } = source;
8421
9265
  if (rule.verdict === "allow") continue;
8422
9266
  if (rule.tool && !matchesPattern(toolNameLower, rule.tool)) continue;
9267
+ if (astRanForBash && rule.name && AST_FS_REGEX_RULES.has(rule.name)) continue;
8423
9268
  if (!evaluateSmartConditions(input, rule)) continue;
8424
9269
  const inputPreview = preview(input, 120);
8425
9270
  const isDupe = result.findings.some(
@@ -8637,12 +9482,27 @@ function scanCodexHistory(startDate, onProgress, onLine) {
8637
9482
  });
8638
9483
  }
8639
9484
  }
8640
- let ruleMatched = false;
9485
+ let astFsMatched = false;
9486
+ const astRanForBash = toolNameLower === "exec_command" || toolNameLower === "bash";
9487
+ if (astRanForBash) {
9488
+ astFsMatched = pushFsOpAstFinding(
9489
+ String(input["command"] ?? ""),
9490
+ toolName,
9491
+ input,
9492
+ ts,
9493
+ projLabel,
9494
+ sessionId,
9495
+ "codex",
9496
+ result
9497
+ );
9498
+ }
9499
+ let ruleMatched = astFsMatched;
8641
9500
  for (const source of ruleSources) {
8642
9501
  const { rule } = source;
8643
9502
  if (rule.verdict === "allow") continue;
8644
9503
  if (rule.tool && !matchesPattern(toolNameLower === "exec_command" ? "bash" : toolNameLower, rule.tool))
8645
9504
  continue;
9505
+ if (astRanForBash && rule.name && AST_FS_REGEX_RULES.has(rule.name)) continue;
8646
9506
  if (!evaluateSmartConditions(input, rule)) continue;
8647
9507
  const inputPreview = preview(input, 120);
8648
9508
  const isDupe = result.findings.some(
@@ -8842,15 +9702,22 @@ function renderCompactScorecard(input) {
8842
9702
  chalk4.red("\u{1F6D1} ") + chalk4.red.bold(String(blockedCount).padEnd(4)) + chalk4.dim("would have blocked".padEnd(20)) + chalk4.dim(`(${topBlocked})`)
8843
9703
  );
8844
9704
  }
8845
- if (scan.loopFindings.length > 0) {
8846
- const wastedCalls = scan.loopFindings.reduce((s, l) => s + Math.max(0, l.count - 1), 0);
9705
+ const realLoops = scan.loopFindings.filter((l) => l.kind !== "long-iteration");
9706
+ const longIterations = scan.loopFindings.filter((l) => l.kind === "long-iteration");
9707
+ if (realLoops.length > 0) {
9708
+ const wastedCalls = realLoops.reduce((s, l) => s + Math.max(0, l.count - 1), 0);
8847
9709
  const wastePct = scan.totalToolCalls > 0 ? Math.round(wastedCalls / scan.totalToolCalls * 100) : 0;
8848
9710
  const wasteParts = [];
8849
9711
  if (wastePct > 0) wasteParts.push(`${wastePct}% wasted`);
8850
9712
  if (summary.loopWastedUSD > 0) wasteParts.push("~" + fmtCost(summary.loopWastedUSD));
8851
9713
  const wasteSummary = wasteParts.length ? `(${wasteParts.join(" \xB7 ")})` : "";
8852
9714
  console.log(
8853
- chalk4.yellow("\u{1F501} ") + chalk4.yellow.bold(String(scan.loopFindings.length).padEnd(4)) + chalk4.dim("agent loops".padEnd(20)) + chalk4.dim(wasteSummary)
9715
+ chalk4.yellow("\u{1F501} ") + chalk4.yellow.bold(String(realLoops.length).padEnd(4)) + chalk4.dim("agent loops".padEnd(20)) + chalk4.dim(wasteSummary)
9716
+ );
9717
+ }
9718
+ if (longIterations.length > 0) {
9719
+ console.log(
9720
+ chalk4.dim("\u{1F4C2} ") + chalk4.dim.bold(String(longIterations.length).padEnd(4)) + chalk4.dim("long iterations".padEnd(20)) + chalk4.dim("(deep work \u2014 not waste)")
8854
9721
  );
8855
9722
  }
8856
9723
  if (reviewCount > 0) {
@@ -9003,7 +9870,7 @@ function renderNarrativeScorecard(input) {
9003
9870
  console.log("");
9004
9871
  }
9005
9872
  function registerScanCommand(program2) {
9006
- 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(
9873
+ 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(
9007
9874
  "--upload-history",
9008
9875
  "Upload aggregate counts from existing JSONL sessions to the SaaS dashboard. Defaults to last 3 months; override with --since. Idempotent (safe to re-run)."
9009
9876
  ).option(
@@ -9022,7 +9889,7 @@ function registerScanCommand(program2) {
9022
9889
  const previewWidth = 70;
9023
9890
  const startDate = options.all ? null : (() => {
9024
9891
  const d = /* @__PURE__ */ new Date();
9025
- d.setDate(d.getDate() - (parseInt(options.days, 10) || 30));
9892
+ d.setDate(d.getDate() - (parseInt(options.days, 10) || 90));
9026
9893
  d.setHours(0, 0, 0, 0);
9027
9894
  return d;
9028
9895
  })();
@@ -9096,7 +9963,7 @@ function registerScanCommand(program2) {
9096
9963
  );
9097
9964
  return;
9098
9965
  }
9099
- const rangeLabel = options.all ? chalk4.dim("all time") : chalk4.dim(`last ${options.days ?? 30} days`);
9966
+ const rangeLabel = options.all ? chalk4.dim("all time") : chalk4.dim(`last ${options.days ?? 90} days`);
9100
9967
  const dateRange = scan.firstDate && scan.lastDate ? chalk4.dim(` ${fmtTs(scan.firstDate)} \u2013 ${fmtTs(scan.lastDate)}`) : "";
9101
9968
  const breakdownParts = [];
9102
9969
  if (claudeScan.sessions > 0)
@@ -9382,13 +10249,14 @@ function registerScanCommand(program2) {
9382
10249
  }
9383
10250
  );
9384
10251
  }
9385
- var 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;
10252
+ var 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;
9386
10253
  var init_scan = __esm({
9387
10254
  "src/cli/commands/scan.ts"() {
9388
10255
  "use strict";
9389
10256
  init_shields();
9390
10257
  init_config();
9391
10258
  init_policy();
10259
+ init_dist();
9392
10260
  init_dlp();
9393
10261
  init_dist();
9394
10262
  init_scan_summary();
@@ -9441,7 +10309,23 @@ var init_scan = __esm({
9441
10309
  ".vue",
9442
10310
  ".svelte"
9443
10311
  ]);
9444
- TERMINAL_ESCAPE_RE = /\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-_]|[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g;
10312
+ SELF_OUTPUT_MARKERS = [
10313
+ /redactedSample:\s*['"]/,
10314
+ /patternName:\s*['"]/,
10315
+ /\bseverity:\s*['"](?:block|review|allow)['"]/,
10316
+ /NODE9 SECURITY ALERT/
10317
+ ];
10318
+ FIXTURE_TOKEN_PATTERNS = [
10319
+ /(.)\1{5,}/,
10320
+ // 6+ repeated characters (aaaaaa, 000000)
10321
+ /(?:EXAMPLE|FAKE|DUMMY|PLACEHOLDER|XXXXX)/i,
10322
+ /abcdefghijklmn/i,
10323
+ // long alpha sequence — fixture, not entropy
10324
+ /1234567890/,
10325
+ // long digit sequence — fixture, not entropy
10326
+ /qwerty/i
10327
+ ];
10328
+ TERMINAL_ESCAPE_RE2 = /\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-_]|[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g;
9445
10329
  LOOP_TOOLS = /* @__PURE__ */ new Set([
9446
10330
  "bash",
9447
10331
  "execute_bash",
@@ -9453,6 +10337,7 @@ var init_scan = __esm({
9453
10337
  "multiedit"
9454
10338
  ]);
9455
10339
  LOOP_THRESHOLD = 3;
10340
+ LOOP_TIMESPAN_THRESHOLD_MS = 10 * 60 * 1e3;
9456
10341
  STUCK_TOOLS_MIN_WASTE = 5;
9457
10342
  STUCK_TOOLS_LIMIT = 3;
9458
10343
  RECURRING_SESSION_THRESHOLD = 3;
@@ -9984,7 +10869,86 @@ function abandonPending() {
9984
10869
  }, 200);
9985
10870
  }
9986
10871
  }
10872
+ function logActivitySocket(msg) {
10873
+ try {
10874
+ fs20.appendFileSync(
10875
+ path22.join(homeDir, ".node9", "hook-debug.log"),
10876
+ `[${(/* @__PURE__ */ new Date()).toISOString()}] [activity-socket] ${msg}
10877
+ `
10878
+ );
10879
+ } catch {
10880
+ }
10881
+ }
10882
+ function shouldRebind(now = Date.now()) {
10883
+ if (activityCircuitTripped) return false;
10884
+ activityRebindAttempts = activityRebindAttempts.filter(
10885
+ (t) => now - t < ACTIVITY_REBIND_WINDOW_MS
10886
+ );
10887
+ activityRebindAttempts.push(now);
10888
+ if (activityRebindAttempts.length > ACTIVITY_REBIND_MAX_ATTEMPTS) {
10889
+ activityCircuitTripped = true;
10890
+ return false;
10891
+ }
10892
+ return true;
10893
+ }
9987
10894
  function startActivitySocket() {
10895
+ bindActivitySocket();
10896
+ if (process.platform !== "win32") {
10897
+ try {
10898
+ activitySocketWatcher = fs20.watch(os18.tmpdir(), (eventType, filename) => {
10899
+ if (filename !== path22.basename(ACTIVITY_SOCKET_PATH2)) return;
10900
+ if (eventType !== "rename") return;
10901
+ if (fs20.existsSync(ACTIVITY_SOCKET_PATH2)) return;
10902
+ attemptRebind("watch-unlink");
10903
+ });
10904
+ activitySocketWatcher.on("error", (err2) => {
10905
+ logActivitySocket(`watcher error: ${err2.message}`);
10906
+ });
10907
+ activitySocketWatcher.unref();
10908
+ } catch (err2) {
10909
+ logActivitySocket(`failed to start watcher: ${err2.message}`);
10910
+ }
10911
+ }
10912
+ activityHealthInterval = setInterval(() => {
10913
+ if (!fs20.existsSync(ACTIVITY_SOCKET_PATH2)) attemptRebind("health-probe");
10914
+ }, ACTIVITY_HEALTH_PROBE_MS);
10915
+ activityHealthInterval.unref();
10916
+ process.on("exit", () => {
10917
+ if (activitySocketWatcher) {
10918
+ try {
10919
+ activitySocketWatcher.close();
10920
+ } catch {
10921
+ }
10922
+ }
10923
+ if (activityHealthInterval) clearInterval(activityHealthInterval);
10924
+ try {
10925
+ fs20.unlinkSync(ACTIVITY_SOCKET_PATH2);
10926
+ } catch {
10927
+ }
10928
+ });
10929
+ }
10930
+ function attemptRebind(reason) {
10931
+ if (!shouldRebind()) {
10932
+ logActivitySocket(
10933
+ `circuit breaker tripped after ${ACTIVITY_REBIND_MAX_ATTEMPTS} attempts in ${ACTIVITY_REBIND_WINDOW_MS}ms \u2014 flight recorder down`
10934
+ );
10935
+ broadcast("flight-recorder-down", {
10936
+ reason: "rebind-loop",
10937
+ message: "Activity socket repeatedly disappearing \u2014 run: node9 daemon restart"
10938
+ });
10939
+ return;
10940
+ }
10941
+ logActivitySocket(`rebinding (reason: ${reason}, attempt ${activityRebindAttempts.length})`);
10942
+ if (activitySocketServer) {
10943
+ try {
10944
+ activitySocketServer.close();
10945
+ } catch {
10946
+ }
10947
+ activitySocketServer = null;
10948
+ }
10949
+ bindActivitySocket();
10950
+ }
10951
+ function bindActivitySocket() {
9988
10952
  try {
9989
10953
  fs20.unlinkSync(ACTIVITY_SOCKET_PATH2);
9990
10954
  } catch {
@@ -10082,15 +11046,15 @@ function startActivitySocket() {
10082
11046
  socket.on("error", () => {
10083
11047
  });
10084
11048
  });
10085
- unixServer.listen(ACTIVITY_SOCKET_PATH2);
10086
- process.on("exit", () => {
10087
- try {
10088
- fs20.unlinkSync(ACTIVITY_SOCKET_PATH2);
10089
- } catch {
10090
- }
11049
+ unixServer.on("error", (err2) => {
11050
+ logActivitySocket(`server error: ${err2.message}`);
11051
+ });
11052
+ unixServer.listen(ACTIVITY_SOCKET_PATH2, () => {
11053
+ logActivitySocket(`bound to ${ACTIVITY_SOCKET_PATH2}`);
10091
11054
  });
11055
+ activitySocketServer = unixServer;
10092
11056
  }
10093
- var 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;
11057
+ var 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;
10094
11058
  var init_state2 = __esm({
10095
11059
  "src/daemon/state.ts"() {
10096
11060
  "use strict";
@@ -10146,6 +11110,14 @@ var init_state2 = __esm({
10146
11110
  "notebook_edit",
10147
11111
  "notebookedit"
10148
11112
  ]);
11113
+ ACTIVITY_REBIND_MAX_ATTEMPTS = 5;
11114
+ ACTIVITY_REBIND_WINDOW_MS = 6e4;
11115
+ ACTIVITY_HEALTH_PROBE_MS = 3e4;
11116
+ activitySocketServer = null;
11117
+ activitySocketWatcher = null;
11118
+ activityHealthInterval = null;
11119
+ activityRebindAttempts = [];
11120
+ activityCircuitTripped = false;
10149
11121
  }
10150
11122
  });
10151
11123
 
@@ -10352,16 +11324,19 @@ async function pushBlastSnapshot(creds) {
10352
11324
  async function pushScanSnapshot(creds) {
10353
11325
  try {
10354
11326
  const tick = await tickScanWatcher();
11327
+ if (tick.schemaFuture) return;
10355
11328
  if (tick.findings.length === 0 && tick.totalToolCalls === 0) {
10356
11329
  return;
10357
11330
  }
10358
11331
  const summary = summarizeScan(tick.findings, {
10359
11332
  totalToolCalls: tick.totalToolCalls
10360
11333
  });
10361
- const sessionDeltas = buildSessionDeltas(tick.findings, tick.toolCallsBySession);
11334
+ const perSession = buildSessionDeltas(tick.findings, tick.toolCallsBySession);
10362
11335
  const scanUrl = creds.apiUrl.endsWith("/policies/sync") ? creds.apiUrl.replace(/\/policies\/sync$/, "/scan/report") : null;
10363
11336
  if (!scanUrl) return;
11337
+ const body = tick.uploadAs === "totals" ? { ...summary, sessionTotals: perSession, extractorVersion: CANONICAL_EXTRACTOR_VERSION } : { ...summary, sessionDeltas: perSession, extractorVersion: CANONICAL_EXTRACTOR_VERSION };
10364
11338
  const parsed = new URL(scanUrl);
11339
+ let posted = false;
10365
11340
  await new Promise((resolve) => {
10366
11341
  const req = https2.request(
10367
11342
  {
@@ -10376,6 +11351,9 @@ async function pushScanSnapshot(creds) {
10376
11351
  timeout: 1e4
10377
11352
  },
10378
11353
  (res) => {
11354
+ if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
11355
+ posted = true;
11356
+ }
10379
11357
  res.resume();
10380
11358
  res.on("end", resolve);
10381
11359
  res.on("error", () => resolve());
@@ -10386,9 +11364,12 @@ async function pushScanSnapshot(creds) {
10386
11364
  req.destroy();
10387
11365
  resolve();
10388
11366
  });
10389
- req.write(JSON.stringify({ ...summary, sessionDeltas }));
11367
+ req.write(JSON.stringify(body));
10390
11368
  req.end();
10391
11369
  });
11370
+ if (posted && tick.uploadAs === "totals") {
11371
+ markUploadComplete();
11372
+ }
10392
11373
  } catch {
10393
11374
  }
10394
11375
  }
@@ -12292,6 +13273,8 @@ async function startTail(options = {}) {
12292
13273
  let initialReplayDone = false;
12293
13274
  const activityPending = /* @__PURE__ */ new Map();
12294
13275
  const orphanedResults = /* @__PURE__ */ new Map();
13276
+ let lastActivityFromDaemon = Date.now();
13277
+ let stallWarned = false;
12295
13278
  const authToken = getInternalToken() ?? "";
12296
13279
  const approvalQueue = [];
12297
13280
  let cardActive = false;
@@ -12518,6 +13501,24 @@ async function startTail(options = {}) {
12518
13501
  console.log(chalk29.dim("\n\u{1F6F0}\uFE0F Disconnected."));
12519
13502
  process.exit(0);
12520
13503
  });
13504
+ const STALL_THRESHOLD_MS = 6e4;
13505
+ const stallWatchdog = setInterval(() => {
13506
+ if (stallWarned) return;
13507
+ if (Date.now() - lastActivityFromDaemon < STALL_THRESHOLD_MS) return;
13508
+ try {
13509
+ const auditMtime = fs44.statSync(auditLog).mtimeMs;
13510
+ if (Date.now() - auditMtime >= STALL_THRESHOLD_MS) return;
13511
+ console.log("");
13512
+ console.log(
13513
+ chalk29.yellow(
13514
+ "\u26A0\uFE0F Tail appears stalled \u2014 hooks are firing but no events are arriving. Try: node9 daemon restart"
13515
+ )
13516
+ );
13517
+ stallWarned = true;
13518
+ } catch {
13519
+ }
13520
+ }, STALL_THRESHOLD_MS / 2);
13521
+ stallWatchdog.unref();
12521
13522
  const sseUrl = `http://127.0.0.1:${port}/events?capabilities=input`;
12522
13523
  const req = http2.get(
12523
13524
  sseUrl,
@@ -12563,7 +13564,18 @@ async function startTail(options = {}) {
12563
13564
  }
12564
13565
  );
12565
13566
  function handleMessage(event, rawData) {
13567
+ lastActivityFromDaemon = Date.now();
12566
13568
  if (event === "csrf") return;
13569
+ if (event === "flight-recorder-down") {
13570
+ try {
13571
+ const parsed = JSON.parse(rawData);
13572
+ const msg = parsed.message ?? "Flight recorder is down \u2014 run: node9 daemon restart";
13573
+ console.log("");
13574
+ console.log(chalk29.bgRed.white.bold(` \u26A0\uFE0F ${msg} `));
13575
+ } catch {
13576
+ }
13577
+ return;
13578
+ }
12567
13579
  if (event === "init") {
12568
13580
  try {
12569
13581
  const parsed = JSON.parse(rawData);
@@ -16139,7 +17151,6 @@ function registerInitCommand(program2) {
16139
17151
  console.log(chalk15.green.bold(`\u{1F6E1}\uFE0F Node9 is protecting ${agentList}!`));
16140
17152
  console.log("");
16141
17153
  console.log(chalk15.white(" Watch live: ") + chalk15.cyan("node9 tail"));
16142
- console.log(chalk15.white(" Local UI: ") + chalk15.cyan("node9 daemon --openui"));
16143
17154
  console.log("");
16144
17155
  console.log(chalk15.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"));
16145
17156
  console.log(