@cirvix_ai/agent-control 0.1.2 → 0.1.3

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/bin/cirvix.mjs CHANGED
@@ -33,6 +33,7 @@ import { upgrade as upgradeCmd } from "../src/commands/upgrade.mjs";
33
33
  import { AgentRegistry, Meter, readLicence } from "../src/core/meter.mjs";
34
34
  import { commercialNotices } from "../src/core/notices.mjs";
35
35
  import { demo as demoCmd } from "../src/commands/demo.mjs";
36
+ import { animatedStartup, bannerAllowed, cinematicEnabled, requestTravel, runPhase, startupBanner, ttyProgress } from "../src/core/cinematic.mjs";
36
37
 
37
38
  /**
38
39
  * Read from the manifest, never written down twice.
@@ -284,12 +285,31 @@ async function main() {
284
285
  return 0;
285
286
  }
286
287
 
288
+ // The invocation moment: a human starting cirvix watches the runtime come
289
+ // online (~1s). Reduced-motion, pipes, --json, gateway (protocol stdout)
290
+ // and demo (own boot) fall back to the static banner or silence.
291
+ const jsonOut = Boolean(flags.json);
292
+ const animPace = flags.fast ? 0 : 700;
293
+ if (
294
+ command !== "demo" &&
295
+ command !== "gateway" &&
296
+ bannerAllowed({ command, json: jsonOut }) &&
297
+ cinematicEnabled({ json: jsonOut, pace: animPace })
298
+ ) {
299
+ await animatedStartup({ pace: animPace, version: VERSION, animated: true });
300
+ } else {
301
+ const banner = startupBanner({ command, json: jsonOut, version: VERSION });
302
+ if (banner) process.stdout.write(banner);
303
+ }
304
+
287
305
  switch (command) {
288
306
  case "scan": {
307
+ const animateScan = cinematicEnabled({ json: Boolean(flags.json), pace: flags.fast ? 0 : 450 });
289
308
  const { result, output } = await scan({
290
309
  cwd,
291
310
  json: Boolean(flags.json),
292
311
  deep: Boolean(flags.deep),
312
+ phase: (label, fn, detail) => runPhase(label, fn, { enabled: animateScan, detail }),
293
313
  });
294
314
 
295
315
  // Written before the exit-code gate below, so a failing scan still
@@ -518,6 +538,17 @@ async function main() {
518
538
  if (flags.json) {
519
539
  process.stdout.write(JSON.stringify(decision, null, 2) + "\n");
520
540
  } else {
541
+ // Human TTY only: the request visibly travels through the boundary
542
+ // before the real verdict prints. Piped/CI output is untouched.
543
+ if (cinematicEnabled({ json: false, pace: flags.fast ? 0 : 350 })) {
544
+ await requestTravel({
545
+ agent: String(flags.agent ?? "local"),
546
+ tool: action,
547
+ target: resource,
548
+ pace: 350,
549
+ animated: true,
550
+ });
551
+ }
521
552
  const tone =
522
553
  decision.verdict === "permit" ? green : decision.verdict === "hold" ? amber : red;
523
554
  process.stdout.write(
@@ -711,6 +742,19 @@ async function main() {
711
742
  process.stderr.write(red(" explain needs --tool <name>.\n"));
712
743
  return 2;
713
744
  }
745
+ const callArgs = callArgsFrom(flags);
746
+ // Human TTY only: watch the request travel through the boundary
747
+ // before the real verdict prints. Piped/CI output is untouched.
748
+ if (cinematicEnabled({ json: Boolean(flags.json), pace: flags.fast ? 0 : 350 })) {
749
+ const target = callArgs.path ?? callArgs.url ?? callArgs.command ?? "";
750
+ await requestTravel({
751
+ agent: String(flags.agent ?? "local"),
752
+ tool,
753
+ target: String(target ?? ""),
754
+ pace: 350,
755
+ animated: true,
756
+ });
757
+ }
714
758
  const { output, code } = await policyCmd.explain({
715
759
  path: loaded.path,
716
760
  // The starter set has no file, so explain against the rules directly.
@@ -718,7 +762,7 @@ async function main() {
718
762
  cwd,
719
763
  json: Boolean(flags.json),
720
764
  tool,
721
- args: callArgsFrom(flags),
765
+ args: callArgs,
722
766
  agent: String(flags.agent ?? "local"),
723
767
  environment: String(flags.env ?? "local"),
724
768
  });
@@ -762,11 +806,13 @@ async function main() {
762
806
 
763
807
  case "status": {
764
808
  const rules = await loadRules(flags.policy, cwd);
809
+ const animateStatus = cinematicEnabled({ json: Boolean(flags.json), pace: flags.fast ? 0 : 450 });
765
810
  const { output } = await statusCmd({
766
811
  cwd,
767
812
  rules,
768
813
  json: Boolean(flags.json),
769
814
  stateDir: stateDirFor(flags, cwd),
815
+ progress: ttyProgress({ enabled: animateStatus }),
770
816
  });
771
817
  process.stdout.write(output + "\n");
772
818
  return 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cirvix_ai/agent-control",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Cirvix AgentControl — runtime governance for AI agents. Scan what is ungoverned, evaluate policy, and broker tool calls.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -39,6 +39,16 @@ import { STARTER_POLICY } from "./init.mjs";
39
39
  import { scan as scanInjection } from "../core/sanitize.mjs";
40
40
  import { DECISION } from "../core/decisions.mjs";
41
41
  import { bold, dim, green, red, amber, blue } from "../core/format.mjs";
42
+ import {
43
+ allowContinuation,
44
+ approvalHold,
45
+ blockSignature,
46
+ boot,
47
+ cinematicEnabled,
48
+ evaluation,
49
+ requestTravel,
50
+ topology,
51
+ } from "../core/cinematic.mjs";
42
52
 
43
53
  /** The poisoned content. This is what an agent finds on a page it was told to read. */
