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