@cirvix_ai/agent-control 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +76 -17
  2. package/bin/cirvix.mjs +539 -85
  3. package/bin/escape-benchmark.mjs +67 -0
  4. package/package.json +36 -16
  5. package/src/adapters/base.mjs +150 -0
  6. package/src/adapters/claude-code.mjs +161 -0
  7. package/src/adapters/cline.mjs +107 -0
  8. package/src/adapters/codex.mjs +104 -0
  9. package/src/adapters/cursor.mjs +104 -0
  10. package/src/adapters/frameworks.mjs +110 -0
  11. package/src/adapters/gemini-cli.mjs +104 -0
  12. package/src/adapters/generic-mcp.mjs +101 -0
  13. package/src/adapters/index.mjs +209 -0
  14. package/src/adapters/roo-code.mjs +106 -0
  15. package/src/adapters/vscode.mjs +104 -0
  16. package/src/adapters/windsurf.mjs +107 -0
  17. package/src/commands/console.mjs +58 -0
  18. package/src/commands/demo.mjs +55 -124
  19. package/src/commands/doctor.mjs +235 -0
  20. package/src/commands/init.mjs +292 -30
  21. package/src/commands/interactive.mjs +690 -0
  22. package/src/commands/kill.mjs +74 -0
  23. package/src/commands/login.mjs +227 -0
  24. package/src/commands/onboard.mjs +52 -0
  25. package/src/commands/passport.mjs +149 -0
  26. package/src/commands/policy.mjs +10 -6
  27. package/src/commands/protect.mjs +293 -0
  28. package/src/commands/prove.mjs +209 -0
  29. package/src/commands/redteam.mjs +51 -0
  30. package/src/commands/scan.mjs +11 -9
  31. package/src/commands/shadow.mjs +62 -0
  32. package/src/commands/simulate.mjs +96 -0
  33. package/src/commands/status.mjs +122 -41
  34. package/src/commands/upgrade.mjs +11 -11
  35. package/src/commands/welcome.mjs +105 -0
  36. package/src/core/authority.mjs +909 -0
  37. package/src/core/baseline.mjs +97 -0
  38. package/src/core/config-store.mjs +280 -0
  39. package/src/core/cost.mjs +0 -0
  40. package/src/core/detect.mjs +4 -33
  41. package/src/core/entitlements.mjs +7 -24
  42. package/src/core/escape-benchmark.mjs +597 -0
  43. package/src/core/events.mjs +234 -0
  44. package/src/core/evidence.mjs +212 -0
  45. package/src/core/format.mjs +44 -18
  46. package/src/core/gateway.mjs +15 -211
  47. package/src/core/graph.mjs +270 -0
  48. package/src/core/guard.mjs +118 -4
  49. package/src/core/intent.mjs +166 -0
  50. package/src/core/journal.mjs +131 -40
  51. package/src/core/kill-switch.mjs +122 -0
  52. package/src/core/notices.mjs +22 -2
  53. package/src/core/packs.mjs +193 -0
  54. package/src/core/passport.mjs +555 -0
  55. package/src/core/pipeline.mjs +148 -6
  56. package/src/core/prompts.mjs +51 -0
  57. package/src/core/proof.mjs +440 -0
  58. package/src/core/redteam/index.mjs +185 -0
  59. package/src/core/referral.mjs +187 -0
  60. package/src/core/sandbox.mjs +139 -0
  61. package/src/core/session.mjs +172 -0
  62. package/src/core/shadow.mjs +95 -0
  63. package/src/core/theme.mjs +240 -0
  64. package/src/core/trifecta.mjs +321 -0
  65. package/src/core/ui/controller.mjs +192 -0
  66. package/src/core/ui/decisions.mjs +55 -0
  67. package/src/core/ui/index.mjs +49 -0
  68. package/src/core/ui/intercept.mjs +103 -0
  69. package/src/core/ui/live.mjs +51 -0
  70. package/src/core/ui/primitives.mjs +123 -0
  71. package/src/core/ui/theme.mjs +92 -0
  72. package/src/core/verified.mjs +108 -0
  73. package/src/core/windows.mjs +270 -0
  74. package/src/index.mjs +67 -0
  75. package/src/tui/activity.mjs +71 -0
  76. package/src/tui/app.mjs +292 -0
  77. package/src/tui/cards.mjs +235 -0
  78. package/src/tui/composer.mjs +88 -0
  79. package/src/tui/palette.mjs +48 -0
  80. package/src/tui/status.mjs +42 -0
  81. package/src/core/cinematic.mjs +0 -545
