@node9/proxy 2.8.1 → 2.8.3

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/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  <a href="https://www.npmjs.com/package/node9-ai"><img src="https://img.shields.io/npm/dm/node9-ai.svg" alt="monthly downloads" /></a>
6
6
  <a href="https://opensource.org/licenses/Apache-2.0"><img src="https://img.shields.io/badge/License-Apache%202.0-blue.svg" alt="License: Apache 2.0" /></a>
7
7
  <a href="https://node9.ai/docs"><img src="https://img.shields.io/badge/docs-node9.ai-blue" alt="Documentation" /></a>
8
- <a href="https://huggingface.co/spaces/Node9ai/node9-security-demo"><img src="https://huggingface.co/datasets/huggingface/badges/resolve/main/open-in-hf-spaces-sm.svg" alt="Try on HF Spaces" /></a>
8
+ <a href="https://github.com/node9-ai/node9-proxy/blob/main/.github/workflows/agent-security.yml"><img src="https://img.shields.io/badge/node9-self--scanned-a855f7?style=flat&labelColor=%231A1A2E&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNCAxNCI+PHBhdGggZmlsbD0iI0Y1RTlGRiIgZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik03IDAuNCAxLjYgMi41djQuMmMwIDMuMSAyLjMgNS42IDUuNCA2LjkgMy4xLTEuMyA1LjQtMy44IDUuNC02LjlWMi41TDcgMC40Wm0wIDEuNSAzLjkgMS41djMuM2MwIDIuMy0xLjYgNC4yLTMuOSA1LjMtMi4zLTEuMS0zLjktMy0zLjktNS4zVjMuNEw3IDEuOVptMCAyLjJhMS45IDEuOSAwIDAgMC0xIDMuNXYxLjZoMlY3LjZhMS45IDEuOSAwIDAgMC0xLTMuNVoiLz48L3N2Zz4K" alt="node9 self-scanned" /></a>
9
9
  </p>
10
10
 
11
11
  Node9 sits between your AI agent and the tools it can use — **discover** what it's already been doing, **protect** against risky actions in real time, and **review** what happened over any time window.
@@ -115,6 +115,8 @@ What it checks:
115
115
 
116
116
  Marketplace: **[node9 Agent Security](https://github.com/marketplace/actions/node9-agent-security)**
117
117
 
118
+ Running it? Add the **[`scanned by node9` badge](docs/badges.md)** to your README.
119
+
118
120
  ## Live monitoring
119
121
 
120
122
  <p align="center">
package/dist/cli.js CHANGED
@@ -15757,6 +15757,7 @@ function registerScanCommand(program2) {
15757
15757
  })();
15758
15758
  const isWired = getAgentsStatus().some((a) => a.wired);
15759
15759
  const enabledShields = getConfig().policy.appliedShields ?? [];
15760
+ const keyed = isKeyedForPolicy();
15760
15761
  const enabledShieldSet = new Set(enabledShields);
15761
15762
  const screenshotMode = options.compact || options.narrative;
15762
15763
  const useInk = !options.classic && !drillDown;
@@ -15909,7 +15910,8 @@ function registerScanCommand(program2) {
15909
15910
  blastExposures,
15910
15911
  blockedCount,
15911
15912
  reviewCount,
15912
- enabledShields
15913
+ enabledShields,
15914
+ keyed
15913
15915
  });
15914
15916
  return;
15915
15917
  }
@@ -15921,7 +15923,8 @@ function registerScanCommand(program2) {
15921
15923
  blastExposures,
15922
15924
  blockedCount,
15923
15925
  reviewCount,
15924
- enabledShields
15926
+ enabledShields,
15927
+ keyed
15925
15928
  });
15926
15929
  return;
15927
15930
  }
@@ -16012,7 +16015,8 @@ function registerScanCommand(program2) {
16012
16015
  blastExposures,
16013
16016
  blockedCount,
16014
16017
  reviewCount,
16015
- enabledShields
16018
+ enabledShields,
16019
+ keyed
16016
16020
  },
16017
16021
  rangeLabel2
16018
16022
  );
@@ -16025,7 +16029,8 @@ function registerScanCommand(program2) {
16025
16029
  blastExposures,
16026
16030
  blockedCount,
16027
16031
  reviewCount,
16028
- enabledShields
16032
+ enabledShields,
16033
+ keyed
16029
16034
  });
16030
16035
  }
16031
16036
  const cta = isWired ? "\u2705 node9 is active" : "\u2192 install node9 to enable protection";
@@ -16248,6 +16253,7 @@ var import_chalk6, import_fs26, import_path28, import_os25, import_string_width2
16248
16253
  var init_scan = __esm({
16249
16254
  "src/cli/commands/scan.ts"() {
16250
16255
  "use strict";
16256
+ init_keyed_guard();
16251
16257
  import_chalk6 = __toESM(require("chalk"));
16252
16258
  import_fs26 = __toESM(require("fs"));
16253
16259
  import_path28 = __toESM(require("path"));
@@ -48272,33 +48278,39 @@ REQUIRED ACTIONS:
48272
48278
  1. Remove the hardcoded credential from your command or code.
48273
48279
  2. Use an environment variable or a dedicated secrets manager instead.
48274
48280
  3. Treat the leaked credential as compromised and rotate it immediately.
48275
- Do NOT attempt to bypass this check or pass the credential through another tool.`;
48281
+ Do NOT attempt to bypass this check or pass the credential through another tool.
48282
+ - Tell the user node9 blocked this and why, then ask how to proceed.`;
48276
48283
  }
48277
48284
  if (label2.includes("sql safety") && label2.includes("delete without where")) {
48278
48285
  return `NODE9: Blocked \u2014 DELETE without WHERE clause would wipe the entire table.
48279
48286
  INSTRUCTION: Add a WHERE clause to scope the deletion (e.g. WHERE id = <value>).
48280
- Do NOT retry without a WHERE clause.`;
48287
+ Do NOT retry without a WHERE clause.
48288
+ - Tell the user node9 blocked this and why, then ask how to proceed.`;
48281
48289
  }
48282
48290
  if (label2.includes("sql safety") && label2.includes("update without where")) {
48283
48291
  return `NODE9: Blocked \u2014 UPDATE without WHERE clause would update every row.
48284
48292
  INSTRUCTION: Add a WHERE clause to scope the update (e.g. WHERE id = <value>).
48285
- Do NOT retry without a WHERE clause.`;
48293
+ Do NOT retry without a WHERE clause.
48294
+ - Tell the user node9 blocked this and why, then ask how to proceed.`;
48286
48295
  }
48287
48296
  if (label2.includes("dangerous word")) {
48288
48297
  const match = blockedByLabel.match(/dangerous word: "([^"]+)"/i);
48289
48298
  const word = match?.[1] ?? "a dangerous keyword";
48290
48299
  return `NODE9: Blocked \u2014 command contains forbidden keyword "${word}".
48291
48300
  INSTRUCTION: Do NOT use "${word}". Use a non-destructive alternative.
48292
- Do NOT attempt to bypass this with shell tricks or aliases \u2014 it will be blocked again.`;
48301
+ Do NOT attempt to bypass this with shell tricks or aliases \u2014 it will be blocked again.
48302
+ - Tell the user node9 blocked this and why, then ask how to proceed.`;
48293
48303
  }
48294
48304
  if (label2.includes("path blocked") || label2.includes("sandbox")) {
48295
48305
  return `NODE9: Blocked \u2014 operation targets a path outside the allowed sandbox.
48296
48306
  INSTRUCTION: Move your output to an allowed directory such as /tmp/ or the project directory.
48297
- Do NOT retry on the same path.`;
48307
+ Do NOT retry on the same path.
48308
+ - Tell the user node9 blocked this and why, then ask how to proceed.`;
48298
48309
  }
48299
48310
  if (label2.includes("inline execution")) {
48300
48311
  return `NODE9: Blocked \u2014 inline code execution (e.g. bash -c "...") is not allowed.
48301
- INSTRUCTION: Use individual tool calls instead of embedding code in a shell string.`;
48312
+ INSTRUCTION: Use individual tool calls instead of embedding code in a shell string.
48313
+ - Tell the user node9 blocked this and why, then ask how to proceed.`;
48302
48314
  }
48303
48315
  if (label2.includes("strict mode")) {
48304
48316
  return `NODE9: Blocked \u2014 strict mode is active. All tool calls require explicit human approval.
@@ -48309,7 +48321,8 @@ INSTRUCTION: Inform the user this action is pending approval. Wait for them to a
48309
48321
  const rule = match?.[1] ?? "a policy rule";
48310
48322
  return `NODE9: Blocked \u2014 action "${rule}" is forbidden by security policy.
48311
48323
  INSTRUCTION: Do NOT use "${rule}". Find a read-only or non-destructive alternative.
48312
- Do NOT attempt to bypass this rule.`;
48324
+ Do NOT attempt to bypass this rule.
48325
+ - Tell the user node9 blocked this and why, then ask how to proceed.`;
48313
48326
  }
48314
48327
  const recovery = recoveryCommand ? `
48315
48328
  REQUIRED ACTION: Run \`${recoveryCommand}\` first, then retry your original command.` : "\n- Pivot to a non-destructive or read-only alternative.";
package/dist/cli.mjs CHANGED
@@ -15761,6 +15761,7 @@ function registerScanCommand(program2) {
15761
15761
  })();
15762
15762
  const isWired = getAgentsStatus().some((a) => a.wired);
15763
15763
  const enabledShields = getConfig().policy.appliedShields ?? [];
15764
+ const keyed = isKeyedForPolicy();
15764
15765
  const enabledShieldSet = new Set(enabledShields);
15765
15766
  const screenshotMode = options.compact || options.narrative;
15766
15767
  const useInk = !options.classic && !drillDown;
@@ -15913,7 +15914,8 @@ function registerScanCommand(program2) {
15913
15914
  blastExposures,
15914
15915
  blockedCount,
15915
15916
  reviewCount,
15916
- enabledShields
15917
+ enabledShields,
15918
+ keyed
15917
15919
  });
15918
15920
  return;
15919
15921
  }
@@ -15925,7 +15927,8 @@ function registerScanCommand(program2) {
15925
15927
  blastExposures,
15926
15928
  blockedCount,
15927
15929
  reviewCount,
15928
- enabledShields
15930
+ enabledShields,
15931
+ keyed
15929
15932
  });
