Skip to content

Store plugin sigstore bundles, carry git signature - #6396

Merged
samuv merged 1 commit into
mainfrom
plugins-sig/06-bundle
Aug 25, 2026
Merged

Store plugin sigstore bundles, carry git signature#6396
samuv merged 1 commit into
mainfrom
plugins-sig/06-bundle

Conversation

@samuv

@samuv samuv commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

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:

  • OCI plugin installs have nowhere to persist the Sigstore bundle captured at install time, so sync cannot re-verify a plugin offline without going back to the registry.
  • Git plugin installs read the resolved commit but throw away its gitsign signature and the payload that signature covers, so a verifier would have nothing to check.

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:

  • Migration 006 adds 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 as 004_add_skill_sigstore_bundle.sql.
  • plugins.InstalledPlugin gains SigstoreBundle []byte (json:"-", never serialized to API responses), wired through the SQLite plugin store's insert, update, and scan paths.
  • cloneAndCollectPlugin returns the whole git.HeadCommit instead 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.HeadCommit already 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 installFromGit takes the per-plugin lock.

Part of #6300.

Type of change

  • Other (describe): groundwork — schema + type plumbing for a follow-up feature, no behavior change

Test plan

  • Unit tests (task test)
  • Linting (task lint-fix)

New coverage:

  • TestMigrations_PluginSigstoreBundleAppliesOverPriorState — rolls back to migration 005, inserts an installed_plugins row, 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_Create now asserts an unset bundle defaults to nil.
  • TestCloneAndCollectPlugin gains 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 the gpgsig header it covers.

Notes on the local runs:

  • task test is green except pkg/transport/proxy/streamable's TestMCPGoClientInitializeAndPing, which fails identically on a clean checkout of main on this machine (hardcoded port 8096 already in use). Unrelated to this PR.
  • task lint-fix over the whole repo hits the known nilness analyzer panic on getsentry/sentry-go from 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

  • This PR does not break the v1beta1 API, OR the api-break-allowed label 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 SigstoreBundle is tagged json:"-" so it never appears in API responses.

Special notes for reviewers

  • cloneAndCollectPlugin returning git.HeadCommit rather than two extra return slots keeps the signature at four values and guarantees hash/signature/payload always describe the same commit.
  • head is bound early in installFromGit but only head.Hash is read today. That is deliberate: PR7 inserts the verify call under the per-plugin lock, where head is already in scope.
  • Migration 006 is a plain ADD COLUMN with a DROP COLUMN Down, so it is reversible without a table rebuild.

Generated with Claude Code

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>
@samuv
samuv requested a review from JAORMX as a code owner August 20, 2026 12:38
@github-actions github-actions Bot added the size/S Small PR: 100-299 lines changed label Aug 20, 2026
@codecov

codecov Bot commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.18519% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 77.70%. Comparing base (d077c74) to head (662d3a5).

Files with missing lines Patch % Lines
pkg/plugins/pluginsvc/install_git.go 50.00% 4 Missing ⚠️
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.
📢 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 self-assigned this Aug 20, 2026

@jhrozek jhrozek left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread pkg/plugins/types.go
Comment thread pkg/storage/sqlite/plugin_store.go
@samuv
samuv merged commit 7f9a098 into main Aug 25, 2026
54 checks passed
@samuv
samuv deleted the plugins-sig/06-bundle branch August 25, 2026 12:59
samuv added a commit that referenced this pull request Aug 25, 2026
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 added a commit that referenced this pull request Aug 25, 2026
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 added a commit that referenced this pull request Aug 26, 2026
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 added a commit that referenced this pull request Aug 26, 2026
* 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>
@github-actions github-actions Bot mentioned this pull request Aug 26, 2026
2 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/S Small PR: 100-299 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants