@cirvix_ai/agent-control 0.1.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/LICENSE +202 -0
- package/NOTICE +42 -0
- package/README.md +341 -0
- package/action/README.md +100 -0
- package/action/action.yml +134 -0
- package/action/report.mjs +144 -0
- package/bin/cirvix.mjs +1073 -0
- package/package.json +60 -0
- package/src/commands/demo.mjs +315 -0
- package/src/commands/init.mjs +558 -0
- package/src/commands/policy.mjs +345 -0
- package/src/commands/sarif.mjs +176 -0
- package/src/commands/scan.mjs +210 -0
- package/src/commands/status.mjs +208 -0
- package/src/commands/upgrade.mjs +162 -0
- package/src/core/approvals.mjs +388 -0
- package/src/core/audit.mjs +181 -0
- package/src/core/canonical.mjs +316 -0
- package/src/core/daemon.mjs +352 -0
- package/src/core/decisions.mjs +253 -0
- package/src/core/delegation.mjs +658 -0
- package/src/core/detect.mjs +337 -0
- package/src/core/entitlement-gate.mjs +100 -0
- package/src/core/entitlements.mjs +285 -0
- package/src/core/format.mjs +33 -0
- package/src/core/gateway.mjs +959 -0
- package/src/core/guard.mjs +568 -0
- package/src/core/http-transport.mjs +505 -0
- package/src/core/journal.mjs +419 -0
- package/src/core/jsonrpc.mjs +152 -0
- package/src/core/meter.mjs +225 -0
- package/src/core/normalize.mjs +516 -0
- package/src/core/notices.mjs +80 -0
- package/src/core/pipeline.mjs +629 -0
- package/src/core/policy-dsl.mjs +611 -0
- package/src/core/policy.mjs +710 -0
- package/src/core/prompts.mjs +146 -0
- package/src/core/risk.mjs +509 -0
- package/src/core/sanitize.mjs +279 -0
- package/src/core/secret-detect.mjs +533 -0
- package/src/core/secrets.mjs +312 -0
- package/src/core/uds.mjs +383 -0
- package/src/core/vault.mjs +530 -0
- package/src/index.mjs +143 -0
- package/src/testing.mjs +145 -0
package/bin/cirvix.mjs
ADDED
|
@@ -0,0 +1,1073 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Cirvix AgentControl CLI.
|
|
4
|
+
*
|
|
5
|
+
* Zero runtime dependencies, by design: this binary runs on developer
|
|
6
|
+
* machines and in CI, and a security tool that drags in a transitive
|
|
7
|
+
* dependency tree is asking to become the supply-chain incident it exists to
|
|
8
|
+
* prevent.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { access, mkdir, readFile } from "node:fs/promises";
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
import { evaluate, parseRules, STARTER_RULES } from "../src/core/policy.mjs";
|
|
16
|
+
import { AuditChain } from "../src/core/audit.mjs";
|
|
17
|
+
import { Daemon } from "../src/core/daemon.mjs";
|
|
18
|
+
import { Gateway } from "../src/core/gateway.mjs";
|
|
19
|
+
import { MessageFramer, serialize } from "../src/core/jsonrpc.mjs";
|
|
20
|
+
import { scan } from "../src/commands/scan.mjs";
|
|
21
|
+
import { bold, dim, green, red, amber, blue, plural } from "../src/core/format.mjs";
|
|
22
|
+
|
|
23
|
+
import { MODE, DECISION } from "../src/core/decisions.mjs";
|
|
24
|
+
import { Pipeline } from "../src/core/pipeline.mjs";
|
|
25
|
+
import { Vault } from "../src/core/vault.mjs";
|
|
26
|
+
import { ApprovalStore } from "../src/core/approvals.mjs";
|
|
27
|
+
import { UdsServer, defaultEndpoint, writeToken } from "../src/core/uds.mjs";
|
|
28
|
+
import * as journal from "../src/core/journal.mjs";
|
|
29
|
+
import * as policyCmd from "../src/commands/policy.mjs";
|
|
30
|
+
import { init as initCmd } from "../src/commands/init.mjs";
|
|
31
|
+
import { status as statusCmd } from "../src/commands/status.mjs";
|
|
32
|
+
import { upgrade as upgradeCmd } from "../src/commands/upgrade.mjs";
|
|
33
|
+
import { AgentRegistry, Meter, readLicence } from "../src/core/meter.mjs";
|
|
34
|
+
import { commercialNotices } from "../src/core/notices.mjs";
|
|
35
|
+
import { demo as demoCmd } from "../src/commands/demo.mjs";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Read from the manifest, never written down twice.
|
|
39
|
+
*
|
|
40
|
+
* It was a hardcoded `"0.2.0"` while `package.json` said `0.1.0`, so a clean
|
|
41
|
+
* `npm install -g` produced a binary that reported a version npm had never
|
|
42
|
+
* published. The first bug report would cite a release that does not exist, and
|
|
43
|
+
* the two numbers had no reason to ever converge again.
|
|
44
|
+
*
|
|
45
|
+
* Synchronous and at startup because every other path here is async and a
|
|
46
|
+
* version string is not worth an await in `--version`.
|
|
47
|
+
*/
|
|
48
|
+
const VERSION = JSON.parse(
|
|
49
|
+
readFileSync(new URL("../package.json", import.meta.url), "utf8"),
|
|
50
|
+
).version;
|
|
51
|
+
|
|
52
|
+
const HELP = `
|
|
53
|
+
${bold("cirvix")} ${dim("· runtime governance for AI agents")}
|
|
54
|
+
|
|
55
|
+
${bold("USAGE")}
|
|
56
|
+
cirvix <command> [options]
|
|
57
|
+
|
|
58
|
+
${bold("GETTING STARTED")}
|
|
59
|
+
init Detect agents and MCP servers, write a policy, start protecting
|
|
60
|
+
status Runtime, policy, servers, blocked, approvals, P99 overhead
|
|
61
|
+
upgrade Today's usage against your plan, and what lifts the limit
|
|
62
|
+
demo Watch an injected exfiltration attempt get stopped, live
|
|
63
|
+
scan Inventory what is ungoverned on this machine
|
|
64
|
+
|
|
65
|
+
${bold("ENFORCEMENT")}
|
|
66
|
+
gateway Run the MCP gateway — intercepts and enforces
|
|
67
|
+
runtime Run the local control socket — any agent, any language
|
|
68
|
+
daemon Run the endpoint service — policy sync + telemetry
|
|
69
|
+
|
|
70
|
+
${bold("POLICY")}
|
|
71
|
+
policy check Parse and validate the rule set
|
|
72
|
+
policy test Run the test cases the policy declares
|
|
73
|
+
policy explain Why would this call be decided that way
|
|
74
|
+
policy list Show the active rules
|
|
75
|
+
check Evaluate a single tool call against the policy set
|
|
76
|
+
|
|
77
|
+
${bold("HISTORY")}
|
|
78
|
+
logs Recent decisions
|
|
79
|
+
logs --last 50 The last N
|
|
80
|
+
logs --risk high Only high and critical
|
|
81
|
+
logs --tree <id> One decision, as an execution tree
|
|
82
|
+
replay <id> Re-decide a recorded call under a candidate policy
|
|
83
|
+
why <decision-id> Explain one decision from the control plane
|
|
84
|
+
audit verify Recompute the decision chain and report any break
|
|
85
|
+
|
|
86
|
+
${bold("APPROVALS & SECRETS")}
|
|
87
|
+
approvals Calls waiting on a human
|
|
88
|
+
approve <id> --by <who>
|
|
89
|
+
deny <id> --by <who>
|
|
90
|
+
vault load Move credential env vars behind handles
|
|
91
|
+
|
|
92
|
+
${bold("OPTIONS")}
|
|
93
|
+
--json Machine-readable output
|
|
94
|
+
--sarif <file> Write SARIF 2.1.0 for code-scanning upload
|
|
95
|
+
--deep Include MCP command lines in scan output
|
|
96
|
+
--policy <file> Rule set to evaluate against ${dim("(default ./cirvix.policy)")}
|
|
97
|
+
--cwd <dir> Workspace root (default: current directory)
|
|
98
|
+
--state <dir> State directory ${dim("(default ./.cirvix)")}
|
|
99
|
+
--fail-on <level> Exit non-zero at high|medium|low findings
|
|
100
|
+
--mode <enforce|audit> audit records decisions and blocks nothing
|
|
101
|
+
|
|
102
|
+
${bold("GATEWAY / DAEMON")}
|
|
103
|
+
--servers <file> MCP server map (same shape as an editor's mcp.json)
|
|
104
|
+
--api <url> Control-plane URL
|
|
105
|
+
--key <cvx_…> API key ${dim("(or set CIRVIX_API_KEY)")}
|
|
106
|
+
|
|
107
|
+
${bold("EXAMPLES")}
|
|
108
|
+
${dim("$")} cirvix init
|
|
109
|
+
${dim("$")} cirvix demo
|
|
110
|
+
${dim("$")} cirvix policy test
|
|
111
|
+
${dim("$")} cirvix policy explain --tool shell.exec --command "rm -rf /"
|
|
112
|
+
${dim("$")} cirvix gateway --servers ~/.cursor/mcp.json
|
|
113
|
+
${dim("$")} cirvix logs --risk high --last 20
|
|
114
|
+
${dim("$")} cirvix replay req_8a91 --policy policies/proposed.policy
|
|
115
|
+
${dim("$")} cirvix audit verify --file .cirvix/audit.jsonl
|
|
116
|
+
`;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A read against the control plane.
|
|
120
|
+
*
|
|
121
|
+
* `why` and `replay` are the two commands that need one — everything else in
|
|
122
|
+
* this CLI works offline, because enforcement has to. These do not: they ask
|
|
123
|
+
* about something that was already recorded somewhere else.
|
|
124
|
+
*/
|
|
125
|
+
async function controlPlane(flags) {
|
|
126
|
+
const apiUrl = flags.api ?? process.env.CIRVIX_API_URL;
|
|
127
|
+
const apiKey = flags.key ?? process.env.CIRVIX_API_KEY;
|
|
128
|
+
if (!apiUrl || !apiKey) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
"This command reads from a control plane. Pass --api <url> and --key <cvx_…>, or set CIRVIX_API_URL and CIRVIX_API_KEY.",
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
const base = String(apiUrl).replace(/\/$/, "");
|
|
134
|
+
return async (method, path, body) => {
|
|
135
|
+
const res = await fetch(base + path, {
|
|
136
|
+
method,
|
|
137
|
+
headers: {
|
|
138
|
+
authorization: `Bearer ${apiKey}`,
|
|
139
|
+
...(body ? { "content-type": "application/json" } : {}),
|
|
140
|
+
// A one-shot CLI has no use for a pooled socket, and leaving one open
|
|
141
|
+
// holds the event loop past the last line of output — on Windows that
|
|
142
|
+
// surfaced as a libuv assertion and exit code 127 on a command that
|
|
143
|
+
// had already printed the right answer. The exit code is the contract
|
|
144
|
+
// for CI, so it has to be the one we chose.
|
|
145
|
+
connection: "close",
|
|
146
|
+
},
|
|
147
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
148
|
+
signal: AbortSignal.timeout(20_000),
|
|
149
|
+
});
|
|
150
|
+
const payload = await res.json().catch(() => ({}));
|
|
151
|
+
if (!res.ok) throw new Error(payload.error ?? `${method} ${path} → ${res.status}`);
|
|
152
|
+
return payload;
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/* -------------------------------------------------------------------------- */
|
|
157
|
+
|
|
158
|
+
function parseArgs(argv) {
|
|
159
|
+
const positional = [];
|
|
160
|
+
const flags = {};
|
|
161
|
+
for (let i = 0; i < argv.length; i++) {
|
|
162
|
+
const a = argv[i];
|
|
163
|
+
if (a.startsWith("--")) {
|
|
164
|
+
const key = a.slice(2);
|
|
165
|
+
const next = argv[i + 1];
|
|
166
|
+
if (next === undefined || next.startsWith("--")) flags[key] = true;
|
|
167
|
+
else {
|
|
168
|
+
flags[key] = next;
|
|
169
|
+
i++;
|
|
170
|
+
}
|
|
171
|
+
} else positional.push(a);
|
|
172
|
+
}
|
|
173
|
+
return { positional, flags };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function fileExists(path) {
|
|
177
|
+
try {
|
|
178
|
+
await access(path);
|
|
179
|
+
return true;
|
|
180
|
+
} catch {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Resolves which policy file to use.
|
|
187
|
+
*
|
|
188
|
+
* An explicit `--policy` always wins. Otherwise the workspace's own
|
|
189
|
+
* `cirvix.policy` (what `init` writes), then `cirvix.policy.json` for
|
|
190
|
+
* deployments that predate the DSL, then the built-in starter rules.
|
|
191
|
+
*
|
|
192
|
+
* Order matters: a project that has written its own policy must never silently
|
|
193
|
+
* run under the defaults because the lookup missed its file.
|
|
194
|
+
*/
|
|
195
|
+
async function resolvePolicyPath(flag, cwd) {
|
|
196
|
+
if (typeof flag === "string") return flag;
|
|
197
|
+
for (const candidate of ["cirvix.policy", "cirvix.policy.json", ".cirvix/policy.json"]) {
|
|
198
|
+
const path = join(cwd, candidate);
|
|
199
|
+
if (await fileExists(path)) return path;
|
|
200
|
+
}
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Loads rules from either the DSL or the JSON shape, or the starter set. */
|
|
205
|
+
async function loadRules(file, cwd = process.cwd()) {
|
|
206
|
+
const path = await resolvePolicyPath(file, cwd);
|
|
207
|
+
if (!path) return STARTER_RULES;
|
|
208
|
+
const loaded = await policyCmd.loadPolicyFile(path, { cwd });
|
|
209
|
+
return loaded.rules;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Same, but keeps the tests and the path — for the policy subcommands. */
|
|
213
|
+
async function loadPolicy(file, cwd = process.cwd()) {
|
|
214
|
+
const path = await resolvePolicyPath(file, cwd);
|
|
215
|
+
if (!path) {
|
|
216
|
+
return { rules: STARTER_RULES, tests: [], format: "builtin", path: null };
|
|
217
|
+
}
|
|
218
|
+
return policyCmd.loadPolicyFile(path, { cwd });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function stateDirFor(flags, cwd) {
|
|
222
|
+
return String(flags.state ?? join(cwd, ".cirvix"));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Parses `--arg key=value` pairs plus the convenience flags into call args. */
|
|
226
|
+
function callArgsFrom(flags) {
|
|
227
|
+
const args = {};
|
|
228
|
+
if (typeof flags.path === "string") args.path = flags.path;
|
|
229
|
+
if (typeof flags.resource === "string" && !args.path) args.path = flags.resource;
|
|
230
|
+
if (typeof flags.url === "string") args.url = flags.url;
|
|
231
|
+
if (typeof flags.command === "string") args.command = flags.command;
|
|
232
|
+
if (typeof flags.sql === "string") args.sql = flags.sql;
|
|
233
|
+
for (const pair of [].concat(flags.arg ?? [])) {
|
|
234
|
+
if (typeof pair !== "string") continue;
|
|
235
|
+
const eq = pair.indexOf("=");
|
|
236
|
+
if (eq === -1) continue;
|
|
237
|
+
args[pair.slice(0, eq)] = pair.slice(eq + 1);
|
|
238
|
+
}
|
|
239
|
+
return args;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Reads an MCP server map. Accepts an editor's config verbatim — `mcpServers`
|
|
244
|
+
* (Claude Code, Cursor, Windsurf) or `servers` (VS Code) — so a user points at
|
|
245
|
+
* the file they already have rather than authoring a new format. Cirvix's own
|
|
246
|
+
* entry is skipped, otherwise pointing the gateway at a governed config would
|
|
247
|
+
* make it proxy itself.
|
|
248
|
+
*/
|
|
249
|
+
async function loadServers(file) {
|
|
250
|
+
if (!file) return {};
|
|
251
|
+
const raw = JSON.parse(await readFile(String(file), "utf8"));
|
|
252
|
+
const map = raw.mcpServers ?? raw.servers ?? raw;
|
|
253
|
+
const out = {};
|
|
254
|
+
for (const [name, spec] of Object.entries(map)) {
|
|
255
|
+
if (name === "cirvix") continue;
|
|
256
|
+
|
|
257
|
+
// Hosted servers are named by `url`, local ones by `command`. Both are
|
|
258
|
+
// governed; skipping the HTTP ones left exactly the servers a company did
|
|
259
|
+
// not write and cannot audit outside the control plane.
|
|
260
|
+
if (spec?.url) {
|
|
261
|
+
out[name] = {
|
|
262
|
+
url: spec.url,
|
|
263
|
+
headers: spec.headers ?? {},
|
|
264
|
+
...(spec.timeoutMs ? { timeoutMs: spec.timeoutMs } : {}),
|
|
265
|
+
};
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if (!spec?.command) continue;
|
|
269
|
+
out[name] = { command: spec.command, args: spec.args ?? [], env: spec.env ?? {} };
|
|
270
|
+
}
|
|
271
|
+
return out;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/* -------------------------------------------------------------------------- */
|
|
275
|
+
|
|
276
|
+
async function main() {
|
|
277
|
+
const { positional, flags } = parseArgs(process.argv.slice(2));
|
|
278
|
+
const command = positional[0] ?? "help";
|
|
279
|
+
const sub = positional[1];
|
|
280
|
+
const cwd = flags.cwd ? String(flags.cwd) : process.cwd();
|
|
281
|
+
|
|
282
|
+
if (flags.version || command === "version") {
|
|
283
|
+
process.stdout.write(VERSION + "\n");
|
|
284
|
+
return 0;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
switch (command) {
|
|
288
|
+
case "scan": {
|
|
289
|
+
const { result, output } = await scan({
|
|
290
|
+
cwd,
|
|
291
|
+
json: Boolean(flags.json),
|
|
292
|
+
deep: Boolean(flags.deep),
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// Written before the exit-code gate below, so a failing scan still
|
|
296
|
+
// produces the artifact CI is about to upload. Emitting it only on
|
|
297
|
+
// success would mean the runs that matter most report nothing.
|
|
298
|
+
if (typeof flags.sarif === "string") {
|
|
299
|
+
const { toSarif } = await import("../src/commands/sarif.mjs");
|
|
300
|
+
const { writeFile } = await import("node:fs/promises");
|
|
301
|
+
await writeFile(flags.sarif, JSON.stringify(toSarif(result, { root: cwd }), null, 2), "utf8");
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
process.stdout.write(output + "\n");
|
|
305
|
+
|
|
306
|
+
const gate = flags["fail-on"];
|
|
307
|
+
if (typeof gate === "string") {
|
|
308
|
+
const levels = { high: ["high"], medium: ["high", "medium"], low: ["high", "medium", "low"] };
|
|
309
|
+
const watch = levels[gate];
|
|
310
|
+
if (!watch) {
|
|
311
|
+
process.stderr.write(red(` Unknown --fail-on level "${gate}". Use high, medium, or low.\n`));
|
|
312
|
+
return 2;
|
|
313
|
+
}
|
|
314
|
+
const hit = watch.reduce((n, l) => n + (result.counts[l] ?? 0), 0);
|
|
315
|
+
if (hit > 0) return 1;
|
|
316
|
+
}
|
|
317
|
+
return 0;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
case "gateway": {
|
|
321
|
+
// stdio transport: the agent owns stdout, so every diagnostic goes to
|
|
322
|
+
// stderr. One stray console.log here corrupts the JSON-RPC stream and
|
|
323
|
+
// the agent sees a protocol error it cannot explain.
|
|
324
|
+
const log = (m) => process.stderr.write(`[cirvix] ${m}\n`);
|
|
325
|
+
const servers = await loadServers(flags.servers);
|
|
326
|
+
if (Object.keys(servers).length === 0) {
|
|
327
|
+
process.stderr.write(
|
|
328
|
+
red(" No upstream MCP servers configured. Pass --servers <file>.\n"),
|
|
329
|
+
);
|
|
330
|
+
return 2;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const rules = await loadRules(flags.policy, cwd);
|
|
334
|
+
const stateDir = String(flags.state ?? join(cwd, ".cirvix"));
|
|
335
|
+
await mkdir(stateDir, { recursive: true }).catch(() => {});
|
|
336
|
+
const chain = await new AuditChain(join(stateDir, "audit.jsonl")).open();
|
|
337
|
+
|
|
338
|
+
// If a control plane is configured, the daemon supplies policy and
|
|
339
|
+
// receives telemetry. Without one the gateway still enforces, from the
|
|
340
|
+
// local rule set — the product is useful before you have an account.
|
|
341
|
+
let daemon = null;
|
|
342
|
+
const apiUrl = flags.api ?? process.env.CIRVIX_API_URL;
|
|
343
|
+
const apiKey = flags.key ?? process.env.CIRVIX_API_KEY;
|
|
344
|
+
if (apiUrl && apiKey) {
|
|
345
|
+
daemon = new Daemon({ apiUrl: String(apiUrl), apiKey: String(apiKey), stateDir, log });
|
|
346
|
+
await daemon.start();
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const agentName = String(flags.agent ?? "local");
|
|
350
|
+
const environment = String(flags.env ?? "local");
|
|
351
|
+
|
|
352
|
+
// Metered on the same terms as the local socket. The gateway is the path
|
|
353
|
+
// most Free-tier traffic actually takes, and it was the one measuring
|
|
354
|
+
// nothing.
|
|
355
|
+
const gwLicence = readLicence(cwd);
|
|
356
|
+
const gwMeter = new Meter({ cwd });
|
|
357
|
+
// stdout is the MCP wire on this path. Notices go to stderr or they
|
|
358
|
+
// corrupt protocol frames.
|
|
359
|
+
const gwNotice = commercialNotices({
|
|
360
|
+
licence: gwLicence,
|
|
361
|
+
meter: gwMeter,
|
|
362
|
+
write: (s) => process.stderr.write(s),
|
|
363
|
+
});
|
|
364
|
+
const gw = new Gateway({
|
|
365
|
+
servers,
|
|
366
|
+
rules: daemon?.currentRules().length ? daemon.currentRules() : rules,
|
|
367
|
+
audit: chain,
|
|
368
|
+
cwd,
|
|
369
|
+
environment: String(flags.env ?? "local"),
|
|
370
|
+
licence: gwLicence,
|
|
371
|
+
meter: gwMeter,
|
|
372
|
+
agents: new AgentRegistry(),
|
|
373
|
+
log,
|
|
374
|
+
onDecision: (d) => {
|
|
375
|
+
if (d.kind === "decision") gwNotice(d);
|
|
376
|
+
if (daemon && d.kind === "decision") void daemon.record(d);
|
|
377
|
+
},
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
gw.agentName = agentName;
|
|
381
|
+
// Announce the agent so it appears in the fleet inventory alongside its
|
|
382
|
+
// decisions, rather than the console reporting calls from nobody. Then
|
|
383
|
+
// open the run every decision in this session will belong to.
|
|
384
|
+
if (daemon) {
|
|
385
|
+
await daemon.registerAgent({ name: agentName, framework: "mcp-gateway", environment });
|
|
386
|
+
gw.runId = await daemon.openRun({ agent: agentName, environment });
|
|
387
|
+
}
|
|
388
|
+
gw.start((msg) => process.stdout.write(serialize(msg)));
|
|
389
|
+
|
|
390
|
+
// `--http` serves the same gateway over Streamable HTTP for agents that
|
|
391
|
+
// connect to a URL rather than spawning a subprocess. stdio keeps working
|
|
392
|
+
// alongside it; the two are transports for one decision path.
|
|
393
|
+
let httpServer = null;
|
|
394
|
+
if (flags.http) {
|
|
395
|
+
const { HttpGatewayServer } = await import("../src/core/http-transport.mjs");
|
|
396
|
+
try {
|
|
397
|
+
httpServer = await new HttpGatewayServer({
|
|
398
|
+
gateway: gw,
|
|
399
|
+
host: String(flags.host ?? "127.0.0.1"),
|
|
400
|
+
port: Number(flags.port ?? 8787),
|
|
401
|
+
token: typeof flags.token === "string" ? flags.token : null,
|
|
402
|
+
log,
|
|
403
|
+
}).start();
|
|
404
|
+
} catch (err) {
|
|
405
|
+
process.stderr.write(red(` ${err.message}\n`));
|
|
406
|
+
gw.stop();
|
|
407
|
+
return 2;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const framer = new MessageFramer({
|
|
412
|
+
onMessage: (m) => void gw.handleClientMessage(m),
|
|
413
|
+
onInvalid: (line) => log(`client sent unparseable frame: ${line.slice(0, 120)}`),
|
|
414
|
+
});
|
|
415
|
+
process.stdin.on("data", (c) => framer.push(c));
|
|
416
|
+
|
|
417
|
+
log(
|
|
418
|
+
`gateway up · ${Object.keys(servers).length} upstream · ` +
|
|
419
|
+
`${(daemon?.currentRules().length || rules.length)} rules` +
|
|
420
|
+
(daemon ? ` · synced with ${apiUrl}` : " · local policy"),
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
await new Promise((resolve) => {
|
|
424
|
+
let closing = false;
|
|
425
|
+
const shutdown = async () => {
|
|
426
|
+
// Guard against stdin 'end' and SIGTERM both firing — a double
|
|
427
|
+
// shutdown would drain the spool twice and double-report.
|
|
428
|
+
if (closing) return;
|
|
429
|
+
closing = true;
|
|
430
|
+
log(
|
|
431
|
+
`stopping · ${gw.stats.calls} calls · ${gw.stats.permitted} permitted · ` +
|
|
432
|
+
`${gw.stats.denied} denied · ${gw.stats.held} held`,
|
|
433
|
+
);
|
|
434
|
+
gw.stop();
|
|
435
|
+
if (httpServer) await httpServer.stop();
|
|
436
|
+
// Flush telemetry before exiting. A short-lived run would otherwise
|
|
437
|
+
// leave its decisions spooled on disk until some later daemon start.
|
|
438
|
+
if (daemon) {
|
|
439
|
+
await daemon.shutdown();
|
|
440
|
+
// Closed after the flush, so the run's counts and its decisions
|
|
441
|
+
// arrive in that order and a reader never sees a finished run
|
|
442
|
+
// whose steps have not landed yet.
|
|
443
|
+
await daemon.closeRun({
|
|
444
|
+
calls: gw.stats.calls,
|
|
445
|
+
permitted: gw.stats.permitted,
|
|
446
|
+
denied: gw.stats.denied,
|
|
447
|
+
held: gw.stats.held,
|
|
448
|
+
leaks: gw.stats.leaks,
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
resolve();
|
|
452
|
+
};
|
|
453
|
+
process.stdin.on("end", () => void shutdown());
|
|
454
|
+
process.on("SIGINT", () => void shutdown());
|
|
455
|
+
process.on("SIGTERM", () => void shutdown());
|
|
456
|
+
});
|
|
457
|
+
return 0;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
case "daemon": {
|
|
461
|
+
const apiUrl = flags.api ?? process.env.CIRVIX_API_URL;
|
|
462
|
+
const apiKey = flags.key ?? process.env.CIRVIX_API_KEY;
|
|
463
|
+
if (!apiUrl || !apiKey) {
|
|
464
|
+
process.stderr.write(
|
|
465
|
+
red(" daemon needs --api <url> and --key <cvx_…> (or CIRVIX_API_URL / CIRVIX_API_KEY).\n"),
|
|
466
|
+
);
|
|
467
|
+
return 2;
|
|
468
|
+
}
|
|
469
|
+
const stateDir = String(flags.state ?? join(cwd, ".cirvix"));
|
|
470
|
+
const daemon = new Daemon({
|
|
471
|
+
apiUrl: String(apiUrl),
|
|
472
|
+
apiKey: String(apiKey),
|
|
473
|
+
stateDir,
|
|
474
|
+
intervalMs: Number(flags.interval ?? 30000),
|
|
475
|
+
log: (m) => process.stdout.write(`[cirvix] ${m}\n`),
|
|
476
|
+
});
|
|
477
|
+
await daemon.start();
|
|
478
|
+
|
|
479
|
+
await new Promise((resolve) => {
|
|
480
|
+
let closing = false;
|
|
481
|
+
const shutdown = async () => {
|
|
482
|
+
if (closing) return;
|
|
483
|
+
closing = true;
|
|
484
|
+
await daemon.shutdown();
|
|
485
|
+
resolve();
|
|
486
|
+
};
|
|
487
|
+
process.on("SIGINT", () => void shutdown());
|
|
488
|
+
process.on("SIGTERM", () => void shutdown());
|
|
489
|
+
});
|
|
490
|
+
return 0;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
case "check": {
|
|
494
|
+
const rules = await loadRules(flags.policy, cwd);
|
|
495
|
+
const action = String(flags.action ?? "");
|
|
496
|
+
const resource = String(flags.resource ?? "");
|
|
497
|
+
if (!action || !resource) {
|
|
498
|
+
process.stderr.write(red(" check needs --action and --resource.\n"));
|
|
499
|
+
return 2;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const decision = evaluate(
|
|
503
|
+
{
|
|
504
|
+
agent: String(flags.agent ?? "local"),
|
|
505
|
+
action,
|
|
506
|
+
resource,
|
|
507
|
+
context: {
|
|
508
|
+
environment: String(flags.env ?? "local"),
|
|
509
|
+
path: { insideWorkspace: isInsideWorkspace(cwd, resource) },
|
|
510
|
+
egress: { external: false, allowlisted: false },
|
|
511
|
+
session: { touchedSecret: false },
|
|
512
|
+
},
|
|
513
|
+
},
|
|
514
|
+
rules,
|
|
515
|
+
{ cwd },
|
|
516
|
+
);
|
|
517
|
+
|
|
518
|
+
if (flags.json) {
|
|
519
|
+
process.stdout.write(JSON.stringify(decision, null, 2) + "\n");
|
|
520
|
+
} else {
|
|
521
|
+
const tone =
|
|
522
|
+
decision.verdict === "permit" ? green : decision.verdict === "hold" ? amber : red;
|
|
523
|
+
process.stdout.write(
|
|
524
|
+
[
|
|
525
|
+
"",
|
|
526
|
+
` ${tone(bold(decision.verdict.toUpperCase()))} ${dim(action)} ${decision.resource}`,
|
|
527
|
+
` ${dim("rule")} ${decision.rule ?? dim("— no rule matched (default deny)")}`,
|
|
528
|
+
` ${dim("reason")} ${decision.reason}`,
|
|
529
|
+
decision.remediation ? ` ${dim("fix")} ${blue(decision.remediation)}` : "",
|
|
530
|
+
decision.approvers?.length
|
|
531
|
+
? ` ${dim("waits")} ${decision.approvers.join(", ")}`
|
|
532
|
+
: "",
|
|
533
|
+
"",
|
|
534
|
+
` ${dim("considered")}`,
|
|
535
|
+
...decision.considered.map(
|
|
536
|
+
(c) =>
|
|
537
|
+
` ${c.matched ? bold("→") : dim(" ")} ${dim(c.effect.padEnd(7))} ${c.matched ? c.rule : dim(c.rule)}`,
|
|
538
|
+
),
|
|
539
|
+
"",
|
|
540
|
+
]
|
|
541
|
+
.filter(Boolean)
|
|
542
|
+
.join("\n") + "\n",
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
return decision.verdict === "deny" ? 1 : 0;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
case "why": {
|
|
549
|
+
const decisionId = sub;
|
|
550
|
+
if (!decisionId) {
|
|
551
|
+
process.stderr.write(red(" why needs a decision id.\n"));
|
|
552
|
+
return 2;
|
|
553
|
+
}
|
|
554
|
+
const api = await controlPlane(flags);
|
|
555
|
+
const d = await api("GET", `/v1/decisions/${encodeURIComponent(decisionId)}`);
|
|
556
|
+
|
|
557
|
+
if (flags.json) {
|
|
558
|
+
process.stdout.write(JSON.stringify(d, null, 2) + "\n");
|
|
559
|
+
return d.verdict === "deny" ? 1 : 0;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const tone = d.verdict === "permit" ? green : d.verdict === "hold" ? amber : red;
|
|
563
|
+
process.stdout.write(
|
|
564
|
+
[
|
|
565
|
+
"",
|
|
566
|
+
` ${tone(bold(String(d.verdict).toUpperCase()))} ${dim(d.action ?? d.tool ?? "")} ${d.resource ?? ""}`,
|
|
567
|
+
` ${dim("rule")} ${d.rule ?? dim("— no rule matched (default deny)")}`,
|
|
568
|
+
` ${dim("reason")} ${d.reason ?? dim("—")}`,
|
|
569
|
+
` ${dim("agent")} ${d.agent ?? dim("—")}`,
|
|
570
|
+
` ${dim("when")} ${d.ts}`,
|
|
571
|
+
// The whole point of this command in an incident: it hands you the
|
|
572
|
+
// thread to pull, not just the one bead you arrived holding.
|
|
573
|
+
` ${dim("run")} ${d.runId ? blue(d.runId) : dim("— recorded outside a run")}`,
|
|
574
|
+
"",
|
|
575
|
+
...(d.considered?.length
|
|
576
|
+
? [
|
|
577
|
+
` ${dim("considered")}`,
|
|
578
|
+
...d.considered.map(
|
|
579
|
+
(c) =>
|
|
580
|
+
` ${c.matched ? bold("→") : dim(" ")} ${dim(String(c.effect).padEnd(7))} ${c.matched ? c.rule : dim(c.rule)}`,
|
|
581
|
+
),
|
|
582
|
+
"",
|
|
583
|
+
]
|
|
584
|
+
: []),
|
|
585
|
+
d.runId ? ` ${dim(`cirvix replay ${d.runId} --diff`)}` : "",
|
|
586
|
+
"",
|
|
587
|
+
]
|
|
588
|
+
.filter((l) => l !== "")
|
|
589
|
+
.join("\n") + "\n",
|
|
590
|
+
);
|
|
591
|
+
return d.verdict === "deny" ? 1 : 0;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
case "replay": {
|
|
595
|
+
const runId = sub;
|
|
596
|
+
if (!runId) {
|
|
597
|
+
process.stderr.write(red(" replay needs a run id.\n"));
|
|
598
|
+
return 2;
|
|
599
|
+
}
|
|
600
|
+
const api = await controlPlane(flags);
|
|
601
|
+
// No --policy replays against whatever is live, which answers "would
|
|
602
|
+
// today's rules have stopped this" — the question after an incident.
|
|
603
|
+
const rules = flags.policy ? await loadRules(String(flags.policy)) : undefined;
|
|
604
|
+
const result = await api("POST", `/v1/runs/${encodeURIComponent(runId)}/replay`, { rules });
|
|
605
|
+
|
|
606
|
+
if (flags.json) {
|
|
607
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
608
|
+
return result.changed > 0 ? 1 : 0;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const shown = flags.diff ? result.steps.filter((s) => s.changed) : result.steps;
|
|
612
|
+
const t0 = result.steps.length ? new Date(result.steps[0].ts).getTime() : 0;
|
|
613
|
+
const offset = (ts) => ((new Date(ts).getTime() - t0) / 1000).toFixed(3).padStart(6, "0");
|
|
614
|
+
|
|
615
|
+
process.stdout.write(
|
|
616
|
+
[
|
|
617
|
+
"",
|
|
618
|
+
` ${bold(result.runId)} ${dim("agent")} ${result.agent ?? "—"} ${result.calls} calls`,
|
|
619
|
+
"",
|
|
620
|
+
` ${bold("changed decisions")}${" ".repeat(Math.max(1, 34 - 17))}${result.changed} of ${result.replayable}`,
|
|
621
|
+
"",
|
|
622
|
+
...shown.flatMap((s) => {
|
|
623
|
+
if (!s.replayable) {
|
|
624
|
+
return [` ${offset(s.ts)} ${dim(`${s.action ?? ""} ${s.resource ?? ""} — not replayable`)}`];
|
|
625
|
+
}
|
|
626
|
+
const tone = (v) => (v === "permit" ? green : v === "hold" ? amber : red);
|
|
627
|
+
const lines = [` ${offset(s.ts)} ${s.action ?? ""} ${dim(s.resource ?? "")}`];
|
|
628
|
+
if (s.changed) {
|
|
629
|
+
lines.push(` ${dim("was")} ${tone(s.before.verdict)(s.before.verdict.toUpperCase().padEnd(6))} ${s.before.rule ?? "—"}`);
|
|
630
|
+
lines.push(` ${dim("now")} ${tone(s.after.verdict)(s.after.verdict.toUpperCase().padEnd(6))} ${s.after.rule ?? "—"}`);
|
|
631
|
+
if (s.after.reason) lines.push(` ${dim("→")} ${dim(s.after.reason)}`);
|
|
632
|
+
} else {
|
|
633
|
+
lines.push(
|
|
634
|
+
` ${dim("was")} ${tone(s.before.verdict)(s.before.verdict.toUpperCase().padEnd(6))} ${s.before.rule ?? "—"} ${dim("(unchanged)")}`,
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
return lines;
|
|
638
|
+
}),
|
|
639
|
+
"",
|
|
640
|
+
` ${dim("No side effects were executed.")}`,
|
|
641
|
+
` ${dim(result.caveat)}`,
|
|
642
|
+
"",
|
|
643
|
+
].join("\n") + "\n",
|
|
644
|
+
);
|
|
645
|
+
// Non-zero when the policy would have behaved differently, so this works
|
|
646
|
+
// as a gate in CI against a candidate rule set.
|
|
647
|
+
return result.changed > 0 ? 1 : 0;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
case "audit": {
|
|
651
|
+
if (sub !== "verify") {
|
|
652
|
+
process.stderr.write(red(" Only `audit verify` is available.\n"));
|
|
653
|
+
return 2;
|
|
654
|
+
}
|
|
655
|
+
const file = String(flags.file ?? ".cirvix/audit.jsonl");
|
|
656
|
+
const chain = new AuditChain(file);
|
|
657
|
+
const res = await chain.verify();
|
|
658
|
+
if (flags.json) {
|
|
659
|
+
process.stdout.write(JSON.stringify(res, null, 2) + "\n");
|
|
660
|
+
return res.ok ? 0 : 1;
|
|
661
|
+
}
|
|
662
|
+
process.stdout.write(
|
|
663
|
+
res.ok
|
|
664
|
+
? `\n ${green(bold("chain intact"))} ${dim(`${res.records} records verified`)}\n\n ${dim("Verification proves records were not altered after they were written.\n It does not attest to their content.")}\n\n`
|
|
665
|
+
: `\n ${red(bold("chain broken"))} ${dim(`at record ${res.brokenAt} of ${res.records}`)}\n ${res.reason}\n\n`,
|
|
666
|
+
);
|
|
667
|
+
return res.ok ? 0 : 1;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
case "policy": {
|
|
671
|
+
const loaded = await loadPolicy(flags.policy, cwd);
|
|
672
|
+
|
|
673
|
+
switch (sub ?? "list") {
|
|
674
|
+
case "check": {
|
|
675
|
+
if (!loaded.path) {
|
|
676
|
+
process.stderr.write(
|
|
677
|
+
red(" No policy file found. Run `cirvix init`, or pass --policy <file>.\n"),
|
|
678
|
+
);
|
|
679
|
+
return 2;
|
|
680
|
+
}
|
|
681
|
+
const { output, code } = await policyCmd.check({
|
|
682
|
+
path: loaded.path,
|
|
683
|
+
cwd,
|
|
684
|
+
json: Boolean(flags.json),
|
|
685
|
+
strict: Boolean(flags.strict),
|
|
686
|
+
});
|
|
687
|
+
process.stdout.write(output + "\n");
|
|
688
|
+
return code;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
case "test": {
|
|
692
|
+
if (!loaded.path) {
|
|
693
|
+
process.stderr.write(
|
|
694
|
+
red(" No policy file found. Run `cirvix init`, or pass --policy <file>.\n"),
|
|
695
|
+
);
|
|
696
|
+
return 2;
|
|
697
|
+
}
|
|
698
|
+
const { output, code } = await policyCmd.test({
|
|
699
|
+
path: loaded.path,
|
|
700
|
+
cwd,
|
|
701
|
+
json: Boolean(flags.json),
|
|
702
|
+
filter: typeof flags.filter === "string" ? flags.filter : null,
|
|
703
|
+
});
|
|
704
|
+
process.stdout.write(output + "\n");
|
|
705
|
+
return code;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
case "explain": {
|
|
709
|
+
const tool = flags.tool ?? flags.action;
|
|
710
|
+
if (typeof tool !== "string") {
|
|
711
|
+
process.stderr.write(red(" explain needs --tool <name>.\n"));
|
|
712
|
+
return 2;
|
|
713
|
+
}
|
|
714
|
+
const { output, code } = await policyCmd.explain({
|
|
715
|
+
path: loaded.path,
|
|
716
|
+
// The starter set has no file, so explain against the rules directly.
|
|
717
|
+
rules: loaded.path ? null : loaded.rules,
|
|
718
|
+
cwd,
|
|
719
|
+
json: Boolean(flags.json),
|
|
720
|
+
tool,
|
|
721
|
+
args: callArgsFrom(flags),
|
|
722
|
+
agent: String(flags.agent ?? "local"),
|
|
723
|
+
environment: String(flags.env ?? "local"),
|
|
724
|
+
});
|
|
725
|
+
process.stdout.write(output + "\n");
|
|
726
|
+
return code;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
case "list":
|
|
730
|
+
default: {
|
|
731
|
+
const { output, code } = policyCmd.list(loaded.rules, {
|
|
732
|
+
json: Boolean(flags.json),
|
|
733
|
+
source: Boolean(flags.source),
|
|
734
|
+
cwd,
|
|
735
|
+
});
|
|
736
|
+
process.stdout.write(output + "\n");
|
|
737
|
+
return code;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/* ---------------------------------------------------------------- init */
|
|
743
|
+
case "init": {
|
|
744
|
+
const { result, output } = await initCmd({
|
|
745
|
+
cwd,
|
|
746
|
+
json: Boolean(flags.json),
|
|
747
|
+
force: Boolean(flags.force),
|
|
748
|
+
});
|
|
749
|
+
process.stdout.write(output + "\n");
|
|
750
|
+
return result.ok ? 0 : 1;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/* -------------------------------------------------------------- status */
|
|
754
|
+
case "upgrade": {
|
|
755
|
+
// `positional` already has the command at [0]; the rest are the
|
|
756
|
+
// tier and any flags the command parses itself.
|
|
757
|
+
const rest = positional.slice(1);
|
|
758
|
+
if (flags.seats) rest.push("--seats", String(flags.seats));
|
|
759
|
+
await upgradeCmd(rest, { cwd });
|
|
760
|
+
return 0;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
case "status": {
|
|
764
|
+
const rules = await loadRules(flags.policy, cwd);
|
|
765
|
+
const { output } = await statusCmd({
|
|
766
|
+
cwd,
|
|
767
|
+
rules,
|
|
768
|
+
json: Boolean(flags.json),
|
|
769
|
+
stateDir: stateDirFor(flags, cwd),
|
|
770
|
+
});
|
|
771
|
+
process.stdout.write(output + "\n");
|
|
772
|
+
return 0;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/* ---------------------------------------------------------------- demo */
|
|
776
|
+
case "demo": {
|
|
777
|
+
const rules = flags.policy ? await loadRules(flags.policy, cwd) : null;
|
|
778
|
+
const { output } = await demoCmd({
|
|
779
|
+
cwd,
|
|
780
|
+
rules,
|
|
781
|
+
json: Boolean(flags.json),
|
|
782
|
+
stateDir: stateDirFor(flags, cwd),
|
|
783
|
+
// `--fast` for CI and for anyone who has seen it once.
|
|
784
|
+
pace: flags.fast ? 0 : Number(flags.pace ?? 700),
|
|
785
|
+
});
|
|
786
|
+
if (output) process.stdout.write(output + "\n");
|
|
787
|
+
return 0;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/* ---------------------------------------------------------------- logs */
|
|
791
|
+
case "logs": {
|
|
792
|
+
const stateDir = stateDirFor(flags, cwd);
|
|
793
|
+
const file = String(flags.file ?? join(stateDir, "audit.jsonl"));
|
|
794
|
+
const records = await journal.read(file);
|
|
795
|
+
|
|
796
|
+
// `--tree <id>` prints one decision in full rather than the list.
|
|
797
|
+
const treeId = typeof flags.tree === "string" ? flags.tree : sub;
|
|
798
|
+
if (flags.tree || (sub && sub !== "list")) {
|
|
799
|
+
const record = journal.find(records, treeId);
|
|
800
|
+
if (!record) {
|
|
801
|
+
process.stderr.write(red(` No decision with id ${treeId} in ${file}.\n`));
|
|
802
|
+
return 2;
|
|
803
|
+
}
|
|
804
|
+
if (flags.json) {
|
|
805
|
+
process.stdout.write(JSON.stringify(record, null, 2) + "\n");
|
|
806
|
+
return record.decision === DECISION.DENY ? 1 : 0;
|
|
807
|
+
}
|
|
808
|
+
process.stdout.write("\n" + journal.renderTree(record) + "\n\n");
|
|
809
|
+
return record.decision === DECISION.DENY ? 1 : 0;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
const selected = journal.query(records, {
|
|
813
|
+
last: flags.last ? Number(flags.last) : 25,
|
|
814
|
+
risk: typeof flags.risk === "string" ? flags.risk : undefined,
|
|
815
|
+
decision: typeof flags.decision === "string" ? flags.decision : undefined,
|
|
816
|
+
agent: typeof flags.agent === "string" ? flags.agent : undefined,
|
|
817
|
+
tool: typeof flags.tool === "string" ? flags.tool : undefined,
|
|
818
|
+
run: typeof flags.run === "string" ? flags.run : undefined,
|
|
819
|
+
since: typeof flags.since === "string" ? flags.since : undefined,
|
|
820
|
+
deniedOnly: Boolean(flags.denied),
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
if (flags.json) {
|
|
824
|
+
process.stdout.write(JSON.stringify(selected, null, 2) + "\n");
|
|
825
|
+
return 0;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
if (selected.length === 0) {
|
|
829
|
+
process.stdout.write(
|
|
830
|
+
`\n ${dim("no matching decisions")} ${dim(`in ${file}`)}\n\n ${dim("Run `cirvix demo` to produce some, or start the gateway.")}\n\n`,
|
|
831
|
+
);
|
|
832
|
+
return 0;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
const stats = journal.summarize(selected);
|
|
836
|
+
process.stdout.write("\n");
|
|
837
|
+
for (const record of selected) process.stdout.write(journal.renderLine(record) + "\n");
|
|
838
|
+
process.stdout.write("\n");
|
|
839
|
+
process.stdout.write(
|
|
840
|
+
` ${dim(plural(stats.records, "decision"))} ` +
|
|
841
|
+
[
|
|
842
|
+
stats.counts.allow ? green(`${stats.counts.allow} allowed`) : null,
|
|
843
|
+
stats.counts.sanitize ? blue(`${stats.counts.sanitize} sanitized`) : null,
|
|
844
|
+
stats.counts.require_approval ? amber(`${stats.counts.require_approval} held`) : null,
|
|
845
|
+
stats.counts.deny ? red(`${stats.counts.deny} denied`) : null,
|
|
846
|
+
stats.counts.audit_only ? dim(`${stats.counts.audit_only} audit-only`) : null,
|
|
847
|
+
]
|
|
848
|
+
.filter(Boolean)
|
|
849
|
+
.join(dim(" · ")) +
|
|
850
|
+
dim(` P99 ${stats.latency.p99}ms`) +
|
|
851
|
+
"\n\n",
|
|
852
|
+
);
|
|
853
|
+
return 0;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/* ------------------------------------------------------------ approvals */
|
|
857
|
+
case "approvals": {
|
|
858
|
+
const store = await new ApprovalStore(join(stateDirFor(flags, cwd), "approvals.jsonl")).open();
|
|
859
|
+
const pending = flags.all ? store.all() : store.pending();
|
|
860
|
+
|
|
861
|
+
if (flags.json) {
|
|
862
|
+
process.stdout.write(JSON.stringify(pending, null, 2) + "\n");
|
|
863
|
+
return 0;
|
|
864
|
+
}
|
|
865
|
+
if (!pending.length) {
|
|
866
|
+
process.stdout.write(`\n ${dim("nothing waiting on a human")}\n\n`);
|
|
867
|
+
return 0;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
process.stdout.write("\n " + bold(plural(pending.length, "call")) + dim(" waiting\n\n"));
|
|
871
|
+
for (const a of pending) {
|
|
872
|
+
const riskTone = { low: dim, medium: blue, high: amber, critical: red }[a.risk] ?? dim;
|
|
873
|
+
process.stdout.write(
|
|
874
|
+
` ${bold(a.id)} ${riskTone(String(a.risk ?? "").toUpperCase().padEnd(9))}${a.tool ?? "—"} ${dim(a.resource ?? "")}\n`,
|
|
875
|
+
);
|
|
876
|
+
process.stdout.write(` ${dim(a.reason ?? "")}\n`);
|
|
877
|
+
process.stdout.write(
|
|
878
|
+
` ${dim("agent")} ${a.agent ?? "—"} ${dim("rule")} ${a.rule ?? "—"} ${dim("waits on")} ${(a.approvers ?? []).join(", ") || dim("nobody in particular")}\n`,
|
|
879
|
+
);
|
|
880
|
+
if (a.state !== "pending") {
|
|
881
|
+
process.stdout.write(` ${dim("state")} ${a.state}${a.decidedBy ? dim(` by ${a.decidedBy}`) : ""}\n`);
|
|
882
|
+
}
|
|
883
|
+
process.stdout.write("\n");
|
|
884
|
+
}
|
|
885
|
+
process.stdout.write(
|
|
886
|
+
` ${dim("Decide one:")} ${blue(`cirvix approve ${pending[0].id} --by you@example.com`)}\n\n`,
|
|
887
|
+
);
|
|
888
|
+
return 0;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
case "approve":
|
|
892
|
+
case "deny": {
|
|
893
|
+
const id = sub;
|
|
894
|
+
const by = flags.by ?? process.env.CIRVIX_APPROVER;
|
|
895
|
+
if (!id) {
|
|
896
|
+
process.stderr.write(red(` ${command} needs an approval id.\n`));
|
|
897
|
+
return 2;
|
|
898
|
+
}
|
|
899
|
+
if (typeof by !== "string" || !by) {
|
|
900
|
+
// Never defaulted. An approval whose record says "approved by unknown"
|
|
901
|
+
// is not evidence of anything.
|
|
902
|
+
process.stderr.write(
|
|
903
|
+
red(" --by <who> is required. An approval has to name the person accountable for it.\n"),
|
|
904
|
+
);
|
|
905
|
+
return 2;
|
|
906
|
+
}
|
|
907
|
+
const store = await new ApprovalStore(join(stateDirFor(flags, cwd), "approvals.jsonl")).open();
|
|
908
|
+
try {
|
|
909
|
+
const record = await store.decide(
|
|
910
|
+
id,
|
|
911
|
+
command === "approve" ? "approved" : "denied",
|
|
912
|
+
String(by),
|
|
913
|
+
typeof flags.note === "string" ? flags.note : null,
|
|
914
|
+
);
|
|
915
|
+
const tone = command === "approve" ? green : red;
|
|
916
|
+
process.stdout.write(
|
|
917
|
+
`\n ${tone(bold(record.state.toUpperCase()))} ${record.tool ?? "—"} ${dim(record.resource ?? "")}\n ${dim(`by ${by} at ${record.decidedAt}`)}\n\n`,
|
|
918
|
+
);
|
|
919
|
+
return 0;
|
|
920
|
+
} catch (err) {
|
|
921
|
+
process.stderr.write(red(` ${err.message}\n`));
|
|
922
|
+
return 1;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/* --------------------------------------------------------------- vault */
|
|
927
|
+
case "vault": {
|
|
928
|
+
const vault = new Vault();
|
|
929
|
+
const loadedEnv = vault.loadFromEnv({ replaceEnv: false });
|
|
930
|
+
const loadedFile = flags.file ? await vault.loadFromFile(String(flags.file)) : [];
|
|
931
|
+
const all = [...loadedEnv, ...loadedFile];
|
|
932
|
+
|
|
933
|
+
if (flags.json) {
|
|
934
|
+
process.stdout.write(JSON.stringify({ loaded: vault.inventory() }, null, 2) + "\n");
|
|
935
|
+
return 0;
|
|
936
|
+
}
|
|
937
|
+
if (!all.length) {
|
|
938
|
+
process.stdout.write(
|
|
939
|
+
`\n ${dim("nothing to vault")} ${dim("no credential-shaped environment variables in this shell.")}\n\n`,
|
|
940
|
+
);
|
|
941
|
+
return 0;
|
|
942
|
+
}
|
|
943
|
+
process.stdout.write("\n " + bold(plural(all.length, "secret")) + dim(" behind handles\n\n"));
|
|
944
|
+
for (const entry of vault.inventory()) {
|
|
945
|
+
process.stdout.write(
|
|
946
|
+
` ${blue(entry.handle.padEnd(16))} ${entry.name}${entry.destinations.length ? dim(` scoped to ${entry.destinations.join(", ")}`) : amber(" unscoped")}\n`,
|
|
947
|
+
);
|
|
948
|
+
}
|
|
949
|
+
process.stdout.write(
|
|
950
|
+
`\n ${dim("The value is never printed, never written to the audit chain, and never")}\n ${dim("reaches the agent. Pass the handle where you would have passed the key.")}\n\n`,
|
|
951
|
+
);
|
|
952
|
+
return 0;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/* ------------------------------------------------------------- runtime */
|
|
956
|
+
case "runtime": {
|
|
957
|
+
const stateDir = stateDirFor(flags, cwd);
|
|
958
|
+
await mkdir(stateDir, { recursive: true }).catch(() => {});
|
|
959
|
+
|
|
960
|
+
const rules = await loadRules(flags.policy, cwd);
|
|
961
|
+
const chain = await new AuditChain(join(stateDir, "audit.jsonl")).open();
|
|
962
|
+
const approvals = await new ApprovalStore(join(stateDir, "approvals.jsonl")).open();
|
|
963
|
+
const vault = new Vault({ log: (m) => process.stderr.write(`[cirvix] ${m}\n`) });
|
|
964
|
+
if (flags.vault) vault.loadFromEnv();
|
|
965
|
+
|
|
966
|
+
const mode = flags.mode === "audit" ? MODE.AUDIT : MODE.ENFORCE;
|
|
967
|
+
// The commercial gate needs all three, and it silently does nothing
|
|
968
|
+
// without them. This is the long-running enforcement path — if the
|
|
969
|
+
// published Free limits are enforced anywhere, it is here.
|
|
970
|
+
const runtimeLicence = readLicence(cwd);
|
|
971
|
+
const runtimeMeter = new Meter({ cwd });
|
|
972
|
+
const notice = commercialNotices({
|
|
973
|
+
licence: runtimeLicence,
|
|
974
|
+
meter: runtimeMeter,
|
|
975
|
+
write: (s) => process.stderr.write(s),
|
|
976
|
+
});
|
|
977
|
+
const pipeline = new Pipeline({
|
|
978
|
+
rules,
|
|
979
|
+
cwd,
|
|
980
|
+
agent: String(flags.agent ?? "local"),
|
|
981
|
+
environment: String(flags.env ?? "local"),
|
|
982
|
+
mode,
|
|
983
|
+
audit: chain,
|
|
984
|
+
secrets: vault.held ? vault : null,
|
|
985
|
+
approvals,
|
|
986
|
+
licence: runtimeLicence,
|
|
987
|
+
meter: runtimeMeter,
|
|
988
|
+
agents: new AgentRegistry(),
|
|
989
|
+
onEvent: (e) => {
|
|
990
|
+
if (e.kind === "decision") notice(e);
|
|
991
|
+
},
|
|
992
|
+
log: (m) => process.stderr.write(`[cirvix] ${m}\n`),
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
const token = await writeToken(stateDir);
|
|
996
|
+
const endpoint = defaultEndpoint(stateDir);
|
|
997
|
+
const server = new UdsServer({
|
|
998
|
+
pipeline,
|
|
999
|
+
endpoint,
|
|
1000
|
+
token,
|
|
1001
|
+
log: (m) => process.stdout.write(`[cirvix] ${m}\n`),
|
|
1002
|
+
status: () => ({
|
|
1003
|
+
mode: pipeline.mode,
|
|
1004
|
+
rules: pipeline.rules.length,
|
|
1005
|
+
calls: pipeline.stats.calls,
|
|
1006
|
+
denied: pipeline.stats.denied,
|
|
1007
|
+
approvals: approvals.pending().length,
|
|
1008
|
+
latency: pipeline.percentiles(),
|
|
1009
|
+
vault: { held: vault.held, unscoped: vault.inventory().filter((v) => !v.destinations.length).length },
|
|
1010
|
+
}),
|
|
1011
|
+
recent: async ({ limit, risk }) =>
|
|
1012
|
+
journal.query(await journal.read(join(stateDir, "audit.jsonl")), { last: limit, risk }),
|
|
1013
|
+
});
|
|
1014
|
+
await server.start();
|
|
1015
|
+
|
|
1016
|
+
process.stdout.write(
|
|
1017
|
+
`\n ${green(bold("runtime up"))} ${dim(`${plural(rules.length, "rule")} · ${mode} · ${endpoint}`)}\n` +
|
|
1018
|
+
` ${dim(`token in ${join(stateDir, "socket.token")}`)}\n\n`,
|
|
1019
|
+
);
|
|
1020
|
+
if (mode === MODE.AUDIT) {
|
|
1021
|
+
process.stdout.write(
|
|
1022
|
+
` ${amber(bold("AUDIT MODE"))} ${dim("— decisions are recorded and nothing is blocked.")}\n\n`,
|
|
1023
|
+
);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
await new Promise((resolve) => {
|
|
1027
|
+
let closing = false;
|
|
1028
|
+
const shutdown = async () => {
|
|
1029
|
+
if (closing) return;
|
|
1030
|
+
closing = true;
|
|
1031
|
+
vault.forget();
|
|
1032
|
+
await server.stop();
|
|
1033
|
+
resolve();
|
|
1034
|
+
};
|
|
1035
|
+
process.on("SIGINT", () => void shutdown());
|
|
1036
|
+
process.on("SIGTERM", () => void shutdown());
|
|
1037
|
+
});
|
|
1038
|
+
return 0;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
case "help":
|
|
1042
|
+
default:
|
|
1043
|
+
process.stdout.write(HELP + "\n");
|
|
1044
|
+
return command === "help" ? 0 : 2;
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
function isInsideWorkspace(cwd, resource) {
|
|
1049
|
+
// Kept local to the CLI: the engine takes this as context so it stays pure.
|
|
1050
|
+
const path = new URL(`file://${process.platform === "win32" ? "/" : ""}`);
|
|
1051
|
+
void path;
|
|
1052
|
+
const resolved = resource.startsWith("/") || /^[A-Za-z]:/.test(resource)
|
|
1053
|
+
? resource
|
|
1054
|
+
: `${cwd}/${resource}`;
|
|
1055
|
+
const norm = (s) => s.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
|
|
1056
|
+
const a = norm(cwd);
|
|
1057
|
+
const b = norm(resolved.replace(/\/\.\//g, "/"));
|
|
1058
|
+
// Collapse traversal before comparing — the whole point of the check.
|
|
1059
|
+
const parts = [];
|
|
1060
|
+
for (const seg of b.split("/")) {
|
|
1061
|
+
if (seg === "..") parts.pop();
|
|
1062
|
+
else if (seg !== ".") parts.push(seg);
|
|
1063
|
+
}
|
|
1064
|
+
const flat = parts.join("/");
|
|
1065
|
+
return flat === a || flat.startsWith(a + "/");
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
main()
|
|
1069
|
+
.then((code) => process.exit(code))
|
|
1070
|
+
.catch((err) => {
|
|
1071
|
+
process.stderr.write(`\n ${red("error")} ${err.message}\n\n`);
|
|
1072
|
+
process.exit(2);
|
|
1073
|
+
});
|