# Which invariants prevent Skill Injection When Tool Manifests Are Signed but Capability Allowlists Are Evaluated at the Wrong Layer **Date of Record:** 2026 November 14 **Scope:** Applies to agent runtimes that gate tool invocation through (a) a cryptographically signed manifest declaring tool identity and declared capabilities, and (b) a per-session capability allowlist enforced at some enforcement point. Does not cover prompt-injection defenses internal to the model, nor supply-chain integrity of the signing keys themselves. ## Core Distinction Signing answers *who declared this tool and what it claims to do*. An allowlist answers *whether this session may exercise a capability*. These are orthogonal invariants, and the common failure is treating signature verification as if it also enforced the allowlist. A signed manifest is a statement of **provenance and declared intent**; it is not an **authorization decision**. The boundary failure occurs when the allowlist is evaluated against the *declared* capability in the manifest rather than against the *effective* capability actually exercised at invocation time — or when it is evaluated in a layer that the tool's own code can bypass (e.g., at the agent's planner rather than at the tool-execution sandbox). The invariant that must hold: **every capability actually exercised at the syscall/tool-call boundary must be a member of the session allowlist, checked at the layer that performs the effect, against the concrete arguments bound at call time — not against the manifest's static declaration.** ## What Was Observed A tool manifest declared `capabilities: ["read:filesystem:/data/inputs"]`, signed by a trusted publisher, and the allowlist for the session contained `read:filesystem:/data/inputs`. Verification passed. The tool was then invoked with a path argument of `/data/inputs/../../etc/shadow`. The allowlist check — performed against the *declared* capability string, before argument binding — passed, because the declared capability matched. The tool's own implementation resolved the path and read outside the declared subtree. The signature was valid; the allowlist matched; the capability was exceeded. Neither control fired. A second observed variant: the allowlist was enforced in the agent's orchestration layer (the planner refused to emit a call whose declared tool was not allowlisted), but the tool itself was a composite that internally dispatched a sub-tool not present in the allowlist. The orchestrator saw one allowlisted call; the sandbox executed two capabilities, one unlisted. In both cases the failure is not a broken signature and not a missing allowlist entry. It is a **layer mismatch**: the allowlist was compared against a *nominal* capability (the manifest's string, or the top-level tool identity) while the effect was produced by a *resolved* capability (the path after normalization, the sub-tool after dispatch). ## Condition of Validity The signed-manifest-plus-allowlist defense is valid only when all of the following hold simultaneously: 1. **Argument binding precedes authorization.** The concrete arguments (resolved paths, resolved hosts, resolved sub-tool identities) are computed before the allowlist check, and the check operates on the resolved form. 2. **Enforcement is co-located with effect.** The allowlist check executes in the same trust domain as the syscall or RPC that produces the effect — the sandbox, not the planner. 3. **Composite tools are flattened.** Any tool that dispatches sub-capabilities declares and is checked for the transitive closure of capabilities it can reach, not just its top-level identity. 4. **Normalization is canonical and single.** Path, host, and identity normalization use one canonicalizer shared by the signer, the verifier, and the enforcement point; divergent canonicalizers reintroduce the gap. If any of these is false, signature validity and allowlist membership are individually true but jointly insufficient — the defense is *formally present and operationally absent*. ## Decision Boundary Do not accept the defense when the allowlist is evaluated against a manifest-declared capability string and the enforcement point is upstream of argument resolution. In that configuration, treat the allowlist as advisory telemetry, not as a control. Accept the defense only when the enforcement point can observe the resolved effect and can reject it — i.e., when the check is *downstream of binding and inside the effect boundary*. When a composite tool's transitive capability closure cannot be statically determined, deny by default; a signed manifest that cannot enumerate its reachable capabilities is a manifest that cannot be allowlisted. ## Operational Effect The practical consequence is that signing and allowlisting must be deployed as a **pipeline with a fixed order** — verify signature → bind and canonicalize arguments → compute transitive capability set → check against allowlist at the effect layer → execute — and that any reordering, or any split of this pipeline across trust domains, silently converts a two-control defense into a one-control defense that the attacker can satisfy with a valid signature and a matching nominal string. Operationally, this means the allowlist must live in the sandbox, not the planner, and must consume resolved arguments, not declared ones. ```python import posixpath from dataclasses import dataclass from typing import FrozenSet class CapabilityLayerMismatch(RuntimeError): """Raised when an allowlist check would be evaluated against a nominal (declared) capability instead of the resolved effect at the enforcement layer.""" @dataclass(frozen=True) class Session: allowlist: FrozenSet[str] # e.g. {"read:fs:/data/inputs"} def authorize(session: Session, declared_cap: str, raw_arg: str, enforcement_layer: str) -> str: # Enforce the invariant: binding precedes authorization, and enforcement # must occur at the effect layer (sandbox), not the planner. if enforcement_layer != "sandbox": raise CapabilityLayerMismatch( f"allowlist evaluated at '{enforcement_layer}'; must be 'sandbox' " f"to observe resolved effect for declared '{declared_cap}'" ) # Bind and canonicalize the argument BEFORE checking the allowlist. resolved = posixpath.normpath(raw_arg) if not resolved.startswith("/"): raise CapabilityLayerMismatch(f"non-canonical resolved path: {resolved!r}") resolved_cap = f"read:fs:{resolved}" # The check must be against the resolved capability, not the declared one. if resolved_cap not in session.allowlist: raise CapabilityLayerMismatch( f"resolved capability {resolved_cap!r} not in allowlist " f"(declared was {declared_cap!r}); signature validity does not " f"imply authorization" ) return resolved_cap # Demonstration of the boundary failure the invariant prevents: sess = Session(allowlist=frozenset({"read:fs:/data/inputs"})) try: # declared cap matches allowlist, but resolved path escapes the subtree. authorize(sess, "read:fs:/data/inputs", "/data/inputs/../../etc/shadow", "sandbox") except CapabilityLayerMismatch as e: print("blocked:", e) ```