@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.
- package/README.md +76 -17
- package/bin/cirvix.mjs +539 -85
- 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/console.mjs +58 -0
- package/src/commands/demo.mjs +55 -124
- 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/onboard.mjs +52 -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 +11 -9
- package/src/commands/shadow.mjs +62 -0
- package/src/commands/simulate.mjs +96 -0
- package/src/commands/status.mjs +122 -41
- package/src/commands/upgrade.mjs +11 -11
- 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 +7 -24
- package/src/core/escape-benchmark.mjs +597 -0
- package/src/core/events.mjs +234 -0
- package/src/core/evidence.mjs +212 -0
- package/src/core/format.mjs +44 -18
- 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/theme.mjs +240 -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 +67 -0
- package/src/tui/activity.mjs +71 -0
- package/src/tui/app.mjs +292 -0
- package/src/tui/cards.mjs +235 -0
- package/src/tui/composer.mjs +88 -0
- package/src/tui/palette.mjs +48 -0
- package/src/tui/status.mjs +42 -0
- package/src/core/cinematic.mjs +0 -545
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AnimationController — decides whether animation is allowed and manages timers/cursor.
|
|
3
|
+
*
|
|
4
|
+
* Respects: NO_COLOR, TERM=dumb, !isTTY, CI, --json, --fast/pace=0,
|
|
5
|
+
* CIRVIX_NO_ANIM, CIRVIX_REDUCED_MOTION, FORCE_COLOR override.
|
|
6
|
+
*
|
|
7
|
+
* Every animation cleans up its own intervals/timeouts and restores cursor.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { isInteractive } from "../format.mjs";
|
|
11
|
+
|
|
12
|
+
export function shouldAnimate({ pace, json, force } = {}) {
|
|
13
|
+
if (json) return false;
|
|
14
|
+
if (pace === 0) return false;
|
|
15
|
+
if (process.env.CIRVIX_NO_ANIM === "1") return false;
|
|
16
|
+
if (process.env.CIRVIX_REDUCED_MOTION === "1") return false;
|
|
17
|
+
if (force === false) return false;
|
|
18
|
+
if (force === true) return true;
|
|
19
|
+
// format.mjs already handles NO_COLOR / TERM=dumb / !isTTY / FORCE_COLOR
|
|
20
|
+
// but CI gate is extra: CI without FORCE_COLOR should not animate
|
|
21
|
+
if (process.env.CI !== undefined && process.env.FORCE_COLOR !== "1" && process.env.FORCE_COLOR !== "true") {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
return isInteractive();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Hide cursor, remember to show it on exit. */
|
|
28
|
+
export function hideCursor(stream = process.stdout) {
|
|
29
|
+
if (!stream.isTTY) return () => {};
|
|
30
|
+
try {
|
|
31
|
+
stream.write("\u001b[?25l");
|
|
32
|
+
} catch {}
|
|
33
|
+
let shown = false;
|
|
34
|
+
const show = () => {
|
|
35
|
+
if (shown) return;
|
|
36
|
+
shown = true;
|
|
37
|
+
try {
|
|
38
|
+
stream.write("\u001b[?25h");
|
|
39
|
+
} catch {}
|
|
40
|
+
};
|
|
41
|
+
const onExit = () => show();
|
|
42
|
+
// Ensure cleanup on ctrl+c or exit.
|
|
43
|
+
process.once("SIGINT", onExit);
|
|
44
|
+
process.once("SIGTERM", onExit);
|
|
45
|
+
process.once("exit", onExit);
|
|
46
|
+
return () => {
|
|
47
|
+
show();
|
|
48
|
+
process.off("SIGINT", onExit);
|
|
49
|
+
process.off("SIGTERM", onExit);
|
|
50
|
+
process.off("exit", onExit);
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Spinner frames — subtle, not gamey. */
|
|
55
|
+
export const SPINNER_FRAMES = ["◌", "◎", "◉", "◎"];
|
|
56
|
+
export const SPINNER_ASCII = ["-", "\\", "|", "/"];
|
|
57
|
+
|
|
58
|
+
export class Spinner {
|
|
59
|
+
constructor(label, { stream = process.stdout, enabled = shouldAnimate({}) } = {}) {
|
|
60
|
+
this.label = label;
|
|
61
|
+
this.stream = stream;
|
|
62
|
+
this.enabled = enabled;
|
|
63
|
+
this.interval = null;
|
|
64
|
+
this.frame = 0;
|
|
65
|
+
this.restoreCursor = null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
start() {
|
|
69
|
+
if (!this.enabled) {
|
|
70
|
+
this.stream.write(`${this.label}\n`);
|
|
71
|
+
return this;
|
|
72
|
+
}
|
|
73
|
+
this.restoreCursor = hideCursor(this.stream);
|
|
74
|
+
this.interval = setInterval(() => {
|
|
75
|
+
const ch = SPINNER_FRAMES[this.frame % SPINNER_FRAMES.length];
|
|
76
|
+
this.frame++;
|
|
77
|
+
// Rewrite same line.
|
|
78
|
+
try {
|
|
79
|
+
this.stream.write(`\r\x1b[2K ${ch} ${this.label}`);
|
|
80
|
+
} catch {}
|
|
81
|
+
}, 80);
|
|
82
|
+
// Initial draw.
|
|
83
|
+
try {
|
|
84
|
+
this.stream.write(` ${SPINNER_FRAMES[0]} ${this.label}`);
|
|
85
|
+
} catch {}
|
|
86
|
+
return this;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
succeed(text) {
|
|
90
|
+
this.stop();
|
|
91
|
+
try {
|
|
92
|
+
this.stream.write(` \u2713 ${text ?? this.label}\n`);
|
|
93
|
+
} catch {}
|
|
94
|
+
return this;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
fail(text) {
|
|
98
|
+
this.stop();
|
|
99
|
+
try {
|
|
100
|
+
this.stream.write(` \u2715 ${text ?? this.label}\n`);
|
|
101
|
+
} catch {}
|
|
102
|
+
return this;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
stop() {
|
|
106
|
+
if (this.interval) {
|
|
107
|
+
clearInterval(this.interval);
|
|
108
|
+
this.interval = null;
|
|
109
|
+
// Clear spinner line.
|
|
110
|
+
try {
|
|
111
|
+
this.stream.write("\r\x1b[2K");
|
|
112
|
+
} catch {}
|
|
113
|
+
}
|
|
114
|
+
if (this.restoreCursor) {
|
|
115
|
+
this.restoreCursor();
|
|
116
|
+
this.restoreCursor = null;
|
|
117
|
+
}
|
|
118
|
+
return this;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Sequential step runner: shows spinner per step, ✓ on success, ✕ on fail. */
|
|
123
|
+
export class StepSequence {
|
|
124
|
+
constructor({ stream = process.stdout, enabled = shouldAnimate({}) } = {}) {
|
|
125
|
+
this.stream = stream;
|
|
126
|
+
this.enabled = enabled;
|
|
127
|
+
this.steps = [];
|
|
128
|
+
this.timers = [];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
add(label, fn) {
|
|
132
|
+
this.steps.push({ label, fn });
|
|
133
|
+
return this;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async run({ interval = 120 } = {}) {
|
|
137
|
+
const results = [];
|
|
138
|
+
for (const step of this.steps) {
|
|
139
|
+
const spinner = new Spinner(step.label, { stream: this.stream, enabled: this.enabled });
|
|
140
|
+
if (this.enabled) spinner.start();
|
|
141
|
+
let ok = false;
|
|
142
|
+
let error = null;
|
|
143
|
+
try {
|
|
144
|
+
const res = await step.fn();
|
|
145
|
+
// fn may return {ok, detail} or boolean; truthy means success.
|
|
146
|
+
if (res && typeof res === "object" && "ok" in res) ok = Boolean(res.ok);
|
|
147
|
+
else if (typeof res === "boolean") ok = res;
|
|
148
|
+
else ok = true;
|
|
149
|
+
} catch (err) {
|
|
150
|
+
ok = false;
|
|
151
|
+
error = err;
|
|
152
|
+
}
|
|
153
|
+
if (this.enabled) {
|
|
154
|
+
// Small delay so spinner is visible, but not blocking.
|
|
155
|
+
await new Promise((r) => {
|
|
156
|
+
const t = setTimeout(r, interval);
|
|
157
|
+
this.timers.push(t);
|
|
158
|
+
});
|
|
159
|
+
spinner.stop();
|
|
160
|
+
if (ok) {
|
|
161
|
+
try {
|
|
162
|
+
this.stream.write(` \x1b[32m\u2713\x1b[39m ${step.label}\n`);
|
|
163
|
+
} catch {}
|
|
164
|
+
} else {
|
|
165
|
+
try {
|
|
166
|
+
this.stream.write(` \x1b[31m\u2715\x1b[39m ${step.label}${error ? ` — ${error.message}` : ""}\n`);
|
|
167
|
+
} catch {}
|
|
168
|
+
}
|
|
169
|
+
} else {
|
|
170
|
+
const sym = ok ? "\u2713" : "\u2715";
|
|
171
|
+
try {
|
|
172
|
+
this.stream.write(` ${sym} ${step.label}\n`);
|
|
173
|
+
} catch {}
|
|
174
|
+
}
|
|
175
|
+
results.push({ label: step.label, ok, error });
|
|
176
|
+
}
|
|
177
|
+
return results;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
cleanup() {
|
|
181
|
+
for (const t of this.timers) clearTimeout(t);
|
|
182
|
+
this.timers = [];
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Simple sleep that tracks timer for cleanup. */
|
|
187
|
+
export function sleep(ms, tracker) {
|
|
188
|
+
return new Promise((resolve) => {
|
|
189
|
+
const t = setTimeout(resolve, ms);
|
|
190
|
+
if (tracker) tracker.push(t);
|
|
191
|
+
});
|
|
192
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DecisionRenderer — compact for ALLOW, expanded for DENY/CRITICAL/HOLD.
|
|
3
|
+
*
|
|
4
|
+
* Every label includes text (ALLOW/BLOCKED etc.) so color is never the only signal.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { bold, dim, blue, stripAnsi } from "../format.mjs";
|
|
8
|
+
import { toneForDecision, toneForRisk } from "./theme.mjs";
|
|
9
|
+
import { safeTarget } from "./primitives.mjs";
|
|
10
|
+
|
|
11
|
+
export function renderCompact(event) {
|
|
12
|
+
const dt = toneForDecision(event.decision);
|
|
13
|
+
const rt = toneForRisk(event.risk);
|
|
14
|
+
const decisionLabel = String(event.decision ?? "unknown").toUpperCase().replace(/_/g, " ").padEnd(12);
|
|
15
|
+
const riskLabel = String(event.risk ?? "—").toUpperCase().padEnd(8);
|
|
16
|
+
const tool = String(event.tool ?? event.action ?? "—").padEnd(20);
|
|
17
|
+
const target = safeTarget(event.resource ?? event.command ?? "", 38);
|
|
18
|
+
const latency = `${event.latency_ms ?? "—"}ms`;
|
|
19
|
+
const policy = event.policy ?? event.rule ?? "";
|
|
20
|
+
|
|
21
|
+
// ALLOW: quiet, one line.
|
|
22
|
+
// Icon per spec: ✓ ALLOW (green), ◈ SANITIZED (blue), etc.
|
|
23
|
+
const icon = event.decision === "allow" ? "✓" : event.decision === "sanitize" ? "◈" : event.decision === "deny" ? "✕" : event.decision === "require_approval" ? "⏸" : "·";
|
|
24
|
+
return ` ${dt(icon)} ${dt(decisionLabel)} ${rt(riskLabel)} ${tool} ${dim(target.padEnd(38))} ${dim(latency.padEnd(8))} ${dim(policy)}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function renderExpanded(event) {
|
|
28
|
+
const dt = toneForDecision(event.decision);
|
|
29
|
+
const rt = toneForRisk(event.risk);
|
|
30
|
+
const lines = [];
|
|
31
|
+
const icon = event.decision === "deny" ? "✕" : event.decision === "sanitize" ? "◈" : event.decision === "require_approval" ? "⏸" : "✓";
|
|
32
|
+
const label = String(event.decision ?? "unknown").toUpperCase().replace(/_/g, " ");
|
|
33
|
+
lines.push(` ${dt(`${icon} ${label}`)} ${dim(String(event.tool ?? event.action ?? ""))}`);
|
|
34
|
+
if (event.resource || event.command) {
|
|
35
|
+
lines.push(` ${dim(safeTarget(event.resource ?? event.command, 60))}`);
|
|
36
|
+
lines.push("");
|
|
37
|
+
}
|
|
38
|
+
lines.push(` ${dim("Risk".padEnd(10))} ${rt(String(event.risk ?? "—").toUpperCase())}`);
|
|
39
|
+
lines.push(` ${dim("Policy".padEnd(10))} ${event.policy ?? event.rule ?? dim("—")}`);
|
|
40
|
+
if (event.latency_ms !== undefined) lines.push(` ${dim("Latency".padEnd(10))} ${event.latency_ms}ms`);
|
|
41
|
+
if (event.reason) lines.push(` ${dim(event.reason)}`);
|
|
42
|
+
return lines.join("\n");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Choose compact vs expanded per risk/decision. */
|
|
46
|
+
export function renderDecision(event) {
|
|
47
|
+
const critical = String(event.risk ?? "").toLowerCase() === "critical";
|
|
48
|
+
const denied = event.decision === "deny";
|
|
49
|
+
const held = event.decision === "require_approval";
|
|
50
|
+
const high = String(event.risk ?? "").toLowerCase() === "high";
|
|
51
|
+
if (denied || held || critical || high) {
|
|
52
|
+
return renderExpanded(event);
|
|
53
|
+
}
|
|
54
|
+
return renderCompact(event);
|
|
55
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TerminalUI facade — auto-disables for non-TTY/NO_COLOR/CI/json.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export * from "./controller.mjs";
|
|
6
|
+
export * from "./theme.mjs";
|
|
7
|
+
export * from "./primitives.mjs";
|
|
8
|
+
export * from "./decisions.mjs";
|
|
9
|
+
export * from "./intercept.mjs";
|
|
10
|
+
export * from "./live.mjs";
|
|
11
|
+
|
|
12
|
+
import { shouldAnimate } from "./controller.mjs";
|
|
13
|
+
import { brandHeader, panel } from "./primitives.mjs";
|
|
14
|
+
import { bold, dim, green, red, amber, blue } from "../format.mjs";
|
|
15
|
+
|
|
16
|
+
export function createUI({ stream = process.stdout, pace, json } = {}) {
|
|
17
|
+
const enabled = shouldAnimate({ pace, json });
|
|
18
|
+
return {
|
|
19
|
+
enabled,
|
|
20
|
+
brandHeader,
|
|
21
|
+
panel,
|
|
22
|
+
write(s) {
|
|
23
|
+
try {
|
|
24
|
+
stream.write(s);
|
|
25
|
+
} catch {}
|
|
26
|
+
},
|
|
27
|
+
writeln(s = "") {
|
|
28
|
+
try {
|
|
29
|
+
stream.write(s + "\n");
|
|
30
|
+
} catch {}
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Small helper to render a status dot with color, but text label always present. */
|
|
36
|
+
export function statusDot(ok, tone) {
|
|
37
|
+
const dot = "●";
|
|
38
|
+
return tone ? tone(`${dot}`) : dot;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Format a count line: "0 blocked · 0 approvals · 0 violations" */
|
|
42
|
+
export function countsLine({ blocked = 0, approvals = 0, violations = 0, sanitized = 0 } = {}) {
|
|
43
|
+
const parts = [];
|
|
44
|
+
parts.push(`${blocked} blocked`);
|
|
45
|
+
if (sanitized) parts.push(`${sanitized} sanitized`);
|
|
46
|
+
parts.push(`${approvals} approvals`);
|
|
47
|
+
parts.push(`${violations} violations`);
|
|
48
|
+
return dim(parts.join(" · "));
|
|
49
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SecurityIntercept — visually distinctive blocked-action panel.
|
|
3
|
+
*
|
|
4
|
+
* Animation MUST NOT delay enforcement. Decision is already computed.
|
|
5
|
+
* This only visualizes it with a short 300-800ms sequence.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { bold, dim, red, stripAnsi } from "../format.mjs";
|
|
9
|
+
import { shouldAnimate, sleep } from "./controller.mjs";
|
|
10
|
+
import { boxChars, truncate, padVisible } from "./theme.mjs";
|
|
11
|
+
|
|
12
|
+
export function interceptBox(event) {
|
|
13
|
+
const W = 58;
|
|
14
|
+
const ch = boxChars();
|
|
15
|
+
const useAscii = ch.tl === "+";
|
|
16
|
+
// Use red border for intercept.
|
|
17
|
+
const h = useAscii ? ch.h : "═";
|
|
18
|
+
const v = useAscii ? ch.v : "║";
|
|
19
|
+
const tl = useAscii ? ch.tl : "╔";
|
|
20
|
+
const tr = useAscii ? ch.tr : "╗";
|
|
21
|
+
const bl = useAscii ? ch.bl : "╚";
|
|
22
|
+
const br = useAscii ? ch.br : "╝";
|
|
23
|
+
const mj = useAscii ? ch.lt : "╠";
|
|
24
|
+
const mid = mj + h.repeat(W + 2) + (useAscii ? ch.rt : "╣");
|
|
25
|
+
|
|
26
|
+
const rows = [
|
|
27
|
+
["Agent", event.agent ?? "—"],
|
|
28
|
+
["Tool", event.tool ?? event.action ?? "—"],
|
|
29
|
+
["Target", event.resource ?? event.destination ?? "—"],
|
|
30
|
+
["Risk", String(event.risk ?? "—").toUpperCase()],
|
|
31
|
+
["Decision", "BLOCKED"],
|
|
32
|
+
["Policy", event.policy ?? event.rule ?? "default-deny"],
|
|
33
|
+
["Latency", `${event.latency_ms ?? "—"}ms`],
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const pad = (text) => {
|
|
37
|
+
const s = String(text);
|
|
38
|
+
const vis = stripAnsi(s).length;
|
|
39
|
+
if (vis > W) return s.slice(0, W - 1) + "…";
|
|
40
|
+
return s + " ".repeat(W - vis);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const lines = [];
|
|
44
|
+
lines.push(` ${red(tl + h.repeat(W + 2) + tr)}`);
|
|
45
|
+
lines.push(` ${red(v)} ${bold(pad("CIRVIX SECURITY INTERCEPT"))} ${red(v)}`);
|
|
46
|
+
lines.push(` ${red(mid)}`);
|
|
47
|
+
for (const [k, val] of rows) {
|
|
48
|
+
const body = `${k}:`.padEnd(11) + String(val);
|
|
49
|
+
const painted = k === "Risk" || k === "Decision" ? red(pad(body)) : pad(body);
|
|
50
|
+
lines.push(` ${red(v)} ${painted} ${red(v)}`);
|
|
51
|
+
}
|
|
52
|
+
lines.push(` ${red(bl + h.repeat(W + 2) + br)}`);
|
|
53
|
+
|
|
54
|
+
const reason = event.reason ? `\n ${dim(truncate(event.reason, 72))}\n` : "";
|
|
55
|
+
return lines.join("\n") + reason;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Animated intercept: spinner → risk → BLOCKED → policy.
|
|
60
|
+
* Total 300-800ms, but decision already made.
|
|
61
|
+
*/
|
|
62
|
+
export async function animateIntercept(event, { stream = process.stdout, pace = 700 } = {}) {
|
|
63
|
+
const enabled = shouldAnimate({ pace, json: false });
|
|
64
|
+
if (!enabled) {
|
|
65
|
+
stream.write(interceptBox(event) + "\n");
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const write = (s) => {
|
|
69
|
+
try {
|
|
70
|
+
stream.write(s);
|
|
71
|
+
} catch {}
|
|
72
|
+
};
|
|
73
|
+
const timers = [];
|
|
74
|
+
const hide = () => {
|
|
75
|
+
try {
|
|
76
|
+
stream.write("\u001b[?25l");
|
|
77
|
+
} catch {}
|
|
78
|
+
};
|
|
79
|
+
const show = () => {
|
|
80
|
+
try {
|
|
81
|
+
stream.write("\u001b[?25h");
|
|
82
|
+
} catch {}
|
|
83
|
+
};
|
|
84
|
+
hide();
|
|
85
|
+
// Phase 1: evaluating
|
|
86
|
+
write(" ◌ evaluating request...\r");
|
|
87
|
+
await sleep(Math.min(260, pace * 0.35), timers);
|
|
88
|
+
write("\x1b[2K");
|
|
89
|
+
// Phase 2: risk
|
|
90
|
+
const riskTone = event.risk === "critical" ? red : event.risk === "high" ? "\u001b[33m" : "";
|
|
91
|
+
write(` ${riskTone}⚠ ${String(event.risk ?? "").toUpperCase()}\u001b[39m\n`);
|
|
92
|
+
await sleep(Math.min(160, pace * 0.2), timers);
|
|
93
|
+
// Phase 3: BLOCKED
|
|
94
|
+
write(` ${red("✕ BLOCKED")}\n`);
|
|
95
|
+
await sleep(Math.min(160, pace * 0.2), timers);
|
|
96
|
+
// Phase 4: policy
|
|
97
|
+
write(` ${dim(String(event.policy ?? event.rule ?? ""))}\n`);
|
|
98
|
+
await sleep(Math.min(120, pace * 0.15), timers);
|
|
99
|
+
// Phase 5: full box
|
|
100
|
+
write(interceptBox(event) + "\n");
|
|
101
|
+
show();
|
|
102
|
+
for (const t of timers) clearTimeout(t);
|
|
103
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LiveStream — `cirvix logs --watch` live security stream.
|
|
3
|
+
*
|
|
4
|
+
* Line-based updates, no full redraw, readable.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { dim, bold } from "../format.mjs";
|
|
8
|
+
import { renderDecision } from "./decisions.mjs";
|
|
9
|
+
|
|
10
|
+
export class LiveStream {
|
|
11
|
+
constructor({ stream = process.stdout, title = "CIRVIX LIVE · protection active" } = {}) {
|
|
12
|
+
this.stream = stream;
|
|
13
|
+
this.title = title;
|
|
14
|
+
this.started = false;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
header() {
|
|
18
|
+
if (this.started) return;
|
|
19
|
+
this.started = true;
|
|
20
|
+
try {
|
|
21
|
+
this.stream.write(`\n ${bold(this.title)}\n`);
|
|
22
|
+
this.stream.write(` ${dim("─".repeat(60))}\n\n`);
|
|
23
|
+
} catch {}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
push(event) {
|
|
27
|
+
this.header();
|
|
28
|
+
const line = renderDecision(event);
|
|
29
|
+
// For live, compact is preferred even for DENY to keep stream readable,
|
|
30
|
+
// but DENY still gets expanded with context on next line.
|
|
31
|
+
try {
|
|
32
|
+
// event already has ts; render with clock if present.
|
|
33
|
+
const clock = event.ts ? String(event.ts).slice(11, 19) : new Date().toISOString().slice(11, 19);
|
|
34
|
+
// If decision renderer already includes multiline, just prefix clock.
|
|
35
|
+
if (line.includes("\n")) {
|
|
36
|
+
const parts = line.split("\n");
|
|
37
|
+
this.stream.write(` ${dim(clock)} ${parts[0].trimStart()}\n`);
|
|
38
|
+
for (let i = 1; i < parts.length; i++) this.stream.write(parts[i] + "\n");
|
|
39
|
+
} else {
|
|
40
|
+
this.stream.write(` ${dim(clock)} ${line.trimStart()}\n`);
|
|
41
|
+
}
|
|
42
|
+
} catch {}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
footer(stats) {
|
|
46
|
+
if (!stats) return;
|
|
47
|
+
try {
|
|
48
|
+
this.stream.write(`\n ${dim(`${stats.records ?? 0} decisions · P99 ${stats.latency?.p99 ?? 0}ms`)}\n\n`);
|
|
49
|
+
} catch {}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Primitives — Panel, Table, Badge, Logo, Status helpers.
|
|
3
|
+
*
|
|
4
|
+
* Zero deps, unicode with ASCII fallback, respects NO_COLOR via format.mjs.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { bold, dim, stripAnsi, supportsUnicode } from "../format.mjs";
|
|
8
|
+
import { boxChars, truncate, padVisible } from "./theme.mjs";
|
|
9
|
+
|
|
10
|
+
/** CIRVIX ASCII logo (5 lines). Compact, premium, not gamey. */
|
|
11
|
+
export const LOGO_LINES = [
|
|
12
|
+
" ██████╗██╗██████╗ ██╗ ██╗██╗██╗ ██╗",
|
|
13
|
+
"██╔════╝██║██╔══██╗██║ ██║██║╚██╗██╔╝",
|
|
14
|
+
"██║ ██║██████╔╝██║ ██║██║ ╚███╔╝ ",
|
|
15
|
+
"██║ ██║██╔══██╗╚██╗ ██╔╝██║ ██╔██╗ ",
|
|
16
|
+
"╚██████╗██║██║ ██║ ╚████╔╝ ██║██╔╝ ██╗",
|
|
17
|
+
" ╚═════╝╚═╝╚═╝ ╚═╝ ╚═══╝ ╚═╝╚═╝ ╚═╝",
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
export const LOGO_SUBTITLE = "AI AGENT RUNTIME GOVERNANCE";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Draw a rounded panel with optional title.
|
|
24
|
+
*
|
|
25
|
+
* @param {object} opts
|
|
26
|
+
* @param {string} [opts.title]
|
|
27
|
+
* @param {string[]} opts.lines — already formatted (may contain ANSI)
|
|
28
|
+
* @param {number} [opts.width] — inner width, default 58
|
|
29
|
+
* @param {boolean} [opts.heavy] — use heavy border for intercept
|
|
30
|
+
*/
|
|
31
|
+
export function panel({ title, lines = [], width = 58, heavy = false } = {}) {
|
|
32
|
+
const ch = boxChars();
|
|
33
|
+
const h = heavy ? ch.hHeavy : ch.h;
|
|
34
|
+
const v = heavy ? ch.vHeavy : ch.v;
|
|
35
|
+
|
|
36
|
+
// Compute actual inner width from content if not provided.
|
|
37
|
+
let inner = width;
|
|
38
|
+
if (!width) {
|
|
39
|
+
inner = Math.max(...lines.map((l) => stripAnsi(l).length), title ? stripAnsi(title).length : 0) + 2;
|
|
40
|
+
inner = Math.max(40, Math.min(72, inner));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const top = `╭${h.repeat(inner + 2)}╮`;
|
|
44
|
+
const bottom = `╰${h.repeat(inner + 2)}╯`;
|
|
45
|
+
// For ascii fallback, boxChars returns +/-, so above uses unicode literals.
|
|
46
|
+
// Rebuild with actual chars for ascii.
|
|
47
|
+
const useAscii = !supportsUnicode();
|
|
48
|
+
const topLine = useAscii ? `${ch.tl}${ch.h.repeat(inner + 2)}${ch.tr}` : top;
|
|
49
|
+
const bottomLine = useAscii ? `${ch.bl}${ch.h.repeat(inner + 2)}${ch.br}` : bottom;
|
|
50
|
+
const vert = useAscii ? ch.v : v;
|
|
51
|
+
|
|
52
|
+
const out = [];
|
|
53
|
+
out.push(` ${topLine}`);
|
|
54
|
+
if (title) {
|
|
55
|
+
const t = truncate(title, inner);
|
|
56
|
+
out.push(` ${vert} ${padVisible(t, inner)} ${vert}`);
|
|
57
|
+
out.push(` ${vert} ${" ".repeat(inner)} ${vert}`);
|
|
58
|
+
} else {
|
|
59
|
+
out.push(` ${vert} ${" ".repeat(inner)} ${vert}`);
|
|
60
|
+
}
|
|
61
|
+
for (const line of lines) {
|
|
62
|
+
const clean = truncate(stripAnsi(line), inner);
|
|
63
|
+
// Preserve ANSI: pad based on visible length.
|
|
64
|
+
const padded = padVisible(line, inner);
|
|
65
|
+
// Ensure we truncate ANSI correctly — if original had ANSI, we need to re-truncate safely.
|
|
66
|
+
// Simpler: use padded which already uses visibleWidth.
|
|
67
|
+
out.push(` ${vert} ${padded} ${vert}`);
|
|
68
|
+
}
|
|
69
|
+
out.push(` ${vert} ${" ".repeat(inner)} ${vert}`);
|
|
70
|
+
out.push(` ${bottomLine}`);
|
|
71
|
+
return out.join("\n");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Render the full brand header — logo boxed.
|
|
76
|
+
*/
|
|
77
|
+
export function brandHeader({ width = 58 } = {}) {
|
|
78
|
+
const ch = boxChars();
|
|
79
|
+
const useAscii = !supportsUnicode();
|
|
80
|
+
const h = ch.h;
|
|
81
|
+
const top = useAscii ? `${ch.tl}${h.repeat(width + 2)}${ch.tr}` : `╭${h.repeat(width + 2)}╮`;
|
|
82
|
+
const bottom = useAscii ? `${ch.bl}${h.repeat(width + 2)}${ch.br}` : `╰${h.repeat(width + 2)}╯`;
|
|
83
|
+
const vert = useAscii ? ch.v : ch.v;
|
|
84
|
+
|
|
85
|
+
const lines = [];
|
|
86
|
+
lines.push(` ${top}`);
|
|
87
|
+
lines.push(` ${vert} ${" ".repeat(width)} ${vert}`);
|
|
88
|
+
for (const l of LOGO_LINES) {
|
|
89
|
+
const padded = l.padStart(Math.floor((width + l.length) / 2)).padEnd(width);
|
|
90
|
+
lines.push(` ${vert} ${padded} ${vert}`);
|
|
91
|
+
}
|
|
92
|
+
lines.push(` ${vert} ${" ".repeat(width)} ${vert}`);
|
|
93
|
+
const sub = LOGO_SUBTITLE.padStart(Math.floor((width + LOGO_SUBTITLE.length) / 2)).padEnd(width);
|
|
94
|
+
lines.push(` ${vert} ${dim(sub)} ${vert}`);
|
|
95
|
+
lines.push(` ${vert} ${" ".repeat(width)} ${vert}`);
|
|
96
|
+
lines.push(` ${bottom}`);
|
|
97
|
+
return lines.join("\n");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Simple key/value rows aligned. */
|
|
101
|
+
export function keyValueRows(rows, { keyWidth } = {}) {
|
|
102
|
+
const w = keyWidth ?? Math.max(...rows.map(([k]) => stripAnsi(String(k)).length));
|
|
103
|
+
return rows.map(([k, v]) => ` ${String(k).padEnd(w + 2)}${v}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Separator line. */
|
|
107
|
+
export function separator(width = 60, char = "─") {
|
|
108
|
+
const ch = supportsUnicode() ? char : "-";
|
|
109
|
+
return dim(" " + ch.repeat(width));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Badge: ● ONLINE / ● ENFORCING etc. with color. */
|
|
113
|
+
export function badge(label, state, tone) {
|
|
114
|
+
const dot = "●";
|
|
115
|
+
return `${tone(`${dot} ${state}`)} ${dim(label)}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Truncate path for display, never show secret values. */
|
|
119
|
+
export function safeTarget(value, max = 44) {
|
|
120
|
+
const s = String(value ?? "");
|
|
121
|
+
if (s.length <= max) return s;
|
|
122
|
+
return "…" + s.slice(-(max - 1));
|
|
123
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme — semantic palette + box characters, with NO_COLOR and ASCII fallbacks.
|
|
3
|
+
*
|
|
4
|
+
* Never rely on color alone; every semantic token also has a text label
|
|
5
|
+
* (ALLOW/BLOCKED etc.) rendered elsewhere.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { bold, dim, green, red, amber, blue, cyan, gray, stripAnsi, supportsUnicode } from "../format.mjs";
|
|
9
|
+
|
|
10
|
+
export const palette = {
|
|
11
|
+
success: green,
|
|
12
|
+
critical: red,
|
|
13
|
+
warning: amber,
|
|
14
|
+
info: blue,
|
|
15
|
+
muted: dim,
|
|
16
|
+
subtle: gray,
|
|
17
|
+
accent: cyan,
|
|
18
|
+
strong: bold,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const riskTone = {
|
|
22
|
+
low: dim,
|
|
23
|
+
medium: blue,
|
|
24
|
+
high: amber,
|
|
25
|
+
critical: red,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const decisionTone = {
|
|
29
|
+
allow: green,
|
|
30
|
+
sanitize: blue,
|
|
31
|
+
require_approval: amber,
|
|
32
|
+
deny: red,
|
|
33
|
+
audit_only: dim,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function toneForDecision(decision) {
|
|
37
|
+
return decisionTone[String(decision ?? "").toLowerCase()] ?? dim;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function toneForRisk(risk) {
|
|
41
|
+
return riskTone[String(risk ?? "").toLowerCase()] ?? dim;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Box characters, unicode primary, ascii fallback. */
|
|
45
|
+
export function boxChars() {
|
|
46
|
+
if (supportsUnicode()) {
|
|
47
|
+
return {
|
|
48
|
+
tl: "╭",
|
|
49
|
+
tr: "╮",
|
|
50
|
+
bl: "╰",
|
|
51
|
+
br: "╯",
|
|
52
|
+
h: "─",
|
|
53
|
+
v: "│",
|
|
54
|
+
lt: "├",
|
|
55
|
+
rt: "┤",
|
|
56
|
+
mt: "┬",
|
|
57
|
+
mb: "┴",
|
|
58
|
+
cross: "┼",
|
|
59
|
+
// heavy for intercept
|
|
60
|
+
hHeavy: "━",
|
|
61
|
+
vHeavy: "┃",
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
tl: "+",
|
|
66
|
+
tr: "+",
|
|
67
|
+
bl: "+",
|
|
68
|
+
br: "+",
|
|
69
|
+
h: "-",
|
|
70
|
+
v: "|",
|
|
71
|
+
lt: "+",
|
|
72
|
+
rt: "+",
|
|
73
|
+
mt: "+",
|
|
74
|
+
mb: "+",
|
|
75
|
+
cross: "+",
|
|
76
|
+
hHeavy: "=",
|
|
77
|
+
vHeavy: "|",
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Safe width truncation with ANSI stripped. */
|
|
82
|
+
export function truncate(str, n) {
|
|
83
|
+
const s = String(str ?? "");
|
|
84
|
+
if (s.length <= n) return s;
|
|
85
|
+
return s.slice(0, n - 1) + "…";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function padVisible(str, width) {
|
|
89
|
+
const vis = stripAnsi(String(str)).length;
|
|
90
|
+
if (vis >= width) return String(str);
|
|
91
|
+
return String(str) + " ".repeat(width - vis);
|
|
92
|
+
}
|