@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.
Files changed (45) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +42 -0
  3. package/README.md +341 -0
  4. package/action/README.md +100 -0
  5. package/action/action.yml +134 -0
  6. package/action/report.mjs +144 -0
  7. package/bin/cirvix.mjs +1073 -0
  8. package/package.json +60 -0
  9. package/src/commands/demo.mjs +315 -0
  10. package/src/commands/init.mjs +558 -0
  11. package/src/commands/policy.mjs +345 -0
  12. package/src/commands/sarif.mjs +176 -0
  13. package/src/commands/scan.mjs +210 -0
  14. package/src/commands/status.mjs +208 -0
  15. package/src/commands/upgrade.mjs +162 -0
  16. package/src/core/approvals.mjs +388 -0
  17. package/src/core/audit.mjs +181 -0
  18. package/src/core/canonical.mjs +316 -0
  19. package/src/core/daemon.mjs +352 -0
  20. package/src/core/decisions.mjs +253 -0
  21. package/src/core/delegation.mjs +658 -0
  22. package/src/core/detect.mjs +337 -0
  23. package/src/core/entitlement-gate.mjs +100 -0
  24. package/src/core/entitlements.mjs +285 -0
  25. package/src/core/format.mjs +33 -0
  26. package/src/core/gateway.mjs +959 -0
  27. package/src/core/guard.mjs +568 -0
  28. package/src/core/http-transport.mjs +505 -0
  29. package/src/core/journal.mjs +419 -0
  30. package/src/core/jsonrpc.mjs +152 -0
  31. package/src/core/meter.mjs +225 -0
  32. package/src/core/normalize.mjs +516 -0
  33. package/src/core/notices.mjs +80 -0
  34. package/src/core/pipeline.mjs +629 -0
  35. package/src/core/policy-dsl.mjs +611 -0
  36. package/src/core/policy.mjs +710 -0
  37. package/src/core/prompts.mjs +146 -0
  38. package/src/core/risk.mjs +509 -0
  39. package/src/core/sanitize.mjs +279 -0
  40. package/src/core/secret-detect.mjs +533 -0
  41. package/src/core/secrets.mjs +312 -0
  42. package/src/core/uds.mjs +383 -0
  43. package/src/core/vault.mjs +530 -0
  44. package/src/index.mjs +143 -0
  45. 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
+ });