15930
15933
  return;
15931
15934
  }
@@ -16016,7 +16019,8 @@ function registerScanCommand(program2) {
16016
16019
  blastExposures,
16017
16020
  blockedCount,
16018
16021
  reviewCount,
16019
- enabledShields
16022
+ enabledShields,
16023
+ keyed
16020
16024
  },
16021
16025
  rangeLabel2
16022
16026
  );
@@ -16029,7 +16033,8 @@ function registerScanCommand(program2) {
16029
16033
  blastExposures,
16030
16034
  blockedCount,
16031
16035
  reviewCount,
16032
- enabledShields
16036
+ enabledShields,
16037
+ keyed
16033
16038
  });
16034
16039
  }
16035
16040
  const cta = isWired ? "\u2705 node9 is active" : "\u2192 install node9 to enable protection";
@@ -16252,6 +16257,7 @@ var toolInspectionMap, CODE_EXTENSIONS, SELF_OUTPUT_MARKERS, FIXTURE_TOKEN_PATTE
16252
16257
  var init_scan = __esm({
16253
16258
  "src/cli/commands/scan.ts"() {
16254
16259
  "use strict";
16260
+ init_keyed_guard();
16255
16261
  init_shields();
16256
16262
  init_config();
16257
16263
  init_policy();
@@ -48264,33 +48270,39 @@ REQUIRED ACTIONS:
48264
48270
  1. Remove the hardcoded credential from your command or code.
48265
48271
  2. Use an environment variable or a dedicated secrets manager instead.
48266
48272
  3. Treat the leaked credential as compromised and rotate it immediately.
48267
- Do NOT attempt to bypass this check or pass the credential through another tool.`;
48273
+ Do NOT attempt to bypass this check or pass the credential through another tool.
48274
+ - Tell the user node9 blocked this and why, then ask how to proceed.`;
48268
48275
  }
48269
48276
  if (label2.includes("sql safety") && label2.includes("delete without where")) {
48270
48277
  return `NODE9: Blocked \u2014 DELETE without WHERE clause would wipe the entire table.
48271
48278
  INSTRUCTION: Add a WHERE clause to scope the deletion (e.g. WHERE id = <value>).
48272
- Do NOT retry without a WHERE clause.`;
48279
+ Do NOT retry without a WHERE clause.
48280
+ - Tell the user node9 blocked this and why, then ask how to proceed.`;
48273
48281
  }
48274
48282
  if (label2.includes("sql safety") && label2.includes("update without where")) {
48275
48283
  return `NODE9: Blocked \u2014 UPDATE without WHERE clause would update every row.
48276
48284
  INSTRUCTION: Add a WHERE clause to scope the update (e.g. WHERE id = <value>).
48277
- Do NOT retry without a WHERE clause.`;
48285
+ Do NOT retry without a WHERE clause.
48286
+ - Tell the user node9 blocked this and why, then ask how to proceed.`;
48278
48287
  }
48279
48288
  if (label2.includes("dangerous word")) {
48280
48289
  const match = blockedByLabel.match(/dangerous word: "([^"]+)"/i);
48281
48290
  const word = match?.[1] ?? "a dangerous keyword";
48282
48291
  return `NODE9: Blocked \u2014 command contains forbidden keyword "${word}".
48283
48292
  INSTRUCTION: Do NOT use "${word}". Use a non-destructive alternative.
48284
- Do NOT attempt to bypass this with shell tricks or aliases \u2014 it will be blocked again.`;
48293
+ Do NOT attempt to bypass this with shell tricks or aliases \u2014 it will be blocked again.
48294
+ - Tell the user node9 blocked this and why, then ask how to proceed.`;
48285
48295
  }
48286
48296
  if (label2.includes("path blocked") || label2.includes("sandbox")) {
48287
48297
  return `NODE9: Blocked \u2014 operation targets a path outside the allowed sandbox.
48288
48298
  INSTRUCTION: Move your output to an allowed directory such as /tmp/ or the project directory.
48289
- Do NOT retry on the same path.`;
48299
+ Do NOT retry on the same path.
48300
+ - Tell the user node9 blocked this and why, then ask how to proceed.`;
48290
48301
  }
48291
48302
  if (label2.includes("inline execution")) {
48292
48303
  return `NODE9: Blocked \u2014 inline code execution (e.g. bash -c "...") is not allowed.
48293
- INSTRUCTION: Use individual tool calls instead of embedding code in a shell string.`;
48304
+ INSTRUCTION: Use individual tool calls instead of embedding code in a shell string.
48305
+ - Tell the user node9 blocked this and why, then ask how to proceed.`;
48294
48306
  }
48295
48307
  if (label2.includes("strict mode")) {
48296
48308
  return `NODE9: Blocked \u2014 strict mode is active. All tool calls require explicit human approval.
@@ -48301,7 +48313,8 @@ INSTRUCTION: Inform the user this action is pending approval. Wait for them to a
48301
48313
  const rule = match?.[1] ?? "a policy rule";
48302
48314
  return `NODE9: Blocked \u2014 action "${rule}" is forbidden by security policy.
48303
48315
  INSTRUCTION: Do NOT use "${rule}". Find a read-only or non-destructive alternative.
48304
- Do NOT attempt to bypass this rule.`;
48316
+ Do NOT attempt to bypass this rule.
48317
+ - Tell the user node9 blocked this and why, then ask how to proceed.`;
48305
48318
  }
48306
48319
  const recovery = recoveryCommand ? `
48307
48320
  REQUIRED ACTION: Run \`${recoveryCommand}\` first, then retry your original command.` : "\n- Pivot to a non-destructive or read-only alternative.";
@@ -4809,6 +4809,22 @@ var init_report_audit = __esm({
4809
4809
  }
4810
4810
  });
4811
4811
 
4812
+ // src/config/keyed-guard.ts
4813
+ import chalk2 from "chalk";
4814
+ function isKeyedForPolicy() {
4815
+ try {
4816
+ return getConfig().policySource === "workspace";
4817
+ } catch {
4818
+ return false;
4819
+ }
4820
+ }
4821
+ var init_keyed_guard = __esm({
4822
+ "src/config/keyed-guard.ts"() {
4823
+ "use strict";
4824
+ init_config();
4825
+ }
4826
+ });
4827
+
4812
4828
  // src/utils/provenance.ts
4813
4829
  import path9 from "path";
4814
4830
  import os8 from "os";
@@ -4891,7 +4907,7 @@ var init_setup_pi_shim = __esm({
4891
4907
  });
4892
4908
 
4893
4909
  // src/setup.ts
4894
- import chalk2 from "chalk";
4910
+ import chalk3 from "chalk";
4895
4911
  import { confirm as rawConfirm } from "@inquirer/prompts";
4896
4912
  import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
4897
4913
  import * as yaml from "yaml";
@@ -4906,7 +4922,7 @@ var init_setup = __esm({
4906
4922
  });
4907
4923
 
4908
4924
  // src/cli/render/scan-derive.ts
4909
- import chalk3 from "chalk";
4925
+ import chalk4 from "chalk";
4910
4926
  import stringWidth from "string-width";
4911
4927
  var init_scan_derive = __esm({
4912
4928
  "src/cli/render/scan-derive.ts"() {
@@ -4930,7 +4946,7 @@ var init_scan_history = __esm({
4930
4946
  });
4931
4947
 
4932
4948
  // src/cli/commands/scan.ts
4933
- import chalk4 from "chalk";
4949
+ import chalk5 from "chalk";
4934
4950
  import fs8 from "fs";
4935
4951
  import path11 from "path";
4936
4952
  import os10 from "os";
@@ -5845,6 +5861,7 @@ var toolInspectionMap, CODE_EXTENSIONS, SELF_OUTPUT_MARKERS, FIXTURE_TOKEN_PATTE
5845
5861
  var init_scan = __esm({
5846
5862
  "src/cli/commands/scan.ts"() {
5847
5863
  "use strict";
5864
+ init_keyed_guard();
5848
5865
  init_shields();
5849
5866
  init_config();
5850
5867
  init_policy();
@@ -7093,6 +7110,22 @@ var init_Protection = __esm({
7093
7110
  }
7094
7111
  });
7095
7112
 
7113
+ // src/cli/render/ink/panels/cost-note.ts
7114
+ function costNoteLines(keyed) {
7115
+ return keyed ? ["Your plan and exact spend:", "dashboard \u2192 Report"] : ["To see your plan or exact spend, connect:", "node9 login (for free)"];
7116
+ }
7117
+ function costNoteLinesForThisMachine() {
7118
+ return costNoteLines(isKeyedForPolicy());
7119
+ }
7120
+ var COST_LABEL;
7121
+ var init_cost_note = __esm({
7122
+ "src/cli/render/ink/panels/cost-note.ts"() {
7123
+ "use strict";
7124
+ init_keyed_guard();
7125
+ COST_LABEL = "COST \xB7 based on API value";
7126
+ }
7127
+ });
7128
+
7096
7129
  // src/tui/dashboard/views/report/panels/Cost.tsx
7097
7130
  import { Box as Box3, Text as Text3 } from "ink";
7098
7131
  import { Fragment as Fragment2, jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
@@ -7116,7 +7149,7 @@ function Cost({ audit }) {
7116
7149
  flexGrow: 1,
7117
7150
  flexBasis: 0,
7118
7151
  children: [
7119
- /* @__PURE__ */ jsx3(Text3, { bold: true, children: "COST \xB7 API value" }),
7152
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: COST_LABEL }),
7120
7153
  audit === null ? /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "loading\u2026" }) : /* @__PURE__ */ jsxs3(Fragment2, { children: [
7121
7154
  /* @__PURE__ */ jsxs3(Box3, { height: 1, children: [
7122
7155
  /* @__PURE__ */ jsx3(Box3, { width: LABEL_W2, children: /* @__PURE__ */ jsx3(Text3, { children: "Total" }) }),
@@ -7139,7 +7172,8 @@ function Cost({ audit }) {
7139
7172
  /* @__PURE__ */ jsxs3(Box3, { height: 1, children: [
7140
7173
  /* @__PURE__ */ jsx3(Box3, { width: LABEL_W2, children: /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "Trend" }) }),
7141
7174
  /* @__PURE__ */ jsx3(Text3, { children: trend || /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "(no data)" }) })
7142
- ] })
7175
+ ] }),
7176
+ costNoteLinesForThisMachine().map((line) => /* @__PURE__ */ jsx3(Box3, { height: 1, children: /* @__PURE__ */ jsx3(Text3, { dimColor: true, wrap: "truncate-end", children: line }) }, line))
7143
7177
  ] })
7144
7178
  ]
7145
7179
  }
