# Why does an instance of a Delegation Chain Fail Its Attenuation Proof When the Confused-Deputy Boundary Is Crossed by a Re-Delegation Hop **Date of Record:** 2026 November 14 **Scope:** This record applies to any agent-to-agent delegation system where a principal issues a capability to an agent, which re-delegates an attenuated subset to a sub-agent, and the receiving service validates a chain of signed attenuation proofs before granting access. It does not apply to single-hop delegation, to systems that re-authenticate the end principal at every hop, or to capability systems that bind authority to the caller's identity rather than to a bearer token. ## Core Distinction The distinction that matters is between **attenuation as a monotone set operation on the authority lattice** and **attenuation as an ordering check on presented proofs**. In a correct implementation these coincide: if a child capability's authority set is a subset of the parent's, then every proof in the chain is dominated by its predecessor. The failure mode arises when the receiver validates *pairwise* domination (each hop is a subset of the hop before it) but does not validate *global* domination (the terminal capability is a subset of the root principal's original grant). Pairwise domination is transitive in a total order but not in a partial order when the lattice includes *negative* or *exclusion* elements — explicit denials, resource exclusions, or scoped revocations. The confused-deputy boundary is crossed at the exact hop where a re-delegation introduces an exclusion that the child then *removes* by re-scoping. The child is not claiming authority the parent held; it is claiming authority the parent *explicitly excluded*. Pairwise checks pass because the child's positive set is a subset of the parent's positive set. The exclusion, however, is not part of the positive set, and a naive subset test on positive sets ignores it. ## What Was Observed A three-hop chain: principal P grants agent A the capability `read` on `bucket/*` **except** `bucket/secrets/*`. Agent A re-delegates to agent B the capability `read` on `bucket/logs/*`. Agent B re-delegates to agent C the capability `read` on `bucket/logs/secrets/*`. At each hop the positive resource set is a strict subset of its predecessor: - A: `bucket/*` minus `bucket/secrets/*` - B: `bucket/logs/*` (subset of A's positive set) - C: `bucket/logs/secrets/*` (subset of B's positive set) Every pairwise check passes. The terminal capability C is granted. But `bucket/logs/secrets/*` intersects A's exclusion `bucket/secrets/*` only if the exclusion was written as a prefix match; if written as a glob `bucket/secrets/*`, it does not match `bucket/logs/secrets/*`, and the exclusion silently fails to propagate. The observed result is that agent C reads objects that principal P explicitly denied to agent A. The confused deputy is not B or C acting maliciously — it is the *validator* treating a positive-set subset relation as a complete attenuation proof. A second observed variant: the exclusion is correctly propagated as a set, but the receiver's proof format serializes exclusions as a flat list attached only to the *root* proof, and the intermediate hops' proofs omit the exclusion because the delegating agent's SDK normalized it away when the child's positive scope did not overlap the exclusion's literal prefix. The chain validates; the exclusion is gone. ## Condition of Validity The pairwise-subset validation is valid **only** when the authority lattice is a distributive lattice with no exclusion elements, or when exclusions are represented as first-class negative set members and the subset test is performed over the *signed* set (positive minus negative) at every hop, not over the positive set alone. Concretely, the invariant that must hold is: ``` authority(child) = authority(parent) ∩ child_scope − child_exclusions ``` and the validator must confirm `authority(child) ⊆ authority(parent)` over the full signed representation. When the implementation instead checks `child_positive ⊆ parent_positive`, validity holds only if `parent_exclusions ∩ child_positive = ∅`. That precondition is almost never checked, because it requires the validator to know the parent's exclusions at the time the child is presented — and in a stateless bearer-token model, the parent's exclusions are only recoverable by walking the entire proof chain, which many receivers skip for latency reasons. ## Decision Boundary The boundary is: **does the receiver reconstruct the full signed authority of every ancestor, or does it trust the immediate parent's attestation of its own authority?** If the receiver trusts the parent's attestation, then a single compromised or buggy intermediate hop can launder an exclusion. If the receiver reconstructs from the root, the cost is O(chain length) signature verifications and lattice intersections per request — acceptable for low-QPS control-plane operations, prohibitive for per-object data-plane reads. The operational rule: reconstruct from root for any capability whose scope intersects a known exclusion namespace, and cache the reconstruction keyed by the chain's terminal signature. Do not cache by the terminal capability's *content* hash alone, because two chains with identical terminal content can have different ancestor exclusions — this is the cache-poisoning variant of the same bug. ## Operational Effect When the boundary is crossed, the failure is silent. No signature fails, no subset check fails, no audit log records an anomaly. The only observable is that a resource the principal denied is read by a downstream agent, and the read is attributed to the terminal agent's identity, not the principal's — so the principal's denial policy appears to have been honored in every log line. Detection requires a separate invariant checker that, for each granted terminal capability, recomputes the signed authority intersection from root and compares against the granted scope. That checker must run out-of-band; it cannot be on the hot path without the latency cost noted above. The corrective pattern is to make exclusions *positive* members of the lattice — represent "read bucket/* except bucket/secrets/*" as two capabilities, a positive grant and an explicit deny token that must be carried and re-validated at every hop — so that the subset test over positive members is again complete. This trades expressiveness for verifiability, which is the correct trade at a trust boundary. ```python class AttenuationProofError(RuntimeError): """Raised when a delegation chain's terminal capability is not dominated by the root principal's signed authority, even though each pairwise hop passed a positive-set subset check.""" pass def validate_chain(root_authority, hops): """ root_authority: signed set with .positive and .excluded (both frozensets) hops: list of (signed_set, signature) from immediate child to terminal Returns the terminal signed set if the full chain is dominated by root. """ if not hops: raise AttenuationProofError("empty delegation chain") # Reconstruct signed authority from root, not from the immediate parent. current_positive = frozenset(root_authority.positive) current_excluded = frozenset(root_authority.excluded) for idx, (cap, sig) in enumerate(hops): if not verify_signature(cap, sig): raise AttenuationProofError(f"bad signature at hop {idx}") # Pairwise positive-subset check (the naive check). if not cap.positive <= current_positive: raise AttenuationProofError( f"hop {idx} claims positive scope outside parent" ) # The check that naive validators omit: the child must not # re-introduce anything the parent excluded. leaked = cap.positive & current_excluded if leaked: raise AttenuationProofError( f"hop {idx} re-delegates excluded scope: {sorted(leaked)}" ) # Child exclusions must be a superset of parent exclusions # restricted to the child's positive scope; otherwise the child # has silently dropped an ancestor exclusion. dropped = (current_excluded & cap.positive) - cap.excluded if dropped: raise AttenuationProofError( f"hop {idx} dropped ancestor exclusion: {sorted(dropped)}" ) current_positive = frozenset(cap.positive) current_excluded = frozenset(cap.excluded) return current_positive, current_excluded ```