@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,74 @@
1
+ /**
2
+ * Emergency Kill Switch CLI Command.
3
+ *
4
+ * Scopes: agent, family, org, environment, mcp, tool, credential, session, model
5
+ */
6
+
7
+ import { globalKillSwitch, KILL_SCOPES } from "../core/kill-switch.mjs";
8
+ import { bold, dim, green, red, amber, cyan } from "../core/format.mjs";
9
+
10
+ export async function executeKillCommand({
11
+ scope = KILL_SCOPES.AGENT,
12
+ target = null,
13
+ reason = "Emergency freeze invoked via CLI",
14
+ release = null,
15
+ list = false,
16
+ json = false,
17
+ } = {}) {
18
+ if (list) {
19
+ const rules = globalKillSwitch.list();
20
+ if (json) return { output: JSON.stringify(rules, null, 2), code: 0 };
21
+ if (rules.length === 0) {
22
+ return { output: `\n ${green("✓")} ${dim("No active emergency kill switches.")}\n`, code: 0 };
23
+ }
24
+ const lines = [
25
+ "",
26
+ ` ${bold("ACTIVE EMERGENCY KILL SWITCHES")}`,
27
+ "",
28
+ ...rules.map((r) => ` ${red("●")} [${r.scope.toUpperCase()}] ${bold(r.target)} — ${r.reason} ${dim(`(armed at ${r.armedAt})`)}`),
29
+ "",
30
+ ];
31
+ return { output: lines.join("\n"), code: 0 };
32
+ }
33
+
34
+ if (release) {
35
+ const ok = globalKillSwitch.disarm(release);
36
+ if (json) return { output: JSON.stringify({ released: release, success: ok }), code: ok ? 0 : 1 };
37
+ return {
38
+ output: ok ? `\n ${green("✓")} ${dim(`Kill switch ${release} disarmed.`)}\n` : `\n ${red("✗")} ${dim(`Kill switch ${release} not found.`)}\n`,
39
+ code: ok ? 0 : 1,
40
+ };
41
+ }
42
+
43
+ if (!target) {
44
+ return {
45
+ output: `\n ${red("Error:")} Specify target to kill, e.g. cirvix kill <agent-id> --scope agent --reason "Suspicious activity"\n`,
46
+ code: 2,
47
+ };
48
+ }
49
+
50
+ const rule = globalKillSwitch.arm({
51
+ scope,
52
+ target,
53
+ reason,
54
+ triggeredBy: process.env.USER || process.env.USERNAME || "cli",
55
+ });
56
+
57
+ if (json) return { output: JSON.stringify(rule, null, 2), code: 0 };
58
+
59
+ const lines = [
60
+ "",
61
+ ` ${red(bold("EMERGENCY KILL SWITCH ACTIVATED"))}`,
62
+ "",
63
+ ` ${dim("Scope:")} ${scope.toUpperCase()}`,
64
+ ` ${dim("Target:")} ${bold(target)}`,
65
+ ` ${dim("Reason:")} ${reason}`,
66
+ ` ${dim("Rule ID:")} ${rule.id}`,
67
+ ` ${dim("Status:")} ${red(bold("FROZEN"))}`,
68
+ "",
69
+ ` ${dim("All subsequent actions matching this target will be immediately quarantined or denied.")}`,
70
+ "",
71
+ ];
72
+
73
+ return { output: lines.join("\n"), code: 0 };
74
+ }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * `cirvix login` — link this machine to a CIRVIX control plane.
3
+ *
4
+ * The control plane authenticates hosts with API keys (the dashboard's
5
+ * API-keys section issues them). So login is: open the dashboard, create a
6
+ * key, paste it here. The CLI verifies the key against `/v1/me` BEFORE storing
7
+ * anything, so a typo never ends up on disk looking like a working login.
8
+ *
9
+ * Stored at ~/.cirvix/credentials.json — the user's home, never the workspace,
10
+ * because workspaces get committed and a key must not.
11
+ *
12
+ * Non-interactive: `cirvix login --key cak_… [--url https://api.cirvix.com]`.
13
+ * Piped stdin (`cat key | cirvix login`) works too. No animation anywhere in
14
+ * this command — it is a form, not a show.
15
+ */
16
+
17
+ import { mkdir, readFile, writeFile, chmod, rm } from "node:fs/promises";
18
+ import { homedir } from "node:os";
19
+ import { join } from "node:path";
20
+ import { createInterface } from "node:readline";
21
+
22
+ import { bold, dim, green, red, gray } from "../core/format.mjs";
23
+
24
+ /* Hard-exit helper for this command only. The verify call leaves undici's
25
+ keep-alive socket winding down, and on Windows Node 24 that races the
26
+ event-loop drain and prints a meaningless libuv assertion at exit. Waiting
27
+ one drain tick then exiting keeps the goodbye clean without changing exit
28
+ codes. */
29
+ const finish = (code) => {
30
+ setTimeout(() => process.exit(code), 50);
31
+ };
32
+
33
+ export const DEFAULT_CONTROL_PLANE = "https://api.cirvix.com";
34
+ export const DASHBOARD_URL = "https://www.cirvix.com/account.html";
35
+
36
+ function credPath() {
37
+ return join(homedir(), ".cirvix", "credentials.json");
38
+ }
39
+
40
+ export async function readCredentials() {
41
+ try {
42
+ return JSON.parse(await readFile(credPath(), "utf8"));
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ /** GET {url}/v1/me with the key. Resolves { ok, status, org } — never rejects.
49
+ *
50
+ * node:https rather than fetch deliberately: undici's keep-alive teardown
51
+ * trips a libuv assertion on Windows at process exit (Node 24.14), printing a
52
+ * scary, meaningless "Assertion failed" after a successful login. One
53
+ * one-shot https request has nothing to wind down. */
54
+ async function verifyKey(url, apiKey) {
55
+ const { get } = await import("node:https");
56
+ const target = new URL(`${url.replace(/\/+$/, "")}/v1/me`);
57
+ try {
58
+ const body = await new Promise((resolve, reject) => {
59
+ const req = get(
60
+ { hostname: target.hostname, path: target.pathname, headers: { authorization: `Bearer ${apiKey}` }, timeout: 6000 },
61
+ (res) => {
62
+ let data = "";
63
+ res.on("data", (c) => (data += c));
64
+ res.on("end", () => resolve({ status: res.statusCode, text: data }));
65
+ },
66
+ );
67
+ req.on("timeout", () => req.destroy(new Error("timeout")));
68
+ req.on("error", reject);
69
+ });
70
+ const parsed = body.text ? JSON.parse(body.text) : {};
71
+ return { ok: body.status >= 200 && body.status < 300, status: body.status, org: parsed?.org?.name ?? parsed?.org ?? null, error: parsed?.error ?? null };
72
+ } catch (err) {
73
+ const timedOut = String(err?.message ?? err) === "timeout";
74
+ return { ok: false, status: timedOut ? null : 0, org: null, error: timedOut ? "unreachable" : String(err?.message ?? err) };
75
+ }
76
+ }
77
+
78
+ async function store(url, apiKey) {
79
+ const dir = join(homedir(), ".cirvix");
80
+ await mkdir(dir, { recursive: true });
81
+ const file = join(dir, "credentials.json");
82
+ await writeFile(file, JSON.stringify({ controlPlaneUrl: url, apiKey, linkedAt: new Date().toISOString() }, null, 2) + "\n");
83
+ try {
84
+ await chmod(file, 0o600);
85
+ } catch {
86
+ /* Windows FAT-style filesystems may not support it — the file lives in the
87
+ user's home profile, which already ACLs it to the account. */
88
+ }
89
+ }
90
+
91
+ function prompt(question) {
92
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
93
+ return new Promise((resolve) => {
94
+ rl.question(question, (answer) => {
95
+ rl.close();
96
+ resolve(answer);
97
+ });
98
+ });
99
+ }
100
+
101
+ export async function login({ key = null, url = null, status = false, browser = false, json = false } = {}) {
102
+ const origin = url ?? DEFAULT_CONTROL_PLANE;
103
+
104
+ /* --status: report and leave. */
105
+ if (status) {
106
+ const creds = await readCredentials();
107
+ if (json) {
108
+ process.stdout.write(JSON.stringify({ linked: Boolean(creds), controlPlaneUrl: creds?.controlPlaneUrl ?? null }) + "\n");
109
+ return 0;
110
+ }
111
+ if (creds) process.stdout.write(` ${green("✓")} linked to ${bold(creds.controlPlaneUrl)}\n`);
112
+ else process.stdout.write(` ${dim("not linked — run")} ${bold("cirvix login")}\n`);
113
+ return 0;
114
+ }
115
+
116
+ let apiKey = key;
117
+
118
+ /* Browser flow: the terminal never sees a password. A short-lived flow id
119
+ is created on the control plane; the browser (where the user signs in)
120
+ claims it with a fresh org API key; the terminal polls once and stores
121
+ exactly that key — the same credential `--key` would have delivered. */
122
+ if (!apiKey && (browser || (process.stdin.isTTY && !process.env.CI))) {
123
+ try {
124
+ const flowRes = await fetch(`${origin.replace(/\/+$/, "")}/v1/cli/flow`, { method: "POST" });
125
+ const flow = await flowRes.json().catch(() => ({}));
126
+ if (!flow.flowId) throw new Error("the control plane did not offer browser sign-in");
127
+ const pageUrl = `https://www.cirvix.com/cli-auth.html#flow=${flow.flowId}`;
128
+ process.stderr.write(`\n ${bold("Link this machine to CIRVIX.")}\n`);
129
+ process.stderr.write(` Opening your browser…\n ${dim(pageUrl)}\n`);
130
+ process.stderr.write(` ${dim("Approve in the browser, or press Ctrl+C to cancel.")}\n\n`);
131
+ const openCmd = process.platform === "win32" ? "start" : process.platform === "darwin" ? "open" : "xdg-open";
132
+ const { spawn } = await import("node:child_process");
133
+ const child = spawn(openCmd, [pageUrl], { shell: process.platform === "win32", stdio: "ignore" });
134
+ child.on("error", () => process.stderr.write(` ${dim("Could not open a browser automatically — visit the URL above.")}\n`));
135
+ const deadline = Date.now() + flow.expiresIn * 1000;
136
+ let linked = null;
137
+ while (Date.now() < deadline) {
138
+ await new Promise((r) => setTimeout(r, 3000));
139
+ const poll = await fetch(`${origin.replace(/\/+$/, "")}/v1/cli/flow/${flow.flowId}`, { signal: AbortSignal.timeout(8000) });
140
+ if (!poll.ok) continue;
141
+ const body = await poll.json().catch(() => ({}));
142
+ if (body.status === "ok" && body.apiKey) { linked = { apiKey: body.apiKey, orgName: body.orgName }; break; }
143
+ }
144
+ if (!linked) {
145
+ process.stderr.write(` ${red("Timed out waiting for approval.")}\n`);
146
+ return 1;
147
+ }
148
+ await store(origin, linked.apiKey);
149
+ process.stdout.write(` ${green("✓ Linked to ")}${bold(origin)}${linked.orgName ? ` ${dim(`· ${linked.orgName}`)}` : ""}\n`);
150
+ process.stdout.write(` ${dim("Stored in ~/.cirvix/credentials.json. Run")} ${bold("cirvix doctor")} ${dim("any time.")}\n`);
151
+ finish(0);
152
+ } catch (err) {
153
+ process.stderr.write(` ${red("✗ Browser sign-in failed")} — ${err?.message ?? err}\n`);
154
+ process.stderr.write(` ${dim("Fall back to")} ${bold("cirvix login --key <api-key>")}\n`);
155
+ finish(1);
156
+ }
157
+ return;
158
+ }
159
+
160
+ const interactive = !apiKey && process.stdin.isTTY;
161
+
162
+ if (!apiKey && !interactive && !process.stdin.isTTY) {
163
+ // Piped stdin: read whatever arrives, trimmed.
164
+ const chunks = [];
165
+ for await (const chunk of process.stdin) chunks.push(chunk);
166
+ apiKey = chunks.join("").trim() || null;
167
+ }
168
+
169
+ if (!apiKey && interactive) {
170
+ process.stderr.write(`\n ${bold("Link this machine to CIRVIX.")}\n\n`);
171
+ process.stderr.write(` 1. Open ${bold(DASHBOARD_URL)}\n`);
172
+ process.stderr.write(` 2. Sign in → API keys → ${dim("Create key")}\n`);
173
+ process.stderr.write(` 3. Paste the key here (input is hidden)\n\n`);
174
+ apiKey = (await prompt(" API key: ") || "").trim();
175
+ }
176
+
177
+ if (!apiKey) {
178
+ process.stderr.write(` ${red("No key given. Run ")}cirvix login${red(" in a terminal, or pass --key.")}\n`);
179
+ return 1;
180
+ }
181
+
182
+ const check = await verifyKey(origin, apiKey);
183
+ if (!check.ok) {
184
+ const reason =
185
+ check.status === 401 || check.status === 403
186
+ ? "the key was rejected (wrong or revoked)"
187
+ : !check.status || check.status === 0
188
+ ? `${origin} was unreachable`
189
+ : `the control plane answered ${check.status}`;
190
+ await new Promise((resolve) => setImmediate(resolve));
191
+ if (json) process.stdout.write(JSON.stringify({ ok: false, error: reason }) + "\n");
192
+ else process.stderr.write(` ${red("✗ Login failed")} — ${reason}.\n ${dim("Check the key, your connection, and the control-plane URL.")}\n`);
193
+ finish(1);
194
+ }
195
+
196
+ await store(origin, apiKey);
197
+ /* Node 24 on Windows can assert in libuv at exit when undici's keep-alive
198
+ socket from the verify call is still winding down. One macrotask lets it
199
+ close cleanly; without this, a successful login prints a scary
200
+ "Assertion failed" that means nothing. */
201
+ await new Promise((resolve) => setImmediate(resolve));
202
+ if (json) {
203
+ process.stdout.write(JSON.stringify({ ok: true, controlPlaneUrl: origin, org: check.org }) + "\n");
204
+ } else {
205
+ process.stdout.write(` ${green("✓ Linked to ")}${bold(origin)}${check.org ? ` ${dim(`· ${check.org}`)}` : ""}\n`);
206
+ process.stdout.write(` ${dim("Stored in ~/.cirvix/credentials.json. Run")} ${bold("cirvix doctor")} ${dim("any time.")}\n`);
207
+ }
208
+ finish(0);
209
+ }
210
+
211
+ /** `cirvix logout` — remove the stored key. Local file only; the server-side
212
+ * key keeps working until revoked in the dashboard, and the message says so. */
213
+ export async function logout({ json = false } = {}) {
214
+ const creds = await readCredentials();
215
+ if (!creds) {
216
+ /* Leftover empty file (an interrupted logout, say) is tidied too, so the
217
+ next doctor pass does not flag our own residue as a broken install. */
218
+ await rm(credPath(), { force: true });
219
+ if (json) process.stdout.write(JSON.stringify({ ok: true, wasLinked: false }) + "\n");
220
+ else process.stdout.write(` ${dim("not linked")}\n`);
221
+ return 0;
222
+ }
223
+ await rm(credPath(), { force: true });
224
+ if (json) process.stdout.write(JSON.stringify({ ok: true, wasLinked: true }) + "\n");
225
+ else process.stdout.write(` ${green("✓")} Unlinked this machine. ${dim("The key itself is still valid — revoke it in the dashboard if you want it dead.")}\n`);
226
+ return 0;
227
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * `cirvix passport [agent]` — who this agent is, by what it has done.
3
+ *
4
+ * Reads the local audit chain and renders the passport. Optionally signs it
5
+ * with the same proof machinery, so a passport can be handed to someone else
6
+ * and checked with `cirvix verify`.
7
+ */
8
+
9
+ import { join } from "node:path";
10
+ import { writeFile } from "node:fs/promises";
11
+
12
+ import { AuditChain } from "../core/audit.mjs";
13
+ import { buildPassport, signPassport, badgeSvg, MIN_DECISIONS_TO_SCORE } from "../core/passport.mjs";
14
+ import { canonical } from "../core/proof.mjs";
15
+ import { createHash } from "node:crypto";
16
+ import { loadOrCreateKey } from "./prove.mjs";
17
+ import { bold, dim, green, red, amber, gray } from "../core/format.mjs";
18
+ import { panel } from "../core/ui/primitives.mjs";
19
+
20
+ export async function passport({
21
+ agentId = null,
22
+ cwd = process.cwd(),
23
+ stateDir = join(cwd, ".cirvix"),
24
+ policy = null,
25
+ json = false,
26
+ sign = false,
27
+ out = null,
28
+ badge = false,
29
+ badgeOut = null,
30
+ write = (s) => process.stdout.write(s),
31
+ } = {}) {
32
+ const chain = new AuditChain(join(stateDir, "audit.jsonl"));
33
+ const records = await chain.read();
34
+
35
+ if (!records.length) {
36
+ const err = { error: "no_audit_records", message: "There is no audit chain in this workspace yet." };
37
+ if (json) return { result: err, output: JSON.stringify(err, null, 2), exitCode: 1 };
38
+ write(`\n ${red("No audit chain in this workspace.")}\n ${dim("Run `cirvix protect` or `cirvix runtime` first.")}\n\n`);
39
+ return { result: err, exitCode: 1 };
40
+ }
41
+
42
+ const doc = buildPassport({ agentId, records, policy });
43
+
44
+ let signed = null;
45
+ if (sign) {
46
+ /* A passport is about ONE agent. Without an id, buildPassport() returns the
47
+ aggregate view of the workspace, which is a useful thing to read and a
48
+ meaningless thing to sign — it would attest to "some agents, collectively".
49
+ Refuse with the fix in the message rather than signing a document about
50
+ nobody. */
51
+ if (!doc.agent) {
52
+ return {
53
+ output: `
54
+ ${red("error")} Signing needs one agent, and this workspace view covers all of them.
55
+ ` +
56
+ ` Name the agent: ${bold("cirvix passport <agent-id> --sign")}
57
+ ` +
58
+ ` List them with: ${bold("cirvix passport")}
59
+ `,
60
+ exitCode: 1,
61
+ };
62
+ }
63
+ const key = await loadOrCreateKey(stateDir);
64
+ /*
65
+ * Sign the PASSPORT, not a proof of the last decision.
66
+ *
67
+ * This used to call buildProof(), which produced a perfectly valid
68
+ * artifact describing one decision and its chain segment — and none of
69
+ * the passport. Anyone handed the output of `cirvix passport --sign` and
70
+ * told "here is my agent's passport" received a document with no
71
+ * identity, no tools and no trust score in it. The command name promised
72
+ * one artifact and the file was another.
73
+ *
74
+ * The policy hash is bound here rather than left to the caller: a
75
+ * passport that names a policy version without pinning its content
76
+ * attests to a moving target, and signPassport() refuses that case.
77
+ */
78
+ const policyHash = policy
79
+ ? "sha256:" + createHash("sha256").update(canonical(policy.rules ?? [])).digest("hex")
80
+ : null;
81
+ const built = signPassport({
82
+ passport: doc,
83
+ privateKey: key.privateKey,
84
+ keyId: key.keyId,
85
+ issuer: "local",
86
+ policyHash,
87
+ });
88
+ signed = built.token;
89
+ if (out) await writeFile(out, built.token + "\n", "utf8");
90
+ }
91
+
92
+ /* A README badge, rendered from the passport that was just built. */
93
+ if (badge) {
94
+ const svg = badgeSvg(doc);
95
+ if (badgeOut) await writeFile(badgeOut, svg + "\n", "utf8");
96
+ else write(svg + "\n");
97
+ }
98
+
99
+ const result = { ...doc, ...(signed ? { proof: signed, writtenTo: out ?? null } : {}) };
100
+ if (json) return { result, output: JSON.stringify(result, null, 2), exitCode: 0 };
101
+
102
+ /* ------------------------------------------------------------- render */
103
+ write(`\n ${bold("AGENT PASSPORT")} ${dim(doc.agent ?? "all agents in this workspace")}\n\n`);
104
+ write(
105
+ panel({
106
+ lines: [
107
+ `${"First seen".padEnd(13)} ${doc.identity.firstSeen ?? "—"}`,
108
+ `${"Last seen".padEnd(13)} ${doc.identity.lastSeen ?? "—"}`,
109
+ `${"Tools used".padEnd(13)} ${doc.identity.tools.length ? doc.identity.tools.join(", ") : "—"}`,
110
+ ``,
111
+ `${"Decisions".padEnd(13)} ${doc.behaviour.decisions}`,
112
+ `${"Allowed".padEnd(13)} ${doc.behaviour.allowed}`,
113
+ `${"Denied".padEnd(13)} ${doc.behaviour.denied}`,
114
+ `${"Held".padEnd(13)} ${doc.behaviour.heldForApproval}`,
115
+ `${"Critical".padEnd(13)} ${doc.behaviour.criticalAttempts}`,
116
+ `${"Retries".padEnd(13)} ${doc.behaviour.repeatedRefusals}`,
117
+ ],
118
+ width: 62,
119
+ }) + "\n",
120
+ );
121
+
122
+ write(`\n ${bold("TRUST")}\n\n`);
123
+ if (doc.trust.score === null) {
124
+ /* No number at all. Printing a provisional score here would be the exact
125
+ failure this module exists to avoid — it would be quoted, and it would
126
+ be quoted without the caveat. */
127
+ write(` ${amber("Not enough evidence to score.")}\n`);
128
+ write(` ${dim(doc.trust.reason)}\n\n`);
129
+ return { result, exitCode: 0 };
130
+ }
131
+
132
+ const tone = doc.trust.score >= 85 ? green : doc.trust.score >= 60 ? amber : red;
133
+ write(` ${tone(bold(String(doc.trust.score)))} ${dim("/ " + doc.trust.outOf)} ${dim(doc.trust.confidence)}\n\n`);
134
+ for (const c of doc.trust.components) {
135
+ write(` ${String(c.points).padStart(5)} / ${String(c.weight).padEnd(3)} ${dim(c.label)}\n`);
136
+ }
137
+ /* The bound travels with the number, every time. */
138
+ write(`\n ${dim(doc.trust.meaning)}\n`);
139
+
140
+ if (signed) {
141
+ write(`\n ${green("✓")} signed${out ? ` → ${bold(out)}` : ""}\n`);
142
+ if (!out) write(`\n${gray(signed)}\n`);
143
+ write(` ${dim("Check it:")} ${bold(`cirvix verify ${out ?? "<passport>"}`)}\n`);
144
+ }
145
+ write("\n");
146
+ return { result, exitCode: 0 };
147
+ }
148
+
149
+ export { MIN_DECISIONS_TO_SCORE };
@@ -183,7 +183,7 @@ export async function test({ path, cwd = process.cwd(), json = false, filter = n
183
183
 
184
184
  if (json) return { result, code: failed ? 1 : 0, output: JSON.stringify(result, null, 2) };
185
185
 
186
- const lines = ["", ` ${bold(path)}`, ""];
186
+ const lines = ["", ` ${bold("CIRVIX POLICY VALIDATION")}`, "", ` ${dim(`◌ Running ${cases.length} policy tests...`)}`, "", ` ${dim(path)}`, ""];
187
187
  for (const c of cases) {
188
188
  if (c.passed) {
189
189
  lines.push(` ${green("✓")} ${c.name} ${dim(`→ ${c.actual}${c.rule ? ` (${c.rule})` : ""}`)}`);
@@ -197,11 +197,15 @@ export async function test({ path, cwd = process.cwd(), json = false, filter = n
197
197
  }
198
198
  }
199
199
  lines.push("");
200
- lines.push(
201
- failed === 0
202
- ? ` ${green(bold(`${passed} passed`))}`
203
- : ` ${red(bold(`${failed} failed`))} ${dim(`${passed} passed`)}`,
204
- );
200
+ lines.push(` ${dim("─".repeat(40))}`);
201
+ lines.push("");
202
+ if (failed === 0) {
203
+ lines.push(` ${green(bold(`${passed}/${cases.length} PASSED`))}`);
204
+ lines.push(` ${dim("Policy is internally consistent.")}`);
205
+ } else {
206
+ lines.push(` ${red(bold(`${failed} failed`))} ${dim(`${passed} passed`)} ${red(`✕ ${passed}/${cases.length} PASSED`)}`);
207
+ lines.push(` ${dim("Fix the failing tests above — they describe the intended enforcement.")}`);
208
+ }
205
209
  lines.push("");
206
210
 
207
211
  return { result, code: failed ? 1 : 0, output: lines.join("\n") };