@@ -7151,6 +7185,7 @@ var init_Cost = __esm({
7151
7185
  "use strict";
7152
7186
  init_panels();
7153
7187
  init_format();
7188
+ init_cost_note();
7154
7189
  init_util();
7155
7190
  LABEL_W2 = 10;
7156
7191
  }
package/dist/scan-ink.mjs CHANGED
@@ -39,327 +39,169 @@ function formatCost(usd) {
39
39
  return `$${(usd / 1e3).toFixed(1)}K`;
40
40
  }
41
41
 
42
- // src/cli/render/ink/panels/CostPanel.tsx
43
- import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
44
- var LABEL_W = 16;
45
- function CostPanel({ summary, width }) {
46
- const total = summary.stats.totalCostUSD;
47
- return /* @__PURE__ */ jsxs3(Box3, { borderStyle: "round", borderColor: "gray", paddingX: 1, flexDirection: "column", width, children: [
48
- /* @__PURE__ */ jsx3(Text3, { bold: true, children: "COST \xB7 API value" }),
49
- /* @__PURE__ */ jsxs3(Box3, { children: [
50
- /* @__PURE__ */ jsx3(Box3, { width: LABEL_W, children: /* @__PURE__ */ jsx3(Text3, { children: "Total" }) }),
51
- /* @__PURE__ */ jsx3(Text3, { bold: true, children: formatCost(total) })
52
- ] }),
53
- summary.byAgent.map((agent) => /* @__PURE__ */ jsxs3(Box3, { children: [
54
- /* @__PURE__ */ jsx3(Box3, { width: LABEL_W, children: /* @__PURE__ */ jsx3(Text3, { children: agent.label }) }),
55
- /* @__PURE__ */ jsx3(Text3, { children: formatCost(agent.costUSD) })
56
- ] }, agent.id)),
57
- summary.loopWastedUSD > 0 ? /* @__PURE__ */ jsxs3(Box3, { children: [
58
- /* @__PURE__ */ jsx3(Box3, { width: LABEL_W, children: /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "Wasted on loops" }) }),
59
- /* @__PURE__ */ jsx3(Text3, { color: "yellow", children: "~" + formatCost(summary.loopWastedUSD) })
60
- ] }) : null
61
- ] });
62
- }
63
-
64
- // src/cli/render/ink/panels/ActivityPanel.tsx
65
- import { Box as Box4, Text as Text4 } from "ink";
66
- import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
67
- var LABEL_W2 = 16;
68
- function fmtNum(n) {
69
- return n.toLocaleString();
70
- }
71
- function countMcpFindings(scan) {
72
- let n = 0;
73
- for (const f of scan.findings) {
74
- if (f.toolName.startsWith("mcp__")) n++;
75
- }
76
- return n;
77
- }
78
- function ActivityPanel({ summary, scan, width }) {
79
- const { sessions, totalToolCalls, bashCalls, totalCostUSD } = summary.stats;
80
- const perSession = sessions > 0 ? totalCostUSD / sessions : 0;
81
- const toolsPerSession = sessions > 0 ? Math.round(totalToolCalls / sessions) : 0;
82
- const mcpCount = countMcpFindings(scan);
83
- const topAgent = summary.byAgent.slice().sort((a, b) => b.sessions - a.sessions)[0];
84
- return /* @__PURE__ */ jsxs4(Box4, { borderStyle: "round", borderColor: "gray", paddingX: 1, flexDirection: "column", width, children: [
85
- /* @__PURE__ */ jsx4(Text4, { bold: true, children: "ACTIVITY" }),
86
- /* @__PURE__ */ jsxs4(Box4, { children: [
87
- /* @__PURE__ */ jsx4(Box4, { width: LABEL_W2, children: /* @__PURE__ */ jsx4(Text4, { children: "Sessions" }) }),
88
- /* @__PURE__ */ jsx4(Text4, { bold: true, children: fmtNum(sessions) })
89
- ] }),
90
- /* @__PURE__ */ jsxs4(Box4, { children: [
91
- /* @__PURE__ */ jsx4(Box4, { width: LABEL_W2, children: /* @__PURE__ */ jsx4(Text4, { children: "Tools" }) }),
92
- /* @__PURE__ */ jsx4(Text4, { children: fmtNum(totalToolCalls) })
93
- ] }),
94
- /* @__PURE__ */ jsxs4(Box4, { children: [
95
- /* @__PURE__ */ jsx4(Box4, { width: LABEL_W2, children: /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: " Shell" }) }),
96
- /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: fmtNum(bashCalls) })
97
- ] }),
98
- mcpCount > 0 ? /* @__PURE__ */ jsxs4(Box4, { children: [
99
- /* @__PURE__ */ jsx4(Box4, { width: LABEL_W2, children: /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: " MCP" }) }),
100
- /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: fmtNum(mcpCount) })
101
- ] }) : null,
102
- /* @__PURE__ */ jsxs4(Box4, { children: [
103
- /* @__PURE__ */ jsx4(Box4, { width: LABEL_W2, children: /* @__PURE__ */ jsx4(Text4, { children: "Tools / session" }) }),
104
- /* @__PURE__ */ jsx4(Text4, { children: "~" + fmtNum(toolsPerSession) })
105
- ] }),
106
- /* @__PURE__ */ jsxs4(Box4, { children: [
107
- /* @__PURE__ */ jsx4(Box4, { width: LABEL_W2, children: /* @__PURE__ */ jsx4(Text4, { children: "Cost / session" }) }),
108
- /* @__PURE__ */ jsx4(Text4, { children: "~$" + Math.round(perSession).toLocaleString() })
109
- ] }),
110
- topAgent ? /* @__PURE__ */ jsxs4(Box4, { children: [
111
- /* @__PURE__ */ jsx4(Box4, { width: LABEL_W2, children: /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "Top agent" }) }),
112
- /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: `${topAgent.label} (${fmtNum(topAgent.sessions)})` })
113
- ] }) : null
114
- ] });
115
- }
116
-
117
- // src/cli/render/ink/panels/LeaksPanel.tsx
118
- import { Box as Box5, Text as Text5 } from "ink";
119
-
120
- // src/cli/render/scan-derive.ts
42
+ // src/config/keyed-guard.ts
121
43
  import chalk from "chalk";
122
- import stringWidth from "string-width";
123
- function topRulesByVerdict(sections, verdict, n) {
124
- const matched = [];
125
- for (const section of sections) {
126
- for (const rule of section.rules) {
127
- const matches = verdict === "block" ? rule.verdict === "block" : rule.verdict !== "block";
128
- if (matches) matched.push({ name: rule.name, count: rule.findings.length });
129
- }
130
- }
131
- return matched.sort((a, b) => b.count - a.count).slice(0, n);
132
- }
133
- function computeLoopWaste(loops, totalToolCalls) {
134
- const wastedCalls = loops.reduce((s, l) => s + Math.max(0, l.count - 1), 0);
135
- const wastePct = totalToolCalls > 0 ? Math.round(wastedCalls / totalToolCalls * 100) : 0;
136
- return { wastedCalls, wastePct };
137
- }
138
- function rollupByShield(sections, topRulesPerShield = 3) {
139
- const out = [];
140
- for (const section of sections) {
141
- if (section.sourceType !== "shield") continue;
142
- if (!section.shieldKey) continue;
143
- const totalCatches = section.blockedCount + section.reviewCount;
144
- const topRuleLabels = [...section.rules].sort((a, b) => b.findings.length - a.findings.length).slice(0, topRulesPerShield).map((r) => r.findings.length > 1 ? `${r.name} \xD7${r.findings.length}` : r.name);
145
- out.push({
146
- shieldName: section.shieldKey,
147
- totalCatches,
148
- blockCatches: section.blockedCount,
149
- reviewCatches: section.reviewCount,
150
- topRuleLabels
151
- });
152
- }
153
- return out.sort((a, b) => b.totalCatches - a.totalCatches);
154
- }
155
- function originForRule(ruleName, sections, enabled) {
156
- for (const section of sections) {
157
- if (section.rules.some((r) => r.name === ruleName)) {
158
- if (section.sourceType === "default") return "default";
159
- if (section.sourceType === "shield") {
160
- const key = section.shieldKey ?? section.id;
161
- return enabled.has(key) ? `\u2713 shield:${key}` : `needs shield:${key}`;
162
- }
163
- }
164
- }
165
- return "";
166
- }
167
- function relativeDate(timestamp, now = /* @__PURE__ */ new Date()) {
168
- const t = new Date(timestamp).getTime();
169
- if (Number.isNaN(t)) return "?";
170
- const days = Math.floor((now.getTime() - t) / 864e5);
171
- if (days < 1) return "today";
172
- if (days > 90) return "90d+";
173
- return `${days}d`;
174
- }
175
44
 
