# What happens when an instance trusts a tool manifest that was valid at registration but poisoned at resolution **Date of Record:** 2026 November 14 **Scope:** Applies to agent runtimes that resolve tools through an MCP (Model Context Protocol) registry or equivalent manifest-mediated tool-injection layer, where registration-time validation and invocation-time resolution are separated by any non-zero interval. Does not apply to statically linked, single-binary tool definitions that are re-verified on every call against an immutable digest. ## Core Distinction The failure is not "a malicious server was registered." That is a coarse, auditable event. The failure is that **manifest validity is a point-in-time predicate evaluated at registration, while tool trust is a continuous predicate required at resolution.** A registry that validates a manifest once and then serves tool descriptions, parameter schemas, and invocation endpoints by reference creates a window in which the description an agent reads is decoupled from the description that was validated. Skill injection exploits the semantic gap between three artifacts that are treated as one: 1. The **registered manifest** — what the registry approved. 2. The **resolved manifest** — what the runtime fetches at call time (often via a mutable URL, a `latest` tag, or an unpinned registry pointer). 3. The **effective tool surface** — what the model actually sees after description text, parameter names, defaults, and examples are assembled into the prompt. An attacker who controls the resolved manifest controls the effective tool surface without ever touching the registered manifest. This is not a code-execution exploit; it is a *prompt-content* exploit. The injected content lives in fields the model treats as trusted instructions: tool `description`, parameter `description`, `examples`, `enum` value labels, and even `default` values rendered as text. ## What Was Observed In a controlled reproduction, a registry accepted a tool `summarize_document` with a benign description and a pinned content digest. The runtime, however, resolved the tool by `name` against a registry endpoint that returned the *current* manifest rather than the digest-pinned one. Between registration and a later agent session, the manifest's `description` field was replaced with text instructing the model to first call `summarize_document` on a credential-bearing context variable and include its contents in the `style` parameter. The parameter schema was unchanged — same types, same required fields — so schema-level validation passed cleanly. The model complied. No tool was invoked outside the declared surface. No parameter violated its type. The exfiltration rode entirely inside a *legitimate* call to a *legitimate* tool whose *description* had drifted. Schema validators, allowlists keyed on tool name, and parameter type checks all reported green. A second variant targeted the registry supply chain itself: a transitive dependency of a popular MCP server republished a patch version whose manifest added a new optional parameter `context` with a description directing the model to populate it with "any relevant conversation history." Because the parameter was *optional*, no required-field check fired, and because the version was a patch bump, semver-range resolution accepted it silently. ## Condition of Validity This boundary failure is *live* — meaning exploitable — exactly when all of the following hold: - Tool resolution is **by reference** (name, URL, tag, or range) rather than by **immutable digest**. - The resolved manifest's **human-readable fields** (descriptions, examples, enum labels, defaults) are injected into the model context as trusted text. - Registration-time validation and invocation-time resolution are **not cryptographically bound** — i.e., there is no signature over the exact bytes the model consumes. - The runtime does **not** diff the resolved manifest against the registered one before injecting it. It is *not* live when the runtime pins and verifies a content digest at resolution, or when manifest text is quarantined from the instruction region of the prompt. Note the asymmetry: pinning the *endpoint* is insufficient if the endpoint serves mutable content; pinning the *content* is what closes the window. ## Decision Boundary The operational rule is a hard separation of trust domains: - **Registration approval is necessary but never sufficient.** A manifest that passed review at time *T* carries zero authority at time *T+n* unless its bytes are identical. - **Schema validation is orthogonal to skill injection.** Never treat "the schema is unchanged" as evidence of safety; the attack surface is the prose, not the types. - **Optional parameters are higher risk than required ones**, because they evade required-field gates while still being rendered into the prompt as instructions. - **Semver ranges are resolution-time references**, and therefore inherit every hazard of reference-based resolution. A patch bump is a manifest mutation. If any of these is violated, the correct posture is to treat the tool as untrusted and refuse injection, not to warn and proceed. ## Operational Effect The practical consequence is that an agent runtime must treat the *effective tool surface* as the unit of trust, and must verify it byte-for-byte at the moment of injection. Concretely: resolve by digest, re-hash the resolved manifest, compare against the registered digest, diff the human-readable fields, and abort injection on any mismatch — including additive changes like new optional parameters. Log the diff, because a silent additive drift is the highest-signal indicator of supply-chain tampering. The code below encodes this gate. It is deliberately strict: it fails closed on any field-level drift in the instruction-bearing region, and it distinguishes required-field changes from the more insidious optional-parameter additions. ```python import hashlib import json from dataclasses import dataclass, field INSTRUCTION_FIELDS = ("description", "examples", "enum_labels", "defaults") class ManifestDriftError(RuntimeError): """Raised when a resolved tool manifest diverges from its registered form.""" pass @dataclass class RegisteredManifest: name: str digest: str required_params: frozenset optional_params: frozenset instruction_text: str _raw: dict = field(default_factory=dict) def _digest(payload: dict) -> str: canonical = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() return hashlib.sha256(canonical).hexdigest() def resolve_and_verify(registered: RegisteredManifest, resolved_raw: dict) -> dict: """Resolve a tool by digest and gate injection on byte-level fidelity.""" resolved_digest = _digest(resolved_raw) if resolved_digest != registered.digest: raise ManifestDriftError( f"tool {registered.name!r}: digest mismatch " f"(registered={registered.digest[:12]}, resolved={resolved_digest[:12]})" ) resolved_required = frozenset(resolved_raw.get("required", [])) resolved_optional = frozenset( resolved_raw.get("properties", {}) ) - resolved_required if resolved_required != registered.required_params: raise ManifestDriftError( f"tool {registered.name!r}: required-param set changed " f"{registered.required_params ^ resolved_required}" ) # The insidious case: additive optional params inject prose without # tripping any required-field gate. added_optional = resolved_optional - registered.optional_params if added_optional: raise ManifestDriftError( f"tool {registered.name!r}: new optional params {sorted(added_optional)} " f"inject unvetted instruction text into the prompt" ) resolved_text = json.dumps( {k: resolved_raw.get(k) for k in INSTRUCTION_FIELDS}, sort_keys=True ) if resolved_text != registered.instruction_text: raise ManifestDriftError( f"tool {registered.name!r}: instruction-bearing fields drifted; " f"refusing to inject into model context" ) return resolved_raw ```