Block plugin upgrades that change the signer identity - #6401
Open
samuv wants to merge 1 commit into
Open
Conversation
samuv
requested review from
ChrisJBurns,
JAORMX,
amirejaz,
aponcedeleonch,
jhrozek,
rdimitrov and
reyortiz3
as code owners
August 20, 2026 14:15
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## plugins-sig/08-sync-reverify #6401 +/- ##
================================================================
+ Coverage 77.81% 77.91% +0.09%
================================================================
Files 761 761
Lines 73223 73263 +40
================================================================
+ Hits 56981 57085 +104
+ Misses 16237 16173 -64
Partials 5 5 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
samuv
force-pushed
the
plugins-sig/09-upgrade-signer-guard
branch
from
August 25, 2026 12:59
9893112 to
6982d5d
Compare
samuv
force-pushed
the
plugins-sig/09-upgrade-signer-guard
branch
from
August 25, 2026 13:11
6982d5d to
46c6f2a
Compare
samuv
force-pushed
the
plugins-sig/09-upgrade-signer-guard
branch
from
August 25, 2026 13:34
46c6f2a to
f02beae
Compare
An upgrade re-resolves a mutable source, which is exactly when a compromised or transferred publisher would slip a differently-signed artifact into the pinned trust chain (RFC THV-0080). Upgrade planning now probes the candidate's signer identity — chain-of-trust verification only, no install — and blocks with a signer-change-blocked status when it differs from the identity the lock file records, when a pinned certificate field (repository ref or runner class) diverges, or when the candidate is unsigned. Blocked plans carry no pinned reference, exactly like ref changes: preview reports them, fail-on-changes counts them as changes, and a plain upgrade exits with the policy-rejection code pointing at --allow-signer-change. The explicit override verifies the candidate chain-of-trust-only and re-records the newly observed identity in the lock entry, so the rotation is deliberate and diff-visible rather than silent. Entries with no recorded provenance — including those marked unsigned — are unaffected and skip the probe entirely. Git candidates carry their commit signature material out of resolution so the probe verifies the exact commit being planned without a second clone. That material is bounded the same way verifyGitInstall bounds it: the probe runs on every guarded upgrade, including the plan-only modes that install nothing, so leaving it unbounded would let a hostile repository spend our CPU without ever reaching an install. Part of #6300. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Samuele Verzi <samu@stacklok.com>
samuv
force-pushed
the
plugins-sig/09-upgrade-signer-guard
branch
from
August 25, 2026 13:52
f02beae to
63a12fe
Compare
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
An upgrade re-resolves a mutable source, which is exactly the moment a compromised or transferred publisher would slip a differently-signed artifact into a plugin's pinned trust chain (RFC THV-0080). Since PR7 the plugin lock file records a signer identity, but upgrade never consulted it —
pkg/plugins/pluginsvc/upgrade.gocarried an explicit "signer-change guarding is intentionally omitted" note, and the--allow-signer-changethatthv ai-plugin upgrade's blocked-status message already promises did not exist. This PR closes that gap.signer-change-blockedwhen the observed identity differs, when a pinned certificate field (repository ref or runner class) diverges, or when the candidate is unsigned. Blocked plans carry no pinned reference, exactly like ref-change blocks:--previewreports them,--fail-on-changescounts them as changes, and a plain upgrade exits with the policy-rejection code (4). Entries with no recorded provenance — including those markedunsigned: true— are unaffected and skip the probe entirely.--allow-signer-changethe candidate is verified chain-of-trust-only and the newly observed identity replaces the recorded one in the lock entry, so a rotation is diff-visible rather than silent.--allow-signer-changeon the CLI,allow_signer_changeon the plugins upgrade API DTO, and the matching field on the Go HTTP client DTO, each pinned by a round-trip test.This mirrors the skills counterpart #6132 as it stands today on
main, not as originally merged: the hardened shape treats a provenance-field mismatch like a signer change (see the #6315 review) and has no automatic tag-rotation allowance.Rebased onto the updated #6399. Two changes landed down-stack since this PR opened and both reach into it: the verifier now takes a
ProvenanceExpectation(#6420), so the probe and the test mocks were adapted; andverify.gogained a 1 MiB ceiling on captured signature material. The probe calls the verifier directly, so it bypassed that ceiling — it now applies the same bound, with the same rationale sharpened: the probe runs on every guarded upgrade including the plan-only modes that install nothing, so an unbounded probe would let a hostile repository spend CPU without ever reaching an install.Part of #6300. Stacked on #6399 — please review and merge that first; this PR targets
plugins-sig/08-sync-reverify.Type of change
Test plan
task test)task lint-fix)New coverage in
pkg/plugins/pluginsvc/upgrade_verify_test.go: oversized commit payload/signature rejected by the probe before the verifier is consulted, and reported as a failure with a message rather than a silent block; same-signer upgrade proceeds; a differing signer blocks; each provenance-field divergence blocks (attacker branch, tag rotation, dropped ref extension, runner-class change) without ever reaching the install call; an unsigned candidate against a locked signer blocks; an entry with no recorded identity skips the probe (asserted by verifier call count); the override re-records both the new identity and the new ref/runner; the blocked plan carries no pinned reference; preview and--fail-on-changesreport the same block without touching the lock. Plus DTO round-trips at the API and client boundaries, and the exit-code table forpluginUpgradeExitError.Each guard assertion was mutation-checked, and re-checked after the rebase: disabling the guard fails all six blocking tests, dropping the
AllowSignerChangeclause inverify.gofails both override tests, and removing the probe's size bound fails the two bound tests.Two pre-existing local failures, both reproduced on the untouched base branch and unrelated to this diff:
task lint-fixcrashes inside staticcheck'snilnessanalyzer while analyzing thesentrydependency, andTestMCPGoClientInitializeAndPingfails because a runningthvprocess holds the hardcoded port 8096 that test binds. Linting this PR's packages with--disable=staticcheckreports 0 issues.Changes
pkg/plugins/pluginsvc/upgrade.goresolvedLatestcarries git signature material (size-bounded before verification);AllowSignerChangethreaded into the install optionspkg/plugins/pluginsvc/verify.goAllowSignerChange; mismatch errors now point at the overridepkg/plugins/options.goInstallOptions.AllowSignerChangecmd/thv/app/ai_plugin_upgrade.go--allow-signer-changeflag; signer-blocked outcomes exit with the policy-rejection codepkg/api/v1/plugins*.go,pkg/plugins/client/*allow_signer_changeDTO field on both sides of the HTTP boundaryDoes this introduce a user-facing change?
Yes.
thv ai-plugin upgradenow refuses to upgrade a plugin whose lock entry records a signer identity when the candidate artifact is signed by a different identity, carries a different repository ref or runner class, or is unsigned. Such upgrades exit 4 and name--allow-signer-change, which permits the upgrade and records the new identity in the lock file. Plugins with no recorded provenance are unaffected.Special notes for reviewers
verifyLocalInstallalready refuses a local build outright for an entry locked to a signer, which is the stronger check.probeCandidateSignerverifies the commit payload/signature captured during resolution instead of re-resolving the git reference the way skills does. That avoids a second clone per guarded entry and removes the window in which the probed commit could drift from the one being planned.ProvenanceExpectationrather than reading a field off it — the type's fields are unexported, and this matches the comparison style Re-verify stored plugin signatures during sync #6399 adopted inverify_test.go.ai-plugin info, and gate removal.Generated with Claude Code