@cirvix_ai/agent-control 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +76 -17
- package/bin/cirvix.mjs +539 -85
- package/bin/escape-benchmark.mjs +67 -0
- package/package.json +36 -16
- package/src/adapters/base.mjs +150 -0
- package/src/adapters/claude-code.mjs +161 -0
- package/src/adapters/cline.mjs +107 -0
- package/src/adapters/codex.mjs +104 -0
- package/src/adapters/cursor.mjs +104 -0
- package/src/adapters/frameworks.mjs +110 -0
- package/src/adapters/gemini-cli.mjs +104 -0
- package/src/adapters/generic-mcp.mjs +101 -0
- package/src/adapters/index.mjs +209 -0
- package/src/adapters/roo-code.mjs +106 -0
- package/src/adapters/vscode.mjs +104 -0
- package/src/adapters/windsurf.mjs +107 -0
- package/src/commands/console.mjs +58 -0
- package/src/commands/demo.mjs +55 -124
- package/src/commands/doctor.mjs +235 -0
- package/src/commands/init.mjs +292 -30
- package/src/commands/interactive.mjs +690 -0
- package/src/commands/kill.mjs +74 -0
- package/src/commands/login.mjs +227 -0
- package/src/commands/onboard.mjs +52 -0
- package/src/commands/passport.mjs +149 -0
- package/src/commands/policy.mjs +10 -6
- package/src/commands/protect.mjs +293 -0
- package/src/commands/prove.mjs +209 -0
- package/src/commands/redteam.mjs +51 -0
- package/src/commands/scan.mjs +11 -9
- package/src/commands/shadow.mjs +62 -0
- package/src/commands/simulate.mjs +96 -0
- package/src/commands/status.mjs +122 -41
- package/src/commands/upgrade.mjs +11 -11
- package/src/commands/welcome.mjs +105 -0
- package/src/core/authority.mjs +909 -0
- package/src/core/baseline.mjs +97 -0
- package/src/core/config-store.mjs +280 -0
- package/src/core/cost.mjs +0 -0
- package/src/core/detect.mjs +4 -33
- package/src/core/entitlements.mjs +7 -24
- package/src/core/escape-benchmark.mjs +597 -0
- package/src/core/events.mjs +234 -0
- package/src/core/evidence.mjs +212 -0
- package/src/core/format.mjs +44 -18
- package/src/core/gateway.mjs +15 -211
- package/src/core/graph.mjs +270 -0
- package/src/core/guard.mjs +118 -4
- package/src/core/intent.mjs +166 -0
- package/src/core/journal.mjs +131 -40
- package/src/core/kill-switch.mjs +122 -0
- package/src/core/notices.mjs +22 -2
- package/src/core/packs.mjs +193 -0
- package/src/core/passport.mjs +555 -0
- package/src/core/pipeline.mjs +148 -6
- package/src/core/prompts.mjs +51 -0
- package/src/core/proof.mjs +440 -0
- package/src/core/redteam/index.mjs +185 -0
- package/src/core/referral.mjs +187 -0
- package/src/core/sandbox.mjs +139 -0
- package/src/core/session.mjs +172 -0
- package/src/core/shadow.mjs +95 -0
- package/src/core/theme.mjs +240 -0
- package/src/core/trifecta.mjs +321 -0
- package/src/core/ui/controller.mjs +192 -0
- package/src/core/ui/decisions.mjs +55 -0
- package/src/core/ui/index.mjs +49 -0
- package/src/core/ui/intercept.mjs +103 -0
- package/src/core/ui/live.mjs +51 -0
- package/src/core/ui/primitives.mjs +123 -0
- package/src/core/ui/theme.mjs +92 -0
- package/src/core/verified.mjs +108 -0
- package/src/core/windows.mjs +270 -0
- package/src/index.mjs +67 -0
- package/src/tui/activity.mjs +71 -0
- package/src/tui/app.mjs +292 -0
- package/src/tui/cards.mjs +235 -0
- package/src/tui/composer.mjs +88 -0
- package/src/tui/palette.mjs +48 -0
- package/src/tui/status.mjs +42 -0
- package/src/core/cinematic.mjs +0 -545
package/src/core/gateway.mjs
CHANGED
|
@@ -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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
575
|
-
//
|
|
576
|
-
|
|
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.
|
|
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
|
+
}
|