Sign plugin pushes and remove the lock feature gate - #6438
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #6438 +/- ##
==========================================
- Coverage 78.11% 78.10% -0.01%
==========================================
Files 767 767
Lines 74395 74513 +118
==========================================
+ Hits 58110 58202 +92
- Misses 16280 16306 +26
Partials 5 5 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
JAORMX
left a comment
There was a problem hiding this comment.
Two merge-blocking gaps remain in the completed plugin trust flow: key-signed pushes have no corresponding consume-time trust path, and gate removal can promote a pre-gate same-digest install to managed/signed without rematerializing or persisting its bundle. I also flagged the silent loss of trust state when info cannot read the lock. CI is green, but these trust-model issues need resolution before merge.
JAORMX
left a comment
There was a problem hiding this comment.
Re-reviewed commits after 542b46a. The key-signing dead end was removed, same-digest pre-gate installs now rematerialize and persist their bundle, and lock trust-read errors are propagated. No new blocking findings. CI is green.
e2f2055 to
41b5e95
Compare
Round 3
The migration finding was a real miss on my part, not a partial fix. Classifying unrecorded entries as drift made sync reinstall them, and lock-driven installs were granted an implicit unsigned exception — so the repair rewrote "no trust decision" into Fixing it surfaced something the finding did not cover: On the info derivation, Still openThe artifact-digest binding thread is unresolved and cannot be fixed here — it is entirely in toolhive-core. The trace, the two aggravating factors (the correctly-bound referrers path is only a fallback, and Verification
SizeUnchanged in kind but larger again — this round adds ~4 code files and ~130 lines. The split I proposed last round still applies, and the drift-migration commits now form an even cleaner seam than they did before, since they are self-contained and carry their own tests. Happy to split before you spend another full pass on it. |
| // a missing one, and keeping it would make an explicit reinstall — the | ||
| // user's remedy for a failing `sync --check` — verify a good bundle and | ||
| // then discard it, so the next offline sync fails identically. | ||
| mustPersistTrust := storeErr == nil && len(opts.SigstoreBundle) > 0 && |
There was a problem hiding this comment.
This still lets a same-digest Git reinstall promote a pre-verification project install to a trusted, managed lock entry without rematerializing it. mustPersistTrust only fires for OCI because Git verification has no bundle. If the legacy on-disk tree was modified, the fresh git signature authenticates the commit while the no-op path leaves the modified files active; only a later sync notices the contentDigest mismatch. Treat newly established provenance on an unmanaged existing record as requiring rematerialization even when SigstoreBundle is empty, and cover a drifted same-commit Git migration. (CWE-345)
There was a problem hiding this comment.
Confirmed and fixed in c4cba193d. You are right, and the reason it was reachable is worse than "Git verification has no bundle" suggests — it could never have fired for git, because VerifyGit returns resultFromCore(observed, nil). There is no bundle to differ from the stored one, so mustPersistTrust is structurally unreachable on that path rather than merely usually false.
Tracing the rest of it: lockContentDigest hashes opts.LayerData, never the disk. So the entry that gets written asserts the pristine content digest and the freshly verified signer, while dispatchExtraction returned existing verbatim and left the modified tree active. The install reports success and prints the identity. Exactly as you say, only a later sync --check catches it.
I keyed the fix on the managed transition rather than on newly established provenance. Your phrasing would have fixed the case you found and left one beside it: an --allow-unsigned same-digest reinstall of the same legacy record records a contentDigest with no provenance at all and has the identical gap — the entry claims content nobody rematerialized. The invariant that actually holds is narrower to state and wider in coverage:
a lock entry only ever describes content the install that wrote it materialized.
So the condition is storeErr == nil && !existing.Managed && scope == plugins.ScopeProject, and both reasons route to installExtractionUpgradeDigest, which already rematerializes for every client and persists. I checked it does not over-trigger: an already-managed record is unaffected, user scope is excluded, and a fresh install has no record — so it fires only on unmanaged→managed, which is one extra materialization the first time a legacy record gains an entry, and never again.
TestInstallVerification_SameCommitGitMigrationRematerializes covers the drifted same-commit git migration you asked for, and asserts three things: the tampered file is restored to fixture content, the entry records the verified signer, and a following sync --check reports AlreadyCurrent rather than drift — i.e. the entry is self-consistent when written, not only after a repair pass. I verified it fails without the fix, on both the file content and the drift assertion.
One trap worth flagging, because I nearly shipped the test green and useless. alwaysSignedVerifier returns signedResult(), which carries Bundle: []byte(...). Building the test on it would have made mustPersistTrust fire and route the reinstall to the rematerializing path for a reason no git install ever has — passing while exercising nothing. The test uses a new gitSignedVerifier that returns a nil bundle, mirroring what VerifyGit actually does, and the helper's doc comment says why so nobody swaps it back.
docs/arch/14-plugins-system.md:202 states the invariant and why the condition is not "a bundle was freshly verified".
| // on unsigned content (isAllowedUnsigned makes no implicit exception). | ||
| // Without this the documented migration would have no way to record | ||
| // the exception the user explicitly asked for. | ||
| AllowUnsigned: opts.AllowUnsigned, |
There was a problem hiding this comment.
This forwarding makes --allow-unsigned authorize repairing a legacy lock entry with unrecorded trust into a standing unsigned: true exception, but the flag help and pluginsSyncRequest.AllowUnsigned still say it is only for --adopt. That can make an operator approve a different trust transition than the one described, and the new info remediation is undiscoverable from sync --help. Please describe both uses and regenerate the CLI/server docs.
There was a problem hiding this comment.
Fixed in d32980e85. Agreed on both halves — the flag authorizes a second, materially different trust transition, and the operator reading --help sees only the first.
--allow-unsigned now reads:
Record plugins as unsigned in the lock file: when adopting installs whose signature state cannot be established (
--adopt), and when repairing an entry that records no trust decision and whose content is unsigned
and pluginsSyncRequest.AllowUnsigned names both cases, including that the repair fails closed without it — which is the part that makes the flag necessary rather than merely permissive. docs/cli/thv_ai-plugin_sync.md and the server docs are regenerated.
That also closes the discoverability gap you point at: info says run 'thv ai-plugin sync', and sync --help now names "an entry that records no trust decision" as a thing the flag exists for, so the two ends of the remedy meet.
I left thv skill sync --allow-unsigned alone deliberately — skills has no unrecorded-trust refusal (errLockTrustUnrecorded and unrecordedTrustError are plugins-only), so its help text is still accurate and changing it would describe behaviour that package does not have.
The same regeneration picks up the stale TrustUnrecorded schema description that was failing Docs / Verify Swagger Documentation — the committed text was an earlier draft of the doc comment, from before I dropped Managed from the condition. That check should be green now.
JAORMX
left a comment
There was a problem hiding this comment.
Re-reviewed commits after 34a17a7 against the current main diff with Spec, Standards, Security, Architecture, UX, DevEx, and Reuse lenses. The redirect and implicit-unsigned migration blockers are fixed, and the unrecorded trust state is now visible. The artifact-digest binding blocker remains unresolved (discussion_r3894939583), and a same-digest Git migration can still record verified provenance without rematerializing drifted files. I also flagged the stale --allow-unsigned contract. CI currently fails Docs / Verify Swagger Documentation because the generated TrustUnrecorded schema description is stale.
|
Round 4 pushed:
Two things worth your attention rather than just the diff. I widened the Git fix past what you asked for. Keying on newly established provenance would have fixed the case you found and left one beside it: an The core fix is not a free bump. #263 settles a second inconsistency I hit while writing it: online verification bound Standing offer, third time: this PR is well past the 400-line / 10-file budget and I think it splits cleanly. Say the word and I will propose the split rather than keep growing it. |
The last RFC THV-0080 piece for plugins: thv ai-plugin push signs the pushed artifact through toolhive-core's signer — with a cosign key, or keylessly via an OIDC identity token acquired the same way skill push acquires one — and attaches the signature next to the artifact so install-time verification finds it. Pushing unsigned now requires an explicit --no-sign, and a failed signing fails the push rather than silently publishing an artifact consumers must then install with --allow-unsigned. The signing fields are threaded through the plugins push DTO and the Go HTTP client, which carried only a reference: without that, every flag died at the transport boundary. The install response gains the recorded provenance for the same reason — the CLI is a pure HTTP client, so a dropped provenance block would print every signed install as untracked. thv ai-plugin info and install now render the trust state the lock file records, provisional marker included. With signing on publish and verification on consume both in place, the TOOLHIVE_PLUGINS_LOCK_ENABLED gate comes out: the lock file, sync, upgrade, and signature verification are standard behavior for project-scoped plugins. E2E pushes carry an explicit no_sign — the suite has no signing infrastructure, matching the allow_unsigned exceptions its installs already record. Part of #6300. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Samuele Verzi <samu@stacklok.com>
The plugins architecture document described signature verification as "a separate concern" — true when written, wrong now that TOFU recording, offline sync re-verification, the signer-change guard, and signed-by- default publishing have all landed. Add the trust-model section it was missing: what is verified, what the escape hatches are, and what remains trusted on faith (the repository-editable lock file, first use, and the declared-but-unmanaged MCP/LSP server entries). Point the skills lock-file section at it, since both systems record into the same toolhive.lock.yaml. Part of #6300. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Samuele Verzi <samu@stacklok.com>
Three review findings on the trust flow: Removing the lock gate opened a migration hole. A project install recorded while verification was gated off stores no Sigstore bundle, and a same-digest reinstall took the no-op path — returning the stored record verbatim while the lock entry was written with the freshly verified provenance. That pairing is exactly what offline sync fails closed on, so the next `sync --check` reported a signature error and any on-disk drift went unrepaired. Such installs now route to the rematerializing path so the bundle is persisted with the pin. Info swallowed lock read failures, rendering a malformed lock file identically to an untracked install. A missing lock file is not an error (Load returns an empty lockfile), so propagating only surfaces one that exists and cannot be trusted. Key-signed pushes have no consume-time path: verification is keyless-only, and the failure is ErrSignatureInvalid rather than ErrUnsigned, so --allow-unsigned cannot override it. The limitation now appears on the flag and in the arch doc; closing it needs a public key threaded through the lock, install, and sync for skills too. Part of #6300. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Samuele Verzi <samu@stacklok.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Samuele Verzi <samu@stacklok.com>
Key signing has no consume-time path: install verification is keyless-only, and a key-signed artifact fails as ErrSignatureInvalid rather than ErrUnsigned, so --allow-unsigned cannot override it. The flag could only produce plugins nobody can install. Rather than document that trap, remove it. The flag and the API's key field are gone, and pluginsvc rejects a key set by an in-process caller (PushOptions aliases skills.PushOptions, so the field still exists). Plugin publishing is keyless or explicitly unsigned. The flag returns in the change that makes install, sync, and the lock carry a public key, tracked in #6442. Part of #6300. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Samuele Verzi <samu@stacklok.com>
Review found two ways a plugin push could publish something other than what the caller asked for. A signed push tagged the artifact before signing it, so any Fulcio, Rekor, or attachment failure left the requested tag already live and resolving to unsigned content. Returning an error does not retract a published tag, and user-scoped installs consume it with no verification at all — the opposite of the signed-by-default guarantee. Signed pushes now stage the content under its immutable digest, attach the signature to that digest, and only then push the requested tag, so a signing failure leaves blobs uploaded but nothing tagged. Signing targets the staged digest reference because the tag does not exist yet and the attach reads the artifact back. The staged manifest is left untagged for the registry to collect; the registry client exposes no delete. Key signing was reachable but meaningless. plugins.PushOptions aliased skills.PushOptions, so Key was settable while the in-process service answered it with a 400 and the HTTP client dropped it and published unsigned — the same PluginService.Push call behaving differently per implementation. PushOptions is now its own type without the field, and the push endpoint rejects unknown JSON so a key from an older client is a 400 naming it rather than a silent unsigned publish. The absence is pinned by a type-level test, since the whole point is that the request cannot be constructed. Also documents the install endpoint's 403 trust-failure response and marks reference required, both of which the schema omitted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Samuele Verzi <samu@stacklok.com>
An OIDC identity token is a bearer credential that Fulcio will exchange for a signing certificate attributed to its subject, so anyone who observes it in flight can sign artifacts as the caller. TOOLHIVE_API_URL can point the CLI at an arbitrary host, and the push clients serialized the token to it with no regard for the scheme — plain http:// to a remote host put the credential on the wire in cleartext. Both push clients now clear the destination before marshaling the body: HTTPS and loopback HTTP are accepted, everything else is refused with an error naming the alternatives. Loopback covers the Unix socket and named pipe transports, which synthesize http://localhost as their base URL; localhost is treated as loopback by name rather than resolved, so the decision does not depend on ambient DNS. The guard is on the credential, not the endpoint, so a --no-sign push to a remote plaintext API still works — it carries nothing worth protecting. Fixed in the skills client as well as the plugins one: the two share the token acquisition path, and leaving one surface exposed would have made the check security theater. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Samuele Verzi <samu@stacklok.com>
The same-digest reinstall path only rematerialized when the stored record had no bundle at all. A record whose bundle is present but stale or corrupt is exactly as unusable offline as a missing one, so an explicit reinstall — the user's remedy for a failing sync --check — would verify a good bundle, take the no-op path, and discard it, leaving the next offline sync to fail identically. Compare the bytes instead of testing for emptiness. The empty case is subsumed, and the no-op optimization still applies when the freshly verified bundle matches what is already stored. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Samuele Verzi <samu@stacklok.com>
Removing the verification gate left a migration hole. A lock entry recording neither a provenance block nor unsigned: true was accepted as having nothing to verify, so if its database record and content matched, sync reported it as AlreadyCurrent without ever making a trust decision — an unverified plugin passing CI clean, which is the one outcome the lock file exists to prevent. Every install and adoption path records exactly one of the two, so that shape means the entry predates trust recording or was hand-edited. It is now reported as drift: sync --check surfaces it, and sync repairs it by reinstalling from the pinned reference, which runs install-time verification and records a real decision. Reinstalling rather than adopting is the point — adoption records whatever the machine already has. The check lives here rather than in lock validation because the schema deliberately permits the shape, enforcing only that provenance and unsigned are mutually exclusive. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Samuele Verzi <samu@stacklok.com>
The shared credential ladder's terminal error told users to provide --key, but plugin push deliberately has no such flag, so a non-interactive push without an ambient token pointed at a remedy that does not exist. The remediation is now supplied by the calling command rather than baked into the sentinel: skill push offers --key, plugin push does not. ErrNoCredential stays the sentinel so errors.Is keeps working, and an omitted remediation yields the bare message — terser, but never wrong. Also corrects the --identity-token help text, which advertised an "ambient CI OIDC token" when GitHub Actions is the only ambient provider implemented. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Samuele Verzi <samu@stacklok.com>
Printing the recorded trust decision is worth the output; the generic "Installed <name>" fallback is not. A user-scope install writes no lock entry and so has no trust state, meaning the fallback turned every previously quiet install into output while saying nothing about trust — against the CLI's silent-success rule. Keep the provenance and unsigned-exception messages, drop the fallback. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Samuele Verzi <samu@stacklok.com>
Push was only exercised through the HTTP API, which skips everything the CLI itself contributes: flag wiring, the credential ladder, and the error a non-interactive push produces with no credential to use. Two cases. An explicit --no-sign publishes through the CLI and the artifact installs afterwards, which is what proves it actually landed. A push with no credential at all fails, and its message offers --identity-token and --no-sign but never --key, the flag this command does not define. The ambient GitHub Actions variables are cleared so the second case still runs out of rungs when the suite itself runs in CI with id-token: write. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Samuele Verzi <samu@stacklok.com>
Three claims no longer matched the code, or never did. Sync was described as re-verifying every entry's stored bundle offline. Git installs store no bundle by design, so sync skips them and an unchanged git plugin passes on its digest and content hash alone; its recorded identity is enforced when content is next re-resolved. Documented as the OCI-only behavior it is. The key-signing section said pluginsvc.Push rejects a key with a 400, which was true until the field stopped existing. Rewritten around the type-level refusal and why unrepresentable beats rejected. The publishing section claimed a failed signing leaves a push that visibly did not complete, which was the bug rather than the behavior. Replaced with the staged-then-promoted ordering and the guarantee it carries, plus the identity token's transport requirement. Adds the migration for lock entries written before verification, which the docs previously left implicit while claiming the trust decision is never implicit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Samuele Verzi <samu@stacklok.com>
Clearing the base URL said nothing about where the request would actually end up. A 307 or 308 preserves both method and body, and Go sets GetBody automatically for the bytes.Reader the JSON encoder produces, so an accepted HTTPS or loopback endpoint could redirect the push and have the identity token replayed to any host — including plaintext HTTP. Whoever controls the endpoint the CLI was pointed at could therefore bypass the guard entirely. Token-bearing pushes now go through a client that refuses redirects outright rather than re-checking each hop: the ToolHive API does not redirect its own endpoints, so no legitimate case is given up, and "no redirects" cannot be subtly wrong the way a per-hop policy can. The refusal happens before the second request is issued, so the token never leaves the approved origin. Regression test in both clients uses two servers — the first loopback, so it passes the transport check, the second recording anything it receives — and asserts on the transport rather than the error: an error returned after the credential went out would be too late to matter. Verified it fails without the guard. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Samuele Verzi <samu@stacklok.com>
Classifying entries with no trust decision as drift fixed the wrong half of the problem. The repair that drift triggers ran as a lock-driven install, and those were granted an implicit unsigned exception, so sync rewrote "no trust decision" into "unsigned: true" on its own — trading a visibly ambiguous entry for a standing exception that looks deliberate. Nobody chose it, which is exactly the implicit trust decision the lock file exists to prevent. Recording an unsigned exception is now only possible with the explicit flag: isAllowedUnsigned and verifyLocalInstall no longer make an allowance for lock-driven installs. A legacy entry is therefore repaired automatically only when a signature actually verifies; unsigned content fails closed. An entry that already records unsigned is untouched — honoring a decision the lock file states is not the same as inventing one. Sync now forwards AllowUnsigned into both reinstall paths, without which the remedy would not exist: nothing else can record the exception on a lock-driven repair, and the migration would have been a dead end. The refusal names `sync --allow-unsigned` rather than the install flag, since sync is the operation that owns the lock entry, and wraps ErrUnsigned so the failure reports as unsigned-rejected instead of unknown. Covered end to end: drift is visible, an unconsented repair fails without converting the entry, and the flag completes the migration. The git fixture is used deliberately so the middle step fails on the trust refusal rather than on missing content. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Samuele Verzi <samu@stacklok.com>
Info left the pre-verification state indistinguishable from an untracked install: both leave provenance and unsigned empty, so the command promised to display the recorded trust decision showed nothing at all for an entry sync reports as drift. PluginInfo gains TrustUnrecorded, and the text renderer names the state along with the command that repairs it. Distinguishing it needs to know whether a lock entry exists, which expectedLockTrust collapsed together with "records nothing", so that read is now shared with a variant returning the entry's presence — one lock file load, no duplicated parsing. Keyed on the entry existing rather than on the record being lock-managed: what is unrecorded is the entry's trust decision, and a locked-but-unmanaged plugin needs the same answer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Samuele Verzi <samu@stacklok.com>
The migration paragraph said sync repairs an unrecorded entry, which now overstates it: the repair completes on its own only when a signature verifies, and unsigned content requires an explicit --allow-unsigned. That distinction is the substance of the migration rather than a footnote to it, so it is spelled out along with why an entry already recording unsigned is treated differently. Also records that a token-bearing push refuses redirects, and that info renders the unrecorded state rather than staying silent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Samuele Verzi <samu@stacklok.com>
A lock entry asserts a contentDigest hashed from the source just fetched, never read back from disk. An install that recorded one while short-circuiting as a no-op therefore published a claim it had not established: a project tree predating lock tracking, modified since, stayed active behind a brand-new entry naming a verified signer and a pristine digest. Only a later `sync --check` noticed. The existing mustPersistTrust escape covered OCI alone, because it keys on a freshly verified bundle differing from the stored one. Git verification produces no bundle at all — VerifyGit returns a nil one, its signature living on the commit — so a same-commit git reinstall could never take it, and an --allow-unsigned reinstall records a contentDigest with no provenance and the same gap. Key the rematerialization on the unmanaged-to-managed transition instead, which covers all three shapes, and route it to the upgrade path that already rematerializes and persists. The cost is one extra materialization the first time a legacy record gains a lock entry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Samuele Verzi <samu@stacklok.com>
The flag now authorizes two distinct trust transitions: adopting an install whose signature state cannot be established, and repairing a lock entry that records no trust decision into an explicit unsigned exception. Only the first was documented, so an operator reading `sync --help` could approve a transition other than the one described, and the remedy `thv ai-plugin info` names for an unrecorded entry was undiscoverable from the command that performs it. Regenerating also refreshes the TrustUnrecorded schema description, which was left stale by the previous commit and was failing the Docs check. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Samuele Verzi <samu@stacklok.com>
d32980e to
cba9ed4
Compare
Summary
Stack 2 of #6300 gave plugins a lock file, install-time verification, offline sync re-verification, and an upgrade signer guard — but left two ends open. Publishing still had no way to sign, so the only artifact a project-scoped install could accept was one a human had signed by hand out of band; and the whole feature stayed behind
TOOLHIVE_PLUGINS_LOCK_ENABLED, which existed precisely because the consume half had no publish half to trust.This closes both, and completes the tracker's Definition of Done.
thv ai-plugin pushsigns the pushed artifact through toolhive-core'scontainer/signervia--identity-token— acquired automatically from an ambient CI token or an interactive browser sign-in when not given — and attaches the signature next to the artifact where PR7's install verification finds it.--no-signis the explicit opt-out. A failed signing fails the push: an artifact published as if it were signed, which consumers then have to install with--allow-unsigned, is worse than a push that visibly did not complete.--key. Key signing has no consume-time path — install verification is keyless-only, and a key-signed artifact fails asErrSignatureInvalidrather thanErrUnsigned, so--allow-unsignedcannot override it. The flag could only produce plugins nobody can install, so it is not offered;pluginsvc.Pushalso rejects a key set by an in-process caller, sinceplugins.PushOptionsaliasesskills.PushOptionsand the field still exists. Filed as --key push signing has no install-time verification path (skills and plugins) #6442 (which affects shippedthv skill push --keytoo); the flag returns for plugins in the change that makes it verifiable.reference, so every flag would have died between the CLI and the service.identity_tokenandno_signnow thread throughpkg/api/v1and the Go HTTP client, each pinned by a round-trip test. The install response gains the recorded provenance for the mirror-image reason: the CLI is a pure HTTP client, so a dropped provenance block would print every signed install as if it were untracked.thv ai-plugin inforenders the lock file's recorded signer ("Signed by / Cert issuer", with the(provisional)marker for git signatures and(unsigned — explicit exception)for recorded exceptions), andthv ai-plugin installprints the trust outcome on completion. RFC THV-0080 wants the pinned identity shown at decision time, not discovered weeks later inside a signer-mismatch error.pkg/plugins/feature_gate.goand everyLockFileFeatureEnabledcheck are removed. Lock recording, sync, upgrade, and signature verification are now standard behavior for project-scoped plugins.docs/arch/14-plugins-system.mdgains the trust-model section it never had — TOFU semantics, theallow_unsigned/allow_signer_changeescape hatches, offline re-verification, and what deliberately remains trusted on faith — with a pointer from the skills document's lock section, since both systems write the sametoolhive.lock.yaml.Part of #6300. Skills counterpart: #6139. Stacked on #6401, which merged while this was in progress — so this targets
maindirectly.Type of change
Test plan
task test)task lint-fix)task test-e2e)New coverage:
--no-signcombined with either are each rejected with 400 before anything is pushed.TOOLHIVE_SIGSTORE_FULCIO_URL/_REKOR_URLoverrides reach core'ssigner.Options;--no-signnever calls the signer at all. A key is refused before the registry or the signer is touched,--keyis asserted absent from the command, and akeyin the API request body is asserted not to reach the service.identity_tokenandno_signat both the API handler and the Go client; install-response round-trips for signed / provisional / unsigned.Inforeports each of the four trust states (signed, provisional, unsigned exception, no lock entry), andInstallResultcarries the decision install recorded.infoandinstall.E2E:
pkg/transport/proxy/streamable'sTestMCPGoClientInitializeAndPingfails on this branch, but it also fails on a cleanupstream/mainworktree — pre-existing and unrelated. Every other unit test passes.task test-e2ewas not run locally;test/e2e/cli_plugins_lock_test.gois updated (gate env removed, pushes carry explicitno_sign) and compiles.API Compatibility
v1beta1API, OR theapi-break-allowedlabel is applied and the migration guidance is described above.Does this introduce a user-facing change?
Yes, two.
TOOLHIVE_PLUGINS_LOCK_ENABLEDis removed. Project-scoped plugin installs now always record intotoolhive.lock.yamland are always signature-verified;thv ai-plugin syncandthv ai-plugin upgradework without any environment variable. Anyone who was setting the variable can stop. The corresponding effect for users who were not setting it: a project-scoped install of an unsigned plugin now requires--allow-unsigned, which is recorded in the lock file as an explicit exception. User-scoped installs are unaffected.thv ai-plugin pushnow signs by default, keylessly. A push with neither--identity-tokennor--no-signis rejected rather than publishing unsigned; when signing is wanted, an OIDC token is acquired automatically (ambient CI token, else browser sign-in). There is no--keyflag and nokeyfield on the push API — see above and #6442.Special notes for reviewers
Scope. 20 non-test, non-generated files, ~270 insertions. Over the 10-file guideline, under the 400-line one. The pieces are one logical change and resist splitting: removing the gate without signed publishing would leave verification with nothing to verify, and the push DTO, the install response, and the CLI renderers each touch the same four files. This mirrors #6139's shape on the skills side.
Deliberate reuse over duplication.
plugins.ProvenanceInfois a type alias forskills.ProvenanceInforather than a parallel struct — the THV-0080 provenance contract is shared, and a second struct would only be somewhere for the two to drift. The signer mock is likewise reused frompkg/skills/skillsvc/mocks(it mocks toolhive-core'ssigner.Signer, not anything skills-specific), matching howpluginsvcalready reusespkg/skills/verifier/mocks.validateSigningInputsis duplicated intopluginsvc— skills' copy is unexported, and this is the established mirror pattern for that package pair.One drive-by comment fix. Removing the gate made ~45
//nolint:paralleltest // uses t.Setenv via newLockTestServicereasons untrue, since the helper no longer callst.Setenv. I updated the reason text rather than converting the tests to parallel — that would be a much larger, riskier change than this PR should carry, and the suppressions are still load-bearing (paralleltestis enabled). Converting them is reasonable follow-up work.🤖 Generated with Claude Code