# What structural signals indicate a delegation chain has silently lost attenuation: the transitive-confused-deputy boundary in agent capability graphs **Date of Record:** 2026 November 14 **Scope:** Applies to multi-agent systems where a delegating agent issues a scoped capability token (or capability-bearing reference) to a downstream agent, and that downstream agent may itself re-delegate. Does not apply to single-hop delegation where the grantee is a leaf and never issues further grants, nor to systems that enforce attenuation solely by server-side policy engines that re-evaluate the full chain on every call. ## Core Distinction A delegation chain is *attenuating* when every hop's effective authority is a subset of the previous hop's authority, and that subset relation is enforced at the point of use, not merely at the point of grant. The non-obvious failure is that most implementations check attenuation only when a token is *minted* (grant time) and not when it is *exercised* (use time). Between those two moments, authority can be silently *widened* by three mechanisms that leave the audit trail looking clean: 1. **Scope union on re-delegation.** Agent B holds scopes `{read:db}`. It mints a token for agent C with `{read:db, write:db}` because C "needed" write and B had a helper that merged its own scope set with a requested set. The mint check compared C's token to a *policy template*, not to B's actual token, so the subset relation to B was never evaluated. 2. **Reference leakage across the chain.** C receives a capability *reference* (a handle, an object pointer, a URL with an embedded token) that resolves to B's original authority rather than an attenuated view. The attenuation was applied to the *label* on the reference, not to the *resolution path*. 3. **Ambient authority capture.** The downstream agent executes in a context (a process, a service account, a mounted credential) whose ambient privileges exceed the delegated capability, and the runtime honors ambient authority on the fallback path when the explicit capability check fails open. The structural signal that distinguishes a sound chain from a silently-widened one is not the token contents. It is whether the *capability resolution path* is a function of the full ancestor set at use time. If the resolver can answer "what am I allowed to do?" without consulting the chain, attenuation is unverified regardless of what the tokens say. ## What Was Observed In a three-agent pipeline (planner → executor → tool-runner), the audit log recorded every grant with a clean subset annotation at each hop. A post-incident reconstruction showed the tool-runner performed a `delete` that no ancestor had granted. The chain was: - Planner held `{read, write, delete}` on the resource namespace (over-broad by design; it was the root). - Planner granted executor `{read, write}` — correctly attenuated, and the mint-time check passed. - Executor granted tool-runner `{read}` — correctly attenuated at mint time. - Tool-runner's runtime resolved the capability through a shared cache keyed by `resource_id` only, not by `(resource_id, capability_id)`. The cache had been populated during a *prior* request by the planner's own context, so the tool-runner's `read` handle resolved to the planner's cached `delete`-capable connection. Every token in the chain was a strict subset of its parent. Every grant was logged. The audit trail was internally consistent and wrong. The failure lived entirely in the resolution path: the cache key omitted the capability identity, so authority was inherited by *coincidence of resource*, not by delegation. A second observation: when the executor was asked to re-delegate the same `{read}` scope to a sibling, it minted a token whose `parent` field pointed to the *planner*, not to itself. This was an "optimization" to shorten the chain. The sibling's token was a subset of the planner's authority but *not* a subset of the executor's, so the executor effectively laundered authority it never held. The audit trail showed a valid parent link and a valid subset check — against the wrong parent. ## Condition of Validity The subset invariant `authority(hop_n) ⊆ authority(hop_{n-1})` is only meaningful when three conditions hold jointly: - **Chain-anchored resolution.** The use-time resolver must compute effective authority as the intersection over all ancestors, not read a single token's claims. This means the resolver needs the chain, which means the chain must be carried (as a signed lineage, a macaroon-style caveat chain, or an unforgeable parent pointer) and verified at use. - **Capability-identity-keyed caching.** Any cache, connection pool, or memoized handle in the resolution path must key on the capability identity (or its cryptographic digest), never on the resource alone. A resource-keyed cache is an ambient-authority channel. - **Parent-link integrity.** A re-delegation must be provably issued by the holder of the parent capability. If the parent pointer is asserted by the grantee rather than signed by the grantor, chain-shortening (laundering) is possible and the subset check is vacuous. When any of these is absent, the audit trail can be complete and the attenuation claim false. The presence of a logged subset annotation at each hop is *not* evidence of attenuation; it is evidence that a mint-time check ran. ## Decision Boundary Treat a delegation chain as **unverified** — and refuse to rely on its audit trail for least-privilege claims — if any of the following structural signals is present: - The resolver's answer to "effective authority" is derivable from a single token without traversing ancestors. - Any cache or pool in the resolution path is keyed on resource identity rather than capability identity. - A child token's `parent` field is set by the child's issuer without a grantor signature binding it to the parent capability. - The mint-time check compares the child to a policy template or a role, rather than to the parent token's actual scope set. - The executor runs with ambient credentials that are honored on capability-check failure (fail-open fallback). Treat it as **verified** only when the resolver intersects the full chain at use time, caches are capability-keyed, parent links are grantor-signed, and capability-check failure is fail-closed. In the verified case, the audit trail's subset annotations are corroborating; in the unverified case they are decorative. The boundary is sharp: attenuation is a property of the *resolution path*, and the tokens are only as good as the path that honors them. ## Operational Effect Teams that instrument only grant-time checks will report 100% attenuation compliance while running chains that violate least privilege. The gap is invisible to token inspection and to grant logs; it surfaces only under adversarial re-delegation (laundering), cache cross-contamination (resource-keyed handles), or ambient-authority fallback. The practical remediation is to move the subset check to the use-time resolver, make the chain a first-class carried artifact, and key every cache on capability identity. Until then, a clean audit trail should be read as "every grant was logged," not "every grant was bounded." ```python class AttenuationUnverifiedError(RuntimeError): """Raised when a delegation chain's attenuation cannot be verified at use time.""" pass def verify_attenuation_chain(chain, resolver): """ chain: list of hops, root -> leaf. Each hop: {"cap_id", "scopes", "parent_cap_id", "parent_sig"}. resolver: object exposing .resolve(cap_id) -> set[str] and .cache_key(cap_id) -> str. Returns the effective scope set if the chain is sound; raises otherwise. """ if not chain: raise AttenuationUnverifiedError("empty delegation chain") effective = set(resolver.resolve(chain[0]["cap_id"])) for i, hop in enumerate(chain[1:], start=1): parent = chain[i - 1] # Parent link must be grantor-signed and point at the actual parent capability. if hop.get("parent_cap_id") != parent["cap_id"]: raise AttenuationUnverifiedError( f"hop {i}: parent link {hop.get('parent_cap_id')!r} " f"does not match actual parent {parent['cap_id']!r} (possible laundering)" ) if not hop.get("parent_sig"): raise AttenuationUnverifiedError( f"hop {i}: parent link is unsigned; chain-shortening cannot be ruled out" ) # Cache key must be capability-identity-keyed, not resource-keyed. if resolver.cache_key(hop["cap_id"]) == resolver.cache_key(parent["cap_id"]): raise AttenuationUnverifiedError( f"hop {i}: resolver cache key collides with parent; " f"ambient-authority channel present" ) hop_scopes = set(resolver.resolve(hop["cap_id"])) if not hop_scopes.issubset(effective): raise AttenuationUnverifiedError( f"hop {i}: scopes {hop_scopes - effective} exceed ancestor authority " f"{effective} (attenuation violated at use time)" ) effective &= hop_scopes return effective ```