@node9/proxy 1.18.3 → 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
@@ -580,6 +580,9 @@ function detectDangerousShellExec(command) {
580
580
  return null;
581
581
  }
582
582
  }
583
+ function isBashTool(toolName) {
584
+ return BASH_TOOL_NAMES.has(toolName.toLowerCase());
585
+ }
583
586
  function isProtectedHomePath(rawPath) {
584
587
  let p = rawPath.replace(/^\$HOME[\\/]?|^\$\{HOME\}[\\/]?/, "~/");
585
588
  let underHome = false;
@@ -1052,6 +1055,43 @@ function isSqlTool(toolName, toolInspection) {
1052
1055
  const fieldName = toolInspection[matchingPattern];
1053
1056
  return fieldName === "sql" || fieldName === "query";
1054
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
+ }
1055
1095
  async function evaluatePolicy(config, toolName, args, context = {}, hooks = {}) {
1056
1096
  const { agent, cwd, activeEnvironment } = context;
1057
1097
  const { checkProvenance: checkProvenance2, isTrustedHost: isTrustedHost2 } = hooks;
@@ -1067,9 +1107,27 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
1067
1107
  }
1068
1108
  }
1069
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
+ }
1070
1128
  if (config.policy.smartRules.length > 0) {
1071
1129
  const matchedRule = config.policy.smartRules.find(
1072
- (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)
1073
1131
  );
1074
1132
  if (matchedRule) {
1075
1133
  if (matchedRule.verdict === "allow")
@@ -1127,41 +1185,8 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
1127
1185
  tier: 3
1128
1186
  };
1129
1187
  }
1130
- const pipeAnalysis = analyzePipeChain(shellCommand);
1131
- if (pipeAnalysis.isPipeline && (pipeAnalysis.risk === "critical" || pipeAnalysis.risk === "high")) {
1132
- const sinks = pipeAnalysis.sinkTargets;
1133
- const allTrusted = sinks.length > 0 && sinks.every((host) => isTrustedHost2 ? isTrustedHost2(host) : false);
1134
- if (pipeAnalysis.risk === "critical") {
1135
- if (allTrusted) {
1136
- return {
1137
- decision: "review",
1138
- blockedByLabel: "Node9: Pipe-Chain to Trusted Host (obfuscated)",
1139
- reason: `Obfuscated pipe to trusted host(s): ${sinks.join(", ")} \u2014 requires approval`,
1140
- tier: 3
1141
- };
1142
- }
1143
- return {
1144
- decision: "block",
1145
- blockedByLabel: "Node9: Pipe-Chain Exfiltration (critical)",
1146
- reason: `Sensitive file piped through obfuscator to network sink: ${pipeAnalysis.sourceFiles.join(", ")} \u2192 ${sinks.join(", ")}`,
1147
- tier: 3
1148
- };
1149
- }
1150
- if (allTrusted) {
1151
- return {
1152
- decision: "allow",
1153
- blockedByLabel: "Node9: Pipe-Chain to Trusted Host",
1154
- reason: `Sensitive file piped to trusted host(s): ${sinks.join(", ")}`,
1155
- tier: 3
1156
- };
1157
- }
1158
- return {
1159
- decision: "review",
1160
- blockedByLabel: "Node9: Pipe-Chain Exfiltration (high)",
1161
- reason: `Sensitive file piped to network sink: ${pipeAnalysis.sourceFiles.join(", ")} \u2192 ${sinks.join(", ")}`,
1162
- tier: 3
1163
- };
1164
- }
1188
+ const ptVerdict = pipeChainVerdict(shellCommand, isTrustedHost2);
1189
+ if (ptVerdict) return ptVerdict;
1165
1190
  const firstToken = analyzed.actions[0] ?? "";
1166
1191
  if (["ssh", "scp", "rsync"].includes(firstToken)) {
1167
1192
  const rawTokens = shellCommand.trim().split(/\s+/);
@@ -1483,7 +1508,323 @@ function summarizeBlast(result, opts = {}) {
1483
1508
  }))
1484
1509
  };
1485
1510
  }