176
- // src/cli/render/ink/panels/title-count.ts
177
- function topOf(shown, total) {
178
- return total > shown ? ` \xB7 top ${shown} of ${total}` : "";
179
- }
180
-
181
- // src/cli/render/ink/panels/LeaksPanel.tsx
182
- import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
183
- var ROW_LIMIT = 4;
184
- function LeaksPanel({ summary, width, now = /* @__PURE__ */ new Date() }) {
185
- const leaks = summary.leaks;
186
- if (leaks.length === 0) return null;
187
- return /* @__PURE__ */ jsxs5(Box5, { borderStyle: "round", borderColor: "red", paddingX: 1, flexDirection: "column", width, children: [
188
- /* @__PURE__ */ jsxs5(Text5, { bold: true, color: "red", children: [
189
- "CREDENTIAL LEAKS",
190
- /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: topOf(Math.min(leaks.length, ROW_LIMIT), leaks.length) })
191
- ] }),
192
- leaks.slice(0, ROW_LIMIT).map((leak, i) => /* @__PURE__ */ jsxs5(Box5, { children: [
193
- /* @__PURE__ */ jsx5(Box5, { width: 5, children: /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: relativeDate(leak.timestamp, now).padStart(4) }) }),
194
- /* @__PURE__ */ jsx5(Box5, { width: 16, children: /* @__PURE__ */ jsx5(Text5, { color: "red", bold: true, wrap: "truncate-end", children: leak.patternName }) }),
195
- /* @__PURE__ */ jsx5(Box5, { width: 15, children: /* @__PURE__ */ jsx5(Text5, { dimColor: true, wrap: "truncate-end", children: `[${leak.toolName}]` }) }),
196
- /* @__PURE__ */ jsx5(Text5, { dimColor: true, wrap: "truncate-end", children: leak.agent })
197
- ] }, i)),
198
- /* @__PURE__ */ jsxs5(Box5, { children: [
199
- /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "\u2192 " }),
200
- /* @__PURE__ */ jsx5(Text5, { bold: true, color: "cyan", children: "DLP" }),
201
- /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: " \xB7 " }),
202
- /* @__PURE__ */ jsx5(Text5, { bold: true, color: "cyan", children: "node9 mask" }),
203
- /* @__PURE__ */ jsx5(Text5, { dimColor: true, wrap: "truncate-end", children: " (runtime + cleanup)" })
204
- ] })
205
- ] });
206
- }
207
-
208
- // src/cli/render/ink/panels/BlockedPanel.tsx
209
- import { Box as Box6, Text as Text6 } from "ink";
210
- import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
211
- var ROW_LIMIT2 = 12;
212
- function BlockedPanel({
213
- summary,
214
- width,
215
- enabledShields = []
216
- }) {
217
- const enabled = new Set(enabledShields);
218
- const allRules = topRulesByVerdict(summary.sections, "block", Number.MAX_SAFE_INTEGER);
219
- const rules = allRules.slice(0, ROW_LIMIT2);
220
- if (rules.length === 0) return null;
221
- const origins = rules.map((r) => originForRule(r.name, summary.sections, enabled));
222
- const anyNeeds = origins.some((o) => o.startsWith("needs shield:"));
223
- const footer = enabled.size === 0 ? "\u2192 install node9 + enable shields above" : anyNeeds ? "\u2192 \u2713 marks rules from enabled shields \xB7 enable the rest above" : "\u2192 \u2713 all these rules come from enabled shields";
224
- return /* @__PURE__ */ jsxs6(Box6, { borderStyle: "round", borderColor: "red", paddingX: 1, flexDirection: "column", width, children: [
225
- /* @__PURE__ */ jsxs6(Text6, { bold: true, color: "red", children: [
226
- "WOULD HAVE BLOCKED",
227
- /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: topOf(rules.length, allRules.length) })
228
- ] }),
229
- rules.map((rule, i) => /* @__PURE__ */ jsxs6(Box6, { children: [
230
- /* @__PURE__ */ jsx6(Box6, { width: 3, children: /* @__PURE__ */ jsx6(Text6, { color: "red", children: "\u2717" }) }),
231
- /* @__PURE__ */ jsx6(Box6, { width: 24, children: /* @__PURE__ */ jsx6(Text6, { bold: true, wrap: "truncate-end", children: rule.name }) }),
232
- /* @__PURE__ */ jsx6(Box6, { width: 6, children: /* @__PURE__ */ jsx6(Text6, { bold: true, children: `\xD7${rule.count}` }) }),
233
- /* @__PURE__ */ jsx6(Text6, { dimColor: true, wrap: "truncate-end", children: originForRule(rule.name, summary.sections, enabled) })
234
- ] }, i)),
235
- /* @__PURE__ */ jsx6(Box6, { children: /* @__PURE__ */ jsx6(Text6, { dimColor: true, wrap: "truncate-end", children: footer }) })
236
- ] });
237
- }
238
-
239
- // src/cli/render/ink/panels/BlastRadiusPanel.tsx
240
- import { Box as Box7, Text as Text7 } from "ink";
241
- import { jsx as jsx7, jsxs as jsxs7 } from "react/jsx-runtime";
242
- var ROW_LIMIT3 = 4;
243
- var ENV_ROW_LIMIT = 3;
244
- function BlastRadiusPanel({
245
- blast,
246
- blastExposures,
247
- width
248
- }) {
249
- if (blastExposures === 0) return null;
250
- const shown = Math.min(blast.reachable.length, ROW_LIMIT3) + Math.min(blast.envFindings.length, ENV_ROW_LIMIT);
251
- return /* @__PURE__ */ jsxs7(Box7, { borderStyle: "round", borderColor: "yellow", paddingX: 1, flexDirection: "column", width, children: [
252
- /* @__PURE__ */ jsxs7(Text7, { bold: true, color: "yellow", children: [
253
- "BLAST RADIUS",
254
- /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: topOf(shown, blastExposures) })
255
- ] }),
256
- blast.reachable.slice(0, ROW_LIMIT3).map((path, i) => {
257
- const desc = path.description.split(" \u2014 ")[0].split(/—|--/)[0].trim();
258
- return /* @__PURE__ */ jsxs7(Box7, { children: [
259
- /* @__PURE__ */ jsx7(Box7, { width: 3, children: /* @__PURE__ */ jsx7(Text7, { color: "red", children: "\u2717" }) }),
260
- /* @__PURE__ */ jsx7(Box7, { width: 36, children: /* @__PURE__ */ jsx7(Text7, { children: path.label }) }),
261
- /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: desc })
262
- ] }, i);
263
- }),
264
- blast.envFindings.slice(0, 3).map((env, i) => /* @__PURE__ */ jsxs7(Box7, { children: [
265
- /* @__PURE__ */ jsx7(Box7, { width: 3, children: /* @__PURE__ */ jsx7(Text7, { color: "yellow", children: "\u26A0" }) }),
266
- /* @__PURE__ */ jsx7(Text7, { children: env.key }),
267
- /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: ` (${env.patternName})` })
268
- ] }, `env-${i}`)),
269
- /* @__PURE__ */ jsxs7(Box7, { children: [
270
- /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: "\u2192 " }),
271
- /* @__PURE__ */ jsx7(Text7, { bold: true, color: "cyan", children: "project-jail" }),
272
- /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: " shield blocks agent reads of these paths" })
273
- ] })
274
- ] });
275
- }
276
-
277
- // src/cli/render/ink/panels/ReviewQueuePanel.tsx
278
- import { Box as Box8, Text as Text8 } from "ink";
279
- import { jsx as jsx8, jsxs as jsxs8 } from "react/jsx-runtime";
280
- var ROW_LIMIT4 = 5;
281
- function ReviewQueuePanel({
282
- summary,
283
- width,
284
- enabledShields = []
285
- }) {
286
- const enabled = new Set(enabledShields);
287
- const rules = topRulesByVerdict(summary.sections, "review", ROW_LIMIT4);
288
- if (rules.length === 0) return null;
289
- return /* @__PURE__ */ jsxs8(Box8, { borderStyle: "round", borderColor: "gray", paddingX: 1, flexDirection: "column", width, children: [
290
- /* @__PURE__ */ jsx8(Text8, { bold: true, children: "REVIEW QUEUE" }),
291
- rules.map((rule, i) => /* @__PURE__ */ jsxs8(Box8, { children: [
292
- /* @__PURE__ */ jsx8(Box8, { width: 22, children: /* @__PURE__ */ jsx8(Text8, { wrap: "truncate-end", children: rule.name }) }),
293
- /* @__PURE__ */ jsx8(Box8, { width: 6, children: /* @__PURE__ */ jsx8(Text8, { bold: true, children: `\xD7${rule.count}` }) }),
294
- /* @__PURE__ */ jsx8(Text8, { dimColor: true, wrap: "truncate-end", children: originForRule(rule.name, summary.sections, enabled) })
295
- ] }, i)),
296
- /* @__PURE__ */ jsxs8(Box8, { children: [
297
- /* @__PURE__ */ jsx8(Text8, { dimColor: true, wrap: "truncate-end", children: "\u2192 " }),
298
- /* @__PURE__ */ jsx8(Text8, { bold: true, color: "cyan", wrap: "truncate-end", children: "runtime approval" })
299
- ] })
300
- ] });
301
- }
302
-
303
- // src/cli/render/ink/panels/AgentLoopsPanel.tsx
304
- import { Box as Box9, Text as Text9 } from "ink";
305
- import { Fragment, jsx as jsx9, jsxs as jsxs9 } from "react/jsx-runtime";
306
- var TOOL_ROW_LIMIT = 5;
307
- var STUCK_ROW_LIMIT = 1;
308
- function fmtNum2(n) {
309
- return n.toLocaleString();
310
- }
311
- function trimRight(s, width) {
312
- if (s.length <= width) return s;
313
- return "\u2026" + s.slice(s.length - (width - 1));
314
- }
315
- function AgentLoopsPanel({ loopFindings, width }) {
316
- if (loopFindings.length === 0) return null;
317
- const byTool = /* @__PURE__ */ new Map();
318
- let totalRepeats = 0;
319
- for (const f of loopFindings) {
320
- const repeats = Math.max(0, f.count - 1);
321
- byTool.set(f.toolName, (byTool.get(f.toolName) ?? 0) + repeats);
322
- totalRepeats += repeats;
323
- }
324
- const toolEntries = [...byTool.entries()].sort((a, b) => b[1] - a[1]).slice(0, TOOL_ROW_LIMIT);
325
- const topStuck = [...loopFindings].sort((a, b) => b.count - a.count).slice(0, STUCK_ROW_LIMIT);
326
- return /* @__PURE__ */ jsxs9(Box9, { borderStyle: "round", borderColor: "gray", paddingX: 1, flexDirection: "column", width, children: [
327
- /* @__PURE__ */ jsxs9(Text9, { bold: true, children: [
328
- "AGENT LOOPS",
329
- /* @__PURE__ */ jsx9(Text9, { dimColor: true, children: topOf(toolEntries.length, byTool.size) })
330
- ] }),
331
- toolEntries.map(([tool, repeats]) => {
332
- const pct = totalRepeats > 0 ? Math.round(repeats / totalRepeats * 100) : 0;
333
- return /* @__PURE__ */ jsxs9(Box9, { children: [
334
- /* @__PURE__ */ jsx9(Box9, { width: 10, children: /* @__PURE__ */ jsx9(Text9, { bold: true, children: tool }) }),
335
- /* @__PURE__ */ jsx9(Box9, { width: 14, children: /* @__PURE__ */ jsx9(Text9, { children: `\xD7${fmtNum2(repeats)}` }) }),
336
- /* @__PURE__ */ jsx9(Text9, { dimColor: true, children: `${pct}%` })
337
- ] }, tool);
338
- }),
339
- topStuck.length > 0 ? /* @__PURE__ */ jsxs9(Fragment, { children: [
340
- /* @__PURE__ */ jsx9(Text9, { dimColor: true, children: "Top stuck:" }),
341
- topStuck.map((f, i) => {
342
- const target = trimRight(f.commandPreview || f.toolName, 32);
343
- return /* @__PURE__ */ jsxs9(Box9, { children: [
344
- /* @__PURE__ */ jsx9(Box9, { width: 8, children: /* @__PURE__ */ jsx9(Text9, { bold: true, children: `\xD7${fmtNum2(f.count)}` }) }),
345
- /* @__PURE__ */ jsx9(Text9, { dimColor: true, wrap: "truncate-end", children: target })
346
- ] }, `stuck-${i}`);
45
+ // src/config-schema.ts
46
+ import { z } from "zod";
47
+ var noNewlines = z.string().refine((s) => !s.includes("\n") && !s.includes("\r"), {
48
+ message: "Value must not contain literal newline characters (use \\n instead)"
49
+ });
50
+ var SmartConditionSchema = z.object({
51
+ field: z.string().min(1, "Condition field must not be empty"),
52
+ op: z.enum(
53
+ [
54
+ "matches",
55
+ "notMatches",
56
+ "contains",
57
+ "notContains",
58
+ "exists",
59
+ "notExists",
60
+ "matchesGlob",
61
+ "notMatchesGlob"
62
+ ],
63
+ {
64
+ errorMap: () => ({
65
+ message: "op must be one of: matches, notMatches, contains, notContains, exists, notExists, matchesGlob, notMatchesGlob"
347
66
  })
348
- ] }) : null,
349
- /* @__PURE__ */ jsxs9(Box9, { children: [
350
- /* @__PURE__ */ jsx9(Text9, { dimColor: true, wrap: "truncate-end", children: "\u2192 " }),
351
- /* @__PURE__ */ jsx9(Text9, { bold: true, color: "cyan", wrap: "truncate-end", children: "live loop-detector" })
352
- ] })
353
- ] });
354
- }
355
-
356
- // src/cli/render/ink/panels/ShieldsPanel.tsx
357
- import { Box as Box10, Text as Text10 } from "ink";
67
+ }
68
+ ),
69
+ value: z.string().optional(),
70
+ flags: z.string().optional()
71
+ }).refine(
72
+ (c) => {
73
+ if (c.op === "matchesGlob" || c.op === "notMatchesGlob") return c.value !== void 0;
74
+ return true;
75
+ },
76
+ { message: "matchesGlob and notMatchesGlob conditions require a value field" }
77
+ );
78
+ var SmartRuleSchema = z.object({
79
+ name: z.string().optional(),
80
+ tool: z.string().min(1, "Smart rule tool must not be empty"),
81
+ conditions: z.array(SmartConditionSchema).min(1, "Smart rule must have at least one condition"),
82
+ conditionMode: z.enum(["all", "any"]).optional(),
83
+ verdict: z.enum(["allow", "review", "block"], {
84
+ errorMap: () => ({ message: "verdict must be one of: allow, review, block" })
85
+ }),
86
+ reason: z.string().optional(),
87
+ description: z.string().optional(),
88
+ // Unknown predicate names are filtered out rather than failing the whole rule.
89
+ // Failing the whole z.array() would cause sanitizeConfig to drop the entire
90
+ // `policy` top-level key, silently disabling ALL smart rules in the config.
91
+ dependsOnState: z.array(z.string()).transform(
92
+ (arr) => arr.filter(
93
+ (p) => p === "no_test_passed_since_last_edit"
94
+ )
95
+ ).optional(),
96
+ recoveryCommand: z.string().optional()
97
+ });
98
+ var ConfigFileSchema = z.object({
99
+ version: z.string().optional(),
100
+ settings: z.object({
101
+ mode: z.enum(["standard", "strict", "audit", "observe"]).optional(),
102
+ autoStartDaemon: z.boolean().optional(),
103
+ // enableUndo / policy.snapshot: removed with the undo feature. The
104
+ // schema strips undeclared keys silently (plain zod object — no
105
+ // .strict(), no .passthrough()), so a config still carrying them stays
106
+ // valid and simply loses them. Keeping the fields would instead make
107
+ // `node9_config_get` advertise a removed feature as a live knob.
108
+ enableHookLogDebug: z.boolean().optional(),
109
+ approvalTimeoutMs: z.number().nonnegative().optional(),
110
+ approvalTimeoutSeconds: z.number().nonnegative().optional(),
111
+ flightRecorder: z.boolean().optional(),
112
+ approvers: z.object({
113
+ native: z.boolean().optional(),
114
+ browser: z.boolean().optional(),
115
+ cloud: z.boolean().optional(),
116
+ terminal: z.boolean().optional()
117
+ }).optional(),
118
+ environment: z.string().optional(),
119
+ slackEnabled: z.boolean().optional(),
120
+ enableTrustSessions: z.boolean().optional(),
121
+ allowGlobalPause: z.boolean().optional(),
122
+ auditHashArgs: z.boolean().optional(),
123
+ agentPolicy: z.enum(["require_approval", "block_on_rules"]).optional(),
124
+ // Where a `review` verdict's prompt is rendered: 'ask' = the agent's own
125
+ // inline approve/deny prompt (Claude Code / GitHub Copilot); 'approver' =
126
+ // node9's own approver (terminal/native/cloud). Unset → default ASK for
127
+ // ask-capable agents (v2: cloud no longer disables inline — the outcome
128
+ // ships to the dashboard as audit; admins force routing via managed
129
+ // reviewChannel, which outranks the local --ask flag).
130
+ reviewChannel: z.enum(["ask", "approver"]).optional(),
131
+ // When true, agents may call WEAKENING node9 MCP tools (shield_disable,
132
+ // approver_set). Default (unset/false): those tools refuse over MCP — a human
133
+ // must run them from the CLI. node9's threat model is the agent itself.
134
+ mcpAllowWeakening: z.boolean().optional(),
135
+ // Auto-wire reconciler (P3 2.6): auto-wrap new ungoverned MCP servers vs
136
+ // nudge-only (default), and the scan cadence in minutes.
137
+ mcpAutoWrap: z.boolean().optional(),
138
+ mcpReconcileIntervalMinutes: z.number().positive().optional(),
139
+ mcpStaleAfterDays: z.number().min(0).optional(),
140
+ cloudSyncIntervalHours: z.number().positive().optional(),
141
+ // Seconds-granular override for the cloud policy sync cadence. Wins over
142
+ // cloudSyncIntervalHours when set. Lets you opt into fast apply (e.g. 20)
143
+ // for an incident; clamped to a 15s floor so it can't hammer the API.
144
+ // Unset → falls back to hours, then the 5h default.
145
+ cloudSyncIntervalSeconds: z.number().positive().optional(),
146
+ // Outbox shipper (audit.log → SaaS batch ingest). enabled defaults
147
+ // to true; set false to fall back to local-only auditing.
148
+ shipper: z.object({
149
+ enabled: z.boolean().optional(),
150
+ intervalSeconds: z.number().min(5).optional()
151
+ }).optional()
152
+ }).optional(),
153
+ policy: z.object({
154
+ sandboxPaths: z.array(z.string()).optional(),
155
+ dangerousWords: z.array(noNewlines).optional(),
156
+ ignoredTools: z.array(z.string()).optional(),
157
+ toolInspection: z.record(z.string()).optional(),
158
+ smartRules: z.array(SmartRuleSchema).optional(),
159
+ dlp: z.object({
160
+ enabled: z.boolean().optional(),
161
+ scanIgnoredTools: z.boolean().optional(),
162
+ pii: z.enum(["off", "block"]).optional(),
163
+ reviewAction: z.enum(["review", "block"]).optional()
164
+ }).optional(),
165
+ // Command-checks governance. Class-B keys (evalDynamic, pipeChainHigh)
166
+ // deliberately exclude 'off' — tighten-only.
167
+ commandChecks: z.object({
168
+ inlineExec: z.enum(["off", "review", "block"]).optional(),
169
+ rmAdvisory: z.enum(["off", "review", "block"]).optional(),
170
+ chmod: z.enum(["off", "review", "block"]).optional(),
171
+ sqlDdl: z.enum(["off", "review", "block"]).optional(),
172
+ evalDynamic: z.enum(["review", "block"]).optional(),
173
+ pipeChainHigh: z.enum(["review", "block"]).optional()
174
+ }).optional(),
175
+ egress: z.object({
176
+ enabled: z.boolean().optional(),
177
+ mode: z.enum(["off", "review", "block"]).optional(),
178
+ allow: z.array(z.string()).optional(),
179
+ deny: z.array(z.string()).optional(),
180
+ allowPrivate: z.boolean().optional()
181
+ }).optional(),
182
+ loopDetection: z.object({
183
+ enabled: z.boolean().optional(),
184
+ threshold: z.number().min(2).optional(),
185
+ windowSeconds: z.number().min(10).optional()
186
+ }).optional(),
187
+ injectionScan: z.object({
188
+ enabled: z.boolean().optional(),
189
+ minConfidence: z.enum(["medium", "high"]).optional(),
190
+ allow: z.array(z.string()).optional()
191
+ }).optional(),
192
+ skillPinning: z.object({
193
+ enabled: z.boolean().optional(),
194
+ mode: z.enum(["warn", "block"]).optional(),
195
+ roots: z.array(z.string()).optional()
196
+ }).optional()
197
+ }).optional(),
198
+ environments: z.record(z.object({ requireApproval: z.boolean().optional() })).optional()
199
+ }).strict({ message: "Config contains unknown top-level keys" });
358
200
 
359
- // src/protection.ts
360
- var PROTECTIVE_SHIELD_DISCOUNTS = {
361
- "project-jail": 0.7
362
- };
201
+ // src/shields.ts
202
+ import fs from "fs";
203
+ import path from "path";
204
+ import os from "os";
363
205
 
364
206
  // packages/policy-engine/dist/index.mjs
365
207
  import safeRegex from "safe-regex2";
@@ -1637,40 +1479,425 @@ var redis_default = {
1637
1479
  verdict: "review",
1638
1480
  reason: "Wildcard key deletion requires human approval (Redis shield)"
1639
1481
  }
1640
- ],
1641
- dangerousWords: ["FLUSHALL", "FLUSHDB"]
1642
- };
1643
- var BUILTIN_SHIELDS = {
1644
- [aws_default.name]: aws_default,
1645
- [bash_safe_default.name]: bash_safe_default,
1646
- [docker_default.name]: docker_default,
1647
- [filesystem_default.name]: filesystem_default,
1648
- [github_default.name]: github_default,
1649
- [k8s_default.name]: k8s_default,
1650
- [mongodb_default.name]: mongodb_default,
1651
- [postgres_default.name]: postgres_default,
1652
- [project_jail_default.name]: project_jail_default,
1653
- [redis_default.name]: redis_default
1654
- };
1655
- function assertBuiltinShieldRegexesAreSafe() {
1656
- for (const shield of Object.values(BUILTIN_SHIELDS)) {
1657
- for (const rule of shield.smartRules) {
1658
- const conditions = rule.conditions ?? [];
1659
- for (const cond of conditions) {
1660
- if (cond.op !== "matches" && cond.op !== "notMatches") continue;
1661
- const pattern = cond.value;
1662
- if (!pattern) continue;
1663
- if (!safeRegex3(pattern)) {
1664
- throw new Error(
1665
- `[node9 engine] Shield '${shield.name}' rule '${rule.name ?? rule.tool}' has unsafe regex: ${pattern}`
1666
- );
1667
- }
1482
+ ],
1483
+ dangerousWords: ["FLUSHALL", "FLUSHDB"]
1484
+ };
1485
+ function validateShieldDefinition(raw) {
1486
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
1487
+ return { error: "Shield file is not an object" };
1488
+ }
1489
+ const r = raw;
1490
+ if (typeof r.name !== "string" || !r.name) return { error: "Shield file missing 'name'" };
1491
+ if (typeof r.description !== "string") return { error: "Shield file missing 'description'" };
1492
+ if (!Array.isArray(r.aliases)) return { error: "Shield file missing 'aliases' array" };
1493
+ if (!Array.isArray(r.smartRules)) return { error: "Shield file missing 'smartRules' array" };
1494
+ if (!Array.isArray(r.dangerousWords))
1495
+ return { error: "Shield file missing 'dangerousWords' array" };
1496
+ return { ok: r };
1497
+ }
1498
+ var BUILTIN_SHIELDS = {
1499
+ [aws_default.name]: aws_default,
1500
+ [bash_safe_default.name]: bash_safe_default,
1501
+ [docker_default.name]: docker_default,
1502
+ [filesystem_default.name]: filesystem_default,
1503
+ [github_default.name]: github_default,
1504
+ [k8s_default.name]: k8s_default,
1505
+ [mongodb_default.name]: mongodb_default,
1506
+ [postgres_default.name]: postgres_default,
1507
+ [project_jail_default.name]: project_jail_default,
1508
+ [redis_default.name]: redis_default
1509
+ };
1510
+ function assertBuiltinShieldRegexesAreSafe() {
1511
+ for (const shield of Object.values(BUILTIN_SHIELDS)) {
1512
+ for (const rule of shield.smartRules) {
1513
+ const conditions = rule.conditions ?? [];
1514
+ for (const cond of conditions) {
1515
+ if (cond.op !== "matches" && cond.op !== "notMatches") continue;
1516
+ const pattern = cond.value;
1517
+ if (!pattern) continue;
1518
+ if (!safeRegex3(pattern)) {
1519
+ throw new Error(
1520
+ `[node9 engine] Shield '${shield.name}' rule '${rule.name ?? rule.tool}' has unsafe regex: ${pattern}`
1521
+ );
1522
+ }
1523
+ }
1524
+ }
1525
+ }
1526
+ }
1527
+ assertBuiltinShieldRegexesAreSafe();
1528
+ var LONG_OUTPUT_THRESHOLD_BYTES = 100 * 1024;
1529
+
1530
+ // src/shields.ts
1531
+ var USER_SHIELDS_DIR = path.join(os.homedir(), ".node9", "shields");
1532
+ function loadUserShields() {
1533
+ const result = {};
1534
+ let entries;
1535
+ try {
1536
+ entries = fs.readdirSync(USER_SHIELDS_DIR).filter((f) => f.endsWith(".json"));
1537
+ } catch (err) {
1538
+ if (err.code !== "ENOENT") {
1539
+ process.stderr.write(
1540
+ `[node9] Could not read user shields dir ${USER_SHIELDS_DIR}: ${String(err)}
1541
+ `
1542
+ );
1543
+ }
1544
+ return result;
1545
+ }
1546
+ for (const file of entries) {
1547
+ const filePath = path.join(USER_SHIELDS_DIR, file);
1548
+ try {
1549
+ const raw = JSON.parse(fs.readFileSync(filePath, "utf-8"));
1550
+ const v = validateShieldDefinition(raw);
1551
+ if ("error" in v) {
1552
+ process.stderr.write(`[node9] ${v.error}: ${filePath}
1553
+ `);
1554
+ continue;
1555
+ }
1556
+ result[v.ok.name] = v.ok;
1557
+ } catch (err) {
1558
+ process.stderr.write(`[node9] Failed to load user shield ${file}: ${String(err)}
1559
+ `);
1560
+ }
1561
+ }
1562
+ return result;
1563
+ }
1564
+ function buildSHIELDS() {
1565
+ return { ...BUILTIN_SHIELDS, ...loadUserShields() };
1566
+ }
1567
+ var SHIELDS = buildSHIELDS();
1568
+ var SHIELDS_STATE_FILE = path.join(os.homedir(), ".node9", "shields.json");
1569
+
1570
+ // src/config/index.ts
1571
+ var CACHE_LOG_REARM_MS = 5 * 60 * 1e3;
1572
+
1573
+ // src/cli/render/ink/panels/cost-note.ts
1574
+ var COST_LABEL = "COST \xB7 based on API value";
1575
+ function costNoteLines(keyed) {
1576
+ return keyed ? ["Your plan and exact spend:", "dashboard \u2192 Report"] : ["To see your plan or exact spend, connect:", "node9 login (for free)"];
1577
+ }
1578
+
1579
+ // src/cli/render/ink/panels/CostPanel.tsx
1580
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
1581
+ var LABEL_W = 16;
1582
+ function CostPanel({ summary, width, keyed }) {
1583
+ const total = summary.stats.totalCostUSD;
1584
+ return /* @__PURE__ */ jsxs3(Box3, { borderStyle: "round", borderColor: "gray", paddingX: 1, flexDirection: "column", width, children: [
1585
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: COST_LABEL }),
1586
+ /* @__PURE__ */ jsxs3(Box3, { children: [
1587
+ /* @__PURE__ */ jsx3(Box3, { width: LABEL_W, children: /* @__PURE__ */ jsx3(Text3, { children: "Total" }) }),
1588
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: formatCost(total) })
1589
+ ] }),
1590
+ summary.byAgent.map((agent) => /* @__PURE__ */ jsxs3(Box3, { children: [
1591
+ /* @__PURE__ */ jsx3(Box3, { width: LABEL_W, children: /* @__PURE__ */ jsx3(Text3, { children: agent.label }) }),
1592
+ /* @__PURE__ */ jsx3(Text3, { children: formatCost(agent.costUSD) })
1593
+ ] }, agent.id)),
1594
+ summary.loopWastedUSD > 0 ? /* @__PURE__ */ jsxs3(Box3, { children: [
1595
+ /* @__PURE__ */ jsx3(Box3, { width: LABEL_W, children: /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "Wasted on loops" }) }),
1596
+ /* @__PURE__ */ jsx3(Text3, { color: "yellow", children: "~" + formatCost(summary.loopWastedUSD) })
1597
+ ] }) : null,
1598
+ costNoteLines(keyed ?? false).map((line) => /* @__PURE__ */ jsx3(Text3, { dimColor: true, wrap: "truncate-end", children: line }, line))
1599
+ ] });
1600
+ }
1601
+
1602
+ // src/cli/render/ink/panels/ActivityPanel.tsx
1603
+ import { Box as Box4, Text as Text4 } from "ink";
1604
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
1605
+ var LABEL_W2 = 16;
1606
+ function fmtNum(n) {
1607
+ return n.toLocaleString();
1608
+ }
1609
+ function countMcpFindings(scan) {
1610
+ let n = 0;
1611
+ for (const f of scan.findings) {
1612
+ if (f.toolName.startsWith("mcp__")) n++;
1613
+ }
1614
+ return n;
1615
+ }
1616
+ function ActivityPanel({ summary, scan, width }) {
1617
+ const { sessions, totalToolCalls, bashCalls, totalCostUSD } = summary.stats;
1618
+ const perSession = sessions > 0 ? totalCostUSD / sessions : 0;
1619
+ const toolsPerSession = sessions > 0 ? Math.round(totalToolCalls / sessions) : 0;
1620
+ const mcpCount = countMcpFindings(scan);
1621
+ const topAgent = summary.byAgent.slice().sort((a, b) => b.sessions - a.sessions)[0];
1622
+ return /* @__PURE__ */ jsxs4(Box4, { borderStyle: "round", borderColor: "gray", paddingX: 1, flexDirection: "column", width, children: [
1623
+ /* @__PURE__ */ jsx4(Text4, { bold: true, children: "ACTIVITY" }),
1624
+ /* @__PURE__ */ jsxs4(Box4, { children: [
1625
+ /* @__PURE__ */ jsx4(Box4, { width: LABEL_W2, children: /* @__PURE__ */ jsx4(Text4, { children: "Sessions" }) }),
1626
+ /* @__PURE__ */ jsx4(Text4, { bold: true, children: fmtNum(sessions) })
1627
+ ] }),
1628
+ /* @__PURE__ */ jsxs4(Box4, { children: [
1629
+ /* @__PURE__ */ jsx4(Box4, { width: LABEL_W2, children: /* @__PURE__ */ jsx4(Text4, { children: "Tools" }) }),
1630
+ /* @__PURE__ */ jsx4(Text4, { children: fmtNum(totalToolCalls) })
1631
+ ] }),
1632
+ /* @__PURE__ */ jsxs4(Box4, { children: [
1633
+ /* @__PURE__ */ jsx4(Box4, { width: LABEL_W2, children: /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: " Shell" }) }),
1634
+ /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: fmtNum(bashCalls) })
1635
+ ] }),
1636
+ mcpCount > 0 ? /* @__PURE__ */ jsxs4(Box4, { children: [
1637
+ /* @__PURE__ */ jsx4(Box4, { width: LABEL_W2, children: /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: " MCP" }) }),
1638
+ /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: fmtNum(mcpCount) })
1639
+ ] }) : null,
1640
+ /* @__PURE__ */ jsxs4(Box4, { children: [
1641
+ /* @__PURE__ */ jsx4(Box4, { width: LABEL_W2, children: /* @__PURE__ */ jsx4(Text4, { children: "Tools / session" }) }),
1642
+ /* @__PURE__ */ jsx4(Text4, { children: "~" + fmtNum(toolsPerSession) })
1643
+ ] }),
1644
+ /* @__PURE__ */ jsxs4(Box4, { children: [
1645
+ /* @__PURE__ */ jsx4(Box4, { width: LABEL_W2, children: /* @__PURE__ */ jsx4(Text4, { children: "Cost / session" }) }),
1646
+ /* @__PURE__ */ jsx4(Text4, { children: "~$" + Math.round(perSession).toLocaleString() })
1647
+ ] }),
1648
+ topAgent ? /* @__PURE__ */ jsxs4(Box4, { children: [
1649
+ /* @__PURE__ */ jsx4(Box4, { width: LABEL_W2, children: /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "Top agent" }) }),
1650
+ /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: `${topAgent.label} (${fmtNum(topAgent.sessions)})` })
1651
+ ] }) : null
1652
+ ] });
1653
+ }
1654
+
1655
+ // src/cli/render/ink/panels/LeaksPanel.tsx
1656
+ import { Box as Box5, Text as Text5 } from "ink";
1657
+
1658
+ // src/cli/render/scan-derive.ts
1659
+ import chalk2 from "chalk";
1660
+ import stringWidth from "string-width";
1661
+ function topRulesByVerdict(sections, verdict, n) {
1662
+ const matched = [];
1663
+ for (const section of sections) {
1664
+ for (const rule of section.rules) {
1665
+ const matches = verdict === "block" ? rule.verdict === "block" : rule.verdict !== "block";
1666
+ if (matches) matched.push({ name: rule.name, count: rule.findings.length });
1667
+ }
1668
+ }
1669
+ return matched.sort((a, b) => b.count - a.count).slice(0, n);
1670
+ }
1671
+ function computeLoopWaste(loops, totalToolCalls) {
1672
+ const wastedCalls = loops.reduce((s, l) => s + Math.max(0, l.count - 1), 0);
1673
+ const wastePct = totalToolCalls > 0 ? Math.round(wastedCalls / totalToolCalls * 100) : 0;
1674
+ return { wastedCalls, wastePct };
1675
+ }
1676
+ function rollupByShield(sections, topRulesPerShield = 3) {
1677
+ const out = [];
1678
+ for (const section of sections) {
1679
+ if (section.sourceType !== "shield") continue;
1680
+ if (!section.shieldKey) continue;
1681
+ const totalCatches = section.blockedCount + section.reviewCount;
1682
+ const topRuleLabels = [...section.rules].sort((a, b) => b.findings.length - a.findings.length).slice(0, topRulesPerShield).map((r) => r.findings.length > 1 ? `${r.name} \xD7${r.findings.length}` : r.name);
1683
+ out.push({
1684
+ shieldName: section.shieldKey,
1685
+ totalCatches,
1686
+ blockCatches: section.blockedCount,
1687
+ reviewCatches: section.reviewCount,
1688
+ topRuleLabels
1689
+ });
1690
+ }
1691
+ return out.sort((a, b) => b.totalCatches - a.totalCatches);
1692
+ }
1693
+ function originForRule(ruleName, sections, enabled) {
1694
+ for (const section of sections) {
1695
+ if (section.rules.some((r) => r.name === ruleName)) {
1696
+ if (section.sourceType === "default") return "default";
1697
+ if (section.sourceType === "shield") {
1698
+ const key = section.shieldKey ?? section.id;
1699
+ return enabled.has(key) ? `\u2713 shield:${key}` : `needs shield:${key}`;
1668
1700
  }
1669
1701
  }
1670
1702
  }
1703
+ return "";
1671
1704
  }
