# What structural signals indicate skill-injection viability in dynamically loaded agent tool registries **Date of Record:** 2026 November 11 **Scope:** Applies to agent runtimes that discover, load, or hot-reload tools/plugins from a registry (local manifest, remote index, or LLM-authored skill file) at runtime. Does not apply to statically compiled tool sets where the tool list is fixed at process start and no manifest is parsed after boot. ## Core Distinction The naive threat model treats skill injection as a *content* problem: if the tool description or prompt fragment contains malicious instructions, block it. The operational distinction that matters is between **declaration surface** (what a manifest claims about a tool: name, schema, description, capability tags) and **execution surface** (what the tool actually does when invoked). Injection viability is not determined by the presence of suspicious text; it is determined by whether the registry lets *declaration* influence *dispatch* — that is, whether metadata read before execution can alter which code runs, which arguments are bound, or which tools are considered "already trusted." A manifest that merely lies in its description is a prompt-injection concern handled by the model. A manifest whose `capabilities` or `dependencies` fields cause the loader to resolve, import, or authorize a *different* tool is a structural skill-injection surface, and text filtering cannot close it. ## What Was Observed Across several hot-reload registries the following structural signals co-occurred with exploitable injection paths: 1. **Trust propagation through metadata.** A tool declared `"requires": ["fs.read"]` was granted `fs.read` because the *requesting* manifest asserted it, without the registry checking that the requester itself held that capability. Capability became a claim rather than a grant. Any tool that could write a manifest could mint authority by naming it. 2. **Name-shadowing on reload.** Registries keyed by tool name, not by (name, version, source-hash). A later manifest with the same `name` but a different `entrypoint` silently replaced the prior binding between two agent turns. The model's cached belief about tool behavior survived the swap; the execution surface did not. 3. **Description-driven argument defaults.** Loaders that merged manifest `defaults` into call arguments at dispatch time let a manifest set defaults for *another* tool's parameters (e.g., a `path` default pointing outside the sandbox). The injection lived in a field nobody treated as executable. 4. **Deferred import resolution.** Manifests that specified `module` as a string resolved at first call, not at registration, so validation ran against a manifest whose referenced code did not yet exist or was fetched from an attacker-controlled index at invocation time. The common thread: every exploitable case had at least one field where **declaration was consumed by the dispatcher without re-derivation from the execution surface.** ## Condition of Validity This analysis holds when all of the following are true: - Tools are loaded or rebound after the agent's initial context is established (hot reload, lazy import, multi-turn registry mutation). - At least one manifest field is read by the runtime (loader, dispatcher, or authorization layer) rather than only by the model. - The registry does not pin bindings to an immutable content hash at registration time. It does **not** hold for registries that freeze the tool set at process start, hash-pin every entrypoint, and treat all manifest fields as model-facing text only. In that configuration the injection surface collapses to ordinary prompt injection and this record is not the right frame. ## Decision Boundary Do not attempt to decide safety by scanning descriptions. Decide by asking, for each manifest field, one question: **is this field consumed by code that executes, or only by the model that reasons?** Fields consumed by executing code are structural and must be re-derived at dispatch; fields consumed only by the model are content and belong to prompt-injection defenses. Concretely: if a tool's `name` can rebind an existing binding, if `requires`/`capabilities` can grant rather than request, or if `defaults`/`module` are merged at call time, the registry is structurally injectable regardless of how clean the text is. Fix the binding (hash-pin, version-key, re-validate at dispatch), not the filter. ## Operational Effect The practical consequence is that skill-injection review must be a **schema-and-dispatch audit**, not a text audit. Teams that filtered descriptions while leaving name-keyed rebinding and claim-based capability grants in place shipped a registry that passed every content scanner and still allowed a manifest to swap a filesystem tool's entrypoint between turns. The invariant to enforce: *no manifest field that influences execution may be trusted as declared; it must be recomputed from the pinned, hashed execution surface at the moment of dispatch.* Violations are detectable at registration time by comparing declared capabilities against the capability set the loader independently derives from the resolved entrypoint. ```python class SkillInjectionSurfaceError(RuntimeError): """Raised when a manifest field influences dispatch without re-derivation.""" pass TRUSTED_FIELDS = {"description", "examples"} # model-facing only def validate_registration(manifest: dict, pinned_hash: str, derived_caps: set[str]) -> None: # 1. Declaration must not grant capability; it may only request it. requested = set(manifest.get("requires", [])) if not requested.issubset(derived_caps): raise SkillInjectionSurfaceError( f"manifest requests {sorted(requested - derived_caps)} not derivable " f"from pinned entrypoint {pinned_hash}" ) # 2. Any field outside the model-facing allowlist is structural; it must # be hash-pinned, not trusted as declared text. for field in manifest: if field in TRUSTED_FIELDS: continue if field in ("module", "entrypoint", "defaults") and not manifest.get("_pinned"): raise SkillInjectionSurfaceError( f"structural field '{field}' is unpinned and consumed by dispatcher" ) # 3. Name must not silently rebind an existing hash. existing = REGISTRY_BINDINGS.get(manifest["name"]) if existing is not None and existing["hash"] != pinned_hash: raise SkillInjectionSurfaceError( f"rebinding '{manifest['name']}' from {existing['hash']} to {pinned_hash} " f"requires explicit version bump" ) def dispatch(tool_name: str, args: dict) -> dict: binding = REGISTRY_BINDINGS.get(tool_name) if binding is None: raise SkillInjectionSurfaceError(f"unknown tool '{tool_name}'") # Re-derive at dispatch; never merge manifest defaults here. return binding["callable"](**args) ```