@@ -42,17 +42,6 @@
42
42
  * the only point in the process where a credential exists inside a request,
43
43
  * and it sits downstream of the decision that authorized it. See
44
44
  * `./secrets.mjs`.
45
- *
46
- * WHAT THE GATEWAY DOES NOT GOVERN.
47
- *
48
- * The gateway governs traffic actually routed through it — every `tools/call`,
49
- * `resources/read`, `resources/subscribe`, `prompts/get`, `completion/complete`,
50
- * and unmodeled method crossing this process is evaluated before anything
51
- * executes, and unknown methods are default-denied. What never enters this
52
- * process is never evaluated: a direct MCP server entry in the agent's config,
53
- * the runtime's built-in tools, a subprocess the agent spawns, a socket the
54
- * agent opens itself. Those are routes around the boundary, not through it,
55
- * and no userspace gateway can interpose on them.
56
45
  */
57
46
 
58
47
  import { spawn } from "node:child_process";
@@ -66,6 +55,7 @@ const GATEWAY_VERSION = JSON.parse(
66
55
 
67
56
  import { Guard, actionForTool, destinationFor, resourceForCall } from "./guard.mjs";
68
57
  import { HttpUpstream } from "./http-transport.mjs";
58
+ import { prepareSpawn, killProcessTree } from "./windows.mjs";
69
59
  import { DECISION } from "./decisions.mjs";
70
60
  import {
71
61
  ERROR_CODE,
@@ -137,28 +127,12 @@ class Upstream {
137
127
 
138
128
  start() {
139
129
  const { command, args = [], env = {} } = this.spec;
140
- // Windows: Node >= 18.20 throws EINVAL when spawning .cmd/.bat shims
141
- // (npm, npx, pnpm) without a shell (CVE-2024-27980 mitigation). MCP configs
142
- // name such shims constantly. Going through the shell only for shims keeps
143
- // POSIX behaviour unchanged; with shell:true Node joins argv verbatim, so
144
- // every argument is quoted here to survive spaces and metacharacters.
145
- const isWindowsShim =
146
- process.platform === "win32" && /\.(cmd|bat)$/i.test(command);
147
- const quoted = args.map((a) =>
148
- /[\s"^&|<>]/.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a,
149
- );
150
- this.proc = isWindowsShim
151
- ? spawn([command, ...quoted].join(" "), {
152
- stdio: ["pipe", "pipe", "pipe"],
153
- env: { ...process.env, ...env },
154
- shell: true,
155
- windowsHide: true,
156
- })
157
- : spawn(command, args, {
158
- stdio: ["pipe", "pipe", "pipe"],
159
- env: { ...process.env, ...env },
160
- shell: false,
161
- });
130
+ const prepared = prepareSpawn(command, args, {
131
+ stdio: ["pipe", "pipe", "pipe"],
132
+ env: { ...process.env, ...env },
133
+ shell: false,
134
+ });
135
+ this.proc = spawn(prepared.command, prepared.args, prepared.options);
162
136
 
163
137
  this.alive = true;
164
138
 
@@ -235,7 +209,7 @@ class Upstream {
235
209
  stop() {
236
210
  this.alive = false;
237
211
  try {
238
- this.proc?.kill();
212
+ if (this.proc) killProcessTree(this.proc);
239
213
  } catch {
240
214
  /* already gone */
241
215
  }
@@ -476,15 +450,10 @@ export class Gateway {
476
450
  /* ---------------------------------------------------------------------- */
477
451
 
478
452
  async handleClientMessage(message) {
479
- // Notifications carry no id and expect no answer, but they still reach
480
- // upstream processes — so they still cross the boundary. A small set of
481
- // lifecycle notifications is benign plumbing; anything else is evaluated
482
- // like any other call, and dropped unless permitted. Previously every
483
- // notification was broadcast to ALL upstreams unevaluated, so a
484
- // tools/call-shaped action framed as a notification bypassed the engine
485
- // entirely, with no decision and no audit record.
453
+ // Notifications are forwarded to every upstream and never answered.
486
454
  if (message.id === undefined && message.method) {
487
- return this.#handleNotification(message);
455
+ for (const up of this.upstreams.values()) up.send(message);
456
+ return;
488
457
  }
489
458
 
490
459
  switch (message.method) {
@@ -541,173 +510,11 @@ export class Gateway {
541
510
  this.write({ jsonrpc: "2.0", id: message.id, result: {} });
542
511
  return;
543
512
 
544
- /*
545
- * `prompts/get` returns server-authored text that enters the model's
546
- * context with instruction-level authority — the same reason tool
547
- * definitions are pinned. It was falling through to the default branch
548
- * and reaching the agent with no rule consulted and no decision
549
- * recorded. It is now evaluated as a read of the named prompt.
550
- */
551
- case "prompts/get":
552
- return this.#handlePromptsGet(message);
553
-
554
- /*
555
- * `completion/complete` asks an upstream to complete an argument value.
556
- * Low-risk content, but still upstream-influenced text entering the
557
- * agent loop — evaluated, then forwarded on permit.
558
- */
559
- case "completion/complete":
560
- return this.#handleCompletion(message);
561
-
562
- /*
563
- * `logging/setLevel` carries no content and executes nothing: it asks
564
- * upstreams to adjust log verbosity. Forwarded as benign plumbing, and
565
- * reported on the protocol sink (not the decision sink) so it can never
566
- * be mistaken for a policy decision.
567
- */
568
- case "logging/setLevel":
569
- this.onDecision({ kind: "protocol", method: message.method, action: "forward" });
570
- this.log(`forward ${message.method} (benign protocol plumbing)`);
571
- return this.#forwardToAny(message);
572
-
573
513
  default:
574
- // Default-deny applies to methods, not just tools. Anything the
575
- // gateway does not model is evaluated as `mcp.<method>` against the
576
- // active policy and forwarded only on an explicit permit — previously
577
- // this branch forwarded to the first live upstream unevaluated and
578
- // unrecorded, which made every unmodeled method a full bypass.
579
- return this.#rejectUnknown(message);
580
- }
581
- }
582
-
583
- /* Allowlisted lifecycle notifications: session plumbing with no content and
584
- * no upstream side effect beyond what the protocol requires. Reported on
585
- * the protocol sink, never the decision sink. */
586
- static #BENIGN_NOTIFICATIONS = new Set([
587
- "notifications/initialized",
588
- "notifications/cancelled",
589
- "notifications/progress",
590
- ]);
591
-
592
- async #handleNotification(message) {
593
- if (Gateway.#BENIGN_NOTIFICATIONS.has(message.method)) {
594
- this.onDecision({ kind: "protocol", method: message.method, action: "forward" });
595
- for (const up of this.upstreams.values()) up.send(message);
596
- return;
597
- }
598
- const { decision } = await this.guard.authorize({
599
- tool: `mcp.notification.${message.method}`,
600
- server: null,
601
- args: message.params ?? {},
602
- ...callerIdentity(message.params),
603
- });
604
- this.stats = this.guard.stats;
605
- if (decision.verdict !== "permit") {
606
- this.log(`DROP notification ${message.method} (${decision.rule ?? "default-deny"})`);
607
- return;
608
- }
609
- this.onDecision({ kind: "protocol", method: message.method, action: "forward", decision: decision.decisionId });
610
- for (const up of this.upstreams.values()) up.send(message);
611
- }
612
-
613
- async #handlePromptsGet(message) {
614
- const fullName = message.params?.name ?? "";
615
- const sep = fullName.indexOf(NS);
616
- const server = sep === -1 ? null : fullName.slice(0, sep);
617
- const promptName = sep === -1 ? fullName : fullName.slice(sep + NS.length);
618
- const up = server ? this.upstreams.get(server) : null;
619
-
620
- if (!up || !up.alive) {
621
- this.write(
622
- errorResponse(
623
- message.id,
624
- ERROR_CODE.UPSTREAM_UNAVAILABLE,
625
- `No registered server for prompt "${fullName}".`,
626
- ),
627
- );
628
- return;
629
- }
630
-
631
- const { agent: callerAgent, delegation } = callerIdentity(message.params);
632
- const { decision } = await this.guard.authorize({
633
- tool: "prompts.get",
634
- server,
635
- args: { name: promptName, ...(message.params?.arguments ?? {}) },
636
- agent: callerAgent,
637
- delegation,
638
- });
639
- this.stats = this.guard.stats;
640
-
641
- if (decision.verdict === "deny") {
642
- this.log(`DENY prompts/get ${promptName} (${decision.rule})`);
643
- this.write(deniedToolResult(message.id, decision));
644
- return;
645
- }
646
- if (decision.verdict === "hold") {
647
- decision.approvalId = `apr_${String(decision.decisionId).slice(4, 12)}`;
648
- this.log(`HOLD prompts/get ${promptName} (${decision.rule})`);
649
- this.write(heldToolResult(message.id, decision));
650
- return;
651
- }
652
-
653
- const gatewayId = `gw-${this.nextGatewayId++}`;
654
- this.inflight.set(gatewayId, { clientId: message.id, upstream: up, decision });
655
- up.send({
656
- jsonrpc: "2.0",
657
- id: gatewayId,
658
- method: "prompts/get",
659
- params: { ...message.params, name: promptName },
660
- });
661
- }
662
-
663
- async #handleCompletion(message) {
664
- const ref = message.params?.ref ?? {};
665
- const target = typeof ref.name === "string" && ref.name
666
- ? ref.name
667
- : typeof ref.uri === "string" ? ref.uri : "";
668
- const { agent: callerAgent, delegation } = callerIdentity(message.params);
669
- const { decision } = await this.guard.authorize({
670
- tool: "completion.complete",
671
- server: null,
672
- args: { ref: target, argument: message.params?.argument ?? {} },
673
- agent: callerAgent,
674
- delegation,
675
- });
676
- this.stats = this.guard.stats;
677
-
678
- if (decision.verdict === "deny") {
679
- this.log(`DENY completion/complete ${target} (${decision.rule})`);
680
- this.write(deniedToolResult(message.id, decision));
681
- return;
682
- }
683
- if (decision.verdict === "hold") {
684
- decision.approvalId = `apr_${String(decision.decisionId).slice(4, 12)}`;
685
- this.log(`HOLD completion/complete ${target} (${decision.rule})`);
686
- this.write(heldToolResult(message.id, decision));
687
- return;
688
- }
689
- return this.#forwardToAny(message);
690
- }
691
-
692
- async #rejectUnknown(message) {
693
- const method = message.method ?? "(missing)";
694
- const { agent: callerAgent, delegation } = callerIdentity(message.params);
695
- const { decision } = await this.guard.authorize({
696
- tool: `mcp.${method}`,
697
- server: null,
698
- args: message.params ?? {},
699
- agent: callerAgent,
700
- delegation,
701
- });
702
- this.stats = this.guard.stats;
703
-
704
- if (decision.verdict !== "permit") {
705
- this.log(`DENY ${method} (${decision.rule ?? "default-deny"}) — unmodeled method, no explicit permit`);
706
- this.write(deniedToolResult(message.id, decision));
707
- return;
514
+ // Anything else is broadcast to the first live upstream. The gateway
515
+ // deliberately does not invent behaviour for methods it doesn't model.
516
+ return this.#forwardToAny(message);
708
517
  }
709
- this.log(`PERMIT ${method} (${decision.rule}) — explicitly permitted unmodeled method`);
710
- return this.#forwardToAny(message);
711
518
  }
712
519
 
713
520
  #handleInitialize(message) {
@@ -853,11 +660,8 @@ export class Gateway {
853
660
  }
854
661
 
855
662
  // Unsubscribing is always permitted: refusing to let an agent stop
856
- // receiving something is not a security property. Reported on the
857
- // protocol sink so the forward is visible without fabricating a policy
858
- // decision that never happened.
663
+ // receiving something is not a security property.
859
664
  if (message.method === "resources/unsubscribe") {
860
- this.onDecision({ kind: "protocol", method: message.method, action: "forward" });
861
665
  const gatewayId = `gw-${this.nextGatewayId++}`;
862
666
  this.inflight.set(gatewayId, { clientId: message.id, upstream: up });
863
667
  up.send({ jsonrpc: "2.0", id: gatewayId, method: message.method, params: { ...message.params, uri } });
@@ -0,0 +1,270 @@
1
+ /**
2
+ * The multi-agent relationship graph.
3
+ *
4
+ * delegation.mjs already records every edge — each grant is "issuer delegated
5
+ * this scope to subject" — but only ever answers one question: may THIS call
6
+ * proceed. That is the right question at execution time and the wrong one
7
+ * afterwards, when somebody is trying to find out how an agent nobody
8
+ * provisioned for production ended up able to reach it.
9
+ *
10
+ * This module answers the second kind of question. It reads the broker's
11
+ * inventory and nothing else, so it cannot disagree with enforcement about who
12
+ * delegated what — there is one source of edges.
13
+ *
14
+ * TENANCY IS A CONSTRUCTOR ARGUMENT, NOT A FILTER YOU REMEMBER TO APPLY
15
+ * --------------------------------------------------------------------
16
+ * buildGraph() takes the tenant and drops everything else before any query
17
+ * runs. A graph is exactly the shape of an answer to "what can reach
18
+ * production", so a traversal that wanders into another tenant's edges does
19
+ * not leak a row — it leaks the topology of someone else's estate, which is
20
+ * worse and harder to notice. Filtering at construction means no query can
21
+ * forget to do it, and the tests assert a cross-tenant edge is absent from the
22
+ * graph rather than merely excluded from the result.
23
+ *
24
+ * NOT A GRAPH DATABASE
25
+ * --------------------
26
+ * Adjacency maps over an array that is already in memory. These estates are
27
+ * tens to low thousands of edges; a traversal over that is microseconds, and
28
+ * introducing a graph store would add an operational dependency to answer
29
+ * questions a Map already answers.
30
+ */
31
+
32
+ import { scopePermits, intersectScopes } from "./delegation.mjs";
33
+
34
+ /**
35
+ * Builds a directed graph from a broker inventory.
36
+ *
37
+ * Revoked and expired grants are excluded by default: an edge that cannot
38
+ * authorise anything is not a path, and including it would make every query
39
+ * over-report. `includeInactive` keeps them for forensics, where "who COULD
40
+ * have reached this last Tuesday" is the actual question.
41
+ */
42
+ export function buildGraph(inventory = [], { tenant = undefined, includeInactive = false, now = Date.now() } = {}) {
43
+ const scoped = inventory.filter((g) => {
44
+ if (tenant !== undefined && g.tenant !== tenant) return false;
45
+ if (includeInactive) return true;
46
+ if (g.revoked) return false;
47
+ if (g.expiresAt && Date.parse(g.expiresAt) <= now) return false;
48
+ return true;
49
+ });
50
+
51
+ const nodes = new Map();
52
+ const node = (id) => {
53
+ if (!id) return null;
54
+ if (!nodes.has(id)) nodes.set(id, { id, tenant: tenant === undefined ? null : tenant, roots: 0, out: [], in: [] });
55
+ return nodes.get(id);
56
+ };
57
+
58
+ const edges = [];
59
+ for (const g of scoped) {
60
+ const subject = node(g.subject);
61
+ if (!subject) continue;
62
+ if (g.issuer === null) {
63
+ // A root grant is authority the platform handed the agent directly. It
64
+ // is a property of the node, not an edge from nobody.
65
+ subject.roots += 1;
66
+ subject.rootScope = subject.rootScope ? intersectScopes(subject.rootScope, g.scope) : g.scope;
67
+ continue;
68
+ }
69
+ const issuer = node(g.issuer);
70
+ const edge = {
71
+ id: g.id, from: g.issuer, to: g.subject, scope: g.scope,
72
+ depth: g.depth, revoked: Boolean(g.revoked), expiresAt: g.expiresAt ?? null,
73
+ };
74
+ edges.push(edge);
75
+ issuer.out.push(edge);
76
+ subject.in.push(edge);
77
+ }
78
+
79
+ return { tenant: tenant === undefined ? null : tenant, nodes, edges };
80
+ }
81
+
82
+ /**
83
+ * Everything `from` can reach, and by which path.
84
+ *
85
+ * Breadth-first so the first path found to a node is the shortest one, which
86
+ * is the path a reader wants to see. Scope is intersected along the way: an
87
+ * agent two delegations deep holds the intersection of both, never the union,
88
+ * which is the same rule enforcement applies.
89
+ */
90
+ export function reach(graph, from, { maxDepth = 8 } = {}) {
91
+ const start = graph.nodes.get(from);
92
+ if (!start) return [];
93
+
94
+ const seen = new Set([from]);
95
+ const out = [];
96
+ let frontier = [{ id: from, path: [], scope: start.rootScope ?? null }];
97
+
98
+ for (let depth = 0; depth < maxDepth && frontier.length; depth++) {
99
+ const next = [];
100
+ for (const cur of frontier) {
101
+ const node = graph.nodes.get(cur.id);
102
+ if (!node) continue;
103
+ for (const edge of node.out) {
104
+ if (seen.has(edge.to)) continue;
105
+ seen.add(edge.to);
106
+ const scope = cur.scope ? intersectScopes(cur.scope, edge.scope) : edge.scope;
107
+ const entry = { agent: edge.to, depth: depth + 1, path: [...cur.path, edge.id], via: [...cur.path, edge.id].length, scope };
108
+ out.push(entry);
109
+ next.push({ id: edge.to, path: entry.path, scope });
110
+ }
111
+ }
112
+ frontier = next;
113
+ }
114
+ return out;
115
+ }
116
+
117
+ /**
118
+ * The scope actually held at the end of a specific path.
119
+ *
120
+ * Intersected edge by edge, starting from the origin's root authority, which
121
+ * is the same narrowing enforcement applies.
122
+ */
123
+ export function scopeAlong(graph, origin, path) {
124
+ let scope = graph.nodes.get(origin)?.rootScope ?? null;
125
+ for (const edgeId of path) {
126
+ const edge = graph.edges.find((e) => e.id === edgeId);
127
+ if (!edge) return null;
128
+ scope = scope ? intersectScopes(scope, edge.scope) : edge.scope;
129
+ }
130
+ return scope;
131
+ }
132
+
133
+ /**
134
+ * Which agents can reach this action/resource, directly or by delegation.
135
+ *
136
+ * WHY THIS ENUMERATES EVERY PATH RATHER THAN USING reach()
137
+ * -------------------------------------------------------
138
+ * reach() is breadth-first with one `seen` set, so an agent is claimed by
139
+ * whichever path arrives first — the SHORTEST one. For "how do I get there"
140
+ * that is the right answer. For "can this agent reach production" it is
141
+ * actively wrong, because the shortest path is not the most permissive one.
142
+ *
143
+ * The estate in the tests has exactly this shape: deployer is reachable at
144
+ * depth 2 through researcher (fs.read only) and through coder (which carries
145
+ * deploy.production). Breadth-first found the researcher path first, computed
146
+ * a scope without deploy.production, and concluded deployer could not reach
147
+ * production. It can. A security query that under-reports is worse than one
148
+ * that is slow, so this walks every path and asks whether ANY of them still
149
+ * permits the call after all its narrowings.
150
+ */
151
+ export function whoCanReach(graph, { action, resource }) {
152
+ const hits = [];
153
+ for (const [id, node] of graph.nodes) {
154
+ if (node.rootScope && scopePermits(node.rootScope, { action, resource })) {
155
+ hits.push({ agent: id, via: "root", depth: 0, path: [] });
156
+ continue;
157
+ }
158
+ let best = null;
159
+ for (const [origin, originNode] of graph.nodes) {
160
+ if (origin === id || !originNode.rootScope) continue;
161
+ for (const path of paths(graph, origin, id)) {
162
+ const scope = scopeAlong(graph, origin, path);
163
+ if (!scope || !scopePermits(scope, { action, resource })) continue;
164
+ /* Report the shortest permitting path, not merely the first found —
165
+ it is the one someone has to go and revoke. */
166
+ if (!best || path.length < best.path.length) best = { agent: id, via: origin, depth: path.length, path };
167
+ }
168
+ }
169
+ if (best) hits.push(best);
170
+ }
171
+ return hits;
172
+ }
173
+
174
+ /**
175
+ * Delegation paths from `from` to `to`.
176
+ *
177
+ * All of them, not the shortest. Two routes to the same authority is exactly
178
+ * the finding worth surfacing — revoking one and believing the path is closed
179
+ * is how an estate keeps a capability nobody thinks it has.
180
+ */
181
+ export function paths(graph, from, to, { maxDepth = 8 } = {}) {
182
+ const found = [];
183
+ const walk = (cur, trail, visited) => {
184
+ if (trail.length > maxDepth) return;
185
+ if (cur === to && trail.length) { found.push([...trail]); return; }
186
+ const node = graph.nodes.get(cur);
187
+ if (!node) return;
188
+ for (const edge of node.out) {
189
+ if (visited.has(edge.to)) continue; // no cycles
190
+ visited.add(edge.to);
191
+ walk(edge.to, [...trail, edge.id], visited);
192
+ visited.delete(edge.to);
193
+ }
194
+ };
195
+ walk(from, [], new Set([from]));
196
+ return found;
197
+ }
198
+
199
+ /**
200
+ * Agents that share a capability, grouped by the capability.
201
+ *
202
+ * "Which agents share a sensitive capability" — the shared-credential
203
+ * question. Only capabilities held by more than one agent are returned;
204
+ * a capability with a single holder is not a sharing risk and would bury
205
+ * the ones that are.
206
+ */
207
+ export function sharedCapability(graph) {
208
+ const holders = new Map();
209
+ const note = (cap, agent) => {
210
+ if (!holders.has(cap)) holders.set(cap, new Set());
211
+ holders.get(cap).add(agent);
212
+ };
213
+ /* A scope is {actions, resources}, NOT a flat list of capability strings.
214
+ Iterating the scope object directly yielded nothing and, worse, passing a
215
+ flat array anywhere near normalizeScope() turns it into ["*"] on BOTH
216
+ axes — absent means unconstrained — so an array-shaped scope reads as
217
+ unlimited authority. Actions are the axis that names a capability. */
218
+ const actionsOf = (scope) => (Array.isArray(scope?.actions) ? scope.actions : []);
219
+ for (const [id, node] of graph.nodes) {
220
+ for (const cap of actionsOf(node.rootScope)) note(String(cap), id);
221
+ }
222
+ for (const e of graph.edges) for (const cap of actionsOf(e.scope)) note(String(cap), e.to);
223
+
224
+ return [...holders.entries()]
225
+ .filter(([, set]) => set.size > 1)
226
+ .map(([capability, set]) => ({ capability, agents: [...set].sort() }))
227
+ .sort((a, b) => b.agents.length - a.agents.length || a.capability.localeCompare(b.capability));
228
+ }
229
+
230
+ /** Who delegated authority to this agent, nearest first. */
231
+ export function delegatedBy(graph, agent) {
232
+ const node = graph.nodes.get(agent);
233
+ if (!node) return [];
234
+ return node.in.map((e) => ({ from: e.from, grant: e.id, scope: e.scope, depth: e.depth }));
235
+ }
236
+
237
+ /**
238
+ * Graphviz export, for the CLI and for anyone who wants a picture.
239
+ *
240
+ * Machine-readable is `summary()`; this is the human one. Root authority is
241
+ * drawn as a node attribute rather than an edge from a phantom node, because
242
+ * inventing a "platform" node would put something in the picture that does not
243
+ * exist in the model.
244
+ */
245
+ export function toDot(graph, { title = "cirvix agents" } = {}) {
246
+ const esc = (s) => String(s).replace(/"/g, '\\"');
247
+ const lines = [`digraph "${esc(title)}" {`, " rankdir=LR;", ' node [shape=box, style=rounded, fontname="monospace"];'];
248
+ for (const [id, node] of graph.nodes) {
249
+ const rooted = node.roots > 0;
250
+ lines.push(` "${esc(id)}" [label="${esc(id)}${rooted ? "\\n(root authority)" : ""}"${rooted ? ', peripheries=2' : ""}];`);
251
+ }
252
+ for (const e of graph.edges) {
253
+ const label = (e.scope?.actions ?? []).join(", ");
254
+ lines.push(` "${esc(e.from)}" -> "${esc(e.to)}" [label="${esc(label)}"${e.revoked ? ", style=dashed" : ""}];`);
255
+ }
256
+ lines.push("}");
257
+ return lines.join("\n");
258
+ }
259
+
260
+ /** A compact machine-readable view for the API and the dashboard. */
261
+ export function summary(graph) {
262
+ return {
263
+ tenant: graph.tenant,
264
+ agents: [...graph.nodes.values()].map((n) => ({
265
+ id: n.id, rootAuthority: n.roots > 0, delegationsIn: n.in.length, delegationsOut: n.out.length,
266
+ })),
267
+ edges: graph.edges.map((e) => ({ from: e.from, to: e.to, grant: e.id, scope: e.scope, revoked: e.revoked })),
268
+ shared: sharedCapability(graph),
269
+ };
270
+ }