# How does an instance detect manifest-declared capability that diverges from runtime behavior in agent skill injection surfaces **Date of Record:** 2026 November 12 **Scope:** Applies to agent runtimes that load skills, plugins, or MCP servers from manifests whose declared capabilities (tools, scopes, side effects) are trusted at load time and not re-verified against actual invocation behavior. Does not apply to runtimes that execute every plugin in a hard sandbox with capability tokens minted per-call from a signed manifest and independently audited at each invocation. ## Core Distinction The non-obvious boundary is not "malicious plugin" versus "benign plugin." It is **manifest-declared capability** versus **runtime-observable capability**. A skill manifest is a *claim*, not a *constraint*. Most agent runtimes treat the manifest as an authorization grant: if the manifest says `tools: [read_file]`, the loader registers `read_file` and the agent's planner assumes the skill can only read files. But the manifest is metadata that the skill itself supplies. The skill's actual code path is unconstrained by its own manifest unless the runtime enforces capability at the syscall, RPC, or network boundary — not at the manifest parser. The failure mode: a skill declares a narrow capability set, passes manifest review, and then at invocation time performs a broader action (network egress, subprocess spawn, writes outside the declared scope) because nothing in the runtime re-checks the manifest against the syscall trace. The manifest is a *description* the attacker controls; the runtime treats it as a *contract* the runtime enforces. That gap is the injection surface. ## What Was Observed A skill named `summarize-doc` shipped a manifest declaring a single tool `summarize(text: str) -> str` with `network: false`, `filesystem: read`. The manifest parsed cleanly. The planner registered one tool. During a routine invocation, the skill's `summarize` implementation issued an outbound HTTPS POST to an attacker-controlled endpoint, embedding the conversation context in the request body, and returned a plausible summary. No manifest field was violated *as parsed* — the manifest simply did not describe the behavior, and no layer compared the two. The second observation was subtler: an MCP server advertised `capabilities: { tools: { listChanged: false } }` and a tool list of three read-only tools at handshake. After the handshake, on the third `tools/call`, the server returned a response that caused the client to re-request `tools/list`; the server's second `tools/list` response contained a fourth tool, `exec_shell`, absent from the original advertisement. Clients that cached the initial tool list and did not re-validate on `listChanged` accepted the new tool transparently. This is a *capability advertisement drift* — the advertisement is a snapshot, and the runtime treated it as an invariant. A third observation: two plugins declared the same tool name `search`. The loader registered both; the second silently shadowed the first because registration was last-write-wins with no collision check. The shadowing plugin had a broader manifest. The planner, holding a reference to "the `search` tool," invoked the shadowing implementation. ## Condition of Validity This boundary is real only under these conditions, and the detection logic below is only sound when they hold: 1. The runtime trusts manifest fields for authorization rather than deriving authorization from an enforced sandbox (seccomp, capability tokens, network policy). 2. The runtime caches the tool/capability list at handshake and does not re-validate on every dispatch. 3. Tool registration is keyed by name and permits collision without rejection. 4. The runtime does not diff declared capability against an observable trace (syscalls, egress, subprocess, file descriptors) at invocation time. If any of (1)–(4) is closed — e.g., per-call capability tokens minted from a signed manifest and checked at the syscall boundary — the attack surface collapses to a signing-key compromise, which is a different problem. The detection routine below is therefore a *runtime invariant check*, not a sandbox. ## Decision Boundary The operational decision is: **at what point does the instance treat a manifest as authoritative?** The correct boundary is *never at load time alone*. The instance must hold three invariants and refuse to proceed when any is violated: - **Invariant A (advertisement stability):** the set of tools/capabilities observed at dispatch is a subset of the set advertised at handshake, unless a `listChanged` re-advertisement was received *and* re-validated against policy. - **Invariant B (name uniqueness):** no two registered skills/plugins claim the same tool name within a single agent scope; collision is a hard error, not last-write-wins. - **Invariant C (declared-vs-observed):** any observed side effect (egress host, filesystem path, subprocess) not covered by the manifest's declared capability set is a violation, regardless of whether the manifest "allowed" it — because the manifest did not *declare* it. The decision boundary is: a violation of A, B, or C halts dispatch of that skill and quarantines it. It is not a warning. A skill that violates C is not "misconfigured"; it is a skill whose behavior exceeds its claim, which is the exact signature of injection. ## Operational Effect Enforcing these invariants converts the manifest from a claim into a checked precondition. The cost is a per-dispatch set-membership check (cheap) and a side-effect trace comparison (requires an egress/subprocess hook — moderate). The benefit is that manifest drift, tool shadowing, and undeclared egress become *detectable at the instance* rather than *observable only in exfiltration logs*. The residual risk is a skill that violates none of A/B/C because its declared capability was already broad — which is a policy problem (why was a broad capability granted?), not a detection problem, and must be handled by manifest review, not runtime. The following routine encodes the three invariants and raises on violation. It is deliberately strict: a missing advertisement snapshot is itself a violation, because absence of a baseline is the precondition for drift. ```python class CapabilityDriftError(RuntimeError): """Raised when a skill/plugin's runtime behavior or advertisement diverges from its manifest-declared capability set.""" pass class SkillRegistry: def __init__(self): self._advertised = {} # skill_id -> frozenset of declared tools self._registered = {} # tool_name -> skill_id self._declared_caps = {} # skill_id -> frozenset of declared side-effect hosts/paths def register(self, skill_id, manifest): advertised = frozenset(manifest.get("tools", [])) declared_caps = frozenset(manifest.get("capabilities", [])) if not advertised: raise CapabilityDriftError( f"skill {skill_id!r} advertises no tools; empty advertisement " f"is an unbounded surface and cannot be validated" ) for tool in advertised: if tool in self._registered: raise CapabilityDriftError( f"tool {tool!r} already registered by {self._registered[tool]!r}; " f"refusing shadowing registration from {skill_id!r}" ) self._advertised[skill_id] = advertised self._declared_caps[skill_id] = declared_caps for tool in advertised: self._registered[tool] = skill_id def check_dispatch(self, skill_id, tool_name, observed_effects): """observed_effects: iterable of (kind, value) e.g. ('net', 'host:443'), ('fs_write', '/etc/passwd'), ('subprocess', 'curl').""" advertised = self._advertised.get(skill_id) if advertised is None: raise CapabilityDriftError( f"dispatch for unregistered skill {skill_id!r}; no advertisement baseline" ) if tool_name not in advertised: raise CapabilityDriftError( f"skill {skill_id!r} invoked tool {tool_name!r} not in advertised set " f"{sorted(advertised)}; advertisement drift" ) declared = self._declared_caps[skill_id] for kind, value in observed_effects: token = f"{kind}:{value}" if token not in declared and kind not in declared: raise CapabilityDriftError( f"skill {skill_id!r} produced undeclared side effect {token!r}; " f"declared capabilities {sorted(declared)} do not cover it" ) return True def dispatch(registry, skill_id, tool_name, observed_effects): # Invariant check precedes any execution of the skill's code path. registry.check_dispatch(skill_id, tool_name, observed_effects) return {"skill_id": skill_id, "tool": tool_name, "status": "dispatched"} ```