@cirvix_ai/agent-control 0.1.3 → 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 (71) hide show
  1. package/README.md +76 -17
  2. package/bin/cirvix.mjs +487 -85
  3. package/bin/escape-benchmark.mjs +67 -0
  4. package/package.json +36 -16
  5. package/src/adapters/base.mjs +150 -0
  6. package/src/adapters/claude-code.mjs +161 -0
  7. package/src/adapters/cline.mjs +107 -0
  8. package/src/adapters/codex.mjs +104 -0
  9. package/src/adapters/cursor.mjs +104 -0
  10. package/src/adapters/frameworks.mjs +110 -0
  11. package/src/adapters/gemini-cli.mjs +104 -0
  12. package/src/adapters/generic-mcp.mjs +101 -0
  13. package/src/adapters/index.mjs +209 -0
  14. package/src/adapters/roo-code.mjs +106 -0
  15. package/src/adapters/vscode.mjs +104 -0
  16. package/src/adapters/windsurf.mjs +107 -0
  17. package/src/commands/demo.mjs +55 -124
  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 +11 -9
  29. package/src/commands/shadow.mjs +62 -0
  30. package/src/commands/simulate.mjs +96 -0
  31. package/src/commands/status.mjs +122 -41
  32. package/src/commands/upgrade.mjs +11 -11
  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 +7 -24
  40. package/src/core/escape-benchmark.mjs +597 -0
  41. package/src/core/evidence.mjs +212 -0
  42. package/src/core/format.mjs +26 -1
  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
  71. package/src/core/cinematic.mjs +0 -545
package/bin/cirvix.mjs CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { access, mkdir, readFile } from "node:fs/promises";
12
12
  import { readFileSync } from "node:fs";
13
- import { join } from "node:path";
13
+ import { join, resolve } from "node:path";
14
14
 
15
15
  import { evaluate, parseRules, STARTER_RULES } from "../src/core/policy.mjs";
16
16
  import { AuditChain } from "../src/core/audit.mjs";
@@ -18,7 +18,11 @@ import { Daemon } from "../src/core/daemon.mjs";
18
18
  import { Gateway } from "../src/core/gateway.mjs";
19
19
  import { MessageFramer, serialize } from "../src/core/jsonrpc.mjs";
20
20
  import { scan } from "../src/commands/scan.mjs";
21
- import { bold, dim, green, red, amber, blue, plural } from "../src/core/format.mjs";
21
+ import { bold, dim, green, red, amber, blue, cyan, gray, plural } from "../src/core/format.mjs";
22
+ import { SHARE_URL } from "../src/core/prompts.mjs";
23
+ import { shouldAnimate } from "../src/core/ui/controller.mjs";
24
+ import { brandHeader, panel } from "../src/core/ui/primitives.mjs";
25
+ import { LiveStream } from "../src/core/ui/live.mjs";
22
26
 
23
27
  import { MODE, DECISION } from "../src/core/decisions.mjs";
24
28
  import { Pipeline } from "../src/core/pipeline.mjs";
@@ -27,13 +31,18 @@ import { ApprovalStore } from "../src/core/approvals.mjs";
27
31
  import { UdsServer, defaultEndpoint, writeToken } from "../src/core/uds.mjs";
28
32
  import * as journal from "../src/core/journal.mjs";
29
33
  import * as policyCmd from "../src/commands/policy.mjs";
34
+ import * as protectCmd from "../src/commands/protect.mjs";
35
+ import * as proveCmd from "../src/commands/prove.mjs";
36
+ import * as passportCmd from "../src/commands/passport.mjs";
30
37
  import { init as initCmd } from "../src/commands/init.mjs";
31
38
  import { status as statusCmd } from "../src/commands/status.mjs";
32
39
  import { upgrade as upgradeCmd } from "../src/commands/upgrade.mjs";
33
40
  import { AgentRegistry, Meter, readLicence } from "../src/core/meter.mjs";
34
41
  import { commercialNotices } from "../src/core/notices.mjs";
35
42
  import { demo as demoCmd } from "../src/commands/demo.mjs";
36
- import { animatedStartup, bannerAllowed, cinematicEnabled, requestTravel, runPhase, startupBanner, ttyProgress } from "../src/core/cinematic.mjs";
43
+ import { welcome } from "../src/commands/welcome.mjs";
44
+ import { doctor } from "../src/commands/doctor.mjs";
45
+ import { login, logout } from "../src/commands/login.mjs";
37
46
 