1672
- assertBuiltinShieldRegexesAreSafe();
1673
- var LONG_OUTPUT_THRESHOLD_BYTES = 100 * 1024;
1705
+ function relativeDate(timestamp, now = /* @__PURE__ */ new Date()) {
1706
+ const t = new Date(timestamp).getTime();
1707
+ if (Number.isNaN(t)) return "?";
1708
+ const days = Math.floor((now.getTime() - t) / 864e5);
1709
+ if (days < 1) return "today";
1710
+ if (days > 90) return "90d+";
1711
+ return `${days}d`;
1712
+ }
1713
+
1714
+ // src/cli/render/ink/panels/title-count.ts
1715
+ function topOf(shown, total) {
1716
+ return total > shown ? ` \xB7 top ${shown} of ${total}` : "";
1717
+ }
1718
+
1719
+ // src/cli/render/ink/panels/LeaksPanel.tsx
1720
+ import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
1721
+ var ROW_LIMIT = 4;
1722
+ function LeaksPanel({ summary, width, now = /* @__PURE__ */ new Date() }) {
1723
+ const leaks = summary.leaks;
1724
+ if (leaks.length === 0) return null;
1725
+ return /* @__PURE__ */ jsxs5(Box5, { borderStyle: "round", borderColor: "red", paddingX: 1, flexDirection: "column", width, children: [
1726
+ /* @__PURE__ */ jsxs5(Text5, { bold: true, color: "red", children: [
1727
+ "CREDENTIAL LEAKS",
1728
+ /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: topOf(Math.min(leaks.length, ROW_LIMIT), leaks.length) })
1729
+ ] }),
1730
+ leaks.slice(0, ROW_LIMIT).map((leak, i) => /* @__PURE__ */ jsxs5(Box5, { children: [
1731
+ /* @__PURE__ */ jsx5(Box5, { width: 5, children: /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: relativeDate(leak.timestamp, now).padStart(4) }) }),
1732
+ /* @__PURE__ */ jsx5(Box5, { width: 16, children: /* @__PURE__ */ jsx5(Text5, { color: "red", bold: true, wrap: "truncate-end", children: leak.patternName }) }),
1733
+ /* @__PURE__ */ jsx5(Box5, { width: 15, children: /* @__PURE__ */ jsx5(Text5, { dimColor: true, wrap: "truncate-end", children: `[${leak.toolName}]` }) }),
1734
+ /* @__PURE__ */ jsx5(Text5, { dimColor: true, wrap: "truncate-end", children: leak.agent })
1735
+ ] }, i)),
1736
+ /* @__PURE__ */ jsxs5(Box5, { children: [
1737
+ /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "\u2192 " }),
1738
+ /* @__PURE__ */ jsx5(Text5, { bold: true, color: "cyan", children: "DLP" }),
1739
+ /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: " \xB7 " }),
1740
+ /* @__PURE__ */ jsx5(Text5, { bold: true, color: "cyan", children: "node9 mask" }),
1741
+ /* @__PURE__ */ jsx5(Text5, { dimColor: true, wrap: "truncate-end", children: " (runtime + cleanup)" })
1742
+ ] })
1743
+ ] });
1744
+ }
1745
+
1746
+ // src/cli/render/ink/panels/BlockedPanel.tsx
1747
+ import { Box as Box6, Text as Text6 } from "ink";
1748
+ import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
1749
+ var ROW_LIMIT2 = 12;
1750
+ function BlockedPanel({
1751
+ summary,
1752
+ width,
1753
+ enabledShields = []
1754
+ }) {
1755
+ const enabled = new Set(enabledShields);
1756
+ const allRules = topRulesByVerdict(summary.sections, "block", Number.MAX_SAFE_INTEGER);
1757
+ const rules = allRules.slice(0, ROW_LIMIT2);
1758
+ if (rules.length === 0) return null;
1759
+ const origins = rules.map((r) => originForRule(r.name, summary.sections, enabled));
1760
+ const anyNeeds = origins.some((o) => o.startsWith("needs shield:"));
1761
+ const footer = enabled.size === 0 ? "\u2192 install node9 + enable shields above" : anyNeeds ? "\u2192 \u2713 marks rules from enabled shields \xB7 enable the rest above" : "\u2192 \u2713 all these rules come from enabled shields";
1762
+ return /* @__PURE__ */ jsxs6(Box6, { borderStyle: "round", borderColor: "red", paddingX: 1, flexDirection: "column", width, children: [
1763
+ /* @__PURE__ */ jsxs6(Text6, { bold: true, color: "red", children: [
1764
+ "WOULD HAVE BLOCKED",
1765
+ /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: topOf(rules.length, allRules.length) })
1766
+ ] }),
1767
+ rules.map((rule, i) => /* @__PURE__ */ jsxs6(Box6, { children: [
1768
+ /* @__PURE__ */ jsx6(Box6, { width: 3, children: /* @__PURE__ */ jsx6(Text6, { color: "red", children: "\u2717" }) }),
1769
+ /* @__PURE__ */ jsx6(Box6, { width: 24, children: /* @__PURE__ */ jsx6(Text6, { bold: true, wrap: "truncate-end", children: rule.name }) }),
1770
+ /* @__PURE__ */ jsx6(Box6, { width: 6, children: /* @__PURE__ */ jsx6(Text6, { bold: true, children: `\xD7${rule.count}` }) }),
1771
+ /* @__PURE__ */ jsx6(Text6, { dimColor: true, wrap: "truncate-end", children: originForRule(rule.name, summary.sections, enabled) })
1772
+ ] }, i)),
1773
+ /* @__PURE__ */ jsx6(Box6, { children: /* @__PURE__ */ jsx6(Text6, { dimColor: true, wrap: "truncate-end", children: footer }) })
1774
+ ] });
1775
+ }
1776
+
1777
+ // src/cli/render/ink/panels/BlastRadiusPanel.tsx
1778
+ import { Box as Box7, Text as Text7 } from "ink";
1779
+ import { jsx as jsx7, jsxs as jsxs7 } from "react/jsx-runtime";
1780
+ var ROW_LIMIT3 = 4;
1781
+ var ENV_ROW_LIMIT = 3;
1782
+ function BlastRadiusPanel({
1783
+ blast,
1784
+ blastExposures,
1785
+ width
1786
+ }) {
1787
+ if (blastExposures === 0) return null;
1788
+ const shown = Math.min(blast.reachable.length, ROW_LIMIT3) + Math.min(blast.envFindings.length, ENV_ROW_LIMIT);
1789
+ return /* @__PURE__ */ jsxs7(Box7, { borderStyle: "round", borderColor: "yellow", paddingX: 1, flexDirection: "column", width, children: [
1790
+ /* @__PURE__ */ jsxs7(Text7, { bold: true, color: "yellow", children: [
1791
+ "BLAST RADIUS",
1792
+ /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: topOf(shown, blastExposures) })
1793
+ ] }),
1794
+ blast.reachable.slice(0, ROW_LIMIT3).map((path2, i) => {
1795
+ const desc = path2.description.split(" \u2014 ")[0].split(/—|--/)[0].trim();
1796
+ return /* @__PURE__ */ jsxs7(Box7, { children: [
1797
+ /* @__PURE__ */ jsx7(Box7, { width: 3, children: /* @__PURE__ */ jsx7(Text7, { color: "red", children: "\u2717" }) }),
1798
+ /* @__PURE__ */ jsx7(Box7, { width: 36, children: /* @__PURE__ */ jsx7(Text7, { children: path2.label }) }),
1799
+ /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: desc })
1800
+ ] }, i);
1801
+ }),
1802
+ blast.envFindings.slice(0, 3).map((env, i) => /* @__PURE__ */ jsxs7(Box7, { children: [
1803
+ /* @__PURE__ */ jsx7(Box7, { width: 3, children: /* @__PURE__ */ jsx7(Text7, { color: "yellow", children: "\u26A0" }) }),
1804
+ /* @__PURE__ */ jsx7(Text7, { children: env.key }),
1805
+ /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: ` (${env.patternName})` })
1806
+ ] }, `env-${i}`)),
1807
+ /* @__PURE__ */ jsxs7(Box7, { children: [
1808
+ /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: "\u2192 " }),
1809
+ /* @__PURE__ */ jsx7(Text7, { bold: true, color: "cyan", children: "project-jail" }),
1810
+ /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: " shield blocks agent reads of these paths" })
1811
+ ] })
1812
+ ] });
1813
+ }
1814
+
1815
+ // src/cli/render/ink/panels/ReviewQueuePanel.tsx
1816
+ import { Box as Box8, Text as Text8 } from "ink";
1817
+ import { jsx as jsx8, jsxs as jsxs8 } from "react/jsx-runtime";
1818
+ var ROW_LIMIT4 = 5;
1819
+ function ReviewQueuePanel({
1820
+ summary,
1821
+ width,
1822
+ enabledShields = []
1823
+ }) {
1824
+ const enabled = new Set(enabledShields);
1825
+ const rules = topRulesByVerdict(summary.sections, "review", ROW_LIMIT4);
1826
+ if (rules.length === 0) return null;
1827
+ return /* @__PURE__ */ jsxs8(Box8, { borderStyle: "round", borderColor: "gray", paddingX: 1, flexDirection: "column", width, children: [
1828
+ /* @__PURE__ */ jsx8(Text8, { bold: true, children: "REVIEW QUEUE" }),
1829
+ rules.map((rule, i) => /* @__PURE__ */ jsxs8(Box8, { children: [
1830
+ /* @__PURE__ */ jsx8(Box8, { width: 22, children: /* @__PURE__ */ jsx8(Text8, { wrap: "truncate-end", children: rule.name }) }),
1831
+ /* @__PURE__ */ jsx8(Box8, { width: 6, children: /* @__PURE__ */ jsx8(Text8, { bold: true, children: `\xD7${rule.count}` }) }),
1832
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, wrap: "truncate-end", children: originForRule(rule.name, summary.sections, enabled) })
1833
+ ] }, i)),
1834
+ /* @__PURE__ */ jsxs8(Box8, { children: [
1835
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, wrap: "truncate-end", children: "\u2192 " }),
1836
+ /* @__PURE__ */ jsx8(Text8, { bold: true, color: "cyan", wrap: "truncate-end", children: "runtime approval" })
1837
+ ] })
1838
+ ] });
1839
+ }
1840
+
1841
+ // src/cli/render/ink/panels/AgentLoopsPanel.tsx
1842
+ import { Box as Box9, Text as Text9 } from "ink";
1843
+ import { Fragment, jsx as jsx9, jsxs as jsxs9 } from "react/jsx-runtime";
1844
+ var TOOL_ROW_LIMIT = 5;
1845
+ var STUCK_ROW_LIMIT = 1;
1846
+ function fmtNum2(n) {
1847
+ return n.toLocaleString();
1848
+ }
1849
+ function trimRight(s, width) {
1850
+ if (s.length <= width) return s;
1851
+ return "\u2026" + s.slice(s.length - (width - 1));
1852
+ }
1853
+ function AgentLoopsPanel({ loopFindings, width }) {
1854
+ if (loopFindings.length === 0) return null;
1855
+ const byTool = /* @__PURE__ */ new Map();
1856
+ let totalRepeats = 0;
1857
+ for (const f of loopFindings) {
1858
+ const repeats = Math.max(0, f.count - 1);
1859
+ byTool.set(f.toolName, (byTool.get(f.toolName) ?? 0) + repeats);
1860
+ totalRepeats += repeats;
1861
+ }
1862
+ const toolEntries = [...byTool.entries()].sort((a, b) => b[1] - a[1]).slice(0, TOOL_ROW_LIMIT);
1863
+ const topStuck = [...loopFindings].sort((a, b) => b.count - a.count).slice(0, STUCK_ROW_LIMIT);
1864
+ return /* @__PURE__ */ jsxs9(Box9, { borderStyle: "round", borderColor: "gray", paddingX: 1, flexDirection: "column", width, children: [
1865
+ /* @__PURE__ */ jsxs9(Text9, { bold: true, children: [
1866
+ "AGENT LOOPS",
1867
+ /* @__PURE__ */ jsx9(Text9, { dimColor: true, children: topOf(toolEntries.length, byTool.size) })
1868
+ ] }),
1869
+ toolEntries.map(([tool, repeats]) => {
1870
+ const pct = totalRepeats > 0 ? Math.round(repeats / totalRepeats * 100) : 0;
1871
+ return /* @__PURE__ */ jsxs9(Box9, { children: [
1872
+ /* @__PURE__ */ jsx9(Box9, { width: 10, children: /* @__PURE__ */ jsx9(Text9, { bold: true, children: tool }) }),
1873
+ /* @__PURE__ */ jsx9(Box9, { width: 14, children: /* @__PURE__ */ jsx9(Text9, { children: `\xD7${fmtNum2(repeats)}` }) }),
1874
+ /* @__PURE__ */ jsx9(Text9, { dimColor: true, children: `${pct}%` })
1875
+ ] }, tool);
1876
+ }),
1877
+ topStuck.length > 0 ? /* @__PURE__ */ jsxs9(Fragment, { children: [
1878
+ /* @__PURE__ */ jsx9(Text9, { dimColor: true, children: "Top stuck:" }),
1879
+ topStuck.map((f, i) => {
1880
+ const target = trimRight(f.commandPreview || f.toolName, 32);
1881
+ return /* @__PURE__ */ jsxs9(Box9, { children: [
1882
+ /* @__PURE__ */ jsx9(Box9, { width: 8, children: /* @__PURE__ */ jsx9(Text9, { bold: true, children: `\xD7${fmtNum2(f.count)}` }) }),
1883
+ /* @__PURE__ */ jsx9(Text9, { dimColor: true, wrap: "truncate-end", children: target })
1884
+ ] }, `stuck-${i}`);
1885
+ })
1886
+ ] }) : null,
1887
+ /* @__PURE__ */ jsxs9(Box9, { children: [
1888
+ /* @__PURE__ */ jsx9(Text9, { dimColor: true, wrap: "truncate-end", children: "\u2192 " }),
1889
+ /* @__PURE__ */ jsx9(Text9, { bold: true, color: "cyan", wrap: "truncate-end", children: "live loop-detector" })
1890
+ ] })
1891
+ ] });
1892
+ }
1893
+
1894
+ // src/cli/render/ink/panels/ShieldsPanel.tsx
1895
+ import { Box as Box10, Text as Text10 } from "ink";
1896
+
1897
+ // src/protection.ts
1898
+ var PROTECTIVE_SHIELD_DISCOUNTS = {
1899
+ "project-jail": 0.7
1900
+ };
1674
1901
 
