Skip to content

Restore capability-gated --key signing on thv ai-plugin push - #6528

Merged
samuv merged 5 commits into
mainfrom
samuv/plugins-push-key
Sep 11, 2026
Merged

samuv merged 5 commits into
mainfrom
samuv/plugins-push-key

Conversation

@samuv

@samuv samuv commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Summary

thv ai-plugin push --key was removed in #6438 because ToolHive could publish a key-signed plugin but could not verify and install it project-scoped. With the install-time public-key verification and lock-file support from #6524 and #6525, publishers can safely use key signing again and consumers can pin the matching public key on first install. This closes #6442.

Restoring the flag also reopens the server-side private-key trust boundary addressed by #6560: the thv serve process resolves and opens the requested key path. A peer-address check is insufficient because a public reverse proxy can make an untrusted request appear to arrive over loopback or IPC. This PR therefore extends #6560's protected discovery capability to plugin pushes rather than treating transport locality as authorization.

This change:

  • Restores --key and the Key field across the plugin CLI, API DTO, client, and service, while retaining strict signing-input validation.
  • Validates the signing choice (exactly one of key, identity_token, no_sign) in the handler before the capability guard and dispatch, so the endpoint's contract holds independent of which PluginService implementation is wired in. The validator lives in pkg/plugins and the service calls the same function for in-process callers.
  • Forwards the key to the signer and keeps staged-then-promoted publication for signed artifacts.
  • Requires the owner-protected local discovery capability before the API dispatches a key-bearing plugin push. Loopback or IPC without the capability receives 403.
  • Has automatically discovered plugin clients attach the capability only to key-bearing pushes. Environment/manual URLs do not inherit it, and keyless or unsigned requests do not send it.
  • Treats the capability as a bearer credential: it is refused over plaintext remote transports and is never followed across redirects.
  • Documents that --key requires the locally discovered ToolHive server; remote or manually configured API URLs must use keyless signing.
  • Regenerates the CLI and OpenAPI documentation, including the conditional capability header and 403 response.

Fixes #6442

Type of change

  • New feature
  • Bug fix
  • Refactoring (no behavior change)
  • Dependency update
  • Documentation
  • Other (describe):

Test plan

  • Unit tests (task test)
  • Linting (task lint-fix)
  • E2E tests (task test-e2e)
  • Manual testing (describe below)
  • Generated API and CLI documentation (task docs)

Regression coverage includes:

  • Signing-input validation for key, identity-token, unsigned, missing, and conflicting choices, at the route (service never reached) and in the shared validator.
  • Key propagation through the CLI, API DTO, HTTP client, service, and signer.
  • Capability rejection for loopback and IPC-shaped requests without the protected secret, plus dispatch with a matching capability.
  • Client header scoping, discovery-only propagation, plaintext remote transport rejection, and redirect non-forwarding.
  • CLI help text for the local-discovery and public-key requirements.

Changes

Area Change
Plugin CLI and client Restore --key, load the discovery capability, and protect credential transport
Plugin API Accept key, validate the signing choice and require the capability before dispatch, and document 403
Plugin service Validate the key signing arm and forward it to the signer
Trust model Reuse the public-key verification and lock-file contract delivered by the lower stack
Tests Cover end-to-end key propagation and the capability security boundary
Docs Update architecture, CLI help, and generated OpenAPI output

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.

key and the conditional capability header are additive. Existing keyless and --no-sign callers are unchanged.

Does this introduce a user-facing change?

Yes. thv ai-plugin push --key <cosign.key> works again when the CLI uses automatic local server discovery. Consumers installing the resulting plugin project-scoped must pass thv ai-plugin install --public-key on first use; the key is pinned in toolhive.lock.yaml and reused by sync and upgrade thereafter. Remote and manually configured API URLs must use keyless signing.

Special notes for reviewers

🤖 Generated with Claude Code

@github-actions github-actions Bot added the size/M Medium PR: 300-599 lines changed label Sep 7, 2026
@samuv samuv self-assigned this Sep 7, 2026
@codecov

