Skip to content

Block plugin upgrades that change the signer identity - #6401

Open
samuv wants to merge 1 commit into
plugins-sig/08-sync-reverifyfrom
plugins-sig/09-upgrade-signer-guard
Open

Block plugin upgrades that change the signer identity#6401
samuv wants to merge 1 commit into
plugins-sig/08-sync-reverifyfrom
plugins-sig/09-upgrade-signer-guard

Conversation

@samuv

@samuv samuv commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

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.go carried an explicit "signer-change guarding is intentionally omitted" note, and the --allow-signer-change that thv ai-plugin upgrade's blocked-status message already promises did not exist. This PR closes that gap.

  • Guard during planning. When a lock entry records a signer identity, upgrade planning probes the candidate's signature — chain-of-trust verification only, no install, nil expected identity — and blocks with signer-change-blocked when 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: --preview reports them, --fail-on-changes counts them as changes, and a plain upgrade exits with the policy-rejection code (4). Entries with no recorded provenance — including those marked unsigned: true — are unaffected and skip the probe entirely.
  • The override re-records deliberately. With --allow-signer-change the 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.
  • Flag wired end to end. --allow-signer-change on the CLI, allow_signer_change on 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; and verify.go gained 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

  • New feature

Test plan

  • Unit tests (task test)
  • Linting (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-changes report the same block without touching the lock. Plus DTO round-trips at the API and client boundaries, and the exit-code table for pluginUpgradeExitError.

Each guard assertion was mutation-checked, and re-checked after the rebase: disabling the guard fails all six blocking tests, dropping the AllowSignerChange clause in verify.go fails 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-fix crashes inside staticcheck's nilness analyzer while analyzing the sentry dependency, and TestMCPGoClientInitializeAndPing fails because a running thv process holds the hardcoded port 8096 that test binds. Linting this PR's packages with --disable=staticcheck reports 0 issues.

Changes

File Change
pkg/plugins/pluginsvc/upgrade.go Signer-change guard during planning; resolvedLatest carries git signature material (size-bounded before verification); AllowSignerChange threaded into the install options
pkg/plugins/pluginsvc/verify.go OCI/git install verification honors AllowSignerChange; mismatch errors now point at the override
pkg/plugins/options.go Internal-only InstallOptions.AllowSignerChange
cmd/thv/app/ai_plugin_upgrade.go --allow-signer-change flag; signer-blocked outcomes exit with the policy-rejection code
pkg/api/v1/plugins*.go, pkg/plugins/client/* allow_signer_change DTO field on both sides of the HTTP boundary

Does this introduce a user-facing change?

Yes. thv ai-plugin upgrade now 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

  • Where the guard sits. It runs after the ref-change check, so local-store upgrade candidates (which return earlier with layer data) never reach it — they carry no signature to probe, and verifyLocalInstall already refuses a local build outright for an entry locked to a signer, which is the stronger check.
  • Divergence from skillsvc. probeCandidateSigner verifies 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.
  • After the rebase, the fixture mock simulates the verifier's lock-pin enforcement by comparing the whole ProvenanceExpectation rather 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 in verify_test.go.
  • Follow-ups in PR10: push signing, trust display on ai-plugin info, and gate removal.

Generated with Claude Code

@github-actions github-actions Bot added the size/L Large PR: 600-999 lines changed label Aug 20, 2026
@samuv samuv self-assigned this Aug 20, 2026
@codecov

codecov Bot commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.33333% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 77.91%. Comparing base (f86c395) to head (63a12fe).

Files with missing lines Patch % Lines
pkg/plugins/pluginsvc/upgrade.go 89.18% 4 Missing ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@samuv
samuv force-pushed the plugins-sig/09-upgrade-signer-guard branch from 9893112 to 6982d5d Compare August 25, 2026 12:59
@github-actions github-actions Bot added size/L Large PR: 600-999 lines changed and removed size/L Large PR: 600-999 lines changed labels Aug 25, 2026
@samuv
samuv force-pushed the plugins-sig/09-upgrade-signer-guard branch from 6982d5d to 46c6f2a Compare August 25, 2026 13:11
@github-actions github-actions Bot added size/L Large PR: 600-999 lines changed and removed size/L Large PR: 600-999 lines changed labels Aug 25, 2026
@samuv
samuv force-pushed the plugins-sig/09-upgrade-signer-guard branch from 46c6f2a to f02beae Compare August 25, 2026 13:34
@github-actions github-actions Bot added size/L Large PR: 600-999 lines changed and removed size/L Large PR: 600-999 lines changed labels Aug 25, 2026
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
samuv force-pushed the plugins-sig/09-upgrade-signer-guard branch from f02beae to 63a12fe Compare August 25, 2026 13:52
@github-actions github-actions Bot added size/L Large PR: 600-999 lines changed and removed size/L Large PR: 600-999 lines changed labels Aug 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/L Large PR: 600-999 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant