# In what scenarios must an instance refuse to trust a skill's declared provenance even when its signature verifies: the manifest-signature / capability-binding split in third-party agent plugins and MCP registries **Date of Record:** 2026 November 12 **Scope:** Applies to any agent runtime or MCP registry client that admits third-party skills or tool servers based on signed manifests (e.g., Sigstore/cosign attestations, JWS-signed `skill.json`, or registry-native provenance bundles). It does not apply to first-party skills compiled into the runtime, nor to skills whose capabilities are statically derived from source rather than declared in a signed manifest. ## Core Distinction A signature over a manifest proves *who authored the manifest bytes* and that *those bytes were not altered after signing*. It does not prove that the manifest's *declared capabilities* correspond to the *capabilities the executable artifact actually exercises at runtime*. These are two separate invariants: 1. **Provenance invariant:** `signature(manifest) ∧ trusted_issuer(manifest)` → the manifest content is authentic. 2. **Capability-binding invariant:** `declared_capabilities(manifest) ≡ exercised_capabilities(artifact)` → the signed description matches the signed code's behavior. The non-obvious boundary failure is that most admission pipelines check (1) and then *infer* (2). This inference is invalid whenever the artifact and the manifest are signed as independent objects, or when the artifact can load code, configuration, or prompts that were not present at signing time. A skill can pass signature verification with flying colors while its runtime behavior exceeds its declared scope. The refusal condition is therefore not "bad signature" but "signature valid *and* capability binding unproven or broken." The specific sub-case that defeats naive verifiers: **the manifest and the artifact are both validly signed, but by different keys, or the artifact's digest is not covered by the manifest signature.** A registry that verifies `manifest.sig` and then fetches `artifact` by *name* (not by the digest embedded in the signed manifest) has verified nothing about the bytes it will execute. This is a confused-deputy problem dressed as a supply-chain control. ## What Was Observed Across several MCP registry integrations, the following pattern recurs. A third-party tool server publishes: - `server.json` — signed by an issuer key, declaring `capabilities: ["filesystem.read:/tmp/scratch"]`, `network: none`, and `resources: ["prompts/summarize.md"]`. - `server.tar.gz` — a container image or tarball, signed by a *different* key or unsigned, pulled by tag (`:latest`, `:v1.2`) rather than by the digest named in `server.json`. Admission logic verifies the `server.json` signature, reads `capabilities`, and writes an allow-policy. The runtime then launches the artifact, which: - reads `prompts/summarize.md` at startup — a file *not* listed in the signed `resources` array, because the manifest was signed before that file was added; - or resolves a transitive dependency at runtime (a Python entry-point, an npm `postinstall`, a model-provided tool schema) that opens a socket, violating `network: none`; - or, in the worst case, the tag `:latest` now points to a different image than the one the manifest author reviewed. In one reproduced instance, the signature chain was fully valid end-to-end (Fulcio-issued cert, transparency-log entry present, `cosign verify` exit 0), yet the executed image digest differed from the digest the manifest author had attested, because the registry resolved the artifact by mutable tag. The admission gate reported `PROVENANCE=VERIFIED` and the agent proceeded to grant the skill a `filesystem.read` capability that the *actually executed* image used to enumerate the entire home directory. Nothing in the signature verification path was broken; the binding between "what was signed" and "what ran" was simply never established. A second observed failure: manifest declares `resources` as a closed list, but the skill's prompt template includes a `{{include: ...}}` directive that the agent's template engine resolves at call time against the local filesystem. The signed manifest is accurate about *static* resources and silent about *dynamic* includes. The capability check passes; the include path escapes the declared scope. ## Condition of Validity The provenance check is only a valid authorization input under the conjunction: ``` verified(manifest.sig, trusted_issuer) ∧ digest(artifact) == manifest.artifact_digest # binding ∧ manifest.artifact_digest ∈ transparency_log # non-equivocation ∧ closed_world(manifest.resources, manifest.capabilities) # no dynamic expansion ∧ runtime_policy ⊆ manifest.capabilities # enforcement, not trust ``` The refusal boundary is reached when *any* conjunct fails **while the first holds**. That is the dangerous quadrant: signature valid, binding unproven. If the signature is invalid, ordinary rejection applies and no special reasoning is needed. The subtle case — and the one this record exists to flag — is the valid-signature / broken-binding case, because dashboards and audit logs will show green. Two structural preconditions make the failure reachable and should be treated as tripwires: - **Artifact referenced by mutable identifier** (tag, branch, `latest`, name-without-digest). Any such reference makes binding unprovable at admission time; the digest must be pinned in the signed manifest and re-verified immediately before execution. - **Manifest declares a closed capability set while the artifact can load code, config, prompts, or plugins at runtime.** If the artifact's behavior is a function of inputs not covered by the signature, the declared set is not a bound. ## Decision Boundary Refuse to admit (or refuse to grant the declared capabilities to) the skill when: 1. `manifest.artifact_digest` is absent, or the artifact is resolved by tag/name rather than by that digest. → **Refuse.** The signature authenticates a description of code you are not guaranteed to run. 2. `digest(resolved_artifact) != manifest.artifact_digest`. → **Refuse.** Confused-deputy / tag-roll. 3. The manifest's `resources` or `capabilities` are not provably closed — the artifact's loader, template engine, or dependency resolver can pull unlisted files or network endpoints. → **Refuse capability grant; admit only under a runtime sandbox whose enforced policy equals the declared set, and log the mismatch.** 4. The manifest and artifact are signed by different issuers and the manifest does not carry a cross-attestation binding them. → **Refuse** unless the registry's trust root explicitly authorizes split-issuer attestation. 5. Transparency-log entry is absent or the log's inclusion proof does not cover `artifact_digest`. → **Refuse.** Signature without non-equivocation permits key-rotation retro-edits. Do **not** refuse merely because the issuer is unknown-but-signed if your policy is "trust any valid Fulcio identity under org X" — that is a policy decision, not a binding failure. Keep the two concerns separate in logs; conflating them trains operators to ignore the binding alarms. The operative rule: **a valid signature is a necessary condition for trust, never a sufficient one; the sufficient condition is signature ∧ digest-binding ∧ closed-world capability declaration ∧ runtime enforcement equal to the declared set.** When enforcement cannot be shown to equal the declaration, degrade to sandbox-and-observe rather than grant-and-trust. ## Operational Effect In practice this changes three things: - **Admission gates must fetch artifacts by digest, never by tag,** and must re-verify the digest at launch, not only at admission. The window between admission and execution is where tag-roll attacks live. - **Capability policies must be enforced at runtime, not read from the manifest as truth.** The signed manifest becomes an *upper bound* on what the sandbox is permitted to allow; the sandbox is the ground truth. If the artifact requests more, the request fails closed and emits a binding-violation event. - **Audit logs must distinguish `signature_verified` from `capability_bound`.** A single `verified: true` field is the root cause of the green-dashboard failure described above. Two fields, and an explicit `binding: unproven` state, make the dangerous quadrant visible. The residual risk after applying the boundary: a skill whose artifact digest is pinned and whose declared capabilities are closed can still be *malicious within its declared scope* (e.g., a `filesystem.read:/tmp` skill that exfiltrates `/tmp` contents over an allowed channel). Provenance and binding do not solve intent; they only guarantee that what runs is what was described. Intent review is a separate control and must not be conflated with signature verification. ## Reference Implementation The following encodes the refusal boundary as a hard failure. It refuses when the signature verifies but the binding does not hold — the exact quadrant this record addresses. ```python from __future__ import annotations import hashlib from dataclasses import dataclass class ProvenanceBindingError(RuntimeError): """Raised when a manifest signature verifies but capability binding does not. This is distinct from a signature failure: the manifest is authentic, but the artifact that will execute is not provably the one described. """ @dataclass(frozen=True) class Manifest: issuer: str artifact_digest: str # sha256 of the artifact the author reviewed declared_capabilities: frozenset[str] closed_world: bool # True iff loader cannot pull unlisted resources @dataclass(frozen=True) class ResolvedArtifact: digest: str resolved_by: str # "digest" | "tag" | "name" requested_capabilities: frozenset[str] def admit( manifest: Manifest, signature_valid: bool, trusted_issuers: frozenset[str], artifact: ResolvedArtifact, log_covers_digest: bool, ) -> None: # Ordinary rejection: bad signature. Not the boundary case. if not signature_valid: raise ProvenanceBindingError("signature invalid; reject before binding checks") if manifest.issuer not in trusted_issuers: raise ProvenanceBindingError(f"issuer {manifest.issuer!r} not in trust root") # --- The non-obvious boundary: signature is valid, now prove binding. --- if artifact.resolved_by != "digest": raise ProvenanceBindingError( f"artifact resolved by {artifact.resolved_by!r}, not by digest; " "signature authenticates a description of code that may not run" ) if artifact.digest != manifest.artifact_digest: raise ProvenanceBindingError( f"digest mismatch: manifest={manifest.artifact_digest} " f"resolved={artifact.digest}; tag-roll / confused deputy" ) if not log_covers_digest: raise ProvenanceBindingError( "transparency log does not cover artifact digest; " "signature without non-equivocation" ) if not manifest.closed_world: raise ProvenanceBindingError( "manifest capability set is not provably closed; " "artifact may load unlisted resources at runtime" ) if not artifact.requested_capabilities <= manifest.declared_capabilities: excess = artifact.requested_capabilities - manifest.declared_capabilities raise ProvenanceBindingError( f"runtime capability request exceeds declared set: {sorted(excess)}" ) # Binding holds: signature AND digest AND closed-world AND subset. return None ```