@cirvix_ai/agent-control 0.1.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/LICENSE +202 -0
- package/NOTICE +42 -0
- package/README.md +341 -0
- package/action/README.md +100 -0
- package/action/action.yml +134 -0
- package/action/report.mjs +144 -0
- package/bin/cirvix.mjs +1073 -0
- package/package.json +60 -0
- package/src/commands/demo.mjs +315 -0
- package/src/commands/init.mjs +558 -0
- package/src/commands/policy.mjs +345 -0
- package/src/commands/sarif.mjs +176 -0
- package/src/commands/scan.mjs +210 -0
- package/src/commands/status.mjs +208 -0
- package/src/commands/upgrade.mjs +162 -0
- package/src/core/approvals.mjs +388 -0
- package/src/core/audit.mjs +181 -0
- package/src/core/canonical.mjs +316 -0
- package/src/core/daemon.mjs +352 -0
- package/src/core/decisions.mjs +253 -0
- package/src/core/delegation.mjs +658 -0
- package/src/core/detect.mjs +337 -0
- package/src/core/entitlement-gate.mjs +100 -0
- package/src/core/entitlements.mjs +285 -0
- package/src/core/format.mjs +33 -0
- package/src/core/gateway.mjs +959 -0
- package/src/core/guard.mjs +568 -0
- package/src/core/http-transport.mjs +505 -0
- package/src/core/journal.mjs +419 -0
- package/src/core/jsonrpc.mjs +152 -0
- package/src/core/meter.mjs +225 -0
- package/src/core/normalize.mjs +516 -0
- package/src/core/notices.mjs +80 -0
- package/src/core/pipeline.mjs +629 -0
- package/src/core/policy-dsl.mjs +611 -0
- package/src/core/policy.mjs +710 -0
- package/src/core/prompts.mjs +146 -0
- package/src/core/risk.mjs +509 -0
- package/src/core/sanitize.mjs +279 -0
- package/src/core/secret-detect.mjs +533 -0
- package/src/core/secrets.mjs +312 -0
- package/src/core/uds.mjs +383 -0
- package/src/core/vault.mjs +530 -0
- package/src/index.mjs +143 -0
- package/src/testing.mjs +145 -0
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent-to-agent delegation.
|
|
3
|
+
*
|
|
4
|
+
* Agent A ──▶ Agent B ──▶ tool
|
|
5
|
+
*
|
|
6
|
+
* One invariant, and everything in this file exists to enforce it:
|
|
7
|
+
*
|
|
8
|
+
* AN AGENT CANNOT GAIN AUTHORITY MERELY BECAUSE ANOTHER AGENT HAS IT.
|
|
9
|
+
*
|
|
10
|
+
* The failure this prevents is the confused deputy, and in a multi-agent system
|
|
11
|
+
* it is the default outcome rather than an edge case. A planner agent holds
|
|
12
|
+
* database authority; a summariser agent does not. The planner asks the
|
|
13
|
+
* summariser to "just run this one query". If the summariser's call is
|
|
14
|
+
* evaluated against the *planner's* authority, the summariser now has database
|
|
15
|
+
* access — permanently, invisibly, and by design rather than by bug.
|
|
16
|
+
*
|
|
17
|
+
* THE RULE: SCOPE ONLY NARROWS
|
|
18
|
+
*
|
|
19
|
+
* A delegation is a *subset* of what the issuer holds. Never a superset, never
|
|
20
|
+
* a sideways set. The effective authority of a chain is the intersection of
|
|
21
|
+
* every link in it, so going one hop deeper can only ever reduce what is
|
|
22
|
+
* reachable. That makes the depth of a chain irrelevant to its danger, which is
|
|
23
|
+
* the property that lets you allow delegation at all.
|
|
24
|
+
*
|
|
25
|
+
* DELEGATION IS A CONSTRAINT, NOT A GRANT
|
|
26
|
+
*
|
|
27
|
+
* The effective scope is ANDed with the policy, never ORed. A delegation can
|
|
28
|
+
* only take authority away from what policy already permits. There is
|
|
29
|
+
* deliberately no path by which presenting a token makes a denied call
|
|
30
|
+
* permitted — if there were, the token would be a capability, and a capability
|
|
31
|
+
* that leaks is authority that leaks.
|
|
32
|
+
*
|
|
33
|
+
* IDENTITY IS NOT A NAME
|
|
34
|
+
*
|
|
35
|
+
* A grant is bound to (issuer, subject) and signed. An agent claiming to be
|
|
36
|
+
* `planner` proves nothing; a grant that verifies under the runtime's key and
|
|
37
|
+
* names it as subject proves exactly one thing, which is what it says. Names
|
|
38
|
+
* are attacker-controlled strings and are treated as such throughout.
|
|
39
|
+
*
|
|
40
|
+
* WHAT THIS DOES NOT DO
|
|
41
|
+
*
|
|
42
|
+
* The signing key is local to one runtime. Two Cirvix instances on two machines
|
|
43
|
+
* cannot verify each other's grants without a shared key, and issuing one is
|
|
44
|
+
* the control plane's job rather than this file's. Stated here rather than
|
|
45
|
+
* implied, because "A2A works" would otherwise read as "across a fleet".
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
49
|
+
|
|
50
|
+
import { matchGlob } from "./policy.mjs";
|
|
51
|
+
import { canonicalAction } from "./normalize.mjs";
|
|
52
|
+
import { DECISION, isForwarded } from "./decisions.mjs";
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* How deep a delegation chain may go.
|
|
56
|
+
*
|
|
57
|
+
* Not a security boundary — scope narrowing already makes depth harmless — but
|
|
58
|
+
* an unbounded chain is an unbounded verification loop over attacker-supplied
|
|
59
|
+
* data, and a cycle check that walks forever is a denial of service.
|
|
60
|
+
*/
|
|
61
|
+
export const MAX_DEPTH = 8;
|
|
62
|
+
|
|
63
|
+
/** Default lifetime of a grant. Delegation is for a task, not for a quarter. */
|
|
64
|
+
const DEFAULT_TTL_MS = 15 * 60 * 1000;
|
|
65
|
+
|
|
66
|
+
export const DELEGATION_ERROR = {
|
|
67
|
+
UNSIGNED: "unsigned",
|
|
68
|
+
BAD_SIGNATURE: "bad_signature",
|
|
69
|
+
EXPIRED: "expired",
|
|
70
|
+
REVOKED: "revoked",
|
|
71
|
+
TOO_DEEP: "too_deep",
|
|
72
|
+
CYCLE: "cycle",
|
|
73
|
+
SUBJECT_MISMATCH: "subject_mismatch",
|
|
74
|
+
BROKEN_CHAIN: "broken_chain",
|
|
75
|
+
WIDENED: "widened",
|
|
76
|
+
UNKNOWN_TENANT: "unknown_tenant",
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/* -------------------------------------------------------------------------- */
|
|
80
|
+
/* Scope */
|
|
81
|
+
/* -------------------------------------------------------------------------- */
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A scope is `{ actions, resources }`, each a list of globs.
|
|
85
|
+
*
|
|
86
|
+
* `["*"]` means unrestricted *within whatever policy already allows* — it is
|
|
87
|
+
* not a grant of anything, because scope is only ever a constraint.
|
|
88
|
+
*/
|
|
89
|
+
export function normalizeScope(scope) {
|
|
90
|
+
/*
|
|
91
|
+
* ABSENT AND EMPTY ARE DIFFERENT, AND CONFLATING THEM IS AN ESCALATION.
|
|
92
|
+
*
|
|
93
|
+
* `undefined` means "not constrained on this axis" and becomes `["*"]`.
|
|
94
|
+
* `[]` means "constrained to nothing" and MUST stay empty.
|
|
95
|
+
*
|
|
96
|
+
* They were the same, and the consequence was severe: `intersectScopes`
|
|
97
|
+
* returns `[]` when two scopes do not overlap, and normalizing that back to
|
|
98
|
+
* `["*"]` turned the intersection of two disjoint authorities into universal
|
|
99
|
+
* authority. Two agents with nothing in common, delegating through each
|
|
100
|
+
* other, ended up able to do anything.
|
|
101
|
+
*/
|
|
102
|
+
const list = (v) => {
|
|
103
|
+
if (v === undefined || v === null) return ["*"];
|
|
104
|
+
const arr = Array.isArray(v) ? v : [v];
|
|
105
|
+
return arr.map(String);
|
|
106
|
+
};
|
|
107
|
+
return {
|
|
108
|
+
actions: list(scope?.actions).map(canonicalAction),
|
|
109
|
+
resources: list(scope?.resources),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* True when `pattern` permits everything `candidate` does.
|
|
115
|
+
*
|
|
116
|
+
* Glob-vs-glob containment is the hard case, and getting it wrong in either
|
|
117
|
+
* direction is a defect: too strict and a legitimate narrowing (`/src/**` to
|
|
118
|
+
* `/src/lib/**`) is refused; too loose and a widening slips through.
|
|
119
|
+
*
|
|
120
|
+
* The rule used here is a SOUND APPROXIMATION by literal prefix — it may refuse
|
|
121
|
+
* a narrowing it cannot prove, and it never accepts a widening:
|
|
122
|
+
*
|
|
123
|
+
* · a bare `*` or `**` in a scope means "unrestricted on this axis", so it
|
|
124
|
+
* covers anything, and only another bare wildcard covers it
|
|
125
|
+
* · a concrete candidate is covered when the pattern matches it
|
|
126
|
+
* · two globs: the candidate's literal prefix must extend the pattern's, and
|
|
127
|
+
* a candidate that crosses separators needs a pattern that also does
|
|
128
|
+
*/
|
|
129
|
+
function patternCovers(pattern, candidate) {
|
|
130
|
+
if (pattern === candidate) return true;
|
|
131
|
+
|
|
132
|
+
const universal = (p) => p === "*" || p === "**";
|
|
133
|
+
if (universal(pattern)) return true;
|
|
134
|
+
if (universal(candidate)) return false;
|
|
135
|
+
|
|
136
|
+
if (!/[*?]/.test(candidate)) return matchGlob(pattern, candidate);
|
|
137
|
+
|
|
138
|
+
const literalPrefix = (p) => p.split(/[*?]/)[0];
|
|
139
|
+
const patternPrefix = literalPrefix(pattern);
|
|
140
|
+
const candidatePrefix = literalPrefix(candidate);
|
|
141
|
+
|
|
142
|
+
if (!candidatePrefix.startsWith(patternPrefix)) return false;
|
|
143
|
+
// A single `*` does not cross `/`; a `**` does. Narrowing to something that
|
|
144
|
+
// crosses when the parent did not is widening.
|
|
145
|
+
if (!pattern.includes("**") && candidate.includes("**")) return false;
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* True when `child` grants nothing `parent` does not already grant.
|
|
151
|
+
*
|
|
152
|
+
* This is the check that makes narrowing mean narrowing. A delegation that
|
|
153
|
+
* fails it is rejected outright rather than silently clamped — clamping hides
|
|
154
|
+
* the attempt, and an agent trying to widen its authority is exactly the event
|
|
155
|
+
* an operator wants to see in the log.
|
|
156
|
+
*/
|
|
157
|
+
export function isNarrowing(parent, child) {
|
|
158
|
+
const p = normalizeScope(parent);
|
|
159
|
+
const c = normalizeScope(child);
|
|
160
|
+
|
|
161
|
+
const covered = (parentList, childList) =>
|
|
162
|
+
childList.every((item) => parentList.some((pattern) => patternCovers(pattern, item)));
|
|
163
|
+
|
|
164
|
+
return covered(p.actions, c.actions) && covered(p.resources, c.resources);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The intersection of two scopes — what both permit.
|
|
169
|
+
*
|
|
170
|
+
* Used to collapse a chain into one effective scope. Intersection rather than
|
|
171
|
+
* "the last link wins", because the last link is the least trusted party in the
|
|
172
|
+
* chain and letting it decide would invert the whole model.
|
|
173
|
+
*/
|
|
174
|
+
export function intersectScopes(a, b) {
|
|
175
|
+
const x = normalizeScope(a);
|
|
176
|
+
const y = normalizeScope(b);
|
|
177
|
+
|
|
178
|
+
const narrow = (left, right) => {
|
|
179
|
+
const out = [];
|
|
180
|
+
for (const item of right) {
|
|
181
|
+
// Keep the more specific of any pair that overlaps.
|
|
182
|
+
if (left.some((pattern) => patternCovers(pattern, item))) out.push(item);
|
|
183
|
+
}
|
|
184
|
+
for (const item of left) {
|
|
185
|
+
if (!out.includes(item) && right.some((pattern) => patternCovers(pattern, item))) out.push(item);
|
|
186
|
+
}
|
|
187
|
+
return out.length ? [...new Set(out)] : [];
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
return { actions: narrow(x.actions, y.actions), resources: narrow(x.resources, y.resources) };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** True when a scope permits this action on this resource. */
|
|
194
|
+
export function scopePermits(scope, { action, resource }) {
|
|
195
|
+
const s = normalizeScope(scope);
|
|
196
|
+
if (s.actions.length === 0 || s.resources.length === 0) return false;
|
|
197
|
+
const actionOk = s.actions.some((p) => matchGlob(p, action ?? ""));
|
|
198
|
+
const resourceOk = s.resources.some((p) => matchGlob(p, resource ?? ""));
|
|
199
|
+
return actionOk && resourceOk;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/* -------------------------------------------------------------------------- */
|
|
203
|
+
/* Grants */
|
|
204
|
+
/* -------------------------------------------------------------------------- */
|
|
205
|
+
|
|
206
|
+
/** Deterministic serialization, so a signature covers meaning rather than spacing. */
|
|
207
|
+
function canonicalGrant(grant) {
|
|
208
|
+
const scope = normalizeScope(grant.scope);
|
|
209
|
+
return JSON.stringify({
|
|
210
|
+
id: grant.id,
|
|
211
|
+
issuer: grant.issuer,
|
|
212
|
+
subject: grant.subject,
|
|
213
|
+
tenant: grant.tenant ?? null,
|
|
214
|
+
parent: grant.parent ?? null,
|
|
215
|
+
depth: grant.depth,
|
|
216
|
+
scope: { actions: [...scope.actions].sort(), resources: [...scope.resources].sort() },
|
|
217
|
+
issuedAt: grant.issuedAt,
|
|
218
|
+
expiresAt: grant.expiresAt,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function sign(grant, key) {
|
|
223
|
+
return createHmac("sha256", key).update(canonicalGrant(grant)).digest("hex");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function signatureMatches(grant, key) {
|
|
227
|
+
const expected = Buffer.from(sign(grant, key));
|
|
228
|
+
const actual = Buffer.from(String(grant.signature ?? ""));
|
|
229
|
+
if (expected.length !== actual.length) return false;
|
|
230
|
+
return timingSafeEqual(expected, actual);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/* -------------------------------------------------------------------------- */
|
|
234
|
+
/* The broker */
|
|
235
|
+
/* -------------------------------------------------------------------------- */
|
|
236
|
+
|
|
237
|
+
export class DelegationBroker {
|
|
238
|
+
/** id → grant, for chain walking and revocation. */
|
|
239
|
+
#grants = new Map();
|
|
240
|
+
#revoked = new Set();
|
|
241
|
+
/**
|
|
242
|
+
* subject → tenant, learned from root grants.
|
|
243
|
+
*
|
|
244
|
+
* An agent's tenancy is a property of who it IS, which only an operator
|
|
245
|
+
* establishes by creating its root. A grant states which tenant's authority
|
|
246
|
+
* it carries; this map states which tenant the presenting agent belongs to.
|
|
247
|
+
* The two must agree — see `#tenantMismatch`.
|
|
248
|
+
*/
|
|
249
|
+
#tenancy = new Map();
|
|
250
|
+
#key;
|
|
251
|
+
#next = 1;
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* @param {object} [opts]
|
|
255
|
+
* @param {Buffer|string} [opts.key] signing key; generated if absent
|
|
256
|
+
* @param {number} [opts.ttlMs]
|
|
257
|
+
* @param {(e:object)=>void} [opts.onEvent]
|
|
258
|
+
*/
|
|
259
|
+
constructor({ key, ttlMs = DEFAULT_TTL_MS, onEvent = () => {} } = {}) {
|
|
260
|
+
// Generated per runtime when not supplied. A predictable key would let
|
|
261
|
+
// anyone who can read this source forge a grant.
|
|
262
|
+
this.#key = key ? Buffer.from(key) : randomBytes(32);
|
|
263
|
+
this.ttlMs = ttlMs;
|
|
264
|
+
this.onEvent = onEvent;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Registers a root authority — what an agent holds on its own.
|
|
269
|
+
*
|
|
270
|
+
* Roots are how an agent has any scope at all. They are not delegations and
|
|
271
|
+
* have no issuer; an operator creates them, and nothing an agent does can
|
|
272
|
+
* mint one.
|
|
273
|
+
*/
|
|
274
|
+
root(agent, scope, { tenant = null } = {}) {
|
|
275
|
+
/*
|
|
276
|
+
* An agent belongs to exactly one tenant.
|
|
277
|
+
*
|
|
278
|
+
* Re-rooting a known agent into a second tenant is not a configuration
|
|
279
|
+
* nuance, it is the cross-tenant escalation written as setup: register
|
|
280
|
+
* `globex-worker` in acme as well, and every acme grant it is handed now
|
|
281
|
+
* resolves. There is no legitimate call that needs this, so it is refused
|
|
282
|
+
* loudly at the point an operator makes the mistake rather than silently at
|
|
283
|
+
* the point an attacker exploits it.
|
|
284
|
+
*/
|
|
285
|
+
const known = this.#tenancy.get(String(agent));
|
|
286
|
+
if (known !== undefined && known !== tenant) {
|
|
287
|
+
throw new Error(
|
|
288
|
+
`${agent} is already rooted in tenant ${known === null ? "(none)" : known} and cannot also be rooted in ` +
|
|
289
|
+
`${tenant === null ? "(none)" : tenant}. An agent belongs to one tenant.`,
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
this.#tenancy.set(String(agent), tenant);
|
|
293
|
+
|
|
294
|
+
const grant = {
|
|
295
|
+
id: `dlg_root_${this.#next++}`,
|
|
296
|
+
issuer: null,
|
|
297
|
+
subject: String(agent),
|
|
298
|
+
tenant,
|
|
299
|
+
parent: null,
|
|
300
|
+
depth: 0,
|
|
301
|
+
scope: normalizeScope(scope),
|
|
302
|
+
issuedAt: Date.now(),
|
|
303
|
+
expiresAt: null,
|
|
304
|
+
};
|
|
305
|
+
grant.signature = sign(grant, this.#key);
|
|
306
|
+
this.#grants.set(grant.id, grant);
|
|
307
|
+
return grant;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Issues a delegation from `parentGrant`'s subject to `subject`.
|
|
312
|
+
*
|
|
313
|
+
* Refuses — rather than clamps — a scope the parent does not already hold.
|
|
314
|
+
* See `isNarrowing`.
|
|
315
|
+
*
|
|
316
|
+
* @returns {{ok:true, grant:object}|{ok:false, error:string, reason:string}}
|
|
317
|
+
*/
|
|
318
|
+
delegate(parentGrant, subject, scope, { ttlMs = this.ttlMs } = {}) {
|
|
319
|
+
const parent = typeof parentGrant === "string" ? this.#grants.get(parentGrant) : parentGrant;
|
|
320
|
+
|
|
321
|
+
if (!parent || !this.#grants.has(parent.id)) {
|
|
322
|
+
return { ok: false, error: DELEGATION_ERROR.BROKEN_CHAIN, reason: "The parent grant is not known to this broker." };
|
|
323
|
+
}
|
|
324
|
+
if (this.#revoked.has(parent.id)) {
|
|
325
|
+
return { ok: false, error: DELEGATION_ERROR.REVOKED, reason: `Grant ${parent.id} has been revoked.` };
|
|
326
|
+
}
|
|
327
|
+
if (parent.expiresAt && Date.now() > parent.expiresAt) {
|
|
328
|
+
return { ok: false, error: DELEGATION_ERROR.EXPIRED, reason: `Grant ${parent.id} has expired.` };
|
|
329
|
+
}
|
|
330
|
+
if (parent.depth + 1 > MAX_DEPTH) {
|
|
331
|
+
return { ok: false, error: DELEGATION_ERROR.TOO_DEEP, reason: `A delegation chain may be at most ${MAX_DEPTH} deep.` };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// A cycle would let authority laundered around a ring look like a fresh
|
|
335
|
+
// chain, and it makes verification non-terminating.
|
|
336
|
+
for (const link of this.#walk(parent)) {
|
|
337
|
+
if (link.subject === String(subject)) {
|
|
338
|
+
return {
|
|
339
|
+
ok: false,
|
|
340
|
+
error: DELEGATION_ERROR.CYCLE,
|
|
341
|
+
reason: `${subject} already appears in this chain; delegating back to it would be circular.`,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/*
|
|
347
|
+
* Checked here as well as at presentation, because an error an operator can
|
|
348
|
+
* see at issue time is worth far more than the same error surfacing as a
|
|
349
|
+
* mysterious denial in production. It is not sufficient on its own —
|
|
350
|
+
* tenancy can be registered after a grant is minted — so `resolve` checks
|
|
351
|
+
* it again where it can actually be enforced.
|
|
352
|
+
*/
|
|
353
|
+
const crossTenant = this.#tenantMismatch(subject, parent.tenant ?? null);
|
|
354
|
+
if (crossTenant) {
|
|
355
|
+
this.onEvent({
|
|
356
|
+
kind: "delegation_cross_tenant_refused",
|
|
357
|
+
issuer: parent.subject,
|
|
358
|
+
subject: String(subject),
|
|
359
|
+
tenant: parent.tenant ?? null,
|
|
360
|
+
});
|
|
361
|
+
return { ok: false, error: DELEGATION_ERROR.UNKNOWN_TENANT, reason: crossTenant };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (!isNarrowing(parent.scope, scope)) {
|
|
365
|
+
this.onEvent({ kind: "delegation_widening_refused", issuer: parent.subject, subject: String(subject) });
|
|
366
|
+
return {
|
|
367
|
+
ok: false,
|
|
368
|
+
error: DELEGATION_ERROR.WIDENED,
|
|
369
|
+
reason: `A delegation cannot grant more than the issuer holds. ${parent.subject} cannot give ${subject} authority it does not have itself.`,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const grant = {
|
|
374
|
+
id: `dlg_${this.#next++}`,
|
|
375
|
+
issuer: parent.subject,
|
|
376
|
+
subject: String(subject),
|
|
377
|
+
// Tenancy is inherited, never chosen. An agent cannot delegate itself
|
|
378
|
+
// into another tenant.
|
|
379
|
+
tenant: parent.tenant ?? null,
|
|
380
|
+
parent: parent.id,
|
|
381
|
+
depth: parent.depth + 1,
|
|
382
|
+
scope: normalizeScope(scope),
|
|
383
|
+
issuedAt: Date.now(),
|
|
384
|
+
expiresAt: Date.now() + ttlMs,
|
|
385
|
+
};
|
|
386
|
+
grant.signature = sign(grant, this.#key);
|
|
387
|
+
this.#grants.set(grant.id, grant);
|
|
388
|
+
|
|
389
|
+
this.onEvent({ kind: "delegation_issued", id: grant.id, issuer: grant.issuer, subject: grant.subject });
|
|
390
|
+
return { ok: true, grant };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Why `subject` may not act under a grant carrying `tenant`, or null if it may.
|
|
395
|
+
*
|
|
396
|
+
* THE BOUNDARY A CUSTOMER WILL NEVER ACCEPT BEING SOFT.
|
|
397
|
+
*
|
|
398
|
+
* Tenancy was previously inherited and *recorded* — a cross-tenant delegation
|
|
399
|
+
* resolved successfully and showed up honestly in the audit log. But a record
|
|
400
|
+
* of a breach is not a control against one. `acme-planner` could hand
|
|
401
|
+
* `globex-worker` a signed, correctly-narrowing grant, and globex's agent
|
|
402
|
+
* would act inside acme's authority with nothing refusing it. The
|
|
403
|
+
* `UNKNOWN_TENANT` error existed for exactly this and was never raised
|
|
404
|
+
* anywhere in the codebase.
|
|
405
|
+
*
|
|
406
|
+
* THE RULE: a tenanted grant presented by an agent with a KNOWN, DIFFERENT
|
|
407
|
+
* tenancy is refused.
|
|
408
|
+
*
|
|
409
|
+
* WHY "UNKNOWN" IS NOT ALSO REFUSED, WHICH IS THE INTERESTING HALF.
|
|
410
|
+
*
|
|
411
|
+
* The stricter rule — refuse anyone whose tenancy is not registered — looks
|
|
412
|
+
* safer and is wrong. Delegating to an agent that has no root of its own is
|
|
413
|
+
* the ORDINARY case: a planner spawns a helper for one task, and that helper
|
|
414
|
+
* never gets an operator-created root. Refusing there would break normal
|
|
415
|
+
* single-tenant use to defend a boundary nobody crossed.
|
|
416
|
+
*
|
|
417
|
+
* And it defends nothing. To present a grant you must be its signed subject,
|
|
418
|
+
* so an attacker inventing an agent name cannot use a grant unless somebody
|
|
419
|
+
* inside the tenant already minted one FOR that name — which is the tenant
|
|
420
|
+
* deliberately vouching for it. Naming yourself `helper` gains nothing; the
|
|
421
|
+
* grant either exists and names you, or it does not.
|
|
422
|
+
*
|
|
423
|
+
* What is genuinely dangerous is the opposite shape: an agent that DOES have
|
|
424
|
+
* a tenancy, and it is a different one. `globex-worker` acting under acme's
|
|
425
|
+
* authority is one customer's agent inside another customer's data, no matter
|
|
426
|
+
* how correctly the chain narrows.
|
|
427
|
+
*
|
|
428
|
+
* A grant with no tenant at all is unaffected, because most installs never
|
|
429
|
+
* set one and a tenant check must not become a tax on single-tenant use.
|
|
430
|
+
*/
|
|
431
|
+
#tenantMismatch(subject, tenant) {
|
|
432
|
+
if (tenant === null || tenant === undefined) return null;
|
|
433
|
+
|
|
434
|
+
const known = this.#tenancy.get(String(subject));
|
|
435
|
+
if (known === undefined) return null;
|
|
436
|
+
if (known !== tenant) {
|
|
437
|
+
return (
|
|
438
|
+
`This delegation carries tenant ${tenant}, and ${subject} belongs to ` +
|
|
439
|
+
`${known === null ? "no tenant" : known}. Authority does not cross a tenant boundary.`
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Every link from a grant up to its root, nearest first. */
|
|
446
|
+
#walk(grant) {
|
|
447
|
+
const chain = [];
|
|
448
|
+
let current = grant;
|
|
449
|
+
let hops = 0;
|
|
450
|
+
while (current && hops++ <= MAX_DEPTH + 1) {
|
|
451
|
+
chain.push(current);
|
|
452
|
+
if (!current.parent) break;
|
|
453
|
+
current = this.#grants.get(current.parent);
|
|
454
|
+
}
|
|
455
|
+
return chain;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Resolves a presented grant into an effective scope, or refuses it.
|
|
460
|
+
*
|
|
461
|
+
* `presentedBy` is who is *making the call*. A grant naming somebody else as
|
|
462
|
+
* subject proves nothing about the caller, and accepting it is precisely the
|
|
463
|
+
* impersonation this exists to stop.
|
|
464
|
+
*
|
|
465
|
+
* @returns {{ok:true, scope:object, chain:string[], depth:number, tenant:string|null}
|
|
466
|
+
* |{ok:false, error:string, reason:string}}
|
|
467
|
+
*/
|
|
468
|
+
resolve(presented, presentedBy, { now = Date.now() } = {}) {
|
|
469
|
+
const grant = typeof presented === "string" ? this.#grants.get(presented) : presented;
|
|
470
|
+
|
|
471
|
+
if (!grant) {
|
|
472
|
+
return { ok: false, error: DELEGATION_ERROR.BROKEN_CHAIN, reason: "No such delegation." };
|
|
473
|
+
}
|
|
474
|
+
if (!grant.signature) {
|
|
475
|
+
return { ok: false, error: DELEGATION_ERROR.UNSIGNED, reason: "The delegation carries no signature." };
|
|
476
|
+
}
|
|
477
|
+
if (!signatureMatches(grant, this.#key)) {
|
|
478
|
+
// Covers both forgery and tampering: the signature is over the scope, the
|
|
479
|
+
// subject, the depth, and the parent, so editing any of them invalidates
|
|
480
|
+
// it.
|
|
481
|
+
return { ok: false, error: DELEGATION_ERROR.BAD_SIGNATURE, reason: "The delegation's signature does not verify." };
|
|
482
|
+
}
|
|
483
|
+
if (String(presentedBy) !== grant.subject) {
|
|
484
|
+
return {
|
|
485
|
+
ok: false,
|
|
486
|
+
error: DELEGATION_ERROR.SUBJECT_MISMATCH,
|
|
487
|
+
reason: `This delegation was issued to ${grant.subject}, and was presented by ${presentedBy}.`,
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// The tenant boundary. See `#tenantMismatch`.
|
|
492
|
+
const crossTenant = this.#tenantMismatch(presentedBy, grant.tenant ?? null);
|
|
493
|
+
if (crossTenant) {
|
|
494
|
+
return { ok: false, error: DELEGATION_ERROR.UNKNOWN_TENANT, reason: crossTenant };
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Walk to the root, verifying every link. A chain is only as valid as its
|
|
498
|
+
// weakest hop, and checking the presented grant alone would let a revoked
|
|
499
|
+
// parent keep authorizing through a still-valid child.
|
|
500
|
+
const chain = this.#walk(grant);
|
|
501
|
+
const rooted = chain[chain.length - 1];
|
|
502
|
+
if (rooted.parent) {
|
|
503
|
+
return { ok: false, error: DELEGATION_ERROR.BROKEN_CHAIN, reason: "The chain does not terminate in a root grant." };
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
let effective = null;
|
|
507
|
+
for (const link of chain) {
|
|
508
|
+
if (!this.#grants.has(link.id)) {
|
|
509
|
+
return { ok: false, error: DELEGATION_ERROR.BROKEN_CHAIN, reason: `Link ${link.id} is missing.` };
|
|
510
|
+
}
|
|
511
|
+
if (!signatureMatches(link, this.#key)) {
|
|
512
|
+
return { ok: false, error: DELEGATION_ERROR.BAD_SIGNATURE, reason: `Link ${link.id} does not verify.` };
|
|
513
|
+
}
|
|
514
|
+
if (this.#revoked.has(link.id)) {
|
|
515
|
+
return { ok: false, error: DELEGATION_ERROR.REVOKED, reason: `Link ${link.id} has been revoked.` };
|
|
516
|
+
}
|
|
517
|
+
if (link.expiresAt && now > link.expiresAt) {
|
|
518
|
+
return { ok: false, error: DELEGATION_ERROR.EXPIRED, reason: `Link ${link.id} expired.` };
|
|
519
|
+
}
|
|
520
|
+
effective = effective === null ? normalizeScope(link.scope) : intersectScopes(effective, link.scope);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
return {
|
|
524
|
+
ok: true,
|
|
525
|
+
scope: effective,
|
|
526
|
+
chain: chain.map((l) => l.id).reverse(),
|
|
527
|
+
principals: chain.map((l) => l.subject).reverse(),
|
|
528
|
+
depth: grant.depth,
|
|
529
|
+
tenant: grant.tenant ?? null,
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Revokes a grant and everything derived from it.
|
|
535
|
+
*
|
|
536
|
+
* Cascading, because revoking a link and leaving its children usable revokes
|
|
537
|
+
* nothing — the authority simply flows around the hole.
|
|
538
|
+
*/
|
|
539
|
+
revoke(id) {
|
|
540
|
+
const revoked = [];
|
|
541
|
+
const queue = [String(id)];
|
|
542
|
+
while (queue.length) {
|
|
543
|
+
const current = queue.shift();
|
|
544
|
+
if (this.#revoked.has(current)) continue;
|
|
545
|
+
this.#revoked.add(current);
|
|
546
|
+
revoked.push(current);
|
|
547
|
+
for (const grant of this.#grants.values()) {
|
|
548
|
+
if (grant.parent === current) queue.push(grant.id);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
this.onEvent({ kind: "delegation_revoked", ids: revoked });
|
|
552
|
+
return revoked;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
isRevoked(id) {
|
|
556
|
+
return this.#revoked.has(String(id));
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
get(id) {
|
|
560
|
+
return this.#grants.get(String(id)) ?? null;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** Which tenant an agent belongs to, or undefined if it has no root. */
|
|
564
|
+
tenantOf(agent) {
|
|
565
|
+
return this.#tenancy.get(String(agent));
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** Grants held, with no signatures — safe to print. */
|
|
569
|
+
inventory() {
|
|
570
|
+
return [...this.#grants.values()].map((g) => ({
|
|
571
|
+
id: g.id,
|
|
572
|
+
issuer: g.issuer,
|
|
573
|
+
subject: g.subject,
|
|
574
|
+
tenant: g.tenant,
|
|
575
|
+
depth: g.depth,
|
|
576
|
+
scope: g.scope,
|
|
577
|
+
revoked: this.#revoked.has(g.id),
|
|
578
|
+
expiresAt: g.expiresAt ? new Date(g.expiresAt).toISOString() : null,
|
|
579
|
+
}));
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/* -------------------------------------------------------------------------- */
|
|
584
|
+
/* The one place delegation is applied to a decision */
|
|
585
|
+
/* -------------------------------------------------------------------------- */
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Narrows an already-made decision by a presented delegation, in place.
|
|
589
|
+
*
|
|
590
|
+
* WHY THIS IS A FUNCTION AND NOT A BLOCK INSIDE THE PIPELINE
|
|
591
|
+
*
|
|
592
|
+
* It was a block inside the pipeline, and the consequence was the failure this
|
|
593
|
+
* codebase has now hit twice: a control that is real on one code path and
|
|
594
|
+
* absent on another.
|
|
595
|
+
*
|
|
596
|
+
* `Pipeline` serves the local socket. `Guard` serves MCP and the SDK. They are
|
|
597
|
+
* deliberately separate objects, and delegation existed only in the first —
|
|
598
|
+
* which meant a delegation could be presented over the socket and was silently
|
|
599
|
+
* ignored everywhere else. Because delegation only ever NARROWS, ignoring it is
|
|
600
|
+
* not a missing feature that fails safe. It is a widening: a worker delegated
|
|
601
|
+
* `fs.read` got everything policy allowed the moment its call arrived over MCP
|
|
602
|
+
* instead. Silence was the dangerous direction.
|
|
603
|
+
*
|
|
604
|
+
* So there is one implementation, both engines call it, and a test asserts the
|
|
605
|
+
* two produce the same rule for the same call.
|
|
606
|
+
*
|
|
607
|
+
* @param {object} decision mutated in place
|
|
608
|
+
* @param {object} opts
|
|
609
|
+
* @param {DelegationBroker} opts.broker
|
|
610
|
+
* @param {object|string} opts.presented the grant the caller presented
|
|
611
|
+
* @param {string} opts.agent who is making the call
|
|
612
|
+
* @param {string} opts.action canonical action
|
|
613
|
+
* @param {string} opts.resource
|
|
614
|
+
* @returns {{chain:string[], principals:string[], depth:number, tenant:string|null}|null}
|
|
615
|
+
* the delegation context for the audit record, or null if refused
|
|
616
|
+
*/
|
|
617
|
+
export function applyDelegation(decision, { broker, presented, agent, action, resource }) {
|
|
618
|
+
if (!presented || !broker) return null;
|
|
619
|
+
|
|
620
|
+
const resolved = broker.resolve(presented, agent);
|
|
621
|
+
|
|
622
|
+
if (!resolved.ok) {
|
|
623
|
+
decision.decision = DECISION.DENY;
|
|
624
|
+
decision.verdict = "deny";
|
|
625
|
+
decision.rule = `delegation-${resolved.error}`;
|
|
626
|
+
decision.reason = resolved.reason;
|
|
627
|
+
decision.remediation =
|
|
628
|
+
"Ask the issuing agent for a delegation scoped to this call, presented by the agent making it.";
|
|
629
|
+
return null;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const context = {
|
|
633
|
+
chain: resolved.chain,
|
|
634
|
+
principals: resolved.principals,
|
|
635
|
+
depth: resolved.depth,
|
|
636
|
+
tenant: resolved.tenant,
|
|
637
|
+
};
|
|
638
|
+
|
|
639
|
+
/*
|
|
640
|
+
* Only a decision that would otherwise go out is narrowed.
|
|
641
|
+
*
|
|
642
|
+
* A call already denied stays denied under its own rule — re-refusing it as
|
|
643
|
+
* "out of scope" would misattribute the refusal in the one record an operator
|
|
644
|
+
* later reads. And a delegation can never turn a denial into a permit, which
|
|
645
|
+
* is the property that makes it safe to accept a token at all.
|
|
646
|
+
*/
|
|
647
|
+
if (isForwarded(decision.decision) && !scopePermits(resolved.scope, { action, resource })) {
|
|
648
|
+
decision.decision = DECISION.DENY;
|
|
649
|
+
decision.verdict = "deny";
|
|
650
|
+
decision.rule = "delegation-out-of-scope";
|
|
651
|
+
decision.reason =
|
|
652
|
+
`Policy permits this call, but the delegation ${agent} is acting under does not cover it. ` +
|
|
653
|
+
`Authority narrows at every hop: ${resolved.principals.join(" → ")}.`;
|
|
654
|
+
decision.remediation = "The call is outside what was delegated. It is not a policy change you need.";
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
return context;
|
|
658
|
+
}
|