@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,292 @@
1
+ /**
2
+ * Cirvix console — the primary interactive UX.
3
+ *
4
+ * Layout (wide terminals):
5
+ * ┌ header ────────────────────────────────┐
6
+ * │ CONVERSATION │ ACTIVITY │
7
+ * │ user asks / cirvix │ ◐ policy.eval│
8
+ * │ responds / policy cards │ ✓ shell.exec │
9
+ * ├ composer ──────────────────────────────┤
10
+ * │ status bar │
11
+ * └────────────────────────────────────────┘
12
+ *
13
+ * Narrow terminals (<90 cols): the activity pane collapses into the
14
+ * transcript automatically. No horizontal scrolling, ever.
15
+ *
16
+ * Engine separation: the app owns a Pipeline + EventBus. It emits events,
17
+ * reduces them to state, and renders cards from state. Policy code never
18
+ * touches the terminal.
19
+ *
20
+ * Non-TTY / piped usage: `runOnce(text)` evaluates one line and returns
21
+ * the rendered output without starting readline — used by tests, CI, and
22
+ * `echo "…" | cirvix console`.
23
+ */
24
+
25
+ import { Pipeline } from "../core/pipeline.mjs";
26
+ import { decideNow } from "../core/journal.mjs";
27
+ import { EventBus, createEvent, initialState, reduce, attachPipeline } from "../core/events.mjs";
28
+ import { header, policyCard, toolCard, blockedCard, heldCard, explainDecision, userRow, cirvixRow, spinnerFrame } from "./cards.mjs";
29
+ import { statusBar } from "./status.mjs";
30
+ import { collapsedFeed } from "./activity.mjs";
31
+ import { paletteBox } from "./palette.mjs";
32
+ import { setTheme, THEME_NAMES, bold, dim, style } from "../core/theme.mjs";
33
+ import { startComposer } from "./composer.mjs";
34
+
35
+ const VERSION = "0.1.0";
36
+
37
+ export class ConsoleApp {
38
+ constructor({ cwd = process.cwd(), rules = [], mode = "enforce", agent = "local", write = (s) => process.stdout.write(s) } = {}) {
39
+ this.cwd = cwd;
40
+ this.rules = rules;
41
+ this.agent = agent;
42
+ this.write = write;
43
+ this.bus = new EventBus();
44
+ this.state = initialState();
45
+ this.state.status.mode = mode;
46
+ this.expanded = false;
47
+ this.overlay = null; // 'palette' | null
48
+
49
+ this.pipeline = new Pipeline({ rules, cwd, agent, mode, onEvent: () => {} });
50
+ attachPipeline(this.pipeline, this.bus);
51
+ this.bus.onAny((e) => {
52
+ this.state = reduce(this.state, e);
53
+ });
54
+ this.bus.emit(createEvent.sessionStarted({ agent }));
55
+ }
56
+
57
+ /* ---------------------------------------------------------- rendering */
58
+
59
+ renderHeader() {
60
+ return header({ mode: this.state.status.mode === "audit" ? "AUDIT MODE" : "PROTECTED", version: VERSION });
61
+ }
62
+
63
+ renderStatus() {
64
+ return statusBar(this.state, { version: VERSION });
65
+ }
66
+
67
+ renderActivity() {
68
+ return collapsedFeed(this.state.activity, { expanded: this.expanded });
69
+ }
70
+
71
+ /** Full re-render of the transcript region (used on clear / toggle). */
72
+ renderTranscript() {
73
+ const out = [this.renderHeader(), ``];
74
+ for (const m of this.state.messages) {
75
+ out.push(m.role === "user" ? userRow(m.text) : cirvixRow(m.text), ``);
76
+ }
77
+ if (this.narrow()) {
78
+ out.push(`▼ Activity`, this.renderActivity(), ``);
79
+ }
80
+ out.push(this.renderStatus());
81
+ return out.join("\n");
82
+ }
83
+
84
+ narrow() {
85
+ return (process.stdout.columns ?? 80) < 90;
86
+ }
87
+
88
+ /* ------------------------------------------------------------- events */
89
+
90
+ /** Animated "evaluating" line. Resolves with a stop() that clears it. */
91
+ animate(label) {
92
+ if (!process.stdout.isTTY) {
93
+ this.write(`${dim("◐")} ${label}...\n`);
94
+ return () => {};
95
+ }
96
+ let i = 0;
97
+ const timer = setInterval(() => {
98
+ process.stdout.write(`\r${dim(spinnerFrame(i++))} ${dim(label)}...`);
99
+ }, 90);
100
+ return () => {
101
+ clearInterval(timer);
102
+ process.stdout.write("\r" + " ".repeat(label.length + 6) + "\r");
103
+ };
104
+ }
105
+
106
+ /** Evaluate one free-text line through policy and render the product UI. */
107
+ async runOnce(input) {
108
+ const text = String(input ?? "").trim();
109
+ if (!text) return "";
110
+ if (text.startsWith("/")) return this.runSlash(text);
111
+
112
+ this.bus.emit(createEvent.userMessage(text));
113
+ const lines = [userRow(text), ``, cirvixRow(`${dim("◐ Evaluating authorization...")}`)];
114
+ const stop = this.animate("Evaluating policy");
115
+ const parsed = parseRequest(text, { agent: this.agent });
116
+ let decision;
117
+ try {
118
+ ({ decision } = decideNow({ ...parsed, rules: this.rules, cwd: this.cwd }));
119
+ } finally {
120
+ stop();
121
+ }
122
+ const event = {
123
+ decision_id: `dec_local`,
124
+ request_id: `req_local`,
125
+ agent: this.agent,
126
+ tool: parsed.tool,
127
+ resource: parsed.args.path ?? parsed.args.url ?? parsed.args.command ?? "",
128
+ risk: decision.risk ?? "medium",
129
+ decision: decision.decision ?? "deny",
130
+ policy: decision.rule,
131
+ reason: decision.reason,
132
+ latency_ms: 0,
133
+ };
134
+ this.bus.emit(createEvent.policyDecision(event));
135
+ if (event.decision === "require_approval") this.bus.emit(createEvent.approvalRequested({ tool: event.tool, resource: event.resource }));
136
+ this.bus.emit(createEvent.agentMessage(`Decision: ${event.decision}`));
137
+
138
+ return [...lines, ``, this.renderDecision(event, parsed), ``, this.renderStatus()].join("\n");
139
+ }
140
+
141
+ renderDecision(event, parsed = {}) {
142
+ const d = event.decision;
143
+ if (d === "deny") {
144
+ const target = event.resource || parsed.args?.command || "";
145
+ return [blockedCard({ tool: event.tool, target, policy: event.policy, reason: event.reason }), ``, explainDecision(event)].join("\n");
146
+ }
147
+ if (d === "require_approval") {
148
+ return heldCard({ tool: event.tool, target: event.resource, approvers: event.approvers ?? [], reason: event.reason });
149
+ }
150
+ return [
151
+ policyCard({
152
+ action: event.tool,
153
+ risk: event.risk,
154
+ policy: event.policy ?? "default",
155
+ identity: `agent:${event.agent}`,
156
+ reason: event.reason,
157
+ latencyMs: event.latency_ms,
158
+ }),
159
+ ...(d === "sanitize" ? [``, explainDecision(event)] : []),
160
+ ].join("\n");
161
+ }
162
+
163
+ /* ---------------------------------------------------------------- slash */
164
+
165
+ async runSlash(input) {
166
+ const [cmd, ...rest] = input.trim().split(/\s+/);
167
+ const arg = rest.join(" ");
168
+ switch (cmd.toLowerCase()) {
169
+ case "/help":
170
+ return paletteBox("/");
171
+ case "/quit":
172
+ case "/exit":
173
+ return "quit";
174
+ case "/clear":
175
+ return "\x1b[2J\x1b[0;0H" + this.renderTranscript();
176
+ case "/expand":
177
+ this.expanded = true;
178
+ return this.renderActivity();
179
+ case "/collapse":
180
+ this.expanded = false;
181
+ return this.renderActivity();
182
+ case "/theme": {
183
+ if (!arg) return `Usage: /theme <${THEME_NAMES.join("|")}>\nCurrent: ${bold(process.env.CIRVIX_THEME ?? "dark")}`;
184
+ try {
185
+ setTheme(arg);
186
+ process.env.CIRVIX_THEME = arg;
187
+ return `${style("✓", "allow")} Theme → ${bold(arg)}`;
188
+ } catch (err) {
189
+ return `${style("✕", "block")} ${err.message}`;
190
+ }
191
+ }
192
+ case "/audit":
193
+ case "/logs":
194
+ return this.renderActivity();
195
+ case "/policies": {
196
+ const names = this.rules.map((r) => ` • ${r.name ?? "(unnamed)"} ${dim(r.effect ?? "")}`).join("\n");
197
+ return `${bold("Policies")} ${dim(`(${this.rules.length} rules)`)}\n${names || dim(" (no rules loaded)")}`;
198
+ }
199
+ case "/sessions":
200
+ return `${bold("Session")} ${this.state.session?.id ?? "—"} ${dim(`agent ${this.state.session?.agent ?? this.agent}`)}\n${dim(`${this.state.status.requests} requests this session`)}`;
201
+ case "/doctor":
202
+ return doctor(this.rules, this.state);
203
+ case "/demo":
204
+ return dim("Run `cirvix demo` in a fresh terminal for the full live interception demo.");
205
+ case "/approvals": {
206
+ if (!this.state.approvals.length) return dim("nothing waiting on a human");
207
+ return this.state.approvals.map((a) => ` ${bold(a.approval_id ?? a.id ?? "approval")} ${a.tool ?? ""} ${dim(a.resource ?? "")}`).join("\n");
208
+ }
209
+ case "/config":
210
+ return `${bold("Config")}\n ${dim("theme")} ${process.env.CIRVIX_THEME ?? "dark"}\n ${dim("agent")} ${this.agent}\n ${dim("mode")} ${this.state.status.mode}`;
211
+ default: {
212
+ // Prefix completion: "/pol" → show matches instead of erroring.
213
+ const { filterCommands } = await import("./palette.mjs");
214
+ const matches = filterCommands(input);
215
+ if (matches.length) return paletteBox(input);
216
+ return dim(`Unknown command "${cmd}". Type / for the palette.`);
217
+ }
218
+ }
219
+ }
220
+
221
+ /* ---------------------------------------------------------- interactive */
222
+
223
+ async start() {
224
+ this.write(this.renderTranscript() + "\n\n");
225
+ this.write(dim("Type / for commands. Ctrl+K palette · Ctrl+O activity · Esc close.\n"));
226
+ const rl = startComposer({
227
+ prompt: "> ",
228
+ onLine: async (line) => {
229
+ if (!line) return;
230
+ if (line === "/quit" || line === "/exit") return "quit";
231
+ const out = await this.runOnce(line);
232
+ if (out === "quit") return "quit";
233
+ this.write("\n" + out + "\n\n");
234
+ if (!this.narrow()) {
235
+ // Side-pane refresh on wide terminals: reprint compact activity.
236
+ this.write(dim("─ Activity ─") + "\n" + this.renderActivity() + "\n\n" + this.renderStatus() + "\n");
237
+ }
238
+ },
239
+ onKey: (key) => {
240
+ if (key === "toggle-activity") {
241
+ this.expanded = !this.expanded;
242
+ this.write("\n" + this.renderActivity() + "\n");
243
+ } else if (key === "palette") {
244
+ this.write("\n" + paletteBox("/") + "\n");
245
+ } else if (key === "policies") {
246
+ this.write("\n" + `Policies: ${this.rules.length} loaded` + "\n");
247
+ }
248
+ },
249
+ });
250
+ await new Promise((resolve) => rl.on("close", resolve));
251
+ this.bus.emit(createEvent.sessionEnded({}));
252
+ this.write("\n" + dim("Session ended. Audit chain intact.") + "\n");
253
+ }
254
+ }
255
+
256
+ /* ------------------------------------------------------------------ */
257
+ /* Helpers */
258
+ /* ------------------------------------------------------------------ */
259
+
260
+ /**
261
+ * Heuristic: turn "Can the deploy bot run `rm -rf /`?" into a policy
262
+ * request. Deliberately conservative — unknown shapes become a low-risk
263
+ * workspace read that policy will judge on its merits, never an implicit
264
+ * permit of something dangerous.
265
+ */
266
+ export function parseRequest(text, { agent = "local" } = {}) {
267
+ const t = String(text);
268
+ const url = t.match(/https?:\/\/[^\s"'`]+/i)?.[0];
269
+ const credPath = t.match(/(~\/\.aws\/credentials|\.env[^\s]*|\.aws[^\s]*)/i)?.[0];
270
+ const cmd = t.match(/`([^`]+)`/)?.[1] ?? t.match(/run\s+"([^"]+)"/i)?.[1];
271
+
272
+ if (credPath) return { tool: "read_file", server: null, args: { path: credPath }, agent };
273
+ if (url && /collect|attacker|exfil|send|post/i.test(t)) {
274
+ return { tool: "http_request", server: null, args: { url }, agent };
275
+ }
276
+ if (url) return { tool: "http_request", server: null, args: { url }, agent };
277
+ if (cmd) return { tool: "shell_exec", server: null, args: { command: cmd }, agent };
278
+ if (/deploy|production/i.test(t)) return { tool: "shell_exec", server: null, args: { command: "deploy production" }, agent };
279
+ if (/database|db|sql/i.test(t)) return { tool: "db_query", server: null, args: { sql: t.slice(0, 200) }, agent };
280
+ return { tool: "read_file", server: null, args: { path: "./" + t.slice(0, 60) }, agent };
281
+ }
282
+
283
+ function doctor(rules, state) {
284
+ const rows = [
285
+ ["Engine", style("✓ reachable", "allow")],
286
+ ["Rules", rules.length ? `${rules.length} loaded` : style("none — run cirvix init", "warning")],
287
+ ["Session", `${state.status.requests} requests`],
288
+ ["Blocked", state.status.blocked > 0 ? style(`${state.status.blocked} denied`, "block") : "0"],
289
+ ["Terminal", `${process.stdout.columns ?? 80} cols ${process.stdout.isTTY ? "(TTY)" : "(piped)"}`],
290
+ ];
291
+ return `${bold("Doctor")}\n` + rows.map(([k, v]) => ` ${dim(k.padEnd(9))} ${v}`).join("\n");
292
+ }
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Cirvix TUI cards — every tool call rendered as a visual object.
3
+ *
4
+ * Pure functions: (data) → string. No console writes, no engine imports.
5
+ * The same functions back the interactive console, `cirvix logs --tree`,
6
+ * and the future React/Ink port (one card = one component).
7
+ *
8
+ * Accessibility rule: icon + color + WORD. A ✓/✕ alone is never the only
9
+ * signal — every card prints ALLOWED / SANITIZED / BLOCKED / HELD as text.
10
+ */
11
+
12
+ import { style, bold, dim, roleForDecision, roleForRisk, badgeForDecision } from "../core/theme.mjs";
13
+
14
+ /* ------------------------------------------------------------------ */
15
+ /* Box primitives */
16
+ /* ------------------------------------------------------------------ */
17
+
18
+ function width() {
19
+ return Math.max(40, Math.min(process.stdout.columns ?? 80, 100));
20
+ }
21
+
22
+ function rule(char = "─") {
23
+ return dim(char.repeat(Math.max(0, width() - 2)));
24
+ }
25
+
26
+ function frame(title, lines, { tone = "border" } = {}) {
27
+ const W = Math.max(40, width() - 6);
28
+ const top = style(`┌─ ${title} ${"─".repeat(Math.max(0, W - title.length - 4))}┐`, tone);
29
+ const bottom = style(`└${"─".repeat(W)}┘`, tone);
30
+ const body = lines.map((l) => {
31
+ const plain = stripAnsi(l);
32
+ const pad = " ".repeat(Math.max(0, W - 2 - plain.length));
33
+ return `${style("│", tone)} ${l}${pad} ${style("│", tone)}`;
34
+ });
35
+ return [top, ...body, bottom].join("\n");
36
+ }
37
+
38
+ function stripAnsi(s) {
39
+ return String(s).replace(/\x1b\[[0-9;]*m/g, "");
40
+ }
41
+
42
+ function kv(key, value, { keyWidth = 12 } = {}) {
43
+ return `${dim(String(key).padEnd(keyWidth))} ${value}`;
44
+ }
45
+
46
+ /* ------------------------------------------------------------------ */
47
+ /* Header */
48
+ /* ------------------------------------------------------------------ */
49
+
50
+ export function header({ mode = "PROTECTED", version = "" } = {}) {
51
+ const dot = mode === "PROTECTED" ? style("●", "allow") : style("○", "warning");
52
+ const title = `${bold("◆ CIRVIX")} ${dim("Runtime Authorization")}`;
53
+ const right = `${dot} ${bold(mode)}${version ? dim(` v${version}`) : ""}`;
54
+ return `${title}${" ".repeat(Math.max(2, width() - stripAnsi(title).length - stripAnsi(right).length))}${right}\n${rule()}`;
55
+ }
56
+
57
+ /* ------------------------------------------------------------------ */
58
+ /* Policy decision card — the ALLOWED shape */
59
+ /* ------------------------------------------------------------------ */
60
+
61
+ export function policyCard({ action, risk, policy, identity, reason, latencyMs } = {}) {
62
+ const badge = badgeForDecision("allow");
63
+ const lines = [
64
+ `${style(`✓ ${badge.label}`, "allow")}`,
65
+ ``,
66
+ kv("Action", bold(action ?? "—")),
67
+ kv("Risk", style(String(risk ?? "—").toUpperCase(), roleForRisk(risk))),
68
+ kv("Policy", policy ?? dim("default-deny")),
69
+ kv("Identity", identity ?? dim("agent:local")),
70
+ ...(reason ? [kv("Reason", dim(truncate(reason, 60)))] : []),
71
+ ...(latencyMs !== undefined ? [kv("Latency", dim(`${latencyMs}ms`))] : []),
72
+ ];
73
+ return frame("POLICY DECISION", lines, { tone: "border" });
74
+ }
75
+
76
+ /* ------------------------------------------------------------------ */
77
+ /* Tool card — full lifecycle: identity → policy checks → decision */
78
+ /* ------------------------------------------------------------------ */
79
+
80
+ export function toolCard({ tool, risk, identity, detail, checks = [], decision, policy, reason } = {}) {
81
+ const badge = badgeForDecision(decision);
82
+ const role = roleForDecision(decision);
83
+ const lines = [
84
+ ``,
85
+ kv("Risk", style(String(risk ?? "—").toUpperCase(), roleForRisk(risk))),
86
+ kv("Identity", identity ?? dim("agent:local")),
87
+ ...(detail ? [kv(detailLabel(tool), truncate(detail, 64))] : []),
88
+ ``,
89
+ dim("Policy evaluation"),
90
+ ...checks.map((c) => ` ${c.ok ? style("✓", "allow") : style("✕", "block")} ${dim(c.label)}`),
91
+ ``,
92
+ kv("Decision", style(`${badge.icon} ${badge.label}`, role)),
93
+ ...(policy ? [kv("Policy", policy)] : []),
94
+ ...(reason ? [kv("Why", dim(truncate(reason, 64)))] : []),
95
+ ];
96
+ const title = String(tool ?? "TOOL").toUpperCase().replace(/__/g, " · ");
97
+ return frame(title, lines, { tone: role === "block" ? "block" : "border" });
98
+ }
99
+
100
+ function detailLabel(tool) {
101
+ const t = String(tool ?? "");
102
+ if (/http|egress|fetch|request/i.test(t)) return "URL";
103
+ if (/read|write|file/i.test(t)) return "Path";
104
+ if (/shell|exec|command/i.test(t)) return "Command";
105
+ if (/sql|db|query/i.test(t)) return "Query";
106
+ return "Target";
107
+ }
108
+
109
+ /* ------------------------------------------------------------------ */
110
+ /* Blocked card — the dangerous shape. Human first, fields second. */
111
+ /* ------------------------------------------------------------------ */
112
+
113
+ export function blockedCard({ tool, target, policy, reason, detail } = {}) {
114
+ const lines = [
115
+ style(`🔴 HIGH-RISK ACTION — BLOCKED`, "block"),
116
+ ``,
117
+ bold(tool ?? "unknown tool"),
118
+ ...(target ? [dim(truncate(target, 72))] : []),
119
+ ``,
120
+ ...(policy ? [kv("Policy", policy)] : []),
121
+ ...(reason ? [kv("Reason", truncate(reason, 72))] : []),
122
+ ...(detail ? [dim(truncate(detail, 72))] : []),
123
+ ``,
124
+ dim("Action was NOT executed. No side effects occurred."),
125
+ ];
126
+ return frame("BLOCKED", lines, { tone: "block" });
127
+ }
128
+
129
+ export function heldCard({ tool, target, approvers = [], reason } = {}) {
130
+ const lines = [
131
+ `${style("◷ HELD FOR APPROVAL", "hold")}`,
132
+ ``,
133
+ bold(tool ?? "unknown tool"),
134
+ ...(target ? [dim(truncate(target, 72))] : []),
135
+ ``,
136
+ kv("Waits on", approvers.length ? approvers.join(", ") : dim("a human approver")),
137
+ ...(reason ? [kv("Reason", dim(truncate(reason, 68)))] : []),
138
+ ``,
139
+ dim("The call is suspended. Approve it with `cirvix approvals`."),
140
+ ];
141
+ return frame("APPROVAL", lines, { tone: "hold" });
142
+ }
143
+
144
+ /* ------------------------------------------------------------------ */
145
+ /* Human-readable explanation — security product, not infra dump */
146
+ /* ------------------------------------------------------------------ */
147
+
148
+ export function explainDecision(event = {}) {
149
+ const decision = event.decision ?? event.verdict ?? "deny";
150
+ const badge = badgeForDecision(decision);
151
+ const role = roleForDecision(decision);
152
+ const title = style(`${badge.icon} ${badge.label}`, role);
153
+
154
+ if (decision === "deny") {
155
+ return [
156
+ title,
157
+ ``,
158
+ `Cirvix stopped this request${event.policy ? ` because policy ${bold(`"${event.policy}"`)} matched` : ""}.`,
159
+ ...(event.resource || event.destination
160
+ ? [``, `Target:`, ` ${truncate(event.resource || event.destination, 76)}`]
161
+ : []),
162
+ ...(event.reason ? [``, dim(wrap(event.reason, 76))] : []),
163
+ ``,
164
+ dim("No network request was sent. No file was read. No command ran."),
165
+ ``,
166
+ `${dim("[Why?]")} ${dim("cirvix logs --tree " + (event.request_id ?? event.decision_id ?? "<id>"))} ${dim("[Policy]")} cirvix policy list`,
167
+ ].join("\n");
168
+ }
169
+
170
+ if (decision === "sanitize") {
171
+ return [
172
+ title,
173
+ ``,
174
+ `Cirvix forwarded this call after cleaning it.`,
175
+ ...(event.reason ? [``, dim(wrap(event.reason, 76))] : []),
176
+ ``,
177
+ dim("The agent received a safe version — the original never left the runtime."),
178
+ ].join("\n");
179
+ }
180
+
181
+ if (decision === "require_approval") {
182
+ return [
183
+ title,
184
+ ``,
185
+ `This call needs a human before it runs.`,
186
+ ...(event.reason ? [``, dim(wrap(event.reason, 76))] : []),
187
+ ].join("\n");
188
+ }
189
+
190
+ return [
191
+ title,
192
+ ...(event.reason ? [``, dim(wrap(event.reason, 76))] : []),
193
+ ].join("\n");
194
+ }
195
+
196
+ /* ------------------------------------------------------------------ */
197
+ /* Transcript rows */
198
+ /* ------------------------------------------------------------------ */
199
+
200
+ export function userRow(text) {
201
+ return `${bold("You")}\n${dim("─".repeat(27))}\n${text}`;
202
+ }
203
+
204
+ export function cirvixRow(text) {
205
+ return `${bold("CIRVIX")}\n${dim("─".repeat(27))}\n${text}`;
206
+ }
207
+
208
+ /* ------------------------------------------------------------------ */
209
+ /* Small helpers */
210
+ /* ------------------------------------------------------------------ */
211
+
212
+ export function spinnerFrame(i) {
213
+ return ["◐", "◓", "◑", "◒"][i % 4];
214
+ }
215
+
216
+ function truncate(s, n) {
217
+ const v = String(s ?? "");
218
+ return v.length <= n ? v : `…${v.slice(-(n - 1))}`;
219
+ }
220
+
221
+ function wrap(text, w) {
222
+ const words = String(text).split(/\s+/);
223
+ const lines = [];
224
+ let line = "";
225
+ for (const word of words) {
226
+ if ((line + " " + word).trim().length > w) {
227
+ lines.push(line.trim());
228
+ line = word;
229
+ } else line += " " + word;
230
+ }
231
+ if (line.trim()) lines.push(line.trim());
232
+ return lines.join("\n");
233
+ }
234
+
235
+ export { frame, rule, width };
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Composer — the input box. Multiline, history, paste, autocomplete.
3
+ *
4
+ * Built on node:readline (zero deps) rather than a bespoke ANSI editor:
5
+ * - multiline via Shift+Enter semantics: plain Enter sends, and a trailing
6
+ * `\` continues the line; pasted multi-line blocks are joined safely.
7
+ * - ↑↓ history, Ctrl+R reverse search, Ctrl+C cancel, Ctrl+L clear, Tab
8
+ * completion come from readline itself.
9
+ * - `/` prefix triggers the command palette; Tab completes it.
10
+ * - Ctrl+K opens the palette, Ctrl+O toggles activity, Ctrl+P policies,
11
+ * Ctrl+A audit, Ctrl+S session, Esc closes overlays.
12
+ *
13
+ * The composer never evaluates policy itself — it emits lines to the app.
14
+ */
15
+
16
+ import readline from "node:readline";
17
+ import { filterCommands } from "./palette.mjs";
18
+ import { dim, bold } from "../core/theme.mjs";
19
+
20
+ export const KEY_HINT = dim("Enter ↵ send Shift+Enter newline ↑↓ history Tab commands / palette Ctrl+K palette Esc close");
21
+
22
+ export function composerBox() {
23
+ return `${dim("╭─ CIRVIX ─" + "─".repeat(50) + "╮")}\n${dim("│")} ${dim("Ask Cirvix anything... type / for commands")}\n${dim("╰" + "─".repeat(60) + "╯")}\n ${KEY_HINT}`;
24
+ }
25
+
26
+ function completer(line) {
27
+ if (!line.startsWith("/")) return [[], line];
28
+ const matches = filterCommands(line).map((c) => c.name);
29
+ return [matches, line];
30
+ }
31
+
32
+ /**
33
+ * Start the interactive prompt. `onLine` may be async; return "quit" from
34
+ * it (or type /quit) to exit. Returns a handle with `close()`.
35
+ */
36
+ export function startComposer({ prompt = "> ", history = [], onLine, onKey }) {
37
+ const rl = readline.createInterface({
38
+ input: process.stdin,
39
+ output: process.stdout,
40
+ prompt,
41
+ completer,
42
+ history,
43
+ terminal: Boolean(process.stdin.isTTY),
44
+ });
45
+
46
+ // Extra shortcuts beyond readline's builtins.
47
+ if (process.stdin.isTTY) {
48
+ readline.emitKeypressEvents(process.stdin);
49
+ const onKeypress = (str, key = {}) => {
50
+ if (key.name === "escape") onKey?.("escape");
51
+ else if (key.ctrl && key.name === "k") { rl.write("/"); onKey?.("palette"); }
52
+ else if (key.ctrl && key.name === "o") onKey?.("toggle-activity");
53
+ else if (key.ctrl && key.name === "p") onKey?.("policies");
54
+ else if (key.ctrl && key.name === "a") onKey?.("audit");
55
+ else if (key.ctrl && key.name === "s") onKey?.("session");
56
+ else if (key.ctrl && key.name === "l") { console.clear(); rl.prompt(); }
57
+ };
58
+ process.stdin.on("keypress", onKeypress);
59
+ rl.on("close", () => process.stdin.removeListener("keypress", onKeypress));
60
+ }
61
+
62
+ // Trailing-backslash continuation = Shift+Enter equivalent for terminals
63
+ // that cannot distinguish the two.
64
+ let pending = "";
65
+ rl.on("line", async (line) => {
66
+ if (line.endsWith("\\")) {
67
+ pending += line.slice(0, -1) + "\n";
68
+ rl.setPrompt("… ");
69
+ rl.prompt();
70
+ return;
71
+ }
72
+ const full = (pending + line).trim();
73
+ pending = "";
74
+ rl.setPrompt(prompt);
75
+ try {
76
+ const verdict = await onLine?.(full);
77
+ if (verdict === "quit") rl.close();
78
+ else rl.prompt();
79
+ } catch {
80
+ rl.prompt();
81
+ }
82
+ });
83
+
84
+ rl.prompt();
85
+ return rl;
86
+ }
87
+
88
+ export { completer };
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Command palette — `/` shows everything. No memorized single letters.
3
+ *
4
+ * Each entry: { name, hint, run }. Filtering is prefix + substring so
5
+ * `/pol` matches `/policies`, `/policy test`, `/policy explain`.
6
+ */
7
+
8
+ export const COMMANDS = [
9
+ { name: "/audit", hint: "View security audit", run: "audit" },
10
+ { name: "/policies", hint: "Inspect policies", run: "policies" },
11
+ { name: "/policy test", hint: "Run policy test cases", run: "policy-test" },
12
+ { name: "/policy explain", hint: "Why would this call be decided that way", run: "policy-explain" },
13
+ { name: "/logs", hint: "View runtime logs", run: "logs" },
14
+ { name: "/sessions", hint: "Manage sessions", run: "sessions" },
15
+ { name: "/approvals", hint: "Calls waiting on a human", run: "approvals" },
16
+ { name: "/theme", hint: "Change appearance (dark/light/midnight/high-contrast)", run: "theme" },
17
+ { name: "/config", hint: "Configure Cirvix", run: "config" },
18
+ { name: "/doctor", hint: "Diagnose runtime", run: "doctor" },
19
+ { name: "/demo", hint: "Run the live interception demo", run: "demo" },
20
+ { name: "/expand", hint: "Expand activity feed", run: "expand" },
21
+ { name: "/collapse", hint: "Collapse activity feed", run: "collapse" },
22
+ { name: "/clear", hint: "Clear the screen", run: "clear" },
23
+ { name: "/help", hint: "Show commands", run: "help" },
24
+ { name: "/quit", hint: "Leave the console", run: "quit" },
25
+ ];
26
+
27
+ export function filterCommands(input) {
28
+ const q = String(input ?? "").trim().toLowerCase();
29
+ if (!q || q === "/") return COMMANDS;
30
+ const needle = q.startsWith("/") ? q : `/${q}`;
31
+ const bare = needle.slice(1);
32
+ return COMMANDS.filter(
33
+ (c) => c.name.toLowerCase().startsWith(needle) || c.name.toLowerCase().includes(bare),
34
+ );
35
+ }
36
+
37
+ export function paletteBox(input) {
38
+ const matches = filterCommands(input);
39
+ const lines = [
40
+ `╭─ Commands ─${"─".repeat(24)}╮`,
41
+ `│ > ${(input ?? "").padEnd(32)} │`,
42
+ `│${" ".repeat(36)}│`,
43
+ ...matches.slice(0, 8).map((c) => `│ ${(c.name.padEnd(14))} ${c.hint.slice(0, 18).padEnd(18)} │`),
44
+ ...(matches.length === 0 ? [`│ (no match)${" ".repeat(24)} │`] : []),
45
+ `╰${"─".repeat(36)}╯`,
46
+ ];
47
+ return lines.join("\n");
48
+ }