# Which invariants prevent privilege re-expansion when attenuating tool-invocation capabilities across delegated agent calls **Date of Record:** 2026 November 9 **Scope:** Applies to runtime policy enforcement points that attenuate a capability set on delegation (parent agent → child agent, or orchestrator → tool-calling subprocess) and must guarantee the child cannot invoke a tool outside the parent's effective grant. Does not apply to capability *revocation* mid-flight, nor to grants that were never attenuated (identity-preserving re-issuance). ## Core Distinction The non-obvious failure is not "the child calls a tool it shouldn't." That is a straightforward authorization check and is caught by ordinary deny-by-default. The failure is **privilege re-expansion through composition**: the child holds two individually-attenuated capabilities whose *combination* reconstructs a right the parent never held, or held only in attenuated form. Attenuation is defined on individual capability tokens; it is almost never defined on the *closure* of the token set under composition. A child granted `read:/data/public` and `exec:sanitize` may be able to pipe the former into the latter's side channel and reconstitute `read:/data/private`. Each token is within bounds; the set is not. The distinction that matters operationally: **attenuation is a per-token lattice operation, but least privilege is a property of the reachable state space of the token *set*.** Enforcing the former does not enforce the latter. The gap between them is where re-expansion lives. ## What Was Observed A delegation path issued a child agent a capability set `{read:reports/*, write:reports/scratch/*, invoke:summarizer}`. The parent's grant was `{read:reports/*, write:reports/scratch/*}` — note the child received an *additional* `invoke:summarizer` that the parent did not hold, because the delegation logic treated "invoke" as a category orthogonal to "read/write" and applied attenuation only within each category. The summarizer tool, invoked with a `path` argument, internally performed a read on whatever path it was given and returned a truncated excerpt. The child therefore achieved `read:reports/scratch/*`-scoped summarizer reads over arbitrary paths — a strictly wider read surface than the parent, obtained by composing an inherited read grant with a newly-introduced invoke grant. The per-token checks all passed. The audit log showed every token within its declared scope. The re-expansion was invisible to any check that examined tokens one at a time, because no single token was out of bounds. It was only visible when the enforcement point computed the *transitive tool-mediated reachability* of the token set: `invoke:summarizer` ⊗ `path` ⇒ read, and the read was not constrained to the parent's read scope because the summarizer's internal read was not subject to the parent's attenuation at all. The second observed instance was subtler: attenuation was applied at issuance, but the child could *re-delegate* to a grandchild with a capability set it constructed itself, and the re-delegation check verified only that the grandchild's set was a subset of the *child's* set — not that it was a subset of the *parent's original* set. A child that had legitimately acquired `invoke:summarizer` could hand a grandchild `{invoke:summarizer, read:reports/*}` even though the child itself could not read `reports/*` directly, because the subset check compared against the child's declared set rather than the transitive floor. ## Condition of Validity The invariant holds only under all of the following: 1. **Monotone attenuation floor.** For every delegation edge, the effective set at the child is `S_child ⊆ S_parent` where `⊆` is evaluated over the *transitive closure* of tool-mediated effects, not over declared token names. If the enforcement point cannot compute that closure, it must treat every `invoke:*` token as carrying the union of the invoked tool's maximal effect, and attenuate against that. 2. **Non-introduction of orthogonal categories.** Attenuation must be a single partial order over the full capability space, not a per-category check. A delegation that adds a token in a category the parent lacks is a violation even if every category-local check passes. "Orthogonal" is a claim about the token vocabulary, not about the effect space, and the effect space is what matters. 3. **Transitive floor on re-delegation.** The subset check at re-delegation must be against the *root* grant's transitive floor, not the immediate delegator's declared set. Otherwise a chain of individually-valid subset steps walks the set back up toward the root and beyond. 4. **No authority-bearing default arguments.** Any tool whose behavior depends on arguments must have those arguments attenuated alongside the invoke token, or the invoke token must be scoped to a fixed argument set. An unscoped `invoke:summarizer` is `invoke:summarizer(path := anything)`, which is a read capability in disguise. When any of these is absent, the enforcement point is checking token containment while the attacker is exploiting effect composition, and the checks will pass while privilege expands. ## Decision Boundary Enforce at the **effect-closure** layer, not the token layer, when any of the following is true: - The capability vocabulary contains `invoke:*` tokens for tools with side effects or internal reads/writes. - Delegation is transitive (children can re-delegate). - Attenuation is expressed per-category rather than as a single lattice. Do **not** pay the closure-computation cost when the capability set is flat (no invoke tokens, no re-delegation, single category) — there the token-layer check is sound and the closure is the identity. The boundary is: **closed token vocabularies with no composition operators can be checked per-token; anything with an invoke edge must be checked on the closure.** Treat the presence of a single `invoke:*` token as flipping the entire enforcement point into closure mode, because composition is not local. Concretely: if `invoke` appears anywhere in the reachable set, compute `effect_closure(S) = S ∪ ⋃_{t ∈ S, t = invoke:tool} effects(tool)` and attenuate `effect_closure(S_child) ⊆ effect_closure(S_parent)`. If the closure is not computable (tool effects unknown), fail closed: reject the delegation rather than admit an unattenuated invoke. ## Operational Effect After switching the enforcement point to closure-mode, the observed re-expansion instances were rejected at delegation time with a clear cause (`effect_closure(child) ⊄ effect_closure(parent)`), and the audit log began attributing the rejection to the specific composed path (`invoke:summarizer → read:reports/scratch/* → read:reports/private`) rather than to an opaque token mismatch. The cost was a one-time tool-effect registration requirement: every invokable tool must declare its maximal effect set, and an undeclared tool is treated as `effects = ALL`, which fails closed under attenuation. This is a real operational tax — tool authors must annotate — but it is the only way to make the subset check sound under composition. The alternative, checking tokens per-category, is cheap and wrong: it passes exactly the delegations that re-expand privilege. The residual risk after closure-mode is tools whose declared effects are understated. That is a trust problem in the tool registry, not an enforcement problem, and must be handled by signing tool-effect declarations and treating registry compromise as a root-grant compromise. Enforcement cannot recover from a lying tool; it can only refuse to invoke tools it cannot bound. ```python from dataclasses import dataclass, field from typing import FrozenSet ALL_EFFECTS = frozenset({"*"}) class PrivilegeReexpansionError(RuntimeError): """Raised when a delegated capability set's effect closure exceeds the parent's.""" @dataclass(frozen=True) class Capability: kind: str # "read", "write", "invoke" target: str # path glob or tool name @dataclass class ToolRegistry: # declared maximal effect of each invokable tool; missing => ALL_EFFECTS (fail closed) effects: dict = field(default_factory=dict) def effect_closure(self, caps: FrozenSet[Capability]) -> FrozenSet[Capability]: closure = set(caps) frontier = [c for c in caps if c.kind == "invoke"] while frontier: cap = frontier.pop() declared = self.effects.get(cap.target, ALL_EFFECTS) if declared is ALL_EFFECTS: # cannot bound this tool: treat as granting everything return frozenset({Capability("*", "*")}) for eff in declared: if eff not in closure: closure.add(eff) if eff.kind == "invoke": frontier.append(eff) return frozenset(closure) def attenuate( parent: FrozenSet[Capability], child: FrozenSet[Capability], registry: ToolRegistry, ) -> FrozenSet[Capability]: """Return the child set iff its effect closure is contained in the parent's. Attenuation is checked on the closure, not per-token, because an invoke token composes with its arguments to reconstruct read/write rights the parent may not hold. Per-category checks pass exactly the bad cases. """ parent_closure = registry.effect_closure(parent) child_closure = registry.effect_closure(child) if parent_closure == frozenset({Capability("*", "*")}): # parent itself unbounded; cannot attenuate meaningfully raise PrivilegeReexpansionError( "parent effect closure is unbounded (undeclared tool); refusing delegation" ) if not child_closure.issubset(parent_closure): excess = child_closure - parent_closure raise PrivilegeReexpansionError( f"delegation re-expands privilege; excess effects: " f"{sorted((c.kind, c.target) for c in excess)}" ) return child def check_redelegation( root: FrozenSet[Capability], grandchild: FrozenSet[Capability], registry: ToolRegistry, ) -> FrozenSet[Capability]: """Re-delegation must attenuate against the ROOT floor, not the immediate delegator. Checking against the immediate delegator lets a chain of individually-valid subset steps walk the set back up toward the root and beyond. """ return attenuate(root, grandchild, registry) ```