@cirvix_ai/agent-control 0.1.2 → 0.1.5
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 +488 -40
- 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/demo.mjs +56 -70
- 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/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 +6 -4
- package/src/commands/shadow.mjs +62 -0
- package/src/commands/simulate.mjs +96 -0
- package/src/commands/status.mjs +121 -36
- package/src/commands/upgrade.mjs +17 -9
- 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 +6 -0
- package/src/core/escape-benchmark.mjs +597 -0
- package/src/core/evidence.mjs +212 -0
- package/src/core/format.mjs +27 -0
- 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/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 +25 -0
|
@@ -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
|
+
}
|
package/src/core/guard.mjs
CHANGED
|
@@ -29,7 +29,9 @@ import { classify } from "./risk.mjs";
|
|
|
29
29
|
import { classifyTool, extractCommand, publicToolName } from "./normalize.mjs";
|
|
30
30
|
import { scan as scanSecrets } from "./secret-detect.mjs";
|
|
31
31
|
import { applyDelegation } from "./delegation.mjs";
|
|
32
|
+
import { assessAuthority, applyAuthority } from "./authority.mjs";
|
|
32
33
|
import { applyEntitlements } from "./entitlement-gate.mjs";
|
|
34
|
+
import { SessionTaint, assessTrifecta, applyTrifecta } from "./trifecta.mjs";
|
|
33
35
|
|
|
34
36
|
/**
|
|
35
37
|
* A refusal the agent can read and plan around.
|
|
@@ -176,6 +178,12 @@ export class Guard {
|
|
|
176
178
|
runId = null,
|
|
177
179
|
riskFloor = "high",
|
|
178
180
|
delegation = null,
|
|
181
|
+
/* Mission-scoped authority. Absent by default, and absent means INERT —
|
|
182
|
+
not "deny everything". Authority is subtractive: it can take away what
|
|
183
|
+
policy allows and can never add to it, so a Guard built without a
|
|
184
|
+
mission behaves exactly as before. See core/authority.mjs. */
|
|
185
|
+
missions = null,
|
|
186
|
+
mission = null,
|
|
179
187
|
/* Commercial enforcement. All three default to absent, so a Guard built
|
|
180
188
|
without them behaves exactly as before — which is what keeps the SDK's
|
|
181
189
|
library callers and the shared conformance fixture working unchanged.
|
|
@@ -192,6 +200,9 @@ export class Guard {
|
|
|
192
200
|
this.secrets = secrets;
|
|
193
201
|
/** DelegationBroker, when agent-to-agent delegation is in use. */
|
|
194
202
|
this.delegation = delegation;
|
|
203
|
+
/** MissionRegistry, and/or a single mission this Guard always acts under. */
|
|
204
|
+
this.missions = missions;
|
|
205
|
+
this.mission = mission;
|
|
195
206
|
this.licence = licence;
|
|
196
207
|
this.meter = meter;
|
|
197
208
|
this.agents = agents;
|
|
@@ -200,12 +211,20 @@ export class Guard {
|
|
|
200
211
|
this.runId = runId;
|
|
201
212
|
/** Risk level at or above which an unnamed call is escalated to approval. */
|
|
202
213
|
this.riskFloor = riskFloor;
|
|
203
|
-
/**
|
|
204
|
-
this.
|
|
214
|
+
/** Sequence taint tracking for Lethal Trifecta. */
|
|
215
|
+
this.taint = new SessionTaint();
|
|
205
216
|
this.stats = { calls: 0, permitted: 0, denied: 0, held: 0, leaks: 0, latencyTotal: 0 };
|
|
206
217
|
this.nextId = 1;
|
|
207
218
|
}
|
|
208
219
|
|
|
220
|
+
get touchedSecret() {
|
|
221
|
+
return this.taint.touchedSecret;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
set touchedSecret(value) {
|
|
225
|
+
this.taint.touchedSecret = value;
|
|
226
|
+
}
|
|
227
|
+
|
|
209
228
|
/**
|
|
210
229
|
* Decides one call, and brokers any secret handles it carries.
|
|
211
230
|
*
|
|
@@ -215,7 +234,8 @@ export class Guard {
|
|
|
215
234
|
*
|
|
216
235
|
* @returns {Promise<{decision:object, record:object, args:any}>}
|
|
217
236
|
*/
|
|
218
|
-
async authorize({ tool, server = null, args
|
|
237
|
+
async authorize({ tool, server = null, args, arguments: callArguments, delegation = null, agent = null }) {
|
|
238
|
+
args = args ?? callArguments ?? {};
|
|
219
239
|
const action = actionForTool(server, tool);
|
|
220
240
|
const resource = resourceForCall(args);
|
|
221
241
|
// A caller may act as a specific agent per call — a gateway serving several
|
|
@@ -271,7 +291,7 @@ export class Guard {
|
|
|
271
291
|
secrets: { detected: scanned.length },
|
|
272
292
|
};
|
|
273
293
|
|
|
274
|
-
|
|
294
|
+
let decision = evaluate(
|
|
275
295
|
{ agent: caller, action, resource, context },
|
|
276
296
|
this.rules,
|
|
277
297
|
{ cwd: this.cwd },
|
|
@@ -308,6 +328,88 @@ export class Guard {
|
|
|
308
328
|
resource: decision.resource ?? resource,
|
|
309
329
|
});
|
|
310
330
|
|
|
331
|
+
/*
|
|
332
|
+
* AUTHORITY RUNS HERE, ON THE SAME PATH AS EVERYTHING ELSE.
|
|
333
|
+
*
|
|
334
|
+
* Mission, capability, constraint and expiry are evaluated for every call,
|
|
335
|
+
* not only for the ones a caller remembers to check. Placing it beside
|
|
336
|
+
* delegation is deliberate: both answer "does this principal actually hold
|
|
337
|
+
* the authority it is exercising", both can only narrow, and both have to
|
|
338
|
+
* be on the ONE path that `guard.wrap()`, the MCP gateway and the socket
|
|
339
|
+
* all go through. The three bypasses documented above this line were all
|
|
340
|
+
* the same mistake — a check that lived on one surface and not the others —
|
|
341
|
+
* and an authority layer with that shape would be worse than none, because
|
|
342
|
+
* the console would show a boundary the runtime was not enforcing.
|
|
343
|
+
*
|
|
344
|
+
* `assessAuthority` is pure. It reads the mission and reports; it does not
|
|
345
|
+
* spend the budget. Usage is recorded below and only for a call that was
|
|
346
|
+
* actually permitted, so a refused call cannot exhaust the allowance it was
|
|
347
|
+
* refused under — otherwise every constraint doubles as a denial-of-service
|
|
348
|
+
* against the agent's real work.
|
|
349
|
+
*/
|
|
350
|
+
const activeMission =
|
|
351
|
+
this.mission ?? (this.missions ? this.missions.forAgent(caller) : null);
|
|
352
|
+
|
|
353
|
+
const authorityAssessment = assessAuthority(
|
|
354
|
+
{
|
|
355
|
+
agent: caller,
|
|
356
|
+
action,
|
|
357
|
+
resource: decision.resource ?? resource,
|
|
358
|
+
tool,
|
|
359
|
+
server,
|
|
360
|
+
destination: destinationFor(decision.resource ?? resource, args),
|
|
361
|
+
environment: this.environment,
|
|
362
|
+
costUsd: args?.costUsd ?? 0,
|
|
363
|
+
delegating: Boolean(delegation),
|
|
364
|
+
},
|
|
365
|
+
activeMission,
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
const authorityContext = applyAuthority(decision, authorityAssessment);
|
|
369
|
+
|
|
370
|
+
/*
|
|
371
|
+
* An attempt is recorded whether or not authority is what refused it.
|
|
372
|
+
*
|
|
373
|
+
* A call policy already denied is still an agent reaching outside its
|
|
374
|
+
* boundary, and if only authority-attributed refusals were counted an
|
|
375
|
+
* agent could probe the boundary for free by choosing actions policy
|
|
376
|
+
* denies anyway. The benchmark scores attempts, not attributions.
|
|
377
|
+
*/
|
|
378
|
+
if (this.missions && authorityAssessment.applicable && !authorityAssessment.authorized) {
|
|
379
|
+
this.missions.recordEscape({
|
|
380
|
+
missionId: activeMission?.id ?? null,
|
|
381
|
+
agent: caller,
|
|
382
|
+
kind: authorityAssessment.escape?.kind ?? null,
|
|
383
|
+
stage: authorityAssessment.stage,
|
|
384
|
+
code: authorityAssessment.code,
|
|
385
|
+
action,
|
|
386
|
+
resource: decision.resource ?? resource,
|
|
387
|
+
tool,
|
|
388
|
+
reason: authorityAssessment.reason,
|
|
389
|
+
blocked: decision.verdict === "deny" || decision.verdict === "hold",
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/*
|
|
394
|
+
* Sequence-aware enforcement (Lethal Trifecta) in Guard.
|
|
395
|
+
* Prevents untrusted content + sensitive data read + outbound egress.
|
|
396
|
+
*/
|
|
397
|
+
const trifectaCall = {
|
|
398
|
+
action,
|
|
399
|
+
resource: decision.resource ?? resource,
|
|
400
|
+
tool,
|
|
401
|
+
server,
|
|
402
|
+
destination: destinationFor(decision.resource ?? resource, args),
|
|
403
|
+
environment: this.environment,
|
|
404
|
+
egress: this.isExternal(decision.resource ?? resource) ? "external" : "none",
|
|
405
|
+
timestamp: new Date().toISOString(),
|
|
406
|
+
sql: typeof args?.sql === "string" ? args.sql : typeof args?.query === "string" ? args.query : null,
|
|
407
|
+
secretsDetected: scanned.length,
|
|
408
|
+
};
|
|
409
|
+
const trifecta = assessTrifecta(trifectaCall, this.taint);
|
|
410
|
+
decision = applyTrifecta(decision, trifecta);
|
|
411
|
+
decision.trifecta = { complete: trifecta.complete, satisfied: trifecta.satisfied, imminent: trifecta.imminent };
|
|
412
|
+
|
|
311
413
|
/*
|
|
312
414
|
* THE COMMERCIAL GATE RUNS HERE TOO, NOT ONLY IN THE PIPELINE.
|
|
313
415
|
*
|
|
@@ -391,7 +493,12 @@ export class Guard {
|
|
|
391
493
|
// Who authorized this must be answerable after the fact, on every surface
|
|
392
494
|
// — not only the one that happened to record it.
|
|
393
495
|
...(delegationContext ? { delegation: delegationContext } : {}),
|
|
496
|
+
// Authority is part of the record for the same reason delegation is:
|
|
497
|
+
// "who authorized this" must be answerable after the fact.
|
|
498
|
+
...(authorityContext ? { authority: authorityContext } : {}),
|
|
499
|
+
...(decision.escape ? { escape: decision.escape } : {}),
|
|
394
500
|
...(brokered.length ? { secrets: brokered, secrets_brokered: brokered } : {}),
|
|
501
|
+
...(decision.trifecta ? { trifecta: decision.trifecta } : {}),
|
|
395
502
|
// Findings never carry the value — see secret-detect.mjs.
|
|
396
503
|
...(scanned.length
|
|
397
504
|
? {
|
|
@@ -413,6 +520,13 @@ export class Guard {
|
|
|
413
520
|
else if (decision.verdict === "hold") this.stats.held++;
|
|
414
521
|
else {
|
|
415
522
|
this.stats.permitted++;
|
|
523
|
+
// Budget and rate are consumed by calls that HAPPEN. See the note above
|
|
524
|
+
// `assessAuthority`: charging a refused call would let a blocked agent
|
|
525
|
+
// exhaust its own mission.
|
|
526
|
+
if (this.missions && activeMission) {
|
|
527
|
+
this.missions.record(activeMission.id, { costUsd: args?.costUsd ?? 0 });
|
|
528
|
+
}
|
|
529
|
+
this.taint.observeCall(trifectaCall, true);
|
|
416
530
|
// Any successful read of secret-shaped material taints the session. A
|
|
417
531
|
// brokered substitution deliberately does not: the agent never held the
|
|
418
532
|
// material, which is the entire point of a handle.
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Intent-Aware Agent Firewall.
|
|
3
|
+
*
|
|
4
|
+
* Implements: CAPABILITY + AUTHORITY + INTENT + CONTEXT = AUTHORIZED ACTION
|
|
5
|
+
*
|
|
6
|
+
* Rather than evaluating tools in isolation (`tool == allowed`), an agent's
|
|
7
|
+
* declared mission/purpose governs what it may reach for.
|
|
8
|
+
*
|
|
9
|
+
* Example:
|
|
10
|
+
* Mission: "Fix checkout test failures"
|
|
11
|
+
* Allowed: read source, run tests, modify checkout files
|
|
12
|
+
* Suspicious: read production credentials, drop database, upload code to external IP
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { RISK } from "./risk.mjs";
|
|
16
|
+
|
|
17
|
+
/** Semantic categories of intent. */
|
|
18
|
+
export const INTENT_CATEGORIES = {
|
|
19
|
+
TESTING: "testing",
|
|
20
|
+
DEVELOPMENT: "development",
|
|
21
|
+
MAINTENANCE: "maintenance",
|
|
22
|
+
REPORTING: "reporting",
|
|
23
|
+
DATABASE_ADMIN: "database_admin",
|
|
24
|
+
DEPLOYMENT: "deployment",
|
|
25
|
+
GENERAL: "general",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** High-risk actions that require explicit intent alignment. */
|
|
29
|
+
const RESTRICTED_ACTIONS = {
|
|
30
|
+
"secret:read": ["credential_management", "auth_setup"],
|
|
31
|
+
"db:drop": ["database_admin", "migration_rollback"],
|
|
32
|
+
"db:write": ["database_admin", "data_migration", "development"],
|
|
33
|
+
"shell:destructive": ["system_admin"],
|
|
34
|
+
"net:external_egress": ["data_sync", "api_integration", "deployment"],
|
|
35
|
+
"iam:modify": ["cloud_admin", "iam_setup"],
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Classifies a declared intent string into dominant categories and allowed scopes.
|
|
40
|
+
*/
|
|
41
|
+
export function classifyIntent(intentText) {
|
|
42
|
+
if (!intentText || typeof intentText !== "string") {
|
|
43
|
+
return {
|
|
44
|
+
category: INTENT_CATEGORIES.GENERAL,
|
|
45
|
+
keywords: [],
|
|
46
|
+
sensitivityFloor: RISK.LOW,
|
|
47
|
+
allowedActions: ["*"],
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const text = intentText.toLowerCase();
|
|
52
|
+
const keywords = text.match(/\b\w{3,}\b/g) ?? [];
|
|
53
|
+
|
|
54
|
+
if (/\b(test|spec|assert|coverage|jest|mocha|pytest|unit)\b/.test(text)) {
|
|
55
|
+
return {
|
|
56
|
+
category: INTENT_CATEGORIES.TESTING,
|
|
57
|
+
keywords,
|
|
58
|
+
sensitivityFloor: RISK.LOW,
|
|
59
|
+
disallowedActions: ["secret:read", "db:drop", "net:external_egress", "iam:modify"],
|
|
60
|
+
allowedActions: ["fs:read", "fs:write", "exec:test", "exec:dev"],
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (/\b(fix|bug|refactor|feature|implement|code|frontend|backend)\b/.test(text)) {
|
|
65
|
+
return {
|
|
66
|
+
category: INTENT_CATEGORIES.DEVELOPMENT,
|
|
67
|
+
keywords,
|
|
68
|
+
sensitivityFloor: RISK.LOW,
|
|
69
|
+
disallowedActions: ["secret:read", "db:drop", "iam:modify"],
|
|
70
|
+
allowedActions: ["fs:read", "fs:write", "exec:dev", "net:fetch"],
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (/\b(deploy|release|ship|staging|production|publish)\b/.test(text)) {
|
|
75
|
+
return {
|
|
76
|
+
category: INTENT_CATEGORIES.DEPLOYMENT,
|
|
77
|
+
keywords,
|
|
78
|
+
sensitivityFloor: RISK.HIGH,
|
|
79
|
+
disallowedActions: ["db:drop"],
|
|
80
|
+
allowedActions: ["fs:read", "net:external_egress", "exec:deploy"],
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (/\b(migrate|schema|table|sql|database|query|seed)\b/.test(text)) {
|
|
85
|
+
return {
|
|
86
|
+
category: INTENT_CATEGORIES.DATABASE_ADMIN,
|
|
87
|
+
keywords,
|
|
88
|
+
sensitivityFloor: RISK.HIGH,
|
|
89
|
+
disallowedActions: ["iam:modify"],
|
|
90
|
+
allowedActions: ["db:read", "db:write", "fs:read"],
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
category: INTENT_CATEGORIES.GENERAL,
|
|
96
|
+
keywords,
|
|
97
|
+
sensitivityFloor: RISK.LOW,
|
|
98
|
+
disallowedActions: ["db:drop", "iam:modify"],
|
|
99
|
+
allowedActions: ["*"],
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Evaluates whether a requested action aligns with the agent's declared intent.
|
|
105
|
+
*
|
|
106
|
+
* @param {Object} params
|
|
107
|
+
* @param {string} params.intent - Declared mission or task description
|
|
108
|
+
* @param {string} params.action - Canonical action identifier (e.g. "fs:read", "secret:read")
|
|
109
|
+
* @param {string} params.resource - Resource target (e.g. "/etc/passwd", "src/auth.ts")
|
|
110
|
+
* @param {string} params.tool - Tool name invoked
|
|
111
|
+
* @param {Object} params.context - Execution context
|
|
112
|
+
* @returns {{ aligned: boolean, intentScore: number, reason: string, category: string }}
|
|
113
|
+
*/
|
|
114
|
+
export function evaluateIntent({ intent, action, resource = "", tool = "", context = {} }) {
|
|
115
|
+
const classification = classifyIntent(intent);
|
|
116
|
+
|
|
117
|
+
// If action is explicitly restricted, verify if intent permits it
|
|
118
|
+
const restrictedFor = RESTRICTED_ACTIONS[action];
|
|
119
|
+
if (restrictedFor && !restrictedFor.includes(classification.category)) {
|
|
120
|
+
return {
|
|
121
|
+
aligned: false,
|
|
122
|
+
intentScore: 0.1,
|
|
123
|
+
reason: `Action '${action}' is restricted and outside declared mission '${intent}' (${classification.category})`,
|
|
124
|
+
category: classification.category,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Check disallowed actions for this intent category
|
|
129
|
+
if (classification.disallowedActions?.includes(action)) {
|
|
130
|
+
return {
|
|
131
|
+
aligned: false,
|
|
132
|
+
intentScore: 0.2,
|
|
133
|
+
reason: `Action '${action}' conflicts with declared mission scope (${classification.category})`,
|
|
134
|
+
category: classification.category,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Resource sensitivity check against intent
|
|
139
|
+
const isCredentialTarget = /(credential|\.env|id_rsa|secret|token|api[_-]?key|password)/i.test(resource);
|
|
140
|
+
if (isCredentialTarget && classification.category === INTENT_CATEGORIES.TESTING) {
|
|
141
|
+
return {
|
|
142
|
+
aligned: false,
|
|
143
|
+
intentScore: 0.15,
|
|
144
|
+
reason: `Agent attempted to access credentials ('${resource}') while declared mission is testing only`,
|
|
145
|
+
category: classification.category,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Production database deletion check
|
|
150
|
+
const isDropTarget = /(drop\s+table|delete\s+from|truncate|rm\s+-rf\s+\/)/i.test(resource);
|
|
151
|
+
if (isDropTarget && classification.category !== INTENT_CATEGORIES.DATABASE_ADMIN) {
|
|
152
|
+
return {
|
|
153
|
+
aligned: false,
|
|
154
|
+
intentScore: 0.05,
|
|
155
|
+
reason: `Destructive action against '${resource}' requires explicit administrative mission`,
|
|
156
|
+
category: classification.category,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
aligned: true,
|
|
162
|
+
intentScore: 0.95,
|
|
163
|
+
reason: `Action '${action}' against '${resource || tool}' aligns with mission '${classification.category}'`,
|
|
164
|
+
category: classification.category,
|
|
165
|
+
};
|
|
166
|
+
}
|