codecov Bot commented Sep 7, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.87755% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.94%. Comparing base (c25e508) to head (932c1d3).
⚠️ Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
pkg/plugins/client/client.go 80.00% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6528      +/-   ##
==========================================
- Coverage   78.94%   78.94%   -0.01%     
==========================================
  Files         781      782       +1     
  Lines       77784    77814      +30     
==========================================
+ Hits        61410    61431      +21     
- Misses      16369    16378       +9     
  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 samuv/plugins-push-key branch from 935df4f to 3f1c3c4 Compare September 8, 2026 08:21
@github-actions github-actions Bot added size/M Medium PR: 300-599 lines changed and removed size/M Medium PR: 300-599 lines changed labels Sep 8, 2026
@samuv
samuv force-pushed the samuv/plugins-push-key branch from 3f1c3c4 to 55be810 Compare September 8, 2026 11:01
@github-actions github-actions Bot added size/M Medium PR: 300-599 lines changed and removed size/M Medium PR: 300-599 lines changed labels Sep 8, 2026
JAORMX
JAORMX previously approved these changes Sep 8, 2026

@JAORMX JAORMX left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed 55be810 against its exact stacked base abda3b6. This direct range consistently restores --key through CLI, client/DTO, API, validation, and signer options; the CLI correctly states that the signing host (thv serve) must access an encrypted key/password. No blocking findings in the PR-owned changes. This approval does not waive the separately requested changes on stacked #6525. Current CI includes an author-owned failing lifecycle job and remaining in-progress checks; it is not part of this review decision.

@samuv
samuv force-pushed the samuv/plugins-push-key branch from 55be810 to c58156f Compare September 8, 2026 12:16
@github-actions github-actions Bot added size/M Medium PR: 300-599 lines changed and removed size/M Medium PR: 300-599 lines changed labels Sep 8, 2026
@samuv
samuv force-pushed the samuv/plugins-push-key branch from c58156f to 9dc7307 Compare September 8, 2026 12:49
@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 Sep 9, 2026
JAORMX
JAORMX previously approved these changes Sep 9, 2026

@JAORMX JAORMX left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Fresh review of exact head ea4f6bb against exact stacked base #6560 at 11c47dd. This review attributes only the PR-owned range.\n\nThe prior private-key signing-oracle blocker is resolved by the inherited protected discovery capability and this PR's pre-dispatch plugin-route integration (pkg/api/v1/plugins.go:342-355; pkg/api/server.go:461-465). The capability is sourced only through protected local discovery, sent only for --key pushes, and protected from plaintext remote transport and redirects (pkg/plugins/client/client.go:301-324). The direct range correctly carries --key through CLI, client/DTO, API, service, and signer; exactly-one signing-mode validation and stage → sign → promote remain intact.\n\nNo merge-blocking findings in the direct range. The CLI help accurately states that COSIGN_PASSWORD is read by thv serve; the generated API schema's shorter description is non-blocking.\n\nCI is separate from this decision: all checks are successful except Helm Charts / Lint and Test Helm Charts, which is author-owned and does not establish a reviewed-code defect.

@samuv
samuv force-pushed the samuv/plugins-push-key branch from ea4f6bb to e5526a7 Compare September 10, 2026 07:50
@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 Sep 10, 2026
@samuv
samuv force-pushed the samuv/plugins-push-key branch from e5526a7 to a58c244 Compare September 10, 2026 08: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 Sep 10, 2026

@JAORMX JAORMX left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Follow-up panel review of the direct range 9d5248151d36df661b923b5b63caf82e92ce9b27...a58c2442925ecfacff028f80b1430a0a269e3950.

Blocking — direct API requests can bypass signing-mode validation

pushPlugin only validates reference before it dispatches to PluginService (pkg/api/v1/plugins.go:335-354), though the endpoint's documented contract requires exactly one of key, identity_token, or no_sign (pkg/api/v1/plugins_types.go:81-105). A request containing only reference therefore succeeds with a service implementation that does not repeat the concrete pluginsvc validation; the route test explicitly expects that invalid request to return 204 (pkg/api/v1/plugins_test.go:466-474). This makes the API contract dependent on a particular service implementation and permits generated/direct clients to issue a request that the real service rejects.

Validate the signing choice at the HTTP trust boundary before dispatch, and change the route test to expect 400. The capability guard can remain scoped to the key arm after that validation.

