@cirvix_ai/agent-control 0.1.2 → 0.1.5
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 +76 -17
- package/bin/cirvix.mjs +488 -40
- package/bin/escape-benchmark.mjs +67 -0
- package/package.json +36 -16
- package/src/adapters/base.mjs +150 -0
- package/src/adapters/claude-code.mjs +161 -0
- package/src/adapters/cline.mjs +107 -0
- package/src/adapters/codex.mjs +104 -0
- package/src/adapters/cursor.mjs +104 -0
- package/src/adapters/frameworks.mjs +110 -0
- package/src/adapters/gemini-cli.mjs +104 -0
- package/src/adapters/generic-mcp.mjs +101 -0
- package/src/adapters/index.mjs +209 -0
- package/src/adapters/roo-code.mjs +106 -0
- package/src/adapters/vscode.mjs +104 -0
- package/src/adapters/windsurf.mjs +107 -0
- package/src/commands/demo.mjs +56 -70
- package/src/commands/doctor.mjs +235 -0
- package/src/commands/init.mjs +292 -30
- package/src/commands/interactive.mjs +690 -0
- package/src/commands/kill.mjs +74 -0
- package/src/commands/login.mjs +227 -0
- package/src/commands/passport.mjs +149 -0
- package/src/commands/policy.mjs +10 -6
- package/src/commands/protect.mjs +293 -0
- package/src/commands/prove.mjs +209 -0
- package/src/commands/redteam.mjs +51 -0
- package/src/commands/scan.mjs +6 -4
- package/src/commands/shadow.mjs +62 -0
- package/src/commands/simulate.mjs +96 -0
- package/src/commands/status.mjs +121 -36
- package/src/commands/upgrade.mjs +17 -9
- package/src/commands/welcome.mjs +105 -0
- package/src/core/authority.mjs +909 -0
- package/src/core/baseline.mjs +97 -0
- package/src/core/config-store.mjs +280 -0
- package/src/core/cost.mjs +0 -0
- package/src/core/detect.mjs +4 -33
- package/src/core/entitlements.mjs +6 -0
- package/src/core/escape-benchmark.mjs +597 -0
- package/src/core/evidence.mjs +212 -0
- package/src/core/format.mjs +27 -0
- package/src/core/gateway.mjs +15 -211
- package/src/core/graph.mjs +270 -0
- package/src/core/guard.mjs +118 -4
- package/src/core/intent.mjs +166 -0
- package/src/core/journal.mjs +131 -40
- package/src/core/kill-switch.mjs +122 -0
- package/src/core/notices.mjs +22 -2
- package/src/core/packs.mjs +193 -0
- package/src/core/passport.mjs +555 -0
- package/src/core/pipeline.mjs +148 -6
- package/src/core/prompts.mjs +51 -0
- package/src/core/proof.mjs +440 -0
- package/src/core/redteam/index.mjs +185 -0
- package/src/core/referral.mjs +187 -0
- package/src/core/sandbox.mjs +139 -0
- package/src/core/session.mjs +172 -0
- package/src/core/shadow.mjs +95 -0
- package/src/core/trifecta.mjs +321 -0
- package/src/core/ui/controller.mjs +192 -0
- package/src/core/ui/decisions.mjs +55 -0
- package/src/core/ui/index.mjs +49 -0
- package/src/core/ui/intercept.mjs +103 -0
- package/src/core/ui/live.mjs +51 -0
- package/src/core/ui/primitives.mjs +123 -0
- package/src/core/ui/theme.mjs +92 -0
- package/src/core/verified.mjs +108 -0
- package/src/core/windows.mjs +270 -0
- package/src/index.mjs +25 -0
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cirvix` interactive — full-screen terminal application.
|
|
3
|
+
*
|
|
4
|
+
* ONE COMMAND: `cirvix` launches the live runtime.
|
|
5
|
+
* Existing `cirvix status` / `logs` / `demo` etc. remain one-shot for scripts/CI.
|
|
6
|
+
*
|
|
7
|
+
* Zero new deps. Uses existing ui/* + format + real Pipeline/Journal/Audit.
|
|
8
|
+
* Respects: !isTTY / CI / --json / --fast / NO_COLOR / TERM=dumb / CIRVIX_NO_ANIM.
|
|
9
|
+
* Restores cursor/rawMode/alt-screen on Ctrl+C / SIGINT / SIGTERM / crash.
|
|
10
|
+
*
|
|
11
|
+
* FIX: stable viewport — ONE authoritative render, coalesced, viewport-cleared.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { access, mkdir, readFile } from "node:fs/promises";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { watch } from "node:fs";
|
|
17
|
+
|
|
18
|
+
import { AuditChain } from "../core/audit.mjs";
|
|
19
|
+
import { ApprovalStore } from "../core/approvals.mjs";
|
|
20
|
+
import { bold, dim, green, red, amber, blue, cyan, plural } from "../core/format.mjs";
|
|
21
|
+
import { shouldAnimate } from "../core/ui/controller.mjs";
|
|
22
|
+
import { brandHeader } from "../core/ui/primitives.mjs";
|
|
23
|
+
import { MODE } from "../core/decisions.mjs";
|
|
24
|
+
import * as journal from "../core/journal.mjs";
|
|
25
|
+
import { collectMcpServers, detectRuntimes } from "../core/detect.mjs";
|
|
26
|
+
import { UdsClient, defaultEndpoint, tokenPath } from "../core/uds.mjs";
|
|
27
|
+
|
|
28
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// helpers: real state
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
async function exists(p) {
|
|
35
|
+
try { await access(p); return true; } catch { return false; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function probeRuntime(stateDir) {
|
|
39
|
+
const endpoint = defaultEndpoint(stateDir);
|
|
40
|
+
if (!(await exists(tokenPath(stateDir)))) return { running: false, reason: "no token" };
|
|
41
|
+
try {
|
|
42
|
+
const token = (await readFile(tokenPath(stateDir), "utf8")).trim();
|
|
43
|
+
const c = new UdsClient({ endpoint, token, timeoutMs: 1200 });
|
|
44
|
+
const s = await c.call("cirvix/status", {});
|
|
45
|
+
return { running: true, live: s };
|
|
46
|
+
} catch { return { running: false, reason: "nothing listening" }; }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function loadStatusData(cwd, stateDir, rules) {
|
|
50
|
+
const [runtimes, runtime, records] = await Promise.all([
|
|
51
|
+
detectRuntimes().catch(() => []),
|
|
52
|
+
probeRuntime(stateDir),
|
|
53
|
+
journal.read(join(stateDir, "audit.jsonl")).catch(() => []),
|
|
54
|
+
]);
|
|
55
|
+
const servers = collectMcpServers(runtimes);
|
|
56
|
+
const stats = journal.summarize(records);
|
|
57
|
+
let approvals = { pending: 0, total: 0 };
|
|
58
|
+
const ap = join(stateDir, "approvals.jsonl");
|
|
59
|
+
if (await exists(ap)) {
|
|
60
|
+
try { const s = await new ApprovalStore(ap).open(); approvals = { pending: s.pending().length, total: s.all().length }; } catch {}
|
|
61
|
+
}
|
|
62
|
+
let tests = { total: 0, passed: null };
|
|
63
|
+
try {
|
|
64
|
+
const { loadPolicyFile } = await import("./policy.mjs");
|
|
65
|
+
for (const cand of ["cirvix.policy", "cirvix.policy.json", ".cirvix/policy.json"]) {
|
|
66
|
+
const p = join(cwd, cand);
|
|
67
|
+
if (await exists(p)) {
|
|
68
|
+
const loaded = await loadPolicyFile(p, { cwd });
|
|
69
|
+
tests.total = loaded.tests?.length ?? 0;
|
|
70
|
+
if (tests.total > 0) {
|
|
71
|
+
const { evaluate } = await import("../core/policy.mjs");
|
|
72
|
+
const { normalize, policyRequest } = await import("../core/normalize.mjs");
|
|
73
|
+
const { toDecision } = await import("../core/decisions.mjs");
|
|
74
|
+
let passed = 0;
|
|
75
|
+
for (const t of loaded.tests) {
|
|
76
|
+
try {
|
|
77
|
+
const call = normalize({ tool: t.call.tool, arguments: t.call.arguments }, { cwd });
|
|
78
|
+
const dec = evaluate(policyRequest(call), loaded.rules, { cwd });
|
|
79
|
+
const actual = dec.decision ?? toDecision(dec.verdict);
|
|
80
|
+
const exp = String(t.expect ?? "").toLowerCase();
|
|
81
|
+
const expected = { allow: "allow", deny: "deny", hold: "require_approval", sanitize: "sanitize" }[exp] ?? exp;
|
|
82
|
+
if (actual === expected) passed++;
|
|
83
|
+
} catch {}
|
|
84
|
+
}
|
|
85
|
+
tests.passed = passed;
|
|
86
|
+
}
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
} catch {}
|
|
91
|
+
return { runtimes, runtime, servers, stats, approvals, tests, rulesCount: rules?.length ?? 0 };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// terminal control — ONE viewport, robust on Windows
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
let inAltScreen = false;
|
|
99
|
+
function enterAltScreen() {
|
|
100
|
+
if (inAltScreen) return;
|
|
101
|
+
try { process.stdout.write("\x1b[?1049h"); inAltScreen = true; } catch {}
|
|
102
|
+
}
|
|
103
|
+
function exitAltScreen() {
|
|
104
|
+
if (!inAltScreen) return;
|
|
105
|
+
try { process.stdout.write("\x1b[?1049l"); inAltScreen = false; } catch {}
|
|
106
|
+
}
|
|
107
|
+
function hideCursor() { try { process.stdout.write("\x1b[?25l"); } catch {} }
|
|
108
|
+
function showCursor() { try { process.stdout.write("\x1b[?25h"); } catch {} }
|
|
109
|
+
function setRaw(enable) {
|
|
110
|
+
if (process.stdin.isTTY) {
|
|
111
|
+
try { process.stdin.setRawMode(enable); } catch {}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// should we launch interactive?
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
export function canLaunchInteractive(flags, positional) {
|
|
120
|
+
if (flags.json) return false;
|
|
121
|
+
if (flags.fast) return false;
|
|
122
|
+
if (process.env.CIRVIX_NO_ANIM === "1") return false;
|
|
123
|
+
if (process.env.NO_COLOR !== undefined) return false;
|
|
124
|
+
if (process.env.TERM === "dumb") return false;
|
|
125
|
+
if (process.env.CI !== undefined && process.env.FORCE_COLOR !== "1") return false;
|
|
126
|
+
if (!process.stdout.isTTY || !process.stdin.isTTY) return false;
|
|
127
|
+
if (positional.length === 0) return true;
|
|
128
|
+
if (positional.length === 1 && positional[0] === "help") return true;
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// main interactive loop
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
export async function interactive({ cwd, flags, rules }) {
|
|
137
|
+
const stateDir = String(flags.state ?? join(cwd, ".cirvix"));
|
|
138
|
+
await mkdir(stateDir, { recursive: true }).catch(() => {});
|
|
139
|
+
const animated = shouldAnimate({ pace: flags.pace ? Number(flags.pace) : 700, json: false }) && !flags.fast;
|
|
140
|
+
|
|
141
|
+
// Enter alt screen BEFORE startup so startup is inside the single viewport
|
|
142
|
+
enterAltScreen();
|
|
143
|
+
hideCursor();
|
|
144
|
+
// Ensure we leave alt screen and restore cursor/raw on any exit
|
|
145
|
+
let cleaned = false;
|
|
146
|
+
const doCleanup = () => {
|
|
147
|
+
if (cleaned) return;
|
|
148
|
+
cleaned = true;
|
|
149
|
+
try { if (watcher) watcher.close(); } catch {}
|
|
150
|
+
if (poll) clearInterval(poll);
|
|
151
|
+
clearInterval(statusPoll);
|
|
152
|
+
if (messageTimer) clearTimeout(messageTimer);
|
|
153
|
+
if (resizeHandler) process.stdout.off("resize", resizeHandler);
|
|
154
|
+
process.stdin.off("data", onData);
|
|
155
|
+
setRaw(false);
|
|
156
|
+
showCursor();
|
|
157
|
+
exitAltScreen();
|
|
158
|
+
try { process.stdin.pause(); } catch {}
|
|
159
|
+
};
|
|
160
|
+
const cleanupAndExit = (code) => {
|
|
161
|
+
doCleanup();
|
|
162
|
+
try { process.stdout.write("\n"); } catch {}
|
|
163
|
+
process.exit(code);
|
|
164
|
+
};
|
|
165
|
+
process.on("SIGINT", () => cleanupAndExit(0));
|
|
166
|
+
process.on("SIGTERM", () => cleanupAndExit(0));
|
|
167
|
+
process.on("uncaughtException", (e) => { doCleanup(); try { process.stderr.write(red(String(e.message)) + "\n"); } catch {} process.exit(1); });
|
|
168
|
+
process.on("exit", () => { if (!cleaned) doCleanup(); });
|
|
169
|
+
|
|
170
|
+
// --- startup animation (once, inside alt screen) ---
|
|
171
|
+
// Use a single write for the whole startup frame, then transition to TUI
|
|
172
|
+
try { process.stdout.write("\x1b[2J\x1b[H"); } catch {}
|
|
173
|
+
if (animated) {
|
|
174
|
+
// Build startup frame as ONE write, not incremental appends that would appear as multiple viewports
|
|
175
|
+
// We still animate steps but each step is a coalesced viewport update via the same alt-screen clear
|
|
176
|
+
let startup = "\n" + brandHeader({ width: 62 }) + "\n\n";
|
|
177
|
+
startup += ` ${dim("Initializing runtime...")}\n`;
|
|
178
|
+
try { process.stdout.write(startup); } catch {}
|
|
179
|
+
const steps = [
|
|
180
|
+
"Runtime initialized",
|
|
181
|
+
"Policy engine loaded",
|
|
182
|
+
`${plural(rules?.length ?? 0, "rule")} loaded`,
|
|
183
|
+
"Secrets protected",
|
|
184
|
+
"Audit chain ready",
|
|
185
|
+
];
|
|
186
|
+
for (const s of steps) {
|
|
187
|
+
await sleep(120);
|
|
188
|
+
// Append one line to the existing viewport by moving cursor and writing line
|
|
189
|
+
// Instead of clearing, we just add a line — this is part of the startup sequence and is intentional
|
|
190
|
+
// But to keep ONE viewport, we redraw the whole startup frame each time
|
|
191
|
+
// Simpler: just write the line and keep viewport growing during startup (startup is transient)
|
|
192
|
+
try { process.stdout.write(` ${green("✓")} ${dim(s)}\n`); } catch {}
|
|
193
|
+
}
|
|
194
|
+
await sleep(180);
|
|
195
|
+
try { process.stdout.write(`\n ${green(bold("CIRVIX ● ONLINE"))}\n\n`); } catch {}
|
|
196
|
+
await sleep(260);
|
|
197
|
+
// Clear startup and enter TUI — single viewport from here on
|
|
198
|
+
try { process.stdout.write("\x1b[2J\x1b[H"); } catch {}
|
|
199
|
+
} else {
|
|
200
|
+
try {
|
|
201
|
+
process.stdout.write(`\n ${bold("CIRVIX")} ${dim("· runtime governance")}\n\n`);
|
|
202
|
+
process.stdout.write(` ${green(bold("CIRVIX ● ONLINE"))}\n\n`);
|
|
203
|
+
await sleep(120);
|
|
204
|
+
try { process.stdout.write("\x1b[2J\x1b[H"); } catch {}
|
|
205
|
+
} catch {}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// --- state ---
|
|
209
|
+
let view = "activity"; // activity | policies | audit | intercept | demo | help
|
|
210
|
+
let selected = 0;
|
|
211
|
+
let detailId = null; // request_id for inspector
|
|
212
|
+
let helpOpen = false;
|
|
213
|
+
let inputMode = false;
|
|
214
|
+
let inputBuf = "";
|
|
215
|
+
let message = ""; // transient message line
|
|
216
|
+
let messageTimer = null;
|
|
217
|
+
let isRunningDemo = false;
|
|
218
|
+
let pendingApprovals = [];
|
|
219
|
+
let events = [];
|
|
220
|
+
let statusData = null;
|
|
221
|
+
let auditData = null;
|
|
222
|
+
let policyTestData = null;
|
|
223
|
+
|
|
224
|
+
// load initial data
|
|
225
|
+
const refresh = async () => {
|
|
226
|
+
try {
|
|
227
|
+
statusData = await loadStatusData(cwd, stateDir, rules);
|
|
228
|
+
const recs = await journal.read(join(stateDir, "audit.jsonl"));
|
|
229
|
+
events = journal.query(recs, { last: 20 });
|
|
230
|
+
if (selected >= events.length) selected = Math.max(0, events.length - 1);
|
|
231
|
+
const ap = join(stateDir, "approvals.jsonl");
|
|
232
|
+
if (await exists(ap)) {
|
|
233
|
+
const store = await new ApprovalStore(ap).open();
|
|
234
|
+
pendingApprovals = store.pending();
|
|
235
|
+
} else pendingApprovals = [];
|
|
236
|
+
const chain = new AuditChain(join(stateDir, "audit.jsonl"));
|
|
237
|
+
auditData = await chain.verify();
|
|
238
|
+
} catch {}
|
|
239
|
+
};
|
|
240
|
+
await refresh();
|
|
241
|
+
|
|
242
|
+
// --- render scheduler: ONE authoritative render, coalesced, no concurrent writes ---
|
|
243
|
+
let renderPending = false;
|
|
244
|
+
let renderRunning = false;
|
|
245
|
+
let renderScheduled = false;
|
|
246
|
+
|
|
247
|
+
function doRender() {
|
|
248
|
+
// Build ONE frame and write it as a single atomic viewport update
|
|
249
|
+
const W = Math.min(78, (process.stdout.columns || 80) - 4);
|
|
250
|
+
let out = "";
|
|
251
|
+
// Move to home and clear viewport — robust: hide cursor already, now clear
|
|
252
|
+
// \x1b[H = cursor home, \x1b[2J = erase display, \x1b[3J = erase scrollback (where supported)
|
|
253
|
+
// On Windows, \x1b[2J\x1b[H is reliable for viewport; \x1b[3J clears scrollback to prevent duplication in alt-screen exit
|
|
254
|
+
out += "\x1b[?25l"; // keep hidden during draw
|
|
255
|
+
out += "\x1b[H\x1b[2J";
|
|
256
|
+
// Some terminals need scrollback clear
|
|
257
|
+
out += "\x1b[3J";
|
|
258
|
+
out += "\x1b[H";
|
|
259
|
+
|
|
260
|
+
// header
|
|
261
|
+
out += ` ${dim(`┌─ CIRVIX ${"─".repeat(Math.max(0, W - 10))}┐`)}\n`;
|
|
262
|
+
const runtimeStatus = statusData?.runtime?.running ? `${green(bold("● ONLINE"))} ${dim("ENFORCE")} ${dim(plural(statusData.rulesCount ?? 0, "RULE"))}` : `${dim("● STOPPED")} ${dim("IDLE")}`;
|
|
263
|
+
out += ` ${dim("│")} ${bold("CIRVIX")} ${runtimeStatus} ${dim("│")}\n`;
|
|
264
|
+
out += ` ${dim("├" + "─".repeat(W) + "┤")}\n`;
|
|
265
|
+
|
|
266
|
+
if (helpOpen) {
|
|
267
|
+
out += `\n ${bold("CIRVIX HELP")} ${dim("press Esc or ? to close")}\n\n`;
|
|
268
|
+
const helps = [
|
|
269
|
+
["?", "show help"],
|
|
270
|
+
["q", "quit"],
|
|
271
|
+
["r", "refresh"],
|
|
272
|
+
["l", "activity/logs"],
|
|
273
|
+
["p", "policies"],
|
|
274
|
+
["a", "audit"],
|
|
275
|
+
["d", "demo"],
|
|
276
|
+
["i", "interceptions"],
|
|
277
|
+
["↑/↓", "navigate events"],
|
|
278
|
+
["Enter", "inspect selected event"],
|
|
279
|
+
["Esc", "close detail view"],
|
|
280
|
+
["Ctrl+C", "exit cleanly"],
|
|
281
|
+
];
|
|
282
|
+
for (const [k, d] of helps) out += ` ${cyan(k.padEnd(8))} ${dim(d)}\n`;
|
|
283
|
+
out += `\n ${dim("Commands inside session:")} ${blue("status")} ${dim("·")} ${blue("logs")} ${dim("·")} ${blue("policy test")} ${dim("·")} ${blue("audit verify")} ${dim("·")} ${blue("demo")} ${dim("·")} ${blue("why <id>")}\n`;
|
|
284
|
+
out += `\n ${dim("─".repeat(W))}\n`;
|
|
285
|
+
out += ` ${dim("P50")} ${statusData?.stats?.latency?.p50 ?? "—"}ms ${dim("P95")} ${statusData?.stats?.latency?.p95 ?? "—"}ms ${dim("P99")} ${statusData?.stats?.latency?.p99 ?? "—"}ms ${auditData?.ok ? green("Audit ✓ INTEGRITY OK") : red("Audit ✕ BROKEN")}\n`;
|
|
286
|
+
out += ` ${dim("└" + "─".repeat(W) + "┘")}\n`;
|
|
287
|
+
out += `\n ${dim("$")} ${inputBuf}${inputMode ? "█" : dim("_")} ${message ? dim("— " + message) : ""}\n`;
|
|
288
|
+
// ensure output ends with exactly one frame, no extra newlines that would scroll
|
|
289
|
+
try { process.stdout.write(out); } catch {}
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (detailId) {
|
|
294
|
+
const rec = events.find((e) => (e.request_id === detailId || e.decision_id === detailId)) || journal.find(events, detailId);
|
|
295
|
+
if (rec) {
|
|
296
|
+
const tree = journal.renderTree(rec);
|
|
297
|
+
out += `\n${tree}\n\n`;
|
|
298
|
+
out += ` ${dim("[Esc] Back")} ${dim("·")} ${dim("↑/↓ navigate")} ${isHoldRecord(rec) ? amber("[A] Approve [R] Reject") : ""}\n`;
|
|
299
|
+
out += `\n ${dim("─".repeat(W))}\n`;
|
|
300
|
+
out += ` ${dim("$")} ${inputBuf}${inputMode ? "█" : dim("_")}\n`;
|
|
301
|
+
try { process.stdout.write(out); } catch {}
|
|
302
|
+
return;
|
|
303
|
+
} else {
|
|
304
|
+
out += `\n ${red("No decision with id " + detailId)}\n\n`;
|
|
305
|
+
out += ` ${dim("[Esc] Back")}\n`;
|
|
306
|
+
try { process.stdout.write(out); } catch {}
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (view === "policies") {
|
|
312
|
+
out += `\n ${bold("CIRVIX POLICIES")} ${dim(`${statusData?.rulesCount ?? 0} rules`)}\n\n`;
|
|
313
|
+
if (policyTestData) {
|
|
314
|
+
out += policyTestData + "\n";
|
|
315
|
+
} else {
|
|
316
|
+
out += ` ${dim("Press")} ${cyan("p")} ${dim("to run")} ${blue("cirvix policy test")} ${dim("inside session, or type")} ${blue("policy test")}\n`;
|
|
317
|
+
out += ` ${dim("Last:")} ${statusData?.tests?.passed ?? "—"}/${statusData?.tests?.total ?? "—"} passed\n`;
|
|
318
|
+
}
|
|
319
|
+
} else if (view === "audit") {
|
|
320
|
+
out += `\n ${bold("CIRVIX AUDIT")}\n\n`;
|
|
321
|
+
if (auditData?.ok) {
|
|
322
|
+
out += ` ${green("✓ Hash chain intact")}\n`;
|
|
323
|
+
out += ` ${green("✓ " + (auditData.records ?? 0) + " records verified")}\n`;
|
|
324
|
+
out += ` ${green("✓ No records altered")}\n\n`;
|
|
325
|
+
out += ` ${bold("CHAIN")}\n\n ${dim("current")}\n ${dim("↓")}\n ${dim("previous")}\n ${dim("↓")}\n ${dim("genesis")}\n\n`;
|
|
326
|
+
out += ` ${bold("STATUS")} ${green(bold("● INTEGRITY OK"))}\n`;
|
|
327
|
+
} else {
|
|
328
|
+
out += ` ${red(bold("chain broken"))} ${auditData?.brokenAt ?? ""} ${auditData?.reason ?? ""}\n`;
|
|
329
|
+
}
|
|
330
|
+
} else if (view === "demo" && isRunningDemo) {
|
|
331
|
+
out += `\n ${dim("Running demo...")} ${dim("(real pipeline, streaming)")}\n\n`;
|
|
332
|
+
} else {
|
|
333
|
+
out += `\n ${bold("ACTIVITY")} ${dim("live — use ↑/↓ to navigate, Enter to inspect")}\n\n`;
|
|
334
|
+
if (events.length === 0) {
|
|
335
|
+
out += ` ${dim("no decisions yet — run")} ${blue("cirvix demo")} ${dim("or start gateway")}\n`;
|
|
336
|
+
} else {
|
|
337
|
+
const start = Math.max(0, events.length - 12);
|
|
338
|
+
const slice = events.slice(start);
|
|
339
|
+
for (let i = 0; i < slice.length; i++) {
|
|
340
|
+
const e = slice[i];
|
|
341
|
+
const idx = start + i;
|
|
342
|
+
const isSel = idx === selected;
|
|
343
|
+
const dec = e.decision ?? "unknown";
|
|
344
|
+
const isBlock = dec === "deny";
|
|
345
|
+
const isHold = dec === "require_approval";
|
|
346
|
+
const isSan = dec === "sanitize";
|
|
347
|
+
const icon = isBlock ? red("✕") : isHold ? amber("●") : isSan ? amber("◇") : green("✓");
|
|
348
|
+
const label = isBlock ? red("BLOCK") : isHold ? amber("APPROVAL") : isSan ? amber("SANITIZE") : green("ALLOW");
|
|
349
|
+
const risk = String(e.risk ?? "—").toUpperCase();
|
|
350
|
+
const riskTone = risk === "CRITICAL" ? red(risk) : risk === "HIGH" ? amber(risk) : dim(risk);
|
|
351
|
+
const time = String(e.ts ?? e.timestamp ?? "").slice(11, 19) || "—";
|
|
352
|
+
const tool = String(e.tool ?? e.action ?? "—");
|
|
353
|
+
const target = String(e.resource ?? e.command ?? "").slice(-36);
|
|
354
|
+
const latency = `${e.latency_ms ?? "—"}ms`;
|
|
355
|
+
const line = ` ${dim(time)} ${icon} ${label.padEnd(8)} ${riskTone.padEnd(10)} ${tool.padEnd(18)} ${dim(target.padEnd(36))} ${dim(latency.padStart(7))}`;
|
|
356
|
+
out += (isSel ? `${cyan("▶")} ` : " ") + (isSel ? bold(line) : line) + "\n";
|
|
357
|
+
if (isSel && isBlock) {
|
|
358
|
+
out += ` ${dim("Policy:")} ${e.policy ?? e.rule ?? "—"} ${dim("Agent:")} ${e.agent ?? "—"}\n`;
|
|
359
|
+
}
|
|
360
|
+
if (isSel && isHold) {
|
|
361
|
+
out += ` ${amber("→ AWAITING APPROVAL")} ${dim(e.policy ?? "")}\n`;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
if (events.length > 0) {
|
|
366
|
+
const last = events[events.length - 1];
|
|
367
|
+
const lastDec = last.decision ?? "";
|
|
368
|
+
if (lastDec === "deny" || last.risk === "critical") {
|
|
369
|
+
out += `\n ${dim("SECURITY")}\n\n`;
|
|
370
|
+
out += ` ${dim("Last decision:")} ${red(bold("✕ BLOCKED"))} ${dim(last.policy ?? last.rule ?? "")}\n`;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
out += `\n ${dim("─".repeat(W))}\n`;
|
|
376
|
+
const p = statusData?.stats?.latency;
|
|
377
|
+
out += ` ${dim("P50")} ${p?.p50 ?? "—"}ms ${dim("P95")} ${p?.p95 ?? "—"}ms ${dim("P99")} ${p?.p99 ?? "—"}ms ${auditData?.ok ? green("Audit ✓ INTEGRITY OK") : dim("Audit —")}`;
|
|
378
|
+
if (message) out += ` ${dim("·")} ${message}`;
|
|
379
|
+
out += `\n`;
|
|
380
|
+
out += ` ${dim("├" + "─".repeat(W) + "┤")}\n`;
|
|
381
|
+
const prompt = inputMode ? `$ ${inputBuf}█` : `$ ${inputBuf}${dim("_")} ${dim("? help q quit r refresh l logs p pol a audit d demo i intercept ↑↓ nav Enter inspect")}`;
|
|
382
|
+
out += ` ${dim("│")} ${prompt.slice(0, W - 4).padEnd(W - 4)} ${dim("│")}\n`;
|
|
383
|
+
out += ` ${dim("└" + "─".repeat(W) + "┘")}\n`;
|
|
384
|
+
try { process.stdout.write(out); } catch {}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function requestRender() {
|
|
388
|
+
if (renderRunning) {
|
|
389
|
+
renderScheduled = true;
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
if (renderPending) return;
|
|
393
|
+
renderPending = true;
|
|
394
|
+
// coalesce to next tick so multiple state updates in same tick result in one frame
|
|
395
|
+
setImmediate(() => {
|
|
396
|
+
renderPending = false;
|
|
397
|
+
if (renderRunning) {
|
|
398
|
+
renderScheduled = true;
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
renderRunning = true;
|
|
402
|
+
try {
|
|
403
|
+
doRender();
|
|
404
|
+
} finally {
|
|
405
|
+
renderRunning = false;
|
|
406
|
+
if (renderScheduled) {
|
|
407
|
+
renderScheduled = false;
|
|
408
|
+
requestRender();
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function isHoldRecord(rec) {
|
|
415
|
+
const d = rec.decision ?? rec.verdict;
|
|
416
|
+
return d === "require_approval" || d === "hold";
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// watcher for live activity — NEVER directly print, only update state + requestRender
|
|
420
|
+
let watcher = null;
|
|
421
|
+
let poll = null;
|
|
422
|
+
let knownLen = events.length;
|
|
423
|
+
const pollLive = async () => {
|
|
424
|
+
try {
|
|
425
|
+
const recs = await journal.read(join(stateDir, "audit.jsonl"));
|
|
426
|
+
if (recs.length > knownLen) {
|
|
427
|
+
const fresh = recs.slice(knownLen);
|
|
428
|
+
events = journal.query(recs, { last: 20 });
|
|
429
|
+
knownLen = recs.length;
|
|
430
|
+
if (selected === events.length - 2) selected = events.length - 1;
|
|
431
|
+
const last = fresh[fresh.length - 1];
|
|
432
|
+
if (last && (last.decision === "deny" || last.risk === "critical")) {
|
|
433
|
+
view = "activity";
|
|
434
|
+
detailId = null;
|
|
435
|
+
message = `✕ BLOCKED ${last.tool ?? ""} — ${last.policy ?? ""}`;
|
|
436
|
+
if (messageTimer) clearTimeout(messageTimer);
|
|
437
|
+
messageTimer = setTimeout(() => { message = ""; requestRender(); }, 3000);
|
|
438
|
+
}
|
|
439
|
+
requestRender();
|
|
440
|
+
} else if (recs.length < knownLen) {
|
|
441
|
+
knownLen = recs.length;
|
|
442
|
+
events = journal.query(recs, { last: 20 });
|
|
443
|
+
requestRender();
|
|
444
|
+
}
|
|
445
|
+
// refresh status periodically via same poll, but coalesced
|
|
446
|
+
statusData = await loadStatusData(cwd, stateDir, rules);
|
|
447
|
+
requestRender();
|
|
448
|
+
} catch {}
|
|
449
|
+
};
|
|
450
|
+
try {
|
|
451
|
+
watcher = watch(join(stateDir, "audit.jsonl"), () => { void pollLive(); });
|
|
452
|
+
} catch {
|
|
453
|
+
poll = setInterval(() => { void pollLive(); }, 900);
|
|
454
|
+
}
|
|
455
|
+
if (!watcher) poll = poll ?? setInterval(() => { void pollLive(); }, 900);
|
|
456
|
+
const statusPoll = setInterval(() => { void refresh().then(() => requestRender()); }, 3000);
|
|
457
|
+
|
|
458
|
+
// --- helpers ---
|
|
459
|
+
function setMessage(msg, ms = 2000) {
|
|
460
|
+
message = msg;
|
|
461
|
+
requestRender();
|
|
462
|
+
if (messageTimer) clearTimeout(messageTimer);
|
|
463
|
+
messageTimer = setTimeout(() => { message = ""; requestRender(); }, ms);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// --- input handling ---
|
|
467
|
+
const onData = async (chunk) => {
|
|
468
|
+
const s = chunk.toString("utf8");
|
|
469
|
+
|
|
470
|
+
if (s === "\x03") {
|
|
471
|
+
cleanupAndExit(0);
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
if (s.startsWith("\x1b[")) {
|
|
475
|
+
if (s === "\x1b[A") {
|
|
476
|
+
if (detailId) return;
|
|
477
|
+
selected = Math.max(0, selected - 1);
|
|
478
|
+
requestRender();
|
|
479
|
+
} else if (s === "\x1b[B") {
|
|
480
|
+
if (detailId) return;
|
|
481
|
+
selected = Math.min(events.length - 1, selected + 1);
|
|
482
|
+
requestRender();
|
|
483
|
+
} else if (s === "\x1b[1;2A" || s === "\x1b[1;2B") {
|
|
484
|
+
// shift+arrow ignored
|
|
485
|
+
}
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
if (!inputMode) {
|
|
489
|
+
if (s === "?") { helpOpen = !helpOpen; requestRender(); return; }
|
|
490
|
+
if (s === "q" || s === "Q") { cleanupAndExit(0); return; }
|
|
491
|
+
if (s === "r" || s === "R") { await refresh(); requestRender(); setMessage("refreshed", 1200); return; }
|
|
492
|
+
if (s === "l" || s === "L") { view = "activity"; detailId = null; helpOpen = false; selected = events.length - 1; requestRender(); return; }
|
|
493
|
+
if (s === "p" || s === "P") {
|
|
494
|
+
view = "policies"; detailId = null; helpOpen = false;
|
|
495
|
+
policyTestData = null; requestRender();
|
|
496
|
+
setMessage("running policy test...", 3000);
|
|
497
|
+
try {
|
|
498
|
+
const { loadPolicyFile } = await import("./policy.mjs");
|
|
499
|
+
let pp = null;
|
|
500
|
+
for (const cand of ["cirvix.policy", "cirvix.policy.json", ".cirvix/policy.json"]) {
|
|
501
|
+
const p = join(cwd, cand);
|
|
502
|
+
if (await exists(p)) { pp = p; break; }
|
|
503
|
+
}
|
|
504
|
+
if (!pp) policyTestData = `\n ${red("No policy file found.")}\n`;
|
|
505
|
+
else {
|
|
506
|
+
const { output } = await (await import("./policy.mjs")).test({ path: pp, cwd, json: false });
|
|
507
|
+
policyTestData = output;
|
|
508
|
+
}
|
|
509
|
+
} catch (e) { policyTestData = `\n ${red(String(e.message))}\n`; }
|
|
510
|
+
requestRender();
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
if (s === "a" || s === "A") { view = "audit"; detailId = null; helpOpen = false; await refresh(); requestRender(); return; }
|
|
514
|
+
if (s === "d" || s === "D") {
|
|
515
|
+
if (isRunningDemo) return;
|
|
516
|
+
view = "activity"; isRunningDemo = true; detailId = null; helpOpen = false; requestRender();
|
|
517
|
+
setMessage("demo streaming — real pipeline", 2000);
|
|
518
|
+
try {
|
|
519
|
+
const { demo } = await import("./demo.mjs");
|
|
520
|
+
await demo({ cwd, pace: 140, stateDir, write: () => {} });
|
|
521
|
+
await refresh(); isRunningDemo = false; requestRender(); setMessage("demo complete — 6 allowed 1 sanitized 3 blocked", 3000);
|
|
522
|
+
} catch (e) { isRunningDemo = false; setMessage(red(String(e.message)), 3000); }
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
if (s === "i" || s === "I") {
|
|
526
|
+
const idx = events.findIndex((e, i) => i > selected && (e.decision === "deny" || e.risk === "critical"));
|
|
527
|
+
if (idx !== -1) { selected = idx; detailId = events[idx].request_id ?? events[idx].decision_id; requestRender(); }
|
|
528
|
+
else setMessage("no more interceptions", 1500);
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
if (s === "\r" || s === "\n") {
|
|
532
|
+
if (events[selected]) { detailId = events[selected].request_id ?? events[selected].decision_id ?? events[selected].request_id; helpOpen = false; requestRender(); }
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
if (s === "\x1b") {
|
|
536
|
+
if (helpOpen) { helpOpen = false; requestRender(); return; }
|
|
537
|
+
if (detailId) { detailId = null; requestRender(); return; }
|
|
538
|
+
if (inputMode) { inputMode = false; inputBuf = ""; requestRender(); return; }
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
if (s.length === 1 && s >= " " && s <= "~") {
|
|
542
|
+
inputMode = true;
|
|
543
|
+
inputBuf = s;
|
|
544
|
+
requestRender();
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
if (detailId) {
|
|
548
|
+
const rec = events.find((e) => (e.request_id === detailId || e.decision_id === detailId));
|
|
549
|
+
if (rec && isHoldRecord(rec)) {
|
|
550
|
+
if (s === "a" || s === "A") {
|
|
551
|
+
try {
|
|
552
|
+
const store = await new ApprovalStore(join(stateDir, "approvals.jsonl")).open();
|
|
553
|
+
const by = process.env.CIRVIX_APPROVER ?? "interactive@cirvix";
|
|
554
|
+
await store.decide(rec.approval_id ?? rec.request_id, "approved", by);
|
|
555
|
+
setMessage(green("APPROVED by " + by), 2000);
|
|
556
|
+
await refresh(); detailId = null; requestRender();
|
|
557
|
+
} catch (e) { setMessage(red(String(e.message)), 3000); }
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
if (s === "r" || s === "R") {
|
|
561
|
+
try {
|
|
562
|
+
const store = await new ApprovalStore(join(stateDir, "approvals.jsonl")).open();
|
|
563
|
+
const by = process.env.CIRVIX_APPROVER ?? "interactive@cirvix";
|
|
564
|
+
await store.decide(rec.approval_id ?? rec.request_id, "denied", by);
|
|
565
|
+
setMessage(red("REJECTED by " + by), 2000);
|
|
566
|
+
await refresh(); detailId = null; requestRender();
|
|
567
|
+
} catch (e) { setMessage(red(String(e.message)), 3000); }
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if (s === "\x03") { cleanupAndExit(0); return; }
|
|
576
|
+
if (s === "\x7f" || s === "\x08") {
|
|
577
|
+
inputBuf = inputBuf.slice(0, -1);
|
|
578
|
+
requestRender();
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
if (s === "\x1b") {
|
|
582
|
+
inputMode = false; inputBuf = ""; requestRender(); return;
|
|
583
|
+
}
|
|
584
|
+
if (s === "\r" || s === "\n") {
|
|
585
|
+
const cmd = inputBuf.trim();
|
|
586
|
+
inputMode = false;
|
|
587
|
+
inputBuf = "";
|
|
588
|
+
if (!cmd) { requestRender(); return; }
|
|
589
|
+
await execCommand(cmd);
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
if (s === "\x1b[A" || s === "\x1b[B") return;
|
|
593
|
+
if (s.length === 1 && s >= " " && s <= "~") {
|
|
594
|
+
inputBuf += s;
|
|
595
|
+
requestRender();
|
|
596
|
+
}
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
async function execCommand(raw) {
|
|
600
|
+
const cmd = raw.trim();
|
|
601
|
+
if (!cmd) return;
|
|
602
|
+
const parts = cmd.split(/\s+/);
|
|
603
|
+
const c = parts[0].toLowerCase();
|
|
604
|
+
const args = parts.slice(1);
|
|
605
|
+
if (c === "help" || c === "?") { helpOpen = true; requestRender(); return; }
|
|
606
|
+
if (c === "q" || c === "quit" || c === "exit") { cleanupAndExit(0); return; }
|
|
607
|
+
if (c === "clear" || c === "r" || c === "refresh") { await refresh(); requestRender(); setMessage("refreshed"); return; }
|
|
608
|
+
if (c === "status") {
|
|
609
|
+
await refresh(); view = "activity"; detailId = null; helpOpen = false; requestRender(); setMessage("status — live", 1500); return;
|
|
610
|
+
}
|
|
611
|
+
if (c === "logs") {
|
|
612
|
+
view = "activity"; detailId = null; helpOpen = false;
|
|
613
|
+
if (args.includes("--watch") || args.includes("-w")) setMessage("already live — streaming", 1500);
|
|
614
|
+
requestRender(); return;
|
|
615
|
+
}
|
|
616
|
+
if (c === "policy" && args[0] === "test") {
|
|
617
|
+
view = "policies"; helpOpen = false; policyTestData = null; requestRender();
|
|
618
|
+
setMessage("running policy test...", 2000);
|
|
619
|
+
try {
|
|
620
|
+
const { loadPolicyFile } = await import("./policy.mjs");
|
|
621
|
+
let pp = null;
|
|
622
|
+
for (const cand of ["cirvix.policy", "cirvix.policy.json", ".cirvix/policy.json"]) {
|
|
623
|
+
const p = join(cwd, cand);
|
|
624
|
+
if (await exists(p)) { pp = p; break; }
|
|
625
|
+
}
|
|
626
|
+
if (!pp) policyTestData = `\n ${red("No policy file found. Run cirvix init.")}\n`;
|
|
627
|
+
else {
|
|
628
|
+
const { output } = await (await import("./policy.mjs")).test({ path: pp, cwd, json: false });
|
|
629
|
+
policyTestData = output;
|
|
630
|
+
}
|
|
631
|
+
} catch (e) { policyTestData = `\n ${red(String(e.message))}\n`; }
|
|
632
|
+
requestRender(); return;
|
|
633
|
+
}
|
|
634
|
+
if (c === "audit" && args[0] === "verify") {
|
|
635
|
+
view = "audit"; helpOpen = false; await refresh(); requestRender(); return;
|
|
636
|
+
}
|
|
637
|
+
if (c === "demo") {
|
|
638
|
+
if (isRunningDemo) { setMessage("demo already running", 1500); return; }
|
|
639
|
+
view = "activity"; isRunningDemo = true; requestRender();
|
|
640
|
+
setMessage("demo streaming — real pipeline", 2000);
|
|
641
|
+
try {
|
|
642
|
+
const { demo } = await import("./demo.mjs");
|
|
643
|
+
await demo({ cwd, pace: 140, stateDir, write: () => {} });
|
|
644
|
+
await refresh(); isRunningDemo = false; requestRender(); setMessage("demo complete", 2500);
|
|
645
|
+
} catch (e) { isRunningDemo = false; setMessage(red(String(e.message)), 3000); }
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
if (c === "why" && args[0]) {
|
|
649
|
+
const id = args[0];
|
|
650
|
+
let rec = events.find((e) => e.request_id === id || e.decision_id === id);
|
|
651
|
+
if (!rec) {
|
|
652
|
+
try {
|
|
653
|
+
const all = await journal.read(join(stateDir, "audit.jsonl"));
|
|
654
|
+
rec = journal.find(all, id);
|
|
655
|
+
} catch {}
|
|
656
|
+
}
|
|
657
|
+
if (rec) { detailId = rec.request_id ?? rec.decision_id ?? id; helpOpen = false; view = "activity"; requestRender(); }
|
|
658
|
+
else setMessage(red("No decision with id " + id), 2500);
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
if (c === "approvals" || c === "approval") {
|
|
662
|
+
await refresh();
|
|
663
|
+
if (pendingApprovals.length === 0) setMessage("nothing waiting on a human", 2000);
|
|
664
|
+
else {
|
|
665
|
+
const idx = events.findIndex((e) => (e.decision === "require_approval" || e.verdict === "hold"));
|
|
666
|
+
if (idx !== -1) { selected = idx; detailId = events[idx].request_id; }
|
|
667
|
+
setMessage(`${pendingApprovals.length} awaiting approval — press Enter to inspect`, 2500);
|
|
668
|
+
}
|
|
669
|
+
requestRender();
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
setMessage(dim(`unknown command: ${cmd} — try ?`), 2000);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// --- setup terminal ---
|
|
676
|
+
setRaw(true);
|
|
677
|
+
process.stdin.resume();
|
|
678
|
+
process.stdin.setEncoding("utf8");
|
|
679
|
+
process.stdin.on("data", onData);
|
|
680
|
+
|
|
681
|
+
// resize -> single coalesced redraw
|
|
682
|
+
const resizeHandler = () => requestRender();
|
|
683
|
+
process.stdout.on("resize", resizeHandler);
|
|
684
|
+
|
|
685
|
+
// initial render — single viewport
|
|
686
|
+
requestRender();
|
|
687
|
+
|
|
688
|
+
// keep alive
|
|
689
|
+
await new Promise(() => {});
|
|
690
|
+
}
|