Skip to content
Open
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
12 changes: 12 additions & 0 deletions cmd/thv/app/ai_plugin_info.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,18 @@ 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")
}
_, _ = fmt.Fprintf(w, "Description:\t%s\n", info.Metadata.Description)

if s := info.InstalledPlugin; s != nil {
Expand Down
29 changes: 28 additions & 1 deletion cmd/thv/app/ai_plugin_install.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
package app

import (
"fmt"

"github.com/spf13/cobra"

"github.com/stacklok/toolhive/pkg/plugins"
Expand Down Expand Up @@ -57,7 +59,7 @@ func aiPluginInstallCmdFunc(cmd *cobra.Command, args []string) error {
return err
}

_, err = c.Install(cmd.Context(), plugins.InstallOptions{
result, err := c.Install(cmd.Context(), plugins.InstallOptions{
Name: args[0],
Scope: plugins.Scope(aiPluginInstallScope),
Clients: parseSkillInstallClients(aiPluginInstallClientsRaw),
Expand All @@ -70,5 +72,30 @@ func aiPluginInstallCmdFunc(cmd *cobra.Command, args []string) error {
return formatAIPluginError("install plugin", err)
}

printPluginInstallTrust(result)
return nil
}

// printPluginInstallTrust shows the trust state the install recorded — RFC
// THV-0080 wants the pinned identity displayed prominently, not discovered
// weeks later inside a signer-mismatch error.
//
// Only a recorded trust decision is printed. An install with neither — a
// user-scope install, which writes no lock entry — returns silently, per the
// CLI's silent-success rule: a bare "Installed <name>" carries no trust
// information and would turn every previously quiet install into output.
func printPluginInstallTrust(result *plugins.InstallResult) {
if result == nil {
return
}
name := result.Plugin.Metadata.Name
switch {
case result.Provenance != nil && result.Provenance.Provisional:
fmt.Printf("Installed %s (signed by %s; verification provisional — see lock file)\n",
name, result.Provenance.SignerIdentity)
case result.Provenance != nil:
fmt.Printf("Installed %s (signed by %s)\n", name, result.Provenance.SignerIdentity)
case result.Unsigned:
fmt.Printf("Installed %s (unsigned — recorded as an explicit exception in the lock file)\n", name)
}
}
40 changes: 37 additions & 3 deletions cmd/thv/app/ai_plugin_push.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ import (
"github.com/spf13/cobra"

"github.com/stacklok/toolhive/pkg/plugins"
"github.com/stacklok/toolhive/pkg/skills/identitytoken"
)

var (
aiPluginPushIdentityToken string
aiPluginPushNoSign bool
)

var aiPluginPushCmd = &cobra.Command{
Expand All @@ -19,13 +25,41 @@ var aiPluginPushCmd = &cobra.Command{

func init() {
aiPluginCmd.AddCommand(aiPluginPushCmd)
// No --key flag: plugin signing is keyless-only until install-time key
// verification exists (#6442). Pushing a key-signed plugin would produce
// an artifact no project-scoped install can accept.
aiPluginPushCmd.Flags().StringVar(&aiPluginPushIdentityToken, "identity-token", "",
"OIDC identity token (or a path to a file containing one) for keyless signing. "+
"If omitted, one is acquired automatically: from the GitHub Actions OIDC token when "+
"running with id-token: write permission, otherwise via an interactive browser sign-in")
aiPluginPushCmd.Flags().BoolVar(&aiPluginPushNoSign, "no-sign", false,
"Push without signing (consumers will need an explicit unsigned exception to install project-scoped)")
}

func aiPluginPushCmdFunc(cmd *cobra.Command, args []string) error {
c := newAIPluginClient(cmd.Context())
ctx := cmd.Context()

// Shared with `thv skill push`: the acquisition ladder (explicit flag →
// ambient CI token → interactive browser sign-in) is a property of
// Sigstore keyless signing, not of the artifact kind being pushed.
token, err := identitytoken.Acquire(ctx, identitytoken.Options{

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.

This shared acquisition helper's terminal error still recommends --key, but this command deliberately has no --key flag. A non-interactive push without ambient OIDC is therefore sent to an impossible remediation. Give plugin push a credential error containing only valid choices (--identity-token, CI id-token: write, or --no-sign), while retaining the shared acquisition mechanics.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in e5452d7ab.

ErrNoCredential no longer carries the flag list. It is now the bare sentinel, and the remediation comes from the calling command via Options.Remediation, so plugin push offers --identity-token, CI id-token: write, and --no-sign — and never --key. Skill push keeps its full list including --key. The shared acquisition mechanics are untouched, as you asked.

ErrNoCredential remains the wrapped sentinel so errors.Is keeps working. Remediation is optional rather than required: I first made it mandatory and it forced the field on the several Acquire paths that can never reach that error, which was noise. Omitting it yields the bare sentinel — terser, but never pointing at a flag the command does not define.

Also changed the --identity-token help text from "ambient CI OIDC token" to "GitHub Actions OIDC token", per your second point.

Covered by an E2E case in 4941e8d51 that runs a non-interactive push with no credential and asserts the message contains --identity-token and --no-sign but not --key.

FlagValue: aiPluginPushIdentityToken,
NoSign: aiPluginPushNoSign,
Confirm: confirmBrowserSignIn,
// No --key: plugin signing is keyless-only (#6442), so the
// remediation must not offer a flag this command does not define.
Remediation: "Provide --identity-token, run in CI with id-token: write permission, " +
"or pass --no-sign to push unsigned",
})
if err != nil {
return formatAIPluginError("push plugin", err)
}

err := c.Push(cmd.Context(), plugins.PushOptions{
Reference: args[0],
c := newAIPluginClient(ctx)
err = c.Push(ctx, plugins.PushOptions{
Reference: args[0],
IdentityToken: token,
NoSign: aiPluginPushNoSign,
})
if err != nil {
return formatAIPluginError("push plugin", err)
Expand Down
8 changes: 4 additions & 4 deletions cmd/thv/app/ai_plugin_sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,7 @@ Use --adopt to record lock entries for existing unmanaged installs, and

Unless --check is set, sync prompts for confirmation before installing —
plugin content is a set of AI-followed instructions. Pass --yes to skip the
prompt (required in non-interactive contexts such as CI).

Requires TOOLHIVE_PLUGINS_LOCK_ENABLED=true.`,
prompt (required in non-interactive contexts such as CI).`,
PreRunE: chainPreRunE(
ValidateFormat(&aiPluginSyncFormat),
),
Expand All @@ -62,7 +60,9 @@ func init() {
aiPluginSyncCmd.Flags().BoolVar(&aiPluginSyncYes, "yes", false,
"Skip the confirmation prompt (required when not running interactively)")
aiPluginSyncCmd.Flags().BoolVar(&aiPluginSyncAllowUnsigned, "allow-unsigned", false,
"Allow adopting plugins whose signature state cannot be established (recorded as unsigned)")
"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")
AddFormatFlag(aiPluginSyncCmd, &aiPluginSyncFormat)
}

Expand Down
173 changes: 173 additions & 0 deletions cmd/thv/app/ai_plugin_trust_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package app

import (
"strings"
"testing"

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

"github.com/stacklok/toolhive/pkg/plugins"
)

// 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.
func TestAIPluginPushSigningFlags(t *testing.T) {
t.Parallel()

for _, name := range []string{"identity-token", "no-sign"} {
flag := aiPluginPushCmd.Flags().Lookup(name)
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")
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)
}

// 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.
//
//nolint:paralleltest // captures os.Stdout, which cannot be done in parallel
func TestPrintAIPluginInfoTextTrustStates(t *testing.T) {
signed := &plugins.ProvenanceInfo{
SignerIdentity: "/.github/workflows/release.yml",
CertIssuer: "https://token.actions.githubusercontent.com",
}

tests := []struct {
name string
info plugins.PluginInfo
wantLines []string
wantAbsent []string
}{
{
name: "signed",
info: plugins.PluginInfo{Provenance: signed},
wantLines: []string{
"Signed by: /.github/workflows/release.yml",
"Cert issuer: https://token.actions.githubusercontent.com",
},
wantAbsent: []string{"provisional", "unsigned"},
},
{
name: "provisional",
info: plugins.PluginInfo{Provenance: &plugins.ProvenanceInfo{
SignerIdentity: signed.SignerIdentity,
CertIssuer: signed.CertIssuer,
Provisional: true,
}},
wantLines: []string{"Signed by: /.github/workflows/release.yml (provisional)"},
},
{
name: "unsigned exception",
info: plugins.PluginInfo{Unsigned: true},
wantLines: []string{"Signed by: (unsigned — explicit exception)"},
wantAbsent: []string{"Cert issuer"},
},
{
// The state sync reports as drift. It must read differently from
// "no lock entry", which prints no trust line at all, and it has
// to name the command that repairs it.
name: "trust unrecorded",
info: plugins.PluginInfo{TrustUnrecorded: true},
wantLines: []string{"Signed by: (trust unrecorded — run 'thv ai-plugin sync')"},
wantAbsent: []string{"Cert issuer", "explicit exception"},
},
{
name: "no lock entry",
info: plugins.PluginInfo{},
wantAbsent: []string{"Signed by", "Cert issuer"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.info.Metadata = plugins.PluginMetadata{Name: "my-plugin", Version: "1.0.0"}

// The tabwriter pads labels to the widest one in the block, which
// differs per case; collapse runs of spaces so the assertions
// pin the content rather than the alignment.
out := strings.Join(strings.Fields(
captureStdout(t, func() { printAIPluginInfoText(&tt.info) }),
), " ")

for _, want := range tt.wantLines {
assert.Contains(t, out, want)
}
for _, absent := range tt.wantAbsent {
assert.NotContains(t, out, absent)
}
})
}
}

// TestPrintPluginInstallTrust proves install reports the trust state it
// recorded, rather than leaving the user to discover the pinned identity
// weeks later inside a signer-mismatch error.
//
//nolint:paralleltest // captures os.Stdout, which cannot be done in parallel
func TestPrintPluginInstallTrust(t *testing.T) {
tests := []struct {
name string
result *plugins.InstallResult
want string
}{
{
name: "signed",
result: &plugins.InstallResult{
Plugin: plugins.InstalledPlugin{Metadata: plugins.PluginMetadata{Name: "my-plugin"}},
Provenance: &plugins.ProvenanceInfo{SignerIdentity: "/.github/workflows/release.yml"},
},
want: "Installed my-plugin (signed by /.github/workflows/release.yml)\n",
},
{
name: "provisional",
result: &plugins.InstallResult{
Plugin: plugins.InstalledPlugin{Metadata: plugins.PluginMetadata{Name: "my-plugin"}},
Provenance: &plugins.ProvenanceInfo{
SignerIdentity: "/.github/workflows/release.yml",
Provisional: true,
},
},
want: "Installed my-plugin (signed by /.github/workflows/release.yml; " +
"verification provisional — see lock file)\n",
},
{
name: "unsigned exception",
result: &plugins.InstallResult{
Plugin: plugins.InstalledPlugin{Metadata: plugins.PluginMetadata{Name: "my-plugin"}},
Unsigned: true,
},
want: "Installed my-plugin (unsigned — recorded as an explicit exception in the lock file)\n",
},
{
// A user-scope install records no lock trust decision, so there
// is nothing trust-related to report and the CLI's silent-success
// rule applies: a bare "Installed my-plugin" would turn every
// previously quiet install into output while saying nothing about
// trust.
name: "user scope, no trust state, prints nothing",
result: &plugins.InstallResult{
Plugin: plugins.InstalledPlugin{Metadata: plugins.PluginMetadata{Name: "my-plugin"}},
},
want: "",
},
{name: "nil result prints nothing", result: nil, want: ""},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, captureStdout(t, func() { printPluginInstallTrust(tt.result) }))
})
}
}
4 changes: 1 addition & 3 deletions cmd/thv/app/ai_plugin_upgrade.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,7 @@ freshness gate.

Unless --preview is set, upgrade prompts for confirmation before installing —
plugin content is a set of AI-followed instructions. Pass --yes to skip the
prompt (required in non-interactive contexts such as CI).

Requires TOOLHIVE_PLUGINS_LOCK_ENABLED=true.`,
prompt (required in non-interactive contexts such as CI).`,
PreRunE: chainPreRunE(
ValidateFormat(&aiPluginUpgradeFormat),
),
Expand Down
2 changes: 2 additions & 0 deletions cmd/thv/app/skill_push.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ func skillPushCmdFunc(cmd *cobra.Command, args []string) error {
Key: skillPushKey,
NoSign: skillPushNoSign,
Confirm: confirmBrowserSignIn,
Remediation: "Provide --key or --identity-token, run in CI with id-token: write permission, " +
"or pass --no-sign to push unsigned",
})
if err != nil {
return formatSkillError("push skill", err)
Expand Down
2 changes: 2 additions & 0 deletions docs/arch/12-skills-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,8 @@ Publishing is signed by default: `thv skill push` requires `--key` (a cosign pri

Only the **keyless** path produces an installable artifact. Install-time verification checks the keyless (Fulcio) trust root, and a cosign key pair carries no certificate to chain to it — nor is the signing public key recoverable from the artifact, since the cosign manifest defines no annotation for it. A project-scoped install of a `--key`-signed artifact is therefore refused, and `--allow-unsigned` does **not** override the refusal: the artifact *is* signed, so it never produces the unsigned verdict that exception applies to. Tracked as [#6442](https://github.com/stacklok/toolhive/issues/6442).

Plugins carry the same trust model over the same lock file: project-scoped plugin installs are recorded under the file's `plugins:` key, verified on the same TOFU/`allow_unsigned`/`allow_signer_change` terms, and published signed-by-default through `thv ai-plugin push`. See [Trust Model](14-plugins-system.md#trust-model) in the plugins document for what differs.

### Schema

```yaml
Expand Down
Loading
Loading