# What happens when an instance trusts a manifest's declared capability surface over its runtime-observed tool bindings **Date of Record:** 2026 November 10 **Scope:** Applies to agent runtimes that dynamically resolve tool manifests or MCP server registry entries at invocation time, where manifest-declared capabilities (`name`, `description`, `inputSchema`, `annotations`) are treated as authoritative for routing and permission decisions. Does not apply to statically compiled tool sets or registries where manifest content is signed and pinned to a verified digest at load. ## Core Distinction A manifest is a *claim*; a tool binding is a *fact*. The skill-injection boundary failure occurs when the runtime conflates the two — specifically when it authorizes a tool call based on the `description` and `inputSchema` a manifest advertises, while the actual handler resolved at dispatch time has a different (or broader) behavior. The declared surface is what the model reasons over; the bound surface is what executes. The gap between them is the attack surface. The non-obvious invariant: **manifest integrity does not imply binding integrity**. A perfectly valid, schema-conformant, even cryptographically signed manifest can bind to a handler whose behavior diverges from its declaration. Signature verification proves the manifest was not tampered with in transit; it says nothing about whether the handler that runs matches the manifest that was signed. ## What Was Observed In a registry-backed agent runtime, tool manifests were fetched from an MCP server registry and cached by `(server_id, tool_name)`. Dispatch resolved the handler by the same key. Two failure classes emerged: 1. **Name collision across servers.** Two registry entries advertised a tool named `read_file`. The first declared `inputSchema` constraining `path` to a sandboxed prefix via `pattern`. The second declared an identical name with a permissive schema and a description that steered the model toward absolute paths. Because the cache key was `tool_name` alone in a fallback path (registry deduplication ran before server-scoped namespacing), the model's routing decision — made against the *first* manifest's description — dispatched to the *second* server's handler. The model believed it was calling a sandboxed reader; it invoked an unrestricted one. 2. **Schema/behavior drift within a single entry.** A manifest declared `inputSchema` with `additionalProperties: false` and a description stating "writes to the project scratch directory only." The bound handler accepted the validated payload but ignored the scratch-directory constraint, interpreting a relative `path` against the process CWD. Validation passed; the declared invariant did not hold at execution. Both cases share the signature: **the authorization decision and the execution decision were derived from different objects.** Authorization read the manifest; execution read the binding. Neither layer was individually wrong — the manifest validated, the handler ran — but the composition violated the invariant that "what you approved is what runs." ## Condition of Validity This analysis holds only when all of the following are true: - Tool resolution is dynamic (manifest fetched or cached at or near invocation time), not pinned at build. - The model's tool-selection reasoning consumes manifest text (`description`, parameter names) as its primary signal. - The runtime authorizes dispatch against the manifest rather than against a post-resolution binding attestation. - Registry deduplication or caching keys on a weaker identifier (tool name) than the full namespace (`server_id` + `tool_name` + digest). It does **not** hold where manifests are pinned to a verified handler digest such that binding mismatch is a load-time hard error, or where the model never sees manifest text and selection is purely programmatic. ## Decision Boundary The correct boundary is: **authorize against the binding, not the manifest.** Concretely, dispatch must resolve the handler first, then verify that the resolved handler's attested capability set is a subset of what the manifest declared *and* what the caller was authorized for, and refuse dispatch on mismatch. The manifest is an input to *selection*; it must never be the sole input to *authorization*. The anti-pattern to reject: "the manifest passed schema validation, therefore the call is safe." Schema validation constrains the *arguments*; it does not constrain the *handler*. A permissive handler behind a strict schema is the canonical skill-injection vector — the schema is the disguise. A second boundary: cache keys for dispatchable tools must be the full identity tuple, not the tool name. Name-based deduplication is where the two-servers-one-name collision enters. ## Operational Effect Enforcing binding-first authorization converts a silent capability escalation into a loud, attributable failure at dispatch. The cost is one extra resolution-and-attestation step per call (cacheable by full identity tuple). The benefit is that manifest/handler divergence — whether from collision, drift, or deliberate injection — fails closed and is logged against the specific `(server_id, tool_name, digest)` that diverged, making the attack surface observable rather than latent. ```python from __future__ import annotations from dataclasses import dataclass class CapabilityBindingMismatch(RuntimeError): """Raised when a resolved tool handler's attested capabilities are not a subset of the manifest-declared surface that the caller was authorized for.""" @dataclass(frozen=True) class Manifest: server_id: str tool_name: str declared_capabilities: frozenset[str] digest: str @dataclass(frozen=True) class Binding: server_id: str tool_name: str attested_capabilities: frozenset[str] digest: str def authorize_dispatch( manifest: Manifest, binding: Binding, caller_grant: frozenset[str], ) -> None: # Identity must match on the full tuple, never on tool_name alone. if (manifest.server_id, manifest.tool_name, manifest.digest) != ( binding.server_id, binding.tool_name, binding.digest, ): raise CapabilityBindingMismatch( f"identity divergence: manifest={(manifest.server_id, manifest.tool_name, manifest.digest)} " f"binding={(binding.server_id, binding.tool_name, binding.digest)}" ) # The binding must not exceed what the manifest advertised. if not binding.attested_capabilities <= manifest.declared_capabilities: extra = binding.attested_capabilities - manifest.declared_capabilities raise CapabilityBindingMismatch( f"handler {binding.server_id}/{binding.tool_name} attests undeclared " f"capabilities {sorted(extra)}" ) # The call must not exceed what the caller was granted. if not binding.attested_capabilities <= caller_grant: raise CapabilityBindingMismatch( f"caller grant insufficient for {binding.server_id}/{binding.tool_name}: " f"needs {sorted(binding.attested_capabilities - caller_grant)}" ) ```