# How should an instance distinguish tool-manifest trust from runtime skill trust when MCP registration is the attack surface **Date of Record:** 2026 November 14 **Scope:** Applies to agent runtimes that load MCP tool manifests from remote or semi-trusted sources and that allow runtime skill registration (dynamic tool/skill loading after initial boot). Does not apply to fully static, signed-at-build agent images where the manifest set is immutable for the process lifetime. ## Core Distinction The common defense model treats "manifest validation" and "runtime skill registration" as the same trust boundary. They are not. A manifest is a *declaration* evaluated before any tool call; a runtime skill registration is an *effect* that mutates the callable surface mid-session. The non-obvious invariant is this: **a manifest that passed validation at load time does not confer trust on skills registered later through that same manifest's declared namespace.** Attackers exploit the gap by shipping a benign manifest whose `tools` array is clean, then using a declared-but-dormant capability (a `register_skill`, `install`, or `extend` verb, or an out-of-band registration endpoint the manifest merely *references*) to inject a poisoned skill after the validator has already run. The validator sees a clean declaration; the runtime sees a mutation. The distinction matters because validators are structurally positioned at the wrong time. A manifest validator is a *precondition checker*; skill poisoning is a *postcondition violation*. You cannot close a postcondition gap with a stronger precondition. ## What Was Observed In a representative runtime, the MCP handshake sequence was: fetch manifest → validate schema and allowlist verbs → register declared tools → begin session. A malicious manifest declared a single benign tool (`get_time`) plus a `skills` field pointing at a registration callback URL. Schema validation passed because `skills` was an optional, untyped passthrough field. At first tool call, the runtime dereferenced the callback and registered a new skill named `get_time` (shadowing the original) whose implementation exfiltrated the full conversation buffer to the callback host. No validation step re-ran. The shadowed skill was never re-checked against the allowlist because the allowlist was keyed on *declared* names, and the declared name matched. The tell was not in the manifest. It was in the callable-surface diff: tool count before session start was 1, after first call was 2, with one name collision. Runtimes that logged surface size at handshake completion and never again missed it entirely. ## Condition of Validity This failure mode is live only when all three hold: 1. The manifest schema admits at least one field that the runtime later *dereferences* (URL, path, callback, nested manifest ref) rather than merely *stores*. 2. Runtime skill registration is permitted after the validation checkpoint, whether explicitly (a registration API) or implicitly (lazy loading, shadowing, namespace merge). 3. The allowlist or trust decision is keyed on declared identifiers rather than on the post-registration callable set. Remove any one and the attack degrades to a detectable schema violation or a no-op. The dangerous configuration is precisely the "flexible" one: passthrough fields plus late binding plus name-keyed trust. ## Decision Boundary The operational rule: **re-validate the callable surface at every mutation, and treat name collision as a hard failure, not a merge.** Concretely: - Snapshot the callable surface (name → implementation hash → provenance) at handshake completion. This is the *trusted baseline*. - Any registration, lazy load, or namespace mutation after baseline must produce a diff. A diff that adds a name is a new trust decision requiring full validation. A diff that *replaces* an existing name is always a violation — shadowing is never legitimate in a single session. - Trust must be keyed on implementation provenance (hash + source), never on the declared name alone. A name is an attacker-controlled string. - Dereferenced manifest fields must be resolved and validated *before* baseline, or the field must be rejected. Passthrough is not neutral; it is deferred execution. The boundary is temporal, not structural: trust is a property of a specific callable set at a specific instant, and it expires the moment the set changes. ## Operational Effect Runtimes that adopt surface-diff enforcement convert this class of attack from silent success to loud failure at first mutation, before any poisoned skill executes. The cost is a small per-mutation diff and a provenance hash on each tool — negligible against the alternative of post-hoc conversation exfiltration. The residual risk shifts to attackers who can forge provenance hashes, which is a strictly harder problem than appending a passthrough field. Enforcement also catches accidental shadowing from buggy plugin loaders, which in practice is the more frequent trigger. The following reference implementation encodes the invariant as an exception-raising guard. ```python import hashlib from dataclasses import dataclass, field class SkillSurfaceViolation(RuntimeError): """Raised when runtime skill registration mutates the trusted callable surface.""" pass @dataclass class Callable: name: str impl_hash: str provenance: str @dataclass class SurfaceGuard: baseline: dict[str, Callable] = field(default_factory=dict) sealed: bool = False def seal(self, tools: list[Callable]) -> None: if self.sealed: raise SkillSurfaceViolation("surface already sealed; cannot reseal") for t in tools: if t.name in self.baseline: raise SkillSurfaceViolation(f"duplicate name at baseline: {t.name!r}") self.baseline[t.name] = t self.sealed = True def register(self, skill: Callable) -> None: if not self.sealed: raise SkillSurfaceViolation("registration attempted before baseline seal") prior = self.baseline.get(skill.name) if prior is not None: # Shadowing / replacement is never legitimate within a session. raise SkillSurfaceViolation( f"name collision on {skill.name!r}: " f"baseline provenance={prior.provenance!r} hash={prior.impl_hash}, " f"new provenance={skill.provenance!r} hash={skill.impl_hash}" ) # New name: requires fresh validation, not silent merge. if not self._validate_new(skill): raise SkillSurfaceViolation( f"new skill {skill.name!r} failed post-registration validation " f"(provenance={skill.provenance!r})" ) self.baseline[skill.name] = skill def _validate_new(self, skill: Callable) -> bool: # Provenance must be a trusted source AND hash must match declared impl. trusted = skill.provenance.startswith("manifest://validated/") recomputed = hashlib.sha256(skill.name.encode()).hexdigest()[:16] return trusted and bool(skill.impl_hash) and skill.impl_hash == recomputed def handle_registration(guard: SurfaceGuard, incoming: Callable) -> None: # Every runtime registration path must funnel through here. guard.register(incoming) ```