Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 15 additions & 11 deletions cmd/thv/app/ai_plugin_install.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@ import (
)

var (
aiPluginInstallScope string
aiPluginInstallClientsRaw string
aiPluginInstallForce bool
aiPluginInstallProjectRoot string
aiPluginInstallGroup string
aiPluginInstallScope string
aiPluginInstallClientsRaw string
aiPluginInstallForce bool
aiPluginInstallProjectRoot string
aiPluginInstallGroup string
aiPluginInstallAllowUnsigned bool
)

var aiPluginInstallCmd = &cobra.Command{
Expand Down Expand Up @@ -44,6 +45,8 @@ func init() {
&aiPluginInstallProjectRoot, "project-root", "", "Project root path for project-scoped installs",
)
aiPluginInstallCmd.Flags().StringVar(&aiPluginInstallGroup, "group", "", "Group to add the plugin to after installation")
aiPluginInstallCmd.Flags().BoolVar(&aiPluginInstallAllowUnsigned, "allow-unsigned", false,
"Allow installing a project-scoped plugin without a verified signature (recorded in the lock file)")
}

func aiPluginInstallCmdFunc(cmd *cobra.Command, args []string) error {
Expand All @@ -55,12 +58,13 @@ func aiPluginInstallCmdFunc(cmd *cobra.Command, args []string) error {
}

_, err = c.Install(cmd.Context(), plugins.InstallOptions{
Name: args[0],
Scope: plugins.Scope(aiPluginInstallScope),
Clients: parseSkillInstallClients(aiPluginInstallClientsRaw),
Force: aiPluginInstallForce,
ProjectRoot: projectRoot,
Group: aiPluginInstallGroup,
Name: args[0],
Scope: plugins.Scope(aiPluginInstallScope),
Clients: parseSkillInstallClients(aiPluginInstallClientsRaw),
Force: aiPluginInstallForce,
ProjectRoot: projectRoot,
Group: aiPluginInstallGroup,
AllowUnsigned: aiPluginInstallAllowUnsigned,
})
if err != nil {
return formatAIPluginError("install plugin", err)
Expand Down
22 changes: 22 additions & 0 deletions cmd/thv/app/ai_plugin_install_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package app

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// TestAIPluginInstallAllowUnsignedFlag pins the flag that carries the
// unsigned-install trust decision: an install of a project-scoped plugin
// without a verified signature is rejected unless the user opts in here.
func TestAIPluginInstallAllowUnsignedFlag(t *testing.T) {
t.Parallel()

flag := aiPluginInstallCmd.Flags().Lookup("allow-unsigned")
require.NotNil(t, flag, "thv ai-plugin install must expose --allow-unsigned")
assert.Equal(t, "false", flag.DefValue, "unsigned installs must never be the default")
}
1 change: 1 addition & 0 deletions docs/cli/thv_ai-plugin_install.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions docs/server/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions docs/server/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions docs/server/swagger.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 8 additions & 7 deletions pkg/api/v1/plugins.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,13 +125,14 @@ func (s *PluginsRoutes) installPlugin(w http.ResponseWriter, r *http.Request) er
}

result, err := s.pluginService.Install(r.Context(), plugins.InstallOptions{
Name: req.Name,
Version: req.Version,
Scope: req.Scope,
ProjectRoot: req.ProjectRoot,
Clients: req.Clients,
Force: req.Force,
Group: req.Group,
Name: req.Name,
Version: req.Version,
Scope: req.Scope,
ProjectRoot: req.ProjectRoot,
Clients: req.Clients,
Force: req.Force,
Group: req.Group,
AllowUnsigned: req.AllowUnsigned,
})
if err != nil {
return err
Expand Down
35 changes: 35 additions & 0 deletions pkg/api/v1/plugins_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -677,3 +677,38 @@ func TestPluginsInstallLocationHeader(t *testing.T) {
assert.Equal(t, http.StatusCreated, rec.Code)
assert.Equal(t, "/api/v1beta/plugins/my-plugin", rec.Header().Get("Location"))
}

// TestPluginsInstallCarriesAllowUnsigned pins the API-side half of the
// unsigned-install exception: a request body that sets allow_unsigned must
// reach the service as InstallOptions.AllowUnsigned, or the flag dies at the
// handler and every caller is told to pass the flag it already passed.
func TestPluginsInstallCarriesAllowUnsigned(t *testing.T) {
t.Parallel()

ctrl := gomock.NewController(t)
mockSvc := plugmocks.NewMockPluginService(ctrl)

mockSvc.EXPECT().Install(gomock.Any(), plugins.InstallOptions{
Name: "my-plugin",
Scope: plugins.ScopeProject,
ProjectRoot: "/tmp/project",
AllowUnsigned: true,
}).Return(&plugins.InstallResult{
Plugin: plugins.InstalledPlugin{
Metadata: plugins.PluginMetadata{Name: "my-plugin"},
Scope: plugins.ScopeProject,
Status: plugins.InstallStatusInstalled,
},
}, nil)

router := chi.NewRouter()
router.Mount("/", PluginsRouter(mockSvc))

body := `{"name":"my-plugin","scope":"project","project_root":"/tmp/project","allow_unsigned":true}`
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)

assert.Equal(t, http.StatusCreated, rec.Code)
}
4 changes: 4 additions & 0 deletions pkg/api/v1/plugins_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ type installPluginRequest struct {
Clients []string `json:"clients,omitempty"`
// Force allows overwriting unmanaged plugin directories
Force bool `json:"force,omitempty"`
// AllowUnsigned permits installing a project-scoped plugin without a
// verified signature; the exception is recorded in the project's lock
// file.
AllowUnsigned bool `json:"allow_unsigned,omitempty"`
// Group is the group name to add the plugin to after installation
Group string `json:"group,omitempty"`
}
Expand Down
15 changes: 8 additions & 7 deletions pkg/plugins/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,13 +206,14 @@ func (c *Client) List(ctx context.Context, opts plugins.ListOptions) ([]plugins.
// Install installs a plugin from a remote source.
func (c *Client) Install(ctx context.Context, opts plugins.InstallOptions) (*plugins.InstallResult, error) {
body := installRequest{
Name: opts.Name,
Version: opts.Version,
Scope: opts.Scope,
ProjectRoot: opts.ProjectRoot,
Clients: opts.Clients,
Force: opts.Force,
Group: opts.Group,
Name: opts.Name,
Version: opts.Version,
Scope: opts.Scope,
ProjectRoot: opts.ProjectRoot,
Clients: opts.Clients,
Force: opts.Force,
Group: opts.Group,
AllowUnsigned: opts.AllowUnsigned,
}

var resp installResponse
Expand Down
26 changes: 26 additions & 0 deletions pkg/plugins/client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -999,3 +999,29 @@ func TestCallerCancellationIsNeitherSentinel(t *testing.T) {
assert.NotErrorIs(t, err, ErrRequestTimeout)
assert.NotErrorIs(t, err, ErrServerUnreachable)
}

// TestInstallCarriesAllowUnsigned round-trips the unsigned exception through
// the client's request body — without this, the CLI flag silently never
// reaches the server (every --allow-unsigned install would 403 telling the
// user to pass the flag they passed).
func TestInstallCarriesAllowUnsigned(t *testing.T) {
t.Parallel()

var got installRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.NoError(t, json.NewDecoder(r.Body).Decode(&got))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(installResponse{})
}))
t.Cleanup(srv.Close)

_, err := newTestClient(t, srv).Install(t.Context(), plugins.InstallOptions{
Name: "my-plugin",
Scope: plugins.ScopeProject,
ProjectRoot: "/tmp/project",
AllowUnsigned: true,
})
require.NoError(t, err)
assert.True(t, got.AllowUnsigned, "allow_unsigned must reach the server")
}
3 changes: 3 additions & 0 deletions pkg/plugins/client/dto.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ type installRequest struct {
Clients []string `json:"clients,omitempty"`
Force bool `json:"force,omitempty"`
Group string `json:"group,omitempty"`
// AllowUnsigned mirrors plugins.InstallOptions.AllowUnsigned; without
// it here the CLI flag would silently never reach the server.
AllowUnsigned bool `json:"allow_unsigned,omitempty"`
}