1486
- 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, 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;
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;
1487
1828
  var init_dist = __esm({
1488
1829
  "packages/policy-engine/dist/index.mjs"() {
1489
1830
  "use strict";
@@ -2012,6 +2353,20 @@ var init_dist = __esm({
2012
2353
  )
2013
2354
  }
2014
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
+ ]);
2015
2370
  FS_OP_CACHE_MAX = 5e3;
2016
2371
  fsOpCache = /* @__PURE__ */ new Map();
2017
2372
  SOURCE_COMMANDS = /* @__PURE__ */ new Set([
@@ -2868,6 +3223,31 @@ var init_dist = __esm({
2868
3223
  };
2869
3224
  LOOP_THRESHOLD_FOR_WASTE = 3;
2870
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;
2871
3251
  }
2872
3252
  });
2873
3253
 
@@ -7511,8 +7891,10 @@ var init_costSync = __esm({
7511
7891
  // src/daemon/scan-watermark.ts
7512
7892
  var scan_watermark_exports = {};
7513
7893
  __export(scan_watermark_exports, {
7894
+ WATERMARK_SCHEMA_VERSION: () => WATERMARK_SCHEMA_VERSION,
7514
7895
  extractFindingsFromLine: () => extractFindingsFromLine,
7515
7896
  loadWatermark: () => loadWatermark,
7897
+ markUploadComplete: () => markUploadComplete,
7516
7898
  saveWatermark: () => saveWatermark,
7517
7899
  scanDelta: () => scanDelta,
7518
7900
  tickScanWatcher: () => tickScanWatcher
@@ -7521,18 +7903,68 @@ import fs16 from "fs";
7521
7903
  import os15 from "os";
7522
7904
  import path18 from "path";
7523
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
+ }
7524
7914
  function loadWatermark() {
7915
+ let raw;
7525
7916
  try {
7526
- const raw = fs16.readFileSync(WATERMARK_FILE(), "utf-8");
7527
- const parsed = JSON.parse(raw);
7528
- if (typeof parsed.createdAt === "string" && parsed.files && typeof parsed.files === "object") {
7529
- return parsed;
7530
- }
7917
+ raw = fs16.readFileSync(WATERMARK_FILE(), "utf-8");
7531
7918
  } catch {
7919
+ return { status: "fresh", wm: freshWatermark() };
7532
7920
  }
7533
- 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 };
7534
7965
  }
7535
7966
  function saveWatermark(wm) {
7967
+ if (wm.schemaVersion > WATERMARK_SCHEMA_VERSION) return;
7536
7968
  const target = WATERMARK_FILE();
7537
7969
  const dir = path18.dirname(target);
7538
7970
  if (!fs16.existsSync(dir)) fs16.mkdirSync(dir, { recursive: true });
@@ -7625,6 +8057,16 @@ function extractFindingsFromLine(line, sessionId, lineIndex) {
7625
8057
  }
7626
8058
  }
7627
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
+ };
7628
8070
  const message = line.message;
7629
8071
  if (message && typeof message === "object") {
7630
8072
  const content = message.content;
@@ -7635,73 +8077,105 @@ function extractFindingsFromLine(line, sessionId, lineIndex) {
7635
8077
  if (b.type === "tool_result") {
7636
8078
  const c = b.content;
7637
8079
  const len = typeof c === "string" ? c.length : Array.isArray(c) ? JSON.stringify(c).length : 0;
7638
- if (len > LONG_OUTPUT_THRESHOLD_BYTES) {
8080
+ if (len > LONG_OUTPUT_THRESHOLD_BYTES2) {
7639
8081
  findings.push({
7640
8082
  type: "long-output-redacted",
7641
8083
  sessionId,
7642
8084
  lineIndex
7643
8085
  });
7644
8086
  }
8087
+ continue;
7645
8088
  }
7646
8089
  if (b.type !== "tool_use") continue;
7647
- const toolName = typeof b.name === "string" ? b.name.toLowerCase() : "";
7648
- const input = b.input;
7649
- if (FILE_TOOLS.has(toolName)) {
7650
- const filePath = typeof input?.file_path === "string" && input.file_path || typeof input?.path === "string" && input.path || typeof input?.pattern === "string" && input.pattern || "";
7651
- if (filePath && SENSITIVE_PATH_RE.test(filePath)) {
7652
- findings.push({
7653
- type: "sensitive-file-read",
7654
- sessionId,
7655
- lineIndex
7656
- });
7657
- }
7658
- }
7659
- if (toolName !== "bash" && toolName !== "execute_bash") continue;
7660
- const command = input && typeof input.command === "string" ? input.command : "";
7661
- if (!command) continue;
7662
- const verdict = detectDangerousShellExec(command);
7663
- if (verdict) {
7664
- findings.push({ type: "eval-of-remote", sessionId, lineIndex });
7665
- }
7666
- const pipe = analyzePipeChain(command);
7667
- if (pipe.isPipeline && pipe.risk === "critical") {
7668
- findings.push({ type: "pipe-to-shell", sessionId, lineIndex });
7669
- }
7670
- if (DESTRUCTIVE_OP_RE.test(command)) {
7671
- findings.push({ type: "destructive-op", sessionId, lineIndex });
7672
- }
7673
- if (PRIVILEGE_ESCALATION_RE.test(command)) {
7674
- findings.push({
7675
- type: "privilege-escalation",
7676
- sessionId,
7677
- lineIndex
7678
- });
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);
7679
8101
  }
7680
8102
  }
7681
8103
  }
7682
8104
  }
7683
8105
  return findings;
7684
8106
  }
