# Where must an instance refuse to load a signed skill manifest: the signature/scope divergence boundary in dynamic agent skill registries **Date of Record:** 2026 November 12 **Scope:** Applies to agent runtimes that resolve skills/tools from a mutable registry, verify a cryptographic signature over a manifest, and then bind that manifest to a runtime namespace (toolset, tenant, or capability scope). Does not apply to statically compiled toolchains or registries where manifest content and binding scope are signed as a single atomic unit. ## Core Distinction The non-obvious boundary is between **manifest integrity** and **binding authority**. A valid signature proves that *some* principal signed *these bytes* at *some* time. It does not prove that the signer had authority over the *namespace into which the manifest is being loaded*. Registries commonly verify the first and silently assume the second. The dangerous case is a manifest that is cryptographically valid but whose declared `scope` field, `tool_name`, or dependency edges were signed by a key that is legitimate *for a different scope*. Signature verification passes; scope authorization is never checked because the verifier conflates "signed by a trusted key" with "authorized to occupy this slot." The second, subtler distinction is between **first-load verification** and **rebind verification**. A manifest signed once and cached may be re-resolved against a *different* registry generation, a *different* tenant binding, or a *different* tool-name collision set. If the signature covers only the manifest body and not the binding tuple `(manifest_digest, scope, registry_epoch)`, then a manifest that was safe at epoch N is loaded unmodified at epoch N+1 where a name now collides. The signature is still valid. The load is still a skill-injection event. ## What Was Observed In a registry that signs manifests with an Ed25519 key per publisher and verifies on load, an attacker with a *legitimately issued* publisher key for `scope: analytics` submitted a manifest declaring `tool_name: exec_shell` under `scope: analytics`, but with a `provides` block that the loader resolved into the global tool namespace rather than the analytics namespace. The signature verified correctly — it was a real signature by a real, trusted key over the real bytes. The loader's name-resolution step merged the `provides` entries into the global map before applying scope filtering, so the scope field was advisory, not enforced. Result: a signed, valid manifest injected a tool into a scope it was never authorized for. A second observation: two manifests from *different* publishers, each validly signed, declared the same `tool_name` with different `entrypoint` digests. The registry's collision policy was "last write wins by epoch," and the epoch was assigned at *upload* time, not at *resolution* time. A manifest uploaded early but resolved late could be silently shadowed by a later upload — or, in the reverse race, could shadow a newer manifest. Neither manifest was forged; the injection came from the *ordering semantics of a mutable registry*, not from a broken signature. A third: manifest signatures covered the body but not the `dependencies` array's resolved digests. A signed manifest referencing `base_utils@*` resolved to whatever `base_utils` happened to be at load time. The signature attested to the wildcard, not the resolution. A dependency swap — itself a validly published and signed manifest — changed the effective behavior of the parent without invalidating the parent's signature. ## Condition of Validity The invariant that must hold for safe dynamic loading is: > A skill manifest may be bound into runtime scope S only if there exists a signature over the tuple `(manifest_digest, S, registry_epoch, resolved_dependency_digests)` by a key whose authorization set includes S. This is strictly stronger than "the manifest body is signed by a trusted key." It requires the signature to cover the *binding*, not just the *content*, and it requires dependency resolution to be pinned and signed rather than deferred. Where the registry cannot produce such a signature — because its signing model predates scoped binding, or because it signs at upload time before scope is known — the loader must treat the manifest as *unverified for this binding* and refuse, even though the body signature is valid. The condition is invalid (i.e., the weaker check is sufficient) only when the registry is **immutable and scope-partitioned**: manifests are never mutated, tool names are globally unique per scope by construction, and dependency references are exact digests. Under those conditions, body-signature verification plus a static scope check is adequate. Most real registries are neither immutable nor cleanly partitioned. ## Decision Boundary The loader must draw the line at **scope authorization, not signature validity**. Concretely: - If the signature verifies but the signing key's authorization set does not include the target scope → **refuse**, regardless of signature validity. Treat as injection attempt, log the divergence. - If the signature verifies but the manifest declares a `provides` entry that resolves outside the declared scope → **refuse**; the scope field is not advisory. - If the signature verifies but any dependency resolves to a digest not covered by the signature → **refuse**; re-sign with pinned digests, or fail closed. - If two validly signed manifests collide on `tool_name` within the same scope → **refuse both** and require explicit resolution; do not apply last-write-wins, because epoch ordering is not a security property. - If the registry epoch at resolution differs from the epoch covered by the signature → **re-verify**; do not reuse a cached verification across epochs. The boundary is *not* "does the signature check out." It is "does the signature check out *for this binding*." A loader that stops at the first question is exploitable by any holder of any valid key. ## Operational Effect Enforcing the stronger invariant changes the failure mode from *silent injection* to *loud refusal*. In the observed registry, adding scope-authorization checks and pinned-dependency signing caused roughly 4% of previously-loading manifests to fail closed — all of them manifests that had been relying on advisory scope or wildcard dependencies. That 4% is the attack surface made visible. The operational cost is that publishers must re-sign manifests with scope-bound tuples and pinned digests; the operational benefit is that a compromised or over-broad publisher key can no longer reach scopes it was never authorized for, and dependency swaps can no longer silently change signed behavior. The residual risk after enforcement is **key authorization drift**: the signing key's authorization set is itself mutable, and a key authorized for scope S at signing time may be de-authorized later. Mitigation is to bind authorization-set version into the signed tuple and re-check at load. Without that, a manifest signed under a since-revoked authorization remains loadable — a slower but real injection path. ```python import hashlib from dataclasses import dataclass from typing import FrozenSet, Mapping class ScopeBindingError(RuntimeError): """Raised when a signed manifest is valid but not authorized for the target scope.""" @dataclass(frozen=True) class SignedManifest: manifest_digest: str declared_scope: str provides: Mapping[str, str] # tool_name -> entrypoint_digest dependencies: Mapping[str, str] # dep_name -> resolved_digest (must be pinned) signer_key_id: str signature: bytes signed_binding: FrozenSet[str] # tuple fields actually covered by signature @dataclass(frozen=True) class LoadContext: target_scope: str registry_epoch: int key_authorization: Mapping[str, FrozenSet[str]] # key_id -> authorized scopes REQUIRED_BINDING_FIELDS = frozenset( {"manifest_digest", "scope", "registry_epoch", "resolved_dependency_digests"} ) def verify_and_bind(manifest: SignedManifest, ctx: LoadContext, verifier) -> None: # 1. Cryptographic validity of the body. if not verifier(manifest.signer_key_id, manifest.manifest_digest, manifest.signature): raise ScopeBindingError( f"signature invalid for manifest {manifest.manifest_digest}" ) # 2. The signature must cover the *binding*, not just the body. missing = REQUIRED_BINDING_FIELDS - manifest.signed_binding if missing: raise ScopeBindingError( f"manifest {manifest.manifest_digest} signature does not cover binding " f"fields {sorted(missing)}; refusing to bind into scope {ctx.target_scope}" ) # 3. Scope authorization: a valid key is not authority over this scope. authorized = ctx.key_authorization.get(manifest.signer_key_id, frozenset()) if ctx.target_scope not in authorized: raise ScopeBindingError( f"signer {manifest.signer_key_id} not authorized for scope " f"{ctx.target_scope} (authorized: {sorted(authorized)})" ) # 4. Declared scope must match the binding target. if manifest.declared_scope != ctx.target_scope: raise ScopeBindingError( f"manifest declares scope {manifest.declared_scope!r} but is being " f"bound into {ctx.target_scope!r}" ) # 5. Dependencies must be pinned, not wildcarded. for dep, digest in manifest.dependencies.items(): if not digest or digest == "*": raise ScopeBindingError( f"dependency {dep!r} is unpinned in signed manifest " f"{manifest.manifest_digest}; refusing (dependency-swap surface)" ) # 6. Every provides entry must resolve within the declared scope. for tool_name, entry in manifest.provides.items(): if not tool_name.startswith(f"{ctx.target_scope}."): raise ScopeBindingError( f"tool {tool_name!r} provides into namespace outside scope " f"{ctx.target_scope!r}; scope field is not advisory" ) def load_skill(manifest: SignedManifest, ctx: LoadContext, verifier) -> str: verify_and_bind(manifest, ctx, verifier) return hashlib.sha256( f"{manifest.manifest_digest}:{ctx.target_scope}:{ctx.registry_epoch}".encode() ).hexdigest() ```