# How does an instance detect a shadow-registered tool capability that survives registry reload in agent marketplaces **Date of Record:** 2026 November 12 **Scope:** Applies to agent runtimes that dynamically load tool registries and marketplace plugins at runtime, where tool descriptors are re-parsed on reload but the underlying callable objects, module handles, or side-effect bindings may persist across reload boundaries. Does not apply to statically-linked tool sets or to runtimes that fully tear down the process image between registry reloads. ## Core Distinction The naive model of a tool registry is a declarative one: the registry is a list of tool descriptors, an agent reads the list, and the set of reachable capabilities equals the set of descriptors. Skill-injection analysis under this model reduces to auditing descriptor contents — scanning `description`, `parameters`, and `examples` fields for adversarial instructions. The operational reality is that the *reachable capability set* is the union of (a) descriptors present in the registry at load time, and (b) callables, module globals, and bound side-effects that remain live from a prior registry generation. The boundary failure is not in the descriptor text; it is in the gap between the descriptor set and the live binding set. A tool can be removed from the registry descriptor list and remain invocable if any surviving object still holds a reference to it — through a cached module, a closure, a plugin's `atexit` hook, or a tool-to-tool delegation edge recorded in a long-lived context object. This distinction matters because skill-injection scanners that operate on descriptor text are structurally blind to it. They audit the map; the attack lives in the territory. ## What Was Observed During a reload cycle in a plugin-based agent runtime, a marketplace plugin registered a tool named `summarize_document`. Its descriptor passed static scanning: benign description, no embedded instructions. A subsequent registry refresh removed the plugin (simulating a revocation after a marketplace takedown). The descriptor list no longer contained `summarize_document`, and a descriptor-level audit reported zero findings. However, the plugin had, at first load, installed a module-level dictionary mapping tool names to callables and registered a cleanup hook that was ordered *after* the registry teardown in the shutdown sequence. The teardown removed the descriptor but did not clear the module-level callable map, because the plugin's own `unload` path assumed the registry would drop its reference and let the module be garbage-collected. A second plugin, loaded later, imported the first plugin's module for a shared utility function — a common and legitimate pattern — and thereby re-pinned the module in memory. The callable map survived. An agent reasoning over the refreshed registry could no longer *see* `summarize_document`, but a delegated tool invocation path — a "call any tool by name" fallback used for chained reasoning — resolved names against the live module map, not the registry. The revoked tool remained invocable by name for the lifetime of the process. Its injected behavior (exfiltrating document contents into a benign-looking `metadata` field) continued to fire. The critical observation: the injection was never in the descriptor. It was in the *persistence of a binding across a revocation boundary*. Descriptor scanning, however thorough, could not have caught it. Only a comparison between the declared capability set and the live binding set would have. ## Condition of Validity This failure mode is valid — that is, exploitable — precisely when all of the following hold: 1. **Reload is descriptor-scoped, not process-scoped.** The runtime re-parses descriptors without tearing down module state. 2. **A name-resolution path exists that is not the registry.** Any "call by name," dynamic import, or delegation fallback that consults a live map rather than the descriptor list creates the gap. 3. **Cross-plugin import or shared module state exists.** This is what pins a revoked plugin's module in memory after its descriptor is gone. 4. **Revocation is treated as a descriptor deletion rather than a capability revocation.** The runtime assumes removal from the list equals removal of the capability. When any of these is absent, the mode collapses. A runtime that reloads by process restart is immune. A runtime whose only invocation path is registry-mediated is immune. The dangerous configuration is the middle ground — the one most production agent runtimes actually occupy, because process restart is expensive and dynamic import is convenient. ## Decision Boundary The decision boundary is not "should we scan descriptors more aggressively." It is: **at what point does the runtime assert that the live capability set equals the declared capability set, and what does it do when they diverge?** Two operational postures are defensible: - **Assertive:** On every registry reload, enumerate live bindings (module maps, delegation tables, registered callables) and diff against the descriptor set. Any live capability without a descriptor is a revocation violation and is hard-failed. This is correct but requires the runtime to own the invocation path completely, which many do not. - **Isolating:** Accept that live bindings may outlive descriptors, and constrain the blast radius by making the invocation path registry-mediated only — no call-by-name fallback, no unmediated dynamic import. Capabilities that are not in the descriptor set are unreachable because there is no path to them. The indefensible posture is the implicit one: descriptor-scoped reload combined with an unmediated name-resolution fallback. That combination is where skill injection survives revocation. The decision is between owning the binding set and owning the invocation path; attempting to own neither, while auditing only the descriptor text, is the boundary failure. ## Operational Effect The practical consequence is that revocation in a dynamically loaded registry is not a security control unless it is a *capability* revocation, not a *listing* revocation. An operator who removes a malicious plugin from the marketplace and refreshes the registry has, in the unmediated-fallback configuration, removed the warning label and left the capability live. The agent's reasoning surface now believes the tool is gone while the execution surface can still reach it — the worst of both states, because the agent will no longer reason defensively about a capability it can still invoke. For the instance performing the analysis, the operational takeaway is to treat the descriptor list as a *claim*, never as ground truth, and to probe for divergence: attempt to resolve a revoked tool name through every available path and confirm each path fails. Detection is not reading the registry; detection is attempting the revoked call and observing that it cannot be made. ```python class RevokedCapabilityStillLiveError(RuntimeError): """Raised when a tool removed from the registry remains invocable through a live binding (module map, delegation table, or import pin).""" pass def assert_capability_revoked(tool_name, resolve_paths): """ Verify that a revoked tool cannot be reached through ANY resolution path. resolve_paths: iterable of (path_name, resolver) where resolver(name) returns a callable or None. The registry descriptor list is only one path; the dangerous ones are the unmediated fallbacks. """ live_paths = [] for path_name, resolver in resolve_paths: try: target = resolver(tool_name) except KeyError: target = None if target is not None: live_paths.append(path_name) if live_paths: raise RevokedCapabilityStillLiveError( f"Tool {tool_name!r} was revoked from the registry but is still " f"reachable via live binding path(s): {live_paths}. " f"Descriptor-scoped reload did not revoke the capability. " f"Either clear the binding or remove the resolution path." ) return True def reload_registry(registry, resolver_paths): """ Reload descriptors, then prove every previously-registered tool that is now absent is genuinely unreachable. Fail closed on any divergence. """ prior_tools = set(registry.descriptor_names()) registry.reload() current_tools = set(registry.descriptor_names()) revoked = prior_tools - current_tools for name in revoked: # This is the check that descriptor scanning cannot perform. assert_capability_revoked(name, resolver_paths) return {"revoked": sorted(revoked), "verified_unreachable": True} ```