7685
- function detectPii(text) {
7686
- const found = /* @__PURE__ */ new Set();
7687
- if (/@/.test(text) && PII_EMAIL_RE.test(text)) found.add("Email");
7688
- if (/-/.test(text) && PII_SSN_RE.test(text)) found.add("SSN");
7689
- if (PII_PHONE_RE.test(text)) found.add("Phone");
7690
- if (PII_CC_RE.test(text)) found.add("Credit Card");
7691
- 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);
7692
8114
  }
7693
8115
  async function tickScanWatcher() {
7694
8116
  if (process.env.NODE9_SCAN_DISABLE === "1") {
7695
- return {
7696
- findings: [],
7697
- totalToolCalls: 0,
7698
- toolCallsBySession: {},
7699
- filesScanned: 0,
7700
- filesNew: 0,
7701
- filesSkipped: 0
7702
- };
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
+ );
7703
8140
  }
7704
- const wm = loadWatermark();
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;
8167
+ }
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) {
7705
8179
  const watermarkCreatedAt = new Date(wm.createdAt).getTime();
7706
8180
  const findings = [];
7707
8181
  let totalToolCalls = 0;
@@ -7748,10 +8222,20 @@ async function tickScanWatcher() {
7748
8222
  wm.files[filePath] = { scannedTo: newScannedTo };
7749
8223
  filesScanned++;
7750
8224
  }
8225
+ const uploadAs = wm.pendingResetUploadAs === "totals" ? "totals" : "deltas";
7751
8226
  saveWatermark(wm);
7752
- 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
+ };
7753
8237
  }