type validateRequest struct {
Expand Down
31 changes: 31 additions & 0 deletions pkg/plugins/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"context"

"github.com/stacklok/toolhive/pkg/skills"
"github.com/stacklok/toolhive/pkg/skills/lockfile"
)

// ListOptions configures the behavior of the List operation. Alias for
Expand All @@ -31,6 +32,13 @@ type InstallOptions struct {
ProjectRoot string `json:"project_root,omitempty"`
// Group is the group name to add the plugin to after installation.
Group string `json:"group,omitempty"`
// AllowUnsigned permits installing a project-scoped plugin whose
// artifact carries no Sigstore signature. Without it, unsigned
// artifacts are rejected; with it, the lock entry records the exception
// as "unsigned: true". A plugin contributes hooks, agents, and MCP
// servers to the client that loads it, so this is an explicit
// per-install trust decision, never a default.
AllowUnsigned bool `json:"allow_unsigned,omitempty"`
// LayerData is the tar.gz content from an OCI layer. Internal use only — NOT exposed via HTTP API.
LayerData []byte `json:"-"`
// Reference is the full OCI reference (e.g. ghcr.io/org/plugin:v1).
Expand Down Expand Up @@ -71,6 +79,24 @@ type InstallOptions struct {
// to equal this value before any install mutation. Used by Sync/Upgrade so a
// lock entry cannot be repaired under a different canonical identity.
ExpectedCanonicalName string `json:"-"`
// Unsigned records the trust decision that this install proceeded
// without a verified signature (via AllowUnsigned). Set internally by
// install-time verification; recorded as `unsigned: true` in the lock
// entry. Internal use only — NOT exposed via HTTP API.
Unsigned bool `json:"-"`
// Provenance carries the verified signer identity established during
// install-time verification, for recording into the lock entry. Set by
// the verification step, nil when the artifact is unsigned or
// verification did not run. Unlike skills.InstallOptions, this is the
// lock file's own shape: plugins have no API-facing provenance type to
// convert through yet, and a conversion pair that exists only to be
// round-tripped is a place for recorded trust data to get dropped.
// Internal use only — NOT exposed via HTTP API.
Provenance *lockfile.Provenance `json:"-"`
// SigstoreBundle is the serialized Sigstore bundle backing Provenance,
// persisted alongside the install record so sync can re-verify offline.
// Internal use only — NOT exposed via HTTP API.
SigstoreBundle []byte `json:"-"`
}

// InstallResult contains the outcome of an Install operation.
Expand Down Expand Up @@ -165,6 +191,11 @@ const (
FailureReasonSignerMismatch = skills.FailureReasonSignerMismatch
FailureReasonUnsignedRejected = skills.FailureReasonUnsignedRejected
FailureReasonUnknown = skills.FailureReasonUnknown

// FailureReasonProvenanceFieldMismatch means the artifact verifies
// against the recorded signer, but a pinned certificate field (the
// repository ref or runner environment) no longer matches.
FailureReasonProvenanceFieldMismatch = skills.FailureReasonProvenanceFieldMismatch
)

// UpgradeOptions configures a lock-file upgrade. Alias for
Expand Down
11 changes: 11 additions & 0 deletions pkg/plugins/pluginsvc/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,17 @@ func (s *service) installByName(
}
}

