Status update 2026-09-03. The skills half of this issue is resolved; the plugins half is not, and its shape changed. Original diagnosis preserved below, since it still explains why the failure cannot be overridden.
What remains is one decision, not the original bug: implement the skills key path for plugins, or ratify keyless-only for plugins permanently. #6300 recorded keyless-only as the agreed interim contract when it closed.
Remaining scope (plugins)
Plugins now have no key surface at all — no --key at push, no PublicKey on install options, no key branch in verify or sync:
| Concern |
Skills (done) |
Plugins (absent) |
--key push flag |
cmd/thv/app/skill_push.go:35 |
none — cmd/thv/app/ai_plugin_push.go:28 documents why |
| Public key on install |
skills.InstallOptions.PublicKey (pkg/skills/options.go:51) |
none |
| Pinned key in lock |
lockfile.Provenance.PublicKey (pkg/skills/lockfile/lockfile.go:140) |
shared schema, unused by plugins |
| Install verify |
pkg/skills/skillsvc/verify.go:264 |
keyless only (pkg/plugins/pluginsvc/verify.go) |
| Upgrade verify |
pkg/skills/skillsvc/upgrade.go:283 |
keyless only |
| Offline sync re-verify |
pkg/skills/skillsvc/sync.go:307 |
keyless only (pkg/plugins/pluginsvc/sync.go) |
The lock schema already carries publicKey, so the plugins work is install options, the install API DTO, the verify/upgrade key branches, the sync re-verify branch, and finally restoring the push flag. Restoring --key first would recreate exactly the dead end this issue was opened about, so it has to come last.
Original diagnosis (still accurate for plugins)
thv skill push --key and thv ai-plugin push --key both accepted a cosign private key and produced a validly key-signed artifact — but install-time verification was keyless-only, so neither could be installed project-scoped. --key was a publish-only dead end on both surfaces.
It is worse than "installs as unsigned". Tracing the failure:
retrieveBundles does find the key-signed bundle — it is a real signature referrer — so ErrUnsigned never fires. That sentinel means no signature material at all (pkg/skills/verifier/errors.go).
- Keyless verification then fails, because a key-pair bundle carries no Fulcio certificate chain to check against the keyless trusted material.
- Classification finds no signer mismatch and no expected identity on first use, so it falls through to
ErrSignatureInvalid.
isAllowedUnsigned requires errors.Is(err, verifier.ErrUnsigned) — false here — so --allow-unsigned does not override it.
Net effect: a hard 403 with no escape hatch. This is why the resolution could not be "let it install as unsigned", and why removing the plugin flag was preferred to leaving it in place with a warning.
Reproduce
Skills — now works, and is the shape plugins should acquire:
cosign generate-key-pair
thv skill push --key cosign.key ghcr.io/<org>/<skill>:v1
thv skill install ghcr.io/<org>/<skill>:v1 --project-root . --public-key <base64 DER SPKI>
Plugins — not reproducible: thv ai-plugin push has no --key to pass.
Expected behavior
Originally either of:
- Support it. A key-signed artifact can be installed project-scoped by supplying the matching public key, the pinned key is recorded in the lock entry, and offline
sync --check re-verifies against it.
- Or reject it at push time, so users cannot publish an artifact ToolHive itself refuses to consume.
Skills took the first branch. Plugins took the second, by not offering the flag. Both are acceptable outcomes of this issue; what remains is whether plugins should move to the first for parity.
Additional context
Introduced with keyless-by-default push signing for skills (#6383, #6385, #6390). #6438 mirrored plugin push from the skills surface but deliberately diverged on this one point, omitting Key rather than reproducing the dead end.
The interim mitigation is now a permanent-until-decided contract: cmd/thv/app/ai_plugin_push.go and the plugins trust-model doc both state that plugin signing is keyless-only and why. The skills --key help text has since been updated to name the --public-key step, which the original version of this issue flagged as missing.
Discovered during review of #6438 (thanks @JAORMX).
Related: #6300 (plugin Sigstore stack, closed 2026-09-03), #6307 (keyless push signing), #5740 (RFC THV-0080).
Remaining scope (plugins)
Plugins now have no key surface at all — no
--keyat push, noPublicKeyon install options, no key branch in verify or sync:--keypush flagcmd/thv/app/skill_push.go:35cmd/thv/app/ai_plugin_push.go:28documents whyskills.InstallOptions.PublicKey(pkg/skills/options.go:51)lockfile.Provenance.PublicKey(pkg/skills/lockfile/lockfile.go:140)pkg/skills/skillsvc/verify.go:264pkg/plugins/pluginsvc/verify.go)pkg/skills/skillsvc/upgrade.go:283pkg/skills/skillsvc/sync.go:307pkg/plugins/pluginsvc/sync.go)The lock schema already carries
publicKey, so the plugins work is install options, the install API DTO, the verify/upgrade key branches, the sync re-verify branch, and finally restoring the push flag. Restoring--keyfirst would recreate exactly the dead end this issue was opened about, so it has to come last.Original diagnosis (still accurate for plugins)
thv skill push --keyandthv ai-plugin push --keyboth accepted a cosign private key and produced a validly key-signed artifact — but install-time verification was keyless-only, so neither could be installed project-scoped.--keywas a publish-only dead end on both surfaces.It is worse than "installs as unsigned". Tracing the failure:
retrieveBundlesdoes find the key-signed bundle — it is a real signature referrer — soErrUnsignednever fires. That sentinel means no signature material at all (pkg/skills/verifier/errors.go).ErrSignatureInvalid.isAllowedUnsignedrequireserrors.Is(err, verifier.ErrUnsigned)— false here — so--allow-unsigneddoes not override it.Net effect: a hard
403with no escape hatch. This is why the resolution could not be "let it install as unsigned", and why removing the plugin flag was preferred to leaving it in place with a warning.Reproduce
Skills — now works, and is the shape plugins should acquire:
Plugins — not reproducible:
thv ai-plugin pushhas no--keyto pass.Expected behavior
Originally either of:
sync --checkre-verifies against it.Skills took the first branch. Plugins took the second, by not offering the flag. Both are acceptable outcomes of this issue; what remains is whether plugins should move to the first for parity.
Additional context
Introduced with keyless-by-default push signing for skills (#6383, #6385, #6390). #6438 mirrored plugin push from the skills surface but deliberately diverged on this one point, omitting
Keyrather than reproducing the dead end.The interim mitigation is now a permanent-until-decided contract:
cmd/thv/app/ai_plugin_push.goand the plugins trust-model doc both state that plugin signing is keyless-only and why. The skills--keyhelp text has since been updated to name the--public-keystep, which the original version of this issue flagged as missing.Discovered during review of #6438 (thanks @JAORMX).
Related: #6300 (plugin Sigstore stack, closed 2026-09-03), #6307 (keyless push signing), #5740 (RFC THV-0080).