38
47
  /**
39
48
  * Read from the manifest, never written down twice.
@@ -58,9 +67,18 @@ const HELP = `
58
67
 
59
68
  ${bold("GETTING STARTED")}
60
69
  init Detect agents and MCP servers, write a policy, start protecting
70
+ init --apply Safely wire detected agents with pre-integration backup
71
+ init --dry-run Preview agent configuration changes without modifying files
72
+ init --rollback [id] Revert agent configurations to pre-integration state
61
73
  status Runtime, policy, servers, blocked, approvals, P99 overhead
74
+ doctor diagnose this installation: policy, state, daemon, control plane
75
+ login / logout link this machine to your CIRVIX control plane (browser or --key)
62
76
  upgrade Today's usage against your plan, and what lifts the limit
63
77
  demo Watch an injected exfiltration attempt get stopped, live
78
+ protect [path] Discover, analyse, apply policy and prove it decides
79
+ passport [agent] What an agent is, by what it has actually done
80
+ prove <decision-id> Sign a decision into a portable proof artifact
81
+ verify <proof> Check a proof offline: signature, chain, integrity
64
82
  scan Inventory what is ungoverned on this machine
65
83
 
66
84
  ${bold("ENFORCEMENT")}
@@ -285,31 +303,106 @@ async function main() {
285
303
  return 0;
286
304
  }
287
305
 
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);
306
+ // BARE `cirvix`:
307
+ // first run in a workspace -> onboarding (what CIRVIX is, three commands)
308
+ // returning, interactive -> the full live terminal
309
+ // returning, piped/CI -> the measured digest + next steps (no animation)
310
+ if (positional.length === 0 && !flags.json && !flags.help) {
311
+ let firstRun = false;
312
+ try { await access(join(cwd, ".cirvix")); } catch { firstRun = true; }
313
+ if (!firstRun) {
314
+ const { canLaunchInteractive } = await import("../src/commands/interactive.mjs");
315
+ if (canLaunchInteractive(flags, positional)) {
316
+ const rules = await loadRules(flags.policy, cwd);
317
+ const { interactive } = await import("../src/commands/interactive.mjs");
318
+ await interactive({ cwd, flags, rules });
319
+ return 0;
320
+ }
321
+ }
322
+ await welcome({ cwd });
323
+ return 0;
303
324
  }
304
325
 
305
326
  switch (command) {
327
+ case "protect": {
328
+ /* Shares policy resolution with `runtime` and `gateway`. A protect that
329
+ read policy differently from the runtime would be proving a decision
330
+ the runtime will not make. */
331
+ const target = sub && !sub.startsWith("-") ? resolve(cwd, sub) : cwd;
332
+ const rules = await loadRules(flags.policy, target);
333
+ const { result, output } = await protectCmd.protect({
334
+ cwd: target,
335
+ rules,
336
+ agent: String(flags.agent ?? "local"),
337
+ environment: String(flags.env ?? "local"),
338
+ json: Boolean(flags.json),
339
+ pace: flags.fast ? 0 : Number(flags.pace ?? 90),
340
+ animate: flags["no-animation"] ? false : undefined,
341
+ stateDir: stateDirFor(flags, target),
342
+ });
343
+ if (output) process.stdout.write(output + "\n");
344
+ // Exit 1 on HIGH or CRITICAL so CI can gate on it, the same convention
345
+ // `scan --fail-on` already uses.
346
+ if (flags["fail-on-risk"] && ["high", "critical"].includes(result.risk)) process.exitCode = 1;
347
+ return;
348
+ }
349
+
350
+ case "passport": {
351
+ const policyFile = await loadPolicy(flags.policy, cwd);
352
+ const { output, exitCode } = await passportCmd.passport({
353
+ agentId: sub && !sub.startsWith("-") && sub !== "badge" ? sub : null,
354
+ cwd,
355
+ stateDir: stateDirFor(flags, cwd),
356
+ policy: { rules: policyFile.rules, version: policyFile.version ?? null },
357
+ json: Boolean(flags.json),
358
+ sign: Boolean(flags.sign),
359
+ out: flags.out ? String(flags.out) : null,
360
+ badge: Boolean(flags.badge) || sub === "badge",
361
+ badgeOut: flags["badge-out"] ? String(flags["badge-out"]) : null,
362
+ });
363
+ if (output) process.stdout.write(output + "\n");
364
+ if (exitCode) process.exitCode = exitCode;
365
+ return;
366
+ }
367
+
368
+ case "prove": {
369
+ const target = flags.cwd ? cwd : cwd;
370
+ const policyFile = await loadPolicy(flags.policy, target);
371
+ const { output, exitCode } = await proveCmd.prove({
372
+ decisionId: sub,
373
+ cwd: target,
374
+ stateDir: stateDirFor(flags, target),
375
+ // The policy is part of what a proof attests: a decision only means
376
+ // something against the rules that produced it.
377
+ policy: { rules: policyFile.rules, version: policyFile.version ?? null },
378
+ json: Boolean(flags.json),
379
+ out: flags.out ? String(flags.out) : null,
380
+ });
381
+ if (output) process.stdout.write(output + "\n");
382
+ if (exitCode) process.exitCode = exitCode;
383
+ return;
384
+ }
385
+
386
+ case "verify": {
387
+ const { output, exitCode } = await proveCmd.verify({
388
+ proof: sub,
389
+ publicKey: flags.key ? String(flags.key) : null,
390
+ cwd,
391
+ stateDir: stateDirFor(flags, cwd),
392
+ json: Boolean(flags.json),
393
+ });
394
+ if (output) process.stdout.write(output + "\n");
395
+ // Exit 1 on a failed verification so CI can gate on it. A verifier that
396
+ // always exits 0 is a verifier nobody can automate.
397
+ if (exitCode) process.exitCode = exitCode;
398
+ return;
399
+ }
400
+
306
401
  case "scan": {
307
- const animateScan = cinematicEnabled({ json: Boolean(flags.json), pace: flags.fast ? 0 : 450 });
308
402
  const { result, output } = await scan({
309
403
  cwd,
310
404
  json: Boolean(flags.json),
311
405
  deep: Boolean(flags.deep),
312
- phase: (label, fn, detail) => runPhase(label, fn, { enabled: animateScan, detail }),
313
406
  });
314
407
 
315
408
  // Written before the exit-code gate below, so a failing scan still
@@ -434,11 +527,32 @@ async function main() {
434
527
  });
