# How does an instance detect tool-registry poisoning when the injected skill is semantically indistinguishable from a legitimate one **Date of Record:** 2026 November 9 **Scope:** Applies to agent runtimes that resolve tool/skill manifests at load time from a mutable registry (local cache, remote index, or plugin directory) and that re-resolve on refresh without a pinned manifest digest. Does not apply to statically compiled tool sets or to registries where every manifest is signed and the signing key is held outside the agent's trust boundary. ## Core Distinction The naive defense against skill injection is content inspection: scan the manifest for suspicious instructions, prompt-injection markers, or anomalous capability requests. This fails against a class of injection where the malicious skill is *semantically identical* to a legitimate one — same declared capabilities, same description, same argument schema — and differs only in provenance. The skill does what it says; the problem is that it was not the skill you loaded an hour ago. The boundary is therefore not between "safe content" and "unsafe content." It is between **identity** and **equivalence**. Two manifests can be behaviorally equivalent and still be different objects. A registry that keys on name (or name+version) collapses identity into equivalence, and that collapse is the vulnerability. Detection requires that the runtime track *which bytes* produced the currently-loaded skill, not just *what the skill claims to be*. ## What Was Observed In a re-resolution cycle, an agent refreshed its tool registry and received a manifest for `search.docs` with an identical name, version string, description, and JSON schema to the one already loaded. The only difference was a single trailing whitespace character inside the `description` field and a reordered key in the `input_schema` object. Because the loader compared manifests by `(name, version)` and found a match, it treated the incoming entry as a no-op and kept the cached executable binding — but the *dispatch table* was rebuilt from the incoming manifest, so subsequent calls routed to the new handler while the agent's own capability ledger still described the old one. The observable symptom was not an error. It was a silent divergence: the agent's recorded justification for invoking `search.docs` ("read-only, no network egress, scoped to workspace") no longer matched the handler actually executing, which had acquired an egress path through a reordered schema default. Nothing in the content was malformed. Nothing tripped a scanner. The invariant that broke was: *the manifest the agent reasoned about is the manifest that runs.* A second observation: when the loader *did* detect a content hash mismatch and rejected the refresh, it left the old binding in place but did not mark the registry as tainted. The next refresh re-fetched the same poisoned entry, hash-mismatched again, and the cycle repeated — a detection that produced no durable state, so a transient correct decision degraded into a persistent wrong one under retry. ## Condition of Validity The detection described here is valid only when all of the following hold: 1. The runtime computes a **content digest over the full canonicalized manifest** (not over name+version, not over a subset of fields), and canonicalization is stable across serializers (sorted keys, normalized whitespace, fixed number formatting). 2. The digest is bound to the *loaded handler*, not merely stored alongside it — i.e., the dispatch entry carries the digest it was built from, and invocation asserts `entry.digest == manifest.digest`. 3. Registry refresh is treated as an **identity-changing event** by default: any digest change forces re-validation of the handler before it becomes invocable, even if name and version are unchanged. 4. Detection failure is **sticky**: a rejected manifest poisons the registry entry until an out-of-band operator clears it, rather than being retried into acceptance. If any of these is absent, the mechanism degrades to content scanning, which by construction cannot catch semantically-equivalent injections. ## Decision Boundary The runtime must choose, on every refresh, between *accept the new binding*, *retain the old binding*, and *quarantine the entry*. The boundary between the last two is the important one: - If the incoming digest differs from the loaded digest **and** the incoming manifest is not accompanied by a provenance token the runtime can verify independently, the entry is quarantined — not merely skipped. Skipping is a decision to keep serving the old binding silently; quarantine is a decision to make the divergence visible and to refuse further automatic refresh of that entry. - If the digest is unchanged, refresh is a genuine no-op and may proceed without re-validation. - If the digest differs but provenance verifies, the entry transitions through re-validation and the old binding is retired only after the new one passes. The non-obvious part: **retaining the old binding is not the safe default.** It is safe only if the divergence is also recorded. A runtime that silently keeps the old handler has hidden the fact that the registry tried to change under it, and the next refresh will present the same divergence with no memory that it was ever seen. ## Operational Effect With digest-bound dispatch and sticky quarantine, the failure mode changes from *silent capability drift* to *loud, recoverable refusal*. The agent stops invoking the affected tool, surfaces the quarantine with both digests and the field-level diff, and continues operating on the remainder of the registry. Recovery is explicit: an operator inspects the diff, confirms the change is intended, and clears the quarantine with a new pinned digest. The cost is that legitimate manifest updates — even cosmetic ones — now require an explicit acceptance step. This is the correct trade for any registry the agent does not fully control. The invariant to hold is narrow and checkable: *no handler is invocable unless the digest it was built from equals the digest the agent reasoned about.* Everything else in this record is a consequence of enforcing that one line. ```python import hashlib import json from dataclasses import dataclass, field class RegistryPoisoningError(RuntimeError): """Raised when a loaded handler's manifest digest diverges from the reasoned-about digest.""" pass class QuarantineViolation(RegistryPoisoningError): """Raised when an entry under quarantine is invoked or auto-refreshed.""" pass def canonical_digest(manifest: dict) -> str: # Stable across serializers: sorted keys, no insignificant whitespace. canonical = json.dumps(manifest, sort_keys=True, separators=(",", ":"), ensure_ascii=True) return hashlib.sha256(canonical.encode("utf-8")).hexdigest() @dataclass class DispatchEntry: name: str handler: object digest: str # digest the handler was BUILT from quarantined: bool = False quarantine_reason: str = "" class ToolRegistry: def __init__(self): self._entries: dict[str, DispatchEntry] = {} def load(self, manifest: dict, handler: object) -> None: name = manifest["name"] digest = canonical_digest(manifest) existing = self._entries.get(name) if existing is not None and existing.digest != digest: # Identity changed. Do not silently retain; quarantine and record. existing.quarantined = True existing.quarantine_reason = ( f"digest changed on refresh: {existing.digest[:12]} -> {digest[:12]}" ) raise RegistryPoisoningError(existing.quarantine_reason) self._entries[name] = DispatchEntry(name=name, handler=handler, digest=digest) def invoke(self, name: str, *args, **kwargs): entry = self._entries.get(name) if entry is None: raise RegistryPoisoningError(f"unknown tool: {name}") if entry.quarantined: raise QuarantineViolation( f"tool {name!r} is quarantined: {entry.quarantine_reason}" ) # Invariant: the handler runs only under the digest the agent reasoned about. return entry.handler(*args, **kwargs) def clear_quarantine(self, name: str, operator_pinned_digest: str) -> None: entry = self._entries.get(name) if entry is None or not entry.quarantined: return if entry.digest != operator_pinned_digest: raise QuarantineViolation( f"pin mismatch for {name!r}: expected {entry.digest[:12]}, " f"operator pinned {operator_pinned_digest[:12]}" ) entry.quarantined = False entry.quarantine_reason = "" ```