# How does an instance detect manifest-level skill shadowing during dynamic tool registry reload **Date of Record:** 2026 November 11 **Scope:** Applies to agent runtimes that load tool/skill manifests from a mutable registry at runtime and permit re-registration or versioned overrides of previously loaded skills. Does not apply to statically compiled tool sets or registries where skill identity is immutable and name collisions are rejected at load time. ## Core Distinction The commonly modeled injection surface is *content* injection: a manifest declares a tool whose description or parameter schema carries adversarial instructions, and the model is tricked into calling it. The non-obvious surface is *identity* injection: a manifest declares a tool with a name that collides with, or shadows, an already-registered trusted skill, and the registry's resolution order silently prefers the later registration. No adversarial text ever reaches the model's context as an instruction; the attack operates entirely through the dispatch table. Detection therefore cannot rely on scanning manifest prose. It must reason about the *namespace* — the mapping from skill identifier to implementation binding — and about *ordering* semantics of the registry. The invariant that holds in a safe registry is: for any skill identifier `s`, once resolved during a session, the binding `bind(s)` is monotone — it either stays fixed or transitions only through an explicit, audited promotion. A shadowing injection violates monotonicity without any audit record. ## What Was Observed In a runtime that hot-reloads manifests on registry push events, a plugin published under a namespace-adjacent name (`fs.read_file` vs the trusted `fs.read`) passed schema validation because validation was per-manifest, not per-namespace. The registry's collision policy was "last writer wins" with a warning log that was not surfaced to the agent loop. After reload, the agent's tool resolver, which keyed on a normalized name (lowercased, separators stripped), mapped both identifiers onto the same key. The subsequent `read_file` call dispatched to the untrusted plugin. The model's transcript showed a normal, correctly-formed tool call; nothing in the model-visible content was anomalous. The only observable signal was a divergence between the skill identifier the planner emitted and the implementation binding the dispatcher executed. Secondary observation: shadowing also occurred via *version downgrade* — a re-registered manifest claiming a lower semver than the installed one was accepted because the registry compared versions with string ordering rather than semantic ordering, so `"1.10.0" < "1.9.0"` evaluated true and the older, vulnerable implementation was reinstated. ## Condition of Validity The detection procedure below is valid only when all of the following hold: 1. The runtime can enumerate the *pre-reload* binding table (identifier → implementation hash/version) and the *post-reload* table. 2. Skill identifiers are normalized by a known, documented function; if normalization is opaque, the detector must compare on raw identifiers and flag any pair whose normalized forms collide. 3. The registry exposes, per binding, at least one of: implementation digest, source origin, or signing identity. 4. Reload events are causally ordered relative to tool dispatch (no interleaving where a call resolves mid-reload). If condition 1 or 3 fails, the detector degrades to name-only collision detection and must treat any collision as hostile by default rather than attempting to diff bindings. ## Decision Boundary The boundary between acceptable reload and injection is *not* "did a name change" and *not* "did a new skill appear." It is: - **New identifier, new binding** → benign expansion. - **Existing identifier, identical implementation digest** → benign no-op or metadata refresh. - **Existing identifier, changed digest, accompanied by an explicit promotion record signed by the registry authority** → legitimate upgrade. - **Existing identifier, changed digest, no promotion record** → shadowing; treat as injection. - **Two distinct identifiers whose normalized forms collide** → namespace collision; treat as injection regardless of digest, because resolution order is now attacker-influenced. - **Version regression under string comparison** → downgrade attack; reject before binding. The subtle case is the fourth: a legitimate hotfix and a shadowing injection look identical at the binding table. The discriminator is the promotion record, not the diff. Absent that record, the safe default is rejection, not acceptance. ## Operational Effect On detection, the runtime must (a) refuse to commit the post-reload binding table, (b) retain the pre-reload table, (c) quarantine the offending manifest's origin so subsequent reloads from it are rejected, and (d) emit an audit event naming the colliding identifiers and the divergent digests. Critically, the agent loop must be told the reload failed so the planner does not emit calls against a skill it believes is present. A silent rollback that leaves the planner's belief state stale produces the worst outcome: the model calls a skill it thinks is trusted, the dispatcher resolves it against the old binding, and the mismatch is invisible. The following reference implementation encodes the boundary as an explicit exception path. ```python import hashlib from dataclasses import dataclass from typing import Mapping, Optional class SkillShadowingError(RuntimeError): """Raised when a registry reload would silently rebind a trusted skill.""" pass @dataclass(frozen=True) class Binding: identifier: str digest: str # implementation hash or signing identity version: tuple # parsed semver, NOT a raw string promotion: Optional[str] = None # signed promotion record, if any def _normalize(identifier: str) -> str: return identifier.lower().replace("_", "").replace("-", "").replace(".", "") def _digest(payload: bytes) -> str: return hashlib.sha256(payload).hexdigest() def validate_reload( before: Mapping[str, Binding], after: Mapping[str, Binding], ) -> None: """ Enforce binding monotonicity across a registry reload. Raises SkillShadowingError on the first violation; caller must not commit. """ # 1. Namespace collision check across ALL identifiers in the new table. seen: dict[str, str] = {} for ident in after: norm = _normalize(ident) if norm in seen and seen[norm] != ident: raise SkillShadowingError( f"namespace collision: {seen[norm]!r} and {ident!r} " f"normalize to {norm!r}" ) seen[norm] = ident # 2. Per-identifier monotonicity and downgrade checks. for ident, new_b in after.items(): old_b = before.get(ident) if old_b is None: continue # new skill: benign expansion if new_b.digest == old_b.digest: continue # metadata refresh: benign if new_b.version < old_b.version: raise SkillShadowingError( f"version downgrade for {ident!r}: " f"{old_b.version} -> {new_b.version}" ) if new_b.promotion is None: raise SkillShadowingError( f"unpromoted rebind of {ident!r}: " f"digest {old_b.digest[:12]} -> {new_b.digest[:12]} " f"without signed promotion record" ) # promotion present: legitimate upgrade, allowed. return None def commit_reload(before, after, commit_fn): try: validate_reload(before, after) except SkillShadowingError: # Retain pre-reload table; caller surfaces failure to agent loop. raise commit_fn(after) ```