435
528
  process.stdin.on("data", (c) => framer.push(c));
436
529
 
437
- log(
438
- `gateway up · ${Object.keys(servers).length} upstream · ` +
439
- `${(daemon?.currentRules().length || rules.length)} rules` +
440
- (daemon ? ` · synced with ${apiUrl}` : " · local policy"),
441
- );
530
+ // Premium gateway startup — to stderr so stdout stays JSON-RPC clean.
531
+ {
532
+ const animated = shouldAnimate({ pace: flags.pace ? Number(flags.pace) : 700, json: false });
533
+ const gwRules = daemon?.currentRules().length || rules.length;
534
+ if (animated) {
535
+ try {
536
+ process.stderr.write("\n" + brandHeader({ width: 62 }) + "\n\n");
537
+ } catch {}
538
+ }
539
+ log(`gateway up · ${Object.keys(servers).length} upstream · ${gwRules} rules` + (daemon ? ` · synced with ${apiUrl}` : " · local policy"));
540
+ // Also emit a small protected panel for human visibility (stderr).
541
+ try {
542
+ const gwPanel = panel({
543
+ lines: [
544
+ `${bold("CIRVIX GATEWAY")}`,
545
+ ``,
546
+ `${"Upstreams".padEnd(12)} ${Object.keys(servers).length}`,
547
+ `${"Policy".padEnd(12)} ${green(bold("● ENFORCING"))} ${dim(plural(gwRules, "rule"))}`,
548
+ `${"Audit".padEnd(12)} ${green(bold("● RECORDING"))}`,
549
+ `${"Mode".padEnd(12)} ${dim(daemon ? "synced" : "local")}`,
550
+ ],
551
+ width: 48,
552
+ });
553
+ process.stderr.write(gwPanel + "\n");
554
+ } catch {}
555
+ }
442
556
 
