Honour a pinned cosign key on plugin sync and upgrade - #6525
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #6525 +/- ##
==========================================
+ Coverage 78.69% 78.72% +0.03%
==========================================
Files 777 777
Lines 76797 77213 +416
==========================================
+ Hits 60432 60785 +353
- Misses 16360 16423 +63
Partials 5 5 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
7f9189f to
fca0e69
Compare
fca0e69 to
abda3b6
Compare
JAORMX
left a comment
There was a problem hiding this comment.
Reviewed fca0e69 against stacked base 9ceccbb. Two blockers:
-
pkg/plugins/pluginsvc/upgrade.go:320-322classifies bothErrKeylessSignedandErrUnsignedasSignerChangeBlocked, whose remediation is--allow-signer-change. That override cannot authorize an unsigned candidate: it bypasses the guard but applies the upgrade withoutAllowUnsigned, so verification still failsunsigned-rejected. Do not describe unsigned as remediable by that flag; report the actual unsigned rejection unless an explicit unsigned-consent path is added. -
cmd/thv/app/ai_plugin_upgrade.go:169-174renders every signer-change outcome with noNewSignerIdentityasunsigned. A keyless-signed candidate therefore gets reported as unsigned, althoughguardKeyedSignerChangeintentionally treats it as a key-to-keyless signer transition. Preserve that distinction in the rendered result and cover it with a CLI-output assertion.
The key-aware offline-sync and keyed candidate verification itself looks sound. CI is currently green and is reported separately from this review decision.
|
Both blockers confirmed against the code and fixed in 8875c6a. Thanks — the unsigned arm was a genuine dead end. 1. Unsigned candidate no longer advertised as remediableTraced it: with
2. Keyless candidate now carries its identityFixed at the source rather than in the renderer. Falling out of that: a candidate whose keyless signature does not verify is now
Two related sites I did not touchBoth pre-date this PR; flagging rather than folding in, since each is a behaviour change on a path this PR isn't about. Happy to take them as a follow-up covering both halves:
|
Signed-off-by: Samuele Verzi <samu@stacklok.com>
A key-pinned entry whose candidate lost its signature was reported as a signer change, whose remedy is --allow-signer-change. That override bypasses the guard but not verification, and upgrade has no unsigned-consent flag to pair with it, so the install failed unsigned-rejected one step later. Report the rejection directly and name the route that works. A candidate that moved to keyless signing is still a signer change, but it now carries the identity it moved to: the CLI infers "unsigned" from an absent NewSignerIdentity, so an unnamed one was rendered as the one thing it is not. Signed-off-by: Samuele Verzi <samu@stacklok.com>
8875c6a to
6f288f8
Compare
JAORMX
left a comment
There was a problem hiding this comment.
Fresh review at 8875c6a against stacked base 1e80f9d.
The two prior blockers are resolved: unsigned keyed candidates now fail as unsigned-rejected rather than advertising --allow-signer-change, and a keyless candidate is probed so its identity is available to the renderer.
Blocking — mixed key-pair and keyless signatures receive a remediation that cannot work
VerifyOCIWithKey returns ErrKeylessSigned only when every discovered bundle has a certificate (pkg/skills/verifier/oci.go:75-82, 222-236). For a realistic migration artifact carrying both a valid keyless signature and a stale/different-key signature, it instead returns ErrSignatureInvalid. guardKeyedSignerChange therefore takes the default failure arm (pkg/plugins/pluginsvc/upgrade.go:320-341) and tells the user to reinstall with --public-key.
That result is wrong: --allow-signer-change drops the pinned key and the later normal keyless verification accepts the valid keyless bundle. This is a supported key-to-keyless transition, but the mixed-bundle case is misclassified and its actionable remedy is withheld. After a failed keyed verification, probe the keyless path before assigning the different-key/damaged-signature failure; when that probe succeeds, return SignerChangeBlocked with the observed identity. Add a mixed key-pair plus valid keyless bundle regression case.
CI is green at this head and is separate from the review decision.
JAORMX
left a comment
There was a problem hiding this comment.
Review of exact head 6f288f8 against stacked base #6524 (9ceccbb). Prior blockers are still present; this pass also found two additional correctness defects.
-
Mixed valid keyless + stale/different-key signature incorrectly fails instead of exposing the supported key-to-keyless transition.
VerifyOCIWithKeyreturnsErrKeylessSignedonly when every bundle is keyless; a mixed artifact returnsErrSignatureInvalid.guardKeyedSignerChangethen takes the default failure arm and tells the user to reinstall with--public-key, even though--allow-signer-changedrops the pin and the ordinary keyless verifier accepts the valid keyless bundle. On a keyed-verification failure, probe the keyless path before classifying a permanent different-key/damaged-signature error; if it verifies, returnSignerChangeBlockedwith the observed identity. Add the mixed-bundle regression. -
--allow-signer-changebreaks a same-key upgrade.planUpgradeskipsguardSignerChangewhenever the flag is set (pkg/plugins/pluginsvc/upgrade.go:210-214), thenapplyUpgradeforwards the flag toinstallAlreadyLocked(:443-455).resolveKeyAnchorconsequently drops the pinned public key (pkg/plugins/pluginsvc/verify.go:199-215), so a candidate still signed by that very key goes through keyless verification and failsErrKeySigned. This is reachable when a multi-plugin upgrade needs the override for another plugin, and even an individual same-key upgrade regresses when the flag is supplied. Preserve verification against the recorded key unless keyed verification establishes a keyless transition that the override authorizes; add an upgrade regression that asserts same-key success and retainedProvenance.PublicKeywith the flag. -
The default keyed-verification arm classifies operational verifier errors (registry/transport/context failures) as
signature-invalidand advises a destructive uninstall/reinstall with a key. Only errors matchingErrSignatureInvalidshould receive that classification/remediation; propagate/classify other failures as operational failures.
The pinned-key offline sync and adoption refusal paths look sound. CI is currently in progress and is reported separately; it is not part of this review decision.
VerifyOCIWithKey reports ErrKeylessSigned only when every attached bundle is keyless, so an artifact mid-migration — a valid keyless bundle beside a stale key-pair one — arrived as ErrSignatureInvalid and was sent to uninstall-and-reinstall. It is the supported key-to-keyless transition, so verify the candidate keylessly before naming a diagnosis. Restrict the signature-invalid verdict to ErrSignatureInvalid. Registry, transport, and context failures say nothing about a signature, and advising an uninstall over a network blip is destructive. Narrow --allow-signer-change per entry. It is project-wide, so needing it for one plugin dropped the pin on every key-pinned plugin beside it, and resolveKeyAnchor then sent a still-key-signed candidate through keyless verification, which fails ErrKeySigned. An entry whose candidate still verifies against its pin keeps it. Signed-off-by: Samuele Verzi <samu@stacklok.com>
|
All three verified against the code and fixed in 1. Mixed keyless + stale-key artifactConfirmed: 2.
|
JAORMX
left a comment
There was a problem hiding this comment.
Fresh review of exact head aed8c3a against stacked base #6524 (9ceccbb).
All three prior blockers are resolved:
- A failed pinned-key verification now probes the keyless path, so a valid mixed keyless plus stale-key artifact is reported as an overridable signer transition (pkg/plugins/pluginsvc/upgrade.go:384-413).
- --allow-signer-change is narrowed per entry: candidates that still verify against their pinned key retain it and use keyed verification (pkg/plugins/pluginsvc/upgrade.go:217-225, 357-381).
- Only ErrSignatureInvalid gets signature-invalid/reinstall remediation; operational verifier errors remain unknown failures without destructive advice (pkg/plugins/pluginsvc/upgrade.go:427-441).
No blocking or non-blocking findings in the current range. Exact-head CI is green; that status is separate from this approval.
JAORMX
left a comment
There was a problem hiding this comment.
Fresh review of exact head aed8c3a against stacked base #6524 as merged (1e80f9d). The prior three blockers are resolved, but two merge blockers remain.
-
pkg/plugins/pluginsvc/upgrade.go:217-225,373-381makescandidateMatchesPinnedKeyreturn false for every keyed-verifier error. With project-wide--allow-signer-change, a registry/transport/context failure is therefore treated as proof that this entry moved off its pinned key;applyUpgradereceivesAllowSignerChange: true(:509-520), drops the key, and may keylessly verify/rewrite the provenance. A candidate carrying both the still-valid pinned-key signature and a valid keyless signature can consequently be re-anchored to keyless merely because the keyed planning probe had a transient failure. Return/propagate an operational error distinctly; only permit the per-entry anchor drop after a conclusive mismatch plus successful keyless verification. Add the regression asserting no install or lock rewrite for an operational keyed-verifier error. -
Several failure messages offer an incomplete reinstall command:
upgrade.go:347-351,430-433andsync.go:578-582.thv ai-plugin install --public-keyand--allow-unsignedrequire[plugin-name]; more importantly, these recoveries must be project-scoped to create/replace the project lock entry, whereas install defaults to user scope and a user-scope--public-keyis rejected. Provide a complete project reinstall sequence (plugin reference/name plus--scope project, and applicable project-root guidance) so the advertised remediation works.
CI is green at this head (including unit, lint, docs, codegen, security, lifecycle, operator, and E2E matrices) and is reported separately from this review decision.
A failed keyed verification was treated as evidence the candidate had moved off its pinned key. Under a project-wide --allow-signer-change that is enough to drop the pin, so a registry, transport, or context fault let an artifact still carrying a valid signature by the pinned key be re-anchored to keyless. Only the verifier's own signature verdicts settle the question, so an operational failure now stops the upgrade before the keyless probe runs, in both modes. Measure the candidate once and share the verdict between the guard and the override, so the two cannot disagree about what it is. Name a reinstall a caller can run. install requires the plugin argument and defaults to user scope, where --public-key is rejected outright and --allow-unsigned records nothing, so the bare flag was refused before it verified anything. Signed-off-by: Samuele Verzi <samu@stacklok.com>
JAORMX
left a comment
There was a problem hiding this comment.
Fresh review of exact head 5b1fc32 against the PR-owned fixup from aed8c3a (the base is #6524 as merged; unrelated main commits are not attributed to this PR).
The prior merge blockers are resolved:
- Keyed candidates are measured against their recorded key even with --allow-signer-change. Operational verifier errors now produce a fail-closed keyedUndecided outcome, so they cannot drop the anchor or rewrite lock provenance (pkg/plugins/pluginsvc/upgrade.go:258-267, 415-453). The regression asserts a deadline failure preserves both pin and digest (upgrade_verify_test.go:931-980).
- Key-signed-adoption remediation now gives a complete project-scoped install command with the plugin name and project-root guidance (pkg/plugins/pluginsvc/sync.go:578-582).
No blocking or non-blocking findings in the current range. Panel review included an independent security check of the anchor-downgrade path with no findings.
Exact-head CI: all 45 checks are successful. CI status is reported separately from this approval.
JAORMX
left a comment
There was a problem hiding this comment.
Fresh review of exact head 5b1fc32 against stacked base #6524 as merged (2533388). The earlier trust-transition blockers remain resolved, but three actionable recovery paths are still incorrect:
-
pkg/plugins/pluginsvc/upgrade.go:523-525generates project-scoped uninstall/install commands without--project-root, whilevalidateProjectRootForScopeunconditionally requires it for--scope project(cmd/thv/app/skill_helpers.go:67-73). The stated conditional is false; the command is rejected even in the project directory. Include--project-root <path>in both commands. -
pkg/plugins/pluginsvc/upgrade.go:507andpkg/plugins/pluginsvc/sync.go:581-582advertise--public-key <path-or-base64>, but the CLI always treats this option as a file path and passes it toos.ReadFile(cmd/thv/app/ai_plugin_install.go:67-72). A base64 value cannot repair the install. Advertise a public-key file path only. -
pkg/plugins/pluginsvc/sync.go:581-585tells a rejected adoption to reinstallpl.Metadata.Name, despitepl.Referencebeing the actual existing source. For a direct OCI reference, the bare name can resolve elsewhere or not at all. Use the original reference and include the mandatory project root.
Please add regression coverage for complete recovery commands, not only their flag substrings. CI is green at this head; that status is separate from this review decision.
Summary
PR 1 of this stack lets a plugin be installed project-scoped against a cosign public key, pinned in
lockfile.Provenance.PublicKey. Butpluginsvc's lock-driven operations were still keyless-only, so that pin was unusable in practice the moment you ran anything other thaninstall:syncnever settled.verifyStoredSignaturehanded the entry toVerifyBundleOffline, which cannot check a key-pair bundle. Sync reads a verification refusal as drift it can heal by reinstalling — so a key-pinned plugin was reported modified on every run, andsync --checkfailed permanently on a project that was in fact intact.upgradecould not run at all.guardSignerChange→probeCandidateSigner→VerifyOCI(..., nil)returnsErrKeySignedfor a key-signed candidate, which fell into the generic failure arm — so the entry was reported as a broken signature where a policy decision was intended.sync --adoptrefused opaquely, with a generic "verifying stored bundle for adoption" wrapper aroundErrKeySigned.This transliterates the skills fix (#6478) onto
pkg/plugins:syncre-verifies against the pinned key.verifyStoredKeySignaturedecodesentry.Provenance.PublicKeyand callsVerifyBundleOfflineWithKey. The signature is checked against the lock entry's digest, and the simple-signing payload is recovered from the stored bundle rather than rebuilt from a reference — a payload reconstructed from a reference verifies against whatever that reference claims, which is exactly the check a signature lifted from another artifact passes. The fail-closed missing-bundle branch is unchanged in behaviour but now reports the anchor vialockedAnchorDescription, so a key pin no longer printssigner "". The plugins-specificerrLockTrustUnrecordeddrift handling is untouched.ErrKeySigned(soclassifySyncFailurereportsFailureReasonKeySigned) namingthv ai-plugin install --public-key, and stating that--allow-unsignedis not a substitute.upgradeapplies the pinned key to the candidate.guardKeyedSignerChangeverifies against it, which is the evidence the signer has not changed — there is no certificate identity to compare. The arms are split by whether the remedy the CLI prints actually works. Only a candidate that moved to keyless signing isSignerChangeBlocked—--allow-signer-changegenuinely resolves that by dropping the key and taking the keyless path — and it carries the identity it moved to, because the renderer infers "unsigned" from an absentNewSignerIdentity. A candidate that lost its signature isFailed/FailureReasonUnsignedRejected: the override bypasses this guard but not verification, and upgrade has no unsigned-consent flag to pair with it, so it would dead-end inunsigned-rejectedone step later. A candidate signed by a different key isFailed/FailureReasonSignatureInvalid, because re-anchoring in place is not offered. Both failures name uninstall-and-reinstall with the flag that applies.Scope stays install-only for
--public-key, exactly as skills v1:syncandupgradetake no key of their own, and no in-place re-anchor.Part of #6442
Type of change
Test plan
task test)task test-e2e)task lint-fix)Unit tests only, mirroring what skills shipped (there is no E2E for the key path on either surface). Every new test was confirmed to fail with the two non-test files reverted, so each one pins behaviour this PR adds rather than behaviour that already worked — the one deliberate exception is the
--allow-signer-changetest, which pins a PR 1 invariant this PR must not break.Pinned explicitly: a key-pinned entry with a stored bundle verifies via
VerifyBundleOfflineWithKeyandVerifyBundleOfflineis asserted never called (reaching the keyless path is the bug, and it fails closed in a way that looks like drift);sync --checkand an apply both report an intact key-pinned project asAlreadyCurrent; a missing bundle names "a cosign public key" and notsigner ""; an undecodable pinned key fails closed rather than verifying nothing; adopting a key-signed install is a 403 wrappingErrKeySignedclassifiedFailureReasonKeySigned, writes no lock entry, and is unchanged by--allow-unsigned; the four keyed upgrade transitions (same key proceeds, keyless blocks, unsigned blocks, different key fails with the re-anchor remedy) with the keyless probe asserted never called and no inventedNewSignerIdentityon the blocked arms; an undecodable pinned key fails the plan rather than reporting a signer change.Two end-to-end upgrade tests run against a real OCI store with a mocked registry client, driving install → publish → upgrade: one proves the candidate guard and the install
applyUpgradeperforms both verify against the pinned key (exactly twoVerifyOCIWithKeycalls,VerifyOCInever), rather than assuming that fromresolveKeyAnchor; the other proves--allow-signer-changedrops the key and re-anchors to the observed identity.Changes
pkg/plugins/pluginsvc/sync.goverifyStoredSignature, newverifyStoredKeySignature, anchor-aware missing-bundle message,ErrKeySignedrefusal inadoptionTrustpkg/plugins/pluginsvc/upgrade.goguardSignerChange, newguardKeyedSignerChangeandblockKeyToKeylessChangepkg/plugins/pluginsvc/sync_verify_test.go,upgrade_verify_test.gocmd/thv/app/ai_plugin_upgrade_test.goDoes this introduce a user-facing change?
Yes. A plugin installed with
thv ai-plugin install --public-keynow behaves like any other pinned plugin:thv ai-plugin syncreports it as current instead of as drift on every run,sync --checkpasses on an intact project, andthv ai-plugin upgradeevaluates candidates against the pinned key and reports a signer change or a failure with the remedy that actually applies.thv ai-plugin sync --adopton a key-signed install now explains that it must be installed with--public-key, instead of failing with a generic stored-bundle error. Nothing changes for keyless or unsigned plugins.Special notes for reviewers
--keypush signing has no install-time verification path (plugins) #6442. PR 3 restores--keyonthv ai-plugin pushand is the one that closes it — restoring the publish flag any earlier would recreate exactly the publish-only dead end the issue was opened about.docs/arch/14-plugins-system.mdstill documents plugin signing as keyless-only for the same reason; the trust-model rewrite belongs with PR 3, so the doc flips when the feature is complete rather than describing a half-built path.main:describeLockAnchortoskillsvc/sync.go, which is byte-identical tolockedAnchorDescriptioninskillsvc/verify.go— two names for the same function in the same package. PR 1 already gavepluginsvclockedAnchorDescription, so this reuses it rather than landing the duplicate.VerifyBundleOfflineWithKeyhas since lost theimageRefparameter Honour a pinned cosign key on sync and upgrade #6478 passed it: the payload is recovered from the stored bundle instead of reconstructed from a reference, which is the signature-transplant fix.verifyStoredKeySignaturetherefore takes no reference, and the "entry pins no reference" arm Honour a pinned cosign key on sync and upgrade #6478's diff shows no longer exists.guardKeyedSignerChange. The lock schema refusespublicKeyon a git entry (lockfile/validation.go— a commit signature is verified against a certificate, never a key), so a key-pinned entry is always OCI andlatest.commitPayloadis always empty there. The comment states the assumption at the call site.guardKeyedSignerChangewere wrong and are fixed: an unsigned candidate was reported as a signer change, whose remedy--allow-signer-changecannot deliver (it drops the key, re-verifies keyless, and failsunsigned-rejectedbecauseUpgradeOptionshas no unsigned-consent field and upgrade has no--allow-unsigned); and a keyless candidate arrived with noNewSignerIdentity, which the CLI renders as "unsigned". The renderer is unchanged — the invariant it relies on is now upheld at the source, andTestPrintPluginUpgradeResultSignerRenderingpins it from the CLI side.upgrade.go:262-264(the keyless guard's unsigned arm) has the identical dead end, andskillsvc/upgrade.go:287-288is byte-equivalent to the pre-fix keyed code, so the two halves now diverge. Fixing either changes behaviour on a path this PR is not about; happy to take both in a follow-up.task docswas run and produced no changes, as expected — there is no CLI or API surface change in this PR.Generated with Claude Code