Report key-signed artifacts as such at install - #6443
Open
samuv wants to merge 2 commits into
Open
Conversation
A cosign key-pair signature is a real signature referrer, so bundle retrieval finds it and the unsigned verdict never fires. The keyless policy then rejects every bundle for want of a Fulcio certificate, and that failure was classified as an invalid signature — blaming the signature for what is actually a missing trust anchor, and leaving no remedy, since the unsigned exception only overrides the unsigned verdict. The result was a hard 403 that no flag could resolve. Key-signed artifacts now classify as their own condition, so install names the key-pair layout, states that keyless signing is the remedy, and says plainly that the unsigned exception does not apply. Sync and upgrade get a matching failure reason, distinct from signature-invalid because nothing is wrong with the signature. The new sentinel deliberately does not wrap the invalid-signature one: reporting a valid signature as a verification failure is the misclassification being removed here, and the narrowing cannot fail open, as no caller treats that sentinel as permission to proceed. Mixed artifacts keep the keyless diagnosis — a bundle that does carry a certificate genuinely failed the policy. Also documents the gap on `thv skill push --key`, which advertised key signing with no caveat while the equivalent plugin warning shipped separately. Refs #6442 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,
aponcedeleonch,
jhrozek,
rdimitrov,
reyortiz3 and
tgrunnagle
as code owners
August 27, 2026 08:48
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #6443 +/- ##
==========================================
- Coverage 78.02% 77.94% -0.08%
==========================================
Files 766 766
Lines 74077 74102 +25
==========================================
- Hits 57795 57760 -35
- Misses 16277 16337 +60
Partials 5 5 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
11 tasks
A first install resolved from a catalog entry that declares provenance is classified by its own route, which reported every failure other than unsigned as a mismatch against the catalog-declared provenance. For a key-signed artifact that is wrong twice over: nothing was compared, because the keyless policy cannot check a key-pair signature at all, and the report carried neither the remedy nor the fact that the unsigned exception does not apply. Both routes now share one formatter. They reach classification differently but the diagnosis and the remedy do not differ, and two copies of the wording would drift apart exactly where the remedy is the whole value of the message. Refs #6442 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Samuele Verzi <samu@stacklok.com>
5 tasks
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
thv skill push --keyandthv ai-plugin push --keyproduce a validly key-signed artifact, but install-time verification is keyless-only — so the result cannot be installed project-scoped. Worse than failing as "unsigned", it failed inexplicably:retrieveBundlesfinds it andErrUnsignednever fires — that sentinel means no signature material at all.verifyKeylessBundlesthen fails for want of a Fulcio certificate chain, andclassifyVerifyFailurefell through towrapInvalid→ErrSignatureInvalid.isAllowedUnsignedrequireserrors.Is(err, ErrUnsigned), so--allow-unsignedcould not override it.Net effect: a hard
403blaming the signature for what is actually a missing trust anchor, with no flag that could resolve it.This PR makes the diagnosis truthful. It does not yet add key verification — that needs a lock-schema change and is the rest of the stack.
verifier.ErrKeySigned, classified when every retrieved bundle is certificate-less (via core's already-exportedBundle.HasCertificate(), which had zero callers here).allow_unsigneddoes not apply — for both skills and plugins.classifyCatalogVerifyError) is classified separately from a lock-constrained one, and previously reported anything other than unsigned as a mismatch against catalog-declared provenance — wrong twice over for a key-signed artifact, since nothing was compared and the remedy was lost.FailureReasonKeySignedfor sync/upgrade, distinct fromsignature-invalidbecause nothing is wrong with the signature.thv skill push --key, which advertised key signing with no caveat while the equivalent plugin warning shipped separately in Sign plugin pushes and remove the lock feature gate #6438.Refs #6442 (first of ~7 PRs; the issue stays open).
Type of change
Test plan
task test)task test-e2e)task lint-fix)Full unit suite passes for every package this PR touches. Two caveats, stated plainly:
pkg/transport/proxy/streamable'sTestMCPGoClientInitializeAndPing, caused by a stray local process holding:8096. Confirmed pre-existing on cleanmainin a detached worktree; untouched by this PR.task lint-fixreports issues only incmd/thv-operator/files this PR does not touch (deprecatedctrl.Result{Requeue}/PrimaryUpstreamProvider). All 15 files here are clean.New coverage: a real key-signed artifact (pushed to an in-process registry via the existing
signArtifacthelper) classifying asErrKeySignedand not asErrSignatureInvalidorErrUnsigned; the same artifact against a keyless-pinned lock entry; the 403 wording; the failure-reason mapping; andisAllowedUnsignedrefusing to rescue a key-signed artifact even withAllowUnsigned: true.API Compatibility
v1beta1API, OR theapi-break-allowedlabel is applied and the migration guidance is described above.No operator API surface is touched. The
docs/server/*churn is the skillsFailureReasonenum, which is swagger-documented — an additive enum value.Changes
pkg/skills/verifier/errors.goErrKeySignedsentinel, with the rationale for not wrappingErrSignatureInvalidpkg/skills/verifier/oci.goonlyKeySigned()helper; classified before the generic invalid verdictpkg/skills/skillsvc/verify.gopkg/plugins/pluginsvc/verify.gopkg/skills/options.goFailureReasonKeySignedpkg/plugins/options.gocmd/thv/app/skill_push.go--keycaveatdocs/arch/12-skills-system.mddocs/cli/*,docs/server/*task docs)*_test.gopkg/skills/skillsvc/verify.gokeySignedInstallError, shared by both classification routesDoes this introduce a user-facing change?
Yes. Installing a key-signed artifact project-scoped still fails — that is unchanged and needs the rest of the stack — but the error now explains why and what to do, instead of reporting a signature verification failure.
thv skill push --keyalso now warns at the point of use that its output is not installable. Sync and upgrade reportkey-signed-unverifiablerather thansignature-invalid.Implementation plan
Approved implementation plan
Resolved through a design interview covering the whole of #6442. The chosen arm is support key verification, install-only for v1;
--keystays on push. Full write-up posted to the issue: #6442 (comment)Decisions relevant to this PR:
ErrKeySignedsentinel, and--allow-unsignedmust not override it: the artifact is signed, so recording it as an unsigned exception would file a false trust decision in the lock.HasCertificate()— no toolhive-core change or release is needed anywhere in this stack.Facts established while planning that shaped later PRs, and explain why this one stops where it does:
toolhive-core/container/signer/signer.go:99). A--keybundle is a bare detached signature: no certificate, no transparency log, no signing time, no revocation.signatureAnnotationswrites no annotation for it, and the retrieve side reconstructs with a fixed placeholder hint (keySignedPublicKeyHint = "cosign-keypair"). So asha256:digest in the lock is impossible,resultFromKeymust not grow an observed-key field, and "wrong key" vs "broken signature" are genuinely indistinguishable.lockfile.CurrentVersionmust not be bumped —validation.go:35compares!=, not<=, so a bump makes every existing v1 lock unreadable. Unnecessary regardless:validateProvenancealready requiressignerIdentity, so an older thv fails a future key-pinned entry closed for free.Remaining sequence: (2) lock schema
Provenance.publicKey, identity XOR key; (3) skills install--public-key; (4) skills sync/upgrade; (5–6) plugins mirrors; (7) E2E + docs.Special notes for reviewers
ErrKeySigneddeliberately does not wrapErrSignatureInvalid, unlike theErrProvenanceFieldMismatchprecedent directly below it. Reporting a valid signature as a verification failure is the exact misclassification being removed. I checked the narrowing cannot fail open: every consumer ofErrSignatureInvalidis either one of the twoclassifySignatureErrorswitches or async.gomessage construction — nothing treats it as permission to proceed.A pre-existing test pinned the old behavior.
TestVerifyOCIKeylessRejectsKeySignedArtifactassertedErrSignatureInvalid. Its comment showed the intent was only "not as unsigned, and never as a panic" —ErrSignatureInvalidwas simply the classification available at the time, not a claim the signature was bad. I refined it in place (keeping both original invariants as explicit negative assertions) rather than treating it as a contract, and folded my duplicate test into it.Mixed artifacts keep the keyless diagnosis.
onlyKeySignedrequires every bundle to be certificate-less; if one carries a certificate, that bundle genuinely failed the keyless policy and the existing error is correct.Two adjacent paths are already broken for key-signed artifacts and are addressed in PR 4/6, not here — worth knowing they are not regressions:
probeCandidateSigner(upgrade.go:275) is keyless-only, so a key-signed upgrade candidate reportsUpgradeStatusFailedrather thanSignerChangeBlocked; andadoptSkill/adoptLockedback-fill viaResultFromBundle, which requires a certificate, so a key-signed install cannot be adopted at all.Generated with Claude Code