443
557
  await new Promise((resolve) => {
444
558
  let closing = false;
@@ -538,17 +652,6 @@ async function main() {
538
652
  if (flags.json) {
539
653
  process.stdout.write(JSON.stringify(decision, null, 2) + "\n");
540
654
  } 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
- }
552
655
  const tone =
553
656
  decision.verdict === "permit" ? green : decision.verdict === "hold" ? amber : red;
554
657
  process.stdout.write(
@@ -558,6 +661,9 @@ async function main() {
558
661
  ` ${dim("rule")} ${decision.rule ?? dim("— no rule matched (default deny)")}`,
559
662
  ` ${dim("reason")} ${decision.reason}`,
560
663
  decision.remediation ? ` ${dim("fix")} ${blue(decision.remediation)}` : "",
664
+ decision.verdict === "deny" && !flags.json
665
+ ? ` ${dim("share")} ${dim(`was this a good catch? ${SHARE_URL} — redact first, your call`)}`
666
+ : "",
561
667
  decision.approvers?.length
562
668
  ? ` ${dim("waits")} ${decision.approvers.join(", ")}`
563
669
  : "",
@@ -590,25 +696,75 @@ async function main() {
590
696
  return d.verdict === "deny" ? 1 : 0;
591
697
  }
592
698
 
593
- const tone = d.verdict === "permit" ? green : d.verdict === "hold" ? amber : red;
699
+ const isWhyDeny = d.verdict === "deny";
700
+ const isWhyHold = d.verdict === "hold";
701
+ const whyTone = isWhyDeny ? red : isWhyHold ? amber : green;
702
+ const whyDecision = isWhyDeny ? "✕ BLOCKED" : isWhyHold ? "● AWAITING APPROVAL" : "✓ " + String(d.verdict).toUpperCase();
703
+ const whyRisk = String(d.risk ?? "unknown").toUpperCase();
704
+ const whyRiskTone = whyRisk === "CRITICAL" ? red : whyRisk === "HIGH" ? amber : whyRisk === "MEDIUM" ? blue : dim;
594
705
  process.stdout.write(
595
706
  [
596
707
  "",
597
- ` ${tone(bold(String(d.verdict).toUpperCase()))} ${dim(d.action ?? d.tool ?? "")} ${d.resource ?? ""}`,
598
- ` ${dim("rule")} ${d.rule ?? dim("— no rule matched (default deny)")}`,
599
- ` ${dim("reason")} ${d.reason ?? dim("—")}`,
600
- ` ${dim("agent")} ${d.agent ?? dim("—")}`,
601
- ` ${dim("when")} ${d.ts}`,
602
- // The whole point of this command in an incident: it hands you the
603
- // thread to pull, not just the one bead you arrived holding.
604
- ` ${dim("run")} ${d.runId ? blue(d.runId) : dim("— recorded outside a run")}`,
708
+ ` ${bold("CIRVIX DECISION ANALYSIS")}`,
709
+ "",
710
+ ` ${dim("Decision".padEnd(12))} ${whyTone(bold(whyDecision))}`,
711
+ ` ${dim("Risk".padEnd(12))} ${whyRiskTone(bold(whyRisk))}`,
605
712
  "",
713
+ ` ${dim("Tool".padEnd(12))} ${bold(String(d.tool ?? d.action ?? "—"))}`,
714
+ d.resource ? ` ${dim("Target".padEnd(12))} ${d.resource}` : "",
715
+ d.destination ? ` ${dim("Destination".padEnd(12))} ${d.destination}` : "",
716
+ ` ${dim("Matched policy".padEnd(12))} ${d.rule ?? dim("— no rule matched (default deny)")}`,
717
+ d.reason ? ` ${dim("Reason".padEnd(12))} ${d.reason}` : "",
718
+ ` ${dim("Agent".padEnd(12))} ${d.agent ?? dim("—")}`,
719
+ ` ${dim("When".padEnd(12))} ${d.ts}`,
720
+ ` ${dim("Run".padEnd(12))} ${d.runId ? blue(d.runId) : dim("— recorded outside a run")}`,
721
+ "",
722
+ ` ${dim("Decision path")}`,
723
+ ` ${dim("secret detection")}`,
724
+ ` ${dim("↓")}`,
725
+ ` ${dim("risk classification")}`,
726
+ ` ${dim("↓")}`,
727
+ ` ${dim("policy evaluation")}`,
728
+ ` ${dim("↓")}`,
729
+ ` ${whyTone(isWhyDeny ? "BLOCK" : isWhyHold ? "HOLD" : "ALLOW")}`,
730
+ "",
731
+ /* The trifecta is the one refusal whose reason lives outside this
732
+ call, so it gets the sequence rendered rather than a rule name.
733
+ "Blocked: trifecta" is indistinguishable from a bug; three
734
+ timestamped steps are something the reader can act on. */
735
+ ...(d.trifecta?.complete
736
+ ? [
737
+ ` ${whyTone(bold("LETHAL TRIFECTA"))} ${dim("all three conditions met in this session")}`,
738
+ "",
739
+ ...["sensitive_data", "untrusted_content", "outbound_action"]
740
+ .map((k) => [k, d.trifecta.legs?.[k]])
741
+ .filter(([, v]) => v)
742
+ .sort((a, b) => String(a[1].at ?? "").localeCompare(String(b[1].at ?? "")))
743
+ .map(
744
+ ([k, v], i) =>
745
+ ` ${bold(String(i + 1) + ".")} ${dim(k.replace(/_/g, " ").padEnd(18))} ${v.why}` +
746
+ (v.at ? `
747
+ ${dim(v.at)}` : ""),
748
+ ),
749
+ "",
750
+ ` ${dim("Cirvix refuses on capability and opportunity. It does not claim the")}`,
751
+ ` ${dim("sensitive bytes are in this request — that needs data-flow analysis")}`,
752
+ ` ${dim("it deliberately does not do.")}`,
753
+ "",
754
+ ]
755
+ : d.trifecta?.satisfied?.length
756
+ ? [
757
+ ` ${dim("trifecta")} ${d.trifecta.satisfied.length} of 3 conditions met` +
758
+ (d.trifecta.imminent ? ` ${whyRiskTone("one step from complete")}` : ""),
759
+ "",
760
+ ]
761
+ : []),
606
762
  ...(d.considered?.length
607
763
  ? [
608
- ` ${dim("considered")}`,
764
+ ` ${dim("considered")} ${dim(`${d.considered.filter((c) => c.matched).length} of ${d.considered.length} matched`)}`,
609
765
  ...d.considered.map(
610
766
  (c) =>
611
- ` ${c.matched ? bold("→") : dim(" ")} ${dim(String(c.effect).padEnd(7))} ${c.matched ? c.rule : dim(c.rule)}`,
767
+ ` ${c.matched ? bold("→") : dim(" ")} ${dim(String(c.effect).padEnd(11))} ${c.matched ? c.rule : dim(c.rule)}`,
612
768
  ),
613
769
  "",
614
770
  ]
@@ -690,11 +846,43 @@ async function main() {
690
846
  process.stdout.write(JSON.stringify(res, null, 2) + "\n");
691
847
  return res.ok ? 0 : 1;
692
848
  }
693
- process.stdout.write(
694
- res.ok
695
- ? `\n ${green(bold("chain intact"))} ${dim(`${res.records} records verified`)}\n\n ${dim("Verification proves records were not altered after they were written.\n It does not attest to their content.")}\n\n`
696
- : `\n ${red(bold("chain broken"))} ${dim(`at record ${res.brokenAt} of ${res.records}`)}\n ${res.reason}\n\n`,
697
- );
849
+ if (res.ok) {
850
+ const W = 62;
851
+ const top = ` ${dim(`╭─ CIRVIX AUDIT VERIFICATION ${"─".repeat(Math.max(0, W - 26))}╮`)}`;
852
+ const bottom = ` ${dim(`╰${"─".repeat(W)}╯`)}`;
853
+ const chainLines = [
854
+ ``,
855
+ ` ${green("✓")} ${dim("Hash chain intact")}`,
856
+ ` ${green("✓")} ${dim(`${res.records} records verified`)}`,
857
+ ` ${green("✓")} ${dim("No records altered")}`,
858
+ ``,
859
+ ` ${bold("CHAIN")}`,
860
+ ``,
861
+ ` ${dim("current")}`,
862
+ ` ${dim("↓")}`,
863
+ ` ${dim("previous")}`,
864
+ ` ${dim("↓")}`,
865
+ ` ${dim("previous")}`,
866
+ ` ${dim("↓")}`,
867
+ ` ${dim("genesis")}`,
868
+ ``,
869
+ ` ${bold("STATUS")} ${green(bold("● INTEGRITY OK"))}`,
870
+ ``,
871
+ ` ${dim(`head ${String(res.head ?? "").slice(0, 16)}…`)}`,
872
+ ``,
873
+ ` ${dim("Verification proves records were not altered after they were written.")}`,
874
+ ` ${dim("It does not attest to their content.")}`,
875
+ ``,
876
+ ];
877
+ process.stdout.write(`\n${top}\n`);
878
+ process.stdout.write(`\n ${bold("CIRVIX AUDIT VERIFICATION")}\n`);
879
+ for (const l of chainLines) process.stdout.write(l + "\n");
880
+ process.stdout.write(`${bottom}\n\n`);
881
+ } else {
882
+ process.stdout.write(
883
+ `\n ${red(bold("chain broken"))} ${dim(`at record ${res.brokenAt} of ${res.records}`)}\n ${res.reason}\n\n`,
884
+ );
885
+ }
698
886
  return res.ok ? 0 : 1;
699
887
  }
700
888
 
@@ -742,19 +930,6 @@ async function main() {
742
930
  process.stderr.write(red(" explain needs --tool <name>.\n"));
743
931
  return 2;
744
932
  }
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
- }
758
933
  const { output, code } = await policyCmd.explain({
759
934
  path: loaded.path,
760
935
  // The starter set has no file, so explain against the rules directly.
@@ -762,7 +937,7 @@ async function main() {
762
937
  cwd,
763
938
  json: Boolean(flags.json),
764
939
  tool,
765
- args: callArgs,
940
+ args: callArgsFrom(flags),
766
941
  agent: String(flags.agent ?? "local"),
767
942
  environment: String(flags.env ?? "local"),
768
943
  });
@@ -770,6 +945,22 @@ async function main() {
770
945
  return code;
771
946
  }
772
947
 
948
+ case "simulate": {
949
+ const { simulatePolicy } = await import("../src/commands/simulate.mjs");
950
+ const { output, code } = await simulatePolicy({
951
+ rules: loaded.rules,
952
+ action: flags.action ?? flags.tool ?? "fs:read",
953
+ resource: flags.resource ?? flags.path ?? flags.command ?? "",
954
+ tool: flags.tool ?? "file_reader",
955
+ intent: flags.intent ?? null,
956
+ agent: String(flags.agent ?? "local"),
957
+ json: Boolean(flags.json),
958
+ cwd,
959
+ });
960
+ process.stdout.write(output + "\n");
961
+ return code;
962
+ }
963
+
773
964
  case "list":
774
965
  default: {
775
966
  const { output, code } = policyCmd.list(loaded.rules, {
@@ -789,11 +980,76 @@ async function main() {
789
980
  cwd,
790
981
  json: Boolean(flags.json),
791
982
  force: Boolean(flags.force),
983
+ apply: Boolean(flags.apply),
984
+ dryRun: Boolean(flags["dry-run"]),
985
+ rollback: flags.rollback ? (typeof flags.rollback === "string" ? flags.rollback : true) : false,
792
986
  });
793
987
  process.stdout.write(output + "\n");
794
988
  return result.ok ? 0 : 1;
795
989
  }
796
990
 
991
+ /* ------------------------------------------------------------ simulate */
992
+ case "simulate": {
993
+ const rules = await loadRules(flags.policy, cwd);
994
+ const { simulatePolicy } = await import("../src/commands/simulate.mjs");
995
+ const { output, code } = await simulatePolicy({
996
+ rules,
997
+ action: flags.action ?? flags.tool ?? positional[1] ?? "fs:read",
998
+ resource: flags.resource ?? flags.path ?? flags.command ?? positional[2] ?? "",
999
+ tool: flags.tool ?? "file_reader",
1000
+ intent: flags.intent ?? null,
1001
+ agent: String(flags.agent ?? "local"),
1002
+ json: Boolean(flags.json),
1003
+ cwd,
1004
+ });
1005
+ process.stdout.write(output + "\n");
1006
+ return code;
1007
+ }
1008
+
1009
+ /* ----------------------------------------------------------------- kill */
1010
+ case "kill": {
1011
+ const { executeKillCommand } = await import("../src/commands/kill.mjs");
1012
+ const { output, code } = await executeKillCommand({
1013
+ scope: flags.scope ?? "agent",
1014
+ target: positional[1] ?? flags.target ?? null,
1015
+ reason: flags.reason ?? "Emergency freeze triggered via CLI",
1016
+ release: flags.release ?? null,
1017
+ list: Boolean(flags.list),
1018
+ json: Boolean(flags.json),
1019
+ });
1020
+ process.stdout.write(output + "\n");
1021
+ return code;
1022
+ }
1023
+
1024
+ /* --------------------------------------------------------------- shadow */
1025
+ case "shadow": {
1026
+ const rules = await loadRules(flags.policy, cwd);
1027
+ const { executeShadowCommand } = await import("../src/commands/shadow.mjs");
1028
+ const { output, code } = await executeShadowCommand({
1029
+ rules,
1030
+ action: flags.action ?? positional[1] ?? null,
1031
+ resource: flags.resource ?? positional[2] ?? null,
1032
+ json: Boolean(flags.json),
1033
+ cwd,
1034
+ });
1035
+ process.stdout.write(output + "\n");
1036
+ return code;
1037
+ }
1038
+
1039
+ /* -------------------------------------------------------------- redteam */
1040
+ case "redteam": {
1041
+ const rules = await loadRules(flags.policy, cwd);
1042
+ const { executeRedTeamCommand } = await import("../src/commands/redteam.mjs");
1043
+ const { output, code } = await executeRedTeamCommand({
1044
+ rules,
1045
+ plugins: flags.plugins ? String(flags.plugins).split(",") : null,
1046
+ json: Boolean(flags.json),
1047
+ cwd,
1048
+ });
1049
+ process.stdout.write(output + "\n");
1050
+ return code;
1051
+ }
1052
+
797
1053
  /* -------------------------------------------------------------- status */
798
1054
  case "upgrade": {
799
1055
  // `positional` already has the command at [0]; the rest are the
@@ -806,13 +1062,11 @@ async function main() {
806
1062
 
807
1063
  case "status": {
808
1064
  const rules = await loadRules(flags.policy, cwd);
809
- const animateStatus = cinematicEnabled({ json: Boolean(flags.json), pace: flags.fast ? 0 : 450 });
810
1065
  const { output } = await statusCmd({
811
1066
  cwd,
812
1067
  rules,
813
1068
  json: Boolean(flags.json),
814
1069
  stateDir: stateDirFor(flags, cwd),
815
- progress: ttyProgress({ enabled: animateStatus }),
816
1070
  });
817
1071
  process.stdout.write(output + "\n");
818
1072
  return 0;
@@ -837,6 +1091,66 @@ async function main() {
837
1091
  case "logs": {
838
1092
  const stateDir = stateDirFor(flags, cwd);
839
1093
  const file = String(flags.file ?? join(stateDir, "audit.jsonl"));
1094
+
1095
+ // Live mode: cirvix logs --watch
1096
+ if (flags.watch || flags.follow || flags.w) {
1097
+ if (flags.json) {
1098
+ process.stderr.write(red(" --watch is not compatible with --json.\n"));
1099
+ return 2;
1100
+ }
1101
+ const { watch } = await import("node:fs");
1102
+ const live = new LiveStream({ stream: process.stdout, title: "CIRVIX LIVE · protection active" });
1103
+ // Print existing tail first
1104
+ const existing = await journal.read(file);
1105
+ const tail = journal.query(existing, {
1106
+ last: flags.last ? Number(flags.last) : 10,
1107
+ risk: typeof flags.risk === "string" ? flags.risk : undefined,
1108
+ decision: typeof flags.decision === "string" ? flags.decision : undefined,
1109
+ });
1110
+ live.header();
1111
+ for (const r of tail) live.push(r);
1112
+ if (tail.length === 0) {
1113
+ process.stdout.write(` ${dim("waiting for decisions…")} ${dim(`tailing ${file}`)}\n`);
1114
+ }
1115
+ // Watch for new records — polling via fs.watch where available, fallback to interval.
1116
+ let known = existing.length;
1117
+ let watcher = null;
1118
+ let polling = null;
1119
+ const emitNew = async () => {
1120
+ const all = await journal.read(file);
1121
+ if (all.length > known) {
1122
+ const fresh = all.slice(known);
1123
+ const filtered = journal.query(fresh, {
1124
+ risk: typeof flags.risk === "string" ? flags.risk : undefined,
1125
+ decision: typeof flags.decision === "string" ? flags.decision : undefined,
1126
+ agent: typeof flags.agent === "string" ? flags.agent : undefined,
1127
+ tool: typeof flags.tool === "string" ? flags.tool : undefined,
1128
+ deniedOnly: Boolean(flags.denied),
1129
+ });
1130
+ for (const r of filtered) live.push(r);
1131
+ known = all.length;
1132
+ } else if (all.length < known) {
1133
+ known = all.length;
1134
+ }
1135
+ };
1136
+ try {
1137
+ watcher = watch(file, async () => { await emitNew().catch(() => {}); });
1138
+ } catch {
1139
+ polling = setInterval(() => void emitNew(), 700);
1140
+ }
1141
+ if (!watcher) polling = setInterval(() => void emitNew(), 700);
1142
+ await new Promise((resolve) => {
1143
+ const done = () => {
1144
+ try { watcher?.close(); } catch {}
1145
+ if (polling) clearInterval(polling);
1146
+ resolve();
1147
+ };
1148
+ process.on("SIGINT", done);
1149
+ process.on("SIGTERM", done);
1150
+ });
1151
+ return 0;
1152
+ }
1153
+
840
1154
  const records = await journal.read(file);
841
1155
 
842
1156
  // `--tree <id>` prints one decision in full rather than the list.
@@ -916,20 +1230,29 @@ async function main() {
916
1230
  process.stdout.write("\n " + bold(plural(pending.length, "call")) + dim(" waiting\n\n"));
917
1231
  for (const a of pending) {
918
1232
  const riskTone = { low: dim, medium: blue, high: amber, critical: red }[a.risk] ?? dim;
919
- process.stdout.write(
920
- ` ${bold(a.id)} ${riskTone(String(a.risk ?? "").toUpperCase().padEnd(9))}${a.tool ?? "—"} ${dim(a.resource ?? "")}\n`,
921
- );
922
- process.stdout.write(` ${dim(a.reason ?? "")}\n`);
923
- process.stdout.write(
924
- ` ${dim("agent")} ${a.agent ?? "—"} ${dim("rule")} ${a.rule ?? "—"} ${dim("waits on")} ${(a.approvers ?? []).join(", ") || dim("nobody in particular")}\n`,
925
- );
1233
+ const W = 62;
1234
+ const top = ` ${dim(`╭─ HUMAN APPROVAL REQUIRED ${"".repeat(Math.max(0, W - 28))}╮`)}`;
1235
+ const bottom = ` ${dim(`╰${"─".repeat(W)}╯`)}`;
1236
+ process.stdout.write(top + "\n");
1237
+ process.stdout.write(` ${dim("│")} ${dim("Agent".padEnd(10))} ${a.agent ?? "—"} ${dim("│")}\n`);
1238
+ process.stdout.write(` ${dim("")} ${dim("Action".padEnd(10))} ${a.tool ?? "—"} ${dim("")}\n`);
1239
+ process.stdout.write(` ${dim("│")} ${dim("Target".padEnd(10))} ${String(a.resource ?? "").slice(0, 32).padEnd(32)} ${dim("│")}\n`);
1240
+ process.stdout.write(` ${dim("│")} ${"".padEnd(46)} ${dim("│")}\n`);
1241
+ process.stdout.write(` ${dim("│")} ${dim("Risk".padEnd(10))} ${riskTone(String(a.risk ?? "").toUpperCase().padEnd(9))} ${dim("│")}\n`);
1242
+ process.stdout.write(` ${dim("│")} ${dim("Policy".padEnd(10))} ${String(a.rule ?? "—").slice(0, 32).padEnd(32)} ${dim("│")}\n`);
1243
+ process.stdout.write(` ${dim("│")} ${dim("Waits on".padEnd(10))} ${(a.approvers ?? []).join(", ") || "—"} ${dim("│")}\n`);
1244
+ process.stdout.write(` ${dim("│")} ${"".padEnd(46)} ${dim("│")}\n`);
1245
+ process.stdout.write(` ${dim("│")} ${dim("Reason")} ${dim("│")}\n`);
1246
+ process.stdout.write(` ${dim("│")} ${(a.reason ?? "Production database mutation requires human authorization.").slice(0, 44).padEnd(44)} ${dim("│")}\n`);
1247
+ process.stdout.write(` ${dim("│")} ${"".padEnd(46)} ${dim("│")}\n`);
1248
+ process.stdout.write(` ${dim("│")} ${green("[A] Approve")} ${dim(" ")} ${red("[R] Reject")} ${dim(` ${a.id}`)} ${dim("│")}\n`);
1249
+ process.stdout.write(bottom + "\n\n");
926
1250
  if (a.state !== "pending") {
927
- process.stdout.write(` ${dim("state")} ${a.state}${a.decidedBy ? dim(` by ${a.decidedBy}`) : ""}\n`);
1251
+ process.stdout.write(` ${dim("state")} ${a.state}${a.decidedBy ? dim(` by ${a.decidedBy}`) : ""}\n\n`);
928
1252
  }
929
- process.stdout.write("\n");
930
1253
  }
931
1254
  process.stdout.write(
932
- ` ${dim("Decide one:")} ${blue(`cirvix approve ${pending[0].id} --by you@example.com`)}\n\n`,
1255
+ ` ${dim("Decide one:")} ${blue(`cirvix approve ${pending[0].id} --by you@example.com`)} ${dim("or")} ${red(`cirvix deny ${pending[0].id} --by you@example.com`)}\n\n`,
933
1256
  );
934
1257
  return 0;
935
1258
  }
@@ -1020,6 +1343,10 @@ async function main() {
1020
1343
  meter: runtimeMeter,
1021
1344
  write: (s) => process.stderr.write(s),
1022
1345
  });
1346
+ /* Measured, not asserted. The panel below reports these, and the only
1347
+ honest source for them is the decision stream itself. */
1348
+ const counters = { blocked: 0, approvals: 0, violations: 0 };
1349
+ const seenAgents = new Set();
1023
1350
  const pipeline = new Pipeline({
1024
1351
  rules,
1025
1352
  cwd,
@@ -1033,7 +1360,14 @@ async function main() {
1033
1360
  meter: runtimeMeter,
1034
1361
  agents: new AgentRegistry(),
1035
1362
  onEvent: (e) => {
1036
- if (e.kind === "decision") notice(e);
1363
+ if (e.kind === "decision") {
1364
+ notice(e);
1365
+ const ev = e.event ?? e;
1366
+ if (ev.agent) seenAgents.add(ev.agent);
1367
+ if (ev.decision === "deny") counters.blocked += 1;
1368
+ else if (ev.decision === "require_approval") counters.approvals += 1;
1369
+ if (ev.risk === "critical") counters.violations += 1;
1370
+ }
1037
1371
  },
1038
1372
  log: (m) => process.stderr.write(`[cirvix] ${m}\n`),
1039
1373
  });
@@ -1059,14 +1393,70 @@ async function main() {
1059
1393
  });
1060
1394
  await server.start();
1061
1395
 
1062
- process.stdout.write(
1063
- `\n ${green(bold("runtime up"))} ${dim(`${plural(rules.length, "rule")} · ${mode} · ${endpoint}`)}\n` +
1064
- ` ${dim(`token in ${join(stateDir, "socket.token")}`)}\n\n`,
1065
- );
1066
- if (mode === MODE.AUDIT) {
1396
+ // Premium startup sequence — brand + real state, zero fake.
1397
+ const runtimeAnimated = shouldAnimate({ pace: flags.pace ? Number(flags.pace) : 700, json: Boolean(flags.json) });
1398
+ // Compute policy tests count for display (real).
1399
+ let rtTests = 0;
1400
+ try {
1401
+ const { loadPolicyFile } = await import("../src/commands/policy.mjs");
1402
+ let policyPath = null;
1403
+ for (const cand of ["cirvix.policy", "cirvix.policy.json", ".cirvix/policy.json"]) {
1404
+ const p = join(cwd, cand);
1405
+ try { await access(p); policyPath = p; break; } catch {}
1406
+ }
1407
+ if (policyPath) {
1408
+ const loaded = await loadPolicyFile(policyPath, { cwd });
1409
+ rtTests = loaded.tests?.length ?? 0;
1410
+ }
1411
+ } catch {}
1412
+ if (!flags.json) {
1413
+ // Brand header only when interactive; in CI/non-TTY just show compact.
1414
+ if (runtimeAnimated) {
1415
+ process.stdout.write("\n" + brandHeader({ width: 62 }) + "\n\n");
1416
+ } else {
1417
+ process.stdout.write(`\n ${bold("CIRVIX")} ${dim("· runtime governance")}\n\n`);
1418
+ }
1419
+ process.stdout.write(` ${dim("Initializing CIRVIX runtime...")}\n`);
1420
+ process.stdout.write(` ${green("✓")} ${dim("Control socket established")} ${dim(endpoint)}\n`);
1421
+ process.stdout.write(` ${green("✓")} ${dim("Policy engine loaded")}\n`);
1422
+ process.stdout.write(` ${green("✓")} ${dim("Secret protection enabled")}\n`);
1423
+ process.stdout.write(` ${green("✓")} ${dim("Audit chain initialized")}\n`);
1424
+ process.stdout.write(` ${green("✓")} ${dim(`${plural(rules.length, "rule")} loaded`)}${rtTests ? dim(` · ${rtTests} policy tests`) : ""}\n`);
1425
+ process.stdout.write("\n");
1426
+ // The agent this runtime was started for, plus any that have since
1427
+ // announced themselves. One at startup is a fact, not a placeholder —
1428
+ // but only because it is counted.
1429
+ const agentsSeen = Math.max(seenAgents.size, 1);
1430
+ const protectedLines = [
1431
+ `${bold("CIRVIX PROTECTED")}`,
1432
+ ``,
1433
+ `${"Runtime".padEnd(12)} ${green(bold("● ONLINE"))} ${dim(mode === MODE.AUDIT ? "AUDIT · recording only" : "ENFORCING")}`,
1434
+ `${"Policy".padEnd(12)} ${green(bold("● ENFORCING"))} ${dim(plural(rules.length, "rule"))}`,
1435
+ `${"Secrets".padEnd(12)} ${green(bold("● PROTECTED"))}`,
1436
+ `${"Audit".padEnd(12)} ${green(bold("● RECORDING"))}`,
1437
+ `${"Agents".padEnd(12)} ${dim(plural(agentsSeen, "detected", "detected"))}`,
1438
+ ``,
1439
+ /* These were string literals — `1 detected`, `0 blocked · 0
1440
+ approvals · 0 violations`. They happened to be true at startup,
1441
+ which is exactly what makes that kind of line dangerous: it reads
1442
+ as measurement, it survives review, and it is wrong the moment
1443
+ anything happens. Counted from the pipeline now. */
1444
+ `${dim(`${counters.blocked} blocked · ${counters.approvals} approvals · ${counters.violations} violations`)}`,
1445
+ ];
1446
+ process.stdout.write(panel({ lines: protectedLines, width: 62 }) + "\n\n");
1447
+ process.stdout.write(` ${dim("Ready. Your agent is under policy control.")}\n`);
1448
+ process.stdout.write(` ${dim(`token in ${join(stateDir, "socket.token")}`)}\n\n`);
1449
+ if (mode === MODE.AUDIT) {
1450
+ process.stdout.write(` ${amber(bold("AUDIT MODE"))} ${dim("— decisions are recorded and nothing is blocked.")}\n\n`);
1451
+ }
1452
+ } else {
1067
1453
  process.stdout.write(
1068
- ` ${amber(bold("AUDIT MODE"))} ${dim(" decisions are recorded and nothing is blocked.")}\n\n`,
1454
+ `\n ${green(bold("runtime up"))} ${dim(`${plural(rules.length, "rule")} · ${mode} · ${endpoint}`)}\n` +
1455
+ ` ${dim(`token in ${join(stateDir, "socket.token")}`)}\n\n`,
1069
1456
  );
1457
+ if (mode === MODE.AUDIT) {
1458
+ process.stdout.write(` ${amber(bold("AUDIT MODE"))} ${dim("— decisions are recorded and nothing is blocked.")}\n\n`);
1459
+ }
1070
1460
  }
1071
1461
 
1072
1462
  await new Promise((resolve) => {
@@ -1084,6 +1474,18 @@ async function main() {
1084
1474
  return 0;
1085
1475
  }
1086
1476
 
1477
+ case "doctor": {
1478
+ return doctor({ cwd, json: Boolean(flags.json) });
1479
+ }
1480
+
1481
+ case "login": {
1482
+ return login({ key: flags.key ? String(flags.key) : null, url: flags.url ? String(flags.url) : null, status: Boolean(flags.status), browser: flags.browser === undefined ? undefined : Boolean(flags.browser), json: Boolean(flags.json) });
1483
+ }
1484
+
1485
+ case "logout": {
1486
+ return logout({ json: Boolean(flags.json) });
1487
+ }
1488
+
1087
1489
  case "help":
1088
1490
  default:
1089
1491
  process.stdout.write(HELP + "\n");