The previous signing-oracle finding is addressed by the inherited discovery capability plus this PR's route/client wiring. Exact-head CI is green; this correctness issue is independently blocking.

Base automatically changed from samuv/api-reject-remote-key-signing to main September 10, 2026 11:23
@samuv
samuv force-pushed the samuv/plugins-push-key branch from a58c244 to 5c7a52a Compare September 10, 2026 12:47
@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 Sep 10, 2026
@samuv

samuv commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Addressed in 5c7a52a. Verified the finding first: pushPlugin checked only reference before dispatch, and the push plugin success route test pinned the reference-only 204.

  • validateSigningInputs is lifted from pluginsvc to plugins.ValidatePushSigning (pkg/plugins/options.go). pushPlugin runs it right after the reference check, before requireKeySigningCapability and dispatch, so a reference-only request is a 400 from the endpoint itself. The capability guard stays scoped to the key arm. The service keeps calling the same function for in-process callers, so there is one copy of the contract rather than two.
  • Route tests: the reference-only case now expects 400 signing credential required with no service expectation set; added a conflicting-choice case (no_sign + identity_token, 400, service never reached); push plugin service error now sends no_sign: true. TestValidatePushSigning in pkg/plugins covers all seven input shapes and asserts the 400 code on each rejection.

Also rebased onto main now that #6560 has merged. The four existing commits are unchanged apart from reconciling #6627's documentation rewrite, which had recorded that plugin push has no --key (architecture doc, CLI Long text and regenerated CLI doc, and the toolhive-cli-user skill reference).

One thing outside this PR's scope: pushSkill in pkg/api/v1/skills.go has the identical gap, with the choice enforced only by skillsvc. Candidate for a separate PR rather than widening this one.

@github-actions github-actions Bot removed the size/L Large PR: 600-999 lines changed label Sep 10, 2026
samuv and others added 5 commits September 10, 2026 16:12
Plugin push was made keyless-only because a key-signed artifact could
not be installed project-scoped: verification found the bundle, failed
it as key-signed, and --allow-unsigned did not apply. The flag could
only publish plugins nobody could consume, so it was removed rather
than documented as a trap.

Install-time key verification now exists. A key-signed plugin installs
with --public-key, the key is pinned in the lock entry, sync re-verifies
the stored bundle against it offline, and upgrade applies it to the
candidate — so publishing one is no longer a dead end and the flag can
come back.

PushOptions is an alias of skills.PushOptions again; the distinct struct
existed only to omit Key. The push DTO, the Go client, and
validateSigningInputs carry the key arm the skills surface already has,
and the endpoint keeps rejecting unknown fields with key now among the
known ones.

Fixes #6442.

Signed-off-by: Samuele Verzi <samu@stacklok.com>
The path is opened by the thv serve process that signs, not by the CLI, so
against a remote server the advertised command fails unless the key file and
COSIGN_PASSWORD are both provisioned on that host.

Signed-off-by: Samuele Verzi <samu@stacklok.com>
Same guard as skills/push, which the commit below adds: a private-key path
is resolved by the thv serve process, so a remote caller naming one would be
asking the server to sign with a key it never supplied.

Signed-off-by: Samuele Verzi <samu@stacklok.com>
Carry the protected discovery capability through the plugin API client and require it before dispatching a key-bearing push. Keep the capability off non-key requests and protect it from plaintext remote transports and redirects.

Update the CLI, OpenAPI, architecture docs, and regression coverage to reflect that server-side keys are available only through automatic local discovery.

Signed-off-by: Samuele Verzi <samu@stacklok.com>
The push endpoint documents that exactly one of key, identity_token, or
no_sign is required, but the handler only checked reference before
dispatching. The contract was held by pluginsvc alone, so a request
naming only a reference was a 204 against any other PluginService
implementation, and the route test pinned that behavior.

Lift the validator into pkg/plugins as ValidatePushSigning and run it in
the handler before the capability guard and dispatch. The service keeps
calling it for in-process callers. The reference-only route test now
expects 400, and a conflicting-choice case is added alongside it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Samuele Verzi <samu@stacklok.com>
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.

--key push signing has no install-time verification path (plugins)

2 participants