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
8 changes: 8 additions & 0 deletions cmd/thv/app/ai_plugin_sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ var (
aiPluginSyncPrune bool
aiPluginSyncYes bool
aiPluginSyncAllowUnsigned bool
aiPluginSyncPublicKey string
aiPluginSyncFormat string
)

Expand Down Expand Up @@ -63,6 +64,8 @@ func init() {
"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")
aiPluginSyncCmd.Flags().StringVar(&aiPluginSyncPublicKey, "public-key", "",
"Path to the cosign public key used to verify key-pair-signed plugins during --adopt")
AddFormatFlag(aiPluginSyncCmd, &aiPluginSyncFormat)
}

Expand All @@ -71,6 +74,10 @@ func aiPluginSyncCmdFunc(cmd *cobra.Command, _ []string) error {
if err != nil {
return err
}
publicKey, err := readInstallPublicKey(aiPluginSyncPublicKey)
if err != nil {
return err
}

if !aiPluginSyncCheck {
if !aiPluginSyncYes {
Expand All @@ -94,6 +101,7 @@ func aiPluginSyncCmdFunc(cmd *cobra.Command, _ []string) error {
Adopt: aiPluginSyncAdopt,
Prune: aiPluginSyncPrune,
AllowUnsigned: aiPluginSyncAllowUnsigned,
PublicKey: publicKey,
})
if err != nil {
return formatAIPluginError("sync plugins", err)
Expand Down
31 changes: 21 additions & 10 deletions cmd/thv/app/ai_plugin_sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,30 @@ import (
"github.com/stretchr/testify/require"
)

// TestAIPluginSyncAllowUnsignedFlag pins the flag that carries the unsigned
// trust decision into sync: adopting an install whose signature state cannot
// be established is rejected unless the user opts in here, and the flag must
// be bound to the variable the command threads into plugins.SyncOptions.
// TestAIPluginSyncTrustFlags pins the flags that carry explicit adoption trust
// decisions into plugins.SyncOptions.
//
//nolint:paralleltest // sets the command's package-level flag variable
func TestAIPluginSyncAllowUnsignedFlag(t *testing.T) {
flag := aiPluginSyncCmd.Flags().Lookup("allow-unsigned")
require.NotNil(t, flag, "thv ai-plugin sync must expose --allow-unsigned")
assert.Equal(t, "false", flag.DefValue, "unsigned adoption must never be the default")
func TestAIPluginSyncTrustFlags(t *testing.T) {
unsignedFlag := aiPluginSyncCmd.Flags().Lookup("allow-unsigned")
require.NotNil(t, unsignedFlag, "thv ai-plugin sync must expose --allow-unsigned")
assert.Equal(t, "false", unsignedFlag.DefValue, "unsigned adoption must never be the default")
publicKeyFlag := aiPluginSyncCmd.Flags().Lookup("public-key")
require.NotNil(t, publicKeyFlag, "thv ai-plugin sync must expose --public-key")
assert.Empty(t, publicKeyFlag.DefValue)

t.Cleanup(func() { aiPluginSyncAllowUnsigned = false })
require.NoError(t, flag.Value.Set("true"))
t.Cleanup(func() {
aiPluginSyncAllowUnsigned = false
aiPluginSyncPublicKey = ""
require.NoError(t, unsignedFlag.Value.Set("false"))
require.NoError(t, publicKeyFlag.Value.Set(""))
unsignedFlag.Changed = false
publicKeyFlag.Changed = false
})
require.NoError(t, unsignedFlag.Value.Set("true"))
require.NoError(t, publicKeyFlag.Value.Set("cosign.pub"))
assert.True(t, aiPluginSyncAllowUnsigned,
"--allow-unsigned must bind to the variable sync passes to the service")
assert.Equal(t, "cosign.pub", aiPluginSyncPublicKey,
"--public-key must bind to the path sync reads and encodes before the HTTP request")
}
25 changes: 18 additions & 7 deletions cmd/thv/app/ai_plugin_upgrade.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import (
"github.com/spf13/cobra"

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

var (
Expand All @@ -20,6 +19,7 @@ var (
aiPluginUpgradeFailOnChanges bool
aiPluginUpgradeAllowRefChange bool
aiPluginUpgradeAllowSignerChange bool
aiPluginUpgradePublicKey string
aiPluginUpgradeYes bool
aiPluginUpgradeFormat string
)
Expand All @@ -29,8 +29,9 @@ var aiPluginUpgradeCmd = &cobra.Command{
Short: "Upgrade project plugins to newer pinned content",
Long: `Re-resolve a project's lock entries and install newer content where available.
Plugins pinned to an immutable reference (an OCI digest or a full git commit
hash) are reported not-upgradable — there is nothing newer to resolve to.
Plugins pinned to a full git commit hash are not upgradable. OCI digest content
is also immutable, but --allow-signer-change --public-key can evaluate its
separately attached signatures for a trust-only update.
Use --preview to see what would change without persisting anything (OCI
sources are still fetched into the local artifact store to compare digests),
and --allow-ref-change to permit the artifact moving to a different
Expand Down Expand Up @@ -62,6 +63,8 @@ func init() {
"Report what would change without installing anything; a CI freshness gate")
aiPluginUpgradeCmd.Flags().BoolVar(&aiPluginUpgradeAllowSignerChange, "allow-signer-change", false,
"Permit upgrading to an artifact signed by a different identity; the new identity replaces the recorded one")
aiPluginUpgradeCmd.Flags().StringVar(&aiPluginUpgradePublicKey, "public-key", "",
"Path to a cosign public key proposed as the replacement trust anchor (requires --allow-signer-change)")
aiPluginUpgradeCmd.Flags().BoolVar(&aiPluginUpgradeAllowRefChange, "allow-ref-change", false,
"Permit the artifact to move to a different repository during upgrade")
aiPluginUpgradeCmd.Flags().BoolVar(&aiPluginUpgradeYes, "yes", false,
Expand All @@ -74,6 +77,10 @@ func aiPluginUpgradeCmdFunc(cmd *cobra.Command, args []string) error {
if err != nil {
return err
}
publicKey, err := readInstallPublicKey(aiPluginUpgradePublicKey)
if err != nil {
return err
}

if !aiPluginUpgradePreview && !aiPluginUpgradeFailOnChanges {
if !aiPluginUpgradeYes {
Expand All @@ -98,6 +105,7 @@ func aiPluginUpgradeCmdFunc(cmd *cobra.Command, args []string) error {
FailOnChanges: aiPluginUpgradeFailOnChanges,
AllowRefChange: aiPluginUpgradeAllowRefChange,
AllowSignerChange: aiPluginUpgradeAllowSignerChange,
PublicKey: publicKey,
})
if err != nil {
return formatAIPluginError("upgrade plugins", err)
Expand Down Expand Up @@ -159,10 +167,13 @@ func printPluginUpgradeResult(result *plugins.UpgradeResult, format string, plan
for _, o := range result.Outcomes {
switch o.Status {
case plugins.UpgradeStatusUpgraded:
fmt.Printf("%s: %s %s -> %s\n", o.Name, upgradedVerb, o.OldDigest, o.NewDigest)
case skills.UpgradeStatusTrustUpdated:
// Plugin upgrade does not produce this status yet. Handle it because
// plugins aliases the shared skills outcome type.
if o.TrustAnchorChanged {
fmt.Printf("%s: %s %s -> %s (trust anchor changed)\n",
o.Name, upgradedVerb, o.OldDigest, o.NewDigest)
} else {
fmt.Printf("%s: %s %s -> %s\n", o.Name, upgradedVerb, o.OldDigest, o.NewDigest)
}
case plugins.UpgradeStatusTrustUpdated:
printTrustUpdateOutcome(o, planOnly)
case plugins.UpgradeStatusUpToDate:
fmt.Printf("%s: up to date\n", o.Name)
Expand Down
80 changes: 71 additions & 9 deletions cmd/thv/app/ai_plugin_upgrade_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ func TestPluginUpgradeExitError(t *testing.T) {
outcomes: []plugins.UpgradeOutcome{{Name: "a", Status: plugins.UpgradeStatusUpToDate}},
wantCode: 0,
},
{
name: "trust-only update is not a failure",
outcomes: []plugins.UpgradeOutcome{{Name: "a", Status: plugins.UpgradeStatusTrustUpdated}},
wantCode: 0,
},
{
name: "signer change blocked is a policy rejection",
outcomes: []plugins.UpgradeOutcome{{Name: "a", Status: plugins.UpgradeStatusSignerChangeBlocked}},
Expand All @@ -44,6 +49,12 @@ func TestPluginUpgradeExitError(t *testing.T) {
failOnChanges: true,
wantCode: ExitCodeCheckFailure,
},
{
name: "trust-only update counts as would-change under fail-on-changes",
outcomes: []plugins.UpgradeOutcome{{Name: "a", Status: plugins.UpgradeStatusTrustUpdated}},
failOnChanges: true,
wantCode: ExitCodeCheckFailure,
},
{
// A genuine failure must never be masked by a guard doing its job.
name: "a failure outranks a signer-change block",
Expand Down Expand Up @@ -74,24 +85,32 @@ func TestPluginUpgradeExitError(t *testing.T) {
}
}

// TestPluginUpgradeAllowSignerChangeFlag pins the flag name the blocked-status
// message tells users to pass, and its binding to the variable the upgrade
// options are built from.
// TestPluginUpgradeTrustFlags pins the consent and replacement-key flags and
// their bindings to the variables used to build plugins.UpgradeOptions.
//
//nolint:paralleltest // mutates the command's package-level flag state
func TestPluginUpgradeAllowSignerChangeFlag(t *testing.T) {
flag := aiPluginUpgradeCmd.Flags().Lookup("allow-signer-change")
require.NotNil(t, flag, "the status message promises --allow-signer-change")
assert.Equal(t, "false", flag.DefValue, "a signer rotation is never permitted by default")
func TestPluginUpgradeTrustFlags(t *testing.T) {
signerFlag := aiPluginUpgradeCmd.Flags().Lookup("allow-signer-change")
require.NotNil(t, signerFlag, "the status message promises --allow-signer-change")
assert.Equal(t, "false", signerFlag.DefValue, "a signer rotation is never permitted by default")
publicKeyFlag := aiPluginUpgradeCmd.Flags().Lookup("public-key")
require.NotNil(t, publicKeyFlag, "thv ai-plugin upgrade must expose --public-key")
assert.Empty(t, publicKeyFlag.DefValue)

t.Cleanup(func() {
aiPluginUpgradeAllowSignerChange = false
require.NoError(t, flag.Value.Set("false"))
flag.Changed = false
aiPluginUpgradePublicKey = ""
require.NoError(t, signerFlag.Value.Set("false"))
require.NoError(t, publicKeyFlag.Value.Set(""))
signerFlag.Changed = false
publicKeyFlag.Changed = false
})
require.NoError(t, aiPluginUpgradeCmd.Flags().Set("allow-signer-change", "true"))
require.NoError(t, aiPluginUpgradeCmd.Flags().Set("public-key", "cosign.pub"))
assert.True(t, aiPluginUpgradeAllowSignerChange,
"the flag must bind to the variable threaded into plugins.UpgradeOptions")
assert.Equal(t, "cosign.pub", aiPluginUpgradePublicKey,
"the key flag must bind to the path upgrade reads and encodes before the HTTP request")
}

// TestPrintPluginUpgradeResultSignerRendering pins the distinction the
Expand Down Expand Up @@ -152,3 +171,46 @@ func TestPrintPluginUpgradeResultSignerRendering(t *testing.T) {
})
}
}

//nolint:paralleltest // captures os.Stdout
func TestPrintPluginUpgradeResultTrustChanges(t *testing.T) {
tests := []struct {
name string
outcome plugins.UpgradeOutcome
planOnly bool
want string
}{
{
name: "applied trust-only update",
outcome: plugins.UpgradeOutcome{
Name: "keyed-plugin", Status: plugins.UpgradeStatusTrustUpdated, OldDigest: "sha256:same",
},
want: "keyed-plugin: updated trust metadata (content remains at sha256:same; verification material refreshed)\n",
},
{
name: "preview trust-only update",
outcome: plugins.UpgradeOutcome{
Name: "keyed-plugin", Status: plugins.UpgradeStatusTrustUpdated, OldDigest: "sha256:same",
},
planOnly: true,
want: "keyed-plugin: would update trust metadata (content remains at sha256:same; verification material refreshed)\n",
},
{
name: "content and trust update",
outcome: plugins.UpgradeOutcome{
Name: "keyed-plugin", Status: plugins.UpgradeStatusUpgraded,
OldDigest: "sha256:old", NewDigest: "sha256:new", TrustAnchorChanged: true,
},
want: "keyed-plugin: upgraded sha256:old -> sha256:new (trust anchor changed)\n",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := captureStdout(t, func() {
require.NoError(t, printPluginUpgradeResult(
&plugins.UpgradeResult{Outcomes: []plugins.UpgradeOutcome{tc.outcome}}, FormatText, tc.planOnly))
})
assert.Equal(t, tc.want, got)
})
}
}
66 changes: 53 additions & 13 deletions docs/arch/14-plugins-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,17 @@ and credential-bearing requests do not follow redirects.

### 4. Installation

After resolution, the service verifies project-scoped content, acquires a
per-plugin lock, materializes each target client, persists the installed record,
User-scoped operations acquire a per-plugin lock. Project-scoped install,
uninstall, sync, and upgrade share the skills project transaction keyed by the
canonical project root. Its in-process mutex and advisory state lock cover the
whole mutation, including resolution, materialization, persistence, group and
dependency changes, lock-file writes, and compensation. The advisory lock
retains the historical `skills-project-locks` state-directory name so plugin
mutations coordinate with older ToolHive processes that only know the skills
implementation.

After acquiring the applicable lock, installation verifies project-scoped
content, materializes each target client, persists the installed record,
updates group membership, and writes the project lock entry. Existing trees and
registration state are snapshotted when the client manager is available. A
later extraction, database, group, or lock-file failure restores prior files,
Expand Down Expand Up @@ -170,7 +179,9 @@ atomic lock-file mechanics are documented in [Project Lock File](12-skills-syste

- missing or drifted installs are reinstalled at the pinned digest;
- `--check` reports drift without writing;
- `--adopt` records eligible unmanaged project installs;
- `--adopt` records eligible unmanaged project installs, and
`--public-key <PUBLIC_KEY_PATH>` verifies and anchors a key-pair-signed OCI
install during adoption;
- `--prune` removes managed installs absent from the lock file;
- real changes require confirmation, or `--yes` in non-interactive use.

Expand All @@ -180,12 +191,32 @@ entries have no stored bundle to re-verify offline; the recorded identity is
rechecked when git content is resolved again.

`thv ai-plugin upgrade [name...]` re-resolves each mutable lock source and
installs changed content. Immutable OCI digests and full git commit pins are
reported as not upgradable. `--preview` and `--fail-on-changes` plan without
installing (OCI candidates are still fetched to compare digests).
`--allow-ref-change` permits a repository move, while
`--allow-signer-change` permits supported trust transitions. Neither option
silently replaces a pinned cosign public key with an arbitrary different key.
installs changed content. Full git commit pins are not upgradable. An immutable
OCI digest has no content update, but its separately attached signatures can
still produce a trust-only update when you pass
`--allow-signer-change --public-key <PUBLIC_KEY_PATH>`. `--preview` and
`--fail-on-changes` plan content and trust changes without installing. OCI
candidates are still fetched because there is no digest-only planning
primitive.

`--allow-ref-change` permits a repository move. Even when the digest is
unchanged, an allowed move follows the complete pinned install path so that the
installed record, resolved reference, signature bundle, and trust decision
remain aligned. `--allow-signer-change` permits supported trust transitions.
For a key-pair re-anchor, it must be combined with
`--public-key <PUBLIC_KEY_PATH>`; a replacement key without signer-change
consent is rejected. Public-key re-anchoring applies only to OCI registry
artifacts, so Git and local-store entries selected by the same request report a
validation failure; target OCI plugins by name when a project mixes source
types. Upgrade tries the recorded key first and retains it when it verifies.
Only a conclusive mismatch allows the replacement key or the existing keyless
transition policy to be considered. Registry, transport, and context failures
do not count as signer evidence and leave the old anchor unchanged.

A successful signature-only refresh records `trust-updated`; changing the
anchor records `trust_anchor_changed`. Both update the stored bundle and lock
trust state without reinstalling unchanged content. The lock update is
compare-and-swap protected against a concurrently changed plan.
An unsigned candidate under an entry that records a signer is not a trust
transition but a failure (`unsigned-rejected`): upgrade has no unsigned-consent
flag, and `--allow-signer-change` re-verifies from scratch, which an unsigned
Expand Down Expand Up @@ -237,10 +268,19 @@ For a key-pair-signed OCI artifact, the first project install requires
SPKI representation in the lock entry. A registry entry with any supported
catalog provenance constraint cannot be installed with `--public-key` because
a key-pair signature carries no certificate identity that can satisfy the
catalog policy. Sync and upgrade reuse the pinned key; they do not take another
key flag. A different key cannot be auto-adopted or substituted with
`--allow-signer-change`: changing to an arbitrary key requires removing the
existing lock anchor and reinstalling explicitly.
catalog policy. Sync normally reuses the pinned key. An unmanaged OCI install
can be adopted with `sync --adopt --public-key <PUBLIC_KEY_PATH>` after its
stored bundle and installed digest verify against that key. Upgrade can replace
an existing anchor only when the caller explicitly combines
`--allow-signer-change` with the new public key and the candidate verifies
against it.

Re-anchor decisions use one complete OCI signature snapshot. ToolHive first
tests the recorded key against that snapshot, then considers a replacement
anchor only after a conclusive mismatch. Discovery or retrieval failures abort
the decision instead of presenting a partial bundle set as proof of a signer
change. Because OCI signature attachments can change independently of content,
the same rule applies to digest-pinned and same-digest upgrades.

The [skills trust tiers](12-skills-system.md#trust-tiers) explain why key-pair
signing provides lower assurance than keyless signing; the same limits apply to
Expand Down
1 change: 1 addition & 0 deletions docs/cli/thv_ai-plugin_sync.md

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

6 changes: 4 additions & 2 deletions docs/cli/thv_ai-plugin_upgrade.md

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

Loading
Loading