Store plugin sigstore bundles, carry git signature - #6396
Conversation
Plugin install-time verification and offline re-verification in sync --check need groundwork that has to land before the verifier does: OCI installs must be able to persist the Sigstore bundle captured at install time so sync can re-check it without contacting the registry, and git installs must surface the resolved commit's gitsign signature and the payload it covers so the verifier has something to check. Adds migration 006 with an installed_plugins.sigstore_bundle BLOB column (NULL for unsigned installs, so rows created by earlier schemas read back as unsigned) and wires it through the SQLite plugin store and InstalledPlugin. cloneAndCollectPlugin now returns the whole git.HeadCommit instead of just the hash, so the signature and payload travel with the commit they describe rather than being looked up again. Nothing verifies yet — the values are carried, not consumed. Mirrors what #6084 did for skills. Part of #6300. Signed-off-by: Samuele Verzi <samu@stacklok.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #6396 +/- ##
==========================================
+ Coverage 77.68% 77.70% +0.01%
==========================================
Files 750 750
Lines 72576 72580 +4
==========================================
+ Hits 56381 56396 +15
+ Misses 16190 16179 -11
Partials 5 5 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
jhrozek
left a comment
There was a problem hiding this comment.
Approving. The staging is right: this matches what #6084 did for skills, migration 006 is correctly sequenced, and every plugin SELECT still goes through the shared pluginColumns const so the 18 -> 19 field change stays consistent.
Two things I'd like to see handled in the PR that actually populates the column, not here. Left them inline so they don't get lost.
One smaller note, no comment needed: buildInstalledPlugin doesn't set SigstoreBundle, and Update writes the column unconditionally, so the re-install path will null it out. Harmless while nothing writes a bundle, but the follow-up needs InstallOptions.SigstoreBundle or it'll look correct and store nothing. Same for upgrade.go:318, which already calls HeadCommit and still only reads .Hash.
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>
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>
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>
* Re-verify stored plugin signatures during sync 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> * Bound stored plugin signature material 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> --------- Signed-off-by: Samuele Verzi <samu@stacklok.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
Plugin install-time signature verification (PR7) and offline re-verification in
sync --check(PR8) both need groundwork that has to land before any verifier does. Two gaps today:synccannot re-verify a plugin offline without going back to the registry.This PR closes both gaps and nothing else — no verification logic, no verifier imports, no lock-file or API changes. It is the plugin counterpart to #6084, which did the same for skills.
What changed:
installed_plugins.sigstore_bundle BLOB DEFAULT NULL. NULL means unsigned or pre-schema, so rows written by an earlier schema read back as unsigned rather than as a corrupt bundle — same convention as004_add_skill_sigstore_bundle.sql.plugins.InstalledPlugingainsSigstoreBundle []byte(json:"-", never serialized to API responses), wired through the SQLite plugin store's insert, update, and scan paths.cloneAndCollectPluginreturns the wholegit.HeadCommitinstead of just the hash, so the commit's signature and payload travel with the hash they describe instead of being looked up again against a possibly different commit.pkg/git.HeadCommitalready carries all three fields (added by Add commit-signature plumbing and sigstore bundle storage #6084); only the plugin caller needed updating.The signature and payload are carried, not consumed — PR7 hands them to the verifier at the point where
installFromGittakes the per-plugin lock.Part of #6300.
Type of change
Test plan
task test)task lint-fix)New coverage:
TestMigrations_PluginSigstoreBundleAppliesOverPriorState— rolls back to migration 005, inserts aninstalled_pluginsrow, re-applies Up, and asserts the pre-existing row reads back with a NULL bundle; then asserts 006 Down drops the column while leaving the table intact.TestPluginStore_SigstoreBundleRoundTrip— Create persists a bundle, Update replaces it, and Update can clear it back to NULL/unsigned.TestPluginStore_Createnow asserts an unset bundle defaults to nil.TestCloneAndCollectPlugingains a signed-commit subtest (HEAD rewritten with an armored signature, mimicking gitsign) asserting the signature and its payload are both surfaced and that the payload excludes thegpgsigheader it covers.Notes on the local runs:
task testis green exceptpkg/transport/proxy/streamable'sTestMCPGoClientInitializeAndPing, which fails identically on a clean checkout ofmainon this machine (hardcoded port 8096 already in use). Unrelated to this PR.task lint-fixover the whole repo hits the knownnilnessanalyzer panic ongetsentry/sentry-gofrom golangci-lint v2.13.0 — the exact panic Pin golangci-lint to avoid nilness panic on main #6393 pinned CI to v2.12.2 to avoid. Linting scoped to./pkg/storage/sqlite/... ./pkg/plugins/...reports 0 issues.API Compatibility
v1beta1API, OR theapi-break-allowedlabel is applied and the migration guidance is described above.Does this introduce a user-facing change?
No. The new column is unused until install-time verification lands, and
SigstoreBundleis taggedjson:"-"so it never appears in API responses.Special notes for reviewers
cloneAndCollectPluginreturninggit.HeadCommitrather than two extra return slots keeps the signature at four values and guarantees hash/signature/payload always describe the same commit.headis bound early ininstallFromGitbut onlyhead.Hashis read today. That is deliberate: PR7 inserts the verify call under the per-plugin lock, whereheadis already in scope.ADD COLUMNwith aDROP COLUMNDown, so it is reversible without a table rebuild.Generated with Claude Code