// Local-store artifacts and raw layer data carry no registry signature
// to verify — installing them project-scoped is an unsigned trust
// decision that must be explicit.
if shouldVerifyInstall(opts, scope) {
decision, verifyErr := verifyLocalInstall(opts, opts.Name)
if verifyErr != nil {
return nil, verifyErr
}
applyDecisionToOpts(&opts, decision)
}

result, err := s.installWithExtraction(ctx, opts, scope)
if err != nil {
return nil, err
Expand Down
21 changes: 11 additions & 10 deletions pkg/plugins/pluginsvc/install_extraction.go
Original file line number Diff line number Diff line change
Expand Up @@ -619,16 +619,17 @@ func buildInstalledPlugin(
Version: opts.Version,
Description: opts.Description,
},
Scope: scope,
ProjectRoot: opts.ProjectRoot,
Reference: opts.Reference,
Tag: opts.Tag,
Digest: opts.Digest,
Status: plugins.InstallStatusInstalled,
InstalledAt: time.Now().UTC(),
Clients: clients,
Components: opts.Components,
Dependencies: opts.Dependencies,
Scope: scope,
ProjectRoot: opts.ProjectRoot,
Reference: opts.Reference,
Tag: opts.Tag,
Digest: opts.Digest,
Status: plugins.InstallStatusInstalled,
InstalledAt: time.Now().UTC(),
Clients: clients,
Components: opts.Components,
Dependencies: opts.Dependencies,
SigstoreBundle: opts.SigstoreBundle,
}
}

Expand Down
17 changes: 14 additions & 3 deletions pkg/plugins/pluginsvc/install_git.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,9 @@ func (s *service) installFromGit(

gitURL := opts.Name

// head carries the resolved commit's hash plus its (unverified) gitsign
// signature and signed payload. Only the hash is consumed today; the
// signature and payload are carried for install-time verification.
// head carries the resolved commit's hash (the install digest) plus its
// gitsign signature and the payload it covers, which install-time
// verification checks below before anything is written.
files, manifest, head, err := s.cloneAndCollectPlugin(ctx, gitRef)
if err != nil {
return nil, httperr.WithCode(
Expand Down Expand Up @@ -105,6 +105,17 @@ func (s *service) installFromGit(
defer unlock()
}

// Verify the commit signature before anything is written or recorded.
// This runs under the per-plugin lock so concurrent first installs
// cannot both read an absent lock entry and race their TOFU anchors.
if shouldVerifyInstall(opts, scope) {
decision, verifyErr := s.verifyGitInstall(ctx, opts, manifest.Name, head.Payload, head.Signature)
if verifyErr != nil {
return nil, verifyErr
}
applyDecisionToOpts(&opts, decision)
}

result, err := s.installWithExtraction(ctx, opts, scope)
if err != nil {
return nil, err
Expand Down
Loading
Loading