@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.
Files changed (70) hide show
  1. package/README.md +76 -17
  2. package/bin/cirvix.mjs +488 -40
  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/demo.mjs +56 -70
  18. package/src/commands/doctor.mjs +235 -0
  19. package/src/commands/init.mjs +292 -30
  20. package/src/commands/interactive.mjs +690 -0
  21. package/src/commands/kill.mjs +74 -0
  22. package/src/commands/login.mjs +227 -0
  23. package/src/commands/passport.mjs +149 -0
  24. package/src/commands/policy.mjs +10 -6
  25. package/src/commands/protect.mjs +293 -0
  26. package/src/commands/prove.mjs +209 -0
  27. package/src/commands/redteam.mjs +51 -0
  28. package/src/commands/scan.mjs +6 -4
  29. package/src/commands/shadow.mjs +62 -0
  30. package/src/commands/simulate.mjs +96 -0
  31. package/src/commands/status.mjs +121 -36
  32. package/src/commands/upgrade.mjs +17 -9
  33. package/src/commands/welcome.mjs +105 -0
  34. package/src/core/authority.mjs +909 -0
  35. package/src/core/baseline.mjs +97 -0
  36. package/src/core/config-store.mjs +280 -0
  37. package/src/core/cost.mjs +0 -0
  38. package/src/core/detect.mjs +4 -33
  39. package/src/core/entitlements.mjs +6 -0
  40. package/src/core/escape-benchmark.mjs +597 -0
  41. package/src/core/evidence.mjs +212 -0
  42. package/src/core/format.mjs +27 -0
  43. package/src/core/gateway.mjs +15 -211
  44. package/src/core/graph.mjs +270 -0
  45. package/src/core/guard.mjs +118 -4
  46. package/src/core/intent.mjs +166 -0
  47. package/src/core/journal.mjs +131 -40
  48. package/src/core/kill-switch.mjs +122 -0
  49. package/src/core/notices.mjs +22 -2
  50. package/src/core/packs.mjs +193 -0
  51. package/src/core/passport.mjs +555 -0
  52. package/src/core/pipeline.mjs +148 -6
  53. package/src/core/prompts.mjs +51 -0
  54. package/src/core/proof.mjs +440 -0
  55. package/src/core/redteam/index.mjs +185 -0
  56. package/src/core/referral.mjs +187 -0
  57. package/src/core/sandbox.mjs +139 -0
  58. package/src/core/session.mjs +172 -0
  59. package/src/core/shadow.mjs +95 -0
  60. package/src/core/trifecta.mjs +321 -0
  61. package/src/core/ui/controller.mjs +192 -0
  62. package/src/core/ui/decisions.mjs +55 -0
  63. package/src/core/ui/index.mjs +49 -0
  64. package/src/core/ui/intercept.mjs +103 -0
  65. package/src/core/ui/live.mjs +51 -0
  66. package/src/core/ui/primitives.mjs +123 -0
  67. package/src/core/ui/theme.mjs +92 -0
  68. package/src/core/verified.mjs +108 -0
  69. package/src/core/windows.mjs +270 -0
  70. package/src/index.mjs +25 -0
