@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.
Files changed (45) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +42 -0
  3. package/README.md +341 -0
  4. package/action/README.md +100 -0
  5. package/action/action.yml +134 -0
  6. package/action/report.mjs +144 -0
  7. package/bin/cirvix.mjs +1073 -0
  8. package/package.json +60 -0
  9. package/src/commands/demo.mjs +315 -0
  10. package/src/commands/init.mjs +558 -0
  11. package/src/commands/policy.mjs +345 -0
  12. package/src/commands/sarif.mjs +176 -0
  13. package/src/commands/scan.mjs +210 -0
  14. package/src/commands/status.mjs +208 -0
  15. package/src/commands/upgrade.mjs +162 -0
  16. package/src/core/approvals.mjs +388 -0
  17. package/src/core/audit.mjs +181 -0
  18. package/src/core/canonical.mjs +316 -0
  19. package/src/core/daemon.mjs +352 -0
  20. package/src/core/decisions.mjs +253 -0
  21. package/src/core/delegation.mjs +658 -0
  22. package/src/core/detect.mjs +337 -0
  23. package/src/core/entitlement-gate.mjs +100 -0
  24. package/src/core/entitlements.mjs +285 -0
  25. package/src/core/format.mjs +33 -0
  26. package/src/core/gateway.mjs +959 -0
  27. package/src/core/guard.mjs +568 -0
  28. package/src/core/http-transport.mjs +505 -0
  29. package/src/core/journal.mjs +419 -0
  30. package/src/core/jsonrpc.mjs +152 -0
  31. package/src/core/meter.mjs +225 -0
  32. package/src/core/normalize.mjs +516 -0
  33. package/src/core/notices.mjs +80 -0
  34. package/src/core/pipeline.mjs +629 -0
  35. package/src/core/policy-dsl.mjs +611 -0
  36. package/src/core/policy.mjs +710 -0
  37. package/src/core/prompts.mjs +146 -0
  38. package/src/core/risk.mjs +509 -0
  39. package/src/core/sanitize.mjs +279 -0
  40. package/src/core/secret-detect.mjs +533 -0
  41. package/src/core/secrets.mjs +312 -0
  42. package/src/core/uds.mjs +383 -0
  43. package/src/core/vault.mjs +530 -0
  44. package/src/index.mjs +143 -0
  45. 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
+ }