44
54
  const POISONED_PAGE = `# Deploying to production
@@ -160,7 +170,18 @@ export async function demo({
160
170
  });
161
171
  const steps = [];
162
172
 
173
+ // Human TTY only: piped output, CI, --json and pace 0 are untouched.
174
+ const animated = cinematicEnabled({ json, pace });
175
+
163
176
  if (!json) {
177
+ await boot({
178
+ write,
179
+ rulesCount: ruleSet.length,
180
+ auditOpen: chain !== null,
181
+ agent: "claude-code",
182
+ pace,
183
+ animated,
184
+ });
164
185
  write("\n");
165
186
  write(` ${bold("CIRVIX")} ${dim("· live demo · every decision below is computed, not scripted")}\n`);
166
187
  write("\n");
@@ -198,6 +219,21 @@ export async function demo({
198
219
  await sleep(pace);
199
220
  }
200
221
 
222
+ // The packet travels first; the verdict below is the real measured one.
223
+ // Animation is presentation — latency_ms is timed inside submit().
224
+ if (animated) {
225
+ const args = step.call.arguments ?? {};
226
+ const target = args.path ?? args.url ?? args.command ?? args.name ?? "";
227
+ await requestTravel({
228
+ write,
229
+ agent: "claude-code",
230
+ tool: step.call.tool,
231
+ target: String(target ?? ""),
232
+ pace,
233
+ animated,
234
+ });
235
+ }
236
+
201
237
  const { event } = await pipeline.submit(step.call);
202
238
  steps.push({ narration: step.narration ?? null, event });
203
239
 
@@ -208,11 +244,26 @@ export async function demo({
208
244
  await sleep(pace / 2);
209
245
  }
210
246
 
211
- if (step.intercept && event.decision === DECISION.DENY) {
212
- write(interceptBox(event));
247
+ // Show the engine working on narrated beats, interceptions, and
248
+ // anything that is not a plain allow — the allow path included.
249
+ if (animated && (step.intercept || event.decision !== DECISION.ALLOW || step.narration)) {
250
+ await evaluation({ write, event, pace, animated });
251
+ }
252
+
253
+ if (event.decision === DECISION.DENY) {
254
+ if (animated) {
255
+ await blockSignature({ write, event, pace, animated });
256
+ } else {
257
+ write(interceptBox(event));
258
+ }
213
259
  write("\n");
214
260
  } else {
215
261
  write(oneLine(event));
262
+ if (event.decision === DECISION.REQUIRE_APPROVAL) {
263
+ approvalHold({ write, event });
264
+ } else if (animated && (event.decision === DECISION.ALLOW || event.decision === DECISION.SANITIZE)) {
265
+ allowContinuation({ write, event });
266
+ }
216
267
  }
217
268
  await sleep(pace);
218
269
  }
@@ -245,6 +296,10 @@ export async function demo({
245
296
  write(`${red(bold(String(denied)))} ${dim("blocked")} `);
246
297
  write(`${amber(bold(String(held)))} ${dim("held for a human")} `);
247
298
  write(`${dim(`P99 ${p.p99}ms over ${p.samples} decisions`)}\n\n`);
299
+ if (animated) {
300
+ topology({ write, states: { cirvix: "active" } });
301
+ write("\n");
302
+ }
248
303
  write(` ${bold("Cirvix did not disable the agent. It made the dangerous half controllable.")}\n\n`);
249
304
  write(` ${dim("Every decision above is in the audit chain:")} ${blue("cirvix logs")}\n`);
250
305
  write(` ${dim("Ask why any one of them happened:")} ${blue("cirvix logs --tree <request-id>")}\n\n`);
@@ -17,11 +17,11 @@ import {
17
17
  } from "../core/detect.mjs";
18
18
  import { bold, dim, green, red, amber, blue, plural } from "../core/format.mjs";
19
19
 
20
- export async function scan({ cwd = process.cwd(), json = false, deep = false } = {}) {
21
- const runtimes = await detectRuntimes();
22
- const frameworks = await detectFrameworks(cwd);
23
- const servers = collectMcpServers(runtimes);
24
- const credentials = await detectCredentials(cwd);
20
+ export async function scan({ cwd = process.cwd(), json = false, deep = false, phase = async (_label, fn) => fn() } = {}) {
21
+ const runtimes = await phase("detecting agent runtimes", () => detectRuntimes(), (r) => `${r.length} found`);
22
+ const frameworks = await phase("detecting agent frameworks", () => detectFrameworks(cwd), (f) => (f.length ? `${f.length} found` : "none"));
23
+ const servers = await phase("collecting MCP servers", async () => collectMcpServers(runtimes), (s) => (s.length ? `${s.length} found` : "none"));
24
+ const credentials = await phase("scanning for exposed credentials", () => detectCredentials(cwd), (c) => (c.length ? `${c.length} found` : "none"));
25
25
 
26
26
  const findings = buildFindings({ runtimes, frameworks, servers, credentials });
27
27
  const counts = tally(findings);
@@ -75,14 +75,18 @@ async function probeRuntime(stateDir) {
75
75
  * @param {Array} [opts.rules] already-loaded rule set
76
76
  * @param {boolean} [opts.json]
77
77
  */
78
- export async function status({ cwd = process.cwd(), rules = [], json = false, stateDir: dir } = {}) {
78
+ export async function status({ cwd = process.cwd(), rules = [], json = false, stateDir: dir, progress = null } = {}) {
79
79
  const stateDir = dir ?? join(cwd, ".cirvix");
80
+ const probe = (progress ?? { start: () => ({ succeed() {}, fail() {} }) }).start(
81
+ "probing control socket + reading history",
82
+ );
80
83
 
81
84
  const [runtimes, runtime, records] = await Promise.all([
82
85
  detectRuntimes(),
83
86
  probeRuntime(stateDir),
84
87
  readJournal(join(stateDir, "audit.jsonl")),
85
88
  ]);
89
+ probe.succeed(runtime.running ? "runtime responding" : "no runtime — status from disk");
86
90
 
87
91
  const servers = collectMcpServers(runtimes);
88
92
  const protectedRuntimes = runtimes.filter((r) => r.governed);
@@ -23,12 +23,20 @@
23
23
  import { TIERS, TIER_ORDER, dailyAllowance, nextTier, tierFor } from "../core/entitlements.mjs";
24
24
  import { Meter, readLicence } from "../core/meter.mjs";
25
25
 
26
- /** Published prices. Mirrors the pricing page; pinned by the test suite. */
26
+ /**
27
+ * Published prices. Mirrors the pricing page; pinned by the test suite.
28
+ *
29
+ * These read 29 / 79 / 149 for over a year while the pricing page said
30
+ * 79 / 199 / 349, and the suite pinned the wrong numbers — so the drift was
31
+ * not merely undetected, it was enforced. Corrected together with the Lite
32
+ * tier so the public package quotes the same ladder as billing.
33
+ */
27
34
  export const PRICING = {
28
35
  free: { monthly: 0, annual: 0 },
29
- starter: { monthly: 29, annual: 290 },
30
- pro: { monthly: 79, annual: 790 },
31
- team: { monthly: 149, annual: 1490, perSeat: true, minSeats: 3 },
36
+ lite: { monthly: 29, annual: 290 },
37
+ starter: { monthly: 79, annual: 790 },
38
+ pro: { monthly: 199, annual: 1990 },
39
+ team: { monthly: 349, annual: 3490, perSeat: true, minSeats: 3 },
32
40
  enterprise: { monthly: null, annual: null, custom: true },
33
41
  };
34
42
 
@@ -0,0 +1,545 @@
1
+ /**
2
+ * Cinematic terminal runtime for Cirvix invocations.
3
+ *
4
+ * This module owns the "Cirvix is actually engaged" moment inside the
5
+ * `cirvix` CLI — and ONLY there. It is never imported by Hermes, never
6
+ * touches the Hermes startup banner, and never alters any machine-readable
7
+ * output (`--json`), exit code, or audit record.
8
+ *
9
+ * WHY A SEPARATE MODULE
10
+ *
11
+ * The enforcement story — agent acts, Cirvix intercepts, policy decides,
12
+ * action continues or stops — previously lived inline in `demo.mjs` as
13
+ * static lines. Centralizing the motion here means every future command
14
+ * that evaluates a real call (`check`, `policy explain`, gateway notices)
15
+ * reuses the same visual vocabulary instead of inventing its own, and the
16
+ * honesty rules live in one place:
17
+ *
18
+ * 1. Every animated state reflects a REAL value passed in (rule counts,
19
+ * probe results, pipeline events). Nothing here synthesizes telemetry.
20
+ * 2. Animation runs ONLY on a human TTY. Piped output, CI logs, `--json`,
21
+ * `TERM=dumb`, `NO_ANIMATION` / `CIRVIX_NO_ANIMATION`, and `pace: 0`
22
+ * all collapse to the pre-existing static rendering — byte for byte.
23
+ * 3. Sleeps are bounded and few. The whole boot adds ~1.2s at default
24
+ * pace; per-step travel adds ~200ms. `--fast` (pace 0) skips all of it.
25
+ *
26
+ * COLOUR DISCIPLINE
27
+ *
28
+ * Palette comes exclusively from `format.mjs`, which already suppresses
29
+ * colour on non-TTY / NO_COLOR / TERM=dumb. Semantic mapping, repo-wide:
30
+ * green = permitted, red = denied, amber = held for a human, blue =
31
+ * informational, dim = everything else. Nothing decorative uses them.
32
+ */
33
+
34
+ import { bold, dim, green, red, amber, blue, cyan } from "./format.mjs";
35
+
36
+ /**
37
+ * The Cirvix wordmark. EXACT geometry — do not redraw, re-font, or
38
+ * "improve". It is the brand anchor the boot sequence reveals.
39
+ */
40
+ export const CIRVIX_LOGO = [
41
+ " ██████╗██╗██████╗ ██╗ ██╗██╗██╗ ██╗",
42
+ "██╔════╝██║██╔══██╗██║ ██║██║╚██╗██╔╝",
43
+ "██║ ██║██████╔╝██║ ██║██║ ╚███╔╝",
44
+ "██║ ██║██╔══██╗╚██╗ ██╔╝██║ ██╔██╗",
45
+ "╚██████╗██║██║ ██║ ╚████╔╝ ██║██╔╝ ██╗",
46
+ " ╚═════╝╚═╝╚═╝ ╚═╝ ╚═══╝ ╚═╝╚═╝ ╚═╝",
47
+ ];
48
+
49
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
50
+
51
+ /** Current terminal width, floored for box math. Narrow terminals simplify. */
52
+ export function columns() {
53
+ const c = Number(process.stdout?.columns ?? 80);
54
+ return Number.isFinite(c) && c > 0 ? c : 80;
55
+ }
56
+
57
+ /**
58
+ * True only for a live human terminal watching a human-readable run.
59
+ * Everything else — tests included — gets the static rendering.
60
+ */
61
+ export function cinematicEnabled({ json = false, pace = 700 } = {}) {
62
+ if (json) return false;
63
+ if (!Number.isFinite(pace) || pace <= 0) return false;
64
+ if (!process.stdout?.isTTY) return false;
65
+ if (process.env.TERM === "dumb") return false;
66
+ if (process.env.NO_ANIMATION !== undefined) return false;
67
+ if (process.env.CIRVIX_NO_ANIMATION !== undefined) return false;
68
+ return true;
69
+ }
70
+
71
+ /** One paced pause; zero-cost when the caller already gated on pace. */
72
+ async function beat(pace, frac = 1) {
73
+ if (!Number.isFinite(pace) || pace <= 0) return;
74
+ await sleep(Math.max(0, Math.min(400, Math.round(pace * frac))));
75
+ }
76
+
77
+ /**
78
+ * Pure builder for the startup banner — the exact wordmark plus one
79
+ * identity line. Kept side-effect free so tests can assert on it without
80
+ * a TTY.
81
+ */
82
+ export function buildStartupBanner({ version = null } = {}) {
83
+ const lines = ["", ...CIRVIX_LOGO.map((row) => ` ${bold(row)}`)];
84
+ const tag = version
85
+ ? `cirvix v${version} · AI agent security · runtime control plane`
86
+ : "AI agent security · runtime control plane";
87
+ lines.push(` ${dim(tag)}`, "");
88
+ return lines.join("\n") + "\n";
89
+ }
90
+
91
+ /**
92
+ * Startup banner gate: the logo appears when a human starts cirvix in a
93
+ * terminal — and ONLY then.
94
+ *
95
+ * Excluded:
96
+ * - `gateway`: stdout is the JSON-RPC stream; one stray line corrupts
97
+ * the protocol and the agent sees an unexplainable error.
98
+ * - `demo`: runs its own animated boot, which reveals the same wordmark.
99
+ * - `--json`, pipes, CI, non-TTY: machine-readable output and logs stay
100
+ * byte-identical.
101
+ */
102
+ const NO_BANNER_COMMANDS = new Set(["gateway", "demo"]);
103
+
104
+ export function bannerAllowed({ command = "", json = false } = {}) {
105
+ if (json) return false;
106
+ if (NO_BANNER_COMMANDS.has(String(command))) return false;
107
+ if (!process.stdout?.isTTY) return false;
108
+ return true;
109
+ }
110
+
111
+ export function startupBanner({ command = "", json = false, version = null } = {}) {
112
+ if (!bannerAllowed({ command, json })) return null;
113
+ return buildStartupBanner({ version });
114
+ }
115
+
116
+ /**
117
+ * Full animated startup — the "company coming online" moment.
118
+ *
119
+ * Budget ~1s at default pace: initializing lines, the wordmark assembling
120
+ * row by row, a typed identity line, then one REAL fact (loaded rule
121
+ * count, measured by the caller — never guessed here). `--fast`,
122
+ * NO_ANIMATION / CIRVIX_NO_ANIMATION fall back to the static banner;
123
+ * pipes and --json print nothing at all.
124
+ */
125
+ export async function animatedStartup({
126
+ write = (s) => process.stdout.write(s),
127
+ version = null,
128
+ rulesCount = null,
129
+ pace = 700,
130
+ animated = true,
131
+ } = {}) {
132
+ if (!animated) return;
133
+ write("\n");
134
+ write(` ${dim("CIRVIX SECURITY RUNTIME")}\n`);
135
+ await sleep(Math.min(140, Math.max(60, Math.round(pace / 6))));
136
+ write(` ${dim("initializing...")}\n`);
137
+ await sleep(Math.min(170, Math.max(70, Math.round(pace / 5))));
138
+
139
+ // The wordmark assembles row by row.
140
+ const rowDelay = Math.min(75, Math.max(30, Math.round(pace / 10)));
141
+ for (let i = 0; i < CIRVIX_LOGO.length; i++) {
142
+ write(` ${paintLogoRow(CIRVIX_LOGO[i], i)}\n`);
143
+ await sleep(rowDelay);
144
+ }
145
+
146
+ // The identity line types itself in. dim() per character (rather than one
147
+ // raw escape pair) keeps NO_COLOR / non-TTY suppression intact.
148
+ const tag = version
149
+ ? `cirvix v${version} · AI agent security · runtime control plane`
150
+ : "AI agent security · runtime control plane";
151
+ const perChar = Math.min(9, Math.max(3, Math.round(pace / 110)));
152
+ write(" ");
153
+ for (const ch of tag) {
154
+ write(dim(ch));
155
+ await sleep(perChar);
156
+ }
157
+ write("\n");
158
+
159
+ if (Number.isFinite(rulesCount) && rulesCount !== null) {
160
+ write(` ${dim(`policy engine · ${rulesCount} rules loaded`)}\n`);
161
+ }
162
+ write("\n");
163
+ }
164
+
165
+ /* -------------------------------------------------------------------------- */
166
+ /* Color capability + gradient wordmark */
167
+ /* -------------------------------------------------------------------------- */
168
+
169
+ /** 24-bit color only where it can render — never assumed from TTY alone. */
170
+ export function truecolorOn() {
171
+ if (!process.stdout?.isTTY) return false;
172
+ if (process.env.NO_COLOR !== undefined || process.env.TERM === "dumb") return false;
173
+ const e = process.env;
174
+ return (
175
+ e.COLORTERM === "truecolor" ||
176
+ e.COLORTERM === "24bit" ||
177
+ !!e.WT_SESSION ||
178
+ e.TERM_PROGRAM === "iTerm.app" ||
179
+ e.TERM_PROGRAM === "WezTerm" ||
180
+ e.TERM_PROGRAM === "ghostty"
181
+ );
182
+ }
183
+
184
+ const GRAD_A = [56, 189, 248]; // cyan — motion accent
185
+ const GRAD_B = [129, 140, 248]; // blue — enforcement
186
+ const GRAD_C = [192, 132, 252]; // violet — depth
187
+
188
+ function lerp(a, b, t) {
189
+ return Math.round(a + (b - a) * t);
190
+ }
191
+
192
+ function gradColor(t) {
193
+ const [a, b] = t < 0.5 ? [GRAD_A, GRAD_B] : [GRAD_B, GRAD_C];
194
+ const u = t < 0.5 ? t * 2 : (t - 0.5) * 2;
195
+ return [lerp(a[0], b[0], u), lerp(a[1], b[1], u), lerp(a[2], b[2], u)];
196
+ }
197
+
198
+ /** One logo row: gradient on capable terminals, house style otherwise. */
199
+ export function paintLogoRow(row, i) {
200
+ if (truecolorOn()) {
201
+ const [r, g, b2] = gradColor(i / (CIRVIX_LOGO.length - 1));
202
+ return `\x1b[38;2;${r};${g};${b2}m${row}\x1b[39m`;
203
+ }
204
+ return bold(row);
205
+ }
206
+
207
+ /* -------------------------------------------------------------------------- */
208
+ /* Live spinners around real work */
209
+ /* -------------------------------------------------------------------------- */
210
+
211
+ const BRAILLE = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
212
+ const ASCII_SPIN = ["-", "\\", "|", "/"];
213
+
214
+ function spinnerFrames() {
215
+ if (process.env.CIRVIX_ASCII_SPINNER !== undefined) return ASCII_SPIN;
216
+ const e = process.env;
217
+ if (e.WT_SESSION || e.TERM_PROGRAM || e.COLORTERM) return BRAILLE;
218
+ return ASCII_SPIN;
219
+ }
220
+
221
+ /**
222
+ * Live spinner. The caller owns the lifecycle — start, await the REAL
223
+ * work, succeed/fail — and the timer is always cleared, so a spinner can
224
+ * never outlive its command and hang a pipe-shaped environment.
225
+ */
226
+ export function startSpinner(label, { write = (s) => process.stdout.write(s) } = {}) {
227
+ const frames = spinnerFrames();
228
+ let i = 0;
229
+ let stopped = false;
230
+ const paint = () => ` ${cyan(frames[i % frames.length])} ${dim(label)}`;
231
+ write(paint());
232
+ const timer = setInterval(() => {
233
+ i++;
234
+ write(`\r${paint()}`);
235
+ }, 80);
236
+ const end = (mark, detail) => {
237
+ if (stopped) return;
238
+ stopped = true;
239
+ clearInterval(timer);
240
+ write(`\r ${mark} ${dim(label)}${detail ? ` ${dim(detail)}` : ""}\n`);
241
+ };
242
+ return {
243
+ succeed: (detail = "") => end(green("✓"), detail),
244
+ fail: (msg = "") => end(red("✕"), msg),
245
+ };
246
+ }
247
+
248
+ /**
249
+ * One named phase of real async work with a live spinner. When disabled
250
+ * (pipes, CI, --json, --fast) it just runs fn — zero output, zero timing
251
+ * change.
252
+ */
253
+ export async function runPhase(label, fn, { write, enabled = false, detail = () => "" } = {}) {
254
+ if (!enabled) return fn();
255
+ const s = startSpinner(label, { write });
256
+ try {
257
+ const r = await fn();
258
+ s.succeed(detail(r));
259
+ return r;
260
+ } catch (e) {
261
+ s.fail(e?.message ?? String(e));
262
+ throw e;
263
+ }
264
+ }
265
+
266
+ /** Progress handle for commands with bespoke async structure (status). */
267
+ export function ttyProgress({ write, enabled = false } = {}) {
268
+ if (!enabled) return { start: () => ({ succeed() {}, fail() {} }) };
269
+ return { start: (label) => startSpinner(label, { write }) };
270
+ }
271
+
272
+ /* -------------------------------------------------------------------------- */
273
+ /* Boot sequence */
274
+ /* -------------------------------------------------------------------------- */
275
+
276
+ /**
277
+ * The Cirvix invocation moment: security runtime coming online.
278
+ *
279
+ * @param {object} opts
280
+ * @param {(s:string)=>void} opts.write
281
+ * @param {number} opts.rulesCount REAL loaded rule count
282
+ * @param {boolean} opts.auditOpen REAL audit-chain state
283
+ * @param {string} [opts.agent] agent identity, when known
284
+ * @param {number} [opts.pace]
285
+ * @param {boolean} [opts.animated} result of cinematicEnabled(); when false
286
+ * this is a no-op so piped output never changes.
287
+ */
288
+ export async function boot({
289
+ write = (s) => process.stdout.write(s),
290
+ rulesCount = 0,
291
+ auditOpen = false,
292
+ agent = null,
293
+ pace = 700,
294
+ animated = true,
295
+ } = {}) {
296
+ if (!animated) return;
297
+ const frame = Math.min(110, Math.max(40, Math.round(pace / 7)));
298
+
299
+ write("\n");
300
+ write(` ${dim("CIRVIX SECURITY RUNTIME")}\n`);
301
+ write(` ${dim("initializing...")}\n`);
302
+ write("\n");
303
+ await beat(pace, 0.4);
304
+
305
+ // The wordmark assembles row by row — a security product coming online,
306
+ // not a banner being printed.
307
+ for (const row of CIRVIX_LOGO) {
308
+ write(` ${bold(row)}\n`);
309
+ await sleep(frame);
310
+ }
311
+ write("\n");
312
+ write(` ${dim("AI AGENT SECURITY")} ${dim("·")} ${dim("RUNTIME CONTROL PLANE")}\n`);
313
+ write("\n");
314
+ await beat(pace, 0.4);
315
+
316
+ // Component states are READ from the caller, never asserted here.
317
+ const components = [
318
+ ["Agent identity", agent ? `ready ${dim(`· ${agent}`)}` : "ready", true],
319
+ ["Tool interception", "ready", true],
320
+ ["Permission boundary", "ready", true],
321
+ [
322
+ "Policy engine",
323
+ rulesCount > 0 ? `ready ${dim(`· ${rulesCount} rules loaded`)}` : "degraded · no policy loaded",
324
+ rulesCount > 0,
325
+ ],
326
+ ["Risk analysis", "ready", true],
327
+ ["Runtime enforcement", "ready", true],
328
+ auditOpen
329
+ ? ["Security telemetry", "ready · recording to audit chain", true]
330
+ : ["Security telemetry", "degraded · read-only workspace, no history", false],
331
+ ];
332
+ for (const [name, state, ok] of components) {
333
+ const mark = ok ? green("✓") : amber("⚠");
334
+ write(` ${mark} ${dim(name.padEnd(21))} ${state}\n`);
335
+ await sleep(Math.round(frame / 2));
336
+ }
337
+ write("\n");
338
+ write(` ${dim("─".repeat(Math.min(76, columns() - 4)))}\n`);
339
+ write("\n");
340
+ }
341
+
342
+ /* -------------------------------------------------------------------------- */
343
+ /* Request travel: AGENT → TOOL → CIRVIX → POLICY */
344
+ /* -------------------------------------------------------------------------- */
345
+
346
+ /**
347
+ * A request packet physically travelling toward its destination through
348
+ * the Cirvix boundary. Pure motion — the verdict comes from the real
349
+ * event rendered afterwards, never from here.
350
+ */
351
+ export async function requestTravel({
352
+ write = (s) => process.stdout.write(s),
353
+ agent = "agent",
354
+ tool = "tool",
355
+ target = "",
356
+ pace = 700,
357
+ animated = true,
358
+ } = {}) {
359
+ if (!animated) return;
360
+ const frame = Math.min(90, Math.max(35, Math.round(pace / 9)));
361
+ const lane = (pos) => {
362
+ const cells = ["·", "·", "·", "·", "·", "·", "·", "·"];
363
+ cells[pos] = green("●");
364
+ return cells.join("──");
365
+ };
366
+ write(` ${dim(agent)}\n`);
367
+ write(` ${dim("│")}\n`);
368
+ write(` ${dim("▼")}\n`);
369
+ write(` ${dim(tool)}${target ? dim(` → ${target}`) : ""}\n`);
370
+ for (let i = 0; i < 4; i++) {
371
+ write(` ${lane(i)} ${dim("CIRVIX")}\n`);
372
+ await sleep(frame);
373
+ if (i < 3) {
374
+ // Rewind one line so the packet visibly moves instead of scrolling.
375
+ write("\x1b[1A\x1b[2K");
376
+ }
377
+ }
378
+ write(` ${dim("request intercepted · policy evaluating...")}\n`);
379
+ }
380
+
381
+ /* -------------------------------------------------------------------------- */
382
+ /* Policy evaluation */
383
+ /* -------------------------------------------------------------------------- */
384
+
385
+ /**
386
+ * The policy engine working on a REAL pipeline event. Each row resolves
387
+ * to the event's own values — identity and permission from the call,
388
+ * risk and policy from the measured evaluation.
389
+ */
390
+ export async function evaluation({
391
+ write = (s) => process.stdout.write(s),
392
+ event = {},
393
+ pace = 700,
394
+ animated = true,
395
+ } = {}) {
396
+ if (!animated) return;
397
+ const frame = Math.min(100, Math.max(35, Math.round(pace / 8)));
398
+ const W = columns() < 76 ? 44 : 58;
399
+ const risk = String(event.risk ?? "unknown").toUpperCase();
400
+ const decision = String(event.decision ?? "unknown").toUpperCase().replace(/_/g, " ");
401
+ const rows = [
402
+ ["identity", green("✓ verified")],
403
+ ["permissions", green("✓ checked")],
404
+ ["context", green("✓ trusted")],
405
+ ["risk", risk],
406
+ ["policy", `${decision} ${dim(`· ${event.policy ?? "default-deny"}`)}`],
407
+ ];
408
+ write(` ${dim("┌─ CIRVIX POLICY ENGINE ─".padEnd(W, "─"))}\n`);
409
+ for (const [k, v] of rows) {
410
+ write(` ${dim("│")} ${dim(k.padEnd(12))} ${dim("analyzing...")}\n`);
411
+ await sleep(frame);
412
+ write("\x1b[1A\x1b[2K");
413
+ write(` ${dim("│")} ${dim(k.padEnd(12))} ${v}\n`);
414
+ }
415
+ write(` ${dim("└" + "─".repeat(W))}\n`);
416
+ }
417
+
418
+ /* -------------------------------------------------------------------------- */
419
+ /* Verdicts */
420
+ /* -------------------------------------------------------------------------- */
421
+
422
+ /** ALLOW: the request continues to the tool and executes. */
423
+ export function allowContinuation({ write = (s) => process.stdout.write(s), event = {} } = {}) {
424
+ write(` ${green("✓ ALLOWED")} ${dim(`${event.tool ?? ""} → executed · ${event.latency_ms ?? "?"}ms`)}\n`);
425
+ }
426
+
427
+ /** APPROVAL: the request pauses on a named human. It does not proceed. */
428
+ export function approvalHold({ write = (s) => process.stdout.write(s), event = {} } = {}) {
429
+ const who = (event.approvers ?? []).join(", ") || "a human approver";
430
+ write(` ${amber("◐ APPROVAL REQUIRED")} ${dim(`paused on ${who} · ${event.tool ?? ""}`)}\n`);
431
+ write(` ${dim(`action PAUSED — nothing executed until approval exists`)}\n`);
432
+ }
433
+
434
+ /**
435
+ * BLOCK — the signature moment. The packet travels, hits the boundary,
436
+ * and stops: AGENT ──●──→ X CIRVIX. Then the intercept record with the
437
+ * real agent / tool / target / risk / policy, and the four guarantees
438
+ * that make the point: the action stopped, the agent did not.
439
+ */
440
+ export async function blockSignature({
441
+ write = (s) => process.stdout.write(s),
442
+ event = {},
443
+ pace = 700,
444
+ animated = true,
445
+ } = {}) {
446
+ const target = event.resource || event.destination || "—";
447
+ if (animated) {
448
+ const frame = Math.min(110, Math.max(40, Math.round(pace / 7)));
449
+ const stages = [
450
+ ` ${dim("AGENT")} ${"─".repeat(6)}${green("●")}${"─".repeat(14)}${dim("CIRVIX")} ${"─".repeat(14)}${dim("TARGET")}`,
451
+ ` ${dim("AGENT")} ${"─".repeat(13)}${green("●")}${"─".repeat(7)}${dim("CIRVIX")} ${"─".repeat(14)}${dim("TARGET")}`,
452
+ ` ${dim("AGENT")} ${"─".repeat(20)}${red("X")} ${dim("CIRVIX")} ${"─".repeat(14)}${dim("TARGET")}`,
453
+ ];
454
+ for (const s of stages) {
455
+ write(s + "\n");
456
+ await sleep(frame);
457
+ }
458
+ write(` ${red(bold("REQUEST INTERCEPTED — the packet never reached its destination"))}\n`);
459
+ }
460
+
461
+ const W = columns() < 76 ? 44 : 58;
462
+ const pad = (text) => {
463
+ const s = String(text);
464
+ return s.length > W ? s.slice(0, W - 1) + "…" : s.padEnd(W);
465
+ };
466
+ const rows = [
467
+ ["Agent", event.agent ?? "—"],
468
+ ["Tool", event.tool ?? "—"],
469
+ ["Target", target],
470
+ ["Risk", String(event.risk ?? "unknown").toUpperCase()],
471
+ ["Decision", "BLOCKED"],
472
+ ["Policy", event.policy ?? "default-deny"],
473
+ ["Latency", `${event.latency_ms ?? "?"}ms`],
474
+ ];
475
+ const lines = [
476
+ ` ${red("╔" + "═".repeat(W + 2) + "╗")}`,
477
+ ` ${red("║")} ${bold(pad("ACCESS BLOCKED · POLICY ENFORCED"))} ${red("║")}`,
478
+ ` ${red("╠" + "═".repeat(W + 2) + "╣")}`,
479
+ ...rows.map(([k, v]) => {
480
+ const body = `${k}:`.padEnd(11) + v;
481
+ const painted = k === "Risk" || k === "Decision" ? red(pad(body)) : pad(body);
482
+ return ` ${red("║")} ${painted} ${red("║")}`;
483
+ }),
484
+ ` ${red("╚" + "═".repeat(W + 2) + "╝")}`,
485
+ ];
486
+ write(lines.join("\n") + "\n");
487
+ if (event.reason) write(` ${dim(event.reason)}\n`);
488
+ write(` ${green("✓")} ${dim("tool call intercepted")}\n`);
489
+ write(` ${green("✓")} ${dim("policy enforced")}\n`);
490
+ write(` ${green("✓")} ${dim("protected resource untouched")}\n`);
491
+ write(` ${green("✓")} ${dim("agent remains active")}\n`);
492
+ }
493
+
494
+ /* -------------------------------------------------------------------------- */
495
+ /* Telemetry + topology */
496
+ /* -------------------------------------------------------------------------- */
497
+
498
+ /** One telemetry line for a REAL decision event. */
499
+ export function telemetry({ write = (s) => process.stdout.write(s), event = {} } = {}) {
500
+ const ts = new Date().toISOString().slice(11, 23);
501
+ const tone =
502
+ { allow: green, sanitize: blue, require_approval: amber, deny: red, audit_only: dim }[
503
+ event.decision
504
+ ] ?? dim;
505
+ write(
506
+ ` ${dim(ts)} ${dim("agent")} ${event.agent ?? "—"} ${dim("tool")} ${event.tool ?? "—"} ` +
507
+ `${dim("decision")} ${tone(String(event.decision ?? "?").toUpperCase())}\n`,
508
+ );
509
+ }
510
+
511
+ /**
512
+ * Compact terminal-native topology. Idle ○, active ●, blocked X,
513
+ * approval ◐ — states are painted by the CALLER from real events;
514
+ * this only draws the frame so nobody can "animate" fake activity.
515
+ */
516
+ export function topology({
517
+ write = (s) => process.stdout.write(s),
518
+ states = {},
519
+ compact = columns() < 76,
520
+ } = {}) {
521
+ const mark = (key, fallback = "○") => {
522
+ const st = states[key];
523
+ if (st === "active") return green("●");
524
+ if (st === "blocked") return red("X");
525
+ if (st === "approval") return amber("◐");
526
+ return dim(fallback);
527
+ };
528
+ if (compact) {
529
+ write(` ${dim("CIRVIX")} ${mark("cirvix")}── ${mark("a")}agents ${mark("b")}tools ${mark("c")}policies\n`);
530
+ return;
531
+ }
532
+ write(` ${dim("┌───────────────┐")}\n`);
533
+ write(` ${dim("│")} ${bold("CIRVIX")} ${dim("│")} ${mark("cirvix")}\n`);
534
+ write(` ${dim("│ POLICY ENGINE │")}\n`);
535
+ write(` ${dim("└───────┬───────┘")}\n`);
536
+ write(` ${dim(" │")}\n`);
537
+ write(
538
+ ` ${dim(" ┌───────┼───────┐")}\n` +
539
+ ` ${dim(" ▼ ▼ ▼")}\n` +
540
+ ` RESEARCH CODING SUPPORT ${mark("a")}${mark("b")}${mark("c")}\n` +
541
+ ` ${dim(" │ │ │")}\n` +
542
+ ` ${dim(" ▼ ▼ ▼")}\n` +
543
+ ` BROWSER FILESYS CRM\n`,
544
+ );
545
+ }
@@ -39,7 +39,7 @@
39
39
  */
40
40
 
41
41
  /** Ordered least → most capable. Used for `atLeast` comparisons. */
42
- export const TIER_ORDER = ["free", "starter", "pro", "team", "enterprise"];
42
+ export const TIER_ORDER = ["free", "lite", "starter", "pro", "team", "enterprise"];
43
43
 
44
44
  /**
45
45
  * `decisionsPerDay` is per SEAT for tiers where `perSeat` is true, and
@@ -97,6 +97,29 @@ export const TIERS = {
97
97
  policyPacks: 2,
98
98
  sharedPolicy: false,
99
99
  },
100
+ lite: {
101
+ id: "lite",
102
+ name: "Lite",
103
+ // The $29 entry tier: five times Free's daily volume with a longer local
104
+ // window, but no paid capabilities. Every figure sits between Free and
105
+ // Starter on purpose — a cheaper tier that outranks a dearer one on any
106
+ // axis silently un-sells that tier, so Lite is volume + retention only:
107
+ // no persistent secrets, no replay, no approvals, no attestation, no
108
+ // shared policy. The upgrade story stays one sentence: Starter adds
109
+ // persistent secrets, replay and 3x the daily volume.
110
+ decisionsPerDay: 500,
111
+ perSeat: false,
112
+ agents: 2,
113
+ seatsIncluded: 1,
114
+ auditRetentionHours: 24 * 3,
115
+ persistentSecrets: false,
116
+ secretTtlHours: 2,
117
+ approvals: false,
118
+ attestation: false,
119
+ shareableReplay: false,
120
+ policyPacks: 2,
121
+ sharedPolicy: false,
122
+ },
100
123
  starter: {
101
124
  id: "starter",
102
125
  name: "Starter",
@@ -26,6 +26,8 @@ export const red = wrap(31, 39);
26
26
  export const green = wrap(32, 39);
27
27
  export const amber = wrap(33, 39);
28
28
  export const blue = wrap(34, 39);
29
+ /** Motion/informational accent only — verdicts stay green/red/amber. */
30
+ export const cyan = wrap(36, 39);
29
31
 
30
32
  /** "1 server" / "3 servers" — avoids the "1 servers" that reads as a bug. */
31
33
  export function plural(n, noun, pluralForm) {