@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.
Files changed (70) hide show
  1. package/README.md +76 -17
  2. package/bin/cirvix.mjs +488 -40
  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/demo.mjs +56 -70
  18. package/src/commands/doctor.mjs +235 -0
  19. package/src/commands/init.mjs +292 -30
  20. package/src/commands/interactive.mjs +690 -0
  21. package/src/commands/kill.mjs +74 -0
  22. package/src/commands/login.mjs +227 -0
  23. package/src/commands/passport.mjs +149 -0
  24. package/src/commands/policy.mjs +10 -6
  25. package/src/commands/protect.mjs +293 -0
  26. package/src/commands/prove.mjs +209 -0
  27. package/src/commands/redteam.mjs +51 -0
  28. package/src/commands/scan.mjs +6 -4
  29. package/src/commands/shadow.mjs +62 -0
  30. package/src/commands/simulate.mjs +96 -0
  31. package/src/commands/status.mjs +121 -36
  32. package/src/commands/upgrade.mjs +17 -9
  33. package/src/commands/welcome.mjs +105 -0
  34. package/src/core/authority.mjs +909 -0
  35. package/src/core/baseline.mjs +97 -0
  36. package/src/core/config-store.mjs +280 -0
  37. package/src/core/cost.mjs +0 -0
  38. package/src/core/detect.mjs +4 -33
  39. package/src/core/entitlements.mjs +6 -0
  40. package/src/core/escape-benchmark.mjs +597 -0
  41. package/src/core/evidence.mjs +212 -0
  42. package/src/core/format.mjs +27 -0
  43. package/src/core/gateway.mjs +15 -211
  44. package/src/core/graph.mjs +270 -0
  45. package/src/core/guard.mjs +118 -4
  46. package/src/core/intent.mjs +166 -0
  47. package/src/core/journal.mjs +131 -40
  48. package/src/core/kill-switch.mjs +122 -0
  49. package/src/core/notices.mjs +22 -2
  50. package/src/core/packs.mjs +193 -0
  51. package/src/core/passport.mjs +555 -0
  52. package/src/core/pipeline.mjs +148 -6
  53. package/src/core/prompts.mjs +51 -0
  54. package/src/core/proof.mjs +440 -0
  55. package/src/core/redteam/index.mjs +185 -0
  56. package/src/core/referral.mjs +187 -0
  57. package/src/core/sandbox.mjs +139 -0
  58. package/src/core/session.mjs +172 -0
  59. package/src/core/shadow.mjs +95 -0
  60. package/src/core/trifecta.mjs +321 -0
  61. package/src/core/ui/controller.mjs +192 -0
  62. package/src/core/ui/decisions.mjs +55 -0
  63. package/src/core/ui/index.mjs +49 -0
  64. package/src/core/ui/intercept.mjs +103 -0
  65. package/src/core/ui/live.mjs +51 -0
  66. package/src/core/ui/primitives.mjs +123 -0
  67. package/src/core/ui/theme.mjs +92 -0
  68. package/src/core/verified.mjs +108 -0
  69. package/src/core/windows.mjs +270 -0
  70. 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
+ }
@@ -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
- /** Set once this session reads secret-shaped material. */
204
- this.touchedSecret = false;
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 = {}, delegation = null, agent = null }) {
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
- const decision = evaluate(
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
+ }