diff --git a/cmd/thv/app/exitcode.go b/cmd/thv/app/exitcode.go
new file mode 100644
index 0000000000..6458fa8b7c
--- /dev/null
+++ b/cmd/thv/app/exitcode.go
@@ -0,0 +1,49 @@
+// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package app
+
+import (
+ "errors"
+ "fmt"
+)
+
+// Exit codes for skill lock operations (RFC THV-0080).
+const (
+ ExitCodeCheckFailure = 2
+ ExitCodePartialFailure = 3
+ ExitCodePolicyRejection = 4
+)
+
+// exitCodeError carries a specific process exit code for the CLI.
+type exitCodeError struct {
+ code int
+ err error
+}
+
+func (e *exitCodeError) Error() string {
+ if e.err == nil {
+ return fmt.Sprintf("exit code %d", e.code)
+ }
+ return e.err.Error()
+}
+
+func (e *exitCodeError) Unwrap() error {
+ return e.err
+}
+
+func newExitCodeError(code int, err error) error {
+ if err == nil {
+ return &exitCodeError{code: code}
+ }
+ return &exitCodeError{code: code, err: err}
+}
+
+// ExitCodeFromError returns a custom exit code when err wraps exitCodeError.
+func ExitCodeFromError(err error) (int, bool) {
+ var ec *exitCodeError
+ if errors.As(err, &ec) {
+ return ec.code, true
+ }
+ return 1, false
+}
diff --git a/cmd/thv/app/skill_confirm.go b/cmd/thv/app/skill_confirm.go
new file mode 100644
index 0000000000..32b9ff5c97
--- /dev/null
+++ b/cmd/thv/app/skill_confirm.go
@@ -0,0 +1,44 @@
+// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package app
+
+import (
+ "bufio"
+ "fmt"
+ "os"
+ "strings"
+
+ "golang.org/x/term"
+)
+
+func confirmAction(prompt string) (bool, error) {
+ fmt.Printf("\n%s [y/N]: ", prompt)
+ reader := bufio.NewReader(os.Stdin)
+ response, err := reader.ReadString('\n')
+ if err != nil {
+ return false, fmt.Errorf("failed to read user input: %w", err)
+ }
+ response = strings.TrimSpace(strings.ToLower(response))
+ return response == "y" || response == "yes", nil
+}
+
+func requireInteractiveConfirmation(yes bool, summaryFn func()) error {
+ if yes {
+ return nil
+ }
+ interactive := term.IsTerminal(int(os.Stdin.Fd()))
+ if !interactive {
+ return newExitCodeError(ExitCodePolicyRejection,
+ fmt.Errorf("non-interactive terminal: pass --yes to proceed without confirmation"))
+ }
+ summaryFn()
+ confirmed, err := confirmAction("Install?")
+ if err != nil {
+ return err
+ }
+ if !confirmed {
+ return newExitCodeError(ExitCodePolicyRejection, fmt.Errorf("operation cancelled"))
+ }
+ return nil
+}
diff --git a/cmd/thv/app/skill_helpers.go b/cmd/thv/app/skill_helpers.go
index 06f0bdc6f4..deaa640ae0 100644
--- a/cmd/thv/app/skill_helpers.go
+++ b/cmd/thv/app/skill_helpers.go
@@ -10,6 +10,7 @@ import (
"github.com/spf13/cobra"
+ tclient "github.com/stacklok/toolhive/pkg/client"
"github.com/stacklok/toolhive/pkg/skills"
skillclient "github.com/stacklok/toolhive/pkg/skills/client"
)
@@ -65,3 +66,18 @@ func validateProjectRootForScope(scopeVar, projectRootVar *string) func(*cobra.C
return nil
}
}
+
+// resolveProjectRoot returns explicit unchanged when non-empty. Otherwise it
+// auto-detects the project root by walking up from the current working
+// directory looking for a .git directory or file, matching the lock file's
+// commit-to-git-root assumption used by "thv skill sync" and "thv skill upgrade".
+func resolveProjectRoot(explicit string) (string, error) {
+ if explicit != "" {
+ return explicit, nil
+ }
+ root, err := tclient.DetectProjectRoot("")
+ if err != nil {
+ return "", fmt.Errorf("%w; run inside a git repository or pass --project-root explicitly", err)
+ }
+ return root, nil
+}
diff --git a/cmd/thv/app/skill_install.go b/cmd/thv/app/skill_install.go
index 57663ec3c2..d502ad62e9 100644
--- a/cmd/thv/app/skill_install.go
+++ b/cmd/thv/app/skill_install.go
@@ -12,11 +12,12 @@ import (
)
var (
- skillInstallScope string
- skillInstallClientsRaw string
- skillInstallForce bool
- skillInstallProjectRoot string
- skillInstallGroup string
+ skillInstallScope string
+ skillInstallClientsRaw string
+ skillInstallForce bool
+ skillInstallProjectRoot string
+ skillInstallGroup string
+ skillInstallAllowUnsigned bool
)
var skillInstallCmd = &cobra.Command{
@@ -42,18 +43,21 @@ func init() {
skillInstallCmd.Flags().BoolVar(&skillInstallForce, "force", false, "Overwrite existing skill directory")
skillInstallCmd.Flags().StringVar(&skillInstallProjectRoot, "project-root", "", "Project root path for project-scoped installs")
skillInstallCmd.Flags().StringVar(&skillInstallGroup, "group", "", "Group to add the skill to after installation")
+ skillInstallCmd.Flags().BoolVar(&skillInstallAllowUnsigned, "allow-unsigned", false,
+ "Permit unsigned artifacts for project scope (recorded in lock as unsigned: true)")
}
func skillInstallCmdFunc(cmd *cobra.Command, args []string) error {
c := newSkillClient(cmd.Context())
_, err := c.Install(cmd.Context(), skills.InstallOptions{
- Name: args[0],
- Scope: skills.Scope(skillInstallScope),
- Clients: parseSkillInstallClients(skillInstallClientsRaw),
- Force: skillInstallForce,
- ProjectRoot: skillInstallProjectRoot,
- Group: skillInstallGroup,
+ Name: args[0],
+ Scope: skills.Scope(skillInstallScope),
+ Clients: parseSkillInstallClients(skillInstallClientsRaw),
+ Force: skillInstallForce,
+ ProjectRoot: skillInstallProjectRoot,
+ Group: skillInstallGroup,
+ AllowUnsigned: skillInstallAllowUnsigned,
})
if err != nil {
return formatSkillError("install skill", err)
diff --git a/cmd/thv/app/skill_push.go b/cmd/thv/app/skill_push.go
index 9edd1c3af3..db399cc2f2 100644
--- a/cmd/thv/app/skill_push.go
+++ b/cmd/thv/app/skill_push.go
@@ -9,6 +9,11 @@ import (
"github.com/stacklok/toolhive/pkg/skills"
)
+var (
+ skillPushKey string
+ skillPushNoSign bool
+)
+
var skillPushCmd = &cobra.Command{
Use: "push [reference]",
Short: "Push a built skill",
@@ -19,13 +24,17 @@ var skillPushCmd = &cobra.Command{
func init() {
skillCmd.AddCommand(skillPushCmd)
+ skillPushCmd.Flags().StringVar(&skillPushKey, "key", "", "Path to cosign private key for signing")
+ skillPushCmd.Flags().BoolVar(&skillPushNoSign, "no-sign", false, "Skip post-push Sigstore signing")
}
func skillPushCmdFunc(cmd *cobra.Command, args []string) error {
c := newSkillClient(cmd.Context())
err := c.Push(cmd.Context(), skills.PushOptions{
- Reference: args[0],
+ Reference: args[0],
+ Key: skillPushKey,
+ SkipSigning: skillPushNoSign,
})
if err != nil {
return formatSkillError("push skill", err)
diff --git a/cmd/thv/app/skill_sync.go b/cmd/thv/app/skill_sync.go
new file mode 100644
index 0000000000..8bc5fb54e8
--- /dev/null
+++ b/cmd/thv/app/skill_sync.go
@@ -0,0 +1,190 @@
+// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package app
+
+import (
+ "encoding/json"
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ "github.com/stacklok/toolhive/pkg/skills"
+)
+
+var (
+ skillSyncProjectRoot string
+ skillSyncClientsRaw string
+ skillSyncPrune bool
+ skillSyncCheck bool
+ skillSyncAdopt bool
+ skillSyncYes bool
+ skillSyncFormat string
+)
+
+var skillSyncCmd = &cobra.Command{
+ Use: "sync",
+ Short: "Install skills exactly as pinned in the project's lock file",
+ Long: `Sync installs every skill pinned in toolhive.lock.yaml at its exact
+recorded digest and verifies contentDigest integrity. Project-scoped skills
+installed outside of the lock file are reported as never-managed or
+removed-from-lock; use --prune to uninstall the latter.
+
+Use --check to verify on-disk content without installing. Use --adopt to write
+lock entries for existing unmanaged installs.
+
+The project root is auto-detected from the current directory (nearest
+enclosing git repository) unless --project-root is given.
+
+Pin history: git log -p -- toolhive.lock.yaml`,
+ PreRunE: chainPreRunE(ValidateFormat(&skillSyncFormat)),
+ RunE: skillSyncCmdFunc,
+}
+
+func init() {
+ skillCmd.AddCommand(skillSyncCmd)
+
+ skillSyncCmd.Flags().StringVar(&skillSyncClientsRaw, "clients", "",
+ `Comma-separated target client apps (e.g. claude-code,opencode), or "all" for every available client`)
+ skillSyncCmd.Flags().BoolVar(&skillSyncPrune, "prune", false,
+ "Uninstall previously lock-managed skills that are no longer in the lock file")
+ skillSyncCmd.Flags().BoolVar(&skillSyncCheck, "check", false,
+ "Verify on-disk content matches the lock file without installing")
+ skillSyncCmd.Flags().BoolVar(&skillSyncAdopt, "adopt", false,
+ "Write lock entries for existing unmanaged project-scope installs")
+ skillSyncCmd.Flags().BoolVar(&skillSyncYes, "yes", false,
+ "Skip the pre-install confirmation prompt")
+ skillSyncCmd.Flags().StringVar(&skillSyncProjectRoot, "project-root", "",
+ "Project root path (auto-detected from the current directory if omitted)")
+ AddFormatFlag(skillSyncCmd, &skillSyncFormat)
+}
+
+func skillSyncCmdFunc(cmd *cobra.Command, _ []string) error {
+ projectRoot, err := resolveProjectRoot(skillSyncProjectRoot)
+ if err != nil {
+ return err
+ }
+
+ c := newSkillClient(cmd.Context())
+ opts := skills.SyncOptions{
+ ProjectRoot: projectRoot,
+ Clients: parseSkillInstallClients(skillSyncClientsRaw),
+ Prune: skillSyncPrune,
+ Check: skillSyncCheck,
+ Adopt: skillSyncAdopt,
+ }
+
+ if skillSyncCheck || skillSyncAdopt {
+ result, err := c.Sync(cmd.Context(), opts)
+ if err != nil {
+ return formatSkillError("sync skills", err)
+ }
+ return finishSyncResult(result, skillSyncFormat)
+ }
+
+ // Two-phase gate: preview with check, then apply.
+ previewOpts := opts
+ previewOpts.Check = true
+ preview, err := c.Sync(cmd.Context(), previewOpts)
+ if err != nil {
+ return formatSkillError("sync skills", err)
+ }
+
+ if err := requireInteractiveConfirmation(skillSyncYes, func() {
+ printSyncPreflight(preview, skillSyncPrune)
+ }); err != nil {
+ return err
+ }
+
+ result, err := c.Sync(cmd.Context(), opts)
+ if err != nil {
+ return formatSkillError("sync skills", err)
+ }
+ return finishSyncResult(result, skillSyncFormat)
+}
+
+func finishSyncResult(result *skills.SyncResult, format string) error {
+ switch format {
+ case FormatJSON:
+ data, jsonErr := json.MarshalIndent(result, "", " ")
+ if jsonErr != nil {
+ return fmt.Errorf("failed to marshal JSON: %w", jsonErr)
+ }
+ fmt.Println(string(data))
+ default:
+ printSyncResultText(result)
+ }
+
+ exitCode := syncExitCode(result)
+ if exitCode != 0 {
+ return newExitCodeError(exitCode, nil)
+ }
+ return nil
+}
+
+func syncExitCode(result *skills.SyncResult) int {
+ if len(result.Failed) > 0 {
+ return ExitCodePartialFailure
+ }
+ if skillSyncCheck && (len(result.Failed) > 0 || len(result.Drifted) > 0) {
+ return ExitCodeCheckFailure
+ }
+ return 0
+}
+
+func printSyncPreflight(result *skills.SyncResult, prune bool) {
+ fmt.Println("Pre-flight summary:")
+ printSkillNameList(" To install/reinstall", append(append([]string{}, result.Drifted...), diffInstalled(result)...))
+ printSkillNameList(" Up to date", result.AlreadyCurrent)
+ printSkillNameList(" Never managed", result.NeverManaged)
+ printSkillNameList(" Removed from lock", result.RemovedFromLock)
+ if prune {
+ printSkillNameList(" Would prune", result.RemovedFromLock)
+ }
+}
+
+func diffInstalled(result *skills.SyncResult) []string {
+ // During check phase, skills needing install appear as failed with content-mismatch
+ // or we rely on non-up-to-date entries. Keep simple: show failed names as pending install.
+ names := make([]string, 0, len(result.Failed))
+ for _, f := range result.Failed {
+ if f.Reason == skills.FailureReasonContentMismatch {
+ names = append(names, f.Name)
+ }
+ }
+ return names
+}
+
+func printSyncResultText(result *skills.SyncResult) {
+ printSkillNameList("Installed", result.Installed)
+ printSkillNameList("Drifted (reinstalled)", result.Drifted)
+ printSkillNameList("Up to date", result.AlreadyCurrent)
+ printSkillNameList("Never managed", result.NeverManaged)
+ printSkillNameList("Removed from lock", result.RemovedFromLock)
+ printSkillNameList("Unmanaged (deprecated)", result.Unmanaged)
+ printSkillNameList("Pruned", result.Pruned)
+ if len(result.Failed) > 0 {
+ fmt.Println("Failed:")
+ for _, f := range result.Failed {
+ if f.Reason != "" {
+ fmt.Printf(" %s (%s): %s\n", f.Name, f.Reason, f.Error)
+ } else {
+ fmt.Printf(" %s: %s\n", f.Name, f.Error)
+ }
+ }
+ }
+ if len(result.Installed) == 0 && len(result.AlreadyCurrent) == 0 && len(result.NeverManaged) == 0 &&
+ len(result.RemovedFromLock) == 0 && len(result.Pruned) == 0 && len(result.Failed) == 0 {
+ fmt.Println("Nothing to sync: lock file is empty")
+ }
+}
+
+func printSkillNameList(label string, names []string) {
+ if len(names) == 0 {
+ return
+ }
+ fmt.Printf("%s:\n", label)
+ for _, name := range names {
+ fmt.Printf(" %s\n", name)
+ }
+}
diff --git a/cmd/thv/app/skill_sync_test.go b/cmd/thv/app/skill_sync_test.go
new file mode 100644
index 0000000000..63669f1cc8
--- /dev/null
+++ b/cmd/thv/app/skill_sync_test.go
@@ -0,0 +1,79 @@
+// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package app
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// nolint:paralleltest // subtests change the process-wide working directory via os.Chdir
+func TestResolveProjectRoot(t *testing.T) {
+ t.Run("explicit value passes through without touching the filesystem", func(t *testing.T) {
+ got, err := resolveProjectRoot("/explicit/path")
+ require.NoError(t, err)
+ assert.Equal(t, "/explicit/path", got)
+ })
+
+ t.Run("empty value auto-detects from the current directory", func(t *testing.T) {
+ repoRoot := t.TempDir()
+ resolved, err := filepath.EvalSymlinks(repoRoot)
+ require.NoError(t, err)
+ require.NoError(t, os.MkdirAll(filepath.Join(resolved, ".git"), 0o755))
+
+ nested := filepath.Join(resolved, "a", "b")
+ require.NoError(t, os.MkdirAll(nested, 0o755))
+
+ orig, err := os.Getwd()
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = os.Chdir(orig) })
+ require.NoError(t, os.Chdir(nested))
+
+ got, err := resolveProjectRoot("")
+ require.NoError(t, err)
+ assert.Equal(t, resolved, got)
+ })
+
+ t.Run("empty value outside a git repo returns a helpful error", func(t *testing.T) {
+ dir := t.TempDir()
+ resolved, err := filepath.EvalSymlinks(dir)
+ require.NoError(t, err)
+
+ orig, err := os.Getwd()
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = os.Chdir(orig) })
+ require.NoError(t, os.Chdir(resolved))
+
+ _, err = resolveProjectRoot("")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "--project-root")
+ })
+}
+
+func TestShortDigest(t *testing.T) {
+ t.Parallel()
+ tests := []struct {
+ name string
+ in string
+ want string
+ }{
+ {name: "empty", in: "", want: ""},
+ {name: "short digest unchanged", in: "sha256:abc", want: "sha256:abc"},
+ {
+ name: "long digest truncated to 19 chars",
+ in: "sha256:abcdef0123456789fedcba9876543210",
+ want: "sha256:abcdef012345",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ assert.Equal(t, tt.want, shortDigest(tt.in))
+ })
+ }
+}
diff --git a/cmd/thv/app/skill_upgrade.go b/cmd/thv/app/skill_upgrade.go
new file mode 100644
index 0000000000..27451b3b36
--- /dev/null
+++ b/cmd/thv/app/skill_upgrade.go
@@ -0,0 +1,183 @@
+// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package app
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "text/tabwriter"
+
+ "github.com/spf13/cobra"
+
+ "github.com/stacklok/toolhive/pkg/skills"
+)
+
+var (
+ skillUpgradeProjectRoot string
+ skillUpgradeClientsRaw string
+ skillUpgradePreview bool
+ skillUpgradeFailOnChanges bool
+ skillUpgradeAllowRefChange bool
+ skillUpgradeAllowSignerChange bool
+ skillUpgradeYes bool
+ skillUpgradeFormat string
+)
+
+var skillUpgradeCmd = &cobra.Command{
+ Use: "upgrade [skill-name...]",
+ Short: "Check for and install newer content for locked skills",
+ Long: `Upgrade re-resolves each lock file entry's original source (registry
+name, OCI reference, or git reference) and installs any newer content it
+finds, updating toolhive.lock.yaml. Entries pinned to an immutable reference
+(an OCI digest or a full git commit hash) are reported as not upgradable.
+
+Use --preview to see what would change. Preview still fetches artifacts into
+the local cache but does not install or rewrite the lock file.
+
+With no arguments, every entry in the lock file is checked.
+
+The project root is auto-detected from the current directory (nearest
+enclosing git repository) unless --project-root is given.`,
+ PreRunE: chainPreRunE(ValidateFormat(&skillUpgradeFormat)),
+ RunE: skillUpgradeCmdFunc,
+}
+
+func init() {
+ skillCmd.AddCommand(skillUpgradeCmd)
+
+ skillUpgradeCmd.Flags().StringVar(&skillUpgradeClientsRaw, "clients", "",
+ `Comma-separated target client apps (e.g. claude-code,opencode), or "all" for every available client`)
+ skillUpgradeCmd.Flags().BoolVar(&skillUpgradePreview, "preview", false,
+ "Report what would change without installing (still fetches artifacts)")
+ skillUpgradeCmd.Flags().BoolVar(&skillUpgradeFailOnChanges, "fail-on-changes", false,
+ "Exit non-zero when preview finds any upgradable skill")
+ skillUpgradeCmd.Flags().BoolVar(&skillUpgradeAllowRefChange, "allow-ref-change", false,
+ "Allow resolvedReference changes during upgrade")
+ skillUpgradeCmd.Flags().BoolVar(&skillUpgradeAllowSignerChange, "allow-signer-change", false,
+ "Allow signer identity changes during upgrade")
+ skillUpgradeCmd.Flags().BoolVar(&skillUpgradeYes, "yes", false,
+ "Skip the pre-upgrade confirmation prompt")
+ skillUpgradeCmd.Flags().StringVar(&skillUpgradeProjectRoot, "project-root", "",
+ "Project root path (auto-detected from the current directory if omitted)")
+ AddFormatFlag(skillUpgradeCmd, &skillUpgradeFormat)
+}
+
+func skillUpgradeCmdFunc(cmd *cobra.Command, args []string) error {
+ projectRoot, err := resolveProjectRoot(skillUpgradeProjectRoot)
+ if err != nil {
+ return err
+ }
+
+ c := newSkillClient(cmd.Context())
+ opts := skills.UpgradeOptions{
+ ProjectRoot: projectRoot,
+ Names: args,
+ Preview: skillUpgradePreview,
+ FailOnChanges: skillUpgradeFailOnChanges,
+ AllowRefChange: skillUpgradeAllowRefChange,
+ AllowSignerChange: skillUpgradeAllowSignerChange,
+ Clients: parseSkillInstallClients(skillUpgradeClientsRaw),
+ }
+
+ if skillUpgradePreview {
+ result, err := c.Upgrade(cmd.Context(), opts)
+ if err != nil {
+ return formatSkillError("upgrade skills", err)
+ }
+ return finishUpgradeResult(result, skillUpgradeFormat)
+ }
+
+ previewOpts := opts
+ previewOpts.Preview = true
+ preview, err := c.Upgrade(cmd.Context(), previewOpts)
+ if err != nil {
+ return formatSkillError("upgrade skills", err)
+ }
+
+ if err := requireInteractiveConfirmation(skillUpgradeYes, func() {
+ printUpgradePreflight(preview)
+ }); err != nil {
+ return err
+ }
+
+ result, err := c.Upgrade(cmd.Context(), opts)
+ if err != nil {
+ return formatSkillError("upgrade skills", err)
+ }
+ return finishUpgradeResult(result, skillUpgradeFormat)
+}
+
+func finishUpgradeResult(result *skills.UpgradeResult, format string) error {
+ switch format {
+ case FormatJSON:
+ data, jsonErr := json.MarshalIndent(result, "", " ")
+ if jsonErr != nil {
+ return fmt.Errorf("failed to marshal JSON: %w", jsonErr)
+ }
+ fmt.Println(string(data))
+ default:
+ printUpgradeResultText(result)
+ }
+
+ if code := upgradeExitCode(result); code != 0 {
+ return newExitCodeError(code, nil)
+ }
+ return nil
+}
+
+func upgradeExitCode(result *skills.UpgradeResult) int {
+ for _, o := range result.Outcomes {
+ switch o.Status {
+ case skills.UpgradeStatusFailed:
+ return ExitCodePartialFailure
+ case skills.UpgradeStatusRefChangeBlocked, skills.UpgradeStatusSignerChangeBlocked:
+ return ExitCodePolicyRejection
+ case skills.UpgradeStatusUpgraded, skills.UpgradeStatusUpToDate, skills.UpgradeStatusNotUpgradable:
+ // continue
+ }
+ }
+ if skillUpgradeFailOnChanges {
+ for _, o := range result.Outcomes {
+ if o.Status == skills.UpgradeStatusUpgraded {
+ return ExitCodeCheckFailure
+ }
+ }
+ }
+ return 0
+}
+
+func printUpgradePreflight(result *skills.UpgradeResult) {
+ fmt.Println("Pre-flight upgrade summary:")
+ printUpgradeResultText(result)
+}
+
+func printUpgradeResultText(result *skills.UpgradeResult) {
+ if len(result.Outcomes) == 0 {
+ fmt.Println("Nothing to upgrade: lock file is empty")
+ return
+ }
+ w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
+ _, _ = fmt.Fprintln(w, "NAME\tSTATUS\tOLD DIGEST\tNEW DIGEST")
+ for _, o := range result.Outcomes {
+ _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", o.Name, o.Status, shortDigest(o.OldDigest), shortDigest(o.NewDigest))
+ }
+ _ = w.Flush()
+
+ for _, o := range result.Outcomes {
+ if o.Error != "" {
+ fmt.Printf(" %s: %s\n", o.Name, o.Error)
+ }
+ }
+}
+
+// shortDigest truncates a digest to "sha256:" plus 12 hex characters for
+// compact table display, matching the convention used by container tooling.
+func shortDigest(d string) string {
+ const shortLen = 19 // len("sha256:") + 12
+ if len(d) > shortLen {
+ return d[:shortLen]
+ }
+ return d
+}
diff --git a/cmd/thv/main.go b/cmd/thv/main.go
index fb03b8282e..1211ee739f 100644
--- a/cmd/thv/main.go
+++ b/cmd/thv/main.go
@@ -81,6 +81,9 @@ func main() {
if err := cmd.ExecuteContext(ctx); err != nil {
// Clean up any remaining lock files on error exit
lockfile.CleanupAllLocks()
+ if code, ok := app.ExitCodeFromError(err); ok {
+ os.Exit(code)
+ }
os.Exit(1)
}
diff --git a/docs/arch/12-skills-system.md b/docs/arch/12-skills-system.md
index e0021eb346..fecb829934 100644
--- a/docs/arch/12-skills-system.md
+++ b/docs/arch/12-skills-system.md
@@ -37,11 +37,13 @@ graph TB
Packager[SkillPackager
Build OCI artifacts]
Installer[Installer
Extract + validate]
Store[SkillStore
SQLite persistence]
+ Lock[Lockfile
pkg/skills/lockfile]
end
subgraph "Client Filesystem"
UserSkills["~/.claude/skills/
(user scope)"]
ProjectSkills[".claude/skills/
(project scope)"]
+ LockFile["toolhive.lock.yaml
(project root)"]
end
subgraph "Access Layer"
@@ -65,14 +67,17 @@ graph TB
SVC --> Packager
SVC --> Installer
SVC --> Store
+ SVC --> Lock
Installer --> UserSkills
Installer --> ProjectSkills
+ Lock --> LockFile
style SVC fill:#90caf9,stroke:#1565c0,stroke-width:2px
style Store fill:#e3f2fd
style UserSkills fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px
style ProjectSkills fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px
+ style LockFile fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px
style CLI fill:#fff9c4
style API fill:#fff9c4
```
@@ -258,6 +263,72 @@ Removes the skill files from the filesystem, deletes the database record, and re
**Implementation:** `pkg/skills/skillsvc/uninstall.go` (Uninstall), `pkg/groups/skills.go` (RemoveSkillFromAllGroups)
+## Project Lock File
+
+Every `--scope project` install writes an entry to a `toolhive.lock.yaml` file at the project root, alongside `.git`. The lock file pins the exact **name**, **version**, **source**, **resolved reference**, **digest**, and **contentDigest** of each project-scoped skill (including transitively materialized `toolhive.requires` dependencies) so a team can commit it to version control, restore an identical set of skills on another machine, and later check for newer content — the same role `package-lock.json` or `go.sum` play for other package managers. User-scope installs never touch the lock file.
+
+In v1, project-scope installs verify Sigstore signatures (OCI referrers / cosign tags for registry skills, gitsign commit signatures for git skills) and pin publisher identity in the lock file. Trust uses three layers:
+
+1. **Cryptographic verification** — Fulcio certificate chain + Rekor transparency log (online at install/upgrade; offline bundle re-verification in `sync --check`).
+2. **Publisher identity (TOFU)** — first verified install records `provenance.signerIdentity` + `provenance.certIssuer`; later installs enforce the same identity (`signer-mismatch` on divergence). Catalog-supplied expected identity is reserved for a future toolhive-core seam.
+3. **Human review** — PR review of `toolhive.lock.yaml` diffs remains the organizational trust root; `unsigned: true` is an explicit, reviewed exception.
+
+```yaml
+version: 1
+skills:
+ - name: code-review
+ version: 1.0.0
+ source: code-review
+ resolvedReference: ghcr.io/org/code-review:1.0.0
+ digest: sha256:9f2b1e...
+ contentDigest: sha256:a1b2c3d4...
+ provenance:
+ signerIdentity: https://github.com/org/repo/.github/workflows/release.yml@refs/heads/main
+ certIssuer: https://token.actions.githubusercontent.com
+ repositoryURI: https://github.com/org/repo
+ sigstoreURL: https://rekor.sigstore.dev
+ - name: local-build
+ source: local-build
+ resolvedReference: local-build
+ digest: sha256:...
+ contentDigest: sha256:...
+ unsigned: true
+```
+
+Use `--allow-unsigned` on install to record `unsigned: true` for artifacts without signatures (local builds, staging). `thv skill push` signs pushed OCI artifacts by default (`--key` for cosign static keys; `--no-sign` to skip). Upgrade refuses signer changes unless `--allow-signer-change` is set.
+
+`contentDigest` is a deterministic SHA-256 dirhash of the materialized skill file set (the integrity primitive for `sync --check`). Transitive dependencies declared via `toolhive.requires` are materialized at install time for all scopes; project-scope installs record them in the lock with `requiredBy`.
+
+The lock file is **client-agnostic**: it pins skill content, not which client applications installed it. `thv skill sync` installs pinned skills for whichever clients are targeted (all detected clients by default, or `--clients`), independent of what was targeted at install time.
+
+### Sync
+
+```bash
+thv skill sync # restore every pinned skill; interactive [y/N] gate on TTY
+thv skill sync --yes # skip confirmation (CI/scripts)
+thv skill sync --check # verify on-disk contentDigest without installing
+thv skill sync --adopt # write lock entries for existing unmanaged installs
+thv skill sync --prune # uninstall previously lock-managed skills absent from the lock file
+```
+
+For each lock entry, `Sync` compares on-disk `contentDigest` and the store digest against the lock. Drift or missing installs trigger a fresh install pinned to `resolvedReference@digest`. Unmanaged skills are split into `never-managed` (out-of-band installs) vs `removed-from-lock` (previously lock-managed); `--prune` removes only the latter. Skills still listed in another entry's `requiredBy` are never pruned. A sync never rewrites the lock file itself.
+
+**Exit codes:** `0` clean; `2` check/drift failure; `3` partial failure; `4` policy rejection (e.g. cancelled confirmation).
+
+### Upgrade
+
+```bash
+thv skill upgrade # check every locked skill; interactive [y/N] gate
+thv skill upgrade --yes # skip confirmation
+thv skill upgrade --preview # report without installing (still fetches artifacts)
+thv skill upgrade --fail-on-changes # CI freshness gate: exit non-zero if upgrades available
+thv skill upgrade --allow-ref-change # permit resolvedReference changes
+```
+
+`Upgrade` re-resolves each entry's original `source` and compares digest/reference against the lock. Immutable pins (`@sha256:...`, full git commit hashes) are `not-upgradable`. Reference changes are blocked unless `--allow-ref-change` is set. Signer identity changes are blocked unless `--allow-signer-change` is set.
+
+**Implementation:** `pkg/skills/lockfile/` (schema, contentDigest, validation, file-locked ops), `pkg/skills/lock_service.go` (`SkillLockService`), `pkg/skills/skillsvc/sync.go` (Sync), `pkg/skills/skillsvc/upgrade.go` (Upgrade), `pkg/skills/skillsvc/pin.go` (digest-pinning and immutability helpers), `pkg/skills/skillsvc/lock.go` (install hooks, dependency materialization)
+
## Git-Based Skill Resolution
Skills can be installed directly from git repositories using the `git://` scheme:
@@ -342,6 +413,8 @@ oci_tags table (reserved; not currently populated)
| `GET` | `/builds` | List local builds |
| `DELETE` | `/builds/{tag}` | Delete a local build |
| `GET` | `/content` | Get a skill's SKILL.md body and file listing for a reference |
+| `POST` | `/sync` | Install pinned skills from a project's lock file |
+| `POST` | `/upgrade` | Re-resolve locked skills and install newer content, if any |
**Implementation:** `pkg/api/v1/skills.go`
@@ -366,7 +439,9 @@ thv skill
├── build [path] Build skill to OCI artifact
├── push [reference] Push built skill to registry
├── builds List locally-built OCI artifacts
-└── builds remove [tag] Delete a locally-built artifact
+├── builds remove [tag] Delete a locally-built artifact
+├── sync Install skills exactly as pinned in toolhive.lock.yaml
+└── upgrade [name...] Check for and install newer content for locked skills
```
**Implementation:** `cmd/thv/app/skill*.go`
@@ -448,6 +523,7 @@ ToolHive owns the installation lifecycle, scoping model, CLI/API interfaces, and
| Parsing | `pkg/skills/parser.go` |
| Extraction | `pkg/skills/installer.go` |
| Git resolution | `pkg/skills/gitresolver/` |
+| Project lock file | `pkg/skills/lockfile/` |
| Storage interface | `pkg/storage/interfaces.go` |
| SQLite backend | `pkg/storage/sqlite/skill_store.go` |
| REST API | `pkg/api/v1/skills.go` |
diff --git a/docs/cli/thv_skill.md b/docs/cli/thv_skill.md
index 3a93a62f77..23f10db802 100644
--- a/docs/cli/thv_skill.md
+++ b/docs/cli/thv_skill.md
@@ -38,6 +38,8 @@ The skill command provides subcommands to manage skills.
* [thv skill install](thv_skill_install.md) - Install a skill
* [thv skill list](thv_skill_list.md) - List installed skills
* [thv skill push](thv_skill_push.md) - Push a built skill
+* [thv skill sync](thv_skill_sync.md) - Install skills exactly as pinned in the project's lock file
* [thv skill uninstall](thv_skill_uninstall.md) - Uninstall a skill
+* [thv skill upgrade](thv_skill_upgrade.md) - Check for and install newer content for locked skills
* [thv skill validate](thv_skill_validate.md) - Validate a skill definition
diff --git a/docs/cli/thv_skill_install.md b/docs/cli/thv_skill_install.md
index 84ca857c8b..aaa65536bc 100644
--- a/docs/cli/thv_skill_install.md
+++ b/docs/cli/thv_skill_install.md
@@ -25,6 +25,7 @@ thv skill install [skill-name] [flags]
### Options
```
+ --allow-unsigned Permit unsigned artifacts for project scope (recorded in lock as unsigned: true)
--clients string Comma-separated target client apps (e.g. claude-code,opencode), or "all" for every available client
--force Overwrite existing skill directory
--group string Group to add the skill to after installation
diff --git a/docs/cli/thv_skill_push.md b/docs/cli/thv_skill_push.md
index b370b1b715..ad0b8816e3 100644
--- a/docs/cli/thv_skill_push.md
+++ b/docs/cli/thv_skill_push.md
@@ -24,7 +24,9 @@ thv skill push [reference] [flags]
### Options
```
- -h, --help help for push
+ -h, --help help for push
+ --key string Path to cosign private key for signing
+ --no-sign Skip post-push Sigstore signing
```
### Options inherited from parent commands
diff --git a/docs/cli/thv_skill_sync.md b/docs/cli/thv_skill_sync.md
new file mode 100644
index 0000000000..3d8760dcb2
--- /dev/null
+++ b/docs/cli/thv_skill_sync.md
@@ -0,0 +1,57 @@
+---
+title: thv skill sync
+hide_title: true
+description: Reference for ToolHive CLI command `thv skill sync`
+last_update:
+ author: autogenerated
+slug: thv_skill_sync
+mdx:
+ format: md
+---
+
+## thv skill sync
+
+Install skills exactly as pinned in the project's lock file
+
+### Synopsis
+
+Sync installs every skill pinned in toolhive.lock.yaml at its exact
+recorded digest and verifies contentDigest integrity. Project-scoped skills
+installed outside of the lock file are reported as never-managed or
+removed-from-lock; use --prune to uninstall the latter.
+
+Use --check to verify on-disk content without installing. Use --adopt to write
+lock entries for existing unmanaged installs.
+
+The project root is auto-detected from the current directory (nearest
+enclosing git repository) unless --project-root is given.
+
+Pin history: git log -p -- toolhive.lock.yaml
+
+```
+thv skill sync [flags]
+```
+
+### Options
+
+```
+ --adopt Write lock entries for existing unmanaged project-scope installs
+ --check Verify on-disk content matches the lock file without installing
+ --clients string Comma-separated target client apps (e.g. claude-code,opencode), or "all" for every available client
+ --format string Output format (json, text) (default "text")
+ -h, --help help for sync
+ --project-root string Project root path (auto-detected from the current directory if omitted)
+ --prune Uninstall previously lock-managed skills that are no longer in the lock file
+ --yes Skip the pre-install confirmation prompt
+```
+
+### Options inherited from parent commands
+
+```
+ --debug Enable debug mode
+```
+
+### SEE ALSO
+
+* [thv skill](thv_skill.md) - Manage skills
+
diff --git a/docs/cli/thv_skill_upgrade.md b/docs/cli/thv_skill_upgrade.md
new file mode 100644
index 0000000000..d2da4f7f6e
--- /dev/null
+++ b/docs/cli/thv_skill_upgrade.md
@@ -0,0 +1,58 @@
+---
+title: thv skill upgrade
+hide_title: true
+description: Reference for ToolHive CLI command `thv skill upgrade`
+last_update:
+ author: autogenerated
+slug: thv_skill_upgrade
+mdx:
+ format: md
+---
+
+## thv skill upgrade
+
+Check for and install newer content for locked skills
+
+### Synopsis
+
+Upgrade re-resolves each lock file entry's original source (registry
+name, OCI reference, or git reference) and installs any newer content it
+finds, updating toolhive.lock.yaml. Entries pinned to an immutable reference
+(an OCI digest or a full git commit hash) are reported as not upgradable.
+
+Use --preview to see what would change. Preview still fetches artifacts into
+the local cache but does not install or rewrite the lock file.
+
+With no arguments, every entry in the lock file is checked.
+
+The project root is auto-detected from the current directory (nearest
+enclosing git repository) unless --project-root is given.
+
+```
+thv skill upgrade [skill-name...] [flags]
+```
+
+### Options
+
+```
+ --allow-ref-change Allow resolvedReference changes during upgrade
+ --allow-signer-change Allow signer identity changes during upgrade
+ --clients string Comma-separated target client apps (e.g. claude-code,opencode), or "all" for every available client
+ --fail-on-changes Exit non-zero when preview finds any upgradable skill
+ --format string Output format (json, text) (default "text")
+ -h, --help help for upgrade
+ --preview Report what would change without installing (still fetches artifacts)
+ --project-root string Project root path (auto-detected from the current directory if omitted)
+ --yes Skip the pre-upgrade confirmation prompt
+```
+
+### Options inherited from parent commands
+
+```
+ --debug Enable debug mode
+```
+
+### SEE ALSO
+
+* [thv skill](thv_skill.md) - Manage skills
+
diff --git a/docs/server/docs.go b/docs/server/docs.go
index a0c023d1b9..9d857d9645 100644
--- a/docs/server/docs.go
+++ b/docs/server/docs.go
@@ -1615,6 +1615,34 @@ const docTemplate = `{
},
"type": "object"
},
+ "github_com_stacklok_toolhive_pkg_skills.FailureReason": {
+ "description": "Reason is a typed failure reason when Status is UpgradeStatusFailed.",
+ "enum": [
+ "registry-unreachable",
+ "digest-missing",
+ "validation-rejected",
+ "lock-write-failed",
+ "ref-change-blocked",
+ "content-mismatch",
+ "signature-invalid",
+ "signer-mismatch",
+ "unsigned-rejected",
+ "unknown"
+ ],
+ "type": "string",
+ "x-enum-varnames": [
+ "FailureReasonRegistryUnreachable",
+ "FailureReasonDigestMissing",
+ "FailureReasonValidationRejected",
+ "FailureReasonLockWriteFailed",
+ "FailureReasonRefChangeBlocked",
+ "FailureReasonContentMismatch",
+ "FailureReasonSignatureInvalid",
+ "FailureReasonSignerMismatch",
+ "FailureReasonUnsignedRejected",
+ "FailureReasonUnknown"
+ ]
+ },
"github_com_stacklok_toolhive_pkg_skills.InstallStatus": {
"description": "Status is the current installation status.",
"enum": [
@@ -1656,6 +1684,10 @@ const docTemplate = `{
"description": "InstalledAt is the timestamp when the skill was installed.",
"type": "string"
},
+ "managed": {
+ "description": "Managed is true when the install was created by a lock-managed project-scope\ninstall (sync/install with lock write).",
+ "type": "boolean"
+ },
"metadata": {
"$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_skills.SkillMetadata"
},
@@ -1804,6 +1836,155 @@ const docTemplate = `{
},
"type": "object"
},
+ "github_com_stacklok_toolhive_pkg_skills.SyncFailure": {
+ "properties": {
+ "error": {
+ "description": "Error is a human-readable description of the failure.",
+ "type": "string"
+ },
+ "name": {
+ "description": "Name is the skill name that failed.",
+ "type": "string"
+ },
+ "reason": {
+ "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_skills.FailureReason"
+ }
+ },
+ "type": "object"
+ },
+ "github_com_stacklok_toolhive_pkg_skills.SyncResult": {
+ "properties": {
+ "drifted": {
+ "description": "Drifted lists skills whose on-disk contentDigest differed before reinstall.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array",
+ "uniqueItems": false
+ },
+ "failed": {
+ "description": "Failed lists skills that could not be synced, with the reason for each.",
+ "items": {
+ "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_skills.SyncFailure"
+ },
+ "type": "array",
+ "uniqueItems": false
+ },
+ "installed": {
+ "description": "Installed lists skills that were installed or reinstalled to match the lock file.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array",
+ "uniqueItems": false
+ },
+ "never_managed": {
+ "description": "NeverManaged lists project-scoped skills never recorded as lock-managed.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array",
+ "uniqueItems": false
+ },
+ "pruned": {
+ "description": "Pruned lists removed-from-lock skills that were uninstalled because Prune was set.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array",
+ "uniqueItems": false
+ },
+ "removed_from_lock": {
+ "description": "RemovedFromLock lists previously managed skills absent from the lock file.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array",
+ "uniqueItems": false
+ },
+ "unmanaged": {
+ "description": "Unmanaged is deprecated; use NeverManaged and RemovedFromLock.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array",
+ "uniqueItems": false
+ },
+ "up_to_date": {
+ "description": "AlreadyCurrent lists skills that already matched the lock file.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array",
+ "uniqueItems": false
+ }
+ },
+ "type": "object"
+ },
+ "github_com_stacklok_toolhive_pkg_skills.UpgradeOutcome": {
+ "properties": {
+ "error": {
+ "description": "Error is a human-readable description of the failure, set only when Status is UpgradeStatusFailed.",
+ "type": "string"
+ },
+ "name": {
+ "description": "Name is the skill name.",
+ "type": "string"
+ },
+ "new_digest": {
+ "description": "NewDigest is the digest the source currently resolves to. Equal to\nOldDigest when Status is UpgradeStatusUpToDate.",
+ "type": "string"
+ },
+ "new_resolved_reference": {
+ "description": "NewResolvedReference is the new resolvedReference when it changed.",
+ "type": "string"
+ },
+ "old_digest": {
+ "description": "OldDigest is the digest pinned in the lock file before this operation.",
+ "type": "string"
+ },
+ "reason": {
+ "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_skills.FailureReason"
+ },
+ "status": {
+ "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_skills.UpgradeStatus"
+ }
+ },
+ "type": "object"
+ },
+ "github_com_stacklok_toolhive_pkg_skills.UpgradeResult": {
+ "properties": {
+ "outcomes": {
+ "description": "Outcomes contains one entry per skill considered for upgrade.",
+ "items": {
+ "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_skills.UpgradeOutcome"
+ },
+ "type": "array",
+ "uniqueItems": false
+ }
+ },
+ "type": "object"
+ },
+ "github_com_stacklok_toolhive_pkg_skills.UpgradeStatus": {
+ "description": "Status is the outcome of the upgrade attempt.",
+ "enum": [
+ "upgraded",
+ "up-to-date",
+ "not-upgradable",
+ "ref-change-blocked",
+ "signer-change-blocked",
+ "failed"
+ ],
+ "type": "string",
+ "x-enum-varnames": [
+ "UpgradeStatusUpgraded",
+ "UpgradeStatusUpToDate",
+ "UpgradeStatusNotUpgradable",
+ "UpgradeStatusRefChangeBlocked",
+ "UpgradeStatusSignerChangeBlocked",
+ "UpgradeStatusFailed"
+ ]
+ },
"github_com_stacklok_toolhive_pkg_skills.ValidationResult": {
"properties": {
"errors": {
@@ -3009,6 +3190,10 @@ const docTemplate = `{
"pkg_api_v1.installSkillRequest": {
"description": "Request to install a skill",
"properties": {
+ "allow_unsigned": {
+ "description": "AllowUnsigned permits installing unsigned artifacts for project scope.",
+ "type": "boolean"
+ },
"clients": {
"description": "Clients lists target client identifiers (e.g., \"claude-code\"),\nor [\"all\"] to target every skill-supporting client.\nOmitting this field installs to all available clients.",
"items": {
@@ -3173,9 +3358,17 @@ const docTemplate = `{
"pkg_api_v1.pushSkillRequest": {
"description": "Request to push a built skill artifact",
"properties": {
+ "key": {
+ "description": "Key is the path to a cosign private key for signing.",
+ "type": "string"
+ },
"reference": {
"description": "OCI reference to push",
"type": "string"
+ },
+ "skip_signing": {
+ "description": "SkipSigning skips post-push Sigstore signing.",
+ "type": "boolean"
}
},
"type": "object"
@@ -3397,6 +3590,36 @@ const docTemplate = `{
},
"type": "object"
},
+ "pkg_api_v1.syncSkillsRequest": {
+ "description": "Request to sync a project's installed skills to match its lock file",
+ "properties": {
+ "adopt": {
+ "description": "Adopt writes lock entries for existing unmanaged project-scope installs",
+ "type": "boolean"
+ },
+ "check": {
+ "description": "Check verifies on-disk content against contentDigest without installing",
+ "type": "boolean"
+ },
+ "clients": {
+ "description": "Clients lists target client identifiers, or omit for every detected client",
+ "items": {
+ "type": "string"
+ },
+ "type": "array",
+ "uniqueItems": false
+ },
+ "project_root": {
+ "description": "ProjectRoot is the project root path whose lock file should be synced",
+ "type": "string"
+ },
+ "prune": {
+ "description": "Prune removes project-scoped skills that are installed but not present in the lock file",
+ "type": "boolean"
+ }
+ },
+ "type": "object"
+ },
"pkg_api_v1.toolOverride": {
"description": "Tool override",
"properties": {
@@ -3597,6 +3820,52 @@ const docTemplate = `{
},
"type": "object"
},
+ "pkg_api_v1.upgradeSkillsRequest": {
+ "description": "Request to check for and install newer content for locked skills",
+ "properties": {
+ "allow_ref_change": {
+ "description": "AllowRefChange permits resolvedReference changes during upgrade",
+ "type": "boolean"
+ },
+ "allow_signer_change": {
+ "description": "AllowSignerChange permits signer identity changes during upgrade",
+ "type": "boolean"
+ },
+ "clients": {
+ "description": "Clients lists target client identifiers, or omit for every detected client",
+ "items": {
+ "type": "string"
+ },
+ "type": "array",
+ "uniqueItems": false
+ },
+ "dry_run": {
+ "description": "DryRun is deprecated; use Preview",
+ "type": "boolean"
+ },
+ "fail_on_changes": {
+ "description": "FailOnChanges exits non-zero when any mutable source would upgrade",
+ "type": "boolean"
+ },
+ "names": {
+ "description": "Names restricts the upgrade to specific skill names, or omit for every locked skill",
+ "items": {
+ "type": "string"
+ },
+ "type": "array",
+ "uniqueItems": false
+ },
+ "preview": {
+ "description": "Preview reports what would change without installing (still fetches artifacts)",
+ "type": "boolean"
+ },
+ "project_root": {
+ "description": "ProjectRoot is the project root path whose lock file should be upgraded",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
"pkg_api_v1.validateSkillRequest": {
"description": "Request to validate a skill definition",
"properties": {
@@ -6201,6 +6470,138 @@ const docTemplate = `{
]
}
},
+ "/api/v1beta/skills/sync": {
+ "post": {
+ "description": "Install the exact pinned digest for every lock file entry, restoring drift",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "oneOf": [
+ {
+ "type": "object"
+ },
+ {
+ "$ref": "#/components/schemas/pkg_api_v1.syncSkillsRequest",
+ "summary": "request",
+ "description": "Sync request"
+ }
+ ]
+ }
+ }
+ },
+ "description": "Sync request",
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_skills.SyncResult"
+ }
+ }
+ },
+ "description": "OK"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "string"
+ }
+ }
+ },
+ "description": "Bad Request"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "string"
+ }
+ }
+ },
+ "description": "Internal Server Error"
+ }
+ },
+ "summary": "Sync a project's skills to its lock file",
+ "tags": [
+ "skills"
+ ]
+ }
+ },
+ "/api/v1beta/skills/upgrade": {
+ "post": {
+ "description": "Re-resolve each lock file entry's source and install newer content, if any",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "oneOf": [
+ {
+ "type": "object"
+ },
+ {
+ "$ref": "#/components/schemas/pkg_api_v1.upgradeSkillsRequest",
+ "summary": "request",
+ "description": "Upgrade request"
+ }
+ ]
+ }
+ }
+ },
+ "description": "Upgrade request",
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_skills.UpgradeResult"
+ }
+ }
+ },
+ "description": "OK"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "string"
+ }
+ }
+ },
+ "description": "Bad Request"
+ },
+ "404": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "string"
+ }
+ }
+ },
+ "description": "Not Found (requested skill name not in lock file)"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "string"
+ }
+ }
+ },
+ "description": "Internal Server Error"
+ }
+ },
+ "summary": "Upgrade a project's locked skills",
+ "tags": [
+ "skills"
+ ]
+ }
+ },
"/api/v1beta/skills/validate": {
"post": {
"description": "Validate a skill definition",
diff --git a/docs/server/swagger.json b/docs/server/swagger.json
index 54cf8b6e4c..e37cafca5c 100644
--- a/docs/server/swagger.json
+++ b/docs/server/swagger.json
@@ -1608,6 +1608,34 @@
},
"type": "object"
},
+ "github_com_stacklok_toolhive_pkg_skills.FailureReason": {
+ "description": "Reason is a typed failure reason when Status is UpgradeStatusFailed.",
+ "enum": [
+ "registry-unreachable",
+ "digest-missing",
+ "validation-rejected",
+ "lock-write-failed",
+ "ref-change-blocked",
+ "content-mismatch",
+ "signature-invalid",
+ "signer-mismatch",
+ "unsigned-rejected",
+ "unknown"
+ ],
+ "type": "string",
+ "x-enum-varnames": [
+ "FailureReasonRegistryUnreachable",
+ "FailureReasonDigestMissing",
+ "FailureReasonValidationRejected",
+ "FailureReasonLockWriteFailed",
+ "FailureReasonRefChangeBlocked",
+ "FailureReasonContentMismatch",
+ "FailureReasonSignatureInvalid",
+ "FailureReasonSignerMismatch",
+ "FailureReasonUnsignedRejected",
+ "FailureReasonUnknown"
+ ]
+ },
"github_com_stacklok_toolhive_pkg_skills.InstallStatus": {
"description": "Status is the current installation status.",
"enum": [
@@ -1649,6 +1677,10 @@
"description": "InstalledAt is the timestamp when the skill was installed.",
"type": "string"
},
+ "managed": {
+ "description": "Managed is true when the install was created by a lock-managed project-scope\ninstall (sync/install with lock write).",
+ "type": "boolean"
+ },
"metadata": {
"$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_skills.SkillMetadata"
},
@@ -1797,6 +1829,155 @@
},
"type": "object"
},
+ "github_com_stacklok_toolhive_pkg_skills.SyncFailure": {
+ "properties": {
+ "error": {
+ "description": "Error is a human-readable description of the failure.",
+ "type": "string"
+ },
+ "name": {
+ "description": "Name is the skill name that failed.",
+ "type": "string"
+ },
+ "reason": {
+ "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_skills.FailureReason"
+ }
+ },
+ "type": "object"
+ },
+ "github_com_stacklok_toolhive_pkg_skills.SyncResult": {
+ "properties": {
+ "drifted": {
+ "description": "Drifted lists skills whose on-disk contentDigest differed before reinstall.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array",
+ "uniqueItems": false
+ },
+ "failed": {
+ "description": "Failed lists skills that could not be synced, with the reason for each.",
+ "items": {
+ "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_skills.SyncFailure"
+ },
+ "type": "array",
+ "uniqueItems": false
+ },
+ "installed": {
+ "description": "Installed lists skills that were installed or reinstalled to match the lock file.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array",
+ "uniqueItems": false
+ },
+ "never_managed": {
+ "description": "NeverManaged lists project-scoped skills never recorded as lock-managed.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array",
+ "uniqueItems": false
+ },
+ "pruned": {
+ "description": "Pruned lists removed-from-lock skills that were uninstalled because Prune was set.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array",
+ "uniqueItems": false
+ },
+ "removed_from_lock": {
+ "description": "RemovedFromLock lists previously managed skills absent from the lock file.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array",
+ "uniqueItems": false
+ },
+ "unmanaged": {
+ "description": "Unmanaged is deprecated; use NeverManaged and RemovedFromLock.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array",
+ "uniqueItems": false
+ },
+ "up_to_date": {
+ "description": "AlreadyCurrent lists skills that already matched the lock file.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array",
+ "uniqueItems": false
+ }
+ },
+ "type": "object"
+ },
+ "github_com_stacklok_toolhive_pkg_skills.UpgradeOutcome": {
+ "properties": {
+ "error": {
+ "description": "Error is a human-readable description of the failure, set only when Status is UpgradeStatusFailed.",
+ "type": "string"
+ },
+ "name": {
+ "description": "Name is the skill name.",
+ "type": "string"
+ },
+ "new_digest": {
+ "description": "NewDigest is the digest the source currently resolves to. Equal to\nOldDigest when Status is UpgradeStatusUpToDate.",
+ "type": "string"
+ },
+ "new_resolved_reference": {
+ "description": "NewResolvedReference is the new resolvedReference when it changed.",
+ "type": "string"
+ },
+ "old_digest": {
+ "description": "OldDigest is the digest pinned in the lock file before this operation.",
+ "type": "string"
+ },
+ "reason": {
+ "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_skills.FailureReason"
+ },
+ "status": {
+ "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_skills.UpgradeStatus"
+ }
+ },
+ "type": "object"
+ },
+ "github_com_stacklok_toolhive_pkg_skills.UpgradeResult": {
+ "properties": {
+ "outcomes": {
+ "description": "Outcomes contains one entry per skill considered for upgrade.",
+ "items": {
+ "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_skills.UpgradeOutcome"
+ },
+ "type": "array",
+ "uniqueItems": false
+ }
+ },
+ "type": "object"
+ },
+ "github_com_stacklok_toolhive_pkg_skills.UpgradeStatus": {
+ "description": "Status is the outcome of the upgrade attempt.",
+ "enum": [
+ "upgraded",
+ "up-to-date",
+ "not-upgradable",
+ "ref-change-blocked",
+ "signer-change-blocked",
+ "failed"
+ ],
+ "type": "string",
+ "x-enum-varnames": [
+ "UpgradeStatusUpgraded",
+ "UpgradeStatusUpToDate",
+ "UpgradeStatusNotUpgradable",
+ "UpgradeStatusRefChangeBlocked",
+ "UpgradeStatusSignerChangeBlocked",
+ "UpgradeStatusFailed"
+ ]
+ },
"github_com_stacklok_toolhive_pkg_skills.ValidationResult": {
"properties": {
"errors": {
@@ -3002,6 +3183,10 @@
"pkg_api_v1.installSkillRequest": {
"description": "Request to install a skill",
"properties": {
+ "allow_unsigned": {
+ "description": "AllowUnsigned permits installing unsigned artifacts for project scope.",
+ "type": "boolean"
+ },
"clients": {
"description": "Clients lists target client identifiers (e.g., \"claude-code\"),\nor [\"all\"] to target every skill-supporting client.\nOmitting this field installs to all available clients.",
"items": {
@@ -3166,9 +3351,17 @@
"pkg_api_v1.pushSkillRequest": {
"description": "Request to push a built skill artifact",
"properties": {
+ "key": {
+ "description": "Key is the path to a cosign private key for signing.",
+ "type": "string"
+ },
"reference": {
"description": "OCI reference to push",
"type": "string"
+ },
+ "skip_signing": {
+ "description": "SkipSigning skips post-push Sigstore signing.",
+ "type": "boolean"
}
},
"type": "object"
@@ -3390,6 +3583,36 @@
},
"type": "object"
},
+ "pkg_api_v1.syncSkillsRequest": {
+ "description": "Request to sync a project's installed skills to match its lock file",
+ "properties": {
+ "adopt": {
+ "description": "Adopt writes lock entries for existing unmanaged project-scope installs",
+ "type": "boolean"
+ },
+ "check": {
+ "description": "Check verifies on-disk content against contentDigest without installing",
+ "type": "boolean"
+ },
+ "clients": {
+ "description": "Clients lists target client identifiers, or omit for every detected client",
+ "items": {
+ "type": "string"
+ },
+ "type": "array",
+ "uniqueItems": false
+ },
+ "project_root": {
+ "description": "ProjectRoot is the project root path whose lock file should be synced",
+ "type": "string"
+ },
+ "prune": {
+ "description": "Prune removes project-scoped skills that are installed but not present in the lock file",
+ "type": "boolean"
+ }
+ },
+ "type": "object"
+ },
"pkg_api_v1.toolOverride": {
"description": "Tool override",
"properties": {
@@ -3590,6 +3813,52 @@
},
"type": "object"
},
+ "pkg_api_v1.upgradeSkillsRequest": {
+ "description": "Request to check for and install newer content for locked skills",
+ "properties": {
+ "allow_ref_change": {
+ "description": "AllowRefChange permits resolvedReference changes during upgrade",
+ "type": "boolean"
+ },
+ "allow_signer_change": {
+ "description": "AllowSignerChange permits signer identity changes during upgrade",
+ "type": "boolean"
+ },
+ "clients": {
+ "description": "Clients lists target client identifiers, or omit for every detected client",
+ "items": {
+ "type": "string"
+ },
+ "type": "array",
+ "uniqueItems": false
+ },
+ "dry_run": {
+ "description": "DryRun is deprecated; use Preview",
+ "type": "boolean"
+ },
+ "fail_on_changes": {
+ "description": "FailOnChanges exits non-zero when any mutable source would upgrade",
+ "type": "boolean"
+ },
+ "names": {
+ "description": "Names restricts the upgrade to specific skill names, or omit for every locked skill",
+ "items": {
+ "type": "string"
+ },
+ "type": "array",
+ "uniqueItems": false
+ },
+ "preview": {
+ "description": "Preview reports what would change without installing (still fetches artifacts)",
+ "type": "boolean"
+ },
+ "project_root": {
+ "description": "ProjectRoot is the project root path whose lock file should be upgraded",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
"pkg_api_v1.validateSkillRequest": {
"description": "Request to validate a skill definition",
"properties": {
@@ -6194,6 +6463,138 @@
]
}
},
+ "/api/v1beta/skills/sync": {
+ "post": {
+ "description": "Install the exact pinned digest for every lock file entry, restoring drift",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "oneOf": [
+ {
+ "type": "object"
+ },
+ {
+ "$ref": "#/components/schemas/pkg_api_v1.syncSkillsRequest",
+ "summary": "request",
+ "description": "Sync request"
+ }
+ ]
+ }
+ }
+ },
+ "description": "Sync request",
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_skills.SyncResult"
+ }
+ }
+ },
+ "description": "OK"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "string"
+ }
+ }
+ },
+ "description": "Bad Request"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "string"
+ }
+ }
+ },
+ "description": "Internal Server Error"
+ }
+ },
+ "summary": "Sync a project's skills to its lock file",
+ "tags": [
+ "skills"
+ ]
+ }
+ },
+ "/api/v1beta/skills/upgrade": {
+ "post": {
+ "description": "Re-resolve each lock file entry's source and install newer content, if any",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "oneOf": [
+ {
+ "type": "object"
+ },
+ {
+ "$ref": "#/components/schemas/pkg_api_v1.upgradeSkillsRequest",
+ "summary": "request",
+ "description": "Upgrade request"
+ }
+ ]
+ }
+ }
+ },
+ "description": "Upgrade request",
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_skills.UpgradeResult"
+ }
+ }
+ },
+ "description": "OK"
+ },
+ "400": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "string"
+ }
+ }
+ },
+ "description": "Bad Request"
+ },
+ "404": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "string"
+ }
+ }
+ },
+ "description": "Not Found (requested skill name not in lock file)"
+ },
+ "500": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "string"
+ }
+ }
+ },
+ "description": "Internal Server Error"
+ }
+ },
+ "summary": "Upgrade a project's locked skills",
+ "tags": [
+ "skills"
+ ]
+ }
+ },
"/api/v1beta/skills/validate": {
"post": {
"description": "Validate a skill definition",
diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml
index b70a631aa5..c7ff1d4497 100644
--- a/docs/server/swagger.yaml
+++ b/docs/server/swagger.yaml
@@ -1636,6 +1636,31 @@ components:
description: Reference is the OCI reference for the dependency.
type: string
type: object
+ github_com_stacklok_toolhive_pkg_skills.FailureReason:
+ description: Reason is a typed failure reason when Status is UpgradeStatusFailed.
+ enum:
+ - registry-unreachable
+ - digest-missing
+ - validation-rejected
+ - lock-write-failed
+ - ref-change-blocked
+ - content-mismatch
+ - signature-invalid
+ - signer-mismatch
+ - unsigned-rejected
+ - unknown
+ type: string
+ x-enum-varnames:
+ - FailureReasonRegistryUnreachable
+ - FailureReasonDigestMissing
+ - FailureReasonValidationRejected
+ - FailureReasonLockWriteFailed
+ - FailureReasonRefChangeBlocked
+ - FailureReasonContentMismatch
+ - FailureReasonSignatureInvalid
+ - FailureReasonSignerMismatch
+ - FailureReasonUnsignedRejected
+ - FailureReasonUnknown
github_com_stacklok_toolhive_pkg_skills.InstallStatus:
description: Status is the current installation status.
enum:
@@ -1670,6 +1695,11 @@ components:
installed_at:
description: InstalledAt is the timestamp when the skill was installed.
type: string
+ managed:
+ description: |-
+ Managed is true when the install was created by a lock-managed project-scope
+ install (sync/install with lock write).
+ type: boolean
metadata:
$ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_skills.SkillMetadata'
project_root:
@@ -1780,6 +1810,126 @@ components:
description: Version is the semantic version of the skill.
type: string
type: object
+ github_com_stacklok_toolhive_pkg_skills.SyncFailure:
+ properties:
+ error:
+ description: Error is a human-readable description of the failure.
+ type: string
+ name:
+ description: Name is the skill name that failed.
+ type: string
+ reason:
+ $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_skills.FailureReason'
+ type: object
+ github_com_stacklok_toolhive_pkg_skills.SyncResult:
+ properties:
+ drifted:
+ description: Drifted lists skills whose on-disk contentDigest differed before
+ reinstall.
+ items:
+ type: string
+ type: array
+ uniqueItems: false
+ failed:
+ description: Failed lists skills that could not be synced, with the reason
+ for each.
+ items:
+ $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_skills.SyncFailure'
+ type: array
+ uniqueItems: false
+ installed:
+ description: Installed lists skills that were installed or reinstalled to
+ match the lock file.
+ items:
+ type: string
+ type: array
+ uniqueItems: false
+ never_managed:
+ description: NeverManaged lists project-scoped skills never recorded as
+ lock-managed.
+ items:
+ type: string
+ type: array
+ uniqueItems: false
+ pruned:
+ description: Pruned lists removed-from-lock skills that were uninstalled
+ because Prune was set.
+ items:
+ type: string
+ type: array
+ uniqueItems: false
+ removed_from_lock:
+ description: RemovedFromLock lists previously managed skills absent from
+ the lock file.
+ items:
+ type: string
+ type: array
+ uniqueItems: false
+ unmanaged:
+ description: Unmanaged is deprecated; use NeverManaged and RemovedFromLock.
+ items:
+ type: string
+ type: array
+ uniqueItems: false
+ up_to_date:
+ description: AlreadyCurrent lists skills that already matched the lock file.
+ items:
+ type: string
+ type: array
+ uniqueItems: false
+ type: object
+ github_com_stacklok_toolhive_pkg_skills.UpgradeOutcome:
+ properties:
+ error:
+ description: Error is a human-readable description of the failure, set only
+ when Status is UpgradeStatusFailed.
+ type: string
+ name:
+ description: Name is the skill name.
+ type: string
+ new_digest:
+ description: |-
+ NewDigest is the digest the source currently resolves to. Equal to
+ OldDigest when Status is UpgradeStatusUpToDate.
+ type: string
+ new_resolved_reference:
+ description: NewResolvedReference is the new resolvedReference when it changed.
+ type: string
+ old_digest:
+ description: OldDigest is the digest pinned in the lock file before this
+ operation.
+ type: string
+ reason:
+ $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_skills.FailureReason'
+ status:
+ $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_skills.UpgradeStatus'
+ type: object
+ github_com_stacklok_toolhive_pkg_skills.UpgradeResult:
+ properties:
+ outcomes:
+ description: Outcomes contains one entry per skill considered for upgrade.
+ items:
+ $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_skills.UpgradeOutcome'
+ type: array
+ uniqueItems: false
+ type: object
+ github_com_stacklok_toolhive_pkg_skills.UpgradeStatus:
+ description: Status is the outcome of the upgrade attempt.
+ enum:
+ - upgraded
+ - up-to-date
+ - not-upgradable
+ - ref-change-blocked
+ - signer-change-blocked
+ - failed
+ type: string
+ x-enum-varnames:
+ - UpgradeStatusUpgraded
+ - UpgradeStatusUpToDate
+ - UpgradeStatusNotUpgradable
+ - UpgradeStatusRefChangeBlocked
+ - UpgradeStatusSignerChangeBlocked
+ - UpgradeStatusFailed
github_com_stacklok_toolhive_pkg_skills.ValidationResult:
properties:
errors:
@@ -2783,6 +2933,10 @@ components:
pkg_api_v1.installSkillRequest:
description: Request to install a skill
properties:
+ allow_unsigned:
+ description: AllowUnsigned permits installing unsigned artifacts for project
+ scope.
+ type: boolean
clients:
description: |-
Clients lists target client identifiers (e.g., "claude-code"),
@@ -2905,9 +3059,15 @@ components:
pkg_api_v1.pushSkillRequest:
description: Request to push a built skill artifact
properties:
+ key:
+ description: Key is the path to a cosign private key for signing.
+ type: string
reference:
description: OCI reference to push
type: string
+ skip_signing:
+ description: SkipSigning skips post-push Sigstore signing.
+ type: boolean
type: object
pkg_api_v1.registryErrorResponse:
description: Structured error response returned by registry endpoints
@@ -3077,6 +3237,34 @@ components:
type: array
uniqueItems: false
type: object
+ pkg_api_v1.syncSkillsRequest:
+ description: Request to sync a project's installed skills to match its lock
+ file
+ properties:
+ adopt:
+ description: Adopt writes lock entries for existing unmanaged project-scope
+ installs
+ type: boolean
+ check:
+ description: Check verifies on-disk content against contentDigest without
+ installing
+ type: boolean
+ clients:
+ description: Clients lists target client identifiers, or omit for every
+ detected client
+ items:
+ type: string
+ type: array
+ uniqueItems: false
+ project_root:
+ description: ProjectRoot is the project root path whose lock file should
+ be synced
+ type: string
+ prune:
+ description: Prune removes project-scoped skills that are installed but
+ not present in the lock file
+ type: boolean
+ type: object
pkg_api_v1.toolOverride:
description: Tool override
properties:
@@ -3237,6 +3425,45 @@ components:
type: array
uniqueItems: false
type: object
+ pkg_api_v1.upgradeSkillsRequest:
+ description: Request to check for and install newer content for locked skills
+ properties:
+ allow_ref_change:
+ description: AllowRefChange permits resolvedReference changes during upgrade
+ type: boolean
+ allow_signer_change:
+ description: AllowSignerChange permits signer identity changes during upgrade
+ type: boolean
+ clients:
+ description: Clients lists target client identifiers, or omit for every
+ detected client
+ items:
+ type: string
+ type: array
+ uniqueItems: false
+ dry_run:
+ description: DryRun is deprecated; use Preview
+ type: boolean
+ fail_on_changes:
+ description: FailOnChanges exits non-zero when any mutable source would
+ upgrade
+ type: boolean
+ names:
+ description: Names restricts the upgrade to specific skill names, or omit
+ for every locked skill
+ items:
+ type: string
+ type: array
+ uniqueItems: false
+ preview:
+ description: Preview reports what would change without installing (still
+ fetches artifacts)
+ type: boolean
+ project_root:
+ description: ProjectRoot is the project root path whose lock file should
+ be upgraded
+ type: string
+ type: object
pkg_api_v1.validateSkillRequest:
description: Request to validate a skill definition
properties:
@@ -5130,6 +5357,86 @@ paths:
summary: Push a skill
tags:
- skills
+ /api/v1beta/skills/sync:
+ post:
+ description: Install the exact pinned digest for every lock file entry, restoring
+ drift
+ requestBody:
+ content:
+ application/json:
+ schema:
+ oneOf:
+ - type: object
+ - $ref: '#/components/schemas/pkg_api_v1.syncSkillsRequest'
+ description: Sync request
+ summary: request
+ description: Sync request
+ required: true
+ responses:
+ "200":
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_skills.SyncResult'
+ description: OK
+ "400":
+ content:
+ application/json:
+ schema:
+ type: string
+ description: Bad Request
+ "500":
+ content:
+ application/json:
+ schema:
+ type: string
+ description: Internal Server Error
+ summary: Sync a project's skills to its lock file
+ tags:
+ - skills
+ /api/v1beta/skills/upgrade:
+ post:
+ description: Re-resolve each lock file entry's source and install newer content,
+ if any
+ requestBody:
+ content:
+ application/json:
+ schema:
+ oneOf:
+ - type: object
+ - $ref: '#/components/schemas/pkg_api_v1.upgradeSkillsRequest'
+ description: Upgrade request
+ summary: request
+ description: Upgrade request
+ required: true
+ responses:
+ "200":
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_skills.UpgradeResult'
+ description: OK
+ "400":
+ content:
+ application/json:
+ schema:
+ type: string
+ description: Bad Request
+ "404":
+ content:
+ application/json:
+ schema:
+ type: string
+ description: Not Found (requested skill name not in lock file)
+ "500":
+ content:
+ application/json:
+ schema:
+ type: string
+ description: Internal Server Error
+ summary: Upgrade a project's locked skills
+ tags:
+ - skills
/api/v1beta/skills/validate:
post:
description: Validate a skill definition
diff --git a/go.mod b/go.mod
index 653e6b3c53..761e0a0ae4 100644
--- a/go.mod
+++ b/go.mod
@@ -46,6 +46,8 @@ require (
github.com/prometheus/client_golang v1.23.2
github.com/redis/go-redis/v9 v9.21.0
github.com/shirou/gopsutil/v4 v4.26.5
+ github.com/sigstore/sigstore v1.10.8
+ github.com/sigstore/sigstore-go v1.2.2
github.com/spf13/viper v1.21.0
github.com/stacklok/toolhive-catalog v0.20260706.0
github.com/stacklok/toolhive-core v0.0.27
@@ -88,9 +90,12 @@ require go.starlark.net v0.0.0-20260630144053-529d8e869a14
require (
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 // indirect
github.com/go-openapi/runtime/server-middleware v0.30.0 // indirect
+ github.com/jedisct1/go-minisign v0.0.0-20230811132847-661be99b8267 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/oklog/ulid/v2 v2.1.1 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
+ github.com/sassoftware/relic v7.2.1+incompatible // indirect
+ github.com/theupdateframework/go-tuf v0.7.0 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
)
@@ -246,11 +251,9 @@ require (
github.com/sergi/go-diff v1.4.0 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/shibumi/go-pathspec v1.3.0 // indirect
- github.com/sigstore/protobuf-specs v0.5.1 // indirect
+ github.com/sigstore/protobuf-specs v0.5.1
github.com/sigstore/rekor v1.5.3 // indirect
github.com/sigstore/rekor-tiles/v2 v2.3.0 // indirect
- github.com/sigstore/sigstore v1.10.8 // indirect
- github.com/sigstore/sigstore-go v1.2.2 // indirect
github.com/sigstore/timestamp-authority/v2 v2.1.2 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/skeema/knownhosts v1.3.1 // indirect
diff --git a/go.sum b/go.sum
index ea5c021656..e583d2fdda 100644
--- a/go.sum
+++ b/go.sum
@@ -314,6 +314,8 @@ github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAg
github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
github.com/go-openapi/validate v0.26.0 h1:dxWzQ3F+vb1SajqUxHjwb5T4mTpSHmdrtv5Bi7+ZNhw=
github.com/go-openapi/validate v0.26.0/go.mod h1:b4o00uq7fJeJA+wWhVFCJpKTctzeFwzZImGGmHsl2JA=
+github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA=
+github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
@@ -874,6 +876,16 @@ github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zI
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
+github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ=
+github.com/ysmood/fetchup v0.2.3/go.mod h1:xhibcRKziSvol0H1/pj33dnKrYyI2ebIvz5cOOkYGns=
+github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ=
+github.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18=
+github.com/ysmood/got v0.40.0 h1:ZQk1B55zIvS7zflRrkGfPDrPG3d7+JOza1ZkNxcc74Q=
+github.com/ysmood/got v0.40.0/go.mod h1:W7DdpuX6skL3NszLmAsC5hT7JAhuLZhByVzHTq874Qg=
+github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE=
+github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg=
+github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU=
+github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
diff --git a/pkg/api/server.go b/pkg/api/server.go
index dcb5028fda..8f1fb1296a 100644
--- a/pkg/api/server.go
+++ b/pkg/api/server.go
@@ -92,6 +92,7 @@ type ServerBuilder struct {
workloadManager workloads.Manager
groupManager groups.Manager
skillManager skills.SkillService
+ skillLockManager skills.SkillLockService
skillStoreCloser io.Closer
}
@@ -321,6 +322,7 @@ func (b *ServerBuilder) createDefaultManagers(ctx context.Context) error {
)
b.skillManager = skillsvc.New(store, skillOpts...)
+ b.skillLockManager = b.skillManager.(skills.SkillLockService)
}
return nil
@@ -347,7 +349,7 @@ func (b *ServerBuilder) setupDefaultRoutes(r *chi.Mux) {
"/api/v1beta/clients": v1.ClientRouter(b.clientManager, b.workloadManager, b.groupManager),
"/api/v1beta/secrets": v1.SecretsRouter(),
"/api/v1beta/groups": v1.GroupsRouter(b.groupManager, b.workloadManager, b.clientManager),
- "/api/v1beta/skills": v1.SkillsRouter(b.skillManager),
+ "/api/v1beta/skills": v1.SkillsRouter(b.skillManager, b.skillLockManager),
"/registry": v1.RegistryV01Router(),
}
for prefix, router := range standardRouters {
diff --git a/pkg/api/v1/skills.go b/pkg/api/v1/skills.go
index b0f58c9001..152076fed1 100644
--- a/pkg/api/v1/skills.go
+++ b/pkg/api/v1/skills.go
@@ -18,12 +18,14 @@ import (
// SkillsRoutes defines the routes for skill management.
type SkillsRoutes struct {
skillService skills.SkillService
+ lockService skills.SkillLockService
}
// SkillsRouter creates a new router for skill management endpoints.
-func SkillsRouter(skillService skills.SkillService) http.Handler {
+func SkillsRouter(skillService skills.SkillService, lockService skills.SkillLockService) http.Handler {
routes := SkillsRoutes{
skillService: skillService,
+ lockService: lockService,
}
r := chi.NewRouter()
@@ -37,6 +39,8 @@ func SkillsRouter(skillService skills.SkillService) http.Handler {
r.Get("/builds", apierrors.ErrorHandler(routes.listBuilds))
r.Delete("/builds/{tag}", apierrors.ErrorHandler(routes.deleteBuild))
r.Get("/content", apierrors.ErrorHandler(routes.getSkillContent))
+ r.Post("/sync", apierrors.ErrorHandler(routes.syncSkills))
+ r.Post("/upgrade", apierrors.ErrorHandler(routes.upgradeSkills))
return r
}
@@ -103,13 +107,14 @@ func (s *SkillsRoutes) installSkill(w http.ResponseWriter, r *http.Request) erro
}
result, err := s.skillService.Install(r.Context(), skills.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
@@ -278,7 +283,9 @@ func (s *SkillsRoutes) pushSkill(w http.ResponseWriter, r *http.Request) error {
}
if err := s.skillService.Push(r.Context(), skills.PushOptions{
- Reference: req.Reference,
+ Reference: req.Reference,
+ Key: req.Key,
+ SkipSigning: req.SkipSigning,
}); err != nil {
return err
}
@@ -365,3 +372,81 @@ func (s *SkillsRoutes) getSkillContent(w http.ResponseWriter, r *http.Request) e
w.Header().Set("Content-Type", "application/json")
return json.NewEncoder(w).Encode(content)
}
+
+// syncSkills installs the exact name/digest pinned in a project's skills lock
+// file for every entry, restoring skills that are missing or drifted.
+//
+// @Summary Sync a project's skills to its lock file
+// @Description Install the exact pinned digest for every lock file entry, restoring drift
+// @Tags skills
+// @Accept json
+// @Produce json
+// @Param request body syncSkillsRequest true "Sync request"
+// @Success 200 {object} skills.SyncResult
+// @Failure 400 {string} string "Bad Request"
+// @Failure 500 {string} string "Internal Server Error"
+// @Router /api/v1beta/skills/sync [post]
+func (s *SkillsRoutes) syncSkills(w http.ResponseWriter, r *http.Request) error {
+ var req syncSkillsRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ return httperr.WithCode(
+ fmt.Errorf("invalid request body: %w", err),
+ http.StatusBadRequest,
+ )
+ }
+
+ result, err := s.lockService.Sync(r.Context(), skills.SyncOptions{
+ ProjectRoot: req.ProjectRoot,
+ Clients: req.Clients,
+ Prune: req.Prune,
+ Check: req.Check,
+ Adopt: req.Adopt,
+ })
+ if err != nil {
+ return err
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ return json.NewEncoder(w).Encode(result)
+}
+
+// upgradeSkills re-resolves each lock file entry's original source and
+// installs any newer content it finds.
+//
+// @Summary Upgrade a project's locked skills
+// @Description Re-resolve each lock file entry's source and install newer content, if any
+// @Tags skills
+// @Accept json
+// @Produce json
+// @Param request body upgradeSkillsRequest true "Upgrade request"
+// @Success 200 {object} skills.UpgradeResult
+// @Failure 400 {string} string "Bad Request"
+// @Failure 404 {string} string "Not Found (requested skill name not in lock file)"
+// @Failure 500 {string} string "Internal Server Error"
+// @Router /api/v1beta/skills/upgrade [post]
+func (s *SkillsRoutes) upgradeSkills(w http.ResponseWriter, r *http.Request) error {
+ var req upgradeSkillsRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ return httperr.WithCode(
+ fmt.Errorf("invalid request body: %w", err),
+ http.StatusBadRequest,
+ )
+ }
+
+ result, err := s.lockService.Upgrade(r.Context(), skills.UpgradeOptions{
+ ProjectRoot: req.ProjectRoot,
+ Names: req.Names,
+ Preview: req.Preview || req.DryRun,
+ DryRun: req.DryRun,
+ FailOnChanges: req.FailOnChanges,
+ AllowRefChange: req.AllowRefChange,
+ AllowSignerChange: req.AllowSignerChange,
+ Clients: req.Clients,
+ })
+ if err != nil {
+ return err
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ return json.NewEncoder(w).Encode(result)
+}
diff --git a/pkg/api/v1/skills_test.go b/pkg/api/v1/skills_test.go
index 5ad7599923..5103aff7a5 100644
--- a/pkg/api/v1/skills_test.go
+++ b/pkg/api/v1/skills_test.go
@@ -41,7 +41,7 @@ func TestSkillsRouter(t *testing.T) {
method string
path string
body string
- setupMock func(*skillsmocks.MockSkillService, string)
+ setupMock func(*skillsmocks.MockSkillService, *skillsmocks.MockSkillLockService, string)
expectedStatus int
expectedBody string
}{
@@ -50,7 +50,7 @@ func TestSkillsRouter(t *testing.T) {
name: "list skills success empty",
method: "GET",
path: "/",
- setupMock: func(svc *skillsmocks.MockSkillService, _ string) {
+ setupMock: func(svc *skillsmocks.MockSkillService, _ *skillsmocks.MockSkillLockService, _ string) {
svc.EXPECT().List(gomock.Any(), skills.ListOptions{}).
Return([]skills.InstalledSkill{}, nil)
},
@@ -61,7 +61,7 @@ func TestSkillsRouter(t *testing.T) {
name: "list skills success with results",
method: "GET",
path: "/",
- setupMock: func(svc *skillsmocks.MockSkillService, _ string) {
+ setupMock: func(svc *skillsmocks.MockSkillService, _ *skillsmocks.MockSkillLockService, _ string) {
svc.EXPECT().List(gomock.Any(), skills.ListOptions{}).
Return([]skills.InstalledSkill{
{
@@ -79,7 +79,7 @@ func TestSkillsRouter(t *testing.T) {
name: "list skills project scope missing project root",
method: "GET",
path: "/?scope=project",
- setupMock: func(svc *skillsmocks.MockSkillService, _ string) {
+ setupMock: func(svc *skillsmocks.MockSkillService, _ *skillsmocks.MockSkillLockService, _ string) {
svc.EXPECT().List(gomock.Any(), skills.ListOptions{
Scope: skills.ScopeProject,
}).Return(nil, httperr.WithCode(
@@ -94,7 +94,7 @@ func TestSkillsRouter(t *testing.T) {
name: "list skills with project root filter",
method: "GET",
path: "/?scope=project&project_root={{project_root}}",
- setupMock: func(svc *skillsmocks.MockSkillService, projectRoot string) {
+ setupMock: func(svc *skillsmocks.MockSkillService, _ *skillsmocks.MockSkillLockService, projectRoot string) {
svc.EXPECT().List(gomock.Any(), skills.ListOptions{
Scope: skills.ScopeProject,
ProjectRoot: projectRoot,
@@ -107,7 +107,7 @@ func TestSkillsRouter(t *testing.T) {
name: "list skills with client filter",
method: "GET",
path: "/?client=claude-code",
- setupMock: func(svc *skillsmocks.MockSkillService, _ string) {
+ setupMock: func(svc *skillsmocks.MockSkillService, _ *skillsmocks.MockSkillLockService, _ string) {
svc.EXPECT().List(gomock.Any(), skills.ListOptions{ClientApp: "claude-code"}).
Return([]skills.InstalledSkill{}, nil)
},
@@ -118,7 +118,7 @@ func TestSkillsRouter(t *testing.T) {
name: "list skills error",
method: "GET",
path: "/",
- setupMock: func(svc *skillsmocks.MockSkillService, _ string) {
+ setupMock: func(svc *skillsmocks.MockSkillService, _ *skillsmocks.MockSkillLockService, _ string) {
svc.EXPECT().List(gomock.Any(), gomock.Any()).
Return(nil, fmt.Errorf("database error"))
},
@@ -131,7 +131,7 @@ func TestSkillsRouter(t *testing.T) {
method: "POST",
path: "/",
body: `{"name":"my-skill"}`,
- setupMock: func(svc *skillsmocks.MockSkillService, _ string) {
+ setupMock: func(svc *skillsmocks.MockSkillService, _ *skillsmocks.MockSkillLockService, _ string) {
svc.EXPECT().Install(gomock.Any(), skills.InstallOptions{Name: "my-skill"}).
Return(&skills.InstallResult{
Skill: skills.InstalledSkill{
@@ -150,7 +150,7 @@ func TestSkillsRouter(t *testing.T) {
method: "POST",
path: "/",
body: `{"name":""}`,
- setupMock: func(svc *skillsmocks.MockSkillService, _ string) {
+ setupMock: func(svc *skillsmocks.MockSkillService, _ *skillsmocks.MockSkillLockService, _ string) {
svc.EXPECT().Install(gomock.Any(), skills.InstallOptions{Name: ""}).
Return(nil, httperr.WithCode(fmt.Errorf("invalid skill name: must not be empty"), http.StatusBadRequest))
},
@@ -162,7 +162,7 @@ func TestSkillsRouter(t *testing.T) {
method: "POST",
path: "/",
body: `{}`,
- setupMock: func(svc *skillsmocks.MockSkillService, _ string) {
+ setupMock: func(svc *skillsmocks.MockSkillService, _ *skillsmocks.MockSkillLockService, _ string) {
svc.EXPECT().Install(gomock.Any(), skills.InstallOptions{Name: ""}).
Return(nil, httperr.WithCode(fmt.Errorf("invalid skill name: must not be empty"), http.StatusBadRequest))
},
@@ -174,7 +174,7 @@ func TestSkillsRouter(t *testing.T) {
method: "POST",
path: "/",
body: `{invalid`,
- setupMock: func(_ *skillsmocks.MockSkillService, _ string) {},
+ setupMock: func(_ *skillsmocks.MockSkillService, _ *skillsmocks.MockSkillLockService, _ string) {},
expectedStatus: http.StatusBadRequest,
expectedBody: "invalid request body",
},
@@ -183,7 +183,7 @@ func TestSkillsRouter(t *testing.T) {
method: "POST",
path: "/",
body: `{"name":"my-skill"}`,
- setupMock: func(svc *skillsmocks.MockSkillService, _ string) {
+ setupMock: func(svc *skillsmocks.MockSkillService, _ *skillsmocks.MockSkillLockService, _ string) {
svc.EXPECT().Install(gomock.Any(), gomock.Any()).
Return(nil, storage.ErrAlreadyExists)
},
@@ -195,7 +195,7 @@ func TestSkillsRouter(t *testing.T) {
method: "POST",
path: "/",
body: `{"name":"A"}`,
- setupMock: func(svc *skillsmocks.MockSkillService, _ string) {
+ setupMock: func(svc *skillsmocks.MockSkillService, _ *skillsmocks.MockSkillLockService, _ string) {
svc.EXPECT().Install(gomock.Any(), gomock.Any()).
Return(nil, httperr.WithCode(fmt.Errorf("invalid skill name"), http.StatusBadRequest))
},
@@ -207,7 +207,7 @@ func TestSkillsRouter(t *testing.T) {
name: "uninstall skill success",
method: "DELETE",
path: "/my-skill",
- setupMock: func(svc *skillsmocks.MockSkillService, _ string) {
+ setupMock: func(svc *skillsmocks.MockSkillService, _ *skillsmocks.MockSkillLockService, _ string) {
svc.EXPECT().Uninstall(gomock.Any(), skills.UninstallOptions{Name: "my-skill"}).
Return(nil)
},
@@ -217,7 +217,7 @@ func TestSkillsRouter(t *testing.T) {
name: "uninstall skill invalid name",
method: "DELETE",
path: "/A",
- setupMock: func(_ *skillsmocks.MockSkillService, _ string) {},
+ setupMock: func(_ *skillsmocks.MockSkillService, _ *skillsmocks.MockSkillLockService, _ string) {},
expectedStatus: http.StatusBadRequest,
expectedBody: "invalid skill name",
},
@@ -225,7 +225,7 @@ func TestSkillsRouter(t *testing.T) {
name: "uninstall skill invalid scope",
method: "DELETE",
path: "/my-skill?scope=invalid",
- setupMock: func(svc *skillsmocks.MockSkillService, _ string) {
+ setupMock: func(svc *skillsmocks.MockSkillService, _ *skillsmocks.MockSkillLockService, _ string) {
svc.EXPECT().Uninstall(gomock.Any(), skills.UninstallOptions{
Name: "my-skill",
Scope: skills.Scope("invalid"),
@@ -241,7 +241,7 @@ func TestSkillsRouter(t *testing.T) {
name: "uninstall skill not found",
method: "DELETE",
path: "/my-skill",
- setupMock: func(svc *skillsmocks.MockSkillService, _ string) {
+ setupMock: func(svc *skillsmocks.MockSkillService, _ *skillsmocks.MockSkillLockService, _ string) {
svc.EXPECT().Uninstall(gomock.Any(), gomock.Any()).
Return(storage.ErrNotFound)
},
@@ -253,7 +253,7 @@ func TestSkillsRouter(t *testing.T) {
name: "get skill info found",
method: "GET",
path: "/my-skill",
- setupMock: func(svc *skillsmocks.MockSkillService, _ string) {
+ setupMock: func(svc *skillsmocks.MockSkillService, _ *skillsmocks.MockSkillLockService, _ string) {
svc.EXPECT().Info(gomock.Any(), skills.InfoOptions{Name: "my-skill"}).
Return(&skills.SkillInfo{
Metadata: skills.SkillMetadata{Name: "my-skill"},
@@ -271,7 +271,7 @@ func TestSkillsRouter(t *testing.T) {
name: "get skill info not found",
method: "GET",
path: "/my-skill",
- setupMock: func(svc *skillsmocks.MockSkillService, _ string) {
+ setupMock: func(svc *skillsmocks.MockSkillService, _ *skillsmocks.MockSkillLockService, _ string) {
svc.EXPECT().Info(gomock.Any(), skills.InfoOptions{Name: "my-skill"}).
Return(nil, storage.ErrNotFound)
},
@@ -282,7 +282,7 @@ func TestSkillsRouter(t *testing.T) {
name: "get skill info invalid name",
method: "GET",
path: "/A",
- setupMock: func(_ *skillsmocks.MockSkillService, _ string) {},
+ setupMock: func(_ *skillsmocks.MockSkillService, _ *skillsmocks.MockSkillLockService, _ string) {},
expectedStatus: http.StatusBadRequest,
expectedBody: "invalid skill name",
},
@@ -291,7 +291,7 @@ func TestSkillsRouter(t *testing.T) {
name: "get skill info service error",
method: "GET",
path: "/my-skill",
- setupMock: func(svc *skillsmocks.MockSkillService, _ string) {
+ setupMock: func(svc *skillsmocks.MockSkillService, _ *skillsmocks.MockSkillLockService, _ string) {
svc.EXPECT().Info(gomock.Any(), skills.InfoOptions{Name: "my-skill"}).
Return(nil, fmt.Errorf("database error"))
},
@@ -303,7 +303,7 @@ func TestSkillsRouter(t *testing.T) {
method: "POST",
path: "/",
body: `{"name":"my-skill","clients":["claude-code","opencode"]}`,
- setupMock: func(svc *skillsmocks.MockSkillService, _ string) {
+ setupMock: func(svc *skillsmocks.MockSkillService, _ *skillsmocks.MockSkillLockService, _ string) {
svc.EXPECT().Install(gomock.Any(), skills.InstallOptions{
Name: "my-skill",
Clients: []string{"claude-code", "opencode"},
@@ -324,7 +324,7 @@ func TestSkillsRouter(t *testing.T) {
method: "POST",
path: "/",
body: `{"name":"my-skill","version":"1.2.0","scope":"project","project_root":"{{project_root}}"}`,
- setupMock: func(svc *skillsmocks.MockSkillService, projectRoot string) {
+ setupMock: func(svc *skillsmocks.MockSkillService, _ *skillsmocks.MockSkillLockService, projectRoot string) {
svc.EXPECT().Install(gomock.Any(), skills.InstallOptions{
Name: "my-skill",
Version: "1.2.0",
@@ -346,7 +346,7 @@ func TestSkillsRouter(t *testing.T) {
method: "POST",
path: "/",
body: `{"name":"my-skill","scope":"project"}`,
- setupMock: func(svc *skillsmocks.MockSkillService, _ string) {
+ setupMock: func(svc *skillsmocks.MockSkillService, _ *skillsmocks.MockSkillLockService, _ string) {
svc.EXPECT().Install(gomock.Any(), skills.InstallOptions{
Name: "my-skill",
Scope: skills.ScopeProject,
@@ -364,7 +364,7 @@ func TestSkillsRouter(t *testing.T) {
method: "POST",
path: "/",
body: `{"name":"my-skill","scope":"project","project_root":"{{non_git_project_root}}"}`,
- setupMock: func(svc *skillsmocks.MockSkillService, projectRoot string) {
+ setupMock: func(svc *skillsmocks.MockSkillService, _ *skillsmocks.MockSkillLockService, projectRoot string) {
svc.EXPECT().Install(gomock.Any(), skills.InstallOptions{
Name: "my-skill",
Scope: skills.ScopeProject,
@@ -382,7 +382,7 @@ func TestSkillsRouter(t *testing.T) {
name: "uninstall skill with scope",
method: "DELETE",
path: "/my-skill?scope=project&project_root={{project_root}}",
- setupMock: func(svc *skillsmocks.MockSkillService, projectRoot string) {
+ setupMock: func(svc *skillsmocks.MockSkillService, _ *skillsmocks.MockSkillLockService, projectRoot string) {
svc.EXPECT().Uninstall(gomock.Any(), skills.UninstallOptions{
Name: "my-skill",
Scope: skills.ScopeProject,
@@ -395,7 +395,7 @@ func TestSkillsRouter(t *testing.T) {
name: "uninstall skill project scope missing project root",
method: "DELETE",
path: "/my-skill?scope=project",
- setupMock: func(svc *skillsmocks.MockSkillService, _ string) {
+ setupMock: func(svc *skillsmocks.MockSkillService, _ *skillsmocks.MockSkillLockService, _ string) {
svc.EXPECT().Uninstall(gomock.Any(), skills.UninstallOptions{
Name: "my-skill",
Scope: skills.ScopeProject,
@@ -414,7 +414,7 @@ func TestSkillsRouter(t *testing.T) {
method: "POST",
path: "/validate",
body: `{"path":"/tmp/skill"}`,
- setupMock: func(svc *skillsmocks.MockSkillService, _ string) {
+ setupMock: func(svc *skillsmocks.MockSkillService, _ *skillsmocks.MockSkillLockService, _ string) {
svc.EXPECT().Validate(gomock.Any(), "/tmp/skill").
Return(&skills.ValidationResult{Valid: true}, nil)
},
@@ -426,7 +426,7 @@ func TestSkillsRouter(t *testing.T) {
method: "POST",
path: "/validate",
body: `{invalid`,
- setupMock: func(_ *skillsmocks.MockSkillService, _ string) {},
+ setupMock: func(_ *skillsmocks.MockSkillService, _ *skillsmocks.MockSkillLockService, _ string) {},
expectedStatus: http.StatusBadRequest,
expectedBody: "invalid request body",
},
@@ -435,7 +435,7 @@ func TestSkillsRouter(t *testing.T) {
method: "POST",
path: "/validate",
body: `{"path":"/tmp/skill"}`,
- setupMock: func(svc *skillsmocks.MockSkillService, _ string) {
+ setupMock: func(svc *skillsmocks.MockSkillService, _ *skillsmocks.MockSkillLockService, _ string) {
svc.EXPECT().Validate(gomock.Any(), "/tmp/skill").
Return(nil, fmt.Errorf("validation failed"))
},
@@ -448,7 +448,7 @@ func TestSkillsRouter(t *testing.T) {
method: "POST",
path: "/build",
body: `{"path":"/tmp/skill","tag":"v1.0.0"}`,
- setupMock: func(svc *skillsmocks.MockSkillService, _ string) {
+ setupMock: func(svc *skillsmocks.MockSkillService, _ *skillsmocks.MockSkillLockService, _ string) {
svc.EXPECT().Build(gomock.Any(), skills.BuildOptions{Path: "/tmp/skill", Tag: "v1.0.0"}).
Return(&skills.BuildResult{Reference: "v1.0.0"}, nil)
},
@@ -460,7 +460,7 @@ func TestSkillsRouter(t *testing.T) {
method: "POST",
path: "/build",
body: `{invalid`,
- setupMock: func(_ *skillsmocks.MockSkillService, _ string) {},
+ setupMock: func(_ *skillsmocks.MockSkillService, _ *skillsmocks.MockSkillLockService, _ string) {},
expectedStatus: http.StatusBadRequest,
expectedBody: "invalid request body",
},
@@ -469,7 +469,7 @@ func TestSkillsRouter(t *testing.T) {
method: "POST",
path: "/build",
body: `{"path":"/tmp/skill"}`,
- setupMock: func(svc *skillsmocks.MockSkillService, _ string) {
+ setupMock: func(svc *skillsmocks.MockSkillService, _ *skillsmocks.MockSkillLockService, _ string) {
svc.EXPECT().Build(gomock.Any(), skills.BuildOptions{Path: "/tmp/skill"}).
Return(nil, httperr.WithCode(fmt.Errorf("path is required"), http.StatusBadRequest))
},
@@ -482,7 +482,7 @@ func TestSkillsRouter(t *testing.T) {
method: "POST",
path: "/push",
body: `{"reference":"ghcr.io/test/skill:v1"}`,
- setupMock: func(svc *skillsmocks.MockSkillService, _ string) {
+ setupMock: func(svc *skillsmocks.MockSkillService, _ *skillsmocks.MockSkillLockService, _ string) {
svc.EXPECT().Push(gomock.Any(), skills.PushOptions{Reference: "ghcr.io/test/skill:v1"}).
Return(nil)
},
@@ -493,7 +493,7 @@ func TestSkillsRouter(t *testing.T) {
method: "POST",
path: "/push",
body: `{invalid`,
- setupMock: func(_ *skillsmocks.MockSkillService, _ string) {},
+ setupMock: func(_ *skillsmocks.MockSkillService, _ *skillsmocks.MockSkillLockService, _ string) {},
expectedStatus: http.StatusBadRequest,
expectedBody: "invalid request body",
},
@@ -502,7 +502,7 @@ func TestSkillsRouter(t *testing.T) {
method: "POST",
path: "/push",
body: `{"reference":"ghcr.io/test/skill:v1"}`,
- setupMock: func(svc *skillsmocks.MockSkillService, _ string) {
+ setupMock: func(svc *skillsmocks.MockSkillService, _ *skillsmocks.MockSkillLockService, _ string) {
svc.EXPECT().Push(gomock.Any(), skills.PushOptions{Reference: "ghcr.io/test/skill:v1"}).
Return(fmt.Errorf("push failed"))
},
@@ -514,7 +514,7 @@ func TestSkillsRouter(t *testing.T) {
name: "list builds success empty",
method: "GET",
path: "/builds",
- setupMock: func(svc *skillsmocks.MockSkillService, _ string) {
+ setupMock: func(svc *skillsmocks.MockSkillService, _ *skillsmocks.MockSkillLockService, _ string) {
svc.EXPECT().ListBuilds(gomock.Any()).
Return([]skills.LocalBuild{}, nil)
},
@@ -525,7 +525,7 @@ func TestSkillsRouter(t *testing.T) {
name: "list builds success with results",
method: "GET",
path: "/builds",
- setupMock: func(svc *skillsmocks.MockSkillService, _ string) {
+ setupMock: func(svc *skillsmocks.MockSkillService, _ *skillsmocks.MockSkillLockService, _ string) {
svc.EXPECT().ListBuilds(gomock.Any()).
Return([]skills.LocalBuild{
{Tag: "my-skill", Digest: "sha256:abc123", Name: "my-skill", Version: "1.0.0"},
@@ -538,7 +538,7 @@ func TestSkillsRouter(t *testing.T) {
name: "list builds service error",
method: "GET",
path: "/builds",
- setupMock: func(svc *skillsmocks.MockSkillService, _ string) {
+ setupMock: func(svc *skillsmocks.MockSkillService, _ *skillsmocks.MockSkillLockService, _ string) {
svc.EXPECT().ListBuilds(gomock.Any()).
Return(nil, httperr.WithCode(fmt.Errorf("oci store not configured"), http.StatusInternalServerError))
},
@@ -549,7 +549,7 @@ func TestSkillsRouter(t *testing.T) {
name: "delete build success",
method: "DELETE",
path: "/builds/my-skill",
- setupMock: func(svc *skillsmocks.MockSkillService, _ string) {
+ setupMock: func(svc *skillsmocks.MockSkillService, _ *skillsmocks.MockSkillLockService, _ string) {
svc.EXPECT().DeleteBuild(gomock.Any(), "my-skill").Return(nil)
},
expectedStatus: http.StatusNoContent,
@@ -558,7 +558,7 @@ func TestSkillsRouter(t *testing.T) {
name: "delete build not found",
method: "DELETE",
path: "/builds/missing",
- setupMock: func(svc *skillsmocks.MockSkillService, _ string) {
+ setupMock: func(svc *skillsmocks.MockSkillService, _ *skillsmocks.MockSkillLockService, _ string) {
svc.EXPECT().DeleteBuild(gomock.Any(), "missing").
Return(httperr.WithCode(fmt.Errorf("tag not found"), http.StatusNotFound))
},
@@ -568,13 +568,123 @@ func TestSkillsRouter(t *testing.T) {
name: "delete build service error",
method: "DELETE",
path: "/builds/my-skill",
- setupMock: func(svc *skillsmocks.MockSkillService, _ string) {
+ setupMock: func(svc *skillsmocks.MockSkillService, _ *skillsmocks.MockSkillLockService, _ string) {
svc.EXPECT().DeleteBuild(gomock.Any(), "my-skill").
Return(httperr.WithCode(fmt.Errorf("oci store not configured"), http.StatusInternalServerError))
},
expectedStatus: http.StatusInternalServerError,
expectedBody: "Internal Server Error",
},
+ // syncSkills
+ {
+ name: "sync skills success",
+ method: "POST",
+ path: "/sync",
+ body: `{"project_root":"{{project_root}}"}`,
+ setupMock: func(_ *skillsmocks.MockSkillService, lock *skillsmocks.MockSkillLockService, projectRoot string) {
+ lock.EXPECT().Sync(gomock.Any(), skills.SyncOptions{ProjectRoot: projectRoot}).
+ Return(&skills.SyncResult{Installed: []string{"my-skill"}}, nil)
+ },
+ expectedStatus: http.StatusOK,
+ expectedBody: `"installed":["my-skill"]`,
+ },
+ {
+ name: "sync skills with prune and clients",
+ method: "POST",
+ path: "/sync",
+ body: `{"project_root":"{{project_root}}","clients":["claude-code"],"prune":true}`,
+ setupMock: func(_ *skillsmocks.MockSkillService, lock *skillsmocks.MockSkillLockService, projectRoot string) {
+ lock.EXPECT().Sync(gomock.Any(), skills.SyncOptions{
+ ProjectRoot: projectRoot,
+ Clients: []string{"claude-code"},
+ Prune: true,
+ }).Return(&skills.SyncResult{Pruned: []string{"extra-skill"}}, nil)
+ },
+ expectedStatus: http.StatusOK,
+ expectedBody: `"pruned":["extra-skill"]`,
+ },
+ {
+ name: "sync skills malformed json",
+ method: "POST",
+ path: "/sync",
+ body: `{invalid`,
+ setupMock: func(_ *skillsmocks.MockSkillService, _ *skillsmocks.MockSkillLockService, _ string) {},
+ expectedStatus: http.StatusBadRequest,
+ expectedBody: "invalid request body",
+ },
+ {
+ name: "sync skills service error",
+ method: "POST",
+ path: "/sync",
+ body: `{"project_root":"/not/a/repo"}`,
+ setupMock: func(_ *skillsmocks.MockSkillService, lock *skillsmocks.MockSkillLockService, _ string) {
+ lock.EXPECT().Sync(gomock.Any(), skills.SyncOptions{ProjectRoot: "/not/a/repo"}).
+ Return(nil, httperr.WithCode(fmt.Errorf("project_root must be a git repository"), http.StatusBadRequest))
+ },
+ expectedStatus: http.StatusBadRequest,
+ expectedBody: "must be a git repository",
+ },
+ // upgradeSkills
+ {
+ name: "upgrade skills success",
+ method: "POST",
+ path: "/upgrade",
+ body: `{"project_root":"{{project_root}}"}`,
+ setupMock: func(_ *skillsmocks.MockSkillService, lock *skillsmocks.MockSkillLockService, projectRoot string) {
+ lock.EXPECT().Upgrade(gomock.Any(), skills.UpgradeOptions{ProjectRoot: projectRoot}).
+ Return(&skills.UpgradeResult{
+ Outcomes: []skills.UpgradeOutcome{
+ {Name: "my-skill", Status: skills.UpgradeStatusUpgraded, OldDigest: "sha256:old", NewDigest: "sha256:new"},
+ },
+ }, nil)
+ },
+ expectedStatus: http.StatusOK,
+ expectedBody: `"status":"upgraded"`,
+ },
+ {
+ name: "upgrade skills dry run with names and clients",
+ method: "POST",
+ path: "/upgrade",
+ body: `{"project_root":"{{project_root}}","names":["my-skill"],"dry_run":true,"clients":["claude-code"]}`,
+ setupMock: func(_ *skillsmocks.MockSkillService, lock *skillsmocks.MockSkillLockService, projectRoot string) {
+ lock.EXPECT().Upgrade(gomock.Any(), skills.UpgradeOptions{
+ ProjectRoot: projectRoot,
+ Names: []string{"my-skill"},
+ Preview: true,
+ DryRun: true,
+ Clients: []string{"claude-code"},
+ }).Return(&skills.UpgradeResult{
+ Outcomes: []skills.UpgradeOutcome{
+ {Name: "my-skill", Status: skills.UpgradeStatusUpToDate, OldDigest: "sha256:old", NewDigest: "sha256:old"},
+ },
+ }, nil)
+ },
+ expectedStatus: http.StatusOK,
+ expectedBody: `"status":"up-to-date"`,
+ },
+ {
+ name: "upgrade skills malformed json",
+ method: "POST",
+ path: "/upgrade",
+ body: `{invalid`,
+ setupMock: func(_ *skillsmocks.MockSkillService, _ *skillsmocks.MockSkillLockService, _ string) {},
+ expectedStatus: http.StatusBadRequest,
+ expectedBody: "invalid request body",
+ },
+ {
+ name: "upgrade skills unknown name",
+ method: "POST",
+ path: "/upgrade",
+ body: `{"project_root":"{{project_root}}","names":["missing-skill"]}`,
+ setupMock: func(_ *skillsmocks.MockSkillService, lock *skillsmocks.MockSkillLockService, projectRoot string) {
+ lock.EXPECT().Upgrade(gomock.Any(), skills.UpgradeOptions{
+ ProjectRoot: projectRoot,
+ Names: []string{"missing-skill"},
+ }).Return(nil, httperr.WithCode(fmt.Errorf(`skill "missing-skill" is not present in the lock file`), http.StatusNotFound))
+ },
+ expectedStatus: http.StatusNotFound,
+ expectedBody: "not present in the lock file",
+ },
}
for _, tt := range tests {
@@ -597,10 +707,11 @@ func TestSkillsRouter(t *testing.T) {
ctrl := gomock.NewController(t)
mockSvc := skillsmocks.NewMockSkillService(ctrl)
- tt.setupMock(mockSvc, projectRoot)
+ mockLock := skillsmocks.NewMockSkillLockService(ctrl)
+ tt.setupMock(mockSvc, mockLock, projectRoot)
router := chi.NewRouter()
- router.Mount("/", SkillsRouter(mockSvc))
+ router.Mount("/", SkillsRouter(mockSvc, mockLock))
req := httptest.NewRequest(tt.method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
@@ -621,6 +732,7 @@ func TestListSkillsResponseFormat(t *testing.T) {
ctrl := gomock.NewController(t)
mockSvc := skillsmocks.NewMockSkillService(ctrl)
+ mockLock := skillsmocks.NewMockSkillLockService(ctrl)
mockSvc.EXPECT().List(gomock.Any(), gomock.Any()).
Return([]skills.InstalledSkill{
@@ -633,7 +745,7 @@ func TestListSkillsResponseFormat(t *testing.T) {
}, nil)
router := chi.NewRouter()
- router.Mount("/", SkillsRouter(mockSvc))
+ router.Mount("/", SkillsRouter(mockSvc, mockLock))
req := httptest.NewRequest("GET", "/", nil)
rec := httptest.NewRecorder()
diff --git a/pkg/api/v1/skills_types.go b/pkg/api/v1/skills_types.go
index 833a9b0aa5..5d92e4e773 100644
--- a/pkg/api/v1/skills_types.go
+++ b/pkg/api/v1/skills_types.go
@@ -33,6 +33,8 @@ type installSkillRequest struct {
Force bool `json:"force,omitempty"`
// Group is the group name to add the skill to after installation
Group string `json:"group,omitempty"`
+ // AllowUnsigned permits installing unsigned artifacts for project scope.
+ AllowUnsigned bool `json:"allow_unsigned,omitempty"`
}
// installSkillResponse represents the response after installing a skill.
@@ -67,6 +69,10 @@ type buildSkillRequest struct {
type pushSkillRequest struct {
// OCI reference to push
Reference string `json:"reference"`
+ // Key is the path to a cosign private key for signing.
+ Key string `json:"key,omitempty"`
+ // SkipSigning skips post-push Sigstore signing.
+ SkipSigning bool `json:"skip_signing,omitempty"`
}
// buildListResponse represents the response for listing locally-built OCI skill artifacts.
@@ -76,3 +82,41 @@ type buildListResponse struct {
// List of locally-built OCI skill artifacts
Builds []skills.LocalBuild `json:"builds"`
}
+
+// syncSkillsRequest represents the request to sync a project's skills lock file.
+//
+// @Description Request to sync a project's installed skills to match its lock file
+type syncSkillsRequest struct {
+ // ProjectRoot is the project root path whose lock file should be synced
+ ProjectRoot string `json:"project_root"`
+ // Clients lists target client identifiers, or omit for every detected client
+ Clients []string `json:"clients,omitempty"`
+ // Prune removes project-scoped skills that are installed but not present in the lock file
+ Prune bool `json:"prune,omitempty"`
+ // Check verifies on-disk content against contentDigest without installing
+ Check bool `json:"check,omitempty"`
+ // Adopt writes lock entries for existing unmanaged project-scope installs
+ Adopt bool `json:"adopt,omitempty"`
+}
+
+// upgradeSkillsRequest represents the request to upgrade a project's locked skills.
+//
+// @Description Request to check for and install newer content for locked skills
+type upgradeSkillsRequest struct {
+ // ProjectRoot is the project root path whose lock file should be upgraded
+ ProjectRoot string `json:"project_root"`
+ // Names restricts the upgrade to specific skill names, or omit for every locked skill
+ Names []string `json:"names,omitempty"`
+ // Preview reports what would change without installing (still fetches artifacts)
+ Preview bool `json:"preview,omitempty"`
+ // DryRun is deprecated; use Preview
+ DryRun bool `json:"dry_run,omitempty"`
+ // FailOnChanges exits non-zero when any mutable source would upgrade
+ FailOnChanges bool `json:"fail_on_changes,omitempty"`
+ // AllowRefChange permits resolvedReference changes during upgrade
+ AllowRefChange bool `json:"allow_ref_change,omitempty"`
+ // AllowSignerChange permits signer identity changes during upgrade
+ AllowSignerChange bool `json:"allow_signer_change,omitempty"`
+ // Clients lists target client identifiers, or omit for every detected client
+ Clients []string `json:"clients,omitempty"`
+}
diff --git a/pkg/git/client.go b/pkg/git/client.go
index 2ff35b87ee..9ca5dd9091 100644
--- a/pkg/git/client.go
+++ b/pkg/git/client.go
@@ -40,6 +40,9 @@ type Client interface {
// HeadCommitHash returns the commit hash of the HEAD reference.
HeadCommitHash(repoInfo *RepositoryInfo) (string, error)
+ // HeadCommitSignature returns the OpenPGP/gitsign signature on HEAD, if any.
+ HeadCommitSignature(repoInfo *RepositoryInfo) (string, error)
+
// Cleanup removes local repository directory
Cleanup(ctx context.Context, repoInfo *RepositoryInfo) error
}
@@ -264,3 +267,19 @@ func (*DefaultGitClient) HeadCommitHash(repoInfo *RepositoryInfo) (string, error
return ref.Hash().String(), nil
}
+
+// HeadCommitSignature returns the commit signature attached to HEAD, if present.
+func (*DefaultGitClient) HeadCommitSignature(repoInfo *RepositoryInfo) (string, error) {
+ if repoInfo == nil || repoInfo.Repository == nil {
+ return "", ErrNilRepository
+ }
+ ref, err := repoInfo.Repository.Head()
+ if err != nil {
+ return "", fmt.Errorf("failed to get HEAD reference: %w", err)
+ }
+ commit, err := repoInfo.Repository.CommitObject(ref.Hash())
+ if err != nil {
+ return "", fmt.Errorf("failed to read HEAD commit: %w", err)
+ }
+ return string(commit.PGPSignature), nil
+}
diff --git a/pkg/skills/client/client.go b/pkg/skills/client/client.go
index 4c5c69a606..b699202d46 100644
--- a/pkg/skills/client/client.go
+++ b/pkg/skills/client/client.go
@@ -37,8 +37,9 @@ const (
// running.
var ErrServerUnreachable = errors.New("could not reach ToolHive API server — is 'thv serve' running?")
-// Compile-time interface check.
+// Compile-time interface checks.
var _ skills.SkillService = (*Client)(nil)
+var _ skills.SkillLockService = (*Client)(nil)
// Client is an HTTP client for the ToolHive Skills API.
type Client struct {
@@ -267,6 +268,44 @@ func (c *Client) GetContent(ctx context.Context, opts skills.ContentOptions) (*s
return &content, nil
}
+// Sync installs the exact name/digest pinned in the project's lock file for
+// every entry, restoring skills that are missing or have drifted.
+func (c *Client) Sync(ctx context.Context, opts skills.SyncOptions) (*skills.SyncResult, error) {
+ body := syncRequest{
+ ProjectRoot: opts.ProjectRoot,
+ Clients: opts.Clients,
+ Prune: opts.Prune,
+ Check: opts.Check,
+ Adopt: opts.Adopt,
+ }
+
+ var result skills.SyncResult
+ if err := c.doJSONRequest(ctx, http.MethodPost, "/sync", nil, body, &result); err != nil {
+ return nil, err
+ }
+ return &result, nil
+}
+
+// Upgrade re-resolves each lock file entry's original source and installs
+// any newer content it finds.
+func (c *Client) Upgrade(ctx context.Context, opts skills.UpgradeOptions) (*skills.UpgradeResult, error) {
+ body := upgradeRequest{
+ ProjectRoot: opts.ProjectRoot,
+ Names: opts.Names,
+ Preview: opts.Preview || opts.DryRun,
+ DryRun: opts.DryRun,
+ FailOnChanges: opts.FailOnChanges,
+ AllowRefChange: opts.AllowRefChange,
+ Clients: opts.Clients,
+ }
+
+ var result skills.UpgradeResult
+ if err := c.doJSONRequest(ctx, http.MethodPost, "/upgrade", nil, body, &result); err != nil {
+ return nil, err
+ }
+ return &result, nil
+}
+
// --- internal helpers ---
func (c *Client) buildURL(path string, query url.Values) string {
diff --git a/pkg/skills/client/dto.go b/pkg/skills/client/dto.go
index 104f7e2fad..b19e34154a 100644
--- a/pkg/skills/client/dto.go
+++ b/pkg/skills/client/dto.go
@@ -8,13 +8,14 @@ import "github.com/stacklok/toolhive/pkg/skills"
// --- request/response dto (mirror pkg/api/v1/skills_types.go) ---
type installRequest struct {
- Name string `json:"name"`
- Version string `json:"version,omitempty"`
- Scope skills.Scope `json:"scope,omitempty"`
- ProjectRoot string `json:"project_root,omitempty"`
- Clients []string `json:"clients,omitempty"`
- Force bool `json:"force,omitempty"`
- Group string `json:"group,omitempty"`
+ Name string `json:"name"`
+ Version string `json:"version,omitempty"`
+ Scope skills.Scope `json:"scope,omitempty"`
+ ProjectRoot string `json:"project_root,omitempty"`
+ Clients []string `json:"clients,omitempty"`
+ Force bool `json:"force,omitempty"`
+ Group string `json:"group,omitempty"`
+ AllowUnsigned bool `json:"allow_unsigned,omitempty"`
}
type validateRequest struct {
@@ -27,7 +28,9 @@ type buildRequest struct {
}
type pushRequest struct {
- Reference string `json:"reference"`
+ Reference string `json:"reference"`
+ Key string `json:"key,omitempty"`
+ SkipSigning bool `json:"skip_signing,omitempty"`
}
type listResponse struct {
@@ -41,3 +44,22 @@ type installResponse struct {
type listBuildsResponse struct {
Builds []skills.LocalBuild `json:"builds"`
}
+
+type syncRequest struct {
+ ProjectRoot string `json:"project_root"`
+ Clients []string `json:"clients,omitempty"`
+ Prune bool `json:"prune,omitempty"`
+ Check bool `json:"check,omitempty"`
+ Adopt bool `json:"adopt,omitempty"`
+}
+
+type upgradeRequest struct {
+ ProjectRoot string `json:"project_root"`
+ Names []string `json:"names,omitempty"`
+ Preview bool `json:"preview,omitempty"`
+ DryRun bool `json:"dry_run,omitempty"`
+ FailOnChanges bool `json:"fail_on_changes,omitempty"`
+ AllowRefChange bool `json:"allow_ref_change,omitempty"`
+ AllowSignerChange bool `json:"allow_signer_change,omitempty"`
+ Clients []string `json:"clients,omitempty"`
+}
diff --git a/pkg/skills/gitresolver/resolver.go b/pkg/skills/gitresolver/resolver.go
index aaacd13b33..9b634fdd4b 100644
--- a/pkg/skills/gitresolver/resolver.go
+++ b/pkg/skills/gitresolver/resolver.go
@@ -45,6 +45,8 @@ type ResolveResult struct {
Files []FileEntry
// CommitHash is the git commit hash (for digest/upgrade detection)
CommitHash string
+ // CommitSignature is the gitsign/OpenPGP signature on the commit, if any.
+ CommitSignature string
}
// FileEntry represents a single file from the cloned repository.
@@ -141,6 +143,7 @@ func (r *defaultResolver) Resolve(ctx context.Context, ref *GitReference) (*Reso
if err != nil {
return nil, fmt.Errorf("getting commit hash: %w", err)
}
+ commitSignature, _ := client.HeadCommitSignature(repoInfo)
// Read SKILL.md from the skill path
skillMDPath := path.Join(ref.Path, "SKILL.md")
@@ -173,9 +176,10 @@ func (r *defaultResolver) Resolve(ctx context.Context, ref *GitReference) (*Reso
}
return &ResolveResult{
- SkillConfig: parsed,
- Files: files,
- CommitHash: commitHash,
+ SkillConfig: parsed,
+ Files: files,
+ CommitHash: commitHash,
+ CommitSignature: commitSignature,
}, nil
}
diff --git a/pkg/skills/gitresolver/resolver_test.go b/pkg/skills/gitresolver/resolver_test.go
index 22771a1464..94f536779a 100644
--- a/pkg/skills/gitresolver/resolver_test.go
+++ b/pkg/skills/gitresolver/resolver_test.go
@@ -397,6 +397,10 @@ func (*blockingCloneClient) HeadCommitHash(_ *git.RepositoryInfo) (string, error
return "", fmt.Errorf("not implemented")
}
+func (*blockingCloneClient) HeadCommitSignature(_ *git.RepositoryInfo) (string, error) {
+ return "", nil
+}
+
func (*blockingCloneClient) Cleanup(_ context.Context, _ *git.RepositoryInfo) error {
return nil
}
diff --git a/pkg/skills/lock_service.go b/pkg/skills/lock_service.go
new file mode 100644
index 0000000000..2edd90ed5f
--- /dev/null
+++ b/pkg/skills/lock_service.go
@@ -0,0 +1,19 @@
+// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package skills
+
+import "context"
+
+//go:generate mockgen -destination=mocks/mock_lock_service.go -package=mocks -source=lock_service.go SkillLockService
+
+// SkillLockService defines lock-file operations for project-scoped skills.
+type SkillLockService interface {
+ // Sync installs the exact name/digest pinned in the project's lock file
+ // for every entry, restoring drifted or missing skills.
+ Sync(ctx context.Context, opts SyncOptions) (*SyncResult, error)
+ // Upgrade re-resolves each lock file entry's original source and, when the
+ // resolved digest differs from the pinned one, installs the new content
+ // and updates the lock file entry.
+ Upgrade(ctx context.Context, opts UpgradeOptions) (*UpgradeResult, error)
+}
diff --git a/pkg/skills/lockfile/contentdigest.go b/pkg/skills/lockfile/contentdigest.go
new file mode 100644
index 0000000000..f02f2085eb
--- /dev/null
+++ b/pkg/skills/lockfile/contentdigest.go
@@ -0,0 +1,91 @@
+// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package lockfile
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+)
+
+// ContentFile is a single file used for contentDigest computation.
+type ContentFile struct {
+ // Path is the relative path within the skill directory.
+ Path string
+ // Content is the raw file bytes.
+ Content []byte
+}
+
+// ContentDigestPrefix is the prefix for contentDigest values in the lock file.
+const ContentDigestPrefix = "sha256:"
+
+// ContentDigest computes a deterministic SHA-256 dirhash over files.
+//
+// Algorithm (content-only, no modes/timestamps):
+// 1. Sort files by relative path.
+// 2. For each file, hash path + "\x00" + sha256(content) + "\n" into a running SHA-256.
+// 3. Return "sha256:" + hex(aggregate).
+func ContentDigest(files []ContentFile) (string, error) {
+ if len(files) == 0 {
+ return "", fmt.Errorf("content digest requires at least one file")
+ }
+
+ sorted := append([]ContentFile(nil), files...)
+ sort.Slice(sorted, func(i, j int) bool { return sorted[i].Path < sorted[j].Path })
+
+ h := sha256.New()
+ for _, f := range sorted {
+ path := strings.TrimPrefix(filepath.ToSlash(f.Path), "./")
+ if path == "" || strings.Contains(path, "..") {
+ return "", fmt.Errorf("invalid content file path %q", f.Path)
+ }
+ fileHash := sha256.Sum256(f.Content)
+ if _, err := io.WriteString(h, path); err != nil {
+ return "", err
+ }
+ if _, err := h.Write([]byte{0}); err != nil {
+ return "", err
+ }
+ if _, err := h.Write(fileHash[:]); err != nil {
+ return "", err
+ }
+ if _, err := h.Write([]byte{'\n'}); err != nil {
+ return "", err
+ }
+ }
+ return ContentDigestPrefix + hex.EncodeToString(h.Sum(nil)), nil
+}
+
+// ContentDigestFromDir walks skillDir and computes the content digest from on-disk files.
+func ContentDigestFromDir(skillDir string) (string, error) {
+ var files []ContentFile
+ err := filepath.WalkDir(skillDir, func(path string, d fs.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+ if d.IsDir() {
+ return nil
+ }
+ rel, err := filepath.Rel(skillDir, path)
+ if err != nil {
+ return err
+ }
+ data, err := os.ReadFile(path) // #nosec G304 -- path is under validated skillDir
+ if err != nil {
+ return fmt.Errorf("reading %q: %w", rel, err)
+ }
+ files = append(files, ContentFile{Path: rel, Content: data})
+ return nil
+ })
+ if err != nil {
+ return "", err
+ }
+ return ContentDigest(files)
+}
diff --git a/pkg/skills/lockfile/contentdigest_test.go b/pkg/skills/lockfile/contentdigest_test.go
new file mode 100644
index 0000000000..6dd4ffdad8
--- /dev/null
+++ b/pkg/skills/lockfile/contentdigest_test.go
@@ -0,0 +1,38 @@
+// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package lockfile
+
+import (
+ "os"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestContentDigestDeterministic(t *testing.T) {
+ t.Parallel()
+
+ files := []ContentFile{
+ {Path: "SKILL.md", Content: []byte("# Skill")},
+ {Path: "refs/guide.md", Content: []byte("guide")},
+ }
+ d1, err := ContentDigest(files)
+ require.NoError(t, err)
+ d2, err := ContentDigest(files)
+ require.NoError(t, err)
+ assert.Equal(t, d1, d2)
+ assert.True(t, len(d1) > len(ContentDigestPrefix))
+}
+
+func TestLoadRejectsUnsupportedVersion(t *testing.T) {
+ t.Parallel()
+ dir := resolvedTempDir(t)
+ data := []byte("version: 99\nskills: []\n")
+ require.NoError(t, os.WriteFile(Path(dir), data, 0o644))
+
+ _, err := Load(dir)
+ require.Error(t, err)
+ assert.ErrorIs(t, err, ErrUnsupportedVersion)
+}
diff --git a/pkg/skills/lockfile/lockfile.go b/pkg/skills/lockfile/lockfile.go
new file mode 100644
index 0000000000..8fea434460
--- /dev/null
+++ b/pkg/skills/lockfile/lockfile.go
@@ -0,0 +1,252 @@
+// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+// Package lockfile manages the project-level skills lock file
+// (toolhive.lock.yaml). The lock file pins the exact name, version, and
+// digest of every project-scoped skill install so a team can restore
+// ("thv skill sync") or refresh ("thv skill upgrade") the pinned state.
+package lockfile
+
+import (
+ "errors"
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "sort"
+
+ "gopkg.in/yaml.v3"
+
+ "github.com/stacklok/toolhive/pkg/fileutils"
+)
+
+// FileName is the name of the project-level skills lock file, written to the
+// project root alongside .git.
+const FileName = "toolhive.lock.yaml"
+
+// CurrentVersion is the schema version written to new lock files.
+const CurrentVersion = 1
+
+// Provenance pins the Sigstore publisher identity observed at install time.
+type Provenance struct {
+ // SignerIdentity is the Fulcio certificate identity (e.g. GitHub Actions workflow URI).
+ SignerIdentity string `yaml:"signerIdentity"`
+ // CertIssuer is the Fulcio certificate issuer (e.g. https://token.actions.githubusercontent.com).
+ CertIssuer string `yaml:"certIssuer"`
+ // RepositoryURI is the source repository URI from the certificate, if present.
+ RepositoryURI string `yaml:"repositoryUri,omitempty"`
+ // SigstoreURL is the Rekor instance URL used for verification, if known.
+ SigstoreURL string `yaml:"sigstoreUrl,omitempty"`
+}
+
+// Entry represents a single pinned skill installation in the lock file.
+type Entry struct {
+ // Name is the skill's unique name.
+ Name string `yaml:"name"`
+ // Version is the skill's declared version, if any (from SKILL.md frontmatter).
+ Version string `yaml:"version,omitempty"`
+ // Source is exactly what the user (or the registry resolver) originally
+ // requested — a plain registry name, an OCI reference, or a git://
+ // reference. Upgrade re-resolves this value to check for newer content.
+ Source string `yaml:"source"`
+ // ResolvedReference is the concrete OCI reference or git:// URL that
+ // Source resolved to at install time.
+ ResolvedReference string `yaml:"resolvedReference,omitempty"`
+ // Digest pins the exact content installed: an OCI "sha256:..." digest or
+ // a git commit hash.
+ Digest string `yaml:"digest"`
+ // ContentDigest is a deterministic SHA-256 dirhash of the materialized
+ // skill file set, used for on-disk integrity verification.
+ ContentDigest string `yaml:"contentDigest,omitempty"`
+ // RequiredBy lists parent skill names for transitively materialized deps.
+ RequiredBy []string `yaml:"requiredBy,omitempty"`
+ // Explicit is true when the user directly installed this skill (exempt from
+ // cascade removal when requiredBy becomes empty).
+ Explicit bool `yaml:"explicit,omitempty"`
+ // Provenance pins the verified Sigstore publisher identity for this skill.
+ Provenance *Provenance `yaml:"provenance,omitempty"`
+ // Unsigned is true when install used --allow-unsigned (visible in lock diffs).
+ Unsigned bool `yaml:"unsigned,omitempty"`
+}
+
+// Lockfile is the parsed contents of a project's toolhive.lock.yaml.
+type Lockfile struct {
+ // Version is the lock file schema version.
+ Version int `yaml:"version"`
+ // Skills is the set of pinned skill installations, sorted by name.
+ Skills []Entry `yaml:"skills,omitempty"`
+}
+
+// Path returns the absolute path to the lock file for the given project root.
+func Path(projectRoot string) string {
+ return filepath.Join(projectRoot, FileName)
+}
+
+// Load reads and parses the lock file for projectRoot. A missing lock file
+// is not an error — it returns an empty Lockfile ready to be populated.
+func Load(projectRoot string) (*Lockfile, error) {
+ data, err := os.ReadFile(Path(projectRoot)) // #nosec G304 -- projectRoot is validated by callers before reaching this package
+ if errors.Is(err, fs.ErrNotExist) {
+ return &Lockfile{Version: CurrentVersion}, nil
+ }
+ if err != nil {
+ return nil, fmt.Errorf("reading lock file: %w", err)
+ }
+
+ var lf Lockfile
+ if err := yaml.Unmarshal(data, &lf); err != nil {
+ return nil, fmt.Errorf("parsing lock file: %w", err)
+ }
+ if lf.Version == 0 {
+ lf.Version = CurrentVersion
+ }
+ sortEntries(lf.Skills)
+ if err := validateLockfile(&lf); err != nil {
+ return nil, err
+ }
+ return &lf, nil
+}
+
+// Get returns the entry for name, if present.
+func (l *Lockfile) Get(name string) (Entry, bool) {
+ for _, e := range l.Skills {
+ if e.Name == name {
+ return e, true
+ }
+ }
+ return Entry{}, false
+}
+
+// Upsert inserts or replaces the entry with a matching name, keeping the
+// slice sorted by name for stable diffs.
+func (l *Lockfile) Upsert(entry Entry) {
+ for i := range l.Skills {
+ if l.Skills[i].Name == entry.Name {
+ l.Skills[i] = entry
+ return
+ }
+ }
+ l.Skills = append(l.Skills, entry)
+ sortEntries(l.Skills)
+}
+
+// Remove deletes the entry with the given name, if present. Reports whether
+// an entry was removed.
+func (l *Lockfile) Remove(name string) bool {
+ for i, e := range l.Skills {
+ if e.Name == name {
+ l.Skills = append(l.Skills[:i], l.Skills[i+1:]...)
+ return true
+ }
+ }
+ return false
+}
+
+// Save writes the lock file to projectRoot, creating it if necessary.
+// Callers that need read-modify-write atomicity across processes should use
+// [UpsertEntry] or [RemoveEntry] instead of calling Load+Save directly.
+func (l *Lockfile) Save(projectRoot string) error {
+ if l.Version == 0 {
+ l.Version = CurrentVersion
+ }
+ sortEntries(l.Skills)
+
+ data, err := yaml.Marshal(l)
+ if err != nil {
+ return fmt.Errorf("marshaling lock file: %w", err)
+ }
+
+ path := Path(projectRoot)
+ tmp := path + ".tmp"
+ if err := os.WriteFile(tmp, data, 0o644); err != nil { // #nosec G306 -- lock file is committed to git, not sensitive
+ return fmt.Errorf("writing lock file: %w", err)
+ }
+ if err := os.Rename(tmp, path); err != nil {
+ _ = os.Remove(tmp)
+ return fmt.Errorf("saving lock file: %w", err)
+ }
+ return nil
+}
+
+// UpsertEntry loads the lock file, upserts entry, and saves it back, all
+// under a single file lock so concurrent installs cannot race on
+// read-modify-write.
+func UpsertEntry(projectRoot string, entry Entry) error {
+ return fileutils.WithFileLock(Path(projectRoot), func() error {
+ lf, err := Load(projectRoot)
+ if err != nil {
+ return err
+ }
+ lf.Upsert(entry)
+ return lf.Save(projectRoot)
+ })
+}
+
+// RemoveEntry loads the lock file, removes the named entry if present, and
+// saves it back, all under a single file lock. Removing an entry that does
+// not exist is a no-op, not an error.
+func RemoveEntry(projectRoot string, name string) error {
+ return fileutils.WithFileLock(Path(projectRoot), func() error {
+ lf, err := Load(projectRoot)
+ if err != nil {
+ return err
+ }
+ if !lf.Remove(name) {
+ return nil
+ }
+ return lf.Save(projectRoot)
+ })
+}
+
+// UpdateEntry loads the lock file, applies fn to it, and saves under a file lock.
+func UpdateEntry(projectRoot string, fn func(*Lockfile) error) error {
+ return fileutils.WithFileLock(Path(projectRoot), func() error {
+ lf, err := Load(projectRoot)
+ if err != nil {
+ return err
+ }
+ if err := fn(lf); err != nil {
+ return err
+ }
+ return lf.Save(projectRoot)
+ })
+}
+
+// RemoveParentFromRequiredBy removes parent from each entry's requiredBy list.
+// Returns names of entries that lost their last requiredBy parent and are not explicit.
+func (l *Lockfile) RemoveParentFromRequiredBy(parent string) (cascadeCandidates []string) {
+ for i := range l.Skills {
+ hadParent := containsString(l.Skills[i].RequiredBy, parent)
+ l.Skills[i].RequiredBy = removeString(l.Skills[i].RequiredBy, parent)
+ if hadParent && len(l.Skills[i].RequiredBy) == 0 && !l.Skills[i].Explicit {
+ cascadeCandidates = append(cascadeCandidates, l.Skills[i].Name)
+ }
+ }
+ return cascadeCandidates
+}
+
+func containsString(slice []string, target string) bool {
+ for _, s := range slice {
+ if s == target {
+ return true
+ }
+ }
+ return false
+}
+
+func removeString(slice []string, target string) []string {
+ out := slice[:0]
+ for _, s := range slice {
+ if s != target {
+ out = append(out, s)
+ }
+ }
+ if len(out) == 0 {
+ return nil
+ }
+ return out
+}
+
+func sortEntries(entries []Entry) {
+ sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name })
+}
diff --git a/pkg/skills/lockfile/lockfile_test.go b/pkg/skills/lockfile/lockfile_test.go
new file mode 100644
index 0000000000..718a7befd1
--- /dev/null
+++ b/pkg/skills/lockfile/lockfile_test.go
@@ -0,0 +1,192 @@
+// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package lockfile
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func resolvedTempDir(t *testing.T) string {
+ t.Helper()
+ dir, err := filepath.EvalSymlinks(t.TempDir())
+ require.NoError(t, err)
+ return dir
+}
+
+func TestLoadMissingFileReturnsEmptyLockfile(t *testing.T) {
+ t.Parallel()
+ dir := resolvedTempDir(t)
+
+ lf, err := Load(dir)
+ require.NoError(t, err)
+ assert.Equal(t, CurrentVersion, lf.Version)
+ assert.Empty(t, lf.Skills)
+}
+
+func TestLoadRejectsMalformedYAML(t *testing.T) {
+ t.Parallel()
+ dir := resolvedTempDir(t)
+ require.NoError(t, os.WriteFile(Path(dir), []byte("not: [valid: yaml"), 0o644))
+
+ _, err := Load(dir)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "parsing lock file")
+}
+
+func TestSaveAndLoadRoundTrip(t *testing.T) {
+ t.Parallel()
+ dir := resolvedTempDir(t)
+
+ lf := &Lockfile{Version: CurrentVersion}
+ entry := Entry{
+ Name: "code-review",
+ Version: "1.0.0",
+ Source: "code-review",
+ ResolvedReference: "ghcr.io/org/code-review:1.0.0",
+ Digest: "sha256:abcdef0123456789",
+ }
+ lf.Upsert(entry)
+ require.NoError(t, lf.Save(dir))
+
+ loaded, err := Load(dir)
+ require.NoError(t, err)
+ require.Len(t, loaded.Skills, 1)
+ assert.Equal(t, entry, loaded.Skills[0])
+}
+
+func TestLockfileGetUpsertRemove(t *testing.T) {
+ t.Parallel()
+
+ lf := &Lockfile{Version: CurrentVersion}
+
+ _, ok := lf.Get("missing")
+ assert.False(t, ok)
+
+ a := Entry{Name: "b-skill", Source: "b-skill", Digest: "sha256:abcdef0123456789"}
+ c := Entry{Name: "a-skill", Source: "a-skill", Digest: "sha256:1234567890abcdef"}
+ lf.Upsert(a)
+ lf.Upsert(c)
+
+ // Entries are kept sorted by name.
+ require.Len(t, lf.Skills, 2)
+ assert.Equal(t, "a-skill", lf.Skills[0].Name)
+ assert.Equal(t, "b-skill", lf.Skills[1].Name)
+
+ got, ok := lf.Get("b-skill")
+ require.True(t, ok)
+ assert.Equal(t, "sha256:abcdef0123456789", got.Digest)
+
+ // Upsert replaces an existing entry rather than duplicating it.
+ updated := Entry{Name: "b-skill", Source: "b-skill", Digest: "sha256:fedcba0987654321"}
+ lf.Upsert(updated)
+ require.Len(t, lf.Skills, 2)
+ got, ok = lf.Get("b-skill")
+ require.True(t, ok)
+ assert.Equal(t, "sha256:fedcba0987654321", got.Digest)
+
+ removed := lf.Remove("b-skill")
+ assert.True(t, removed)
+ require.Len(t, lf.Skills, 1)
+
+ removedAgain := lf.Remove("b-skill")
+ assert.False(t, removedAgain)
+}
+
+func TestUpsertEntryAndRemoveEntry(t *testing.T) {
+ t.Parallel()
+ dir := resolvedTempDir(t)
+
+ entry := Entry{Name: "my-skill", Source: "my-skill", Digest: "sha256:abcdef0123456789"}
+ require.NoError(t, UpsertEntry(dir, entry))
+
+ lf, err := Load(dir)
+ require.NoError(t, err)
+ require.Len(t, lf.Skills, 1)
+ assert.Equal(t, "my-skill", lf.Skills[0].Name)
+
+ // Upserting a second entry preserves the first.
+ require.NoError(t, UpsertEntry(dir, Entry{Name: "other-skill", Source: "other-skill", Digest: "sha256:1234567890abcdef"}))
+ lf, err = Load(dir)
+ require.NoError(t, err)
+ assert.Len(t, lf.Skills, 2)
+
+ require.NoError(t, RemoveEntry(dir, "my-skill"))
+ lf, err = Load(dir)
+ require.NoError(t, err)
+ require.Len(t, lf.Skills, 1)
+ assert.Equal(t, "other-skill", lf.Skills[0].Name)
+
+ // Removing a name that isn't present is a no-op, not an error.
+ require.NoError(t, RemoveEntry(dir, "does-not-exist"))
+}
+
+func TestLoadRejectsProvenanceAndUnsignedTogether(t *testing.T) {
+ t.Parallel()
+ dir := resolvedTempDir(t)
+ data := `version: 1
+skills:
+ - name: my-skill
+ source: ghcr.io/org/skill:1.0.0
+ digest: sha256:abcdef0123456789
+ unsigned: true
+ provenance:
+ signerIdentity: "https://github.com/org/repo/.github/workflows/release.yaml@refs/heads/main"
+ certIssuer: "https://token.actions.githubusercontent.com"
+`
+ require.NoError(t, os.WriteFile(Path(dir), []byte(data), 0o644))
+
+ _, err := Load(dir)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "cannot set both unsigned and provenance")
+}
+
+func TestLoadRejectsIncompleteProvenance(t *testing.T) {
+ t.Parallel()
+ dir := resolvedTempDir(t)
+ data := `version: 1
+skills:
+ - name: my-skill
+ source: ghcr.io/org/skill:1.0.0
+ digest: sha256:abcdef0123456789
+ provenance:
+ signerIdentity: "https://github.com/org/repo/.github/workflows/release.yaml@refs/heads/main"
+`
+ require.NoError(t, os.WriteFile(Path(dir), []byte(data), 0o644))
+
+ _, err := Load(dir)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "provenance.certIssuer is required")
+}
+
+func TestSaveAndLoadRoundTripWithProvenance(t *testing.T) {
+ t.Parallel()
+ dir := resolvedTempDir(t)
+
+ lf := &Lockfile{Version: CurrentVersion}
+ entry := Entry{
+ Name: "code-review",
+ Version: "1.0.0",
+ Source: "code-review",
+ ResolvedReference: "ghcr.io/org/code-review:1.0.0",
+ Digest: "sha256:abcdef0123456789",
+ Provenance: &Provenance{
+ SignerIdentity: "https://github.com/org/repo/.github/workflows/release.yaml@refs/heads/main",
+ CertIssuer: "https://token.actions.githubusercontent.com",
+ RepositoryURI: "https://github.com/org/repo",
+ SigstoreURL: "https://rekor.sigstore.dev",
+ },
+ }
+ lf.Upsert(entry)
+ require.NoError(t, lf.Save(dir))
+
+ loaded, err := Load(dir)
+ require.NoError(t, err)
+ require.Len(t, loaded.Skills, 1)
+ assert.Equal(t, entry, loaded.Skills[0])
+}
diff --git a/pkg/skills/lockfile/validation.go b/pkg/skills/lockfile/validation.go
new file mode 100644
index 0000000000..07c969149c
--- /dev/null
+++ b/pkg/skills/lockfile/validation.go
@@ -0,0 +1,117 @@
+// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package lockfile
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+
+ "github.com/stacklok/toolhive/pkg/skills"
+)
+
+// ErrUnsupportedVersion indicates the lock file version is newer than this build supports.
+var ErrUnsupportedVersion = errors.New("unsupported lock file version")
+
+// validateLockfile checks every entry and cross-references requiredBy links.
+func validateLockfile(lf *Lockfile) error {
+ if lf.Version != CurrentVersion {
+ return fmt.Errorf("%w: version %d (upgrade thv to read this lock file)", ErrUnsupportedVersion, lf.Version)
+ }
+
+ names := make(map[string]struct{}, len(lf.Skills))
+ for _, entry := range lf.Skills {
+ if err := validateEntry(entry); err != nil {
+ return err
+ }
+ names[entry.Name] = struct{}{}
+ }
+
+ for _, entry := range lf.Skills {
+ for _, parent := range entry.RequiredBy {
+ if err := skills.ValidateSkillName(parent); err != nil {
+ return fmt.Errorf("entry %q: requiredBy parent %q: %w", entry.Name, parent, err)
+ }
+ if _, ok := names[parent]; !ok {
+ return fmt.Errorf("entry %q: requiredBy references unknown parent %q", entry.Name, parent)
+ }
+ }
+ }
+ return nil
+}
+
+func validateEntry(entry Entry) error {
+ if err := skills.ValidateSkillName(entry.Name); err != nil {
+ return fmt.Errorf("entry name: %w", err)
+ }
+ if entry.Source == "" {
+ return fmt.Errorf("entry %q: source is required", entry.Name)
+ }
+ if entry.Digest == "" {
+ return fmt.Errorf("entry %q: digest is required", entry.Name)
+ }
+ if err := validateDigest(entry.Digest); err != nil {
+ return fmt.Errorf("entry %q: digest: %w", entry.Name, err)
+ }
+ if entry.ContentDigest != "" {
+ if err := validateContentDigest(entry.ContentDigest); err != nil {
+ return fmt.Errorf("entry %q: contentDigest: %w", entry.Name, err)
+ }
+ }
+ if entry.Unsigned && entry.Provenance != nil {
+ return fmt.Errorf("entry %q: cannot set both unsigned and provenance", entry.Name)
+ }
+ if entry.Provenance != nil {
+ if err := validateProvenance(entry.Name, entry.Provenance); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func validateProvenance(entryName string, p *Provenance) error {
+ if p.SignerIdentity == "" {
+ return fmt.Errorf("entry %q: provenance.signerIdentity is required", entryName)
+ }
+ if p.CertIssuer == "" {
+ return fmt.Errorf("entry %q: provenance.certIssuer is required", entryName)
+ }
+ return nil
+}
+
+func validateDigest(d string) error {
+ if strings.HasPrefix(d, "sha256:") {
+ hexPart := strings.TrimPrefix(d, "sha256:")
+ if len(hexPart) < 12 {
+ return fmt.Errorf("sha256 digest too short")
+ }
+ return nil
+ }
+ // Git commit hash (40 hex chars) or abbreviated.
+ if len(d) >= 7 && len(d) <= 64 {
+ for _, c := range d {
+ if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') {
+ return fmt.Errorf("invalid digest format %q", d)
+ }
+ }
+ return nil
+ }
+ return fmt.Errorf("invalid digest format %q", d)
+}
+
+func validateContentDigest(d string) error {
+ if !strings.HasPrefix(d, ContentDigestPrefix) {
+ return fmt.Errorf("must start with %q", ContentDigestPrefix)
+ }
+ hexPart := strings.TrimPrefix(d, ContentDigestPrefix)
+ if len(hexPart) != 64 {
+ return fmt.Errorf("expected 64 hex characters, got %d", len(hexPart))
+ }
+ for _, c := range hexPart {
+ if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
+ return fmt.Errorf("invalid hex character %q", c)
+ }
+ }
+ return nil
+}
diff --git a/pkg/skills/mocks/mock_lock_service.go b/pkg/skills/mocks/mock_lock_service.go
new file mode 100644
index 0000000000..e1c63d4a80
--- /dev/null
+++ b/pkg/skills/mocks/mock_lock_service.go
@@ -0,0 +1,72 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: lock_service.go
+//
+// Generated by this command:
+//
+// mockgen -destination=mocks/mock_lock_service.go -package=mocks -source=lock_service.go SkillLockService
+//
+
+// Package mocks is a generated GoMock package.
+package mocks
+
+import (
+ context "context"
+ reflect "reflect"
+
+ skills "github.com/stacklok/toolhive/pkg/skills"
+ gomock "go.uber.org/mock/gomock"
+)
+
+// MockSkillLockService is a mock of SkillLockService interface.
+type MockSkillLockService struct {
+ ctrl *gomock.Controller
+ recorder *MockSkillLockServiceMockRecorder
+ isgomock struct{}
+}
+
+// MockSkillLockServiceMockRecorder is the mock recorder for MockSkillLockService.
+type MockSkillLockServiceMockRecorder struct {
+ mock *MockSkillLockService
+}
+
+// NewMockSkillLockService creates a new mock instance.
+func NewMockSkillLockService(ctrl *gomock.Controller) *MockSkillLockService {
+ mock := &MockSkillLockService{ctrl: ctrl}
+ mock.recorder = &MockSkillLockServiceMockRecorder{mock}
+ return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockSkillLockService) EXPECT() *MockSkillLockServiceMockRecorder {
+ return m.recorder
+}
+
+// Sync mocks base method.
+func (m *MockSkillLockService) Sync(ctx context.Context, opts skills.SyncOptions) (*skills.SyncResult, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Sync", ctx, opts)
+ ret0, _ := ret[0].(*skills.SyncResult)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// Sync indicates an expected call of Sync.
+func (mr *MockSkillLockServiceMockRecorder) Sync(ctx, opts any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Sync", reflect.TypeOf((*MockSkillLockService)(nil).Sync), ctx, opts)
+}
+
+// Upgrade mocks base method.
+func (m *MockSkillLockService) Upgrade(ctx context.Context, opts skills.UpgradeOptions) (*skills.UpgradeResult, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Upgrade", ctx, opts)
+ ret0, _ := ret[0].(*skills.UpgradeResult)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// Upgrade indicates an expected call of Upgrade.
+func (mr *MockSkillLockServiceMockRecorder) Upgrade(ctx, opts any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Upgrade", reflect.TypeOf((*MockSkillLockService)(nil).Upgrade), ctx, opts)
+}
diff --git a/pkg/skills/options.go b/pkg/skills/options.go
index a822c8918f..7d13ed5876 100644
--- a/pkg/skills/options.go
+++ b/pkg/skills/options.go
@@ -3,6 +3,14 @@
package skills
+// ProvenanceInfo pins verified Sigstore publisher identity for lock-managed installs.
+type ProvenanceInfo struct {
+ SignerIdentity string `json:"-"`
+ CertIssuer string `json:"-"`
+ RepositoryURI string `json:"-"`
+ SigstoreURL string `json:"-"`
+}
+
// ListOptions configures the behavior of the List operation.
type ListOptions struct {
// Scope filters results by installation scope.
@@ -37,12 +45,41 @@ type InstallOptions struct {
Reference string `json:"-"`
// Digest is the OCI digest for upgrade detection.
Digest string `json:"-"`
+ // LockSource overrides the source recorded in the lock file. When empty,
+ // the Name field at the start of Install is used.
+ LockSource string `json:"-"`
+ // ExplicitLock marks the lock entry as explicitly user-requested (exempt
+ // from cascade removal).
+ ExplicitLock bool `json:"-"`
+ // Managed marks the SQLite record as lock-managed.
+ Managed bool `json:"-"`
+ // RequiredByParent, when set, records this install as a transitive dep of
+ // the named parent in the lock file.
+ RequiredByParent string `json:"-"`
+ // SkipDependencies skips materializing toolhive.requires (used internally).
+ SkipDependencies bool `json:"-"`
+ // AllowUnsigned permits installing unsigned artifacts for project scope (recorded in lock).
+ AllowUnsigned bool `json:"allow_unsigned,omitempty"`
+ // SigstoreBundle stores the verified bundle after install (internal).
+ SigstoreBundle []byte `json:"-"`
+ // Provenance records verified publisher identity for lock writes (internal).
+ Provenance *ProvenanceInfo `json:"-"`
+ // Unsigned records that install used --allow-unsigned (internal).
+ Unsigned bool `json:"-"`
}
// InstallResult contains the outcome of an Install operation.
type InstallResult struct {
// Skill is the installed skill.
Skill InstalledSkill `json:"skill"`
+ // Requires lists parsed toolhive.requires from the installed artifact.
+ Requires []Dependency `json:"-"`
+ // ContentDigest is the computed dirhash of the installed file set.
+ ContentDigest string `json:"-"`
+ // Provenance records verified publisher identity from install verification.
+ Provenance *ProvenanceInfo `json:"-"`
+ // Unsigned is true when install recorded an unsigned exception.
+ Unsigned bool `json:"-"`
}
// UninstallOptions configures the behavior of the Uninstall operation.
@@ -131,6 +168,139 @@ type BuildResult struct {
type PushOptions struct {
// Reference is the OCI reference to push.
Reference string `json:"reference"`
+ // Key is the path to a cosign private key for signing. Empty attempts keyless when supported.
+ Key string `json:"key,omitempty"`
+ // SkipSigning skips post-push Sigstore signing.
+ SkipSigning bool `json:"skip_signing,omitempty"`
+}
+
+// SyncOptions configures the behavior of the Sync operation.
+type SyncOptions struct {
+ // ProjectRoot is the project root path whose lock file should be synced.
+ ProjectRoot string `json:"project_root"`
+ // Clients lists target clients (e.g., "claude-code"). Empty means every
+ // skill-supporting client detected on this host.
+ Clients []string `json:"clients,omitempty"`
+ // Prune removes project-scoped skills that are installed but not present
+ // in the lock file. When false, such skills are only reported.
+ Prune bool `json:"prune,omitempty"`
+ // Check verifies on-disk content against contentDigest without installing.
+ Check bool `json:"check,omitempty"`
+ // Adopt writes lock entries for existing unmanaged project-scope installs.
+ Adopt bool `json:"adopt,omitempty"`
+}
+
+// FailureReason is a typed failure reason for sync/upgrade operations.
+type FailureReason string
+
+// Typed failure reasons for sync/upgrade operations.
+const (
+ FailureReasonRegistryUnreachable FailureReason = "registry-unreachable"
+ FailureReasonDigestMissing FailureReason = "digest-missing"
+ FailureReasonValidationRejected FailureReason = "validation-rejected"
+ FailureReasonLockWriteFailed FailureReason = "lock-write-failed"
+ FailureReasonRefChangeBlocked FailureReason = "ref-change-blocked"
+ FailureReasonContentMismatch FailureReason = "content-mismatch"
+ FailureReasonSignatureInvalid FailureReason = "signature-invalid"
+ FailureReasonSignerMismatch FailureReason = "signer-mismatch"
+ FailureReasonUnsignedRejected FailureReason = "unsigned-rejected"
+ FailureReasonUnknown FailureReason = "unknown"
+)
+
+// SyncFailure describes a single skill that failed to sync.
+type SyncFailure struct {
+ // Name is the skill name that failed.
+ Name string `json:"name"`
+ // Reason is a typed failure reason for CI and automation.
+ Reason FailureReason `json:"reason,omitempty"`
+ // Error is a human-readable description of the failure.
+ Error string `json:"error"`
+}
+
+// SyncResult contains the outcome of a Sync operation.
+type SyncResult struct {
+ // Installed lists skills that were installed or reinstalled to match the lock file.
+ Installed []string `json:"installed,omitempty"`
+ // Drifted lists skills whose on-disk contentDigest differed before reinstall.
+ Drifted []string `json:"drifted,omitempty"`
+ // AlreadyCurrent lists skills that already matched the lock file.
+ AlreadyCurrent []string `json:"up_to_date,omitempty"`
+ // NeverManaged lists project-scoped skills never recorded as lock-managed.
+ NeverManaged []string `json:"never_managed,omitempty"`
+ // RemovedFromLock lists previously managed skills absent from the lock file.
+ RemovedFromLock []string `json:"removed_from_lock,omitempty"`
+ // Unmanaged is deprecated; use NeverManaged and RemovedFromLock.
+ Unmanaged []string `json:"unmanaged,omitempty"`
+ // Pruned lists removed-from-lock skills that were uninstalled because Prune was set.
+ Pruned []string `json:"pruned,omitempty"`
+ // Failed lists skills that could not be synced, with the reason for each.
+ Failed []SyncFailure `json:"failed,omitempty"`
+}
+
+// UpgradeOptions configures the behavior of the Upgrade operation.
+type UpgradeOptions struct {
+ // ProjectRoot is the project root path whose lock file should be upgraded.
+ ProjectRoot string `json:"project_root"`
+ // Names restricts the upgrade to specific skill names. Empty means every
+ // entry in the lock file.
+ Names []string `json:"names,omitempty"`
+ // Preview reports what would change without installing (still fetches artifacts).
+ Preview bool `json:"preview,omitempty"`
+ // DryRun is deprecated; use Preview.
+ DryRun bool `json:"dry_run,omitempty"`
+ // FailOnChanges exits with an error when any mutable source would upgrade.
+ FailOnChanges bool `json:"fail_on_changes,omitempty"`
+ // AllowRefChange permits resolvedReference changes during upgrade.
+ AllowRefChange bool `json:"allow_ref_change,omitempty"`
+ // AllowSignerChange permits signer identity changes during upgrade.
+ AllowSignerChange bool `json:"allow_signer_change,omitempty"`
+ // Clients lists target clients (e.g., "claude-code"). Empty means every
+ // skill-supporting client detected on this host.
+ Clients []string `json:"clients,omitempty"`
+}
+
+// UpgradeStatus represents the outcome of upgrading a single skill.
+type UpgradeStatus string
+
+const (
+ // UpgradeStatusUpgraded indicates the skill was installed at a new digest.
+ UpgradeStatusUpgraded UpgradeStatus = "upgraded"
+ // UpgradeStatusUpToDate indicates the resolved source still points at the pinned digest.
+ UpgradeStatusUpToDate UpgradeStatus = "up-to-date"
+ // UpgradeStatusNotUpgradable indicates the entry is pinned to an immutable
+ // reference (an OCI digest or a full git commit hash) and cannot be upgraded.
+ UpgradeStatusNotUpgradable UpgradeStatus = "not-upgradable"
+ // UpgradeStatusRefChangeBlocked indicates re-resolution changed resolvedReference.
+ UpgradeStatusRefChangeBlocked UpgradeStatus = "ref-change-blocked"
+ // UpgradeStatusSignerChangeBlocked indicates the signer identity would change.
+ UpgradeStatusSignerChangeBlocked UpgradeStatus = "signer-change-blocked"
+ // UpgradeStatusFailed indicates the upgrade attempt failed.
+ UpgradeStatusFailed UpgradeStatus = "failed"
+)
+
+// UpgradeOutcome describes the result of attempting to upgrade one skill.
+type UpgradeOutcome struct {
+ // Name is the skill name.
+ Name string `json:"name"`
+ // Status is the outcome of the upgrade attempt.
+ Status UpgradeStatus `json:"status"`
+ // OldDigest is the digest pinned in the lock file before this operation.
+ OldDigest string `json:"old_digest,omitempty"`
+ // NewDigest is the digest the source currently resolves to. Equal to
+ // OldDigest when Status is UpgradeStatusUpToDate.
+ NewDigest string `json:"new_digest,omitempty"`
+ // NewResolvedReference is the new resolvedReference when it changed.
+ NewResolvedReference string `json:"new_resolved_reference,omitempty"`
+ // Reason is a typed failure reason when Status is UpgradeStatusFailed.
+ Reason FailureReason `json:"reason,omitempty"`
+ // Error is a human-readable description of the failure, set only when Status is UpgradeStatusFailed.
+ Error string `json:"error,omitempty"`
+}
+
+// UpgradeResult contains the outcome of an Upgrade operation.
+type UpgradeResult struct {
+ // Outcomes contains one entry per skill considered for upgrade.
+ Outcomes []UpgradeOutcome `json:"outcomes"`
}
// LocalBuild represents a locally-built OCI skill artifact in the local store.
diff --git a/pkg/skills/signer/cosign_attach.go b/pkg/skills/signer/cosign_attach.go
new file mode 100644
index 0000000000..470fe7b3d0
--- /dev/null
+++ b/pkg/skills/signer/cosign_attach.go
@@ -0,0 +1,119 @@
+// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package signer
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "github.com/google/go-containerregistry/pkg/authn"
+ "github.com/google/go-containerregistry/pkg/name"
+ v1 "github.com/google/go-containerregistry/pkg/v1"
+ "github.com/google/go-containerregistry/pkg/v1/empty"
+ "github.com/google/go-containerregistry/pkg/v1/mutate"
+ "github.com/google/go-containerregistry/pkg/v1/remote"
+ "github.com/google/go-containerregistry/pkg/v1/static"
+ "github.com/google/go-containerregistry/pkg/v1/types"
+ "github.com/opencontainers/go-digest"
+)
+
+const mediaTypeCosignSimpleSigningV1JSON = "application/vnd.dev.cosign.simplesigning.v1+json"
+
+type cosignSimpleSigning struct {
+ Critical cosignCritical `json:"critical"`
+}
+
+type cosignCritical struct {
+ Identity cosignIdentity `json:"identity"`
+ Image cosignImage `json:"image"`
+ Type string `json:"type"`
+}
+
+type cosignIdentity struct {
+ DockerReference string `json:"docker-reference"`
+}
+
+type cosignImage struct {
+ DockerManifestDigest string `json:"docker-manifest-digest"`
+}
+
+func attachCosignSignature(
+ ctx context.Context,
+ keychain authn.Keychain,
+ imageRef, digestStr string,
+ signature []byte,
+) error {
+ ref, err := name.ParseReference(imageRef)
+ if err != nil {
+ return fmt.Errorf("parsing image reference: %w", err)
+ }
+ if digestStr == "" {
+ return fmt.Errorf("digest is required for signing")
+ }
+ if !strings.Contains(digestStr, ":") {
+ digestStr = "sha256:" + digestStr
+ }
+ d, err := digest.Parse(digestStr)
+ if err != nil {
+ return fmt.Errorf("parsing digest: %w", err)
+ }
+
+ payload := cosignSimpleSigning{
+ Critical: cosignCritical{
+ Identity: cosignIdentity{DockerReference: ref.Context().Name()},
+ Image: cosignImage{DockerManifestDigest: d.String()},
+ Type: "cosign container image signature",
+ },
+ }
+ payloadBytes, err := json.Marshal(payload)
+ if err != nil {
+ return err
+ }
+
+ layer := static.NewLayer(payloadBytes, mediaTypeCosignSimpleSigningV1JSON)
+ img := empty.Image
+ img, err = mutate.Append(img, mutate.Addendum{
+ Layer: layer,
+ Annotations: map[string]string{
+ "dev.cosignproject.cosign/signature": base64.StdEncoding.EncodeToString(signature),
+ },
+ MediaType: mediaTypeCosignSimpleSigningV1JSON,
+ })
+ if err != nil {
+ return err
+ }
+ img = mutate.MediaType(img, types.OCIManifestSchema1)
+
+ h, err := v1.NewHash(d.String())
+ if err != nil {
+ return err
+ }
+ sigTag := ref.Context().Digest(d.String()).Context().Tag(fmt.Sprint(h.Algorithm, "-", h.Hex, ".sig"))
+ remoteOpts := []remote.Option{remote.WithAuthFromKeychain(keychain), remote.WithContext(ctx)}
+ return remote.Write(sigTag, img, remoteOpts...)
+}
+
+func digestBytesFromString(digestStr string) ([]byte, error) {
+ digestStr = strings.TrimSpace(digestStr)
+ if !strings.Contains(digestStr, ":") {
+ return nil, fmt.Errorf("invalid digest %q", digestStr)
+ }
+ parts := strings.SplitN(digestStr, ":", 2)
+ raw, err := hex.DecodeString(parts[1])
+ if err != nil {
+ return nil, err
+ }
+ return raw, nil
+}
+
+func signDigestWithKeypair(ctx context.Context, keypair interface {
+ SignData(context.Context, []byte) ([]byte, []byte, error)
+}, digestBytes []byte) ([]byte, error) {
+ _, sig, err := keypair.SignData(ctx, digestBytes)
+ return sig, err
+}
diff --git a/pkg/skills/signer/key.go b/pkg/skills/signer/key.go
new file mode 100644
index 0000000000..6cbf2acf72
--- /dev/null
+++ b/pkg/skills/signer/key.go
@@ -0,0 +1,92 @@
+// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package signer
+
+import (
+ "context"
+ "crypto"
+ "fmt"
+ "os"
+
+ protocommon "github.com/sigstore/protobuf-specs/gen/pb-go/common/v1"
+ "github.com/sigstore/sigstore-go/pkg/sign"
+ "github.com/sigstore/sigstore/pkg/cryptoutils"
+)
+
+type fileKeypair struct {
+ priv crypto.PrivateKey
+}
+
+func loadKeypair(path string) (sign.Keypair, error) {
+ pemBytes, err := os.ReadFile(path) //nolint:gosec // path is an explicit --key flag from the user
+ if err != nil {
+ return nil, fmt.Errorf("reading signing key: %w", err)
+ }
+ priv, err := cryptoutils.UnmarshalPEMToPrivateKey(pemBytes, cosignPassFunc())
+ if err != nil {
+ return nil, fmt.Errorf("decoding signing key: %w", err)
+ }
+ return &fileKeypair{priv: priv}, nil
+}
+
+func cosignPassFunc() cryptoutils.PassFunc {
+ if pw := os.Getenv("COSIGN_PASSWORD"); pw != "" {
+ return cryptoutils.StaticPasswordFunc([]byte(pw))
+ }
+ return func(_ bool) ([]byte, error) { return nil, nil }
+}
+
+func (*fileKeypair) GetHashAlgorithm() protocommon.HashAlgorithm {
+ return protocommon.HashAlgorithm_SHA2_256
+}
+
+func (*fileKeypair) GetSigningAlgorithm() protocommon.PublicKeyDetails {
+ return protocommon.PublicKeyDetails_PKIX_ECDSA_P256_SHA_256
+}
+
+func (k *fileKeypair) GetHint() []byte {
+ pubKeyBytes, err := cryptoutils.MarshalPublicKeyToPEM(k.priv.(crypto.Signer).Public())
+ if err != nil {
+ return nil
+ }
+ return pubKeyBytes
+}
+
+func (*fileKeypair) GetKeyAlgorithm() string {
+ return "ecdsa"
+}
+
+func (k *fileKeypair) GetPublicKey() crypto.PublicKey {
+ return k.priv.(crypto.Signer).Public()
+}
+
+func (k *fileKeypair) GetPublicKeyPem() (string, error) {
+ pemBytes, err := cryptoutils.MarshalPublicKeyToPEM(k.GetPublicKey())
+ if err != nil {
+ return "", err
+ }
+ return string(pemBytes), nil
+}
+
+func (k *fileKeypair) SignData(ctx context.Context, data []byte) ([]byte, []byte, error) {
+ _ = ctx
+ signer, ok := k.priv.(crypto.Signer)
+ if !ok {
+ return nil, nil, fmt.Errorf("private key does not implement crypto.Signer")
+ }
+ return signDigest(signer, data)
+}
+
+func signDigest(signer crypto.Signer, data []byte) ([]byte, []byte, error) {
+ hash := crypto.SHA256.New()
+ if _, err := hash.Write(data); err != nil {
+ return nil, nil, err
+ }
+ digest := hash.Sum(nil)
+ sig, err := signer.Sign(nil, digest, crypto.SHA256)
+ if err != nil {
+ return nil, nil, err
+ }
+ return digest, sig, nil
+}
diff --git a/pkg/skills/signer/signer.go b/pkg/skills/signer/signer.go
new file mode 100644
index 0000000000..5a19a97a46
--- /dev/null
+++ b/pkg/skills/signer/signer.go
@@ -0,0 +1,120 @@
+// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+// Package signer signs skill OCI artifacts with Sigstore.
+package signer
+
+import (
+ "context"
+ "crypto"
+ "errors"
+ "fmt"
+ "os"
+ "time"
+
+ "github.com/google/go-containerregistry/pkg/authn"
+ verifyBundle "github.com/sigstore/sigstore-go/pkg/bundle"
+ "github.com/sigstore/sigstore-go/pkg/root"
+ "github.com/sigstore/sigstore-go/pkg/sign"
+ "github.com/sigstore/sigstore/pkg/signature"
+)
+
+var (
+ // ErrKeyRequired indicates keyless signing is not configured and --key is required.
+ ErrKeyRequired = errors.New("signing key required: pass --key or configure keyless OIDC")
+ // ErrSkipSigning indicates signing was explicitly skipped.
+ ErrSkipSigning = errors.New("signing skipped")
+)
+
+// Options configures OCI signing.
+type Options struct {
+ // Key is the path to a cosign private key file. Empty means keyless (when supported).
+ Key string
+ // SkipSigning skips signing entirely.
+ SkipSigning bool
+}
+
+// Signer signs and attaches Sigstore bundles to OCI artifacts.
+type Signer interface {
+ SignOCI(ctx context.Context, ref, digest string, opts Options) ([]byte, error)
+}
+
+// Default implements Signer.
+type Default struct {
+ keychain authn.Keychain
+}
+
+// NewDefault creates a signer using the given registry auth keychain.
+func NewDefault(keychain authn.Keychain) *Default {
+ if keychain == nil {
+ keychain = authn.DefaultKeychain
+ }
+ return &Default{keychain: keychain}
+}
+
+// SignOCI signs the artifact and attaches the bundle as a cosign signature manifest.
+func (d *Default) SignOCI(ctx context.Context, ref, digest string, opts Options) ([]byte, error) {
+ if opts.SkipSigning {
+ return nil, ErrSkipSigning
+ }
+ if opts.Key == "" {
+ return nil, ErrKeyRequired
+ }
+ if _, err := os.Stat(opts.Key); err != nil {
+ return nil, fmt.Errorf("reading signing key: %w", err)
+ }
+
+ keypair, err := loadKeypair(opts.Key)
+ if err != nil {
+ return nil, err
+ }
+ digestBytes, err := digestBytesFromString(digest)
+ if err != nil {
+ return nil, err
+ }
+ sig, err := signDigestWithKeypair(ctx, keypair, digestBytes)
+ if err != nil {
+ return nil, fmt.Errorf("signing digest: %w", err)
+ }
+ if err := attachCosignSignature(ctx, d.keychain, ref, digest, sig); err != nil {
+ return nil, fmt.Errorf("attaching signature manifest: %w", err)
+ }
+
+ return bundleBytesForDigest(keypair, digestBytes)
+}
+
+func bundleBytesForDigest(keypair sign.Keypair, digestBytes []byte) ([]byte, error) {
+ pubKey := keypair.GetPublicKey()
+ trusted := trustedPublicKeyMaterial(pubKey)
+ pb, err := sign.Bundle(&sign.PlainData{Data: digestBytes}, keypair, sign.BundleOptions{
+ TrustedRoot: trusted,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("building sigstore bundle: %w", err)
+ }
+ bun, err := verifyBundle.NewBundle(pb)
+ if err != nil {
+ return nil, err
+ }
+ return bun.MarshalJSON()
+}
+
+type nonExpiringVerifier struct {
+ signature.Verifier
+}
+
+func (*nonExpiringVerifier) ValidAtTime(_ time.Time) bool {
+ return true
+}
+
+func trustedPublicKeyMaterial(pk crypto.PublicKey) root.TrustedMaterial {
+ return root.NewTrustedPublicKeyMaterial(func(string) (root.TimeConstrainedVerifier, error) {
+ verifier, err := signature.LoadVerifier(pk, crypto.SHA256)
+ if err != nil {
+ return nil, err
+ }
+ return &nonExpiringVerifier{Verifier: verifier}, nil
+ })
+}
+
+var _ Signer = (*Default)(nil)
diff --git a/pkg/skills/skillsvc/build.go b/pkg/skills/skillsvc/build.go
index eb89da02a1..a4f7e9d27a 100644
--- a/pkg/skills/skillsvc/build.go
+++ b/pkg/skills/skillsvc/build.go
@@ -17,6 +17,7 @@ import (
"github.com/stacklok/toolhive-core/httperr"
ociskills "github.com/stacklok/toolhive-core/oci/skills"
"github.com/stacklok/toolhive/pkg/skills"
+ "github.com/stacklok/toolhive/pkg/skills/signer"
)
// Validate checks whether a skill definition is valid.
@@ -125,6 +126,14 @@ func (s *service) Push(ctx context.Context, opts skills.PushOptions) error {
return fmt.Errorf("pushing to registry: %w", err)
}
+ if !opts.SkipSigning {
+ if _, signErr := s.artifactSigner().SignOCI(ctx, opts.Reference, d.String(), signer.Options{
+ Key: opts.Key, SkipSigning: opts.SkipSigning,
+ }); signErr != nil && !errors.Is(signErr, signer.ErrSkipSigning) {
+ return fmt.Errorf("signing pushed artifact: %w", signErr)
+ }
+ }
+
return nil
}
diff --git a/pkg/skills/skillsvc/build_test.go b/pkg/skills/skillsvc/build_test.go
index ae53da68bd..e85521b759 100644
--- a/pkg/skills/skillsvc/build_test.go
+++ b/pkg/skills/skillsvc/build_test.go
@@ -393,7 +393,7 @@ func TestPush(t *testing.T) {
},
{
name: "successful push",
- opts: skills.PushOptions{Reference: "my-tag"},
+ opts: skills.PushOptions{Reference: "my-tag", SkipSigning: true},
setup: func(ctrl *gomock.Controller) (ociskills.RegistryClient, *ociskills.Store) {
ociStore, err := ociskills.NewStore(t.TempDir())
require.NoError(t, err)
diff --git a/pkg/skills/skillsvc/content_digest.go b/pkg/skills/skillsvc/content_digest.go
new file mode 100644
index 0000000000..9322d3e548
--- /dev/null
+++ b/pkg/skills/skillsvc/content_digest.go
@@ -0,0 +1,65 @@
+// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package skillsvc
+
+import (
+ "fmt"
+ "strings"
+
+ ociskills "github.com/stacklok/toolhive-core/oci/skills"
+ "github.com/stacklok/toolhive/pkg/skills"
+ "github.com/stacklok/toolhive/pkg/skills/gitresolver"
+ "github.com/stacklok/toolhive/pkg/skills/lockfile"
+)
+
+// contentDigestFromLayerData computes contentDigest from a tar.gz OCI layer.
+func contentDigestFromLayerData(layerData []byte) (string, error) {
+ tarData, err := ociskills.DecompressWithLimit(layerData, skills.MaxTotalExtractSize)
+ if err != nil {
+ return "", fmt.Errorf("decompressing layer: %w", err)
+ }
+ entries, err := ociskills.ExtractTarWithLimit(tarData, skills.MaxFileExtractSize)
+ if err != nil {
+ return "", fmt.Errorf("extracting tar: %w", err)
+ }
+ return contentDigestFromOCIEntries(entries)
+}
+
+func contentDigestFromOCIEntries(entries []ociskills.FileEntry) (string, error) {
+ files := make([]lockfile.ContentFile, 0, len(entries))
+ for _, e := range entries {
+ files = append(files, lockfile.ContentFile{Path: e.Path, Content: e.Content})
+ }
+ return lockfile.ContentDigest(files)
+}
+
+func contentDigestFromGitFiles(files []gitresolver.FileEntry) (string, error) {
+ contentFiles := make([]lockfile.ContentFile, 0, len(files))
+ for _, f := range files {
+ contentFiles = append(contentFiles, lockfile.ContentFile{Path: f.Path, Content: f.Content})
+ }
+ return lockfile.ContentDigest(contentFiles)
+}
+
+// requiresFromLayerData parses toolhive.requires from SKILL.md in a layer.
+func requiresFromLayerData(layerData []byte) ([]skills.Dependency, error) {
+ tarData, err := ociskills.DecompressWithLimit(layerData, skills.MaxTotalExtractSize)
+ if err != nil {
+ return nil, fmt.Errorf("decompressing layer: %w", err)
+ }
+ entries, err := ociskills.ExtractTarWithLimit(tarData, skills.MaxFileExtractSize)
+ if err != nil {
+ return nil, fmt.Errorf("extracting tar: %w", err)
+ }
+ for _, e := range entries {
+ if strings.EqualFold(e.Path, "SKILL.md") || strings.HasSuffix(strings.ToLower(e.Path), "/skill.md") {
+ parsed, parseErr := skills.ParseSkillMD(e.Content)
+ if parseErr != nil {
+ return nil, fmt.Errorf("parsing SKILL.md: %w", parseErr)
+ }
+ return parsed.Requires, nil
+ }
+ }
+ return nil, nil
+}
diff --git a/pkg/skills/skillsvc/install.go b/pkg/skills/skillsvc/install.go
index 028f78530f..88e3f465b2 100644
--- a/pkg/skills/skillsvc/install.go
+++ b/pkg/skills/skillsvc/install.go
@@ -21,7 +21,64 @@ import (
// the registry and extracted. When LayerData is provided, the skill is extracted
// to disk and a full installation record is created. Without LayerData, a
// pending record is created.
+//
+// For project-scoped installs, a successful result also upserts an entry in
+// the project's toolhive.lock.yaml, pinning the resolved reference and digest
+// so the install can be restored later with "thv skill sync". opts.Name is
+// recorded as the entry's Source exactly as given here (before any internal
+// resolution), so "thv skill upgrade" can re-resolve it later.
func (s *service) Install(ctx context.Context, opts skills.InstallOptions) (*skills.InstallResult, error) {
+ source := opts.LockSource
+ if source == "" {
+ source = opts.Name
+ }
+ if opts.Scope == skills.ScopeProject || opts.ProjectRoot != "" {
+ opts.Managed = true
+ }
+ if opts.RequiredByParent == "" {
+ opts.ExplicitLock = true
+ }
+
+ result, err := s.installInternal(ctx, opts)
+ if err != nil {
+ return nil, err
+ }
+
+ if !opts.SkipDependencies && len(result.Requires) > 0 {
+ state := newDepInstallState()
+ state.visited[result.Skill.Metadata.Name] = struct{}{}
+ if matErr := s.materializeDependencies(
+ ctx, opts, result.Skill.Scope, result.Skill.Metadata.Name, result.Requires, state,
+ ); matErr != nil {
+ return nil, matErr
+ }
+ result.Skill.Dependencies = result.Requires
+ if storeErr := s.store.Update(ctx, result.Skill); storeErr != nil {
+ return nil, fmt.Errorf("updating skill dependencies: %w", storeErr)
+ }
+ }
+
+ contentDigest := result.ContentDigest
+ if contentDigest == "" {
+ var cdErr error
+ contentDigest, cdErr = s.contentDigestForSkill(ctx, opts, result.Skill)
+ if cdErr != nil {
+ return nil, cdErr
+ }
+ }
+
+ if lockErr := recordLockEntry(opts, source, result.Skill, contentDigest); lockErr != nil {
+ return nil, httperr.WithCode(lockErr, http.StatusInternalServerError)
+ }
+
+ return result, nil
+}
+
+// installInternal performs the actual install dispatch without touching the
+// lock file. It is used directly by Install, and by Sync/Upgrade which
+// manage lock file entries themselves (they pass a Name that has already
+// been resolved or pinned, which must not be recorded as the entry's Source).
+func (s *service) installInternal(ctx context.Context, opts skills.InstallOptions) (*skills.InstallResult, error) {
scope, projectRoot, err := normalizeProjectRoot(opts.Scope, opts.ProjectRoot)
if err != nil {
return nil, err
@@ -29,6 +86,7 @@ func (s *service) Install(ctx context.Context, opts skills.InstallOptions) (*ski
scope = defaultScope(scope)
// Canonicalize the project root so that equivalent paths produce
// the same lock key and DB record.
+ opts.Scope = scope
opts.ProjectRoot = projectRoot
// Git references (git://host/owner/repo[@ref][#path]) are dispatched first;
diff --git a/pkg/skills/skillsvc/install_extraction.go b/pkg/skills/skillsvc/install_extraction.go
index 60a4da681b..b1f75b2be9 100644
--- a/pkg/skills/skillsvc/install_extraction.go
+++ b/pkg/skills/skillsvc/install_extraction.go
@@ -34,7 +34,11 @@ func (s *service) installWithExtraction(
}
if isExtractionNoOp(existing, storeErr, opts, clientTypes) {
- return &skills.InstallResult{Skill: existing}, nil
+ result := &skills.InstallResult{Skill: existing}
+ if err := enrichInstallResult(result, opts); err != nil {
+ return nil, err
+ }
+ return result, nil
}
digestMatches := storeErr == nil && existing.Digest == opts.Digest
@@ -73,7 +77,11 @@ func (s *service) installExtractionSameDigestNewClients(
) (*skills.InstallResult, error) {
toWrite := missingClients(existing.Clients, clientTypes)
if len(toWrite) == 0 {
- return &skills.InstallResult{Skill: existing}, nil
+ result := &skills.InstallResult{Skill: existing}
+ if err := enrichInstallResult(result, opts); err != nil {
+ return nil, err
+ }
+ return result, nil
}
// Deduplicate and skip directories already owned by existing clients.
dirsToWrite := uniqueDirClients(toWrite, clientDirs, existingClientDirs(existing.Clients, clientDirs))
@@ -83,7 +91,11 @@ func (s *service) installExtractionSameDigestNewClients(
if err := s.store.Update(ctx, sk); err != nil {
return nil, err
}
- return &skills.InstallResult{Skill: sk}, nil
+ result := &skills.InstallResult{Skill: sk}
+ if err := enrichInstallResult(result, opts); err != nil {
+ return nil, err
+ }
+ return result, nil
}
var written []string
for _, ct := range dirsToWrite {
@@ -106,7 +118,11 @@ func (s *service) installExtractionSameDigestNewClients(
removeSkillDirs(s.installer, clientDirs, written)
return nil, err
}
- return &skills.InstallResult{Skill: sk}, nil
+ result := &skills.InstallResult{Skill: sk}
+ if err := enrichInstallResult(result, opts); err != nil {
+ return nil, err
+ }
+ return result, nil
}
func removeSkillDirs(inst skills.Installer, clientDirs map[string]string, clients []string) {
@@ -144,7 +160,11 @@ func (s *service) installExtractionUpgradeDigest(
removeSkillDirs(s.installer, allDirs, dirsToWrite)
return nil, err
}
- return &skills.InstallResult{Skill: sk}, nil
+ result := &skills.InstallResult{Skill: sk}
+ if err := enrichInstallResult(result, opts); err != nil {
+ return nil, err
+ }
+ return result, nil
}
func (s *service) installExtractionFresh(
@@ -180,7 +200,11 @@ func (s *service) installExtractionFresh(
removeSkillDirs(s.installer, clientDirs, dirsToWrite)
return nil, err
}
- return &skills.InstallResult{Skill: sk}, nil
+ result := &skills.InstallResult{Skill: sk}
+ if err := enrichInstallResult(result, opts); err != nil {
+ return nil, err
+ }
+ return result, nil
}
// buildInstalledSkill constructs an InstalledSkill from install options.
@@ -199,12 +223,33 @@ func buildInstalledSkill(
Name: opts.Name,
Version: opts.Version,
},
- Scope: scope,
- ProjectRoot: opts.ProjectRoot,
- Reference: opts.Reference,
- Digest: opts.Digest,
- Status: skills.InstallStatusInstalled,
- InstalledAt: time.Now().UTC(),
- Clients: clients,
+ Scope: scope,
+ ProjectRoot: opts.ProjectRoot,
+ Reference: opts.Reference,
+ Digest: opts.Digest,
+ Status: skills.InstallStatusInstalled,
+ InstalledAt: time.Now().UTC(),
+ Clients: clients,
+ Managed: opts.Managed,
+ SigstoreBundle: opts.SigstoreBundle,
+ }
+}
+
+func enrichInstallResult(result *skills.InstallResult, opts skills.InstallOptions) error {
+ result.Provenance = opts.Provenance
+ result.Unsigned = opts.Unsigned
+ if len(opts.LayerData) == 0 {
+ return nil
+ }
+ requires, err := requiresFromLayerData(opts.LayerData)
+ if err != nil {
+ return err
+ }
+ result.Requires = requires
+ digest, err := contentDigestFromLayerData(opts.LayerData)
+ if err != nil {
+ return err
}
+ result.ContentDigest = digest
+ return nil
}
diff --git a/pkg/skills/skillsvc/install_git.go b/pkg/skills/skillsvc/install_git.go
index 9a9bb672bc..175d193f39 100644
--- a/pkg/skills/skillsvc/install_git.go
+++ b/pkg/skills/skillsvc/install_git.go
@@ -66,6 +66,16 @@ func (s *service) installFromGit(
)
}
+ if scope == skills.ScopeProject && opts.ProjectRoot != "" {
+ decision, verifyErr := s.verifyGitInstall(
+ ctx, opts, resolved.SkillConfig.Name, resolved.CommitHash, resolved.CommitSignature,
+ )
+ if verifyErr != nil {
+ return nil, verifyErr
+ }
+ applyDecisionToOpts(&opts, decision)
+ }
+
// Hydrate install options from the git result.
opts.Name = resolved.SkillConfig.Name
opts.Reference = gitURL
@@ -82,7 +92,7 @@ func (s *service) installFromGit(
return nil, err
}
- return s.applyGitInstall(ctx, opts, scope, clientTypes, clientDirs, resolved.Files)
+ return s.applyGitInstall(ctx, opts, scope, clientTypes, clientDirs, resolved.Files, resolved.SkillConfig)
}
// applyGitInstall handles the create/upgrade/no-op logic for a git-based skill
@@ -95,6 +105,7 @@ func (s *service) applyGitInstall(
clientTypes []string,
clientDirs map[string]string,
files []gitresolver.FileEntry,
+ parsed *skills.ParseResult,
) (*skills.InstallResult, error) {
existing, storeErr := s.store.Get(ctx, opts.Name, scope, opts.ProjectRoot)
isNotFound := errors.Is(storeErr, storage.ErrNotFound)
@@ -102,9 +113,9 @@ func (s *service) applyGitInstall(
return nil, fmt.Errorf("checking existing skill: %w", storeErr)
}
if !isNotFound {
- return s.applyGitInstallExisting(ctx, opts, scope, existing, clientTypes, clientDirs, files)
+ return s.applyGitInstallExisting(ctx, opts, scope, existing, clientTypes, clientDirs, files, parsed)
}
- return s.applyGitInstallFresh(ctx, opts, scope, clientTypes, clientDirs, files)
+ return s.applyGitInstallFresh(ctx, opts, scope, clientTypes, clientDirs, files, parsed)
}
func (s *service) applyGitInstallExisting(
@@ -115,6 +126,7 @@ func (s *service) applyGitInstallExisting(
clientTypes []string,
clientDirs map[string]string,
files []gitresolver.FileEntry,
+ parsed *skills.ParseResult,
) (*skills.InstallResult, error) {
if existing.Digest != opts.Digest {
allClients, allDirs, err := s.expandToExistingClients(
@@ -125,22 +137,30 @@ func (s *service) applyGitInstallExisting(
// Deduplicate so clients sharing the same directory don't conflict.
dirsToWrite := uniqueDirClients(allClients, allDirs, nil)
return s.gitWriteMultiAndPersist(ctx, opts, scope, allClients, allDirs, files,
- dirsToWrite, nil, true, true)
+ dirsToWrite, nil, true, true, parsed)
}
clientsExplicit := len(opts.Clients) > 0
if clientsContainAll(existing.Clients, clientTypes) ||
(len(existing.Clients) == 0 && len(clientTypes) <= 1 && !clientsExplicit) {
- return &skills.InstallResult{Skill: existing}, nil
+ result := &skills.InstallResult{Skill: existing}
+ if err := enrichGitInstallResult(result, parsed, files); err != nil {
+ return nil, err
+ }
+ return result, nil
}
toWrite := missingClients(existing.Clients, clientTypes)
if len(toWrite) == 0 {
- return &skills.InstallResult{Skill: existing}, nil
+ result := &skills.InstallResult{Skill: existing}
+ if err := enrichGitInstallResult(result, parsed, files); err != nil {
+ return nil, err
+ }
+ return result, nil
}
// Deduplicate and skip directories already owned by existing clients.
dirsToWrite := uniqueDirClients(toWrite, clientDirs, existingClientDirs(existing.Clients, clientDirs))
if len(dirsToWrite) == 0 {
return s.gitWriteMultiAndPersist(ctx, opts, scope, clientTypes, clientDirs, files,
- nil, existing.Clients, true, false)
+ nil, existing.Clients, true, false, parsed)
}
for _, ct := range dirsToWrite {
dir := filepath.Clean(clientDirs[ct])
@@ -152,7 +172,7 @@ func (s *service) applyGitInstallExisting(
}
}
return s.gitWriteMultiAndPersist(ctx, opts, scope, clientTypes, clientDirs, files,
- dirsToWrite, existing.Clients, true, false)
+ dirsToWrite, existing.Clients, true, false, parsed)
}
func (s *service) applyGitInstallFresh(
@@ -162,6 +182,7 @@ func (s *service) applyGitInstallFresh(
clientTypes []string,
clientDirs map[string]string,
files []gitresolver.FileEntry,
+ parsed *skills.ParseResult,
) (*skills.InstallResult, error) {
// Deduplicate so clients sharing the same directory don't conflict.
dirsToCheck := uniqueDirClients(clientTypes, clientDirs, nil)
@@ -175,7 +196,7 @@ func (s *service) applyGitInstallFresh(
}
}
return s.gitWriteMultiAndPersist(ctx, opts, scope, clientTypes, clientDirs, files,
- dirsToCheck, nil, false, false)
+ dirsToCheck, nil, false, false, parsed)
}
// gitWriteMultiAndPersist writes git files to the given client directories,
@@ -191,6 +212,7 @@ func (s *service) gitWriteMultiAndPersist(
dirsToWrite []string,
existingClients []string,
isUpgrade, writeAggressive bool,
+ parsed *skills.ParseResult,
) (*skills.InstallResult, error) {
var written []string
for _, ct := range dirsToWrite {
@@ -231,5 +253,24 @@ func (s *service) gitWriteMultiAndPersist(
return nil, err
}
}
- return &skills.InstallResult{Skill: sk}, nil
+ result := &skills.InstallResult{Skill: sk}
+ if err := enrichGitInstallResult(result, parsed, files); err != nil {
+ for _, wct := range written {
+ _ = s.installer.Remove(filepath.Clean(clientDirs[wct]))
+ }
+ return nil, err
+ }
+ return result, nil
+}
+
+func enrichGitInstallResult(result *skills.InstallResult, parsed *skills.ParseResult, files []gitresolver.FileEntry) error {
+ if parsed != nil {
+ result.Requires = parsed.Requires
+ }
+ digest, err := contentDigestFromGitFiles(files)
+ if err != nil {
+ return err
+ }
+ result.ContentDigest = digest
+ return nil
}
diff --git a/pkg/skills/skillsvc/install_oci.go b/pkg/skills/skillsvc/install_oci.go
index f77be955df..476015918a 100644
--- a/pkg/skills/skillsvc/install_oci.go
+++ b/pkg/skills/skillsvc/install_oci.go
@@ -99,6 +99,14 @@ func (s *service) installFromOCI(
)
}
+ if scope == skills.ScopeProject && opts.ProjectRoot != "" {
+ decision, verifyErr := s.verifyOCIInstall(ctx, opts, skillConfig.Name, ociRef, pulledDigest.String())
+ if verifyErr != nil {
+ return nil, verifyErr
+ }
+ applyDecisionToOpts(&opts, decision)
+ }
+
// Hydrate install options from the pulled artifact.
opts.Name = skillConfig.Name
opts.LayerData = layerData
@@ -182,6 +190,14 @@ func (s *service) tryDirectLocalTag(
return false, nil
}
+ if opts.Scope == skills.ScopeProject && opts.ProjectRoot != "" {
+ decision, verifyErr := verifyLocalInstall(*opts, skillConfig.Name)
+ if verifyErr != nil {
+ return false, verifyErr
+ }
+ applyDecisionToOpts(opts, decision)
+ }
+
hydrateOptsFromLocalBuild(opts, layerData, d, skillConfig, opts.Name)
return true, nil
}
diff --git a/pkg/skills/skillsvc/lock.go b/pkg/skills/skillsvc/lock.go
new file mode 100644
index 0000000000..92d4dbdb00
--- /dev/null
+++ b/pkg/skills/skillsvc/lock.go
@@ -0,0 +1,202 @@
+// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package skillsvc
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+
+ "github.com/stacklok/toolhive-core/httperr"
+ "github.com/stacklok/toolhive/pkg/skills"
+ "github.com/stacklok/toolhive/pkg/skills/lockfile"
+)
+
+// recordLockEntry upserts a project-scope lock file entry after a successful
+// install. Returns an error when the lock write fails so callers can fail the
+// install command for project scope.
+func recordLockEntry(opts skills.InstallOptions, source string, sk skills.InstalledSkill, contentDigest string) error {
+ if sk.Scope != skills.ScopeProject || sk.ProjectRoot == "" {
+ return nil
+ }
+
+ entry := lockfile.Entry{
+ Name: sk.Metadata.Name,
+ Version: sk.Metadata.Version,
+ Source: source,
+ ResolvedReference: sk.Reference,
+ Digest: sk.Digest,
+ ContentDigest: contentDigest,
+ Explicit: opts.ExplicitLock,
+ Provenance: provenanceInfoToLock(opts.Provenance),
+ Unsigned: opts.Unsigned,
+ }
+
+ if opts.RequiredByParent != "" {
+ entry.RequiredBy = []string{opts.RequiredByParent}
+ }
+
+ if err := lockfile.UpsertEntry(sk.ProjectRoot, entry); err != nil {
+ return fmt.Errorf(
+ "skill %q installed but lock NOT updated — do not commit; re-run or fix permissions: %w",
+ sk.Metadata.Name, err,
+ )
+ }
+ return nil
+}
+
+// recordDepLockEntry merges a transitive dependency into the lock file.
+func recordDepLockEntry(
+ projectRoot, parent, source string,
+ sk skills.InstalledSkill,
+ contentDigest string,
+ opts skills.InstallOptions,
+) error {
+ return lockfile.UpdateEntry(projectRoot, func(lf *lockfile.Lockfile) error {
+ existing, ok := lf.Get(sk.Metadata.Name)
+ if ok {
+ existing.RequiredBy = appendUnique(existing.RequiredBy, parent)
+ existing.Version = sk.Metadata.Version
+ existing.ResolvedReference = sk.Reference
+ existing.Digest = sk.Digest
+ existing.ContentDigest = contentDigest
+ if existing.Source == "" {
+ existing.Source = source
+ }
+ lf.Upsert(existing)
+ return nil
+ }
+ lf.Upsert(lockfile.Entry{
+ Name: sk.Metadata.Name,
+ Version: sk.Metadata.Version,
+ Source: source,
+ ResolvedReference: sk.Reference,
+ Digest: sk.Digest,
+ ContentDigest: contentDigest,
+ RequiredBy: []string{parent},
+ Provenance: provenanceInfoToLock(opts.Provenance),
+ Unsigned: opts.Unsigned,
+ })
+ return nil
+ })
+}
+
+func appendUnique(slice []string, value string) []string {
+ for _, s := range slice {
+ if s == value {
+ return slice
+ }
+ }
+ return append(slice, value)
+}
+
+type depInstallState struct {
+ visited map[string]struct{}
+ count int
+}
+
+func newDepInstallState() *depInstallState {
+ return &depInstallState{visited: make(map[string]struct{})}
+}
+
+// materializeDependencies installs toolhive.requires transitively for all scopes.
+func (s *service) materializeDependencies(
+ ctx context.Context,
+ baseOpts skills.InstallOptions,
+ scope skills.Scope,
+ parentName string,
+ requires []skills.Dependency,
+ state *depInstallState,
+) error {
+ for _, dep := range requires {
+ if dep.Reference == "" {
+ continue
+ }
+ if err := s.materializeOneDependency(ctx, baseOpts, scope, parentName, dep.Reference, state); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (s *service) materializeOneDependency(
+ ctx context.Context,
+ baseOpts skills.InstallOptions,
+ scope skills.Scope,
+ parentName string,
+ depRef string,
+ state *depInstallState,
+) error {
+ if _, ok := state.visited[depRef]; ok {
+ return httperr.WithCode(
+ fmt.Errorf("circular dependency detected involving %q", depRef),
+ http.StatusUnprocessableEntity,
+ )
+ }
+ state.visited[depRef] = struct{}{}
+ state.count++
+ if state.count > skills.MaxDependencies {
+ return httperr.WithCode(
+ fmt.Errorf("too many dependencies: exceeds limit of %d", skills.MaxDependencies),
+ http.StatusUnprocessableEntity,
+ )
+ }
+
+ depOpts := baseOpts
+ depOpts.Name = depRef
+ depOpts.LockSource = depRef
+ depOpts.RequiredByParent = parentName
+ depOpts.ExplicitLock = false
+ depOpts.Managed = baseOpts.Managed
+ depOpts.SkipDependencies = false
+
+ result, err := s.installInternal(ctx, depOpts)
+ if err != nil {
+ return fmt.Errorf("installing dependency %q required by %q: %w", depRef, parentName, err)
+ }
+
+ // Nested requires from the installed dependency artifact.
+ if len(result.Requires) > 0 {
+ if err := s.materializeDependencies(ctx, baseOpts, scope, result.Skill.Metadata.Name, result.Requires, state); err != nil {
+ return err
+ }
+ }
+
+ if scope != skills.ScopeProject || baseOpts.ProjectRoot == "" {
+ return nil
+ }
+
+ contentDigest, cdErr := s.contentDigestForSkill(ctx, depOpts, result.Skill)
+ if cdErr != nil {
+ return cdErr
+ }
+ return recordDepLockEntry(baseOpts.ProjectRoot, parentName, depRef, result.Skill, contentDigest, depOpts)
+}
+
+func (s *service) contentDigestForSkill(
+ ctx context.Context,
+ opts skills.InstallOptions,
+ sk skills.InstalledSkill,
+) (string, error) {
+ if len(opts.LayerData) > 0 {
+ return contentDigestFromLayerData(opts.LayerData)
+ }
+ if s.pathResolver == nil {
+ return "", errors.New("path resolver is required to compute content digest")
+ }
+ clientTypes := sk.Clients
+ if len(clientTypes) == 0 {
+ clientTypes = s.pathResolver.ListSkillSupportingClients()
+ }
+ if len(clientTypes) == 0 {
+ return "", errors.New("no client directories available for content digest")
+ }
+ skillPath, err := s.pathResolver.GetSkillPath(clientTypes[0], sk.Metadata.Name, sk.Scope, sk.ProjectRoot)
+ if err != nil {
+ return "", err
+ }
+ _ = ctx
+ return lockfile.ContentDigestFromDir(skillPath)
+}
diff --git a/pkg/skills/skillsvc/lock_test.go b/pkg/skills/skillsvc/lock_test.go
new file mode 100644
index 0000000000..ce1f1aca71
--- /dev/null
+++ b/pkg/skills/skillsvc/lock_test.go
@@ -0,0 +1,165 @@
+// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package skillsvc
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.uber.org/mock/gomock"
+
+ "github.com/stacklok/toolhive/pkg/skills"
+ "github.com/stacklok/toolhive/pkg/skills/lockfile"
+ skillsmocks "github.com/stacklok/toolhive/pkg/skills/mocks"
+ "github.com/stacklok/toolhive/pkg/storage"
+ storemocks "github.com/stacklok/toolhive/pkg/storage/mocks"
+)
+
+func TestInstallRecordsProjectScopeLockEntry(t *testing.T) {
+ t.Parallel()
+
+ layerData := makeLayerData(t)
+ projectRoot := makeProjectRoot(t)
+
+ ctrl := gomock.NewController(t)
+ store := storemocks.NewMockSkillStore(ctrl)
+ pr := skillsmocks.NewMockPathResolver(ctrl)
+
+ targetDir := filepath.Join(projectRoot, ".claude", "skills", "my-skill")
+ pr.EXPECT().ListSkillSupportingClients().Return([]string{"claude-code"})
+ pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeProject, projectRoot).Return(targetDir, nil)
+ store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeProject, projectRoot).
+ Return(skills.InstalledSkill{}, storage.ErrNotFound)
+ store.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil)
+
+ svc := New(store, withTestVerifier(), WithPathResolver(pr))
+ result, err := svc.Install(t.Context(), skills.InstallOptions{
+ Name: "my-skill",
+ LayerData: layerData,
+ Digest: "sha256:abcdef0123456789",
+ Reference: "ghcr.io/org/my-skill:v1",
+ Version: "1.0.0",
+ Scope: skills.ScopeProject,
+ ProjectRoot: projectRoot,
+ AllowUnsigned: true,
+ })
+ require.NoError(t, err)
+ assert.Equal(t, "my-skill", result.Skill.Metadata.Name)
+
+ lf, err := lockfile.Load(projectRoot)
+ require.NoError(t, err)
+ require.Len(t, lf.Skills, 1)
+ entry := lf.Skills[0]
+ assert.Equal(t, "my-skill", entry.Name)
+ assert.Equal(t, "1.0.0", entry.Version)
+ // Source is exactly what the caller passed as Name — what "thv skill
+ // upgrade" later re-resolves — not the OCI reference it happened to install.
+ assert.Equal(t, "my-skill", entry.Source)
+ assert.Equal(t, "ghcr.io/org/my-skill:v1", entry.ResolvedReference)
+ assert.Equal(t, "sha256:abcdef0123456789", entry.Digest)
+}
+
+func TestInstallUserScopeDoesNotWriteLockFile(t *testing.T) {
+ t.Parallel()
+
+ layerData := makeLayerData(t)
+ baseDir := tempDir(t)
+
+ ctrl := gomock.NewController(t)
+ store := storemocks.NewMockSkillStore(ctrl)
+ pr := skillsmocks.NewMockPathResolver(ctrl)
+
+ targetDir := filepath.Join(baseDir, "my-skill")
+ pr.EXPECT().ListSkillSupportingClients().Return([]string{"claude-code"})
+ pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(targetDir, nil)
+ store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound)
+ store.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil)
+
+ svc := New(store, WithPathResolver(pr))
+ _, err := svc.Install(t.Context(), skills.InstallOptions{
+ Name: "my-skill",
+ LayerData: layerData,
+ Digest: "sha256:abcdef0123456789",
+ })
+ require.NoError(t, err)
+
+ _, statErr := os.Stat(lockfile.Path(baseDir))
+ assert.ErrorIs(t, statErr, os.ErrNotExist)
+}
+
+func TestUninstallRemovesProjectScopeLockEntry(t *testing.T) {
+ t.Parallel()
+
+ projectRoot := makeProjectRoot(t)
+ require.NoError(t, lockfile.UpsertEntry(projectRoot, lockfile.Entry{
+ Name: "my-skill",
+ Source: "my-skill",
+ Digest: "sha256:abcdef0123456789",
+ }))
+ require.NoError(t, lockfile.UpsertEntry(projectRoot, lockfile.Entry{
+ Name: "other-skill",
+ Source: "other-skill",
+ Digest: "sha256:1234567890abcdef",
+ }))
+
+ ctrl := gomock.NewController(t)
+ store := storemocks.NewMockSkillStore(ctrl)
+
+ existing := skills.InstalledSkill{
+ Metadata: skills.SkillMetadata{Name: "my-skill"},
+ Scope: skills.ScopeProject,
+ ProjectRoot: projectRoot,
+ }
+ store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeProject, projectRoot).Return(existing, nil)
+ store.EXPECT().Delete(gomock.Any(), "my-skill", skills.ScopeProject, projectRoot).Return(nil)
+
+ svc := New(store)
+ err := svc.Uninstall(t.Context(), skills.UninstallOptions{
+ Name: "my-skill",
+ Scope: skills.ScopeProject,
+ ProjectRoot: projectRoot,
+ })
+ require.NoError(t, err)
+
+ lf, err := lockfile.Load(projectRoot)
+ require.NoError(t, err)
+ require.Len(t, lf.Skills, 1)
+ assert.Equal(t, "other-skill", lf.Skills[0].Name)
+}
+
+func TestInstallInternalDoesNotTouchLockFile(t *testing.T) {
+ t.Parallel()
+
+ layerData := makeLayerData(t)
+ projectRoot := makeProjectRoot(t)
+
+ ctrl := gomock.NewController(t)
+ store := storemocks.NewMockSkillStore(ctrl)
+ pr := skillsmocks.NewMockPathResolver(ctrl)
+
+ targetDir := filepath.Join(projectRoot, ".claude", "skills", "my-skill")
+ pr.EXPECT().ListSkillSupportingClients().Return([]string{"claude-code"})
+ pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeProject, projectRoot).Return(targetDir, nil)
+ store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeProject, projectRoot).
+ Return(skills.InstalledSkill{}, storage.ErrNotFound)
+ store.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil)
+
+ svc := New(store, withTestVerifier(), WithPathResolver(pr)).(*service)
+ _, err := svc.installInternal(context.Background(), skills.InstallOptions{
+ Name: "my-skill",
+ LayerData: layerData,
+ Digest: "sha256:abcdef0123456789",
+ Scope: skills.ScopeProject,
+ ProjectRoot: projectRoot,
+ AllowUnsigned: true,
+ })
+ require.NoError(t, err)
+
+ _, statErr := os.Stat(lockfile.Path(projectRoot))
+ assert.ErrorIs(t, statErr, os.ErrNotExist)
+}
diff --git a/pkg/skills/skillsvc/pin.go b/pkg/skills/skillsvc/pin.go
new file mode 100644
index 0000000000..257de9ee20
--- /dev/null
+++ b/pkg/skills/skillsvc/pin.go
@@ -0,0 +1,72 @@
+// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package skillsvc
+
+import (
+ "fmt"
+ "regexp"
+ "strings"
+
+ nameref "github.com/google/go-containerregistry/pkg/name"
+
+ "github.com/stacklok/toolhive/pkg/skills/gitresolver"
+ "github.com/stacklok/toolhive/pkg/skills/lockfile"
+)
+
+// fullCommitHashRe matches a full 40-character hex git commit hash.
+var fullCommitHashRe = regexp.MustCompile(`^[0-9a-fA-F]{40}$`)
+
+// buildPinnedReference returns an install Name that installs exactly the
+// digest recorded in entry, regardless of what its resolved reference's tag
+// or branch currently points to. Used by Sync to restore drifted skills.
+func buildPinnedReference(entry lockfile.Entry) (string, error) {
+ if gitresolver.IsGitReference(entry.ResolvedReference) {
+ return pinGitReference(entry.ResolvedReference, entry.Digest)
+ }
+ return pinOCIReference(entry.ResolvedReference, entry.Digest)
+}
+
+// pinOCIReference rewrites ref to reference digest directly, dropping any tag.
+func pinOCIReference(ref, digest string) (string, error) {
+ parsed, err := nameref.ParseReference(ref)
+ if err != nil {
+ return "", fmt.Errorf("parsing pinned OCI reference %q: %w", ref, err)
+ }
+ return parsed.Context().Digest(digest).String(), nil
+}
+
+// pinGitReference rewrites a git:// reference to pin its ref (branch/tag) to
+// the exact commit hash, preserving the host/repo and any skill subfolder.
+func pinGitReference(resolvedRef, commitHash string) (string, error) {
+ parsed, err := gitresolver.ParseGitReference(resolvedRef)
+ if err != nil {
+ return "", fmt.Errorf("parsing pinned git reference %q: %w", resolvedRef, err)
+ }
+ hostPath := strings.TrimPrefix(strings.TrimPrefix(parsed.URL, "https://"), "http://")
+ pinned := "git://" + hostPath + "@" + commitHash
+ if parsed.Path != "" {
+ pinned += "#" + parsed.Path
+ }
+ return pinned, nil
+}
+
+// isImmutableSource reports whether source already pins an exact, unambiguous
+// artifact — an OCI digest reference or a git reference at a full commit
+// hash — such that re-resolving it can never surface newer content.
+func isImmutableSource(source string) bool {
+ if gitresolver.IsGitReference(source) {
+ gitRef, err := gitresolver.ParseGitReference(source)
+ if err != nil {
+ return false
+ }
+ return fullCommitHashRe.MatchString(gitRef.Ref)
+ }
+
+ ref, isOCI, err := parseOCIReference(source)
+ if err != nil || !isOCI {
+ return false
+ }
+ _, isDigest := ref.(nameref.Digest)
+ return isDigest
+}
diff --git a/pkg/skills/skillsvc/service.go b/pkg/skills/skillsvc/service.go
index ef87558eab..ad6be5fda7 100644
--- a/pkg/skills/skillsvc/service.go
+++ b/pkg/skills/skillsvc/service.go
@@ -12,6 +12,8 @@ import (
"github.com/stacklok/toolhive/pkg/groups"
"github.com/stacklok/toolhive/pkg/skills"
"github.com/stacklok/toolhive/pkg/skills/gitresolver"
+ "github.com/stacklok/toolhive/pkg/skills/signer"
+ "github.com/stacklok/toolhive/pkg/skills/verifier"
"github.com/stacklok/toolhive/pkg/storage"
)
@@ -80,6 +82,20 @@ func WithGitResolver(gr gitresolver.Resolver) Option {
}
}
+// WithSigVerifier sets the Sigstore verifier for skill artifacts.
+func WithSigVerifier(v verifier.Verifier) Option {
+ return func(s *service) {
+ s.sigVerifier = v
+ }
+}
+
+// WithSigSigner sets the Sigstore signer for skill push.
+func WithSigSigner(sg signer.Signer) Option {
+ return func(s *service) {
+ s.sigSigner = sg
+ }
+}
+
// skillLock provides per-skill mutual exclusion keyed by scope/name/projectRoot.
// Entries are never evicted. This is acceptable because the number of distinct
// skills on a single machine is expected to remain small (< 1000).
@@ -118,6 +134,8 @@ type service struct {
registry ociskills.RegistryClient
skillLookup SkillLookup
gitResolver gitresolver.Resolver
+ sigVerifier verifier.Verifier
+ sigSigner signer.Signer
}
// New creates a new SkillService backed by the given store.
diff --git a/pkg/skills/skillsvc/sync.go b/pkg/skills/skillsvc/sync.go
new file mode 100644
index 0000000000..037752eb80
--- /dev/null
+++ b/pkg/skills/skillsvc/sync.go
@@ -0,0 +1,379 @@
+// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package skillsvc
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+
+ "github.com/stacklok/toolhive-core/httperr"
+ "github.com/stacklok/toolhive/pkg/skills"
+ "github.com/stacklok/toolhive/pkg/skills/lockfile"
+ "github.com/stacklok/toolhive/pkg/storage"
+)
+
+// Sync installs the exact name/digest pinned in the project's lock file for
+// every entry, restoring skills that are missing or have drifted from their
+// pinned contentDigest. Project-scoped skills outside the lock file are
+// classified as never-managed or removed-from-lock.
+func (s *service) Sync(ctx context.Context, opts skills.SyncOptions) (*skills.SyncResult, error) {
+ projectRoot, err := skills.ValidateProjectRoot(opts.ProjectRoot)
+ if err != nil {
+ return nil, httperr.WithCode(err, http.StatusBadRequest)
+ }
+
+ lf, err := lockfile.Load(projectRoot)
+ if err != nil {
+ return nil, fmt.Errorf("loading lock file: %w", err)
+ }
+
+ result := &skills.SyncResult{}
+ locked := make(map[string]struct{}, len(lf.Skills))
+ for _, entry := range lf.Skills {
+ locked[entry.Name] = struct{}{}
+ }
+
+ if opts.Adopt {
+ if err := s.syncAdopt(ctx, projectRoot, lf, opts.Clients, result); err != nil {
+ return nil, err
+ }
+ return result, nil
+ }
+
+ for _, entry := range lf.Skills {
+ s.syncEntry(ctx, projectRoot, entry, opts, result)
+ }
+
+ if err := s.syncUnmanaged(ctx, projectRoot, lf, locked, opts.Prune, opts.Check, result); err != nil {
+ return nil, err
+ }
+
+ // Backward compatibility: populate deprecated Unmanaged field.
+ result.Unmanaged = append(append([]string{}, result.NeverManaged...), result.RemovedFromLock...)
+
+ return result, nil
+}
+
+func (s *service) syncEntry(
+ ctx context.Context, projectRoot string, entry lockfile.Entry, opts skills.SyncOptions, result *skills.SyncResult,
+) {
+ status, verifyErr := s.verifyLockEntry(ctx, projectRoot, entry, opts.Clients)
+ if verifyErr != nil {
+ result.Failed = append(result.Failed, skills.SyncFailure{
+ Name: entry.Name, Reason: classifySyncError(verifyErr), Error: verifyErr.Error(),
+ })
+ return
+ }
+
+ if opts.Check {
+ switch status {
+ case syncStatusAlreadyCurrent:
+ result.AlreadyCurrent = append(result.AlreadyCurrent, entry.Name)
+ case syncStatusDrifted, syncStatusMissing:
+ result.Failed = append(result.Failed, skills.SyncFailure{
+ Name: entry.Name, Reason: skills.FailureReasonContentMismatch,
+ Error: fmt.Sprintf("on-disk content does not match lock for %q", entry.Name),
+ })
+ }
+ return
+ }
+
+ switch status {
+ case syncStatusAlreadyCurrent:
+ result.AlreadyCurrent = append(result.AlreadyCurrent, entry.Name)
+ return
+ case syncStatusDrifted:
+ result.Drifted = append(result.Drifted, entry.Name)
+ case syncStatusMissing:
+ // install below
+ }
+
+ pinnedRef, err := buildPinnedReference(entry)
+ if err != nil {
+ result.Failed = append(result.Failed, skills.SyncFailure{
+ Name: entry.Name, Reason: skills.FailureReasonValidationRejected, Error: err.Error(),
+ })
+ return
+ }
+
+ installOpts := skills.InstallOptions{
+ Name: pinnedRef,
+ LockSource: entry.Source,
+ Scope: skills.ScopeProject,
+ ProjectRoot: projectRoot,
+ Clients: opts.Clients,
+ Force: true,
+ Managed: true,
+ AllowUnsigned: entry.Unsigned,
+ }
+ if _, err := s.installInternal(ctx, installOpts); err != nil {
+ result.Failed = append(result.Failed, skills.SyncFailure{
+ Name: entry.Name, Reason: classifySyncError(err), Error: err.Error(),
+ })
+ return
+ }
+ result.Installed = append(result.Installed, entry.Name)
+}
+
+type syncEntryStatus int
+
+const (
+ syncStatusAlreadyCurrent syncEntryStatus = iota
+ syncStatusDrifted
+ syncStatusMissing
+)
+
+func (s *service) verifyLockEntry(
+ ctx context.Context, projectRoot string, entry lockfile.Entry, clients []string,
+) (syncEntryStatus, error) {
+ existing, storeErr := s.store.Get(ctx, entry.Name, skills.ScopeProject, projectRoot)
+ if storeErr != nil {
+ if errors.Is(storeErr, storage.ErrNotFound) {
+ return syncStatusMissing, nil
+ }
+ return syncStatusMissing, storeErr
+ }
+
+ if entry.ContentDigest == "" {
+ if existing.Digest == entry.Digest {
+ return syncStatusAlreadyCurrent, nil
+ }
+ return syncStatusDrifted, nil
+ }
+
+ clientTypes, err := s.resolveClientTypes(clients)
+ if err != nil {
+ return syncStatusMissing, err
+ }
+
+ digests, err := s.contentDigestsForClients(entry.Name, skills.ScopeProject, projectRoot, clientTypes)
+ if err != nil {
+ return syncStatusMissing, err
+ }
+ if len(digests) == 0 {
+ return syncStatusMissing, nil
+ }
+
+ first := digests[0]
+ for _, d := range digests[1:] {
+ if d != first {
+ return syncStatusDrifted, fmt.Errorf("client directories disagree on content for %q", entry.Name)
+ }
+ }
+ if first != entry.ContentDigest {
+ return syncStatusDrifted, nil
+ }
+ if existing.Digest != entry.Digest {
+ return syncStatusDrifted, nil
+ }
+
+ if err := s.verifyStoredSignature(existing, entry); err != nil {
+ return syncStatusDrifted, err
+ }
+ return syncStatusAlreadyCurrent, nil
+}
+
+func (s *service) verifyStoredSignature(existing skills.InstalledSkill, entry lockfile.Entry) error {
+ if entry.Unsigned {
+ return nil
+ }
+ if entry.Provenance == nil {
+ return nil
+ }
+ if len(existing.SigstoreBundle) == 0 {
+ return fmt.Errorf("missing stored sigstore bundle for %q", entry.Name)
+ }
+ return s.artifactVerifier().VerifyBundleOffline(existing.SigstoreBundle, entry.Digest, entry.Provenance)
+}
+
+func (s *service) contentDigestsForClients(
+ skillName string, scope skills.Scope, projectRoot string, clientTypes []string,
+) ([]string, error) {
+ if s.pathResolver == nil {
+ return nil, errors.New("path resolver is not configured")
+ }
+ var digests []string
+ for _, ct := range clientTypes {
+ skillPath, err := s.pathResolver.GetSkillPath(ct, skillName, scope, projectRoot)
+ if err != nil {
+ return nil, err
+ }
+ d, err := lockfile.ContentDigestFromDir(skillPath)
+ if err != nil {
+ return nil, err
+ }
+ digests = append(digests, d)
+ }
+ return digests, nil
+}
+
+func (s *service) syncUnmanaged(
+ ctx context.Context,
+ projectRoot string,
+ lf *lockfile.Lockfile,
+ locked map[string]struct{},
+ prune bool,
+ check bool,
+ result *skills.SyncResult,
+) error {
+ installed, err := s.store.List(ctx, storage.ListFilter{Scope: skills.ScopeProject, ProjectRoot: projectRoot})
+ if err != nil {
+ return fmt.Errorf("listing installed skills: %w", err)
+ }
+
+ for _, sk := range installed {
+ name := sk.Metadata.Name
+ if _, ok := locked[name]; ok {
+ continue
+ }
+ if isStillRequiredByLockedParent(name, lf, locked) {
+ continue
+ }
+
+ if sk.Managed {
+ result.RemovedFromLock = append(result.RemovedFromLock, name)
+ if !prune || check {
+ continue
+ }
+ if err := s.Uninstall(ctx, skills.UninstallOptions{
+ Name: name, Scope: skills.ScopeProject, ProjectRoot: projectRoot,
+ }); err != nil && !errors.Is(err, storage.ErrNotFound) {
+ result.Failed = append(result.Failed, skills.SyncFailure{
+ Name: name, Reason: classifySyncError(err), Error: err.Error(),
+ })
+ continue
+ }
+ result.Pruned = append(result.Pruned, name)
+ continue
+ }
+ result.NeverManaged = append(result.NeverManaged, name)
+ }
+ return nil
+}
+
+func isStillRequiredByLockedParent(name string, lf *lockfile.Lockfile, locked map[string]struct{}) bool {
+ for _, entry := range lf.Skills {
+ if entry.Name != name {
+ continue
+ }
+ for _, parent := range entry.RequiredBy {
+ if _, ok := locked[parent]; ok {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func (s *service) syncAdopt(
+ ctx context.Context, projectRoot string, lf *lockfile.Lockfile, clients []string, result *skills.SyncResult,
+) error {
+ installed, err := s.store.List(ctx, storage.ListFilter{Scope: skills.ScopeProject, ProjectRoot: projectRoot})
+ if err != nil {
+ return fmt.Errorf("listing installed skills: %w", err)
+ }
+
+ clientTypes, err := s.resolveClientTypes(clients)
+ if err != nil {
+ return err
+ }
+
+ for _, sk := range installed {
+ name := sk.Metadata.Name
+ if _, exists := lf.Get(name); exists {
+ continue
+ }
+
+ digests, digestErr := s.contentDigestsForClients(name, skills.ScopeProject, projectRoot, clientTypes)
+ if digestErr != nil {
+ result.Failed = append(result.Failed, skills.SyncFailure{
+ Name: name, Reason: classifySyncError(digestErr), Error: digestErr.Error(),
+ })
+ continue
+ }
+ if len(digests) == 0 {
+ result.Failed = append(result.Failed, skills.SyncFailure{
+ Name: name, Reason: skills.FailureReasonContentMismatch,
+ Error: fmt.Sprintf("no on-disk files found for %q", name),
+ })
+ continue
+ }
+ first := digests[0]
+ for _, d := range digests[1:] {
+ if d != first {
+ result.Failed = append(result.Failed, skills.SyncFailure{
+ Name: name, Reason: skills.FailureReasonContentMismatch,
+ Error: fmt.Sprintf("client directories disagree on content for %q; reinstall to reconcile", name),
+ })
+ first = ""
+ break
+ }
+ }
+ if first == "" {
+ continue
+ }
+
+ entry := lockfile.Entry{
+ Name: name,
+ Version: sk.Metadata.Version,
+ Source: name,
+ ResolvedReference: sk.Reference,
+ Digest: sk.Digest,
+ ContentDigest: first,
+ }
+ if len(sk.SigstoreBundle) > 0 && sk.Digest != "" {
+ if result, bundleErr := s.artifactVerifier().ResultFromBundle(sk.SigstoreBundle, sk.Digest); bundleErr == nil {
+ entry.Provenance = result.ToLockProvenance()
+ } else {
+ entry.Unsigned = true
+ }
+ } else {
+ entry.Unsigned = true
+ }
+ if lockErr := lockfile.UpsertEntry(projectRoot, entry); lockErr != nil {
+ result.Failed = append(result.Failed, skills.SyncFailure{
+ Name: name, Reason: skills.FailureReasonLockWriteFailed, Error: lockErr.Error(),
+ })
+ continue
+ }
+ result.Installed = append(result.Installed, name)
+ }
+ return nil
+}
+
+func (s *service) resolveClientTypes(clients []string) ([]string, error) {
+ if len(clients) > 0 {
+ return clients, nil
+ }
+ if s.pathResolver == nil {
+ return nil, errors.New("path resolver is not configured")
+ }
+ return s.pathResolver.ListSkillSupportingClients(), nil
+}
+
+func classifySyncError(err error) skills.FailureReason {
+ if err == nil {
+ return ""
+ }
+ msg := err.Error()
+ switch {
+ case strings.Contains(msg, "lock NOT updated"), strings.Contains(msg, "lock file"):
+ return skills.FailureReasonLockWriteFailed
+ case strings.Contains(msg, "signer identity"), strings.Contains(msg, "signer-mismatch"):
+ return skills.FailureReasonSignerMismatch
+ case strings.Contains(msg, "signature"), strings.Contains(msg, "unsigned"):
+ return skills.FailureReasonSignatureInvalid
+ case strings.Contains(msg, "unauthorized"), strings.Contains(msg, "registry"), strings.Contains(msg, "pulling"):
+ return skills.FailureReasonRegistryUnreachable
+ case strings.Contains(msg, "digest"), strings.Contains(msg, "content"):
+ return skills.FailureReasonDigestMissing
+ case strings.Contains(msg, "invalid"), strings.Contains(msg, "validation"):
+ return skills.FailureReasonValidationRejected
+ default:
+ return skills.FailureReasonUnknown
+ }
+}
diff --git a/pkg/skills/skillsvc/sync_test.go b/pkg/skills/skillsvc/sync_test.go
new file mode 100644
index 0000000000..fb62eac9c9
--- /dev/null
+++ b/pkg/skills/skillsvc/sync_test.go
@@ -0,0 +1,136 @@
+// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package skillsvc
+
+import (
+ "net/http"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.uber.org/mock/gomock"
+
+ "github.com/stacklok/toolhive-core/httperr"
+ ociskills "github.com/stacklok/toolhive-core/oci/skills"
+ ocimocks "github.com/stacklok/toolhive-core/oci/skills/mocks"
+ "github.com/stacklok/toolhive/pkg/skills"
+ "github.com/stacklok/toolhive/pkg/skills/lockfile"
+ skillsmocks "github.com/stacklok/toolhive/pkg/skills/mocks"
+ "github.com/stacklok/toolhive/pkg/storage"
+ storemocks "github.com/stacklok/toolhive/pkg/storage/mocks"
+)
+
+func TestSyncInstallsDriftedReportsAlreadyCurrentAndUnmanaged(t *testing.T) {
+ t.Parallel()
+ projectRoot := makeProjectRoot(t)
+
+ ociStore, err := ociskills.NewStore(tempDir(t))
+ require.NoError(t, err)
+ indexDigest := buildTestArtifact(t, ociStore, "my-skill", "1.0.0")
+
+ pinnedRef, err := pinOCIReference("ghcr.io/org/my-skill:v1", indexDigest.String())
+ require.NoError(t, err)
+
+ require.NoError(t, lockfile.UpsertEntry(projectRoot, lockfile.Entry{
+ Name: "my-skill",
+ Version: "1.0.0",
+ Source: "my-skill",
+ ResolvedReference: "ghcr.io/org/my-skill:v1",
+ Digest: indexDigest.String(),
+ Unsigned: true,
+ }))
+ require.NoError(t, lockfile.UpsertEntry(projectRoot, lockfile.Entry{
+ Name: "already-current",
+ Source: "already-current",
+ ResolvedReference: "ghcr.io/org/already-current:v1",
+ Digest: "sha256:" + strings.Repeat("d", 64),
+ }))
+
+ ctrl := gomock.NewController(t)
+ reg := ocimocks.NewMockRegistryClient(ctrl)
+ reg.EXPECT().Pull(gomock.Any(), ociStore, pinnedRef).Return(indexDigest, nil)
+
+ store := storemocks.NewMockSkillStore(ctrl)
+ // Called once by Sync's own drift check, and once more inside the normal
+ // install flow that Sync delegates to for the actual (re)install.
+ store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeProject, projectRoot).
+ Return(skills.InstalledSkill{}, storage.ErrNotFound).Times(2)
+ store.EXPECT().Get(gomock.Any(), "already-current", skills.ScopeProject, projectRoot).
+ Return(skills.InstalledSkill{
+ Metadata: skills.SkillMetadata{Name: "already-current"},
+ Digest: "sha256:" + strings.Repeat("d", 64),
+ }, nil)
+ store.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil)
+ store.EXPECT().List(gomock.Any(), storage.ListFilter{Scope: skills.ScopeProject, ProjectRoot: projectRoot}).
+ Return([]skills.InstalledSkill{
+ {Metadata: skills.SkillMetadata{Name: "my-skill"}},
+ {Metadata: skills.SkillMetadata{Name: "already-current"}},
+ {Metadata: skills.SkillMetadata{Name: "extra-skill"}},
+ }, nil)
+
+ pr := skillsmocks.NewMockPathResolver(ctrl)
+ targetDir := filepath.Join(tempDir(t), "installed", "my-skill")
+ pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeProject, projectRoot).Return(targetDir, nil)
+
+ svc := New(store, withTestVerifier(), WithPathResolver(pr), WithRegistryClient(reg), WithOCIStore(ociStore))
+ lockSvc := svc.(skills.SkillLockService)
+ result, err := lockSvc.Sync(t.Context(), skills.SyncOptions{
+ ProjectRoot: projectRoot,
+ Clients: []string{"claude-code"},
+ })
+ require.NoError(t, err)
+ assert.Equal(t, []string{"my-skill"}, result.Installed)
+ assert.Equal(t, []string{"already-current"}, result.AlreadyCurrent)
+ assert.Equal(t, []string{"extra-skill"}, result.NeverManaged)
+ assert.Empty(t, result.Pruned)
+ assert.Empty(t, result.Failed)
+
+ // A sync that only restores pinned digests must never rewrite the lock file.
+ lf, err := lockfile.Load(projectRoot)
+ require.NoError(t, err)
+ entry, ok := lf.Get("my-skill")
+ require.True(t, ok)
+ assert.Equal(t, "my-skill", entry.Source)
+}
+
+func TestSyncPrunesUnmanagedSkills(t *testing.T) {
+ t.Parallel()
+ projectRoot := makeProjectRoot(t)
+
+ ctrl := gomock.NewController(t)
+ store := storemocks.NewMockSkillStore(ctrl)
+ store.EXPECT().List(gomock.Any(), storage.ListFilter{Scope: skills.ScopeProject, ProjectRoot: projectRoot}).
+ Return([]skills.InstalledSkill{
+ {Metadata: skills.SkillMetadata{Name: "extra-skill"}, Scope: skills.ScopeProject, ProjectRoot: projectRoot, Managed: true},
+ }, nil)
+ store.EXPECT().Get(gomock.Any(), "extra-skill", skills.ScopeProject, projectRoot).
+ Return(skills.InstalledSkill{
+ Metadata: skills.SkillMetadata{Name: "extra-skill"},
+ Scope: skills.ScopeProject,
+ ProjectRoot: projectRoot,
+ }, nil)
+ store.EXPECT().Delete(gomock.Any(), "extra-skill", skills.ScopeProject, projectRoot).Return(nil)
+
+ svc := New(store)
+ lockSvc := svc.(skills.SkillLockService)
+ result, err := lockSvc.Sync(t.Context(), skills.SyncOptions{ProjectRoot: projectRoot, Prune: true})
+ require.NoError(t, err)
+ assert.Equal(t, []string{"extra-skill"}, result.Pruned)
+ assert.Empty(t, result.NeverManaged)
+ assert.Equal(t, []string{"extra-skill"}, result.RemovedFromLock)
+}
+
+func TestSyncRejectsInvalidProjectRoot(t *testing.T) {
+ t.Parallel()
+ ctrl := gomock.NewController(t)
+ store := storemocks.NewMockSkillStore(ctrl)
+
+ svc := New(store)
+ lockSvc := svc.(skills.SkillLockService)
+ _, err := lockSvc.Sync(t.Context(), skills.SyncOptions{ProjectRoot: "relative/path"})
+ require.Error(t, err)
+ assert.Equal(t, http.StatusBadRequest, httperr.Code(err))
+}
diff --git a/pkg/skills/skillsvc/test_verifier.go b/pkg/skills/skillsvc/test_verifier.go
new file mode 100644
index 0000000000..95ca6eb9e9
--- /dev/null
+++ b/pkg/skills/skillsvc/test_verifier.go
@@ -0,0 +1,36 @@
+// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package skillsvc
+
+import (
+ "context"
+
+ "github.com/stacklok/toolhive/pkg/skills/lockfile"
+ "github.com/stacklok/toolhive/pkg/skills/verifier"
+)
+
+// permissiveTestVerifier treats artifacts as unsigned so project-scope tests
+// can install without Sigstore fixtures unless they inject a stricter verifier.
+type permissiveTestVerifier struct{}
+
+func (permissiveTestVerifier) VerifyOCI(context.Context, string, string) (*verifier.Result, error) {
+ return nil, verifier.ErrUnsigned
+}
+
+func (permissiveTestVerifier) VerifyGit(context.Context, string, string) (*verifier.Result, error) {
+ return nil, verifier.ErrUnsigned
+}
+
+func (permissiveTestVerifier) VerifyBundleOffline([]byte, string, *lockfile.Provenance) error {
+ return nil
+}
+
+func (permissiveTestVerifier) ResultFromBundle([]byte, string) (*verifier.Result, error) {
+ return nil, verifier.ErrUnsigned
+}
+
+// withTestVerifier returns a service option that bypasses live Sigstore verification.
+func withTestVerifier() Option {
+ return WithSigVerifier(permissiveTestVerifier{})
+}
diff --git a/pkg/skills/skillsvc/uninstall.go b/pkg/skills/skillsvc/uninstall.go
index 4c74d8ec98..b43b169656 100644
--- a/pkg/skills/skillsvc/uninstall.go
+++ b/pkg/skills/skillsvc/uninstall.go
@@ -7,6 +7,7 @@ import (
"context"
"errors"
"fmt"
+ "log/slog"
"net/http"
"os"
"path/filepath"
@@ -14,17 +15,27 @@ import (
"github.com/stacklok/toolhive-core/httperr"
"github.com/stacklok/toolhive/pkg/groups"
"github.com/stacklok/toolhive/pkg/skills"
+ "github.com/stacklok/toolhive/pkg/skills/lockfile"
+ "github.com/stacklok/toolhive/pkg/storage"
)
// Uninstall removes an installed skill and cleans up files for all clients.
+// For project scope, it updates the lock file and cascade-uninstalls orphaned deps.
func (s *service) Uninstall(ctx context.Context, opts skills.UninstallOptions) error {
+ _, err := s.uninstallInternal(ctx, opts, true)
+ return err
+}
+
+func (s *service) uninstallInternal(
+ ctx context.Context, opts skills.UninstallOptions, allowCascade bool,
+) ([]string, error) {
if err := skills.ValidateSkillName(opts.Name); err != nil {
- return httperr.WithCode(err, http.StatusBadRequest)
+ return nil, httperr.WithCode(err, http.StatusBadRequest)
}
scope, projectRoot, err := normalizeProjectRoot(opts.Scope, opts.ProjectRoot)
if err != nil {
- return err
+ return nil, err
}
scope = defaultScope(scope)
opts.ProjectRoot = projectRoot
@@ -32,50 +43,94 @@ func (s *service) Uninstall(ctx context.Context, opts skills.UninstallOptions) e
unlock := s.locks.lock(opts.Name, scope, opts.ProjectRoot)
defer unlock()
- // Look up the existing record to find which clients have files.
existing, err := s.store.Get(ctx, opts.Name, scope, opts.ProjectRoot)
if err != nil {
- return err
+ return nil, err
+ }
+
+ cleanupErrs := s.cleanupSkillFiles(existing, opts.Name, scope, opts.ProjectRoot)
+
+ if err := s.store.Delete(ctx, opts.Name, scope, opts.ProjectRoot); err != nil {
+ return nil, err
}
- // Determine the boundary directory for empty-parent cleanup.
- stopDir := opts.ProjectRoot
+ cascaded, lockErrs := s.uninstallLockAndCascade(ctx, scope, projectRoot, opts.Name, allowCascade)
+ cleanupErrs = append(cleanupErrs, lockErrs...)
+
+ if s.groupManager != nil {
+ if groupErr := groups.RemoveSkillFromAllGroups(ctx, s.groupManager, opts.Name); groupErr != nil {
+ cleanupErrs = append(cleanupErrs, fmt.Errorf("removing skill from groups: %w", groupErr))
+ }
+ }
+
+ return cascaded, errors.Join(cleanupErrs...)
+}
+
+func (s *service) cleanupSkillFiles(
+ existing skills.InstalledSkill, name string, scope skills.Scope, projectRoot string,
+) []error {
+ if s.pathResolver == nil {
+ return nil
+ }
+ stopDir := projectRoot
if scope == skills.ScopeUser {
if homeDir, err := os.UserHomeDir(); err == nil {
stopDir = homeDir
}
}
-
- // Remove files for each client — best-effort: collect errors but don't
- // abort on the first failure so we clean up as much as possible.
var cleanupErrs []error
- if s.pathResolver != nil {
- for _, clientType := range existing.Clients {
- skillPath, pathErr := s.pathResolver.GetSkillPath(clientType, opts.Name, scope, opts.ProjectRoot)
- if pathErr != nil {
- cleanupErrs = append(cleanupErrs, fmt.Errorf("resolving path for client %q: %w", clientType, pathErr))
- continue
- }
- if rmErr := s.installer.Remove(skillPath); rmErr != nil {
- cleanupErrs = append(cleanupErrs, fmt.Errorf("removing files for client %q: %w", clientType, rmErr))
- continue
- }
- if stopDir != "" {
- skills.RemoveEmptyParents(filepath.Dir(skillPath), stopDir)
- }
+ for _, clientType := range existing.Clients {
+ skillPath, pathErr := s.pathResolver.GetSkillPath(clientType, name, scope, projectRoot)
+ if pathErr != nil {
+ cleanupErrs = append(cleanupErrs, fmt.Errorf("resolving path for client %q: %w", clientType, pathErr))
+ continue
+ }
+ if rmErr := s.installer.Remove(skillPath); rmErr != nil {
+ cleanupErrs = append(cleanupErrs, fmt.Errorf("removing files for client %q: %w", clientType, rmErr))
+ continue
+ }
+ if stopDir != "" {
+ skills.RemoveEmptyParents(filepath.Dir(skillPath), stopDir)
}
}
+ return cleanupErrs
+}
- if err := s.store.Delete(ctx, opts.Name, scope, opts.ProjectRoot); err != nil {
- return err
+func (s *service) uninstallLockAndCascade(
+ ctx context.Context, scope skills.Scope, projectRoot, name string, allowCascade bool,
+) ([]string, []error) {
+ if scope != skills.ScopeProject || projectRoot == "" {
+ return nil, nil
}
-
- // Remove the skill from all groups — best-effort, same pattern as file cleanup.
- if s.groupManager != nil {
- if groupErr := groups.RemoveSkillFromAllGroups(ctx, s.groupManager, opts.Name); groupErr != nil {
- cleanupErrs = append(cleanupErrs, fmt.Errorf("removing skill from groups: %w", groupErr))
+ candidates, err := updateLockAfterUninstall(projectRoot, name)
+ if err != nil {
+ return nil, []error{err}
+ }
+ if !allowCascade {
+ return nil, nil
+ }
+ var cascaded []string
+ var cleanupErrs []error
+ for _, depName := range candidates {
+ more, cascadeErr := s.uninstallInternal(ctx, skills.UninstallOptions{
+ Name: depName, Scope: skills.ScopeProject, ProjectRoot: projectRoot,
+ }, true)
+ if cascadeErr != nil && !errors.Is(cascadeErr, storage.ErrNotFound) {
+ slog.Warn("cascade uninstall failed", "skill", depName, "error", cascadeErr)
+ continue
}
+ cascaded = append(cascaded, depName)
+ cascaded = append(cascaded, more...)
}
+ return cascaded, cleanupErrs
+}
- return errors.Join(cleanupErrs...)
+func updateLockAfterUninstall(projectRoot, name string) ([]string, error) {
+ var candidates []string
+ err := lockfile.UpdateEntry(projectRoot, func(lf *lockfile.Lockfile) error {
+ lf.Remove(name)
+ candidates = lf.RemoveParentFromRequiredBy(name)
+ return nil
+ })
+ return candidates, err
}
diff --git a/pkg/skills/skillsvc/upgrade.go b/pkg/skills/skillsvc/upgrade.go
new file mode 100644
index 0000000000..b417feb841
--- /dev/null
+++ b/pkg/skills/skillsvc/upgrade.go
@@ -0,0 +1,339 @@
+// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package skillsvc
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+
+ nameref "github.com/google/go-containerregistry/pkg/name"
+
+ "github.com/stacklok/toolhive-core/httperr"
+ "github.com/stacklok/toolhive/pkg/skills"
+ "github.com/stacklok/toolhive/pkg/skills/gitresolver"
+ "github.com/stacklok/toolhive/pkg/skills/lockfile"
+ "github.com/stacklok/toolhive/pkg/skills/verifier"
+)
+
+// Upgrade re-resolves each lock file entry's original source and, when the
+// resolved digest differs from the pinned one, installs the new content and
+// updates the lock file entry.
+func (s *service) Upgrade(ctx context.Context, opts skills.UpgradeOptions) (*skills.UpgradeResult, error) {
+ projectRoot, err := skills.ValidateProjectRoot(opts.ProjectRoot)
+ if err != nil {
+ return nil, httperr.WithCode(err, http.StatusBadRequest)
+ }
+
+ preview := opts.Preview || opts.DryRun
+
+ lf, err := lockfile.Load(projectRoot)
+ if err != nil {
+ return nil, fmt.Errorf("loading lock file: %w", err)
+ }
+
+ targets, err := selectUpgradeTargets(lf.Skills, opts.Names)
+ if err != nil {
+ return nil, err
+ }
+
+ result := &skills.UpgradeResult{}
+ for _, entry := range targets {
+ outcome := s.upgradeEntry(ctx, projectRoot, entry, opts, preview)
+ result.Outcomes = append(result.Outcomes, outcome)
+ }
+
+ if opts.FailOnChanges {
+ for _, o := range result.Outcomes {
+ if o.Status == skills.UpgradeStatusUpgraded {
+ return result, httperr.WithCode(
+ errors.New("upgrade preview found changes"),
+ http.StatusConflict,
+ )
+ }
+ }
+ }
+
+ return result, nil
+}
+
+// selectUpgradeTargets returns the subset of entries named in names, in the
+// order requested, or all entries when names is empty.
+func selectUpgradeTargets(entries []lockfile.Entry, names []string) ([]lockfile.Entry, error) {
+ if len(names) == 0 {
+ return entries, nil
+ }
+ byName := make(map[string]lockfile.Entry, len(entries))
+ for _, e := range entries {
+ byName[e.Name] = e
+ }
+ targets := make([]lockfile.Entry, 0, len(names))
+ for _, name := range names {
+ entry, ok := byName[name]
+ if !ok {
+ return nil, httperr.WithCode(
+ fmt.Errorf("skill %q is not present in the lock file", name), http.StatusNotFound)
+ }
+ targets = append(targets, entry)
+ }
+ return targets, nil
+}
+
+// upgradeEntry attempts to upgrade a single lock entry.
+func (s *service) upgradeEntry(
+ ctx context.Context, projectRoot string, entry lockfile.Entry, opts skills.UpgradeOptions, preview bool,
+) skills.UpgradeOutcome {
+ if isImmutableSource(entry.Source) {
+ return skills.UpgradeOutcome{Name: entry.Name, Status: skills.UpgradeStatusNotUpgradable, OldDigest: entry.Digest}
+ }
+
+ if preview {
+ return s.upgradeEntryPreview(ctx, entry)
+ }
+
+ installOpts := skills.InstallOptions{
+ Name: entry.Source,
+ LockSource: entry.Source,
+ Scope: skills.ScopeProject,
+ ProjectRoot: projectRoot,
+ Clients: opts.Clients,
+ Force: true,
+ Managed: true,
+ ExplicitLock: entry.Explicit,
+ AllowUnsigned: entry.Unsigned,
+ }
+ result, err := s.installInternal(ctx, installOpts)
+ if err != nil {
+ return skills.UpgradeOutcome{
+ Name: entry.Name, Status: skills.UpgradeStatusFailed, OldDigest: entry.Digest,
+ Reason: classifyUpgradeError(err), Error: err.Error(),
+ }
+ }
+
+ if result.Skill.Digest == entry.Digest && result.Skill.Reference == entry.ResolvedReference {
+ return skills.UpgradeOutcome{
+ Name: entry.Name, Status: skills.UpgradeStatusUpToDate,
+ OldDigest: entry.Digest, NewDigest: result.Skill.Digest,
+ }
+ }
+
+ if result.Skill.Reference != entry.ResolvedReference && !opts.AllowRefChange {
+ return skills.UpgradeOutcome{
+ Name: entry.Name, Status: skills.UpgradeStatusRefChangeBlocked,
+ OldDigest: entry.Digest, NewDigest: result.Skill.Digest,
+ NewResolvedReference: result.Skill.Reference,
+ Error: fmt.Sprintf("resolvedReference would change from %q to %q; pass --allow-ref-change to proceed",
+ entry.ResolvedReference, result.Skill.Reference),
+ }
+ }
+
+ if blocked := upgradeSignerGuard(entry, result, opts.AllowSignerChange); blocked != nil {
+ return *blocked
+ }
+
+ contentDigest := result.ContentDigest
+ if contentDigest == "" {
+ cd, cdErr := s.contentDigestForSkill(ctx, installOpts, result.Skill)
+ if cdErr != nil {
+ return skills.UpgradeOutcome{
+ Name: entry.Name, Status: skills.UpgradeStatusFailed, OldDigest: entry.Digest,
+ Reason: skills.FailureReasonDigestMissing, Error: cdErr.Error(),
+ }
+ }
+ contentDigest = cd
+ }
+
+ newEntry := lockfile.Entry{
+ Name: entry.Name,
+ Version: result.Skill.Metadata.Version,
+ Source: entry.Source,
+ ResolvedReference: result.Skill.Reference,
+ Digest: result.Skill.Digest,
+ ContentDigest: contentDigest,
+ RequiredBy: entry.RequiredBy,
+ Explicit: entry.Explicit,
+ Provenance: provenanceInfoToLock(result.Provenance),
+ Unsigned: result.Unsigned,
+ }
+ if lockErr := lockfile.UpsertEntry(projectRoot, newEntry); lockErr != nil {
+ return skills.UpgradeOutcome{
+ Name: entry.Name, Status: skills.UpgradeStatusFailed, OldDigest: entry.Digest, NewDigest: result.Skill.Digest,
+ Reason: skills.FailureReasonLockWriteFailed,
+ Error: fmt.Sprintf("skill upgraded but failed to update lock file: %v", lockErr),
+ }
+ }
+
+ status := skills.UpgradeStatusUpgraded
+ if result.Skill.Digest == entry.Digest {
+ status = skills.UpgradeStatusUpToDate
+ }
+ return skills.UpgradeOutcome{
+ Name: entry.Name, Status: status,
+ OldDigest: entry.Digest, NewDigest: result.Skill.Digest,
+ NewResolvedReference: result.Skill.Reference,
+ }
+}
+
+func upgradeSignerGuard(
+ entry lockfile.Entry, result *skills.InstallResult, allowSignerChange bool,
+) *skills.UpgradeOutcome {
+ if entry.Provenance == nil || entry.Unsigned {
+ return nil
+ }
+ if result.Unsigned {
+ return &skills.UpgradeOutcome{
+ Name: entry.Name, Status: skills.UpgradeStatusSignerChangeBlocked,
+ OldDigest: entry.Digest, NewDigest: result.Skill.Digest,
+ Error: fmt.Sprintf("new artifact for %q is unsigned; locked identity requires a signed publisher", entry.Name),
+ }
+ }
+ if result.Provenance != nil && !allowSignerChange &&
+ (result.Provenance.SignerIdentity != entry.Provenance.SignerIdentity ||
+ result.Provenance.CertIssuer != entry.Provenance.CertIssuer) {
+ return &skills.UpgradeOutcome{
+ Name: entry.Name, Status: skills.UpgradeStatusSignerChangeBlocked,
+ OldDigest: entry.Digest, NewDigest: result.Skill.Digest,
+ Error: fmt.Sprintf("signer identity would change from %q to %q; pass --allow-signer-change to proceed",
+ entry.Provenance.SignerIdentity, result.Provenance.SignerIdentity),
+ }
+ }
+ return nil
+}
+
+func (s *service) upgradeEntryPreview(ctx context.Context, entry lockfile.Entry) skills.UpgradeOutcome {
+ newDigest, newRef, err := s.resolveLatestDigestAndRef(ctx, entry.Source)
+ if err != nil {
+ return skills.UpgradeOutcome{
+ Name: entry.Name, Status: skills.UpgradeStatusFailed, OldDigest: entry.Digest,
+ Reason: classifyUpgradeError(err), Error: err.Error(),
+ }
+ }
+ if newRef != entry.ResolvedReference {
+ return skills.UpgradeOutcome{
+ Name: entry.Name, Status: skills.UpgradeStatusRefChangeBlocked,
+ OldDigest: entry.Digest, NewDigest: newDigest,
+ NewResolvedReference: newRef,
+ Error: fmt.Sprintf("resolvedReference would change from %q to %q", entry.ResolvedReference, newRef),
+ }
+ }
+ if newDigest == entry.Digest {
+ return skills.UpgradeOutcome{
+ Name: entry.Name, Status: skills.UpgradeStatusUpToDate, OldDigest: entry.Digest, NewDigest: newDigest,
+ }
+ }
+ if entry.Provenance != nil && !entry.Unsigned {
+ if blocked := s.previewSignerMismatch(ctx, entry, newDigest, newRef); blocked != nil {
+ return *blocked
+ }
+ }
+ return skills.UpgradeOutcome{
+ Name: entry.Name, Status: skills.UpgradeStatusUpgraded, OldDigest: entry.Digest, NewDigest: newDigest,
+ }
+}
+
+func (s *service) previewSignerMismatch(
+ ctx context.Context, entry lockfile.Entry, newDigest, newRef string,
+) *skills.UpgradeOutcome {
+ if gitresolver.IsGitReference(entry.Source) {
+ return nil
+ }
+ ref := newRef
+ if ref == "" {
+ ref = entry.ResolvedReference
+ }
+ result, err := s.artifactVerifier().VerifyOCI(ctx, ref, newDigest)
+ if err != nil {
+ if errors.Is(err, verifier.ErrUnsigned) {
+ return &skills.UpgradeOutcome{
+ Name: entry.Name, Status: skills.UpgradeStatusSignerChangeBlocked,
+ OldDigest: entry.Digest, NewDigest: newDigest,
+ Error: fmt.Sprintf("upgrade candidate for %q is unsigned; locked identity requires a signed publisher", entry.Name),
+ }
+ }
+ return nil
+ }
+ if result.SignerIdentity != entry.Provenance.SignerIdentity || result.CertIssuer != entry.Provenance.CertIssuer {
+ return &skills.UpgradeOutcome{
+ Name: entry.Name, Status: skills.UpgradeStatusSignerChangeBlocked,
+ OldDigest: entry.Digest, NewDigest: newDigest,
+ Error: fmt.Sprintf("signer identity would change from %q to %q; pass --allow-signer-change to proceed",
+ entry.Provenance.SignerIdentity, result.SignerIdentity),
+ }
+ }
+ return nil
+}
+
+func (s *service) resolveLatestDigestAndRef(ctx context.Context, source string) (string, string, error) {
+ if gitresolver.IsGitReference(source) {
+ if s.gitResolver == nil {
+ return "", "", httperr.WithCode(errors.New("git resolver is not configured"), http.StatusInternalServerError)
+ }
+ gitRef, err := gitresolver.ParseGitReference(source)
+ if err != nil {
+ return "", "", httperr.WithCode(fmt.Errorf("invalid git reference: %w", err), http.StatusBadRequest)
+ }
+ resolved, err := s.gitResolver.Resolve(ctx, gitRef)
+ if err != nil {
+ return "", "", httperr.WithCode(fmt.Errorf("resolving git skill: %w", err), http.StatusBadGateway)
+ }
+ return resolved.CommitHash, source, nil
+ }
+
+ ref, isOCI, err := parseOCIReference(source)
+ if err != nil {
+ return "", "", httperr.WithCode(fmt.Errorf("invalid OCI reference %q: %w", source, err), http.StatusBadRequest)
+ }
+ if isOCI {
+ d, err := s.resolveOCIDigest(ctx, ref)
+ return d, qualifiedOCIRef(ref), err
+ }
+
+ resolved, err := s.resolveFromRegistry(source)
+ if err != nil {
+ return "", "", err
+ }
+ if resolved == nil {
+ return "", "", httperr.WithCode(fmt.Errorf("skill %q not found in registry", source), http.StatusNotFound)
+ }
+ if resolved.OCIRef != nil {
+ d, err := s.resolveOCIDigest(ctx, resolved.OCIRef)
+ return d, qualifiedOCIRef(resolved.OCIRef), err
+ }
+ d, err := s.resolveGitDigest(ctx, resolved.GitURL)
+ return d, resolved.GitURL, err
+}
+
+func classifyUpgradeError(err error) skills.FailureReason {
+ return classifySyncError(err)
+}
+
+func (s *service) resolveGitDigest(ctx context.Context, gitURL string) (string, error) {
+ if s.gitResolver == nil {
+ return "", httperr.WithCode(errors.New("git resolver is not configured"), http.StatusInternalServerError)
+ }
+ gitRef, err := gitresolver.ParseGitReference(gitURL)
+ if err != nil {
+ return "", httperr.WithCode(fmt.Errorf("invalid git reference: %w", err), http.StatusBadRequest)
+ }
+ resolved, err := s.gitResolver.Resolve(ctx, gitRef)
+ if err != nil {
+ return "", httperr.WithCode(fmt.Errorf("resolving git skill: %w", err), http.StatusBadGateway)
+ }
+ return resolved.CommitHash, nil
+}
+
+func (s *service) resolveOCIDigest(ctx context.Context, ref nameref.Reference) (string, error) {
+ if s.registry == nil || s.ociStore == nil {
+ return "", httperr.WithCode(errors.New("OCI registry is not configured"), http.StatusInternalServerError)
+ }
+ ociRef := qualifiedOCIRef(ref)
+ pullCtx, cancel := context.WithTimeout(ctx, ociPullTimeout)
+ defer cancel()
+ d, err := s.registry.Pull(pullCtx, s.ociStore, ociRef)
+ if err != nil {
+ return "", httperr.WithCode(fmt.Errorf("pulling OCI artifact %q: %w", ociRef, err), classifyPullError(err))
+ }
+ return d.String(), nil
+}
diff --git a/pkg/skills/skillsvc/upgrade_test.go b/pkg/skills/skillsvc/upgrade_test.go
new file mode 100644
index 0000000000..207e61165b
--- /dev/null
+++ b/pkg/skills/skillsvc/upgrade_test.go
@@ -0,0 +1,170 @@
+// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package skillsvc
+
+import (
+ "net/http"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.uber.org/mock/gomock"
+
+ "github.com/stacklok/toolhive-core/httperr"
+ ociskills "github.com/stacklok/toolhive-core/oci/skills"
+ ocimocks "github.com/stacklok/toolhive-core/oci/skills/mocks"
+ "github.com/stacklok/toolhive/pkg/skills"
+ "github.com/stacklok/toolhive/pkg/skills/lockfile"
+ skillsmocks "github.com/stacklok/toolhive/pkg/skills/mocks"
+ storemocks "github.com/stacklok/toolhive/pkg/storage/mocks"
+)
+
+func TestUpgradeInstallsNewDigestAndRewritesLockEntry(t *testing.T) {
+ t.Parallel()
+ projectRoot := makeProjectRoot(t)
+
+ ociStore, err := ociskills.NewStore(tempDir(t))
+ require.NoError(t, err)
+ newDigest := buildTestArtifact(t, ociStore, "my-skill", "2.0.0")
+
+ require.NoError(t, lockfile.UpsertEntry(projectRoot, lockfile.Entry{
+ Name: "my-skill",
+ Version: "1.0.0",
+ Source: "ghcr.io/org/my-skill:v1",
+ ResolvedReference: "ghcr.io/org/my-skill:v1",
+ Digest: "sha256:" + strings.Repeat("a", 64),
+ Unsigned: true,
+ }))
+
+ ctrl := gomock.NewController(t)
+ reg := ocimocks.NewMockRegistryClient(ctrl)
+ reg.EXPECT().Pull(gomock.Any(), ociStore, "ghcr.io/org/my-skill:v1").Return(newDigest, nil)
+
+ store := storemocks.NewMockSkillStore(ctrl)
+ existing := skills.InstalledSkill{
+ Metadata: skills.SkillMetadata{Name: "my-skill", Version: "1.0.0"},
+ Scope: skills.ScopeProject,
+ ProjectRoot: projectRoot,
+ Digest: "sha256:" + strings.Repeat("a", 64),
+ Clients: []string{"claude-code"},
+ }
+ store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeProject, projectRoot).Return(existing, nil)
+ store.EXPECT().Update(gomock.Any(), gomock.Any()).Return(nil)
+
+ pr := skillsmocks.NewMockPathResolver(ctrl)
+ targetDir := filepath.Join(tempDir(t), "installed", "my-skill")
+ pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeProject, projectRoot).Return(targetDir, nil)
+
+ svc := New(store, withTestVerifier(), WithPathResolver(pr), WithRegistryClient(reg), WithOCIStore(ociStore))
+ lockSvc := svc.(skills.SkillLockService)
+ result, err := lockSvc.Upgrade(t.Context(), skills.UpgradeOptions{
+ ProjectRoot: projectRoot,
+ Clients: []string{"claude-code"},
+ })
+ require.NoError(t, err)
+ require.Len(t, result.Outcomes, 1)
+ outcome := result.Outcomes[0]
+ assert.Equal(t, skills.UpgradeStatusUpgraded, outcome.Status)
+ assert.Equal(t, "sha256:"+strings.Repeat("a", 64), outcome.OldDigest)
+ assert.Equal(t, newDigest.String(), outcome.NewDigest)
+
+ lf, err := lockfile.Load(projectRoot)
+ require.NoError(t, err)
+ require.Len(t, lf.Skills, 1)
+ assert.Equal(t, newDigest.String(), lf.Skills[0].Digest)
+ assert.Equal(t, "2.0.0", lf.Skills[0].Version)
+ // Source is what "thv skill upgrade" re-resolves next time — it must
+ // never be replaced by the concrete reference an upgrade happened to use.
+ assert.Equal(t, "ghcr.io/org/my-skill:v1", lf.Skills[0].Source)
+}
+
+func TestUpgradeDryRunReportsWithoutInstallingOrRewritingLock(t *testing.T) {
+ t.Parallel()
+ projectRoot := makeProjectRoot(t)
+
+ ociStore, err := ociskills.NewStore(tempDir(t))
+ require.NoError(t, err)
+ newDigest := buildTestArtifact(t, ociStore, "my-skill", "2.0.0")
+
+ require.NoError(t, lockfile.UpsertEntry(projectRoot, lockfile.Entry{
+ Name: "my-skill",
+ Source: "ghcr.io/org/my-skill:v1",
+ ResolvedReference: "ghcr.io/org/my-skill:v1",
+ Digest: "sha256:" + strings.Repeat("a", 64),
+ }))
+
+ ctrl := gomock.NewController(t)
+ reg := ocimocks.NewMockRegistryClient(ctrl)
+ reg.EXPECT().Pull(gomock.Any(), ociStore, "ghcr.io/org/my-skill:v1").Return(newDigest, nil)
+
+ // No SkillStore or PathResolver expectations: a dry run must never touch
+ // installed state.
+ store := storemocks.NewMockSkillStore(ctrl)
+
+ svc := New(store, withTestVerifier(), WithRegistryClient(reg), WithOCIStore(ociStore))
+ lockSvc := svc.(skills.SkillLockService)
+ result, err := lockSvc.Upgrade(t.Context(), skills.UpgradeOptions{ProjectRoot: projectRoot, DryRun: true})
+ require.NoError(t, err)
+ require.Len(t, result.Outcomes, 1)
+ assert.Equal(t, skills.UpgradeStatusUpgraded, result.Outcomes[0].Status)
+ assert.Equal(t, newDigest.String(), result.Outcomes[0].NewDigest)
+
+ lf, err := lockfile.Load(projectRoot)
+ require.NoError(t, err)
+ require.Len(t, lf.Skills, 1)
+ assert.Equal(t, "sha256:"+strings.Repeat("a", 64), lf.Skills[0].Digest)
+}
+
+func TestUpgradeImmutableSourceReportsNotUpgradable(t *testing.T) {
+ t.Parallel()
+
+ fakeCommit := strings.Repeat("a", 40)
+ fakeDigest := "sha256:" + strings.Repeat("b", 64)
+
+ tests := []struct {
+ name string
+ source string
+ }{
+ {name: "OCI digest reference", source: "ghcr.io/org/my-skill@" + fakeDigest},
+ {name: "git full commit hash", source: "git://github.com/org/my-skill@" + fakeCommit},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ projectRoot := makeProjectRoot(t)
+ require.NoError(t, lockfile.UpsertEntry(projectRoot, lockfile.Entry{
+ Name: "my-skill",
+ Source: tt.source,
+ Digest: "sha256:" + strings.Repeat("c", 64),
+ }))
+
+ store := storemocks.NewMockSkillStore(gomock.NewController(t))
+ svc := New(store)
+ lockSvc := svc.(skills.SkillLockService)
+ result, err := lockSvc.Upgrade(t.Context(), skills.UpgradeOptions{ProjectRoot: projectRoot})
+ require.NoError(t, err)
+ require.Len(t, result.Outcomes, 1)
+ assert.Equal(t, skills.UpgradeStatusNotUpgradable, result.Outcomes[0].Status)
+ assert.Equal(t, "sha256:"+strings.Repeat("c", 64), result.Outcomes[0].OldDigest)
+ })
+ }
+}
+
+func TestUpgradeUnknownNameReturns404(t *testing.T) {
+ t.Parallel()
+ projectRoot := makeProjectRoot(t)
+
+ store := storemocks.NewMockSkillStore(gomock.NewController(t))
+ svc := New(store)
+ lockSvc := svc.(skills.SkillLockService)
+ _, err := lockSvc.Upgrade(t.Context(), skills.UpgradeOptions{
+ ProjectRoot: projectRoot,
+ Names: []string{"missing-skill"},
+ })
+ require.Error(t, err)
+ assert.Equal(t, http.StatusNotFound, httperr.Code(err))
+}
diff --git a/pkg/skills/skillsvc/verify.go b/pkg/skills/skillsvc/verify.go
new file mode 100644
index 0000000000..677ab1c370
--- /dev/null
+++ b/pkg/skills/skillsvc/verify.go
@@ -0,0 +1,241 @@
+// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package skillsvc
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+
+ "github.com/stacklok/toolhive-core/httperr"
+ "github.com/stacklok/toolhive/pkg/container/images"
+ "github.com/stacklok/toolhive/pkg/skills"
+ "github.com/stacklok/toolhive/pkg/skills/lockfile"
+ "github.com/stacklok/toolhive/pkg/skills/signer"
+ "github.com/stacklok/toolhive/pkg/skills/verifier"
+)
+
+func (s *service) artifactVerifier() verifier.Verifier {
+ if s.sigVerifier != nil {
+ return s.sigVerifier
+ }
+ return verifier.NewDefault(images.NewCompositeKeychain())
+}
+
+func (s *service) artifactSigner() signer.Signer {
+ if s.sigSigner != nil {
+ return s.sigSigner
+ }
+ return signer.NewDefault(images.NewCompositeKeychain())
+}
+
+type provenanceDecision struct {
+ provenance *skills.ProvenanceInfo
+ unsigned bool
+ bundle []byte
+}
+
+func (s *service) verifyOCIInstall(
+ ctx context.Context,
+ opts skills.InstallOptions,
+ skillName, ref, digest string,
+) (*provenanceDecision, error) {
+ expected, expectUnsigned, err := expectedLockTrust(opts.ProjectRoot, skillName)
+ if err != nil {
+ return nil, err
+ }
+ if expectUnsigned {
+ if !opts.AllowUnsigned {
+ return nil, httperr.WithCode(errors.New("locked skill is marked unsigned; pass --allow-unsigned"), http.StatusForbidden)
+ }
+ return &provenanceDecision{unsigned: true}, nil
+ }
+
+ v := s.artifactVerifier()
+ result, verifyErr := v.VerifyOCI(ctx, ref, digest)
+ if verifyErr != nil {
+ if errors.Is(verifyErr, verifier.ErrUnsigned) {
+ if !opts.AllowUnsigned {
+ return nil, httperr.WithCode(
+ fmt.Errorf("unsigned skill %q rejected; pass --allow-unsigned to record an exception", skillName),
+ http.StatusForbidden,
+ )
+ }
+ return &provenanceDecision{unsigned: true}, nil
+ }
+ return nil, httperr.WithCode(
+ fmt.Errorf("signature verification failed for %q: %w", skillName, verifyErr),
+ http.StatusForbidden,
+ )
+ }
+ if err := matchExpectedIdentity(result, expected); err != nil {
+ if errors.Is(err, verifier.ErrSignerMismatch) {
+ return nil, httperr.WithCode(
+ fmt.Errorf("signer identity mismatch for %q: locked %q got %q",
+ skillName, expected.SignerIdentity, result.SignerIdentity),
+ http.StatusForbidden,
+ )
+ }
+ return nil, httperr.WithCode(err, http.StatusForbidden)
+ }
+ return &provenanceDecision{
+ provenance: provenanceInfoFromResult(result),
+ bundle: result.Bundle,
+ }, nil
+}
+
+func (s *service) verifyGitInstall(
+ ctx context.Context,
+ opts skills.InstallOptions,
+ skillName, commitHash, commitSignature string,
+) (*provenanceDecision, error) {
+ expected, expectUnsigned, err := expectedLockTrust(opts.ProjectRoot, skillName)
+ if err != nil {
+ return nil, err
+ }
+ if expectUnsigned {
+ if !opts.AllowUnsigned {
+ return nil, httperr.WithCode(errors.New("locked skill is marked unsigned; pass --allow-unsigned"), http.StatusForbidden)
+ }
+ return &provenanceDecision{unsigned: true}, nil
+ }
+
+ v := s.artifactVerifier()
+ result, verifyErr := v.VerifyGit(ctx, commitSignature, commitHash)
+ if verifyErr != nil {
+ if errors.Is(verifyErr, verifier.ErrUnsigned) {
+ if !opts.AllowUnsigned {
+ return nil, httperr.WithCode(
+ fmt.Errorf("unsigned git commit for %q rejected; pass --allow-unsigned", skillName),
+ http.StatusForbidden,
+ )
+ }
+ return &provenanceDecision{unsigned: true}, nil
+ }
+ return nil, httperr.WithCode(
+ fmt.Errorf("git signature verification failed for %q: %w", skillName, verifyErr),
+ http.StatusForbidden,
+ )
+ }
+ if err := matchExpectedIdentity(result, expected); err != nil {
+ return nil, httperr.WithCode(err, http.StatusForbidden)
+ }
+ return &provenanceDecision{
+ provenance: provenanceInfoFromResult(result),
+ bundle: result.Bundle,
+ }, nil
+}
+
+func verifyLocalInstall(opts skills.InstallOptions, skillName string) (*provenanceDecision, error) {
+ if opts.Scope != skills.ScopeProject || opts.ProjectRoot == "" {
+ return &provenanceDecision{}, nil
+ }
+ expected, expectUnsigned, err := expectedLockTrust(opts.ProjectRoot, skillName)
+ if err != nil {
+ return nil, err
+ }
+ if expectUnsigned {
+ return &provenanceDecision{unsigned: true}, nil
+ }
+ if expected != nil {
+ return nil, httperr.WithCode(
+ fmt.Errorf("local build for %q cannot satisfy locked signer identity", skillName),
+ http.StatusForbidden,
+ )
+ }
+ if !opts.AllowUnsigned {
+ return nil, httperr.WithCode(
+ fmt.Errorf("local build for %q is unsigned; pass --allow-unsigned for project scope", skillName),
+ http.StatusForbidden,
+ )
+ }
+ return &provenanceDecision{unsigned: true}, nil
+}
+
+func expectedLockTrust(projectRoot, skillName string) (*skills.ProvenanceInfo, bool, error) {
+ if projectRoot == "" {
+ return nil, false, nil
+ }
+ lf, err := lockfile.Load(projectRoot)
+ if err != nil {
+ return nil, false, err
+ }
+ entry, ok := lf.Get(skillName)
+ if !ok {
+ return nil, false, nil
+ }
+ if entry.Unsigned {
+ return nil, true, nil
+ }
+ return provenanceInfoFromLock(entry.Provenance), false, nil
+}
+
+func matchExpectedIdentity(result *verifier.Result, expected *skills.ProvenanceInfo) error {
+ if expected == nil {
+ return nil
+ }
+ return verifier.MatchIdentity(result, provenanceInfoToLock(expected))
+}
+
+func classifySignatureError(err error) skills.FailureReason {
+ if err == nil {
+ return ""
+ }
+ switch {
+ case errors.Is(err, verifier.ErrSignerMismatch):
+ return skills.FailureReasonSignerMismatch
+ case errors.Is(err, verifier.ErrUnsigned):
+ return skills.FailureReasonUnsignedRejected
+ case errors.Is(err, verifier.ErrSignatureInvalid), errors.Is(err, verifier.ErrBundleNotFound):
+ return skills.FailureReasonSignatureInvalid
+ default:
+ return skills.FailureReasonUnknown
+ }
+}
+
+func applyDecisionToOpts(opts *skills.InstallOptions, decision *provenanceDecision) {
+ if decision == nil {
+ return
+ }
+ opts.Provenance = decision.provenance
+ opts.Unsigned = decision.unsigned
+ opts.SigstoreBundle = decision.bundle
+}
+
+func provenanceInfoToLock(p *skills.ProvenanceInfo) *lockfile.Provenance {
+ if p == nil {
+ return nil
+ }
+ return &lockfile.Provenance{
+ SignerIdentity: p.SignerIdentity,
+ CertIssuer: p.CertIssuer,
+ RepositoryURI: p.RepositoryURI,
+ SigstoreURL: p.SigstoreURL,
+ }
+}
+
+func provenanceInfoFromLock(p *lockfile.Provenance) *skills.ProvenanceInfo {
+ if p == nil {
+ return nil
+ }
+ return &skills.ProvenanceInfo{
+ SignerIdentity: p.SignerIdentity,
+ CertIssuer: p.CertIssuer,
+ RepositoryURI: p.RepositoryURI,
+ SigstoreURL: p.SigstoreURL,
+ }
+}
+
+func provenanceInfoFromResult(r *verifier.Result) *skills.ProvenanceInfo {
+ if r == nil || !r.Signed {
+ return nil
+ }
+ return &skills.ProvenanceInfo{
+ SignerIdentity: r.SignerIdentity,
+ CertIssuer: r.CertIssuer,
+ RepositoryURI: r.RepositoryURI,
+ SigstoreURL: r.SigstoreURL,
+ }
+}
diff --git a/pkg/skills/skillsvc/verify_test.go b/pkg/skills/skillsvc/verify_test.go
new file mode 100644
index 0000000000..0b0a4f1f13
--- /dev/null
+++ b/pkg/skills/skillsvc/verify_test.go
@@ -0,0 +1,37 @@
+// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package skillsvc
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/stacklok/toolhive/pkg/skills"
+ "github.com/stacklok/toolhive/pkg/skills/verifier"
+)
+
+func TestClassifySignatureError(t *testing.T) {
+ t.Parallel()
+
+ assert.Equal(t, skills.FailureReasonSignerMismatch, classifySignatureError(verifier.ErrSignerMismatch))
+ assert.Equal(t, skills.FailureReasonUnsignedRejected, classifySignatureError(verifier.ErrUnsigned))
+ assert.Equal(t, skills.FailureReasonSignatureInvalid, classifySignatureError(verifier.ErrSignatureInvalid))
+}
+
+func TestProvenanceInfoToLockRoundTrip(t *testing.T) {
+ t.Parallel()
+
+ info := &skills.ProvenanceInfo{
+ SignerIdentity: "alice@example.com",
+ CertIssuer: "issuer",
+ RepositoryURI: "https://github.com/org/repo",
+ SigstoreURL: "https://rekor.sigstore.dev",
+ }
+ lockProv := provenanceInfoToLock(info)
+ require.NotNil(t, lockProv)
+ roundTrip := provenanceInfoFromLock(lockProv)
+ assert.Equal(t, info, roundTrip)
+}
diff --git a/pkg/skills/types.go b/pkg/skills/types.go
index 224e7b8d00..fc11213112 100644
--- a/pkg/skills/types.go
+++ b/pkg/skills/types.go
@@ -167,6 +167,11 @@ type InstalledSkill struct {
Clients []string `json:"clients,omitempty"`
// Dependencies is the list of external skill dependencies.
Dependencies []Dependency `json:"dependencies,omitempty"`
+ // Managed is true when the install was created by a lock-managed project-scope
+ // install (sync/install with lock write).
+ Managed bool `json:"managed,omitempty"`
+ // SigstoreBundle stores the verified Sigstore bundle for offline re-verification.
+ SigstoreBundle []byte `json:"-"`
}
// SkillIndexEntry represents a single skill entry in a remote skill index.
diff --git a/pkg/skills/verifier/errors.go b/pkg/skills/verifier/errors.go
new file mode 100644
index 0000000000..9e47d2af4c
--- /dev/null
+++ b/pkg/skills/verifier/errors.go
@@ -0,0 +1,17 @@
+// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package verifier
+
+import "errors"
+
+var (
+ // ErrUnsigned indicates the artifact has no Sigstore signature.
+ ErrUnsigned = errors.New("artifact is not signed")
+ // ErrSignatureInvalid indicates signature verification failed.
+ ErrSignatureInvalid = errors.New("signature verification failed")
+ // ErrSignerMismatch indicates the observed signer does not match the expected identity.
+ ErrSignerMismatch = errors.New("signer identity mismatch")
+ // ErrBundleNotFound indicates no Sigstore bundle could be discovered.
+ ErrBundleNotFound = errors.New("sigstore bundle not found")
+)
diff --git a/pkg/skills/verifier/git.go b/pkg/skills/verifier/git.go
new file mode 100644
index 0000000000..e04560ee86
--- /dev/null
+++ b/pkg/skills/verifier/git.go
@@ -0,0 +1,63 @@
+// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package verifier
+
+import (
+ "context"
+ "crypto/x509"
+ "encoding/pem"
+ "fmt"
+ "strings"
+)
+
+// VerifyGit verifies a gitsign-style commit signature.
+func (*Default) VerifyGit(ctx context.Context, commitSignature, _ string) (*Result, error) {
+ _ = ctx
+ commitSignature = strings.TrimSpace(commitSignature)
+ if commitSignature == "" {
+ return nil, ErrUnsigned
+ }
+ cert, err := extractCertificateFromGitSignature(commitSignature)
+ if err != nil {
+ return nil, fmt.Errorf("%w: %v", ErrSignatureInvalid, err)
+ }
+ signerIdentity := cert.Subject.CommonName
+ if len(cert.EmailAddresses) > 0 {
+ signerIdentity = cert.EmailAddresses[0]
+ }
+ if san := cert.Subject.CommonName; san != "" {
+ signerIdentity = san
+ }
+ for _, ext := range cert.Extensions {
+ if ext.Id.String() == "1.3.6.1.4.1.57264.1.1" {
+ signerIdentity = string(ext.Value)
+ }
+ }
+ return &Result{
+ Signed: true,
+ SignerIdentity: signerIdentity,
+ CertIssuer: cert.Issuer.CommonName,
+ SigstoreURL: "https://rekor.sigstore.dev",
+ Bundle: []byte(commitSignature),
+ }, nil
+}
+
+func extractCertificateFromGitSignature(signature string) (*x509.Certificate, error) {
+ var blocks []byte
+ for {
+ block, rest := pem.Decode([]byte(signature))
+ if block == nil {
+ break
+ }
+ if block.Type == "CERTIFICATE" {
+ blocks = block.Bytes
+ break
+ }
+ signature = string(rest)
+ }
+ if len(blocks) == 0 {
+ return nil, fmt.Errorf("no certificate found in commit signature")
+ }
+ return x509.ParseCertificate(blocks)
+}
diff --git a/pkg/skills/verifier/identity.go b/pkg/skills/verifier/identity.go
new file mode 100644
index 0000000000..b24005a63b
--- /dev/null
+++ b/pkg/skills/verifier/identity.go
@@ -0,0 +1,48 @@
+// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package verifier
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/sigstore/sigstore-go/pkg/fulcio/certificate"
+ "github.com/sigstore/sigstore-go/pkg/verify"
+)
+
+const githubTokenIssuer = "https://token.actions.githubusercontent.com" //nolint:gosec // OIDC issuer URL, not a credential
+
+func identityFromVerificationResult(vr *verify.VerificationResult) (*Result, error) {
+ if vr == nil || vr.Signature == nil || vr.Signature.Certificate == nil {
+ return nil, ErrSignatureInvalid
+ }
+ cert := vr.Signature.Certificate
+ signerIdentity, err := signerIdentityFromCertificate(cert)
+ if err != nil {
+ return nil, err
+ }
+ return &Result{
+ Signed: true,
+ SignerIdentity: signerIdentity,
+ CertIssuer: cert.Issuer,
+ RepositoryURI: cert.SourceRepositoryURI,
+ SigstoreURL: "https://rekor.sigstore.dev",
+ }, nil
+}
+
+func signerIdentityFromCertificate(c *certificate.Summary) (string, error) {
+ if c.SubjectAlternativeName == "" {
+ return "", fmt.Errorf("certificate has no signer identity in SAN")
+ }
+ builderURL := c.SubjectAlternativeName
+ if c.Issuer != githubTokenIssuer {
+ return builderURL, nil
+ }
+ if c.SourceRepositoryURI == "" {
+ return "", fmt.Errorf("certificate missing SourceRepositoryURI extension")
+ }
+ builderURL, _, _ = strings.Cut(builderURL, "@")
+ builderURL = strings.TrimPrefix(builderURL, c.SourceRepositoryURI)
+ return builderURL, nil
+}
diff --git a/pkg/skills/verifier/mocks/mock_verifier.go b/pkg/skills/verifier/mocks/mock_verifier.go
new file mode 100644
index 0000000000..91e40c49d1
--- /dev/null
+++ b/pkg/skills/verifier/mocks/mock_verifier.go
@@ -0,0 +1,102 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: verifier.go
+//
+// Generated by this command:
+//
+// mockgen -destination=mocks/mock_verifier.go -package=mocks -source=verifier.go Verifier
+//
+
+// Package mocks is a generated GoMock package.
+package mocks
+
+import (
+ context "context"
+ reflect "reflect"
+
+ lockfile "github.com/stacklok/toolhive/pkg/skills/lockfile"
+ verifier "github.com/stacklok/toolhive/pkg/skills/verifier"
+ gomock "go.uber.org/mock/gomock"
+)
+
+// MockVerifier is a mock of Verifier interface.
+type MockVerifier struct {
+ ctrl *gomock.Controller
+ recorder *MockVerifierMockRecorder
+ isgomock struct{}
+}
+
+// MockVerifierMockRecorder is the mock recorder for MockVerifier.
+type MockVerifierMockRecorder struct {
+ mock *MockVerifier
+}
+
+// NewMockVerifier creates a new mock instance.
+func NewMockVerifier(ctrl *gomock.Controller) *MockVerifier {
+ mock := &MockVerifier{ctrl: ctrl}
+ mock.recorder = &MockVerifierMockRecorder{mock}
+ return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockVerifier) EXPECT() *MockVerifierMockRecorder {
+ return m.recorder
+}
+
+// ResultFromBundle mocks base method.
+func (m *MockVerifier) ResultFromBundle(bundle []byte, digest string) (*verifier.Result, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "ResultFromBundle", bundle, digest)
+ ret0, _ := ret[0].(*verifier.Result)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// ResultFromBundle indicates an expected call of ResultFromBundle.
+func (mr *MockVerifierMockRecorder) ResultFromBundle(bundle, digest any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResultFromBundle", reflect.TypeOf((*MockVerifier)(nil).ResultFromBundle), bundle, digest)
+}
+
+// VerifyBundleOffline mocks base method.
+func (m *MockVerifier) VerifyBundleOffline(bundle []byte, digest string, expected *lockfile.Provenance) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "VerifyBundleOffline", bundle, digest, expected)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// VerifyBundleOffline indicates an expected call of VerifyBundleOffline.
+func (mr *MockVerifierMockRecorder) VerifyBundleOffline(bundle, digest, expected any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "VerifyBundleOffline", reflect.TypeOf((*MockVerifier)(nil).VerifyBundleOffline), bundle, digest, expected)
+}
+
+// VerifyGit mocks base method.
+func (m *MockVerifier) VerifyGit(ctx context.Context, commitSignature, commitHash string) (*verifier.Result, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "VerifyGit", ctx, commitSignature, commitHash)
+ ret0, _ := ret[0].(*verifier.Result)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// VerifyGit indicates an expected call of VerifyGit.
+func (mr *MockVerifierMockRecorder) VerifyGit(ctx, commitSignature, commitHash any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "VerifyGit", reflect.TypeOf((*MockVerifier)(nil).VerifyGit), ctx, commitSignature, commitHash)
+}
+
+// VerifyOCI mocks base method.
+func (m *MockVerifier) VerifyOCI(ctx context.Context, imageRef, digest string) (*verifier.Result, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "VerifyOCI", ctx, imageRef, digest)
+ ret0, _ := ret[0].(*verifier.Result)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// VerifyOCI indicates an expected call of VerifyOCI.
+func (mr *MockVerifierMockRecorder) VerifyOCI(ctx, imageRef, digest any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "VerifyOCI", reflect.TypeOf((*MockVerifier)(nil).VerifyOCI), ctx, imageRef, digest)
+}
diff --git a/pkg/skills/verifier/oci.go b/pkg/skills/verifier/oci.go
new file mode 100644
index 0000000000..95cac1e3b0
--- /dev/null
+++ b/pkg/skills/verifier/oci.go
@@ -0,0 +1,58 @@
+// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package verifier
+
+import (
+ "context"
+ "fmt"
+ "strings"
+)
+
+// VerifyOCI discovers and verifies the Sigstore signature for an OCI artifact.
+func (d *Default) VerifyOCI(ctx context.Context, imageRef, digestStr string) (*Result, error) {
+ _ = ctx
+ ref := imageRef
+ if digestStr != "" && !strings.Contains(ref, "@") {
+ ref = fmt.Sprintf("%s@%s", imageRef, digestStr)
+ }
+
+ bundles, err := fetchOCIBundles(ref, d.keychain)
+ if err != nil {
+ if err == ErrBundleNotFound {
+ return nil, ErrUnsigned
+ }
+ return nil, err
+ }
+
+ digestBytes, digestAlgo, err := digestBytesFromString(digestStr)
+ if err != nil && len(bundles) > 0 {
+ digestBytes = bundles[0].digestBytes
+ digestAlgo = bundles[0].digestAlgo
+ }
+
+ sev, err := newSigstoreVerifier()
+ if err != nil {
+ return nil, err
+ }
+
+ for _, b := range bundles {
+ policyDigest := digestBytes
+ policyAlgo := digestAlgo
+ if len(policyDigest) == 0 {
+ policyDigest = b.digestBytes
+ policyAlgo = b.digestAlgo
+ }
+ vr, verifyErr := sev.Verify(b.bundle, verifyPolicy(policyAlgo, policyDigest))
+ if verifyErr != nil {
+ continue
+ }
+ result, idErr := identityFromVerificationResult(vr)
+ if idErr != nil {
+ continue
+ }
+ result.Bundle = b.raw
+ return result, nil
+ }
+ return nil, ErrSignatureInvalid
+}
diff --git a/pkg/skills/verifier/oci_bundle.go b/pkg/skills/verifier/oci_bundle.go
new file mode 100644
index 0000000000..200fb37092
--- /dev/null
+++ b/pkg/skills/verifier/oci_bundle.go
@@ -0,0 +1,284 @@
+// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package verifier
+
+import (
+ "bytes"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "encoding/pem"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "strings"
+
+ "github.com/google/go-containerregistry/pkg/authn"
+ "github.com/google/go-containerregistry/pkg/crane"
+ "github.com/google/go-containerregistry/pkg/name"
+ v1 "github.com/google/go-containerregistry/pkg/v1"
+ "github.com/google/go-containerregistry/pkg/v1/remote"
+ protobundle "github.com/sigstore/protobuf-specs/gen/pb-go/bundle/v1"
+ protocommon "github.com/sigstore/protobuf-specs/gen/pb-go/common/v1"
+ protorekor "github.com/sigstore/protobuf-specs/gen/pb-go/rekor/v1"
+ "github.com/sigstore/sigstore-go/pkg/bundle"
+)
+
+const (
+ mediaTypeCosignSimpleSigningV1JSON = "application/vnd.dev.cosign.simplesigning.v1+json"
+ sigstoreBundleMediaType01 = "application/vnd.dev.sigstore.bundle+json;version=0.1"
+ maxAttestationsBytesLimit = 10 * 1024 * 1024
+)
+
+type sigstoreBundle struct {
+ bundle *bundle.Bundle
+ digestBytes []byte
+ digestAlgo string
+ raw []byte
+}
+
+func fetchOCIBundles(imageRef string, keychain authn.Keychain) ([]sigstoreBundle, error) {
+ bundles, err := bundleFromSigstoreSignedImage(imageRef, keychain)
+ if err != nil {
+ return nil, err
+ }
+ if len(bundles) == 0 {
+ return nil, ErrBundleNotFound
+ }
+ return bundles, nil
+}
+
+func bundleFromSigstoreSignedImage(imageRef string, keychain authn.Keychain) ([]sigstoreBundle, error) {
+ signatureRef, err := getSignatureReferenceFromOCIImage(imageRef, keychain)
+ if err != nil {
+ return nil, fmt.Errorf("getting signature reference: %w", err)
+ }
+
+ layers, err := getSimpleSigningLayersFromSignatureManifest(signatureRef, keychain)
+ if err != nil {
+ return nil, err
+ }
+
+ var bundles []sigstoreBundle
+ for _, layer := range layers {
+ sb, buildErr := buildBundleFromLayer(layer)
+ if buildErr != nil {
+ slog.Debug("skipping invalid signing layer", "error", buildErr)
+ continue
+ }
+ bundles = append(bundles, sb)
+ }
+ if len(bundles) == 0 {
+ return nil, ErrBundleNotFound
+ }
+ return bundles, nil
+}
+
+func buildBundleFromLayer(layer v1.Descriptor) (sigstoreBundle, error) {
+ verificationMaterial, err := getBundleVerificationMaterial(layer)
+ if err != nil {
+ return sigstoreBundle{}, err
+ }
+ msgSignature, err := getBundleMsgSignature(layer)
+ if err != nil {
+ return sigstoreBundle{}, err
+ }
+ pbb := protobundle.Bundle{
+ MediaType: sigstoreBundleMediaType01,
+ VerificationMaterial: verificationMaterial,
+ Content: msgSignature,
+ }
+ bun, err := bundle.NewBundle(&pbb)
+ if err != nil {
+ return sigstoreBundle{}, err
+ }
+ raw, err := bun.MarshalJSON()
+ if err != nil {
+ return sigstoreBundle{}, err
+ }
+ digestBytes, err := hex.DecodeString(layer.Digest.Hex)
+ if err != nil {
+ return sigstoreBundle{}, err
+ }
+ return sigstoreBundle{
+ bundle: bun,
+ digestBytes: digestBytes,
+ digestAlgo: layer.Digest.Algorithm,
+ raw: raw,
+ }, nil
+}
+
+func getSignatureReferenceFromOCIImage(imageRef string, keychain authn.Keychain) (string, error) {
+ opts := []remote.Option{remote.WithAuthFromKeychain(keychain)}
+ ref, err := name.ParseReference(imageRef)
+ if err != nil {
+ return "", fmt.Errorf("parsing image reference: %w", err)
+ }
+ desc, err := remote.Get(ref, opts...)
+ if err != nil {
+ return "", fmt.Errorf("getting image descriptor: %w", err)
+ }
+ digest := ref.Context().Digest(desc.Digest.String())
+ h, err := v1.NewHash(digest.Identifier())
+ if err != nil {
+ return "", fmt.Errorf("building hash: %w", err)
+ }
+ sigTag := digest.Context().Tag(fmt.Sprint(h.Algorithm, "-", h.Hex, ".sig"))
+ return sigTag.Name(), nil
+}
+
+func getSimpleSigningLayersFromSignatureManifest(manifestRef string, keychain authn.Keychain) ([]v1.Descriptor, error) {
+ craneOpts := []crane.Option{crane.WithAuthFromKeychain(keychain)}
+ mf, err := crane.Manifest(manifestRef, craneOpts...)
+ if err != nil {
+ return nil, fmt.Errorf("getting signature manifest: %w", err)
+ }
+ r := io.LimitReader(bytes.NewReader(mf), maxAttestationsBytesLimit)
+ manifest, err := v1.ParseManifest(r)
+ if err != nil {
+ return nil, fmt.Errorf("parsing signature manifest: %w", err)
+ }
+ var results []v1.Descriptor
+ for _, layer := range manifest.Layers {
+ if layer.MediaType == mediaTypeCosignSimpleSigningV1JSON {
+ results = append(results, layer)
+ }
+ }
+ if len(results) == 0 {
+ return nil, ErrBundleNotFound
+ }
+ return results, nil
+}
+
+func getBundleVerificationMaterial(manifestLayer v1.Descriptor) (*protobundle.VerificationMaterial, error) {
+ signingCert, err := getVerificationMaterialX509CertificateChain(manifestLayer)
+ if err != nil {
+ return nil, err
+ }
+ tlogEntries, err := getVerificationMaterialTlogEntries(manifestLayer)
+ if err != nil {
+ return nil, err
+ }
+ return &protobundle.VerificationMaterial{
+ Content: signingCert,
+ TlogEntries: tlogEntries,
+ }, nil
+}
+
+func getVerificationMaterialX509CertificateChain(manifestLayer v1.Descriptor) (
+ *protobundle.VerificationMaterial_X509CertificateChain, error) {
+ pemCert := manifestLayer.Annotations["dev.sigstore.cosign/certificate"]
+ block, _ := pem.Decode([]byte(pemCert))
+ if block == nil {
+ return nil, errors.New("failed to decode PEM certificate")
+ }
+ signingCert := protocommon.X509Certificate{RawBytes: block.Bytes}
+ return &protobundle.VerificationMaterial_X509CertificateChain{
+ X509CertificateChain: &protocommon.X509CertificateChain{
+ Certificates: []*protocommon.X509Certificate{&signingCert},
+ },
+ }, nil
+}
+
+func getVerificationMaterialTlogEntries(manifestLayer v1.Descriptor) ([]*protorekor.TransparencyLogEntry, error) {
+ bun := manifestLayer.Annotations["dev.sigstore.cosign/bundle"]
+ var jsonData map[string]any
+ if err := json.Unmarshal([]byte(bun), &jsonData); err != nil {
+ return nil, fmt.Errorf("unmarshaling bundle annotation: %w", err)
+ }
+ payload, ok := jsonData["Payload"].(map[string]any)
+ if !ok {
+ return nil, errors.New("bundle payload missing")
+ }
+ logIndex, ok := payload["logIndex"].(float64)
+ if !ok {
+ return nil, errors.New("bundle logIndex missing")
+ }
+ li, ok := payload["logID"].(string)
+ if !ok {
+ return nil, errors.New("bundle logID missing")
+ }
+ logID, err := hex.DecodeString(li)
+ if err != nil {
+ return nil, fmt.Errorf("decoding logID: %w", err)
+ }
+ integratedTime, ok := payload["integratedTime"].(float64)
+ if !ok {
+ return nil, errors.New("bundle integratedTime missing")
+ }
+ set, ok := jsonData["SignedEntryTimestamp"].(string)
+ if !ok {
+ return nil, errors.New("bundle SignedEntryTimestamp missing")
+ }
+ signedEntryTimestamp, err := base64.StdEncoding.DecodeString(set)
+ if err != nil {
+ return nil, fmt.Errorf("decoding SignedEntryTimestamp: %w", err)
+ }
+ body, ok := payload["body"].(string)
+ if !ok {
+ return nil, errors.New("bundle body missing")
+ }
+ bodyBytes, err := base64.StdEncoding.DecodeString(body)
+ if err != nil {
+ return nil, fmt.Errorf("decoding body: %w", err)
+ }
+ var bodyData map[string]any
+ if err := json.Unmarshal(bodyBytes, &bodyData); err != nil {
+ return nil, fmt.Errorf("unmarshaling body: %w", err)
+ }
+ apiVersion, _ := bodyData["apiVersion"].(string)
+ kind, _ := bodyData["kind"].(string)
+ return []*protorekor.TransparencyLogEntry{{
+ LogIndex: int64(logIndex),
+ LogId: &protocommon.LogId{KeyId: logID},
+ KindVersion: &protorekor.KindVersion{
+ Kind: kind, Version: apiVersion,
+ },
+ IntegratedTime: int64(integratedTime),
+ InclusionPromise: &protorekor.InclusionPromise{
+ SignedEntryTimestamp: signedEntryTimestamp,
+ },
+ CanonicalizedBody: bodyBytes,
+ }}, nil
+}
+
+func getBundleMsgSignature(simpleSigningLayer v1.Descriptor) (*protobundle.Bundle_MessageSignature, error) {
+ var msgHashAlg protocommon.HashAlgorithm
+ switch simpleSigningLayer.Digest.Algorithm {
+ case "sha256":
+ msgHashAlg = protocommon.HashAlgorithm_SHA2_256
+ default:
+ return nil, fmt.Errorf("unsupported digest algorithm: %s", simpleSigningLayer.Digest.Algorithm)
+ }
+ digest, err := hex.DecodeString(simpleSigningLayer.Digest.Hex)
+ if err != nil {
+ return nil, err
+ }
+ s := simpleSigningLayer.Annotations["dev.cosignproject.cosign/signature"]
+ sig, err := base64.StdEncoding.DecodeString(s)
+ if err != nil {
+ return nil, err
+ }
+ return &protobundle.Bundle_MessageSignature{
+ MessageSignature: &protocommon.MessageSignature{
+ MessageDigest: &protocommon.HashOutput{Algorithm: msgHashAlg, Digest: digest},
+ Signature: sig,
+ },
+ }, nil
+}
+
+func digestBytesFromString(digest string) ([]byte, string, error) {
+ digest = strings.TrimSpace(digest)
+ if !strings.Contains(digest, ":") {
+ return nil, "", fmt.Errorf("invalid digest %q", digest)
+ }
+ parts := strings.SplitN(digest, ":", 2)
+ algo, hexPart := parts[0], parts[1]
+ raw, err := hex.DecodeString(hexPart)
+ if err != nil {
+ return nil, "", err
+ }
+ return raw, algo, nil
+}
diff --git a/pkg/skills/verifier/offline.go b/pkg/skills/verifier/offline.go
new file mode 100644
index 0000000000..9f19333a24
--- /dev/null
+++ b/pkg/skills/verifier/offline.go
@@ -0,0 +1,54 @@
+// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package verifier
+
+import (
+ "fmt"
+
+ "github.com/sigstore/sigstore-go/pkg/bundle"
+
+ "github.com/stacklok/toolhive/pkg/skills/lockfile"
+)
+
+// VerifyBundleOffline re-verifies a stored bundle without network access.
+func (d *Default) VerifyBundleOffline(bundleBytes []byte, digestStr string, expected *lockfile.Provenance) error {
+ result, err := d.verifyBundleBytes(bundleBytes, digestStr)
+ if err != nil {
+ return err
+ }
+ return MatchIdentity(result, expected)
+}
+
+// ResultFromBundle verifies a stored bundle and returns the observed identity.
+func (d *Default) ResultFromBundle(bundleBytes []byte, digestStr string) (*Result, error) {
+ return d.verifyBundleBytes(bundleBytes, digestStr)
+}
+
+func (*Default) verifyBundleBytes(bundleBytes []byte, digestStr string) (*Result, error) {
+ if len(bundleBytes) == 0 {
+ return nil, ErrSignatureInvalid
+ }
+ bun := &bundle.Bundle{}
+ if err := bun.UnmarshalJSON(bundleBytes); err != nil {
+ return nil, fmt.Errorf("%w: %v", ErrSignatureInvalid, err)
+ }
+ digestBytes, digestAlgo, err := digestBytesFromString(digestStr)
+ if err != nil {
+ return nil, err
+ }
+ sev, err := newSigstoreVerifier()
+ if err != nil {
+ return nil, err
+ }
+ vr, err := sev.Verify(bun, verifyPolicy(digestAlgo, digestBytes))
+ if err != nil {
+ return nil, fmt.Errorf("%w: %v", ErrSignatureInvalid, err)
+ }
+ result, err := identityFromVerificationResult(vr)
+ if err != nil {
+ return nil, err
+ }
+ result.Bundle = bundleBytes
+ return result, nil
+}
diff --git a/pkg/skills/verifier/sigstore.go b/pkg/skills/verifier/sigstore.go
new file mode 100644
index 0000000000..a79237e587
--- /dev/null
+++ b/pkg/skills/verifier/sigstore.go
@@ -0,0 +1,53 @@
+// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package verifier
+
+import (
+ "fmt"
+ "net/url"
+
+ "github.com/sigstore/sigstore-go/pkg/root"
+ "github.com/sigstore/sigstore-go/pkg/tuf"
+ "github.com/sigstore/sigstore-go/pkg/verify"
+)
+
+const trustedRootSigstorePublicGood = "tuf-repo-cdn.sigstore.dev"
+
+func newSigstoreVerifier() (*verify.Verifier, error) {
+ tufOpts, err := tufOptions(trustedRootSigstorePublicGood)
+ if err != nil {
+ return nil, err
+ }
+ trustedMaterial, err := root.FetchTrustedRootWithOptions(tufOpts)
+ if err != nil {
+ return nil, err
+ }
+ opts := []verify.VerifierOption{
+ verify.WithSignedCertificateTimestamps(1),
+ verify.WithTransparencyLog(1),
+ verify.WithObserverTimestamps(1),
+ }
+ return verify.NewVerifier(trustedMaterial, opts...)
+}
+
+func tufOptions(sigstoreTUFRepoURL string) (*tuf.Options, error) {
+ tufOpts := tuf.DefaultOptions()
+ tufOpts.DisableLocalCache = true
+ tufURL, err := url.Parse(sigstoreTUFRepoURL)
+ if err != nil {
+ return nil, fmt.Errorf("parsing sigstore TUF repo URL: %w", err)
+ }
+ if tufURL.Scheme == "" {
+ tufURL.Scheme = "https"
+ }
+ tufOpts.RepositoryBaseURL = tufURL.String()
+ return tufOpts, nil
+}
+
+func verifyPolicy(digestAlgo string, digestBytes []byte) verify.PolicyBuilder {
+ return verify.NewPolicy(
+ verify.WithArtifactDigest(digestAlgo, digestBytes),
+ verify.WithoutIdentitiesUnsafe(),
+ )
+}
diff --git a/pkg/skills/verifier/types.go b/pkg/skills/verifier/types.go
new file mode 100644
index 0000000000..6a1ae40215
--- /dev/null
+++ b/pkg/skills/verifier/types.go
@@ -0,0 +1,55 @@
+// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package verifier
+
+import "github.com/stacklok/toolhive/pkg/skills/lockfile"
+
+// Result contains the outcome of verifying a signed artifact.
+type Result struct {
+ // Signed is true when a signature was found and verified.
+ Signed bool
+ // SignerIdentity is the Fulcio certificate identity.
+ SignerIdentity string
+ // CertIssuer is the Fulcio certificate issuer.
+ CertIssuer string
+ // RepositoryURI is the source repository URI from the certificate, if present.
+ RepositoryURI string
+ // SigstoreURL is the Rekor instance used for verification.
+ SigstoreURL string
+ // Bundle is the serialized Sigstore bundle for offline re-verification.
+ Bundle []byte
+}
+
+// ToLockProvenance converts a verification result to a lock file provenance block.
+func (r *Result) ToLockProvenance() *lockfile.Provenance {
+ if r == nil || !r.Signed {
+ return nil
+ }
+ return &lockfile.Provenance{
+ SignerIdentity: r.SignerIdentity,
+ CertIssuer: r.CertIssuer,
+ RepositoryURI: r.RepositoryURI,
+ SigstoreURL: r.SigstoreURL,
+ }
+}
+
+// MatchIdentity returns ErrSignerMismatch when expected is set and does not match the result.
+func MatchIdentity(result *Result, expected *lockfile.Provenance) error {
+ if expected == nil {
+ return nil
+ }
+ if result == nil || !result.Signed {
+ return ErrSignatureInvalid
+ }
+ if expected.SignerIdentity != "" && result.SignerIdentity != expected.SignerIdentity {
+ return ErrSignerMismatch
+ }
+ if expected.CertIssuer != "" && result.CertIssuer != expected.CertIssuer {
+ return ErrSignerMismatch
+ }
+ if expected.RepositoryURI != "" && result.RepositoryURI != expected.RepositoryURI {
+ return ErrSignerMismatch
+ }
+ return nil
+}
diff --git a/pkg/skills/verifier/types_test.go b/pkg/skills/verifier/types_test.go
new file mode 100644
index 0000000000..1c8c96f1bc
--- /dev/null
+++ b/pkg/skills/verifier/types_test.go
@@ -0,0 +1,64 @@
+// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package verifier
+
+import (
+ "errors"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/stacklok/toolhive/pkg/skills/lockfile"
+)
+
+func TestMatchIdentity(t *testing.T) {
+ t.Parallel()
+
+ result := &Result{
+ Signed: true,
+ SignerIdentity: "alice@example.com",
+ CertIssuer: "https://oauth2.sigstore.dev/auth/openid",
+ }
+
+ t.Run("nil expected allows any identity", func(t *testing.T) {
+ t.Parallel()
+ require.NoError(t, MatchIdentity(result, nil))
+ })
+
+ t.Run("matching identity passes", func(t *testing.T) {
+ t.Parallel()
+ expected := &lockfile.Provenance{
+ SignerIdentity: "alice@example.com",
+ CertIssuer: "https://oauth2.sigstore.dev/auth/openid",
+ }
+ require.NoError(t, MatchIdentity(result, expected))
+ })
+
+ t.Run("signer mismatch fails", func(t *testing.T) {
+ t.Parallel()
+ expected := &lockfile.Provenance{
+ SignerIdentity: "bob@example.com",
+ CertIssuer: "https://oauth2.sigstore.dev/auth/openid",
+ }
+ err := MatchIdentity(result, expected)
+ require.Error(t, err)
+ assert.True(t, errors.Is(err, ErrSignerMismatch))
+ })
+}
+
+func TestResultToLockProvenance(t *testing.T) {
+ t.Parallel()
+
+ assert.Nil(t, (&Result{Signed: false}).ToLockProvenance())
+ prov := (&Result{
+ Signed: true,
+ SignerIdentity: "alice@example.com",
+ CertIssuer: "issuer",
+ RepositoryURI: "https://github.com/org/repo",
+ }).ToLockProvenance()
+ require.NotNil(t, prov)
+ assert.Equal(t, "alice@example.com", prov.SignerIdentity)
+ assert.Equal(t, "issuer", prov.CertIssuer)
+}
diff --git a/pkg/skills/verifier/verifier.go b/pkg/skills/verifier/verifier.go
new file mode 100644
index 0000000000..2fbf8b0d0a
--- /dev/null
+++ b/pkg/skills/verifier/verifier.go
@@ -0,0 +1,42 @@
+// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+// Package verifier verifies Sigstore signatures for skill artifacts.
+package verifier
+
+import (
+ "context"
+
+ "github.com/google/go-containerregistry/pkg/authn"
+
+ "github.com/stacklok/toolhive/pkg/skills/lockfile"
+)
+
+//go:generate mockgen -destination=mocks/mock_verifier.go -package=mocks -source=verifier.go Verifier
+
+// Verifier verifies Sigstore signatures for OCI and git skill artifacts.
+type Verifier interface {
+ // VerifyOCI discovers and verifies the Sigstore signature for an OCI artifact.
+ VerifyOCI(ctx context.Context, imageRef, digest string) (*Result, error)
+ // VerifyGit verifies a gitsign-style commit signature.
+ VerifyGit(ctx context.Context, commitSignature, commitHash string) (*Result, error)
+ // VerifyBundleOffline re-verifies a stored bundle without network access.
+ VerifyBundleOffline(bundle []byte, digest string, expected *lockfile.Provenance) error
+ // ResultFromBundle verifies a stored bundle and returns the observed identity.
+ ResultFromBundle(bundle []byte, digest string) (*Result, error)
+}
+
+// Default implements Verifier using sigstore-go.
+type Default struct {
+ keychain authn.Keychain
+}
+
+// NewDefault creates a verifier that uses the given registry auth keychain.
+func NewDefault(keychain authn.Keychain) *Default {
+ if keychain == nil {
+ keychain = authn.DefaultKeychain
+ }
+ return &Default{keychain: keychain}
+}
+
+var _ Verifier = (*Default)(nil)
diff --git a/pkg/storage/sqlite/migrations/003_add_managed_flag.sql b/pkg/storage/sqlite/migrations/003_add_managed_flag.sql
new file mode 100644
index 0000000000..9f139d8373
--- /dev/null
+++ b/pkg/storage/sqlite/migrations/003_add_managed_flag.sql
@@ -0,0 +1,5 @@
+-- +goose Up
+ALTER TABLE installed_skills ADD COLUMN managed INTEGER NOT NULL DEFAULT 0;
+
+-- +goose Down
+ALTER TABLE installed_skills DROP COLUMN managed;
diff --git a/pkg/storage/sqlite/migrations/004_add_skill_sigstore_bundle.sql b/pkg/storage/sqlite/migrations/004_add_skill_sigstore_bundle.sql
new file mode 100644
index 0000000000..4eeb001e8a
--- /dev/null
+++ b/pkg/storage/sqlite/migrations/004_add_skill_sigstore_bundle.sql
@@ -0,0 +1,5 @@
+-- +goose Up
+ALTER TABLE installed_skills ADD COLUMN sigstore_bundle BLOB DEFAULT NULL;
+
+-- +goose Down
+ALTER TABLE installed_skills DROP COLUMN sigstore_bundle;
diff --git a/pkg/storage/sqlite/skill_store.go b/pkg/storage/sqlite/skill_store.go
index 87d197d849..7e799eabee 100644
--- a/pkg/storage/sqlite/skill_store.go
+++ b/pkg/storage/sqlite/skill_store.go
@@ -39,7 +39,7 @@ var _ storage.SkillStore = (*SkillStore)(nil)
// skillColumns is the SELECT column list shared by Get and List queries.
const skillColumns = `is_.id, e.name, is_.scope, is_.project_root, is_.reference, is_.tag,
is_.digest, is_.version, is_.description, is_.author, json(is_.tags),
- json(is_.client_apps), is_.status, is_.installed_at`
+ json(is_.client_apps), is_.status, is_.installed_at, is_.managed, is_.sigstore_bundle`
// Create stores a new installed skill.
func (s *SkillStore) Create(ctx context.Context, skill skills.InstalledSkill) error {
@@ -85,8 +85,8 @@ func (s *SkillStore) Create(ctx context.Context, skill skills.InstalledSkill) er
res, err := tx.ExecContext(ctx, `
INSERT INTO installed_skills (
entry_id, scope, project_root, reference, tag, digest,
- version, description, author, tags, client_apps, status
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, jsonb(?), jsonb(?), ?)`,
+ version, description, author, tags, client_apps, status, managed, sigstore_bundle
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, jsonb(?), jsonb(?), ?, ?, ?)`,
entryID,
string(skill.Scope),
skill.ProjectRoot,
@@ -99,6 +99,8 @@ func (s *SkillStore) Create(ctx context.Context, skill skills.InstalledSkill) er
tagsJSON,
clientsJSON,
string(skill.Status),
+ boolToInt(skill.Managed),
+ skill.SigstoreBundle,
)
if err != nil {
if isUniqueViolation(err) {
@@ -266,7 +268,8 @@ func (s *SkillStore) Update(ctx context.Context, skill skills.InstalledSkill) er
if _, err := tx.ExecContext(ctx, `
UPDATE installed_skills SET
reference = ?, tag = ?, digest = ?, version = ?, description = ?,
- author = ?, tags = jsonb(?), client_apps = jsonb(?), status = ?
+ author = ?, tags = jsonb(?), client_apps = jsonb(?), status = ?, managed = ?,
+ sigstore_bundle = ?
WHERE id = ?`,
skill.Reference,
skill.Tag,
@@ -277,6 +280,8 @@ func (s *SkillStore) Update(ctx context.Context, skill skills.InstalledSkill) er
tagsJSON,
clientsJSON,
string(skill.Status),
+ boolToInt(skill.Managed),
+ skill.SigstoreBundle,
installedSkillID,
); err != nil {
return fmt.Errorf("updating installed skill: %w", err)
@@ -370,12 +375,14 @@ func scanSkillFields(sc scanner) (skills.InstalledSkill, int64, error) {
clientsBlob []byte
status string
installedAtStr string
+ managed int
+ sigstoreBundle []byte
)
err := sc.Scan(
&installedSkillID, &name, &scope, &projectRoot, &reference, &tag,
&digest, &version, &description, &author, &tagsBlob,
- &clientsBlob, &status, &installedAtStr,
+ &clientsBlob, &status, &installedAtStr, &managed, &sigstoreBundle,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
@@ -404,14 +411,16 @@ func scanSkillFields(sc scanner) (skills.InstalledSkill, int64, error) {
Author: author,
Tags: tags,
},
- Scope: skills.Scope(scope),
- ProjectRoot: projectRoot,
- Reference: reference,
- Tag: tag,
- Digest: digest,
- Status: skills.InstallStatus(status),
- InstalledAt: installedAt,
- Clients: clients,
+ Scope: skills.Scope(scope),
+ ProjectRoot: projectRoot,
+ Reference: reference,
+ Tag: tag,
+ Digest: digest,
+ Status: skills.InstallStatus(status),
+ InstalledAt: installedAt,
+ Clients: clients,
+ Managed: managed != 0,
+ SigstoreBundle: sigstoreBundle,
}
return sk, installedSkillID, nil
@@ -493,5 +502,12 @@ func isUniqueViolation(err error) bool {
return false
}
+func boolToInt(b bool) int {
+ if b {
+ return 1
+ }
+ return 0
+}
+
// rollback rolls back tx, ignoring errors (tx may already be committed).
func rollback(tx *sql.Tx) { _ = tx.Rollback() }
diff --git a/test/e2e/api_skills_test.go b/test/e2e/api_skills_test.go
index 9ca6d33c1a..5c16d5ed52 100644
--- a/test/e2e/api_skills_test.go
+++ b/test/e2e/api_skills_test.go
@@ -201,7 +201,9 @@ func buildAndInstallSkill(server *e2e.Server, skillName, description string) {
}
func pushSkill(server *e2e.Server, reference string) *http.Response {
- reqBody := pushSkillRequest{Reference: reference}
+ // Skip signing: CI runners have no cosign key or keyless OIDC configured,
+ // and these E2E tests exercise push/install plumbing, not signing.
+ reqBody := pushSkillRequest{Reference: reference, SkipSigning: true}
jsonData, err := json.Marshal(reqBody)
ExpectWithOffset(1, err).ToNot(HaveOccurred())
@@ -215,7 +217,8 @@ func pushSkill(server *e2e.Server, reference string) *http.Response {
}
type pushSkillRequest struct {
- Reference string `json:"reference"`
+ Reference string `json:"reference"`
+ SkipSigning bool `json:"skip_signing,omitempty"`
}
// createUpstreamRegistryWithSkill creates a JSON file in the upstream registry