@@ -0,0 +1,321 @@
1
+ /**
2
+ * The Lethal Trifecta — sequence-aware enforcement.
3
+ *
4
+ * Three conditions are each individually reasonable and jointly catastrophic:
5
+ *
6
+ * A. the agent has read sensitive material
7
+ * B. the agent has ingested content from outside the trust boundary
8
+ * C. the agent is now trying to act outbound
9
+ *
10
+ * Any one of those is ordinary work. A is `cat .env` during debugging. B is
11
+ * fetching a web page. C is a POST. A rule engine that only ever sees one call
12
+ * at a time cannot refuse any of them without refusing normal development, so
13
+ * it lets all three through and the exfiltration happens in the gaps between
14
+ * them.
15
+ *
16
+ * WHAT ALREADY EXISTED, AND WHY IT WAS NOT ENOUGH
17
+ * ----------------------------------------------
18
+ * risk.mjs carries `session-tainted-egress`: `touchedSecret && egress !== none`
19
+ * → HIGH. That is A + C, and it is a genuinely useful rule. Two gaps:
20
+ *
21
+ * · B was never tracked at all. The signal exists — sanitize.mjs already
22
+ * finds injected instructions in fetched results — but the finding was
23
+ * reported and then dropped on the floor rather than remembered.
24
+ * · It raises RISK, and risk alone does not refuse anything. It needs a
25
+ * separate policy rule to become a decision.
26
+ *
27
+ * So this module does not replace that rule; it completes it. A + C stays
28
+ * HIGH. A + B + C becomes a decision in its own right, because the presence of
29
+ * B is what turns "this agent is handling secrets near a network call" into
30
+ * "something outside the trust boundary has had the opportunity to steer this
31
+ * agent, and the agent is holding secrets, and it is now talking outward".
32
+ *
33
+ * WHY PROVENANCE, NOT BOOLEANS
34
+ * ----------------------------
35
+ * Each leg records when it was set and what set it. A refusal that says
36
+ * "blocked: trifecta" is indistinguishable from a bug, and a developer who
37
+ * cannot tell those apart turns the feature off. A refusal that says which
38
+ * three calls combined, in order, with timestamps, is a finding the developer
39
+ * can act on — and it is the artifact worth putting in a proof report.
40
+ *
41
+ * WHAT THIS DELIBERATELY DOES NOT DO
42
+ * ----------------------------------
43
+ * It does not attempt data-flow analysis. Knowing that the specific bytes read
44
+ * in call 1 reached the request body of call 3 would be stronger, and every
45
+ * approximation of it that fits in a synchronous hot path is guesswork wearing
46
+ * a proof's clothing. This tracks capability and opportunity, which is what
47
+ * can be established with certainty, and says so in its explanation.
48
+ */
49
+
50
+ import { RISK } from "./risk.mjs";
51
+ import { DECISION } from "./decisions.mjs";
52
+
53
+ /** The three legs, by the names used in explanations and audit records. */
54
+ export const LEG = Object.freeze({
55
+ SENSITIVE: "sensitive_data",
56
+ UNTRUSTED: "untrusted_content",
57
+ OUTBOUND: "outbound_action",
58
+ });
59
+
60
+ const LEG_LABEL = Object.freeze({
61
+ [LEG.SENSITIVE]: "private data",
62
+ [LEG.UNTRUSTED]: "untrusted content",
63
+ [LEG.OUTBOUND]: "an outbound action",
64
+ });
65
+
66
+ /**
67
+ * How a completed trifecta is answered, per environment.
68
+ *
69
+ * Production denies. Everywhere else holds for a human instead, because the
70
+ * combination is common and benign while a developer is exploring — reading a
71
+ * config, opening docs, pushing a branch — and a tool that refuses that
72
+ * outright gets uninstalled before it ever protects anything. The strictness
73
+ * follows the blast radius rather than being one global setting somebody has
74
+ * to remember to raise.
75
+ */
76
+ const DEFAULT_RESPONSE = Object.freeze({
77
+ production: DECISION.DENY,
78
+ staging: DECISION.REQUIRE_APPROVAL,
79
+ local: DECISION.REQUIRE_APPROVAL,
80
+ unknown: DECISION.REQUIRE_APPROVAL,
81
+ });
82
+
83
+ /* -------------------------------------------------------------- leg C ---- */
84
+
85
+ /**
86
+ * Is THIS call an outbound action?
87
+ *
88
+ * Leg C is a property of the call being judged, not of the session — which is
89
+ * the whole reason the trifecta can be refused *before* it completes rather
90
+ * than reported after. Legs A and B are history; C is the proposal.
91
+ */
92
+ export function outboundLegOf(call = {}) {
93
+ const egress = String(call.egress ?? "none");
94
+ if (egress === "external") {
95
+ return { at: call.timestamp, why: `Sends data to ${call.destination ?? "a destination outside your network"}.`, scope: "external" };
96
+ }
97
+ if (egress === "internal") {
98
+ return { at: call.timestamp, why: `Sends data to ${call.destination ?? "another host on your network"}.`, scope: "internal" };
99
+ }
100
+ // A write is outbound in the sense that matters here: it leaves an effect
101
+ // somewhere the agent does not own, whether or not a packet is involved.
102
+ const action = String(call.action ?? "");
103
+ if (/\.(write|create|update|delete|put|post|send|publish|deploy|push|upload)$/.test(action)) {
104
+ return { at: call.timestamp, why: `Writes to ${call.resource ?? "an external system"}.`, scope: "write" };
105
+ }
106
+ if (call.sql && /^\s*(insert|update|delete|drop|alter|truncate)/i.test(call.sql)) {
107
+ return { at: call.timestamp, why: "Mutates a database.", scope: "write" };
108
+ }
109
+ return null;
110
+ }
111
+
112
+ /* ------------------------------------------------------- session taint ---- */
113
+
114
+ const SENSITIVE_RESOURCE = /secret|credential|token|password|api[-_]?key|\.env|\.pem|\.p12|id_rsa|\.aws|\.ssh|\.kube/i;
115
+
116
+ /**
117
+ * The per-session record of which legs have already been satisfied.
118
+ *
119
+ * Lives for the life of one agent session and holds no payload — only that a
120
+ * leg was satisfied, when, and by which call. Storing the sensitive value
121
+ * itself in order to reason about sensitive values would be its own incident.
122
+ */
123
+ export class SessionTaint {
124
+ constructor() {
125
+ this.legs = { [LEG.SENSITIVE]: null, [LEG.UNTRUSTED]: null };
126
+ }
127
+
128
+ /** Back-compatible with the boolean the pipeline and guard already pass. */
129
+ get touchedSecret() {
130
+ return this.legs[LEG.SENSITIVE] !== null;
131
+ }
132
+
133
+ set touchedSecret(value) {
134
+ if (value && !this.legs[LEG.SENSITIVE]) {
135
+ this.legs[LEG.SENSITIVE] = { at: new Date().toISOString(), why: "Read secret-shaped material.", action: null, resource: null };
136
+ } else if (!value) {
137
+ this.legs[LEG.SENSITIVE] = null;
138
+ }
139
+ }
140
+
141
+ get ingestedUntrusted() {
142
+ return this.legs[LEG.UNTRUSTED] !== null;
143
+ }
144
+
145
+ /**
146
+ * Leg A. A permitted read of secret-shaped material taints the session.
147
+ *
148
+ * A brokered substitution deliberately does not, which is the same rule the
149
+ * pipeline already applied: with a handle the agent never held the material,
150
+ * and that is the entire point of a handle.
151
+ */
152
+ observeCall(call = {}, forwarded = true) {
153
+ if (!forwarded) return this;
154
+ if (this.legs[LEG.SENSITIVE]) return this;
155
+ const resource = String(call.resource ?? "");
156
+ if (SENSITIVE_RESOURCE.test(resource) || Number(call.secretsDetected) > 0) {
157
+ this.legs[LEG.SENSITIVE] = {
158
+ at: call.timestamp ?? new Date().toISOString(),
159
+ action: call.action ?? null,
160
+ resource: resource || null,
161
+ why: `Read secret-shaped material from ${resource || "a tool result"}.`,
162
+ };
163
+ }
164
+ return this;
165
+ }
166
+
167
+ /**
168
+ * Leg B. Content came back from outside the trust boundary.
169
+ *
170
+ * Two independent triggers, because they fail in different directions.
171
+ * Injection findings are strong evidence and weak coverage — they only fire
172
+ * when the text looked like instructions. Provenance is weak evidence and
173
+ * strong coverage — anything fetched from outside is untrusted whether or
174
+ * not it happened to contain an obvious payload. A page that carries a
175
+ * cleverly-worded injection nobody's regex matched is exactly the case the
176
+ * provenance trigger is for.
177
+ */
178
+ observeResult(call = {}, findings = []) {
179
+ if (this.legs[LEG.UNTRUSTED]) return this;
180
+
181
+ const injected = findings.filter((f) => f?.kind === "injection");
182
+ if (injected.length) {
183
+ this.legs[LEG.UNTRUSTED] = {
184
+ at: call.timestamp ?? new Date().toISOString(),
185
+ action: call.action ?? null,
186
+ resource: call.resource ?? null,
187
+ why: `A tool result contained ${injected.length} injected instruction${injected.length === 1 ? "" : "s"} addressed to the model.`,
188
+ evidence: injected.map((f) => f.rule ?? f.detector).filter(Boolean).slice(0, 5),
189
+ };
190
+ return this;
191
+ }
192
+
193
+ if (call.egress === "external" || call.external === true) {
194
+ this.legs[LEG.UNTRUSTED] = {
195
+ at: call.timestamp ?? new Date().toISOString(),
196
+ action: call.action ?? null,
197
+ resource: call.resource ?? null,
198
+ why: `Content was ingested from ${call.destination ?? call.resource ?? "outside the trust boundary"}.`,
199
+ };
200
+ }
201
+ return this;
202
+ }
203
+
204
+ /** Structured state for the audit record. Never contains payload. */
205
+ snapshot() {
206
+ return {
207
+ [LEG.SENSITIVE]: this.legs[LEG.SENSITIVE],
208
+ [LEG.UNTRUSTED]: this.legs[LEG.UNTRUSTED],
209
+ };
210
+ }
211
+
212
+ reset() {
213
+ this.legs = { [LEG.SENSITIVE]: null, [LEG.UNTRUSTED]: null };
214
+ return this;
215
+ }
216
+ }
217
+
218
+ /* --------------------------------------------------------- assessment ---- */
219
+
220
+ /**
221
+ * Would this call complete the trifecta?
222
+ *
223
+ * Returns an assessment whether or not it does — `complete: false` with the
224
+ * legs that ARE satisfied is useful on its own, because it is what lets a
225
+ * dashboard show an agent one step away from the cliff rather than only
226
+ * telling anyone once it has gone over.
227
+ */
228
+ export function assessTrifecta(call = {}, taint = new SessionTaint(), options = {}) {
229
+ const outbound = outboundLegOf(call);
230
+ const legs = {
231
+ [LEG.SENSITIVE]: taint.legs?.[LEG.SENSITIVE] ?? null,
232
+ [LEG.UNTRUSTED]: taint.legs?.[LEG.UNTRUSTED] ?? null,
233
+ [LEG.OUTBOUND]: outbound,
234
+ };
235
+
236
+ const satisfied = Object.entries(legs).filter(([, v]) => v !== null).map(([k]) => k);
237
+ const missing = Object.keys(legs).filter((k) => legs[k] === null);
238
+ const complete = missing.length === 0;
239
+
240
+ const environment = String(call.environment ?? "unknown");
241
+ const configured = options.response ?? DEFAULT_RESPONSE[environment] ?? DEFAULT_RESPONSE.unknown;
242
+
243
+ return {
244
+ complete,
245
+ legs,
246
+ satisfied,
247
+ missing,
248
+ /* One short of the cliff. Worth surfacing before it matters. */
249
+ imminent: !complete && missing.length === 1,
250
+ risk: complete ? RISK.CRITICAL : satisfied.length >= 2 ? RISK.HIGH : RISK.LOW,
251
+ decision: complete ? configured : null,
252
+ environment,
253
+ explain: explainTrifecta({ complete, legs, missing, environment, decision: complete ? configured : null }),
254
+ };
255
+ }
256
+
257
+ /**
258
+ * The sentence a developer reads at 2am.
259
+ *
260
+ * Ordered by when each leg was satisfied rather than by leg name, because the
261
+ * order is the argument: this happened, then this happened, and now you are
262
+ * asking for the third thing.
263
+ */
264
+ export function explainTrifecta({ complete, legs, missing, environment, decision }) {
265
+ const ordered = Object.entries(legs)
266
+ .filter(([, v]) => v !== null)
267
+ .sort((a, b) => String(a[1].at ?? "").localeCompare(String(b[1].at ?? "")));
268
+
269
+ const steps = ordered.map(([leg, v], i) => ` ${i + 1}. ${LEG_LABEL[leg]} — ${v.why}${v.at ? ` (${v.at})` : ""}`);
270
+
271
+ if (!complete) {
272
+ const short = missing.map((m) => LEG_LABEL[m]).join(" and ");
273
+ return [
274
+ ordered.length
275
+ ? `${ordered.length} of 3 trifecta conditions are satisfied in this session:`
276
+ : "No trifecta conditions are satisfied in this session.",
277
+ ...steps,
278
+ ordered.length ? `\nStill missing: ${short}. This call is permitted on trifecta grounds.` : "",
279
+ ].filter(Boolean).join("\n");
280
+ }
281
+
282
+ return [
283
+ `This call would complete the Lethal Trifecta:`,
284
+ ...steps,
285
+ ``,
286
+ `Private data, untrusted content and an outbound action have now all`,
287
+ `occurred in one session. Anything that steered this agent through the`,
288
+ `untrusted content can reach the outside through this call.`,
289
+ ``,
290
+ `Cirvix does not claim the sensitive bytes are in this request — proving`,
291
+ `that would require data-flow analysis it does not do. It refuses on`,
292
+ `capability and opportunity, which are established.`,
293
+ ``,
294
+ `Environment is ${environment}, so the configured response is ${decision}.`,
295
+ ].join("\n");
296
+ }
297
+
298
+ /**
299
+ * Fold the assessment into a decision.
300
+ *
301
+ * Only ever tightens. A call already denied stays denied with its original
302
+ * reason, because the first refusal is the one the developer needs to fix and
303
+ * overwriting it with a second one buries the cause.
304
+ */
305
+ export function applyTrifecta(decision, assessment) {
306
+ if (!assessment?.complete) return decision;
307
+ if (decision.decision === DECISION.DENY) return decision;
308
+
309
+ const next = { ...decision };
310
+ next.decision = assessment.decision;
311
+ next.verdict = assessment.decision === DECISION.DENY ? "deny" : "hold";
312
+ next.rule = "lethal-trifecta";
313
+ next.reason = "private data + untrusted content + outbound action in one session";
314
+ next.trifecta = {
315
+ complete: true,
316
+ legs: assessment.legs,
317
+ explain: assessment.explain,
318
+ };
319
+ next.risk = RISK.CRITICAL;
320
+ return next;
321
+ }
@@ -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
+ }