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
38 changes: 26 additions & 12 deletions cmd/thv/app/ai_plugin_info.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package app
import (
"encoding/json"
"fmt"
"io"
"maps"
"os"
"slices"
Expand Down Expand Up @@ -81,18 +82,7 @@ func printAIPluginInfoText(info *plugins.PluginInfo) {

_, _ = fmt.Fprintf(w, "Name:\t%s\n", info.Metadata.Name)
_, _ = fmt.Fprintf(w, "Version:\t%s\n", info.Metadata.Version)
switch {
case info.Provenance != nil && info.Provenance.Provisional:
_, _ = fmt.Fprintf(w, "Signed by:\t%s (provisional)\n", info.Provenance.SignerIdentity)
_, _ = fmt.Fprintf(w, "Cert issuer:\t%s\n", info.Provenance.CertIssuer)
case info.Provenance != nil:
_, _ = fmt.Fprintf(w, "Signed by:\t%s\n", info.Provenance.SignerIdentity)
_, _ = fmt.Fprintf(w, "Cert issuer:\t%s\n", info.Provenance.CertIssuer)
case info.Unsigned:
_, _ = fmt.Fprintf(w, "Signed by:\t(unsigned — explicit exception)\n")
case info.TrustUnrecorded:
_, _ = fmt.Fprintf(w, "Signed by:\t(trust unrecorded — run 'thv ai-plugin sync')\n")
}
printAIPluginTrustState(w, info)
_, _ = fmt.Fprintf(w, "Description:\t%s\n", info.Metadata.Description)

if s := info.InstalledPlugin; s != nil {
Expand Down Expand Up @@ -135,6 +125,30 @@ func printAIPluginInfoText(info *plugins.PluginInfo) {
_ = w.Flush()
}

// printAIPluginTrustState renders the trust anchor the lock file records for
// the plugin. Every state gets a line of its own: a state that rendered as no
// trust block would be indistinguishable from a plugin nothing is pinning.
func printAIPluginTrustState(w io.Writer, info *plugins.PluginInfo) {
switch {
// Checked before the identity cases: a key-pinned entry has no signer
// identity and no cert issuer, so those would render as empty values and
// read exactly like an untracked install.
case info.Provenance != nil && info.Provenance.PublicKey != "":
_, _ = fmt.Fprintf(w, "Signed by:\t(cosign key pair)\n")
_, _ = fmt.Fprintf(w, "Public key:\t%s\n", info.Provenance.PublicKey)
case info.Provenance != nil && info.Provenance.Provisional:
_, _ = fmt.Fprintf(w, "Signed by:\t%s (provisional)\n", info.Provenance.SignerIdentity)
_, _ = fmt.Fprintf(w, "Cert issuer:\t%s\n", info.Provenance.CertIssuer)
case info.Provenance != nil:
_, _ = fmt.Fprintf(w, "Signed by:\t%s\n", info.Provenance.SignerIdentity)
_, _ = fmt.Fprintf(w, "Cert issuer:\t%s\n", info.Provenance.CertIssuer)
case info.Unsigned:
_, _ = fmt.Fprintf(w, "Signed by:\t(unsigned — explicit exception)\n")
case info.TrustUnrecorded:
_, _ = fmt.Fprintf(w, "Signed by:\t(trust unrecorded — run 'thv ai-plugin sync')\n")
}
}

// formatComponentInventory renders a ComponentInventory (map[string]int) as a
// sorted, space-separated "key=count" sequence for deterministic output.
func formatComponentInventory(inv plugins.ComponentInventory) string {
Expand Down
18 changes: 18 additions & 0 deletions cmd/thv/app/ai_plugin_install.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ var (
aiPluginInstallProjectRoot string
aiPluginInstallGroup string
aiPluginInstallAllowUnsigned bool
aiPluginInstallPublicKey string
)

var aiPluginInstallCmd = &cobra.Command{
Expand Down Expand Up @@ -49,6 +50,10 @@ func init() {
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)")
aiPluginInstallCmd.Flags().StringVar(&aiPluginInstallPublicKey, "public-key", "",
"Path to the cosign public key (cosign.pub) a key-pair-signed plugin must verify against."+
" Required the first time such a plugin is installed project-scoped; the key is then pinned"+
" in the lock file and reused automatically")
}

func aiPluginInstallCmdFunc(cmd *cobra.Command, args []string) error {
Expand All @@ -59,6 +64,14 @@ func aiPluginInstallCmdFunc(cmd *cobra.Command, args []string) error {
return err
}

// Shared with `thv skill install`: the flag names a file, but the API
// carries the key material, because the server may be another process on
// another host where that path names nothing — or something else.
publicKey, err := readInstallPublicKey(aiPluginInstallPublicKey)
if err != nil {
return err
}

result, err := c.Install(cmd.Context(), plugins.InstallOptions{
Name: args[0],
Scope: plugins.Scope(aiPluginInstallScope),
Expand All @@ -67,6 +80,7 @@ func aiPluginInstallCmdFunc(cmd *cobra.Command, args []string) error {
ProjectRoot: projectRoot,
Group: aiPluginInstallGroup,
AllowUnsigned: aiPluginInstallAllowUnsigned,
PublicKey: publicKey,
})
if err != nil {
return formatAIPluginError("install plugin", err)
Expand All @@ -90,6 +104,10 @@ func printPluginInstallTrust(result *plugins.InstallResult) {
}
name := result.Plugin.Metadata.Name
switch {
// Before the identity cases: a key-pinned install has no signer identity
// to name, and "signed by " with nothing after it is worse than silence.
case result.Provenance != nil && result.Provenance.PublicKey != "":
fmt.Printf("Installed %s (signed by a cosign key pair; the pinned public key is in the lock file)\n", name)
case result.Provenance != nil && result.Provenance.Provisional:
fmt.Printf("Installed %s (signed by %s; verification provisional — see lock file)\n",
name, result.Provenance.SignerIdentity)
Expand Down
46 changes: 43 additions & 3 deletions cmd/thv/app/ai_plugin_trust_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,17 @@ import (
"github.com/stacklok/toolhive/pkg/plugins"
)

// testCLIPublicKeyB64 stands in for the base64 DER SPKI a key-pinned lock
// entry records; the CLI renders it verbatim and parses nothing.
const testCLIPublicKeyB64 = "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAExlVDpbnOEv2fH3gS8n7UCHS9Gs0wKxIPR5EAcl8F1jSxlxAV/pll0NsSiuAK95Ws4Fpkn+5QkdVKNXy7LHgb2A=="

// TestAIPluginPushSigningFlags pins the signed-by-default publish surface:
// the keyless flags must exist, the opt-out must not be preset (a defaulted
// --no-sign would publish unsigned artifacts silently), and --key must NOT be
// offered — ToolHive cannot verify key-signed artifacts at install time, so
// the flag would only produce uninstallable plugins (#6442). Re-add it in the
// change that makes key verification work.
// the flag would only produce uninstallable plugins (#6442). Install-time key
// verification now exists; re-add the flag in the change that restores plugin
// push signing, which is what closes #6442.
func TestAIPluginPushSigningFlags(t *testing.T) {
t.Parallel()

Expand All @@ -27,12 +32,24 @@ func TestAIPluginPushSigningFlags(t *testing.T) {
require.NotNil(t, flag, "thv ai-plugin push must expose --%s", name)
}
assert.Nil(t, aiPluginPushCmd.Flags().Lookup("key"),
"plugin signing is keyless-only; --key must not be advertised until install can verify it")
"plugin push is still keyless-only; --key returns with the push half of #6442")
assert.Equal(t, "false", aiPluginPushCmd.Flags().Lookup("no-sign").DefValue,
"pushing unsigned must always be an explicit choice")
assert.Empty(t, aiPluginPushCmd.Flags().Lookup("identity-token").DefValue)
}

// TestAIPluginInstallKeyFlag pins the consuming half of key signing: without
// --public-key on install there is no way to supply the trust anchor a
// key-signed artifact needs, since the key is recoverable from neither the
// artifact nor its bundle.
func TestAIPluginInstallKeyFlag(t *testing.T) {
t.Parallel()

flag := aiPluginInstallCmd.Flags().Lookup("public-key")
require.NotNil(t, flag, "thv ai-plugin install must expose --public-key")
assert.Empty(t, flag.DefValue, "there is no default trust anchor to assume")
}

// TestPrintAIPluginInfoTextTrustStates covers each trust state the info
// command renders. RFC THV-0080 wants the pinned identity visible at read
// time, so a state that silently renders as "no trust block" is a bug.
Expand All @@ -59,6 +76,20 @@ func TestPrintAIPluginInfoTextTrustStates(t *testing.T) {
},
wantAbsent: []string{"provisional", "unsigned"},
},
{
// A key-pinned entry has no signer identity and no cert issuer, so
// the identity rendering would print empty values and read exactly
// like an untracked install.
name: "key pinned",
info: plugins.PluginInfo{Provenance: &plugins.ProvenanceInfo{
PublicKey: testCLIPublicKeyB64,
}},
wantLines: []string{
"Signed by: (cosign key pair)",
"Public key: " + testCLIPublicKeyB64,
},
wantAbsent: []string{"Cert issuer", "provisional", "unsigned"},
},
{
name: "provisional",
info: plugins.PluginInfo{Provenance: &plugins.ProvenanceInfo{
Expand Down Expand Up @@ -130,6 +161,15 @@ func TestPrintPluginInstallTrust(t *testing.T) {
},
want: "Installed my-plugin (signed by /.github/workflows/release.yml)\n",
},
{
name: "key pinned",
result: &plugins.InstallResult{
Plugin: plugins.InstalledPlugin{Metadata: plugins.PluginMetadata{Name: "my-plugin"}},
Provenance: &plugins.ProvenanceInfo{PublicKey: testCLIPublicKeyB64},
},
want: "Installed my-plugin (signed by a cosign key pair; " +
"the pinned public key is in the lock file)\n",
},
{
name: "provisional",
result: &plugins.InstallResult{
Expand Down
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.

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

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

1 change: 1 addition & 0 deletions pkg/api/v1/plugins.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ func (s *PluginsRoutes) installPlugin(w http.ResponseWriter, r *http.Request) er
Force: req.Force,
Group: req.Group,
AllowUnsigned: req.AllowUnsigned,
PublicKey: req.PublicKey,
})
if err != nil {
return err
Expand Down
41 changes: 41 additions & 0 deletions pkg/api/v1/plugins_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -767,6 +767,47 @@ func TestPluginsInstallLocationHeader(t *testing.T) {
assert.Equal(t, "/api/v1beta/plugins/my-plugin", rec.Header().Get("Location"))
}

// TestPluginsInstallCarriesPublicKey pins the API-side half of the key path: a
// public_key that dies at the handler would leave a key-signed artifact
// failing verification while the caller is told to supply the key they did.
func TestPluginsInstallCarriesPublicKey(t *testing.T) {
t.Parallel()

const encodedKey = "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAExlVDpbnOEv2fH3gS8n7UCHS9Gs0wKxIPR5" +
"EAcl8F1jSxlxAV/pll0NsSiuAK95Ws4Fpkn+5QkdVKNXy7LHgb2A=="

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

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

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

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

require.Equal(t, http.StatusCreated, rec.Code)
assert.Contains(t, rec.Body.String(), `"public_key":"`+encodedKey+`"`,
"the pinned key must come back so the CLI can report the anchor it recorded")
}

// 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
Expand Down
5 changes: 5 additions & 0 deletions pkg/api/v1/plugins_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ type installPluginRequest struct {
// verified signature; the exception is recorded in the project's lock
// file.
AllowUnsigned bool `json:"allow_unsigned,omitempty"`
// PublicKey is the base64-encoded DER SPKI cosign public key the artifact
// must verify against, for artifacts signed with a cosign key pair rather
// than keylessly. Required the first time such an artifact is installed
// project-scoped, and pinned in the lock file from then on.
PublicKey string `json:"public_key,omitempty"`
// Group is the group name to add the plugin to after installation
Group string `json:"group,omitempty"`
}
Expand Down
1 change: 1 addition & 0 deletions pkg/plugins/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ func (c *Client) Install(ctx context.Context, opts plugins.InstallOptions) (*plu
Force: opts.Force,
Group: opts.Group,
AllowUnsigned: opts.AllowUnsigned,
PublicKey: opts.PublicKey,
}

var resp installResponse
Expand Down
28 changes: 28 additions & 0 deletions pkg/plugins/client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1041,6 +1041,34 @@ func TestInstallCarriesAllowUnsigned(t *testing.T) {
assert.True(t, got.AllowUnsigned, "allow_unsigned must reach the server")
}

// TestInstallCarriesPublicKey is the same guard for the cosign public key: the
// CLI reads the key file and sends the material, so a field dropped here would
// make every --public-key install fail as though no key had been given.
func TestInstallCarriesPublicKey(t *testing.T) {
t.Parallel()

const encodedKey = "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAExlVDpbnOEv2fH3gS8n7UCHS9Gs0wKxIPR5" +
"EAcl8F1jSxlxAV/pll0NsSiuAK95Ws4Fpkn+5QkdVKNXy7LHgb2A=="

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",
PublicKey: encodedKey,
})
require.NoError(t, err)
assert.Equal(t, encodedKey, got.PublicKey, "public_key must reach the server")
}

// TestInstallReturnsTrustState guards the response half of the same DTO
// boundary: the CLI is a pure HTTP client, so a provenance block dropped
// here would make every signed install print as if it were untracked.
Expand Down
4 changes: 4 additions & 0 deletions pkg/plugins/client/dto.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ type installRequest struct {
// AllowUnsigned mirrors plugins.InstallOptions.AllowUnsigned; without
// it here the CLI flag would silently never reach the server.
AllowUnsigned bool `json:"allow_unsigned,omitempty"`
// PublicKey mirrors plugins.InstallOptions.PublicKey: the base64 DER SPKI
// the CLI encoded from the --public-key file, since a path would not
// resolve on a server in another process or on another host.
PublicKey string `json:"public_key,omitempty"`
}

type validateRequest struct {
Expand Down
9 changes: 9 additions & 0 deletions pkg/plugins/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,15 @@ type InstallOptions struct {
// 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"`
// PublicKey is the base64-encoded DER SPKI cosign public key a
// project-scoped install must verify the artifact against, for artifacts
// signed with a cosign key pair rather than keylessly. Required on true
// first use of such an artifact — the signing key is recoverable from
// neither the artifact nor its bundle, so nothing else can supply the
// trust anchor — and pinned into the lock entry, which supplies it on
// every install thereafter. A value that conflicts with what the lock
// already pins is rejected, never ignored.
PublicKey string `json:"public_key,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
10 changes: 10 additions & 0 deletions pkg/plugins/pluginsvc/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,16 @@ func (s *service) install(
opts.LockSource = opts.Name
}

// Checked here, before any resolve or fetch work: this is the only path a
// caller-supplied public key arrives through, and rejecting it now means a
// key that could never be used is reported as bad input rather than as a
// verification failure after the artifact has been pulled. Lock-driven
// callers (sync, upgrade) never set it — they verify against the key the
// lock records.
if err := validateInstallPublicKey(opts, scope); err != nil {
return nil, err
}

// Git references are dispatched first; the prefix is unambiguous and
// cannot collide with OCI references. installFromGit holds the per-plugin
// lock across extraction, DB, group, lock-file, and rollback unless the
Expand Down
Loading
Loading