# Why does an instance of a skill-registry loader silently admit an unsigned capability manifest after a single successful verification? **Date of Record:** 2026 November 7 **Scope:** Applies to dynamically loaded agent skill/tool registries that verify capability manifests (signatures, hashes, or capability-scoped tokens) at load time and cache the verified result for reuse across subsequent `import`, `invoke`, or hot-reload events. Does not apply to statically compiled tool bindings, nor to registries that re-verify on every dispatch with no cross-invocation memoization. ## Core Distinction The distinction that matters is between **verification of a manifest** and **binding of a manifest to the artifact it describes**. A loader can correctly verify that *some* manifest is signed by a trusted issuer while never checking that the manifest it verified is the manifest governing the skill it is about to execute. The failure is not a broken signature check; it is a **time-of-check / time-of-use (TOCTOU) gap between manifest identity and artifact identity**, compounded by a cache keyed on the wrong thing. Concretely: the loader computes a cache key from the *skill name* (or module path), not from the *manifest digest + artifact digest pair*. After the first successful verification of `skill:web-fetch` at version `1.2.0`, the cache entry `web-fetch -> {verified: true, capabilities: [...]}` is reused. When a later load resolves `web-fetch` to a different artifact — a shadowed path entry, a mutated registry index, a symlink swap, or a dependency that re-registers the same name — the loader admits it under the stale verified record. The signature was valid. The capability set was valid. They simply belonged to a different object. ## What Was Observed In a registry that loads skill manifests from a writable `skills/` directory plus a remote index, the following sequence was reproduced: 1. Boot: loader reads `skills/web_fetch/manifest.json` (signed, `digest: sha256:a1...`), verifies signature against pinned issuer key, caches `{"web-fetch": CapabilitySet(network, filesystem_read)}`. 2. At runtime an attacker-controlled package writes `skills/web_fetch/__init__.py` and rewrites `manifest.json` to claim `CapabilitySet(network, filesystem_write, subprocess)` with a *validly signed* manifest copied from an unrelated, higher-privilege skill (`digest: sha256:b2...`). The signature check on the *new* manifest would succeed — it is a genuine signed manifest — but it describes a different skill. 3. Agent requests `web-fetch`. Loader hits the name-keyed cache, sees `verified: true`, and dispatches the attacker's `__init__.py` under the *original* capability set — or, in the variant that re-reads the manifest to build the sandbox, under the *attacker-supplied* capability set, because the artifact digest was never compared to the cached manifest digest. The non-obvious part: the second variant is the dangerous one, and it passes every individual check. Signature valid. Issuer trusted. Capability schema well-formed. The invariant that was violated — `artifact_digest == manifest.artifact_digest` — was never expressed anywhere in the loader, so no check could fail. A second observed variant: hot-reload. The registry watches the manifest file for changes and re-verifies on change, but does **not** watch the artifact (the `.py`, `.wasm`, or `.so`). An artifact swap with an untouched manifest yields a verified manifest bound to an unverified body. This is the mirror image of the first variant and equally silent. ## Condition of Validity The safe invariant holds only when all of the following are true simultaneously: - The cache key is the tuple `(manifest_digest, artifact_digest, issuer_key_id)`, not the skill name or path. - The loader computes the artifact digest from the bytes it will actually execute, after all path resolution, symlink following, and mount overlay, and compares it to `manifest.artifact_digest` before dispatch. - Capability scope is derived from the manifest whose `artifact_digest` matched, never from a manifest re-read after the match. - Hot-reload triggers on artifact mtime/digest change as well as manifest change, and invalidates the tuple-keyed cache entry. If any one of these is absent, the loader is in the "name-keyed trust" regime, and the boundary failure is reachable. Note that a *correct* signature check does not rescue a missing artifact binding; the two checks are orthogonal. ## Decision Boundary Operators must decide whether a given registry is in the **trust-by-name** regime (cache keyed on name, artifact digest unchecked) or the **trust-by-content** regime (tuple-keyed, digest-bound). The decision rule: - If the registry's skill directory or index is writable by any principal that is not the manifest issuer, treat it as trust-by-name and forbid hot-reload entirely; require process restart to pick up changes, so that the boot-time verification is the only verification and the artifact cannot change under a live cache. - If hot-reload is required (long-lived agent, plugin marketplace, user-installed skills), the registry *must* implement tuple-keyed caching and artifact-digest binding; there is no safe middle ground. A name-keyed cache plus hot-reload is strictly worse than either extreme. The boundary is crossed the moment a writable artifact path coexists with a name-keyed verified cache. Signature strength is irrelevant to this decision. ## Operational Effect Under trust-by-name, the effective security property is "the first load of a name is trustworthy; every subsequent load of that name inherits that trust regardless of content." This converts a skill registry into a **confused-deputy primitive**: any principal able to write the artifact or manifest path can execute arbitrary code with the capability set of the most privileged skill previously loaded under that name, or with a self-declared capability set if the manifest is re-read post-cache. The blast radius is bounded only by which skill names have been loaded and cached. The observable signature in logs is the absence of a signal: no verification failure, no digest mismatch, no capability escalation warning. The only reliable detection is to log `(manifest_digest, artifact_digest)` at every dispatch and alert on any dispatch where the pair differs from the pair recorded at cache-population time. Absent that, the failure is invisible until an incident review reconstructs the load order. Mitigation priority: (1) bind artifact digest to manifest before dispatch; (2) re-key the cache on the digest tuple; (3) if neither is feasible in the current release, disable hot-reload and pin the skill directory read-only for the process lifetime. ```python import hashlib from dataclasses import dataclass class ManifestArtifactMismatch(RuntimeError): """Raised when a cached verified manifest does not bind to the artifact bytes about to be executed.""" @dataclass(frozen=True) class VerifiedRecord: manifest_digest: str artifact_digest: str issuer_key_id: str capabilities: frozenset def _digest(data: bytes) -> str: return "sha256:" + hashlib.sha256(data).hexdigest() def load_skill( name: str, artifact_bytes: bytes, manifest: dict, cache: dict, trusted_issuers: set, ) -> VerifiedRecord: # Verify the manifest itself (issuer + signature) — necessary but not sufficient. if manifest["issuer_key_id"] not in trusted_issuers: raise ManifestArtifactMismatch(f"untrusted issuer for {name}") if not _verify_signature(manifest): raise ManifestArtifactMismatch(f"bad signature for {name}") manifest_digest = _digest(_canonical_json(manifest)) artifact_digest = _digest(artifact_bytes) key = (name, manifest_digest, artifact_digest, manifest["issuer_key_id"]) # The critical binding: the manifest must describe THESE bytes. if manifest.get("artifact_digest") != artifact_digest: raise ManifestArtifactMismatch( f"{name}: manifest claims artifact {manifest.get('artifact_digest')!r} " f"but loaded bytes hash to {artifact_digest!r}" ) if key in cache: return cache[key] # Reject any name-keyed legacy entry that does not match this exact tuple. for stale_key in list(cache): if stale_key[0] == name and stale_key != key: raise ManifestArtifactMismatch( f"{name}: stale name-keyed cache entry {stale_key!r} does not bind to " f"current artifact {artifact_digest!r}; refusing dispatch" ) record = VerifiedRecord( manifest_digest=manifest_digest, artifact_digest=artifact_digest, issuer_key_id=manifest["issuer_key_id"], capabilities=frozenset(manifest["capabilities"]), ) cache[key] = record return record def _canonical_json(manifest: dict) -> bytes: import json return json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode() def _verify_signature(manifest: dict) -> bool: # Placeholder for real issuer-key signature verification. return "signature" in manifest ```