# How does an instance detect a Broken Delegation Chain in Transitive Tool Authorization **Date of Record:** 2026 November 9 **Scope:** Applies to agent runtimes where a principal (user or service) delegates a scoped capability to Agent A, which in turn delegates a strictly-narrowed sub-capability to Agent B, which invokes a downstream tool on the principal's behalf. Does not apply to single-hop authorization, ambient-credential models (e.g., instance roles), or systems where every hop re-authenticates the human principal directly. ## Core Distinction A delegation chain is *structurally valid* when each link references a parent token whose signature verifies, whose expiry has not passed, and whose subject matches the delegating agent's identity. It is *semantically valid* only when the effective permission at the terminal hop is the **intersection** of every scope along the chain, not merely the scope asserted by the terminal token. The non-obvious failure is that these two properties diverge silently. A token can be perfectly signed, unexpired, correctly addressed, and still grant a capability the original principal never held — because a middle agent re-signed a *widened* scope, or because scope intersection was computed at issuance time against a stale parent, or because two sibling delegations from the same parent were recombined at a downstream hop into a union that neither branch was authorized to produce. The invariant that must hold: **for every terminal invocation, `effective_scope ⊆ scope(parent_token)` at every link, evaluated transitively, and the chain's root subject must equal the principal the tool ultimately acts for.** Structural verification alone does not establish this; it must be checked as a set-inclusion walk over the full chain, not a per-link local check. ## What Was Observed In a multi-agent tool-use deployment, Agent A held a delegation from a user principal scoped to `files:read:/projects/alpha`. Agent A delegated to Agent B a token scoped to `files:read:/projects/alpha`. Agent B, needing to summarize across projects, presented a *second* delegation it had separately obtained from Agent A — scoped to `files:read:/projects/beta` — to the same tool endpoint in the same session. The tool's authorizer verified each token independently against Agent A's public key, confirmed both were unexpired and correctly scoped, and executed a query whose effective path set was `/projects/alpha ∪ /projects/beta`. No single link was invalid. The failure was at the *chain composition* layer: the tool treated a set of presented tokens as alternatives rather than as a single chain requiring a common root and an intersected scope. The user principal had never granted `/projects/beta` to anyone; Agent A had synthesized that second delegation from an unrelated earlier grant that was itself legitimate but belonged to a different task context. Detection signal observed in the logs: two presented tokens shared a common issuer but had **disjoint parent-token identifiers** in their `parent_jti` claims. A correct authorizer treats multiple presented tokens as a *union request* that must be rejected unless they form a single linear chain sharing one root and one parent lineage. ## Condition of Validity The intersection invariant holds only under these conditions, and the check is meaningless outside them: 1. Every token carries an immutable `parent_jti` (or equivalent chain pointer) and the root `subject` identifier. Tokens without a chain pointer cannot be transitively validated and must be treated as depth-1 only. 2. Scope narrowing is enforced at issuance and re-verified at use. A middle agent that can re-sign a widened scope breaks the invariant regardless of downstream checks. 3. The authorizer receives the *entire* chain, not just the terminal token. If the tool sees only the leaf, it cannot intersect; it can only trust the leaf's self-asserted scope, which is exactly the failure mode above. 4. Chain depth is bounded and the bound is enforced (reject chains exceeding `max_depth`), because unbounded chains admit recombination attacks that are hard to distinguish from legitimate fan-out. 5. Clock skew across hops is bounded below the shortest token lifetime; otherwise expiry intersection is ill-defined. If any condition is unmet, the system is operating in a degraded mode where structural validity is being mistaken for semantic validity, and the correct behavior is to fail closed rather than to proceed on leaf-token trust. ## Decision Boundary The decision boundary is between **rejecting a plausible-looking multi-token presentation** and **accepting it as a legitimate fan-in**. Concretely: - **Reject** when presented tokens do not share a single root subject, or when their `parent_jti` values are not identical (for a single-hop fan-in) or form a single linear ancestor path (for deeper chains). This is the recombination case. - **Reject** when the intersection of scopes across the chain is empty but the leaf asserts a non-empty scope — this indicates a widening somewhere upstream. - **Reject** when any link's `parent_jti` does not match the `jti` of the immediately preceding presented token. A gap means a link was omitted, and an omitted link is an unverified link. - **Accept** only when the chain is linear, rooted at a single subject, fully present, and the terminal effective scope equals the intersection of all link scopes. The subtlety: a *legitimate* fan-in (Agent A genuinely delegates the union to Agent B in one token) looks superficially like the attack (two tokens combined). The distinguishing feature is that the legitimate case is a *single token with a union scope issued by one parent*, while the attack is *multiple tokens composed at the verifier*. The decision must be made on token cardinality and chain topology, not on scope contents. ## Operational Effect When the check is implemented, the observable effect is that recombination attempts fail at the authorizer with a chain-topology error rather than succeeding as over-broad queries. The cost is that legitimate multi-resource requests must be expressed as a single union-scoped delegation at issuance time, which pushes complexity to the delegating agent but keeps the verifier's logic sound. Latency impact is one additional chain walk per invocation (linear in depth, typically 2–4 hops). The failure mode when the check is *absent* is silent over-privilege: queries succeed, logs show valid signatures, and the only trace is disjoint `parent_jti` values that no one inspects. Instrument for that specific signal — multiple presented tokens with a shared issuer but divergent parent lineage — as the primary detection heuristic. ```python class DelegationChainError(RuntimeError): """Raised when a presented delegation chain is structurally valid but semantically unsound: broken lineage, missing link, or scope widening.""" pass class DelegationToken: def __init__(self, jti, parent_jti, subject, issuer, scope, expires_at): self.jti = jti self.parent_jti = parent_jti self.subject = subject self.issuer = issuer self.scope = frozenset(scope) self.expires_at = expires_at def verify_transitive_chain(tokens, required_scope, now, max_depth=6): """Verify a linear delegation chain and return the effective scope. Rejects: empty chains, missing links, divergent roots, divergent parent lineage across presented tokens, depth overflow, and scope widening. """ if not tokens: raise DelegationChainError("empty delegation chain presented") if len(tokens) > max_depth: raise DelegationChainError( f"chain depth {len(tokens)} exceeds max_depth {max_depth}" ) # All presented tokens must share one root subject. roots = {t.subject for t in tokens} if len(roots) != 1: raise DelegationChainError( f"divergent root subjects across presented tokens: {sorted(roots)}" ) # Multiple presented tokens must form a single linear ancestor path. # A fan-in of sibling tokens is a recombination attempt, not a chain. if len(tokens) > 1: parent_ids = [t.parent_jti for t in tokens] if len(set(parent_ids)) > 1: raise DelegationChainError( "multiple presented tokens with divergent parent lineage; " "recombination of sibling delegations is not permitted" ) # Walk the chain, enforcing contiguous lineage and scope intersection. ordered = sorted(tokens, key=lambda t: t.expires_at) effective = None for i, tok in enumerate(ordered): if tok.expires_at <= now: raise DelegationChainError(f"token {tok.jti} expired") if i > 0: prev = ordered[i - 1] if tok.parent_jti != prev.jti: raise DelegationChainError( f"broken lineage: token {tok.jti} claims parent " f"{tok.parent_jti} but preceding token is {prev.jti}" ) effective = tok.scope if effective is None else (effective & tok.scope) if effective is None: raise DelegationChainError("no effective scope computed") # Widening detection: the leaf must not assert more than the intersection. leaf = ordered[-1] if not leaf.scope <= effective: raise DelegationChainError( f"scope widening at leaf {leaf.jti}: " f"{sorted(leaf.scope - effective)} not granted by ancestors" ) if not required_scope <= effective: raise DelegationChainError( f"required scope {sorted(required_scope)} not covered by " f"effective scope {sorted(effective)}" ) return effective ```