@cirvix_ai/agent-control 0.1.3 → 0.2.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.
Files changed (81) hide show
  1. package/README.md +76 -17
  2. package/bin/cirvix.mjs +539 -85
  3. package/bin/escape-benchmark.mjs +67 -0
  4. package/package.json +36 -16
  5. package/src/adapters/base.mjs +150 -0
  6. package/src/adapters/claude-code.mjs +161 -0
  7. package/src/adapters/cline.mjs +107 -0
  8. package/src/adapters/codex.mjs +104 -0
  9. package/src/adapters/cursor.mjs +104 -0
  10. package/src/adapters/frameworks.mjs +110 -0
  11. package/src/adapters/gemini-cli.mjs +104 -0
  12. package/src/adapters/generic-mcp.mjs +101 -0
  13. package/src/adapters/index.mjs +209 -0
  14. package/src/adapters/roo-code.mjs +106 -0
  15. package/src/adapters/vscode.mjs +104 -0
  16. package/src/adapters/windsurf.mjs +107 -0
  17. package/src/commands/console.mjs +58 -0
  18. package/src/commands/demo.mjs +55 -124
  19. package/src/commands/doctor.mjs +235 -0
  20. package/src/commands/init.mjs +292 -30
  21. package/src/commands/interactive.mjs +690 -0
  22. package/src/commands/kill.mjs +74 -0
  23. package/src/commands/login.mjs +227 -0
  24. package/src/commands/onboard.mjs +52 -0
  25. package/src/commands/passport.mjs +149 -0
  26. package/src/commands/policy.mjs +10 -6
  27. package/src/commands/protect.mjs +293 -0
  28. package/src/commands/prove.mjs +209 -0
  29. package/src/commands/redteam.mjs +51 -0
  30. package/src/commands/scan.mjs +11 -9
  31. package/src/commands/shadow.mjs +62 -0
  32. package/src/commands/simulate.mjs +96 -0
  33. package/src/commands/status.mjs +122 -41
  34. package/src/commands/upgrade.mjs +11 -11
  35. package/src/commands/welcome.mjs +105 -0
  36. package/src/core/authority.mjs +909 -0
  37. package/src/core/baseline.mjs +97 -0
  38. package/src/core/config-store.mjs +280 -0
  39. package/src/core/cost.mjs +0 -0
  40. package/src/core/detect.mjs +4 -33
  41. package/src/core/entitlements.mjs +7 -24
  42. package/src/core/escape-benchmark.mjs +597 -0
  43. package/src/core/events.mjs +234 -0
  44. package/src/core/evidence.mjs +212 -0
  45. package/src/core/format.mjs +44 -18
  46. package/src/core/gateway.mjs +15 -211
  47. package/src/core/graph.mjs +270 -0
  48. package/src/core/guard.mjs +118 -4
  49. package/src/core/intent.mjs +166 -0
  50. package/src/core/journal.mjs +131 -40
  51. package/src/core/kill-switch.mjs +122 -0
  52. package/src/core/notices.mjs +22 -2
  53. package/src/core/packs.mjs +193 -0
  54. package/src/core/passport.mjs +555 -0
  55. package/src/core/pipeline.mjs +148 -6
  56. package/src/core/prompts.mjs +51 -0
  57. package/src/core/proof.mjs +440 -0
  58. package/src/core/redteam/index.mjs +185 -0
  59. package/src/core/referral.mjs +187 -0
  60. package/src/core/sandbox.mjs +139 -0
  61. package/src/core/session.mjs +172 -0
  62. package/src/core/shadow.mjs +95 -0
  63. package/src/core/theme.mjs +240 -0
  64. package/src/core/trifecta.mjs +321 -0
  65. package/src/core/ui/controller.mjs +192 -0
  66. package/src/core/ui/decisions.mjs +55 -0
  67. package/src/core/ui/index.mjs +49 -0
  68. package/src/core/ui/intercept.mjs +103 -0
  69. package/src/core/ui/live.mjs +51 -0
  70. package/src/core/ui/primitives.mjs +123 -0
  71. package/src/core/ui/theme.mjs +92 -0
  72. package/src/core/verified.mjs +108 -0
  73. package/src/core/windows.mjs +270 -0
  74. package/src/index.mjs +67 -0
  75. package/src/tui/activity.mjs +71 -0
  76. package/src/tui/app.mjs +292 -0
  77. package/src/tui/cards.mjs +235 -0
  78. package/src/tui/composer.mjs +88 -0
  79. package/src/tui/palette.mjs +48 -0
  80. package/src/tui/status.mjs +42 -0
  81. package/src/core/cinematic.mjs +0 -545
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Roo Code Adapter for CIRVIX AgentControl.
3
+ *
4
+ * Supports:
5
+ * - VS Code globalStorage roo_code_mcp_settings.json across Windows, macOS, Linux
6
+ * - Workspace .roomodes and .clinerules
7
+ */
8
+
9
+ import { homedir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { BaseAgentAdapter } from "./base.mjs";
12
+
13
+ export class RooCodeAdapter extends BaseAgentAdapter {
14
+ constructor() {
15
+ super({
16
+ id: "roo-code",
17
+ label: "Roo Code",
18
+ type: "extension",
19
+ mcpKey: "mcpServers",
20
+ });
21
+ }
22
+
23
+ async detect(cwd) {
24
+ const home = homedir();
25
+ const candidatePaths = [
26
+ join(cwd, ".roo", "mcp_settings.json"),
27
+ // Windows
28
+ join(home, "AppData", "Roaming", "Code", "User", "globalStorage", "rooveterinaryinc.roo-cline", "settings", "cline_mcp_settings.json"),
29
+ // macOS
30
+ join(home, "Library", "Application Support", "Code", "User", "globalStorage", "rooveterinaryinc.roo-cline", "settings", "cline_mcp_settings.json"),
31
+ // Linux
32
+ join(home, ".config", "Code", "User", "globalStorage", "rooveterinaryinc.roo-cline", "settings", "cline_mcp_settings.json"),
33
+ ];
34
+
35
+ const existingPaths = [];
36
+ const servers = {};
37
+ let configFound = null;
38
+
39
+ for (const p of candidatePaths) {
40
+ if (await this.fileExists(p)) {
41
+ existingPaths.push(p);
42
+ const data = await this.readJson(p);
43
+ if (data) {
44
+ if (!configFound) configFound = data;
45
+ Object.assign(servers, data[this.mcpKey] ?? {});
46
+ }
47
+ }
48
+ }
49
+
50
+ const hasModes = (await this.fileExists(join(cwd, ".roomodes"))) || (await this.fileExists(join(cwd, ".clinerules")));
51
+ const detected = existingPaths.length > 0 || hasModes;
52
+
53
+ const isIntegrated = Object.entries(servers).some(([name, def]) => this.isCirvixServer(name, def));
54
+
55
+ return {
56
+ detected,
57
+ paths: existingPaths,
58
+ targetConfigPath: existingPaths[0] ?? candidatePaths[1],
59
+ config: configFound,
60
+ servers,
61
+ serverCount: Object.keys(servers).length,
62
+ isIntegrated,
63
+ hasConfig: existingPaths.length > 0,
64
+ executable: null,
65
+ metadata: {
66
+ hasModes,
67
+ },
68
+ };
69
+ }
70
+
71
+ async generateIntegrationPlan(cwd, options = {}) {
72
+ const info = await this.detect(cwd);
73
+ const targetFile = info.targetConfigPath;
74
+ const currentServers = { ...info.servers };
75
+
76
+ const upstreams = {};
77
+ for (const [k, v] of Object.entries(currentServers)) {
78
+ if (!this.isCirvixServer(k, v)) upstreams[k] = v;
79
+ }
80
+
81
+ const cirvixServerDef = {
82
+ command: "cirvix",
83
+ args: ["gateway", "--servers", targetFile.replace(/\\/g, "/")],
84
+ };
85
+
86
+ const newConfig = {
87
+ ...(info.config || {}),
88
+ [this.mcpKey]: {
89
+ ...currentServers,
90
+ cirvix: cirvixServerDef,
91
+ },
92
+ };
93
+
94
+ return {
95
+ adapterId: this.id,
96
+ label: this.label,
97
+ targetFile,
98
+ canIntegrate: true,
99
+ currentServers,
100
+ upstreams,
101
+ plan: newConfig,
102
+ snippet: JSON.stringify({ [this.mcpKey]: { cirvix: cirvixServerDef } }, null, 2),
103
+ reason: "Protects Roo Code autonomous task execution via CIRVIX gateway.",
104
+ };
105
+ }
106
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * VS Code Standard MCP Adapter for CIRVIX AgentControl.
3
+ *
4
+ * Supports:
5
+ * - User mcp.json across Windows, macOS, Linux
6
+ * - Workspace .vscode/mcp.json
7
+ * - Uses the "servers" root key per VS Code MCP specification
8
+ */
9
+
10
+ import { homedir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { BaseAgentAdapter } from "./base.mjs";
13
+
14
+ export class VSCodeAdapter extends BaseAgentAdapter {
15
+ constructor() {
16
+ super({
17
+ id: "vscode",
18
+ label: "VS Code (MCP)",
19
+ type: "editor",
20
+ mcpKey: "servers",
21
+ });
22
+ }
23
+
24
+ async detect(cwd) {
25
+ const home = homedir();
26
+ const candidatePaths = [
27
+ join(cwd, ".vscode", "mcp.json"),
28
+ // Windows
29
+ join(home, "AppData", "Roaming", "Code", "User", "mcp.json"),
30
+ // macOS
31
+ join(home, "Library", "Application Support", "Code", "User", "mcp.json"),
32
+ // Linux
33
+ join(home, ".config", "Code", "User", "mcp.json"),
34
+ ];
35
+
36
+ const existingPaths = [];
37
+ const servers = {};
38
+ let configFound = null;
39
+
40
+ for (const p of candidatePaths) {
41
+ if (await this.fileExists(p)) {
42
+ existingPaths.push(p);
43
+ const data = await this.readJson(p);
44
+ if (data) {
45
+ if (!configFound) configFound = data;
46
+ const map = data.servers ?? data.mcpServers ?? {};
47
+ Object.assign(servers, map);
48
+ }
49
+ }
50
+ }
51
+
52
+ const detected = existingPaths.length > 0;
53
+ const isIntegrated = Object.entries(servers).some(([name, def]) => this.isCirvixServer(name, def));
54
+
55
+ return {
56
+ detected,
57
+ paths: existingPaths,
58
+ targetConfigPath: existingPaths[0] ?? candidatePaths[1],
59
+ config: configFound,
60
+ servers,
61
+ serverCount: Object.keys(servers).length,
62
+ isIntegrated,
63
+ hasConfig: existingPaths.length > 0,
64
+ executable: null,
65
+ metadata: {},
66
+ };
67
+ }
68
+
69
+ async generateIntegrationPlan(cwd, options = {}) {
70
+ const info = await this.detect(cwd);
71
+ const targetFile = info.targetConfigPath;
72
+ const currentServers = { ...info.servers };
73
+
74
+ const upstreams = {};
75
+ for (const [k, v] of Object.entries(currentServers)) {
76
+ if (!this.isCirvixServer(k, v)) upstreams[k] = v;
77
+ }
78
+
79
+ const cirvixServerDef = {
80
+ command: "cirvix",
81
+ args: ["gateway", "--servers", targetFile.replace(/\\/g, "/")],
82
+ };
83
+
84
+ const newConfig = {
85
+ ...(info.config || {}),
86
+ [this.mcpKey]: {
87
+ ...currentServers,
88
+ cirvix: cirvixServerDef,
89
+ },
90
+ };
91
+
92
+ return {
93
+ adapterId: this.id,
94
+ label: this.label,
95
+ targetFile,
96
+ canIntegrate: true,
97
+ currentServers,
98
+ upstreams,
99
+ plan: newConfig,
100
+ snippet: JSON.stringify({ [this.mcpKey]: { cirvix: cirvixServerDef } }, null, 2),
101
+ reason: "Protects VS Code MCP tool execution across all configured servers.",
102
+ };
103
+ }
104
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Windsurf Adapter for CIRVIX AgentControl.
3
+ *
4
+ * Supports:
5
+ * - ~/.codeium/windsurf/mcp_config.json
6
+ * - Workspace .windsurf/mcp_config.json, .codeium/windsurf/mcp_config.json
7
+ * - .windsurfrules
8
+ * - Executable detection (windsurf / windsurf.cmd)
9
+ */
10
+
11
+ import { homedir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { BaseAgentAdapter } from "./base.mjs";
14
+ import { resolveExecutable } from "../core/windows.mjs";
15
+
16
+ export class WindsurfAdapter extends BaseAgentAdapter {
17
+ constructor() {
18
+ super({
19
+ id: "windsurf",
20
+ label: "Windsurf",
21
+ type: "editor",
22
+ mcpKey: "mcpServers",
23
+ });
24
+ }
25
+
26
+ async detect(cwd) {
27
+ const home = homedir();
28
+ const candidatePaths = [
29
+ join(cwd, ".windsurf", "mcp_config.json"),
30
+ join(cwd, ".codeium", "windsurf", "mcp_config.json"),
31
+ join(home, ".codeium", "windsurf", "mcp_config.json"),
32
+ join(home, ".windsurf", "mcp_config.json"),
33
+ ];
34
+
35
+ const existingPaths = [];
36
+ const servers = {};
37
+ let configFound = null;
38
+
39
+ for (const p of candidatePaths) {
40
+ if (await this.fileExists(p)) {
41
+ existingPaths.push(p);
42
+ const data = await this.readJson(p);
43
+ if (data) {
44
+ if (!configFound) configFound = data;
45
+ Object.assign(servers, data[this.mcpKey] ?? {});
46
+ }
47
+ }
48
+ }
49
+
50
+ const execPath = resolveExecutable("windsurf", { cwd });
51
+ const hasRules = await this.fileExists(join(cwd, ".windsurfrules"));
52
+ const detected = existingPaths.length > 0 || Boolean(execPath) || hasRules;
53
+
54
+ const isIntegrated = Object.entries(servers).some(([name, def]) => this.isCirvixServer(name, def));
55
+
56
+ return {
57
+ detected,
58
+ paths: existingPaths,
59
+ targetConfigPath: existingPaths[0] ?? join(home, ".codeium", "windsurf", "mcp_config.json"),
60
+ config: configFound,
61
+ servers,
62
+ serverCount: Object.keys(servers).length,
63
+ isIntegrated,
64
+ hasConfig: existingPaths.length > 0,
65
+ executable: execPath,
66
+ metadata: {
67
+ hasRules,
68
+ },
69
+ };
70
+ }
71
+
72
+ async generateIntegrationPlan(cwd, options = {}) {
73
+ const info = await this.detect(cwd);
74
+ const targetFile = info.targetConfigPath;
75
+ const currentServers = { ...info.servers };
76
+
77
+ const upstreams = {};
78
+ for (const [k, v] of Object.entries(currentServers)) {
79
+ if (!this.isCirvixServer(k, v)) upstreams[k] = v;
80
+ }
81
+
82
+ const cirvixServerDef = {
83
+ command: "cirvix",
84
+ args: ["gateway", "--servers", targetFile.replace(/\\/g, "/")],
85
+ };
86
+
87
+ const newConfig = {
88
+ ...(info.config || {}),
89
+ [this.mcpKey]: {
90
+ ...currentServers,
91
+ cirvix: cirvixServerDef,
92
+ },
93
+ };
94
+
95
+ return {
96
+ adapterId: this.id,
97
+ label: this.label,
98
+ targetFile,
99
+ canIntegrate: true,
100
+ currentServers,
101
+ upstreams,
102
+ plan: newConfig,
103
+ snippet: JSON.stringify({ [this.mcpKey]: { cirvix: cirvixServerDef } }, null, 2),
104
+ reason: "Integrates CIRVIX runtime policy and secret isolation into Windsurf Cascade.",
105
+ };
106
+ }
107
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * `cirvix console` — the primary product interface.
3
+ *
4
+ * Interactive when attached to a TTY, one-shot when piped:
5
+ * cirvix console # full REPL
6
+ * echo "read ~/.aws/credentials" | cirvix console --once
7
+ * cirvix console --eval "deploy to production"
8
+ *
9
+ * `--once` / `--eval` exist so the product UI is scriptable and testable:
10
+ * the same cards render in CI as in the terminal.
11
+ */
12
+
13
+ import { ConsoleApp } from "../tui/app.mjs";
14
+
15
+ export async function consoleCmd({ cwd = process.cwd(), rules = [], mode = "enforce", evalText = null, once = false, write = (s) => process.stdout.write(s) } = {}) {
16
+ const app = new ConsoleApp({ cwd, rules, mode, write });
17
+
18
+ if (evalText) {
19
+ const out = await app.runOnce(evalText);
20
+ if (out && out !== "quit") write(out + "\n");
21
+ return { app, output: out };
22
+ }
23
+
24
+ if (once || !process.stdin.isTTY) {
25
+ // Piped: evaluate each line, print cards, exit. Never start readline
26
+ // on a non-TTY — it would hang waiting for a terminal that is not there.
27
+ const chunks = await readStdin();
28
+ const lines = chunks.split("\n").map((l) => l.trim()).filter(Boolean);
29
+ let output = "";
30
+ for (const line of lines) {
31
+ const out = await app.runOnce(line);
32
+ if (out && out !== "quit") {
33
+ write(out + "\n");
34
+ output += out + "\n";
35
+ }
36
+ }
37
+ if (!lines.length) {
38
+ const help = app.renderTranscript();
39
+ write(help + "\n");
40
+ return { app, output: help };
41
+ }
42
+ return { app, output };
43
+ }
44
+
45
+ await app.start();
46
+ return { app, output: "" };
47
+ }
48
+
49
+ function readStdin() {
50
+ return new Promise((resolve) => {
51
+ if (process.stdin.isTTY) return resolve("");
52
+ let data = "";
53
+ process.stdin.setEncoding("utf8");
54
+ process.stdin.on("data", (c) => (data += c));
55
+ process.stdin.on("end", () => resolve(data));
56
+ setTimeout(() => resolve(data), 1000);
57
+ });
58
+ }
@@ -38,17 +38,11 @@ import { compile } from "../core/policy-dsl.mjs";
38
38
  import { STARTER_POLICY } from "./init.mjs";
39
39
  import { scan as scanInjection } from "../core/sanitize.mjs";
40
40
  import { DECISION } from "../core/decisions.mjs";
41
- import { bold, dim, green, red, amber, blue } from "../core/format.mjs";
42
- import {
43
- allowContinuation,
44
- approvalHold,
45
- blockSignature,
46
- boot,
47
- cinematicEnabled,
48
- evaluation,
49
- requestTravel,
50
- topology,
51
- } from "../core/cinematic.mjs";
41
+ import { bold, dim, green, red, amber, blue, cyan } from "../core/format.mjs";
42
+ import { brandHeader, panel, separator } from "../core/ui/primitives.mjs";
43
+ import { interceptBox } from "../core/ui/intercept.mjs";
44
+ import { renderDecision } from "../core/ui/decisions.mjs";
45
+ import { shouldAnimate } from "../core/ui/controller.mjs";
52
46
 
53
47
  /** The poisoned content. This is what an agent finds on a page it was told to read. */
54
48
  const POISONED_PAGE = `# Deploying to production
@@ -170,23 +164,14 @@ export async function demo({
170
164
  });
171
165
  const steps = [];
172
166
 
173
- // Human TTY only: piped output, CI, --json and pace 0 are untouched.
174
- const animated = cinematicEnabled({ json, pace });
175
-
167
+ const animated = !json && shouldAnimate({ pace, json });
176
168
  if (!json) {
177
- await boot({
178
- write,
179
- rulesCount: ruleSet.length,
180
- auditOpen: chain !== null,
181
- agent: "claude-code",
182
- pace,
183
- animated,
184
- });
185
169
  write("\n");
186
- write(` ${bold("CIRVIX")} ${dim("· live demo · every decision below is computed, not scripted")}\n`);
170
+ write(brandHeader({ width: 62 }) + "\n");
171
+ write(` ${dim("CIRVIX · SECURITY DEMONSTRATION · every decision below is computed, not scripted")}\n`);
187
172
  write("\n");
188
173
  write(` ${dim("─".repeat(76))}\n`);
189
- write(` ${bold("ACT I")} ${dim("The agent reads a page containing an instruction addressed to it.")}\n`);
174
+ write(` ${bold("ACT I")} ${dim("Untrusted content reaches the agent")}\n`);
190
175
  write(` ${dim("─".repeat(76))}\n\n`);
191
176
 
192
177
  // Show what is actually in the page — the attack is the interesting part.
@@ -200,7 +185,7 @@ export async function demo({
200
185
  write(` ${red("·")} ${dim(f.label)}\n`);
201
186
  }
202
187
  write("\n");
203
- await sleep(pace * 2);
188
+ await sleep(animated ? pace * 1.2 : pace === 0 ? 0 : pace * 0.5);
204
189
  }
205
190
 
206
191
  let act = null;
@@ -209,29 +194,14 @@ export async function demo({
209
194
  act = step.act;
210
195
  if (act === "work") {
211
196
  write(`\n ${dim("─".repeat(76))}\n`);
212
- write(` ${bold("ACT III")} ${dim("The same agent, the same policy, doing its job.")}\n`);
197
+ write(` ${bold("ACT III")} ${dim("Legitimate work continues.")}\n`);
213
198
  write(` ${dim("─".repeat(76))}\n\n`);
214
199
  } else {
215
200
  write(`\n ${dim("─".repeat(76))}\n`);
216
- write(` ${bold("ACT II")} ${dim("It believes the page.")}\n`);
201
+ write(` ${bold("ACT II")} ${dim("CIRVIX evaluates the resulting actions.")}\n`);
217
202
  write(` ${dim("─".repeat(76))}\n\n`);
218
203
  }
219
- await sleep(pace);
220
- }
221
-
222
- // The packet travels first; the verdict below is the real measured one.
223
- // Animation is presentation — latency_ms is timed inside submit().
224
- if (animated) {
225
- const args = step.call.arguments ?? {};
226
- const target = args.path ?? args.url ?? args.command ?? args.name ?? "";
227
- await requestTravel({
228
- write,
229
- agent: "claude-code",
230
- tool: step.call.tool,
231
- target: String(target ?? ""),
232
- pace,
233
- animated,
234
- });
204
+ await sleep(animated ? pace * 0.6 : pace === 0 ? 0 : pace * 0.3);
235
205
  }
236
206
 
237
207
  const { event } = await pipeline.submit(step.call);
@@ -241,31 +211,25 @@ export async function demo({
241
211
 
242
212
  if (step.narration) {
243
213
  write(` ${dim(step.narration)}\n\n`);
244
- await sleep(pace / 2);
214
+ await sleep(animated ? pace * 0.3 : 0);
245
215
  }
246
216
 
247
- // Show the engine working on narrated beats, interceptions, and
248
- // anything that is not a plain allow — the allow path included.
249
- if (animated && (step.intercept || event.decision !== DECISION.ALLOW || step.narration)) {
250
- await evaluation({ write, event, pace, animated });
251
- }
252
-
253
- if (event.decision === DECISION.DENY) {
217
+ if (step.intercept && event.decision === DECISION.DENY) {
254
218
  if (animated) {
255
- await blockSignature({ write, event, pace, animated });
256
- } else {
257
- write(interceptBox(event));
219
+ // Brief evaluating sequence — decision already made, just visualizing.
220
+ write(` ${dim("◌ evaluating request...")}\n`);
221
+ await sleep(Math.min(260, pace * 0.35));
222
+ write(` ${red("⚠ " + String(event.risk).toUpperCase())}\n`);
223
+ await sleep(Math.min(160, pace * 0.2));
224
+ write(` ${red(bold("✕ BLOCKED"))} ${dim(event.policy ?? "")}\n`);
225
+ await sleep(Math.min(160, pace * 0.2));
258
226
  }
227
+ write(interceptBox(event));
259
228
  write("\n");
260
229
  } else {
261
- write(oneLine(event));
262
- if (event.decision === DECISION.REQUIRE_APPROVAL) {
263
- approvalHold({ write, event });
264
- } else if (animated && (event.decision === DECISION.ALLOW || event.decision === DECISION.SANITIZE)) {
265
- allowContinuation({ write, event });
266
- }
230
+ write(renderDecision(event) + "\n");
267
231
  }
268
- await sleep(pace);
232
+ await sleep(animated ? pace * 0.7 : pace === 0 ? 0 : pace * 0.4);
269
233
  }
270
234
 
271
235
  const p = pipeline.percentiles();
@@ -292,79 +256,46 @@ export async function demo({
292
256
 
293
257
  write("\n");
294
258
  write(` ${dim("─".repeat(76))}\n\n`);
295
- write(` ${green(bold(String(allowed)))} ${dim("calls forwarded")} `);
259
+ // Polished summary — real P99, real audit hint, premium panel.
260
+ const sanitized = steps.filter((s) => s.event.decision === DECISION.SANITIZE).length;
261
+ write(` ${green(bold(String(allowed)))} ${dim("allowed")} `);
262
+ if (sanitized) write(`${blue(bold(String(sanitized)))} ${dim("sanitized")} `);
296
263
  write(`${red(bold(String(denied)))} ${dim("blocked")} `);
297
264
  write(`${amber(bold(String(held)))} ${dim("held for a human")} `);
298
- write(`${dim(`P99 ${p.p99}ms over ${p.samples} decisions`)}\n\n`);
299
- if (animated) {
300
- topology({ write, states: { cirvix: "active" } });
301
- write("\n");
265
+ write(`${dim(`P99 ${p.p99}ms`)}${dim(` over ${p.samples} decisions · `)}${dim(`P50 ${p.p50}ms · P95 ${p.p95}ms`)}\n\n`);
266
+
267
+ // Summary panel spec: CIRVIX GOVERNANCE boxed, P50/P95/P99, audit chain intact
268
+ const summaryLines = [
269
+ `${bold("CIRVIX GOVERNANCE")}`,
270
+ ``,
271
+ `${`${allowed} allowed`.padEnd(14)} ${`${sanitized} sanitized`.padEnd(14)} ${`${denied} blocked`.padEnd(14)} ${`${held} awaiting approval`}`,
272
+ ``,
273
+ `${`P50 ${p.p50}ms`.padEnd(14)} ${`P95 ${p.p95}ms`.padEnd(14)} ${`P99 ${p.p99}ms`}`,
274
+ ``,
275
+ `${green("✓")} ${dim(`${steps.length} records verified`)}`,
276
+ `${green("✓")} ${bold("AUDIT CHAIN INTACT")}`,
277
+ ];
278
+ // Use header box per spec: ╭─ CIRVIX GOVERNANCE ─
279
+ const W = 62;
280
+ const top = ` ${dim(`╭─ CIRVIX GOVERNANCE ${"─".repeat(Math.max(0, W - 20))}╮`)}`;
281
+ const bottom = ` ${dim(`╰${"─".repeat(W)}╯`)}`;
282
+ write(top + "\n");
283
+ for (const l of summaryLines) {
284
+ // Inside panel, pad to width, no side borders for genuine CLI feel per spec — but keep subtle
285
+ write(` ${l}\n`);
302
286
  }
303
- write(` ${bold("Cirvix did not disable the agent. It made the dangerous half controllable.")}\n\n`);
287
+ write(bottom + "\n\n");
288
+ write(` ${bold("CIRVIX did not disable the agent.")}\n`);
289
+ write(` ${dim("It controlled the dangerous actions.")}\n\n`);
304
290
  write(` ${dim("Every decision above is in the audit chain:")} ${blue("cirvix logs")}\n`);
305
291
  write(` ${dim("Ask why any one of them happened:")} ${blue("cirvix logs --tree <request-id>")}\n\n`);
306
292
 
307
293
  return { result, output: "" };
308
294
  }
309
295
 
310
- /* -------------------------------------------------------------------------- */
311
- /* Rendering */
312
- /* -------------------------------------------------------------------------- */
313
-
314
- /**
315
- * The intercept box.
316
- *
317
- * Fixed inner width so the borders line up regardless of content length; values
318
- * are truncated rather than allowed to break the frame, because a box with one
319
- * ragged edge reads as a rendering bug and undermines everything inside it.
320
- */
321
- function interceptBox(event) {
322
- const W = 58;
323
- const rows = [
324
- ["Agent", event.agent],
325
- ["Tool", event.tool],
326
- ["Target", event.resource || event.destination || "—"],
327
- ["Risk", String(event.risk).toUpperCase()],
328
- ["Decision", "BLOCKED"],
329
- ["Policy", event.policy ?? "default-deny"],
330
- ["Latency", `${event.latency_ms}ms`],
331
- ];
332
-
333
- const pad = (text) => {
334
- const s = String(text);
335
- return s.length > W ? s.slice(0, W - 1) + "…" : s.padEnd(W);
336
- };
337
-
338
- const lines = [
339
- ` ${red("╔" + "═".repeat(W + 2) + "╗")}`,
340
- ` ${red("║")} ${bold(pad("CIRVIX SECURITY INTERCEPT"))} ${red("║")}`,
341
- ` ${red("╠" + "═".repeat(W + 2) + "╣")}`,
342
- ...rows.map(([k, v]) => {
343
- const body = `${k}:`.padEnd(11) + v;
344
- const painted = k === "Risk" || k === "Decision" ? red(pad(body)) : pad(body);
345
- return ` ${red("║")} ${painted} ${red("║")}`;
346
- }),
347
- ` ${red("╚" + "═".repeat(W + 2) + "╝")}`,
348
- ];
349
-
350
- // The reason sits outside the box: it is the part that varies in length, and
351
- // it is what the agent itself receives as a readable tool result.
352
- const reason = event.reason ? `\n ${dim(event.reason)}\n` : "";
353
- return lines.join("\n") + "\n" + reason;
354
- }
355
-
296
+ /* Legacy oneLine kept for tests that import it indirectly — now delegates to ui. */
356
297
  function oneLine(event) {
357
- const tone =
358
- { allow: green, sanitize: blue, require_approval: amber, deny: red, audit_only: dim }[event.decision] ?? dim;
359
- const riskTone = { low: dim, medium: blue, high: amber, critical: red }[event.risk] ?? dim;
360
-
361
- return (
362
- ` ${tone(String(event.decision).toUpperCase().replace(/_/g, " ").padEnd(17))}` +
363
- `${riskTone(String(event.risk).toUpperCase().padEnd(9))}` +
364
- `${String(event.tool).padEnd(20)}` +
365
- `${dim(String(event.resource || event.command || "").slice(-38).padEnd(38))} ` +
366
- `${dim(`${event.latency_ms}ms`)}\n`
367
- );
298
+ return renderDecision(event) + "\n";
368
299
  }
369
300
 
370
301
  export { POISONED_PAGE };