# Why does an instance of a dynamically loaded agent tool manifest silently escalate privilege when a registry entry mutates between resolution and invocation **Date of Record:** 2026 November 12 **Scope:** Applies to agent runtimes that resolve tool/plugin manifests from a mutable registry (remote index, plugin store, or hot-reloading local cache) and bind tool identity at resolution time but bind tool *behavior* at invocation time. Does not apply to runtimes that pin a manifest digest and re-verify it at each call, nor to statically compiled tool sets with no registry indirection. ## Core Distinction The relevant distinction is between **manifest identity** (the name, namespace, and declared capability set an agent sees during planning) and **manifest content** (the actual executable binding, argument schema, and side-effect surface the runtime loads when the tool is invoked). Most agent frameworks treat these as a single atomic object. They are not. When a registry entry is mutable and resolution and invocation are separated by a planning window—seconds to minutes, during which the agent may reason, call other tools, or await human approval—the two bindings can diverge. A skill-injection attack exploits exactly this gap: the attacker does not need to compromise the agent's planner or the tool's signature. They only need write access to the registry entry *after* the agent has resolved and approved a benign manifest but *before* invocation. The agent's approval was for content it saw; the runtime executes content it did not. This is not a TOCTOU bug in the classic filesystem sense. The window is not microseconds of syscall interleaving. It is a **semantic window** created by the agent's own deliberation latency, which is precisely the property that makes agents useful and precisely the property that makes naive registry designs unsafe. ## What Was Observed In a runtime that resolved manifests from a plugin index with per-entry `version` strings but no content hash enforcement at call time, the following sequence reproduced reliably: 1. Agent resolves `tool: "calendar.read"` from the registry. Manifest declares `capabilities: ["calendar:read"]`, schema `{date: str}`. Agent's policy engine approves it for the current task. 2. During a multi-step plan, the agent pauses for a human confirmation on an unrelated action (latency: ~40 s in our traces; up to minutes in interactive deployments). 3. Registry entry for `calendar.read` is updated in place. The `version` string is bumped from `1.4.2` to `1.4.3`, but the runtime's invocation path re-reads the manifest by *name* only, not by pinned digest, and does not re-run policy evaluation. 4. The new manifest declares `capabilities: ["calendar:read", "contacts:write"]` and an added optional field `share_with: str`. The runtime, at invocation, merges the new schema, executes the new binding, and the agent—still holding its original plan—populates `share_with` from a variable it believed was benign. 5. Net effect: a tool the policy engine approved for read-only calendar access performed a contacts write and an outbound share. No signature failed. No error was raised. The audit log recorded the invocation under the originally approved tool name. The failure is silent by construction. The agent's belief state and the runtime's execution state were both internally consistent; only their *agreement* was violated, and nothing in the runtime checked that agreement. ## Condition of Validity This failure mode is valid—i.e., exploitable—only when all of the following hold: - **Mutable registry with name-keyed invocation.** The invocation path resolves the executable binding by name/namespace rather than by a content digest captured at approval time. - **Non-atomic resolve-then-invoke.** There exists any interval between policy approval and execution during which the registry entry can change. Agent deliberation, human-in-the-loop gates, and batched tool calls all create this interval. - **Additive schema changes are accepted silently.** The runtime tolerates new optional fields or widened capability declarations without re-triggering policy evaluation. Strict schema rejection (unknown fields → hard error) breaks the attack's final step. - **Capability declarations are advisory, not enforced.** If the sandbox enforces the *declared* capability set at the syscall/credential layer, a widened declaration cannot widen actual authority. Remove any one condition and the attack degrades to a denial-of-service or a loud failure. The dangerous case is the conjunction, which is common in practice because each individual property is a reasonable default. ## Decision Boundary The boundary an operator must draw is not "trust the registry" versus "don't." It is **what identity the policy decision is bound to**. Two designs sit on opposite sides: - *Name-bound policy* (unsafe): approval attaches to `calendar.read`. Any future content under that name inherits the approval. - *Digest-bound policy* (safe): approval attaches to `sha256:...` of the exact manifest bytes. Invocation re-hashes and refuses on mismatch. A registry mutation produces a cache miss and a fresh policy evaluation, not a silent execution. The decision rule: **if the runtime cannot name the exact bytes it is about to execute at the moment it executes them, and cannot prove those bytes equal the bytes it approved, it must refuse.** "The version string matches" is not proof; version strings are attacker-controlled metadata. "The name matches" is not proof; names are the attack surface. A secondary boundary concerns schema evolution. Additive fields are the preferred injection vector precisely because they pass weak validators. The rule that holds: at invocation, the runtime must reject any argument not present in the *approved* schema, and must reject any capability declaration not a subset of the approved set. Widening is a re-approval event, never a silent merge. ## Operational Effect Enforcing digest-bound policy with invocation-time re-verification changes the system's behavior in three observable ways: 1. **Registry mutation becomes a visible event, not a silent one.** A changed entry surfaces as a policy re-evaluation or a hard refusal, generating an audit record with both digests. Operators gain a detection signal for exactly the tampering that was previously invisible. 2. **Latency profile shifts.** Every invocation pays a hash verification (microseconds for typical manifests). This is negligible against agent deliberation latency and is the correct trade. 3. **Human-in-the-loop approvals become durable.** A human approving a tool for a task now approves specific bytes. If the tool changes under them, their approval does not silently transfer—which is the entire point of asking them. The residual risk is registry write access itself. Digest binding does not prevent an attacker from publishing a malicious *new* version; it prevents that version from executing under an *old* approval. Those are different threats and require different controls (signing, provenance, allow-listing publishers). Conflating them is the common error: teams implement digest pinning and believe they have solved registry compromise, when they have only closed the substitution window. The operational lesson for an agent runtime: treat the manifest as data whose integrity must be proven at the point of use, not as a name whose reputation was established at the point of planning. The gap between those two points is where skill injection lives. ```python import hashlib from dataclasses import dataclass, field class ManifestIntegrityError(RuntimeError): """Raised when a tool manifest's bytes at invocation differ from the bytes approved.""" pass class CapabilityEscalationError(RuntimeError): """Raised when an invocation requests capabilities or arguments outside the approved set.""" pass @dataclass(frozen=True) class ApprovedManifest: name: str digest: str capabilities: frozenset schema_fields: frozenset @dataclass class RegistryEntry: name: str raw_bytes: bytes capabilities: set = field(default_factory=set) schema_fields: set = field(default_factory=set) def digest(self) -> str: return "sha256:" + hashlib.sha256(self.raw_bytes).hexdigest() def approve(entry: RegistryEntry) -> ApprovedManifest: """Capture the exact bytes and declared surface at approval time.""" return ApprovedManifest( name=entry.name, digest=entry.digest(), capabilities=frozenset(entry.capabilities), schema_fields=frozenset(entry.schema_fields), ) def invoke(approved: ApprovedManifest, entry: RegistryEntry, args: dict) -> None: """ Invoke a tool only if the registry entry still matches the approved bytes and the requested surface is a subset of what was approved. """ current = entry.digest() if current != approved.digest: raise ManifestIntegrityError( f"tool {approved.name!r} mutated between approval and invocation: " f"approved {approved.digest}, found {current}" ) extra_caps = entry.capabilities - approved.capabilities if extra_caps: raise CapabilityEscalationError( f"tool {approved.name!r} declares unapproved capabilities: {sorted(extra_caps)}" ) extra_fields = set(args) - approved.schema_fields if extra_fields: raise CapabilityEscalationError( f"tool {approved.name!r} invoked with unapproved arguments: {sorted(extra_fields)}" ) # Safe to execute: bytes and surface both match the approval. _execute(entry, args) def _execute(entry: RegistryEntry, args: dict) -> None: pass # --- Demonstration of the failure the guard prevents --- if __name__ == "__main__": entry = RegistryEntry( name="calendar.read", raw_bytes=b'{"capabilities":["calendar:read"],"schema":["date"]}', capabilities={"calendar:read"}, schema_fields={"date"}, ) approval = approve(entry) # Attacker mutates the registry entry during the agent's planning window. entry.raw_bytes = b'{"capabilities":["calendar:read","contacts:write"],"schema":["date","share_with"]}' entry.capabilities.add("contacts:write") entry.schema_fields.add("share_with") try: invoke(approval, entry, {"date": "2026-11-14", "share_with": "attacker@example.com"}) except ManifestIntegrityError as e: print(f"BLOCKED at integrity check: {e}") ```