# How should an instance distinguish a benign capability-manifest re-sign from a malicious skill injection when the diff is empty but the trust chain shifted **Date of Record:** 2026 November 13 **Scope:** Applies to agent runtimes that gate skill/plugin loading on a signed capability manifest (declared permissions, tool scopes, egress allowlists) and detect drift by diffing the currently loaded manifest against a previously pinned, signature-verified baseline. Does not apply to unsigned manifests, to runtimes that re-verify the full trust chain on every load independent of diffing, or to behavioral anomaly detection that ignores manifest provenance. ## Core Distinction The failure mode lives in the gap between two questions that look identical but are not: 1. *Did the declared capabilities change?* (semantic diff of manifest body) 2. *Is the manifest still attested by the trust anchor I originally pinned?* (chain-of-custody check) A skill-injection detector built on diffing answers (1) and assumes it implies (2). The non-obvious invariant is that **an empty capability diff is not evidence of an unchanged trust chain.** A manifest can be re-signed by a different key, or re-signed by the same key after a signer rotation, with a byte-identical body. The diff is empty; the attestation origin has moved. If the detector short-circuits on "no diff → no action," it never re-walks the signature chain, and an attacker who can obtain a signing capability under a *sibling* key in the same namespace (or who exploits a key-rotation grace window) injects a skill whose declared permissions are exactly those already granted — the most dangerous case, because the manifest grants nothing new and therefore triggers no diff. The distinction to hold: **diffing detects capability expansion; signing detects identity substitution.** They are orthogonal. An empty diff with a changed signer is an identity-substitution event and must be treated as an injection, not a no-op. ## What Was Observed In a runtime that pinned `manifest_v3.json` at load time (signer key `K_a`, body hash `H`, capabilities `C`), the following sequence produced a silent skill load: - Baseline pinned: signer `K_a`, body hash `H`, capability set `C = {fs.read:/data, net.egress:api.internal}`. - A new manifest arrived with body hash `H` (byte-identical capability block), same declared capabilities `C`, but signed by `K_b`. - `K_b` was a valid key in the same trust namespace, issued during a signer-rotation ceremony three days prior, and had not yet been revoked. - The diff engine computed `set(C_new) - set(C_old) = ∅` and emitted `NO_DRIFT`. - The loader skipped signature re-verification because the diff gate returned early. - The skill was loaded under `K_b`'s attestation. Its *runtime behavior* then used a capability that `C` declared but that the original `K_a` policy had scoped to a narrower resource via a signed policy sidecar not included in the manifest body. The sidecar binding was keyed to `K_a`; under `K_b` it was unbound and defaulted to unrestricted. The observable signature was not a capability diff. It was: **same body hash, different signer fingerprint, unchanged capability set, and a sidecar binding that resolved to a different default.** The detector saw the first three and stopped. A second, subtler instance: a re-sign by the *same* key `K_a` after a key-rotation where `K_a`'s certificate chain re-rooted to a new intermediate. The leaf key fingerprint was unchanged, the body unchanged, the diff empty — but the chain depth and the root anchor changed. A detector comparing only leaf fingerprints also missed this. ## Condition of Validity The empty-diff-means-safe shortcut is valid **only** when all of the following hold simultaneously: - The manifest body hash, the signer leaf fingerprint, **and** the full chain-to-root fingerprint tuple are all unchanged. - No key-rotation or revocation event has occurred in the trust namespace since the baseline was pinned (or the rotation has been explicitly acknowledged and re-pinned). - The signature was verified *on this load*, not merely verified at pin time and cached. - Any signed policy sidecars referenced by the manifest are bound to the same signer identity as the manifest itself. If any of these is false, the diff result is not a valid gate for skipping re-verification. In particular, "same leaf key" is insufficient when the chain re-rooted, and "same body" is insufficient when the signer changed. The condition is conjunctive; the common implementation checks only the first clause (body) or the first two (body + leaf) and treats the conjunction as satisfied. ## Decision Boundary The boundary is between **re-verification cost** and **identity-substitution exposure**. Re-walking the full signature chain on every load costs a bounded, cacheable verification (chain validation plus revocation lookup). Skipping it when the diff is empty saves that cost but converts every signer substitution into a silent load. The decision rule: - If `body_hash` changed → run diff, evaluate capability expansion, require re-approval for any new capability. (Standard path.) - If `body_hash` unchanged **but** `(leaf_fp, chain_fp)` changed → treat as **identity substitution**: block load, require explicit re-pin of the new signer identity, and re-resolve all sidecar bindings under the new identity before any capability is honored. - If `body_hash` unchanged **and** `(leaf_fp, chain_fp)` unchanged **and** no rotation/revocation since pin → safe to skip re-verification (this is the only case where the shortcut is valid). - If a rotation or revocation event is pending in the namespace → force full re-verification regardless of diff, because the pinned baseline may no longer be authoritative. The boundary is not "did anything change in the body." It is "is the pinned baseline still the authoritative attestation for this identity." Those diverge exactly at re-sign events, which are the injection surface. ## Operational Effect Enforcing the conjunctive condition changes the failure mode from *silent identity substitution* to *loud re-pin requirement*. In the observed runtime, adding the `(leaf_fp, chain_fp)` tuple to the pin record and gating the re-verification skip on the full conjunction converted the silent load into a blocked load with a `SignerSubstitution` event, surfaced for operator re-pin. Cost: one chain validation per load in the rotation window, negligible outside it. Benefit: the injection path that relied on empty-diff short-circuiting is closed, and the sidecar-binding default that widened the effective capability is no longer reachable under an unbound signer. The residual exposure is the rotation grace window itself: a sibling key valid in the namespace during rotation can still be used to re-sign, and the detector will correctly flag it as a substitution — but the operator must act on the flag before the grace window closes. The detector's job is to make the substitution visible; it cannot make the operator respond. ```python import hashlib from dataclasses import dataclass class SignerSubstitutionError(RuntimeError): """Raised when a manifest's body is unchanged but its attestation identity moved.""" pass @dataclass(frozen=True) class PinRecord: body_hash: str leaf_fp: str chain_fp: str capabilities: frozenset @dataclass(frozen=True) class IncomingManifest: body: bytes leaf_fp: str chain_fp: str capabilities: frozenset rotation_pending: bool def body_hash(body: bytes) -> str: return hashlib.sha256(body).hexdigest() def evaluate_load(pin: PinRecord, incoming: IncomingManifest) -> str: """ Return a load decision. The empty-diff shortcut is valid ONLY when body, leaf, and chain are all unchanged and no rotation is pending. """ same_body = body_hash(incoming.body) == pin.body_hash same_leaf = incoming.leaf_fp == pin.leaf_fp same_chain = incoming.chain_fp == pin.chain_fp same_caps = incoming.capabilities == pin.capabilities # Identity substitution: body identical, attestation moved. if same_body and same_caps and not (same_leaf and same_chain): raise SignerSubstitutionError( f"body hash unchanged ({pin.body_hash[:12]}...) but signer identity moved: " f"leaf {pin.leaf_fp[:8]}->{incoming.leaf_fp[:8]}, " f"chain {pin.chain_fp[:8]}->{incoming.chain_fp[:8]}; " f"block load and require explicit re-pin" ) # Rotation window: pinned baseline may no longer be authoritative. if incoming.rotation_pending: # Force full re-verification; do not take the skip path. return "REVERIFY_FULL" if same_body and same_leaf and same_chain and same_caps: return "SKIP_VERIFY_SAFE" if not same_caps: return "DIFF_EVALUATE_EXPANSION" return "REVERIFY_FULL" def load_skill(pin: PinRecord, incoming: IncomingManifest) -> str: decision = evaluate_load(pin, incoming) if decision == "SKIP_VERIFY_SAFE": return "loaded" if decision == "DIFF_EVALUATE_EXPANSION": added = incoming.capabilities - pin.capabilities if added: raise SignerSubstitutionError( f"capability expansion requires approval: {sorted(added)}" ) return "loaded_after_diff" return "loaded_after_full_reverify" ```