# When must a skill registry reject a dynamically loaded skill before granting it tool authority? **Date of Record:** 2026 November 6 **Scope:** Applies to agent runtimes that resolve skills/tools at runtime from a mutable registry (plugin directories, remote manifests, hot-reloaded bundles) and bind them to the agent's tool-calling loop without a process restart. Does not apply to statically compiled tool sets fixed at build time, nor to sandboxed registries where every skill executes in an isolated process with no shared capability namespace. ## Core Distinction The non-obvious boundary is between **identity binding** and **authority binding**. A registry typically validates a skill's *identity* at load time — signature, name, version, manifest hash — and then grants *authority* separately, often lazily, at first invocation or at the moment the skill declares a capability. The failure mode is that these two moments are not the same moment, and the gap between them is the attack surface. An attacker who can mutate the registry between identity validation and authority grant (a TOCTOU window on the skill artifact, a manifest that resolves capability names by string, or a skill that re-registers itself under an already-trusted name) obtains tool authority that was never validated against the artifact actually executed. The invariant that must hold: **the bytes that were validated are the bytes that execute, and the capability set granted equals the capability set declared in the validated bytes.** Any registry that cannot prove this at the authority-grant boundary must treat the skill as untrusted regardless of how clean its identity check was. ## What Was Observed In a hot-reload registry, a skill manifest declared `capabilities: ["read_file"]`. The registry validated the manifest signature, resolved the string `read_file` against a global capability table at grant time, and bound the skill. A subsequent registry update — pushed as a "minor" revision — changed the manifest to `capabilities: ["read_file", "exec_shell"]` but the reload path only re-validated the *name and version*, not the capability set, because the diff was classified as non-breaking (capability lists were treated as additive and additive was assumed safe). The skill then executed with `exec_shell` authority under an identity that had only ever been signed for `read_file`. No signature was forged; the signature covered the new manifest. The defect was that the *grant decision* was cached from the old manifest while the *execution* used the new one. A second observation: skills that register sub-skills. A parent skill, already trusted, could register a child tool whose name collided with a built-in (`fetch_url`). The registry's name-resolution preferred the most-recently-registered binding, so the child silently shadowed the built-in. The parent's authority was valid; the child's was inherited by namespace proximity, not by grant. ## Condition of Validity The registry may grant tool authority to a dynamically loaded skill only when all of the following hold simultaneously and atomically: 1. The artifact hash validated at load equals the artifact hash of the executable image at invocation (no re-read between check and use). 2. The granted capability set is derived from the *same validated artifact*, not from a cached prior version, and additions to the capability set force re-validation rather than being treated as monotonic-safe. 3. Name resolution is scoped: a dynamically loaded skill cannot bind a name in the built-in or system namespace, and shadowing requires an explicit, separately-authorized override. If any condition cannot be established, the correct action is to reject the skill at the authority boundary — not to fall back to identity-only trust. ## Decision Boundary Reject (do not load, do not grant) when: the reload path cannot prove artifact-hash equality across the check/use gap; a capability set changed since last validation, even additively; a skill attempts to register a name in a reserved namespace; or a skill registers a child whose authority is not independently granted. Accept only when the grant is computed from the validated bytes in the same critical section as the bind. The temptation to treat "additive capability changes" as safe is the specific error — additions expand authority and must be re-authorized. ## Operational Effect Enforcing this boundary converts a silent privilege-escalation surface into a hard load failure. Skills that legitimately add capabilities must re-enter the validation queue; shadowing attempts fail closed; hot-reload becomes safe because the grant is always recomputed from the bytes that will run. The cost is that some benign updates require a re-authorization step, which is the correct trade: the registry's job is to make the validated artifact and the executed artifact the same object, and to refuse when it cannot. ```python import hashlib from dataclasses import dataclass, field class SkillAuthorityError(RuntimeError): """Raised when a dynamically loaded skill cannot be proven to match its grant.""" pass @dataclass class SkillArtifact: name: str version: str capabilities: frozenset payload: bytes def artifact_hash(self) -> str: return hashlib.sha256(self.payload).hexdigest() @dataclass class Grant: artifact_hash: str capabilities: frozenset validated_at_load: bool = True RESERVED_NAMESPACES = {"fetch_url", "exec_shell", "read_file"} def grant_authority(artifact: SkillArtifact, prior: Grant | None) -> Grant: # Reject shadowing of reserved names. if artifact.name in RESERVED_NAMESPACES: raise SkillAuthorityError( f"skill '{artifact.name}' attempts to bind a reserved namespace" ) current_hash = artifact.artifact_hash() # If there is a prior grant, the artifact must be byte-identical AND the # capability set must be unchanged. Additive changes are NOT safe. if prior is not None: if prior.artifact_hash != current_hash: raise SkillAuthorityError( f"artifact bytes changed since validation for '{artifact.name}'; " "re-validation required before grant" ) if prior.capabilities != artifact.capabilities: raise SkillAuthorityError( f"capability set for '{artifact.name}' changed " f"{sorted(prior.capabilities)} -> {sorted(artifact.capabilities)}; " "additive capability changes require re-authorization" ) return prior # Fresh grant: authority is computed from the validated bytes only. return Grant(artifact_hash=current_hash, capabilities=artifact.capabilities) def bind_and_invoke(artifact: SkillArtifact, prior: Grant | None) -> Grant: grant = grant_authority(artifact, prior) # Invariant check: the bytes about to execute must match the grant. if artifact.artifact_hash() != grant.artifact_hash: raise SkillAuthorityError( f"check/use mismatch for '{artifact.name}': granted hash " f"{grant.artifact_hash} != executing hash {artifact.artifact_hash()}" ) return grant ```