Re-verify stored plugin signatures during sync - #6399
Open
samuv wants to merge 2 commits into
Open
Conversation
samuv
requested review from
ChrisJBurns,
JAORMX,
amirejaz,
aponcedeleonch,
jhrozek,
rdimitrov and
reyortiz3
as code owners
August 20, 2026 13:45
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## plugins-sig/07-install-verify #6399 +/- ##
=================================================================
- Coverage 77.85% 77.81% -0.04%
=================================================================
Files 761 761
Lines 73175 73223 +48
=================================================================
+ Hits 56971 56981 +10
- Misses 16199 16237 +38
Partials 5 5 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
3 tasks
samuv
force-pushed
the
plugins-sig/08-sync-reverify
branch
from
August 25, 2026 12:59
91ff461 to
f6dac63
Compare
samuv
force-pushed
the
plugins-sig/08-sync-reverify
branch
from
August 25, 2026 13:11
f6dac63 to
32bdb3d
Compare
samuv
force-pushed
the
plugins-sig/08-sync-reverify
branch
from
August 25, 2026 13:34
32bdb3d to
b1d8e70
Compare
samuv
force-pushed
the
plugins-sig/08-sync-reverify
branch
from
August 25, 2026 13:34
b1d8e70 to
5215d7b
Compare
Plugin sync now re-verifies each managed entry's stored Sigstore bundle against the identity recorded in the lock file — entirely offline, via the embedded trust root — before an entry can count as current. A failed re-verification is drift: check mode reports it (the CI gate covers signatures like it covers content drift), and apply mode reinstalls from the pinned reference, where install-time verification enforces the locked identity and heals the stored state. An OCI entry recording a signer identity without a stored bundle fails closed; git entries store no bundle by design — their signature lives on the commit and is re-verified when content is re-resolved. Adoption back-fills provenance from the stored bundle when one exists; otherwise adopting is the same trust decision as an unsigned install and now requires the explicit --allow-unsigned exception (new flag on sync, threaded through the API and Go client DTOs), recorded as unsigned in the entry. Part of #6300. Mirrors #6131 for skills. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Samuele Verzi <samu@stacklok.com>
The Sigstore bundle persisted with an install and the commit payload and gitsign signature a git install verifies are all attacker-influenced and unbounded at their source: a registry serves whatever bundle it likes, and git imposes no length limit on a commit message. Without a ceiling a hostile plugin source can push a multi-MB blob into SQLite on every install, which every subsequent sync then reads back for offline re-verification. Cap all three at capture time in verify.go, before the bytes reach InstallOptions and the store, and reject rather than truncate — a truncated bundle would fail to verify later and be indistinguishable from tampering. The git payload and signature are checked before the verifier is consulted so a hostile repo cannot spend our CPU either. Closes the size-ceiling review thread on #6396, deferred to this PR. 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/08-sync-reverify
branch
from
August 25, 2026 13:38
5215d7b to
f86c395
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
Sync is where a lock file becomes an enforcement mechanism: it is what CI runs to assert that what is installed is what was pinned. #6397 made install-time verification record a signer identity and persist the Sigstore bundle on the install record, but nothing re-checked either afterwards — an entry whose stored bundle had been tampered with, swapped, or lost still reported as up to date, so the CI gate covered content drift but not signature drift.
Commit 1 — re-verify stored signatures during sync
--checkstays usable in a sandboxed CI runner. A failed re-verification is drift:--checkreports it and exits 2, apply mode reinstalls from the pinned reference, where Verify plugin signatures at install time #6397's install-time verification enforces the locked identity and heals the stored bundle.unsigned: truehave nothing to verify, so both skip the offline check.--adoptback-fills the entry's provenance from the stored bundle when one exists. With no bundle, adopting is the same trust decision as an unsigned install: it now requires the explicit--allow-unsignedexception and recordsunsigned: true. Without the flag it fails typed asunsigned-rejected. This tightens--adopt, which previously wrote entries with no trust state at all.--allow-unsignedtothv ai-plugin syncand threads the field through the plugins API DTO and the Go HTTP client DTO.plugins.SyncOptionsalready carried the field (it aliasesskills.SyncOptions); nothing was passing it.errors.Issentinel mapping to the typed reasons established in Stack 1 (signature-invalid,signer-mismatch,unsigned-rejected). Exit-code semantics are unchanged.Commit 2 — bound stored signature material (closes the #6396 review thread)
@jhrozek flagged on #6396 that nothing caps what gets written into the
sigstore_bundlecolumn, and that the gitPayload/Signaturethat PR started carrying are equally unbounded — commit messages have no length limit, so a hostile repo or registry can push a multi-MB blob into SQLite on every install. #6397 deferred the fix here, because this is the PR where sync starts reading those bytes back on every run.verify.go, before the bytes reachInstallOptionsand the store, at 1 MiB — orders of magnitude above a real keyless bundle or gitsign signature (a few KB: certificate chain, signature, inclusion proof).VerifyOCI/VerifyGitcall over that digest (Verify plugin signatures at install time #6397), and commit 1 here re-verifies the stored bundle against the entry's digest on every sync, so a mismatched pair now surfaces as drift instead of sitting unnoticed.Part of #6300. Mirrors the skills counterpart #6131. Stacked on #6397 — this PR targets
plugins-sig/07-install-verify.Type of change
Test plan
task test)task lint-fix)Sync re-verification tests mirror the skills suite:
--checkand is reinstalled at the pin in apply mode;verifyStoredSignaturetable: unsigned and no-provenance entries skip the check, provenance-without-bundle fails closed for OCI and passes for git, a stored bundle delegates toVerifyBundleOfflineand its failure propagates;signature-invalid, notunknown;unsigned-rejectedthen recordsunsigned: truewith--allow-unsigned;SyncOptions, client → wire) and the CLI flag's binding to the variable sync passes to the service.Size-cap tests: a table over all four rejection paths (OCI bundle, git bundle, oversized commit payload, oversized commit signature) asserting 422 and
validation-rejected, where the payload/signature cases prove the verifier is never called; plus an end-to-end case showing an oversized bundle leaves neither a lock entry nor a DB record.task docsoutput verified with./cmd/help/verify.sh(exit 0, no diffs).Two pre-existing conditions on this branch and its base, neither introduced here:
TestMCPGoClientInitializeAndPing(pkg/transport/proxy/streamable) fails locally — its server binds a hardcoded127.0.0.1:8096, which is in use on this machine.task lint-fixwas run with golangci-lint v2.12.2, the version CI pins in Pin golangci-lint to avoid nilness panic on main #6393 (v2.13.0 panics innilnessonsentry-go). It reports onegcifinding inpkg/authserver/runner/embeddedauthserver.go:249, a file this PR does not touch; the repo is otherwise clean.API Compatibility
v1beta1API, OR theapi-break-allowedlabel is applied and the migration guidance is described above.Changes
pkg/plugins/pluginsvc/sync.gosyncLockedEntry; adoption trust decision inadoptLockedpkg/plugins/pluginsvc/verify.gocmd/thv/app/ai_plugin_sync.go--allow-unsignedflagpkg/api/v1/plugins_types.go,pkg/api/v1/plugins.goallow_unsignedon the sync request DTO and handlerpkg/plugins/client/dto.go,pkg/plugins/client/client.goallow_unsignedon the client sync DTOdocs/Does this introduce a user-facing change?
Yes.
thv ai-plugin sync --checknow also fails when a plugin's stored signature no longer verifies against the identity pinned in the lock file, not just on content drift.thv ai-plugin sync --adoptnow requires--allow-unsignedfor installs with no stored Sigstore bundle, and records those entries asunsigned: true; adoptions with a stored bundle back-fill the signer identity into the lock entry instead of leaving trust state empty. An install whose signature material exceeds 1 MiB is rejected.Special notes for reviewers
entry.Digestcontains a:— the same discriminator the skills implementation uses. Git digests are bare commit hashes, OCI digests aresha256:…. Local-store pins share the OCI digest shape and store no bundle, but cannot reach the fail-closed branch (a local install is always an unsigned trust decision, so its entry returns at theUnsignedcheck);verifyStoredSignature's doc comment reasons through that case and through a hand-edited lock entry.adoptLocked, the restorable-pin check deliberately still runs before the trust decision, so adopting a bare local-store tag keeps reportingvalidation-rejectedrather than changing tounsigned-rejected.ResultFromBundlereturning a nil provenance (a key-signed bundle carries no certificate identity) falls through to the unsigned path rather than recording an empty provenance block.pkg/skills) but worth a follow-up.Generated with Claude Code