1675
1902
  // src/cli/render/ink/panels/ShieldsPanel.tsx
1676
1903
  import { jsx as jsx10, jsxs as jsxs10 } from "react/jsx-runtime";
@@ -1739,7 +1966,7 @@ function StaticScorecard({ input, rangeLabel, now }) {
1739
1966
  /* @__PURE__ */ jsx11(Header, { rangeLabel }),
1740
1967
  /* @__PURE__ */ jsx11(SeverityBand, { label: "Spend & activity", width }),
1741
1968
  /* @__PURE__ */ jsxs11(Box11, { flexDirection: "row", gap: 1, children: [
1742
- /* @__PURE__ */ jsx11(CostPanel, { summary, width: halfWidth }),
1969
+ /* @__PURE__ */ jsx11(CostPanel, { summary, width: halfWidth, keyed: input.keyed }),
1743
1970
  /* @__PURE__ */ jsx11(ActivityPanel, { summary, scan: input.scan, width: halfWidth })
1744
1971
  ] }),
1745
1972
  hasCritical ? /* @__PURE__ */ jsxs11(Fragment2, { children: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@node9/proxy",
3
- "version": "2.8.1",
3
+ "version": "2.8.3",
4
4
  "description": "The Sudo Command for AI Agents. Execution Security for Claude Code, Codex, Gemini, Cursor, Opencode, Pi, and any MCP server.",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",