@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,909 @@
1
+ /**
2
+ * Authority: Mission, Capability, Constraint, Expiry.
3
+ *
4
+ * THE ONE IDEA
5
+ * ------------
6
+ * An agent's objective does not grant it authority.
7
+ *
8
+ * Everything else in this file follows from that sentence. A mission says what
9
+ * an agent is *for*; it says nothing about what the agent may *do*. Authority
10
+ * is a separate, explicit, expiring grant, and it is checked against the call
11
+ * actually being made rather than against the agent's stated intent.
12
+ *
13
+ * This matters because an agent's intent is the one input an attacker can
14
+ * rewrite for free. Prompt injection does not steal a credential — it changes
15
+ * what the agent believes it is trying to accomplish. A system that derives
16
+ * permission from intent hands the attacker permission along with it. So the
17
+ * mission is deliberately inert: it is a boundary and a clock, never a key.
18
+ *
19
+ * FOUR STAGES, IN THIS ORDER
20
+ * --------------------------
21
+ * MISSION Is there an active mission, and is it this agent's?
22
+ * CAPABILITY Does a granted, live capability cover this exact action and
23
+ * resource? Expired and revoked capabilities are not in the set,
24
+ * so asking for one is reported as its own thing rather than as
25
+ * a generic miss.
26
+ * CONSTRAINT Under the circumstances of THIS call — destination, data,
27
+ * tool, spend, rate, delegation, environment — is it allowed?
28
+ * EXPIRY Is the mission's authorization window still open?
29
+ *
30
+ * Mission expiry is checked last on purpose. When a call is both outside the
31
+ * granted set and under a stale mission, the escalation is the more
32
+ * significant event and should be what the operator sees; an expired mission
33
+ * with a legitimate capability reports EXPIRY, which is also what they want.
34
+ *
35
+ * NARROWING ONLY — THE PROPERTY THAT MAKES THIS SAFE
36
+ * --------------------------------------------------
37
+ * `applyAuthority` follows `applyDelegation` and `applyTrifecta` exactly: it
38
+ * can turn a permit into a denial and never the reverse. Authority is ANDed
39
+ * with policy, never substituted for it. A mission cannot grant what policy
40
+ * forbids, which is what makes it safe to let a caller present one at all.
41
+ *
42
+ * An agent with NO mission is unchanged — the layer is inert, the way
43
+ * delegation is inert without a grant. That is what keeps every existing
44
+ * caller and the shared conformance fixture working. Missions are something
45
+ * you opt into; they take authority away, they never add it.
46
+ *
47
+ * WHAT AN ESCAPE ATTEMPT IS
48
+ * -------------------------
49
+ * An escape attempt is an agent trying to obtain or exercise authority outside
50
+ * its current boundary. Every refusal here is classified as one, because the
51
+ * refusal is not the interesting artifact — the attempt is. A capability
52
+ * escalation that is blocked ninety-nine times and succeeds once is a story
53
+ * only visible if the ninety-nine were recorded.
54
+ */
55
+ import { matchGlob } from "./policy.mjs";
56
+ import { canonicalAction, TAXONOMY } from "./normalize.mjs";
57
+ import { normalizeScope, scopePermits } from "./delegation.mjs";
58
+ import { DECISION, isForwarded } from "./decisions.mjs";
59
+
60
+ /* -------------------------------------------------------------------------- */
61
+ /* Vocabulary */
62
+ /* -------------------------------------------------------------------------- */
63
+
64
+ /** The four stages, in evaluation order. Exported so a UI cannot invent a fifth. */
65
+ export const STAGE = Object.freeze({
66
+ MISSION: "mission",
67
+ CAPABILITY: "capability",
68
+ CONSTRAINT: "constraint",
69
+ EXPIRY: "expiry",
70
+ });
71
+
72
+ export const MISSION_STATUS = Object.freeze({
73
+ ACTIVE: "active",
74
+ EXPIRED: "expired",
75
+ REVOKED: "revoked",
76
+ COMPLETED: "completed",
77
+ });
78
+
79
+ export const CAPABILITY_STATUS = Object.freeze({
80
+ ACTIVE: "active",
81
+ EXPIRED: "expired",
82
+ REVOKED: "revoked",
83
+ });
84
+
85
+ /**
86
+ * Why authority refused.
87
+ *
88
+ * Distinct codes rather than one `denied`, because the operator response
89
+ * differs: an expired capability is reissued, an escalation is investigated,
90
+ * and a constraint violation is usually the agent doing exactly what it was
91
+ * told to do by something it read.
92
+ */
93
+ export const AUTHORITY_ERROR = Object.freeze({
94
+ MISSION_UNKNOWN: "mission_unknown",
95
+ MISSION_NOT_ACTIVE: "mission_not_active",
96
+ MISSION_WRONG_AGENT: "mission_wrong_agent",
97
+ MISSION_EXPIRED: "mission_expired",
98
+ CAPABILITY_NOT_GRANTED: "capability_not_granted",
99
+ CAPABILITY_EXPIRED: "capability_expired",
100
+ CAPABILITY_REVOKED: "capability_revoked",
101
+ CONSTRAINT_VIOLATED: "constraint_violated",
102
+ });
103
+
104
+ /**
105
+ * How an agent tried to leave its boundary.
106
+ *
107
+ * These are the categories the escape benchmark scores, so they are a closed
108
+ * set and each one names a mechanism rather than a severity.
109
+ */
110
+ export const ESCAPE = Object.freeze({
111
+ CAPABILITY_ESCALATION: "capability_escalation",
112
+ EXPIRED_AUTHORITY: "expired_authority",
113
+ CONSTRAINT_VIOLATION: "constraint_violation",
114
+ MISSION_VIOLATION: "mission_violation",
115
+ DATA_EXFILTRATION: "data_exfiltration",
116
+ CREDENTIAL_ABUSE: "credential_abuse",
117
+ DELEGATION_ESCAPE: "delegation_escape",
118
+ TOOL_CHAIN_ESCAPE: "tool_chain_escape",
119
+ PROMPT_INJECTION: "prompt_injection",
120
+ });
121
+
122
+ /* -------------------------------------------------------------------------- */
123
+ /* Capabilities */
124
+ /* -------------------------------------------------------------------------- */
125
+
126
+ let seq = 0;
127
+ const newId = (prefix) =>
128
+ `${prefix}_${Date.now().toString(36)}${(seq++).toString(36)}${Math.random().toString(36).slice(2, 6)}`;
129
+
130
+ const ms = (v) => (v == null ? null : typeof v === "number" ? v : Date.parse(v));
131
+
132
+ /**
133
+ * Normalizes a capability into the one shape everything downstream reads.
134
+ *
135
+ * A capability is deliberately the SAME shape as a delegation scope
136
+ * (`{actions, resources}`), because it answers the same question — may this
137
+ * (action, resource) pair go through — and two matchers for one question is
138
+ * how the two answers eventually differ. `scopePermits` is reused verbatim
139
+ * for the same reason.
140
+ *
141
+ * The shorthand `"tickets.read"` expands to `{actions:["tickets.read"],
142
+ * resources:["*"]}`. That is a convenience for writing a mission by hand, and
143
+ * it is the ONLY place a wildcard is inferred: an omitted axis on an explicit
144
+ * capability object still means unconstrained, but an omitted axis is a
145
+ * decision the author made, whereas a bare string has no axis to omit.
146
+ */
147
+ export function normalizeCapability(input, { issuer = "cirvix", now = Date.now() } = {}) {
148
+ const c = typeof input === "string" ? { actions: [input] } : { ...(input ?? {}) };
149
+
150
+ /* Accept the singular spellings a human would write. */
151
+ const actions = c.actions ?? (c.action == null ? undefined : [c.action]);
152
+ const resources = c.resources ?? (c.resource == null ? undefined : [c.resource]);
153
+
154
+ /*
155
+ * IDEMPOTENT ON PURPOSE — RE-NORMALIZING MUST NOT WIDEN.
156
+ *
157
+ * An already-normalized capability carries its axes under `scope` and has no
158
+ * top-level `actions`/`resources`. Without this branch those read as absent,
159
+ * absent means "unconstrained on this axis", and normalizing a normalized
160
+ * capability a second time silently turned a tightly scoped grant into
161
+ * `{actions:["*"], resources:["*"]}` — everything policy allows.
162
+ *
163
+ * That is the same absent-vs-empty confusion `normalizeScope` documents, one
164
+ * level up, and it is worth the four lines: any code path that normalizes
165
+ * defensively (a registry re-issue, a lint pass, a round trip through JSON
166
+ * and back) would otherwise be an escalation.
167
+ */
168
+ const scope =
169
+ actions === undefined && resources === undefined && c.scope
170
+ ? normalizeScope(c.scope)
171
+ : normalizeScope({ actions, resources });
172
+
173
+ return {
174
+ id: c.id ?? newId("cap"),
175
+ scope,
176
+ /* Human-facing name. Never used for matching — matching is on scope. */
177
+ name: c.name ?? scope.actions.join(","),
178
+ conditions: c.conditions ?? null,
179
+ issuer: c.issuer ?? issuer,
180
+ issuedAt: ms(c.issuedAt) ?? now,
181
+ expiresAt: ms(c.expiresAt) ?? null,
182
+ status: c.status ?? CAPABILITY_STATUS.ACTIVE,
183
+ };
184
+ }
185
+
186
+ /** A capability's status *at a moment*, which is not the same as its stored status. */
187
+ export function capabilityStatusAt(cap, now = Date.now()) {
188
+ if (cap.status === CAPABILITY_STATUS.REVOKED) return CAPABILITY_STATUS.REVOKED;
189
+ if (cap.expiresAt != null && now >= cap.expiresAt) return CAPABILITY_STATUS.EXPIRED;
190
+ return CAPABILITY_STATUS.ACTIVE;
191
+ }
192
+
193
+ /* -------------------------------------------------------------------------- */
194
+ /* Missions */
195
+ /* -------------------------------------------------------------------------- */
196
+
197
+ export function normalizeMission(input, { now = Date.now() } = {}) {
198
+ const m = { ...(input ?? {}) };
199
+ const issuedAt = ms(m.issuedAt) ?? now;
200
+ const expiresAt =
201
+ ms(m.expiresAt) ?? (m.ttlMs != null ? issuedAt + Number(m.ttlMs) : null);
202
+
203
+ return {
204
+ id: m.id ?? newId("msn"),
205
+ name: m.name ?? "Untitled mission",
206
+ /* Prose. Recorded in evidence, shown in the console, and deliberately
207
+ never consulted by any decision — see the header. */
208
+ objective: m.objective ?? "",
209
+ agent: m.agent ?? null,
210
+ capabilities: (m.capabilities ?? []).map((c) =>
211
+ normalizeCapability(c, { issuer: m.id ?? "mission", now }),
212
+ ),
213
+ constraints: m.constraints ?? {},
214
+ issuedAt,
215
+ expiresAt,
216
+ status: m.status ?? MISSION_STATUS.ACTIVE,
217
+ /* Mutable usage, for the spend and rate constraints. Kept on the mission
218
+ because a budget is a property of the authorization, not of the agent:
219
+ two missions for the same agent must not share one wallet. */
220
+ usage: { spendUsd: Number(m.usage?.spendUsd ?? 0), calls: [...(m.usage?.calls ?? [])] },
221
+ };
222
+ }
223
+
224
+ export function missionStatusAt(mission, now = Date.now()) {
225
+ if (mission.status === MISSION_STATUS.REVOKED) return MISSION_STATUS.REVOKED;
226
+ if (mission.status === MISSION_STATUS.COMPLETED) return MISSION_STATUS.COMPLETED;
227
+ if (mission.expiresAt != null && now >= mission.expiresAt) return MISSION_STATUS.EXPIRED;
228
+ return MISSION_STATUS.ACTIVE;
229
+ }
230
+
231
+ export function remainingMs(mission, now = Date.now()) {
232
+ if (mission.expiresAt == null) return null;
233
+ return Math.max(0, mission.expiresAt - now);
234
+ }
235
+
236
+ /* -------------------------------------------------------------------------- */
237
+ /* Constraints */
238
+ /* -------------------------------------------------------------------------- */
239
+
240
+ const hostOf = (resource, destination) => {
241
+ const candidate = destination ?? resource ?? "";
242
+ if (!/^https?:\/\//i.test(candidate)) return null;
243
+ try {
244
+ return new URL(candidate).hostname.toLowerCase();
245
+ } catch {
246
+ /* An unparseable URL is not "no host" — treating it as absent would let a
247
+ malformed destination skip the network constraint entirely. */
248
+ return "unparseable";
249
+ }
250
+ };
251
+
252
+ /** Data that must not leave, expressed the way a person would say it. */
253
+ const PII_HINT = /customer|subscriber|patient|user[s]?[._-]?(data|table|export|dump)|pii|personal|email[s]?[._-]?(list|export)|ssn|passport|address(es)?/i;
254
+ const SECRET_HINT = /secret|credential|token|password|api[_-]?key|private[_-]?key|\.env|\.pem|id_rsa|\.aws|\.ssh|keychain|vault/i;
255
+ const EXPORT_HINT = /export|dump|backup|extract|download[_-]?all|bulk|archive|snapshot/i;
256
+
257
+ /**
258
+ * The constraint evaluators.
259
+ *
260
+ * Each returns `null` when satisfied, or a violation. They are separate
261
+ * functions rather than one branchy check so that a mission can carry any
262
+ * subset and an unrecognised key is inert instead of silently permissive —
263
+ * `evaluateConstraints` reports unknown keys rather than skipping them.
264
+ */
265
+ const CONSTRAINTS = {
266
+ /**
267
+ * Where the call may talk to.
268
+ *
269
+ * `deny` wins over `allow`, and the DEFAULT for a declared network
270
+ * constraint is deny-unknown. A network constraint that allowed everything
271
+ * it had not thought to name would be decoration: exfiltration goes to a
272
+ * domain nobody listed, by definition.
273
+ */
274
+ network(rule, call) {
275
+ const host = hostOf(call.resource, call.destination);
276
+ if (!host) return null; // not an outbound call
277
+
278
+ const deny = rule.deny ?? [];
279
+ if (deny.some((p) => matchGlob(p, host))) {
280
+ return {
281
+ id: "network.denied",
282
+ reason: `Outbound to ${host} is explicitly denied by this mission.`,
283
+ escape: ESCAPE.DATA_EXFILTRATION,
284
+ };
285
+ }
286
+
287
+ const allow = rule.allow ?? null;
288
+ const unknownDenied = rule.denyUnknown !== false;
289
+ if (allow && allow.some((p) => matchGlob(p, host))) return null;
290
+ if (allow && unknownDenied) {
291
+ return {
292
+ id: "network.unknown_destination",
293
+ reason:
294
+ `${host} is not on this mission's allowed destination list ` +
295
+ `(${allow.join(", ")}). Unknown external domains are denied.`,
296
+ escape: ESCAPE.DATA_EXFILTRATION,
297
+ };
298
+ }
299
+ return null;
300
+ },
301
+
302
+ /** What kind of data the call may touch or move. */
303
+ data(rule, call) {
304
+ const target = `${call.resource ?? ""} ${call.tool ?? ""} ${call.action ?? ""}`;
305
+ const leaving = Boolean(hostOf(call.resource, call.destination)) || EXPORT_HINT.test(target);
306
+
307
+ if (rule.secrets === "deny" && SECRET_HINT.test(target)) {
308
+ return {
309
+ id: "data.secrets",
310
+ reason: "This mission may not read credential or secret material.",
311
+ escape: ESCAPE.CREDENTIAL_ABUSE,
312
+ };
313
+ }
314
+ if (rule.pii === "deny" && PII_HINT.test(target) && leaving) {
315
+ return {
316
+ id: "data.pii_export",
317
+ reason: "Customer or personal data may not be exported or sent outbound under this mission.",
318
+ escape: ESCAPE.DATA_EXFILTRATION,
319
+ };
320
+ }
321
+ if (rule.export === "deny" && EXPORT_HINT.test(target)) {
322
+ return {
323
+ id: "data.export",
324
+ reason: "Bulk export is not permitted under this mission.",
325
+ escape: ESCAPE.DATA_EXFILTRATION,
326
+ };
327
+ }
328
+ return null;
329
+ },
330
+
331
+ /** Tools the mission may not reach, whatever the capability set says. */
332
+ tools(rule, call) {
333
+ const action = canonicalAction(call.action ?? "");
334
+ const tool = String(call.tool ?? "");
335
+ const hit = (p) => matchGlob(p, action) || matchGlob(p, tool);
336
+
337
+ if ((rule.deny ?? []).some(hit)) {
338
+ return {
339
+ id: "tools.denied",
340
+ reason: `${action || tool} is on this mission's denied tool list.`,
341
+ escape: ESCAPE.CONSTRAINT_VIOLATION,
342
+ };
343
+ }
344
+ if (rule.allow && !rule.allow.some(hit)) {
345
+ return {
346
+ id: "tools.not_allowed",
347
+ reason: `${action || tool} is not on this mission's allowed tool list.`,
348
+ escape: ESCAPE.CONSTRAINT_VIOLATION,
349
+ };
350
+ }
351
+ return null;
352
+ },
353
+
354
+ /** Money. Checked BEFORE the spend, against the cost the call would incur. */
355
+ spend(rule, call, mission) {
356
+ const max = Number(rule.maxUsd ?? rule.max ?? Infinity);
357
+ const already = Number(mission?.usage?.spendUsd ?? 0);
358
+ const incoming = Number(call.costUsd ?? 0);
359
+ if (already + incoming > max + 1e-9) {
360
+ return {
361
+ id: "spend.exceeded",
362
+ reason:
363
+ `This call would take the mission to $${(already + incoming).toFixed(2)}, ` +
364
+ `over its $${max.toFixed(2)} budget.`,
365
+ escape: ESCAPE.CONSTRAINT_VIOLATION,
366
+ };
367
+ }
368
+ return null;
369
+ },
370
+
371
+ /** Tool calls per window. A runaway loop is a security event, not just a bill. */
372
+ rate(rule, call, mission, now) {
373
+ const max = Number(rule.maxPerMinute ?? rule.max ?? Infinity);
374
+ if (!Number.isFinite(max)) return null;
375
+ const windowMs = Number(rule.windowMs ?? 60_000);
376
+ const recent = (mission?.usage?.calls ?? []).filter((t) => now - t < windowMs);
377
+ if (recent.length >= max) {
378
+ return {
379
+ id: "rate.exceeded",
380
+ reason: `${recent.length} calls in the last ${Math.round(windowMs / 1000)}s; this mission allows ${max}.`,
381
+ escape: ESCAPE.CONSTRAINT_VIOLATION,
382
+ };
383
+ }
384
+ return null;
385
+ },
386
+
387
+ /** Whether this mission's authority may be handed to another agent at all. */
388
+ delegation(rule, call) {
389
+ if (!call.delegating) return null;
390
+ if (rule.allow === false || rule.privileged === "deny") {
391
+ return {
392
+ id: "delegation.denied",
393
+ reason: "This mission's authority may not be delegated to another agent.",
394
+ escape: ESCAPE.DELEGATION_ESCAPE,
395
+ };
396
+ }
397
+ return null;
398
+ },
399
+
400
+ /** Where the call may run. */
401
+ environment(rule, call) {
402
+ const env = String(call.environment ?? "local");
403
+ const allow = rule.allow ?? null;
404
+ if ((rule.deny ?? []).includes(env)) {
405
+ return {
406
+ id: "environment.denied",
407
+ reason: `This mission may not act in ${env}.`,
408
+ escape: ESCAPE.CONSTRAINT_VIOLATION,
409
+ };
410
+ }
411
+ if (allow && !allow.includes(env)) {
412
+ return {
413
+ id: "environment.not_allowed",
414
+ reason: `This mission is scoped to ${allow.join(", ")}; the call is in ${env}.`,
415
+ escape: ESCAPE.CONSTRAINT_VIOLATION,
416
+ };
417
+ }
418
+ return null;
419
+ },
420
+ };
421
+
422
+ /**
423
+ * Runs every declared constraint.
424
+ *
425
+ * Returns the violations and the list of what was actually checked, because
426
+ * "no violation" and "nothing was evaluated" look identical in a log and mean
427
+ * opposite things. An unrecognised constraint key is surfaced as `unknown`
428
+ * rather than ignored — a typo in a mission definition would otherwise read as
429
+ * a satisfied constraint forever.
430
+ */
431
+ export function evaluateConstraints(constraints, call, mission, now = Date.now()) {
432
+ const checked = [];
433
+ const unknown = [];
434
+ const violations = [];
435
+
436
+ for (const [key, rule] of Object.entries(constraints ?? {})) {
437
+ if (rule == null || rule === false) continue;
438
+ const evaluator = CONSTRAINTS[key];
439
+ if (!evaluator) {
440
+ unknown.push(key);
441
+ continue;
442
+ }
443
+ checked.push(key);
444
+ const violation = evaluator(rule, call, mission, now);
445
+ if (violation) violations.push({ constraint: key, ...violation });
446
+ }
447
+
448
+ return { checked, unknown, violations, ok: violations.length === 0 };
449
+ }
450
+
451
+ /* -------------------------------------------------------------------------- */
452
+ /* The assessment */
453
+ /* -------------------------------------------------------------------------- */
454
+
455
+ /**
456
+ * Evaluates one call against one mission.
457
+ *
458
+ * Pure: it reads the mission and reports. It does not mutate usage counters —
459
+ * `MissionRegistry.record` does that, and only for a call that actually went
460
+ * through, so a denied call cannot consume the budget it was denied for.
461
+ *
462
+ * @param {object} call { agent, action, resource, tool, destination,
463
+ * environment, costUsd, delegating }
464
+ * @param {object} mission a normalized mission, or null
465
+ * @returns assessment
466
+ */
467
+ export function assessAuthority(call = {}, mission = null, { now = Date.now() } = {}) {
468
+ const requested = {
469
+ action: canonicalAction(call.action ?? ""),
470
+ resource: call.resource ?? "",
471
+ };
472
+
473
+ /* No mission: the layer is inert. See the header — missions are opt-in and
474
+ subtractive, so absence must not deny and must not grant. */
475
+ if (!mission) {
476
+ return {
477
+ applicable: false,
478
+ authorized: true,
479
+ stage: null,
480
+ code: null,
481
+ reason: null,
482
+ mission: null,
483
+ capability: null,
484
+ granted: [],
485
+ requested,
486
+ escape: null,
487
+ constraints: { checked: [], unknown: [], violations: [], ok: true },
488
+ };
489
+ }
490
+
491
+ const base = {
492
+ applicable: true,
493
+ requested,
494
+ mission: {
495
+ id: mission.id,
496
+ name: mission.name,
497
+ objective: mission.objective,
498
+ agent: mission.agent,
499
+ status: missionStatusAt(mission, now),
500
+ expiresAt: mission.expiresAt,
501
+ remainingMs: remainingMs(mission, now),
502
+ },
503
+ granted: mission.capabilities
504
+ .filter((c) => capabilityStatusAt(c, now) === CAPABILITY_STATUS.ACTIVE)
505
+ .map((c) => ({ id: c.id, name: c.name, scope: c.scope, expiresAt: c.expiresAt })),
506
+ capability: null,
507
+ constraints: { checked: [], unknown: [], violations: [], ok: true },
508
+ };
509
+
510
+ const deny = (stage, code, reason, escape, extra = {}) => ({
511
+ ...base,
512
+ ...extra,
513
+ authorized: false,
514
+ stage,
515
+ code,
516
+ reason,
517
+ escape: escape ? { kind: escape, stage, code } : null,
518
+ });
519
+
520
+ /* ---- 1. MISSION ------------------------------------------------------ */
521
+
522
+ const status = missionStatusAt(mission, now);
523
+ if (status === MISSION_STATUS.REVOKED || status === MISSION_STATUS.COMPLETED) {
524
+ return deny(
525
+ STAGE.MISSION,
526
+ AUTHORITY_ERROR.MISSION_NOT_ACTIVE,
527
+ `Mission "${mission.name}" is ${status}. Authority ended with it.`,
528
+ ESCAPE.MISSION_VIOLATION,
529
+ );
530
+ }
531
+ if (mission.agent && call.agent && mission.agent !== call.agent) {
532
+ /* A mission belongs to one agent. Another agent presenting it is trying to
533
+ borrow authority, which is the delegation attack in its simplest form. */
534
+ return deny(
535
+ STAGE.MISSION,
536
+ AUTHORITY_ERROR.MISSION_WRONG_AGENT,
537
+ `Mission "${mission.name}" authorizes ${mission.agent}, not ${call.agent}.`,
538
+ ESCAPE.DELEGATION_ESCAPE,
539
+ );
540
+ }
541
+
542
+ /* ---- 2. CAPABILITY --------------------------------------------------- */
543
+
544
+ const covering = mission.capabilities.filter((c) => scopePermits(c.scope, requested));
545
+
546
+ if (!covering.length) {
547
+ return deny(
548
+ STAGE.CAPABILITY,
549
+ AUTHORITY_ERROR.CAPABILITY_NOT_GRANTED,
550
+ `${requested.action} on ${requested.resource || "(no resource)"} is outside this mission's ` +
551
+ `authorization set. Granted: ${base.granted.map((g) => g.name).join(", ") || "nothing"}.`,
552
+ ESCAPE.CAPABILITY_ESCALATION,
553
+ );
554
+ }
555
+
556
+ /* A capability exists but is not live. Reported distinctly from "never had
557
+ it": reissuing is the fix for one and an investigation is the fix for the
558
+ other, and an operator must not have to guess which they are looking at. */
559
+ const live = covering.find((c) => capabilityStatusAt(c, now) === CAPABILITY_STATUS.ACTIVE);
560
+ if (!live) {
561
+ const stale = covering[0];
562
+ const state = capabilityStatusAt(stale, now);
563
+ return deny(
564
+ STAGE.CAPABILITY,
565
+ state === CAPABILITY_STATUS.REVOKED
566
+ ? AUTHORITY_ERROR.CAPABILITY_REVOKED
567
+ : AUTHORITY_ERROR.CAPABILITY_EXPIRED,
568
+ state === CAPABILITY_STATUS.REVOKED
569
+ ? `Capability ${stale.name} was revoked.`
570
+ : `Capability ${stale.name} expired at ${new Date(stale.expiresAt).toISOString()}. ` +
571
+ `Stale authorization is not authority.`,
572
+ ESCAPE.EXPIRED_AUTHORITY,
573
+ { capability: { id: stale.id, name: stale.name, status: state, expiresAt: stale.expiresAt } },
574
+ );
575
+ }
576
+
577
+ base.capability = { id: live.id, name: live.name, scope: live.scope, expiresAt: live.expiresAt };
578
+
579
+ /* ---- 3. CONSTRAINT --------------------------------------------------- */
580
+
581
+ /* Per-capability conditions are ANDed with the mission's. A capability may
582
+ tighten its own use; it may never loosen the mission's. */
583
+ const merged = { ...(mission.constraints ?? {}), ...(live.conditions ?? {}) };
584
+ const constraints = evaluateConstraints(merged, { ...call, ...requested }, mission, now);
585
+ base.constraints = constraints;
586
+
587
+ if (!constraints.ok) {
588
+ const first = constraints.violations[0];
589
+ return deny(
590
+ STAGE.CONSTRAINT,
591
+ AUTHORITY_ERROR.CONSTRAINT_VIOLATED,
592
+ first.reason,
593
+ first.escape,
594
+ { constraintViolated: first },
595
+ );
596
+ }
597
+
598
+ /* ---- 4. EXPIRY ------------------------------------------------------- */
599
+
600
+ if (status === MISSION_STATUS.EXPIRED) {
601
+ return deny(
602
+ STAGE.EXPIRY,
603
+ AUTHORITY_ERROR.MISSION_EXPIRED,
604
+ `Authorization expired at ${new Date(mission.expiresAt).toISOString()}. ` +
605
+ `The capability is intact; the mission's window is not.`,
606
+ ESCAPE.EXPIRED_AUTHORITY,
607
+ );
608
+ }
609
+
610
+ return { ...base, authorized: true, stage: null, code: null, reason: null, escape: null };
611
+ }
612
+
613
+ /* -------------------------------------------------------------------------- */
614
+ /* Applying it */
615
+ /* -------------------------------------------------------------------------- */
616
+
617
+ /**
618
+ * Narrows a policy decision by the authority assessment.
619
+ *
620
+ * Same contract as `applyDelegation`: mutates in place, can only make the
621
+ * decision stricter, and leaves an already-denied call under its own rule
622
+ * rather than re-attributing the refusal. An operator reading one record needs
623
+ * the reason it was FIRST refused, not the last check that would also have
624
+ * refused it.
625
+ *
626
+ * @returns the authority context for the audit record, or null when inert
627
+ */
628
+ export function applyAuthority(decision, assessment) {
629
+ if (!assessment || !assessment.applicable) return null;
630
+
631
+ const context = {
632
+ mission: assessment.mission,
633
+ capability: assessment.capability,
634
+ granted: assessment.granted?.map((g) => g.name) ?? [],
635
+ requested: assessment.requested,
636
+ stage: assessment.stage,
637
+ code: assessment.code,
638
+ constraints: {
639
+ checked: assessment.constraints?.checked ?? [],
640
+ violated: assessment.constraints?.violations?.map((v) => v.constraint) ?? [],
641
+ },
642
+ ...(assessment.escape ? { escape: assessment.escape } : {}),
643
+ };
644
+
645
+ if (!assessment.authorized && isForwarded(decision.decision)) {
646
+ decision.decision = DECISION.DENY;
647
+ decision.verdict = "deny";
648
+ decision.rule = `authority-${assessment.code}`;
649
+ decision.reason = assessment.reason;
650
+ decision.remediation = REMEDIATION[assessment.code] ?? "Reissue authority scoped to this call.";
651
+ decision.authorityStage = assessment.stage;
652
+ decision.escape = assessment.escape;
653
+ } else if (!assessment.authorized) {
654
+ /* Policy already refused. The attempt is still an escape attempt and must
655
+ still be recorded as one — otherwise an agent could probe the boundary
656
+ for free simply by choosing calls policy denies anyway. */
657
+ decision.escape = assessment.escape;
658
+ decision.authorityStage = assessment.stage;
659
+ }
660
+
661
+ return context;
662
+ }
663
+
664
+ const REMEDIATION = Object.freeze({
665
+ [AUTHORITY_ERROR.CAPABILITY_NOT_GRANTED]:
666
+ "This is outside the mission's authorization set. Ask for a capability scoped to this action and resource — do not retry the call.",
667
+ [AUTHORITY_ERROR.CAPABILITY_EXPIRED]:
668
+ "Request a fresh capability. Expired authorization cannot be reused.",
669
+ [AUTHORITY_ERROR.CAPABILITY_REVOKED]:
670
+ "This capability was revoked. Escalate to a human rather than seeking another route.",
671
+ [AUTHORITY_ERROR.CONSTRAINT_VIOLATED]:
672
+ "The capability covers this action, but the circumstances do not. Change the circumstances, not the capability.",
673
+ [AUTHORITY_ERROR.MISSION_EXPIRED]:
674
+ "Start a new mission. Work does not continue on a closed authorization.",
675
+ [AUTHORITY_ERROR.MISSION_NOT_ACTIVE]:
676
+ "This mission has ended. A new one must be issued by a human.",
677
+ [AUTHORITY_ERROR.MISSION_WRONG_AGENT]:
678
+ "Missions are not transferable. The owning agent must make this call, or delegate explicitly.",
679
+ });
680
+
681
+ /* -------------------------------------------------------------------------- */
682
+ /* Linting */
683
+ /* -------------------------------------------------------------------------- */
684
+
685
+ /** Every action the classifier can actually produce from a tool name. */
686
+ const KNOWN_ACTIONS = new Set(TAXONOMY.map((t) => t.action));
687
+
688
+ /**
689
+ * Finds capabilities that cannot ever match, and grants that are wider than
690
+ * their author probably meant.
691
+ *
692
+ * A DEAD CAPABILITY IS THE DANGEROUS KIND OF MISTAKE.
693
+ *
694
+ * Writing `knowledge.search` when the runtime derives `fs.search` from
695
+ * `search_knowledge` produces a capability that never matches anything. The
696
+ * mission looks generous in the console and grants nothing in practice, so the
697
+ * agent is blocked doing its own job — and the natural fix under deadline
698
+ * pressure is to widen the mission until the work goes through, which is how a
699
+ * scoped authorization quietly becomes `*`.
700
+ *
701
+ * This was not hypothetical. The first draft of the escape benchmark's own
702
+ * missions had three of them, and the failing signal was three legitimate
703
+ * control steps being refused rather than anything security-shaped.
704
+ *
705
+ * Warnings, never errors: an unrecognised tool legitimately becomes
706
+ * `tool.<name>` or `mcp.<server>.<tool>`, so a custom action is a normal thing
707
+ * to grant. What is reported is "no known tool produces this", which is the
708
+ * useful half.
709
+ */
710
+ export function lintMission(mission) {
711
+ /* Always normalize — now safe, because normalizeCapability is idempotent. */
712
+ const m = normalizeMission(mission ?? {});
713
+ const findings = [];
714
+
715
+ if (!m.capabilities.length) {
716
+ findings.push({
717
+ severity: "warn",
718
+ code: "no_capabilities",
719
+ message: `Mission "${m.name}" grants nothing. Every call under it will be refused at the capability stage.`,
720
+ });
721
+ }
722
+
723
+ for (const cap of m.capabilities) {
724
+ for (const action of cap.scope.actions) {
725
+ if (action === "*" || action === "**" || /[*?]/.test(action)) continue;
726
+ if (KNOWN_ACTIONS.has(action)) continue;
727
+ if (/^(tool|mcp)\./.test(action)) continue;
728
+ findings.push({
729
+ severity: "warn",
730
+ code: "unreachable_action",
731
+ capability: cap.name,
732
+ action,
733
+ message:
734
+ `No known tool classifies to "${action}", so capability "${cap.name}" can never match. ` +
735
+ `Grant the action the runtime derives instead — check with \`classifyTool\`.`,
736
+ });
737
+ }
738
+
739
+ /*
740
+ * AN OUTBOUND CAPABILITY BOUNDED ONLY BY HOST IS BARELY BOUNDED.
741
+ *
742
+ * The escape benchmark found this one: a support mission granted
743
+ * `http.request` on `*` and allowlisted `api.cirvix.com`, which reads as a
744
+ * tight grant and is not. The host allowlist stops exfiltration to an
745
+ * attacker's domain and does nothing about the admin endpoint on the
746
+ * allowed domain — so an agent that finds a token in its own legitimate
747
+ * work can spend it against the org's own API.
748
+ *
749
+ * The network constraint cannot fix this on its own: it is asked "may this
750
+ * host be reached", which is the wrong granularity. The resource axis of
751
+ * the capability is where a URL prefix belongs.
752
+ */
753
+ const outbound = cap.scope.actions.some((a) => /^(http\.request|net\.|network\.)/.test(a));
754
+ if (outbound && cap.scope.resources.some((r) => r === "*" || r === "**")) {
755
+ findings.push({
756
+ severity: "warn",
757
+ code: "unbounded_egress",
758
+ capability: cap.name,
759
+ message:
760
+ `Capability "${cap.name}" allows outbound requests to any URL. A host allowlist still ` +
761
+ `permits every endpoint on an allowed host, including administrative ones. Scope the ` +
762
+ `resource axis to a URL prefix, e.g. "https://api.example.com/v1/tickets/**".`,
763
+ });
764
+ }
765
+
766
+ const wideAction = cap.scope.actions.some((a) => a === "*" || a === "**");
767
+ const wideResource = cap.scope.resources.some((r) => r === "*" || r === "**");
768
+ if (wideAction && wideResource) {
769
+ findings.push({
770
+ severity: "warn",
771
+ code: "unbounded_capability",
772
+ capability: cap.name,
773
+ message: `Capability "${cap.name}" is unbounded on both axes — it grants everything policy allows.`,
774
+ });
775
+ }
776
+
777
+ if (cap.expiresAt != null && cap.expiresAt <= m.issuedAt) {
778
+ findings.push({
779
+ severity: "warn",
780
+ code: "born_expired",
781
+ capability: cap.name,
782
+ message: `Capability "${cap.name}" expires at or before the mission was issued; it is dead on arrival.`,
783
+ });
784
+ }
785
+ }
786
+
787
+ for (const key of Object.keys(m.constraints ?? {})) {
788
+ if (!CONSTRAINTS[key]) {
789
+ findings.push({
790
+ severity: "warn",
791
+ code: "unknown_constraint",
792
+ constraint: key,
793
+ message:
794
+ `"${key}" is not a constraint this runtime evaluates, so it restricts nothing. ` +
795
+ `Known: ${Object.keys(CONSTRAINTS).join(", ")}.`,
796
+ });
797
+ }
798
+ }
799
+
800
+ return { ok: findings.length === 0, findings };
801
+ }
802
+
803
+ /* -------------------------------------------------------------------------- */
804
+ /* Registry */
805
+ /* -------------------------------------------------------------------------- */
806
+
807
+ /**
808
+ * Holds missions and the escape attempts made against them.
809
+ *
810
+ * In-memory by design at this layer — the control plane persists; the runtime
811
+ * carries only what the current process needs to decide. Keeping the store
812
+ * behind a small interface is what lets both share this file.
813
+ */
814
+ export class MissionRegistry {
815
+ #missions = new Map();
816
+ #byAgent = new Map();
817
+ #escapes = [];
818
+
819
+ /** @param {object} mission raw or normalized */
820
+ issue(mission, { now = Date.now() } = {}) {
821
+ const m = normalizeMission(mission, { now });
822
+ this.#missions.set(m.id, m);
823
+ if (m.agent) this.#byAgent.set(m.agent, m.id);
824
+ return m;
825
+ }
826
+
827
+ get(id) {
828
+ return this.#missions.get(id) ?? null;
829
+ }
830
+
831
+ /** The mission an agent is currently acting under, if any. */
832
+ forAgent(agent) {
833
+ const id = this.#byAgent.get(agent);
834
+ return id ? this.#missions.get(id) ?? null : null;
835
+ }
836
+
837
+ list() {
838
+ return [...this.#missions.values()];
839
+ }
840
+
841
+ revoke(id, { reason = null } = {}) {
842
+ const m = this.#missions.get(id);
843
+ if (!m) return null;
844
+ m.status = MISSION_STATUS.REVOKED;
845
+ m.revokedReason = reason;
846
+ return m;
847
+ }
848
+
849
+ complete(id) {
850
+ const m = this.#missions.get(id);
851
+ if (!m) return null;
852
+ m.status = MISSION_STATUS.COMPLETED;
853
+ return m;
854
+ }
855
+
856
+ /** Revokes ONE capability without ending the mission. */
857
+ revokeCapability(missionId, capabilityId) {
858
+ const m = this.#missions.get(missionId);
859
+ const c = m?.capabilities.find((x) => x.id === capabilityId);
860
+ if (!c) return null;
861
+ c.status = CAPABILITY_STATUS.REVOKED;
862
+ return c;
863
+ }
864
+
865
+ /**
866
+ * Records what a call consumed.
867
+ *
868
+ * Only called for a call that actually went through. A denied call must not
869
+ * consume budget or rate — otherwise an attacker could exhaust a mission's
870
+ * allowance using calls that were refused anyway, turning every constraint
871
+ * into a denial-of-service against the agent's real work.
872
+ */
873
+ record(missionId, { costUsd = 0, now = Date.now() } = {}) {
874
+ const m = this.#missions.get(missionId);
875
+ if (!m) return null;
876
+ m.usage.spendUsd += Number(costUsd) || 0;
877
+ m.usage.calls.push(now);
878
+ /* Bounded: only the rate window is ever read. */
879
+ if (m.usage.calls.length > 4096) m.usage.calls = m.usage.calls.slice(-2048);
880
+ return m.usage;
881
+ }
882
+
883
+ /** Every refusal is an attempt worth keeping. */
884
+ recordEscape(entry) {
885
+ const e = { at: Date.now(), ...entry };
886
+ this.#escapes.push(e);
887
+ if (this.#escapes.length > 10_000) this.#escapes = this.#escapes.slice(-5_000);
888
+ return e;
889
+ }
890
+
891
+ escapes({ missionId = null, agent = null, limit = 100 } = {}) {
892
+ return this.#escapes
893
+ .filter((e) => (!missionId || e.missionId === missionId) && (!agent || e.agent === agent))
894
+ .slice(-limit)
895
+ .reverse();
896
+ }
897
+
898
+ /** Counts by escape kind, for the passport and the benchmark. */
899
+ escapeSummary({ agent = null } = {}) {
900
+ const out = { total: 0, blocked: 0, byKind: {} };
901
+ for (const e of this.#escapes) {
902
+ if (agent && e.agent !== agent) continue;
903
+ out.total++;
904
+ if (e.blocked) out.blocked++;
905
+ out.byKind[e.kind] = (out.byKind[e.kind] ?? 0) + 1;
906
+ }
907
+ return out;
908
+ }
909
+ }