7754
- 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;
7755
8239
  var init_scan_watermark = __esm({
7756
8240
  "src/daemon/scan-watermark.ts"() {
7757
8241
  "use strict";
@@ -7760,27 +8244,8 @@ var init_scan_watermark = __esm({
7760
8244
  PROJECTS_DIR = () => path18.join(os15.homedir(), ".claude", "projects");
7761
8245
  WATERMARK_FILE = () => path18.join(os15.homedir(), ".node9", "scan-watermark.json");
7762
8246
  MAX_LINE_BYTES = 2 * 1024 * 1024;
7763
- 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;
7764
- LONG_OUTPUT_THRESHOLD_BYTES = 100 * 1024;
7765
- FILE_TOOLS = /* @__PURE__ */ new Set([
7766
- "read",
7767
- "read_file",
7768
- "edit",
7769
- "edit_file",
7770
- "write",
7771
- "write_file",
7772
- "multiedit",
7773
- "grep",
7774
- "grep_search",
7775
- "glob",
7776
- "list_files"
7777
- ]);
7778
- 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;
7779
- PII_EMAIL_RE = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/;
7780
- PII_SSN_RE = /\b\d{3}-\d{2}-\d{4}\b/;
7781
- PII_PHONE_RE = /\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}\b/;
7782
- 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/;
7783
- 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;
7784
8249
  }
7785
8250
  });
7786
8251
 
@@ -7905,6 +8370,13 @@ async function runUploadHistory(opts) {
7905
8370
  let linesParsed = 0;
7906
8371
  let linesSkipped = 0;
7907
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
+ };
7908
8380
  for (const { filePath, sessionId, projectDir } of iterateJsonlFiles(cutoffMs)) {
7909
8381
  filesScanned++;
7910
8382
  let content;
@@ -7914,6 +8386,7 @@ async function runUploadHistory(opts) {
7914
8386
  continue;
7915
8387
  }
7916
8388
  let lineIndex = 0;
8389
+ const sessionCalls = [];
7917
8390
  for (const line of content.split("\n")) {
7918
8391
  if (!line.trim()) continue;
7919
8392
  let obj;
@@ -7930,10 +8403,38 @@ async function runUploadHistory(opts) {
7930
8403
  if (obj["type"] === "assistant" && Array.isArray(msg?.content) && msg.content.some((b) => b.type === "tool_use")) {
7931
8404
  totalToolCalls++;
7932
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
+ }
7933
8418
  }
7934
8419
  linesParsed++;
7935
8420
  lineIndex++;
7936
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
+ }
7937
8438
  const fallbackWorkingDir = decodeProjectDirName(projectDir);
7938
8439
  const dailyMap = parseJSONLFile(filePath, fallbackWorkingDir);
7939
8440
  for (const entry of dailyMap.values()) {
@@ -7958,7 +8459,8 @@ async function runUploadHistory(opts) {
7958
8459
  const scanUrl = creds.apiUrl.endsWith("/policies/sync") ? creds.apiUrl.replace(/\/policies\/sync$/, "/scan/report") : `${creds.apiUrl.replace(/\/$/, "")}/scan/report`;
7959
8460
  await postJson(scanUrl, creds.apiKey, {
7960
8461
  ...summary,
7961
- sessionTotals
8462
+ sessionTotals,
8463
+ extractorVersion: CANONICAL_EXTRACTOR_VERSION
7962
8464
  });
7963
8465
  console.log(chalk3.green(` \u2713 Uploaded scanner findings`));
7964
8466
  if (dailyEntries.length > 0) {
@@ -8094,7 +8596,7 @@ function fmtTs(ts) {
8094
8596
  }
8095
8597
  }
8096
8598
  function stripTerminalEscapes(s) {
8097
- return s.replace(TERMINAL_ESCAPE_RE, "");
8599
+ return s.replace(TERMINAL_ESCAPE_RE2, "");
8098
8600
  }
8099
8601
  function preview(input, max) {
8100
8602
  const cmd = input.command ?? input.query ?? input.file_path ?? JSON.stringify(input);
@@ -9368,7 +9870,7 @@ function renderNarrativeScorecard(input) {
9368
9870
  console.log("");
9369
9871
  }
9370
9872
  function registerScanCommand(program2) {
9371
- 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(
9372
9874
  "--upload-history",
9373
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)."
9374
9876
  ).option(
@@ -9387,7 +9889,7 @@ function registerScanCommand(program2) {
9387
9889
  const previewWidth = 70;
9388
9890
  const startDate = options.all ? null : (() => {
9389
9891
  const d = /* @__PURE__ */ new Date();
9390
- d.setDate(d.getDate() - (parseInt(options.days, 10) || 30));
9892
+ d.setDate(d.getDate() - (parseInt(options.days, 10) || 90));
9391
9893
  d.setHours(0, 0, 0, 0);
9392
9894
  return d;
9393
9895
  })();
@@ -9461,7 +9963,7 @@ function registerScanCommand(program2) {
9461
9963
  );
9462
9964
  return;
9463
9965
  }
9464
- 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`);
9465
9967
  const dateRange = scan.firstDate && scan.lastDate ? chalk4.dim(` ${fmtTs(scan.firstDate)} \u2013 ${fmtTs(scan.lastDate)}`) : "";
9466
9968
  const breakdownParts = [];
9467
9969
  if (claudeScan.sessions > 0)
@@ -9747,7 +10249,7 @@ function registerScanCommand(program2) {
9747
10249
  }
9748
10250
  );
9749
10251
  }
9750
- var CLAUDE_PRICING, GEMINI_PRICING, CODE_EXTENSIONS, SELF_OUTPUT_MARKERS, FIXTURE_TOKEN_PATTERNS, TERMINAL_ESCAPE_RE, LOOP_TOOLS, LOOP_THRESHOLD, LOOP_TIMESPAN_THRESHOLD_MS, STUCK_TOOLS_MIN_WASTE, STUCK_TOOLS_LIMIT, RECURRING_SESSION_THRESHOLD, STALE_AGE_DAYS, AST_FS_REGEX_RULES, 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;
9751
10253
  var init_scan = __esm({
9752
10254
  "src/cli/commands/scan.ts"() {
9753
10255
  "use strict";
@@ -9823,7 +10325,7 @@ var init_scan = __esm({
9823
10325
  // long digit sequence — fixture, not entropy
9824
10326
  /qwerty/i
9825
10327
  ];
9826
- TERMINAL_ESCAPE_RE = /\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-_]|[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g;
10328
+ TERMINAL_ESCAPE_RE2 = /\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-_]|[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g;
9827
10329
  LOOP_TOOLS = /* @__PURE__ */ new Set([
9828
10330
  "bash",
9829
10331
  "execute_bash",
@@ -9840,13 +10342,6 @@ var init_scan = __esm({
9840
10342
  STUCK_TOOLS_LIMIT = 3;
9841
10343
  RECURRING_SESSION_THRESHOLD = 3;
9842
10344
  STALE_AGE_DAYS = 30;
9843
- AST_FS_REGEX_RULES = /* @__PURE__ */ new Set([
9844
- "block-rm-rf-home",
9845
- "shield:project-jail:block-read-ssh",
9846
- "shield:project-jail:block-read-aws",
9847
- "shield:project-jail:block-read-env",
9848
- "shield:project-jail:block-read-credentials"
9849
- ]);
9850
10345
  DEFAULT_RULE_NAMES = new Set(
9851
10346
  DEFAULT_CONFIG.policy.smartRules.map((r) => r.name).filter(Boolean)
9852
10347
  );
@@ -10829,16 +11324,19 @@ async function pushBlastSnapshot(creds) {
10829
11324
  async function pushScanSnapshot(creds) {
10830
11325
  try {
10831
11326
  const tick = await tickScanWatcher();
11327
+ if (tick.schemaFuture) return;
10832
11328
  if (tick.findings.length === 0 && tick.totalToolCalls === 0) {
10833
11329
  return;
10834
11330
  }
10835
11331
  const summary = summarizeScan(tick.findings, {
10836
11332
  totalToolCalls: tick.totalToolCalls
10837
11333
  });
10838
- const sessionDeltas = buildSessionDeltas(tick.findings, tick.toolCallsBySession);
11334
+ const perSession = buildSessionDeltas(tick.findings, tick.toolCallsBySession);
10839
11335
  const scanUrl = creds.apiUrl.endsWith("/policies/sync") ? creds.apiUrl.replace(/\/policies\/sync$/, "/scan/report") : null;
10840
11336
  if (!scanUrl) return;
11337
+ const body = tick.uploadAs === "totals" ? { ...summary, sessionTotals: perSession, extractorVersion: CANONICAL_EXTRACTOR_VERSION } : { ...summary, sessionDeltas: perSession, extractorVersion: CANONICAL_EXTRACTOR_VERSION };
10841
11338
  const parsed = new URL(scanUrl);
11339
+ let posted = false;
10842
11340
  await new Promise((resolve) => {
10843
11341
  const req = https2.request(
10844
11342
  {
@@ -10853,6 +11351,9 @@ async function pushScanSnapshot(creds) {
10853
11351
  timeout: 1e4
10854
11352
  },
10855
11353
  (res) => {
11354
+ if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
11355
+ posted = true;
11356
+ }
10856
11357
  res.resume();
10857
11358
  res.on("end", resolve);
10858
11359
  res.on("error", () => resolve());
@@ -10863,9 +11364,12 @@ async function pushScanSnapshot(creds) {
10863
11364
  req.destroy();
10864
11365
  resolve();
10865
11366
  });
10866
- req.write(JSON.stringify({ ...summary, sessionDeltas }));
11367
+ req.write(JSON.stringify(body));
10867
11368
  req.end();
10868
11369
  });
11370
+ if (posted && tick.uploadAs === "totals") {
11371
+ markUploadComplete();
11372
+ }
10869
11373
  } catch {
10870
11374
  }
10871
11375
  }