Honour a pinned cosign key on sync and upgrade - #6478
Open
samuv wants to merge 3 commits into
Open
Conversation
Back-filling provenance from a stored bundle assumed the bundle carried a certificate. A key-signed one does not: the trust anchor lives outside the artifact, which is the whole shape of the key-pair flow. Handed one, the keyless verification underneath failed on the absent certificate and reported a broken signature — for a bundle that is perfectly intact and verifies fine against its key. Report it as ErrKeySigned instead, decided from the bundle's own shape rather than from a failed attempt to read an identity out of it. Callers that need to treat "there is nothing here to observe" differently from "this signature is damaged" can now tell the two apart, which the next change needs to explain itself to the user. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Samuele Verzi <samu@stacklok.com>
Sync re-verified every stored bundle through the keyless path, which refuses a key-pinned entry outright. Sync reads that refusal as drift it can heal by reinstalling, so a key-pinned skill reported as modified on every run and never settled, and `--check` failed permanently on a project that was in fact intact. The trust anchor was recorded correctly and simply never consulted. Re-verify against the pinned key instead, reconstructing the signed payload from the lock's reference rather than the install record's: the lock is the authority on what the project is pinned to, and the install record is the thing being checked against it. A missing bundle now names the key as the anchor it could not satisfy — the keyless wording named an empty signer identity, since a key entry records none. Adoption refuses a key-signed install rather than attempting it. Adoption back-fills trust from what the bundle reveals, and a key-pair bundle reveals no identity and does not carry the key, so there is nothing to record; sync takes no public key of its own to supply one. Recording it as unsigned instead would file a false trust decision about an artifact that is signed, so the refusal names the one path that can anchor it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Samuele Verzi <samu@stacklok.com>
A key-pinned entry could not be upgraded at all. The signer-change guard probes the candidate for a certificate identity to compare against the recorded one, and a key-pair bundle has none — so the probe returned "this is key-signed" and the plan failed with a signature error that named none of the real situation. The apply step would have worked: it reads the anchor from the lock, so the pinned key was there all along. Only the guard stood in the way. Apply the pinned key to the candidate directly. Verifying against it IS the evidence the signer has not changed, which is the same claim the keyless guard makes by comparing identities. No new key is accepted here — the lock supplies it, and `--public-key` remains install-only. Split the blocked arms by whether the remedy the CLI prints actually works, because it prints one. A candidate that moved to keyless signing or lost its signature blocks as a signer change, and `--allow-signer-change` genuinely resolves those: dropping the recorded key and re-verifying keylessly is the supported key-to-keyless move. A candidate signed by a different key gets a failure naming the uninstall-and-reinstall route instead, because re-anchoring in place is not offered and pointing at `--allow-signer-change` would send the caller into a refusal one step later. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Samuele Verzi <samu@stacklok.com>
samuv
requested review from
ChrisJBurns,
JAORMX,
amirejaz,
jhrozek,
rdimitrov and
tgrunnagle
as code owners
August 31, 2026 21:12
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## t3code/key-verify/03-skills-install-public-key #6478 +/- ##
==================================================================================
+ Coverage 78.07% 78.10% +0.03%
==================================================================================
Files 768 768
Lines 74551 74604 +53
==================================================================================
+ Hits 58205 58270 +65
+ Misses 16341 16329 -12
Partials 5 5 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
#6447 taught
installto verify a key-signed skill and pin the key in the lock file. Nothing downstream read that pin back. This PR makes the lock-driven operations honour the anchor they already record — no new flag, no new trust decision, just the recorded key doing its job.syncre-verified everything through the keyless path, which refuses a key-pinned entry outright. Sync reads a refusal as drift it can heal by reinstalling, so a key-pinned skill reported as modified on every run and never settled, and--checkfailed permanently on a project that was in fact intact. It now re-verifies against the pinned key offline.upgradecould not advance a key-pinned entry at all. The signer-change guard probes the candidate for a certificate identity to compare, and a key-pair bundle has none — so the probe returned "key-signed" and the plan failed with a signature error naming none of the real situation. The apply step would have worked all along: it reads the anchor from the lock. Only the guard stood in the way.sync --adoptattempted a key-signed install and failed opaquely. Adoption back-fills trust from what the bundle reveals; a key-pair bundle reveals no identity and does not carry the key. It is now refused with the route that can anchor it.Part of #6442.
--public-keyremains install-only —upgradeandsynctake no key of their own, and in-place re-anchoring is still deliberately not offered.Where the remedy had to be chosen carefully
SignerChangeBlockedmakes the CLI print "use--allow-signer-change", so the status can only be used where that advice works. For a key-pinned entry it works when the candidate moved to keyless signing or lost its signature — dropping the recorded key and re-verifying keylessly is the key-to-keyless moveresolveKeyAnchorsupports. It does not work when the candidate is signed by a different key:--allow-signer-changedrops the key, takes the keyless path, and hitsErrKeySignedone step later. That arm reports a failure naming the uninstall-and-reinstall route instead.This is the same trap as the stale install-time guidance fixed in #6447 — a status that advertises a remedy the code then refuses.
Type of change
Changes
pkg/skills/verifier/offline.goResultFromBundlereportsErrKeySignedfor a certificate-less bundle, decided from its shape rather than from a failed identity readpkg/skills/skillsvc/sync.goverifyStoredKeySignaturefor key-pinned entries;describeLockAnchor; adoption refuses key-signed installspkg/skills/skillsvc/upgrade.goguardKeyedSignerChange: the pinned key applied to the candidate, blocked arms split by whether the printed remedy worksdocs/arch/12-skills-system.mdTest plan
task testpasses with no failurestask lint-fixclean for every file touched (the 6 remainingstaticcheckhits are pre-existing, in untouchedcmd/thv-operator/files)New coverage: the sync key branch including the reference fallback and the fail-closed case when no reference is recorded anywhere; every arm of the keyed upgrade guard, including that the keyless probe is never reached and that an undecodable pinned key fails rather than reporting a signer change; the adoption refusal, including that it writes no lock entry; and
ResultFromBundle's classification against a genuinely key-signed bundle produced by the signer package.Does this introduce a user-facing change?
Yes. A skill installed with
--public-keynow syncs, upgrades and reports correctly instead of showing permanent drift and refusing to upgrade. Adopting a key-signed install is refused with an actionable message rather than an opaque verification error.Special notes for reviewers
Why the lock's reference, not the install record's. The offline key check reconstructs the signed payload from a reference, so that reference is part of what is being verified. The lock is the authority on what the project is pinned to; the install record is the thing being checked against it. It falls back to the install record only when the entry records no resolved reference, and fails closed when neither has one.
No new trust decision anywhere. Every path here consumes an anchor that a previous
installalready verified and recorded. The one place that would have needed a new one — adoption — refuses instead.Production diff is 130 insertions across 3 files, inside the repo's guideline.
Generated with Claude Code