From 67ca1abc4a323b3e5dcf0d652dadcebfb6eb0216 Mon Sep 17 00:00:00 2001 From: Samuele Verzi Date: Fri, 3 Jul 2026 16:30:05 +0200 Subject: [PATCH 01/20] Add project-level skills lock file with sync and upgrade commands Introduce toolhive.lock.yaml, committed at the project root, that pins the name, version, source, resolved reference, and digest of every project-scoped skill install. This gives teams reproducible skill installs (restorable via "thv skill sync") and a path to refresh pinned content when the catalog moves ("thv skill upgrade"), mirroring the role package-lock.json plays for npm. - New pkg/skills/lockfile package: schema, load/save, and file-locked upsert/remove using pkg/fileutils.WithFileLock; entries sorted by name for stable diffs. - skillsvc install/uninstall hooks: project-scope installs upsert a lock entry (recording the original source string for later re-resolution); uninstalls remove it. User-scope installs never touch the lock. - Sync: installs each entry strictly at its pinned resolvedReference@digest, reports unmanaged project-scope skills, and prunes them with --prune. - Upgrade: re-resolves each entry's original source, compares digests, and installs + rewrites the entry on change; immutable sources (OCI digest, git commit) reported as not-upgradable; --dry-run supported. - API: POST /skills/sync and POST /skills/upgrade plus skills client methods. - CLI: thv skill sync and thv skill upgrade with git-root auto-detection and --project-root override; JSON/text output. - Docs: regenerated CLI/swagger docs; updated docs/arch/12-skills-system.md with a Project Lock File section, diagram, and endpoint/CLI tables. Co-authored-by: Cursor --- cmd/thv/app/skill_helpers.go | 16 ++ cmd/thv/app/skill_sync.go | 102 +++++++++ cmd/thv/app/skill_sync_test.go | 79 +++++++ cmd/thv/app/skill_upgrade.go | 110 ++++++++++ docs/arch/12-skills-system.md | 49 ++++- docs/cli/thv_skill.md | 2 + docs/cli/thv_skill_sync.md | 49 +++++ docs/cli/thv_skill_upgrade.md | 52 +++++ docs/server/docs.go | 295 +++++++++++++++++++++++++++ docs/server/swagger.json | 295 +++++++++++++++++++++++++++ docs/server/swagger.yaml | 215 +++++++++++++++++++ pkg/api/v1/skills.go | 74 +++++++ pkg/api/v1/skills_test.go | 109 ++++++++++ pkg/api/v1/skills_types.go | 26 +++ pkg/skills/client/client.go | 33 +++ pkg/skills/client/dto.go | 13 ++ pkg/skills/lockfile/lockfile.go | 176 ++++++++++++++++ pkg/skills/lockfile/lockfile_test.go | 127 ++++++++++++ pkg/skills/mocks/mock_service.go | 30 +++ pkg/skills/options.go | 84 ++++++++ pkg/skills/service.go | 9 + pkg/skills/skillsvc/install.go | 43 ++++ pkg/skills/skillsvc/lock_test.go | 163 +++++++++++++++ pkg/skills/skillsvc/pin.go | 72 +++++++ pkg/skills/skillsvc/sync.go | 109 ++++++++++ pkg/skills/skillsvc/sync_test.go | 130 ++++++++++++ pkg/skills/skillsvc/uninstall.go | 17 ++ pkg/skills/skillsvc/upgrade.go | 203 ++++++++++++++++++ pkg/skills/skillsvc/upgrade_test.go | 164 +++++++++++++++ 29 files changed, 2845 insertions(+), 1 deletion(-) create mode 100644 cmd/thv/app/skill_sync.go create mode 100644 cmd/thv/app/skill_sync_test.go create mode 100644 cmd/thv/app/skill_upgrade.go create mode 100644 docs/cli/thv_skill_sync.md create mode 100644 docs/cli/thv_skill_upgrade.md create mode 100644 pkg/skills/lockfile/lockfile.go create mode 100644 pkg/skills/lockfile/lockfile_test.go create mode 100644 pkg/skills/skillsvc/lock_test.go create mode 100644 pkg/skills/skillsvc/pin.go create mode 100644 pkg/skills/skillsvc/sync.go create mode 100644 pkg/skills/skillsvc/sync_test.go create mode 100644 pkg/skills/skillsvc/upgrade.go create mode 100644 pkg/skills/skillsvc/upgrade_test.go 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_sync.go b/cmd/thv/app/skill_sync.go new file mode 100644 index 0000000000..2ee9680b46 --- /dev/null +++ b/cmd/thv/app/skill_sync.go @@ -0,0 +1,102 @@ +// 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 + 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, restoring skills that are missing or have drifted from the +pinned state. Project-scoped skills installed outside of the lock file are +reported as unmanaged, or removed with --prune. + +The project root is auto-detected from the current directory (nearest +enclosing git repository) unless --project-root is given.`, + 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 project-scoped skills that are not present in the lock file") + 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()) + result, err := c.Sync(cmd.Context(), skills.SyncOptions{ + ProjectRoot: projectRoot, + Clients: parseSkillInstallClients(skillSyncClientsRaw), + Prune: skillSyncPrune, + }) + if err != nil { + return formatSkillError("sync skills", err) + } + + switch skillSyncFormat { + 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) + } + return nil +} + +func printSyncResultText(result *skills.SyncResult) { + printSkillNameList("Installed", result.Installed) + printSkillNameList("Up to date", result.UpToDate) + printSkillNameList("Unmanaged (not in lock file; re-run with --prune to remove)", result.Unmanaged) + printSkillNameList("Pruned", result.Pruned) + if len(result.Failed) > 0 { + fmt.Println("Failed:") + for _, f := range result.Failed { + fmt.Printf(" %s: %s\n", f.Name, f.Error) + } + } + if len(result.Installed) == 0 && len(result.UpToDate) == 0 && len(result.Unmanaged) == 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..561bf74154 --- /dev/null +++ b/cmd/thv/app/skill_upgrade.go @@ -0,0 +1,110 @@ +// 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 + skillUpgradeDryRun 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. + +With no arguments, every entry in the lock file is checked. Use --dry-run to +see what would change without installing anything. + +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(&skillUpgradeDryRun, "dry-run", false, + "Report what would change without installing anything") + 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()) + result, err := c.Upgrade(cmd.Context(), skills.UpgradeOptions{ + ProjectRoot: projectRoot, + Names: args, + DryRun: skillUpgradeDryRun, + Clients: parseSkillInstallClients(skillUpgradeClientsRaw), + }) + if err != nil { + return formatSkillError("upgrade skills", err) + } + + switch skillUpgradeFormat { + 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) + } + return nil +} + +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.Status == skills.UpgradeStatusFailed && 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/docs/arch/12-skills-system.md b/docs/arch/12-skills-system.md index e0021eb346..18ff828724 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,43 @@ 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**, and **digest** of each project-scoped skill 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. + +```yaml +version: 1 +skills: + - name: code-review + version: 1.0.0 + source: code-review # what the user/registry resolver originally typed + resolvedReference: ghcr.io/org/code-review:1.0.0 + digest: sha256:9f2b1e... +``` + +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 at its exact digest +thv skill sync --prune # also uninstall project-scoped skills absent from the lock file +``` + +For each lock entry, `Sync` compares the currently installed digest (if any) against the pinned digest. A mismatch or missing install triggers a fresh install pinned to `resolvedReference@digest` — an OCI digest reference, or a git reference pinned to the exact commit hash — so the installed content is byte-for-byte reproducible regardless of what a mutable tag or branch currently points to. Project-scoped skills installed outside the lock file are reported as unmanaged, or removed when `--prune` is set. A sync never rewrites the lock file itself: it only makes the filesystem and database match what is already pinned. + +### Upgrade + +```bash +thv skill upgrade # check every locked skill for newer content +thv skill upgrade code-review # check specific skills only +thv skill upgrade --dry-run # report without installing or writing the lock file +``` + +`Upgrade` re-resolves each entry's original `source` exactly as a fresh `thv skill install ` would (registry name -> catalog lookup, git branch/tag -> current head, OCI tag -> current digest) and compares the result against the pinned digest. A different digest installs the new content and rewrites the entry's `resolvedReference`, `digest`, and `version` — `source` never changes, so future upgrades keep re-resolving the same catalog name or ref. Entries already pinned to an immutable reference (an OCI `@sha256:...` digest or a full 40-character git commit hash) are reported as `not-upgradable` without contacting the network, since re-resolving them can never surface different content. + +**Implementation:** `pkg/skills/lockfile/` (schema, file-locked read/write/upsert/remove), `pkg/skills/skillsvc/sync.go` (Sync), `pkg/skills/skillsvc/upgrade.go` (Upgrade), `pkg/skills/skillsvc/pin.go` (digest-pinning and immutability helpers) + ## Git-Based Skill Resolution Skills can be installed directly from git repositories using the `git://` scheme: @@ -342,6 +384,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 +410,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 +494,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_sync.md b/docs/cli/thv_skill_sync.md new file mode 100644 index 0000000000..57d217bad6 --- /dev/null +++ b/docs/cli/thv_skill_sync.md @@ -0,0 +1,49 @@ +--- +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, restoring skills that are missing or have drifted from the +pinned state. Project-scoped skills installed outside of the lock file are +reported as unmanaged, or removed with --prune. + +The project root is auto-detected from the current directory (nearest +enclosing git repository) unless --project-root is given. + +``` +thv skill sync [flags] +``` + +### Options + +``` + --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 project-scoped skills that are not present in the lock file +``` + +### 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..4e7d058813 --- /dev/null +++ b/docs/cli/thv_skill_upgrade.md @@ -0,0 +1,52 @@ +--- +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. + +With no arguments, every entry in the lock file is checked. Use --dry-run to +see what would change without installing anything. + +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 + +``` + --clients string Comma-separated target client apps (e.g. claude-code,opencode), or "all" for every available client + --dry-run Report what would change without installing anything + --format string Output format (json, text) (default "text") + -h, --help help for upgrade + --project-root string Project root path (auto-detected from the current directory if omitted) +``` + +### 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..0f6b68887d 100644 --- a/docs/server/docs.go +++ b/docs/server/docs.go @@ -1804,6 +1804,117 @@ 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" + } + }, + "type": "object" + }, + "github_com_stacklok_toolhive_pkg_skills.SyncResult": { + "properties": { + "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 + }, + "pruned": { + "description": "Pruned lists unmanaged skills that were uninstalled because Prune was set.", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "unmanaged": { + "description": "Unmanaged lists project-scoped skills present on disk but absent from the lock file.", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "up_to_date": { + "description": "UpToDate lists skills that already matched the lock file's pinned digest.", + "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" + }, + "old_digest": { + "description": "OldDigest is the digest pinned in the lock file before this operation.", + "type": "string" + }, + "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", + "failed" + ], + "type": "string", + "x-enum-varnames": [ + "UpgradeStatusUpgraded", + "UpgradeStatusUpToDate", + "UpgradeStatusNotUpgradable", + "UpgradeStatusFailed" + ] + }, "github_com_stacklok_toolhive_pkg_skills.ValidationResult": { "properties": { "errors": { @@ -3397,6 +3508,28 @@ const docTemplate = `{ }, "type": "object" }, + "pkg_api_v1.syncSkillsRequest": { + "description": "Request to sync a project's installed skills to match its lock file", + "properties": { + "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 +3730,36 @@ const docTemplate = `{ }, "type": "object" }, + "pkg_api_v1.upgradeSkillsRequest": { + "description": "Request to check for and install newer content for locked skills", + "properties": { + "clients": { + "description": "Clients lists target client identifiers, or omit for every detected client", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "dry_run": { + "description": "DryRun reports what would change without installing anything", + "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 + }, + "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 +6364,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..5cb9d4c057 100644 --- a/docs/server/swagger.json +++ b/docs/server/swagger.json @@ -1797,6 +1797,117 @@ }, "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" + } + }, + "type": "object" + }, + "github_com_stacklok_toolhive_pkg_skills.SyncResult": { + "properties": { + "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 + }, + "pruned": { + "description": "Pruned lists unmanaged skills that were uninstalled because Prune was set.", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "unmanaged": { + "description": "Unmanaged lists project-scoped skills present on disk but absent from the lock file.", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "up_to_date": { + "description": "UpToDate lists skills that already matched the lock file's pinned digest.", + "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" + }, + "old_digest": { + "description": "OldDigest is the digest pinned in the lock file before this operation.", + "type": "string" + }, + "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", + "failed" + ], + "type": "string", + "x-enum-varnames": [ + "UpgradeStatusUpgraded", + "UpgradeStatusUpToDate", + "UpgradeStatusNotUpgradable", + "UpgradeStatusFailed" + ] + }, "github_com_stacklok_toolhive_pkg_skills.ValidationResult": { "properties": { "errors": { @@ -3390,6 +3501,28 @@ }, "type": "object" }, + "pkg_api_v1.syncSkillsRequest": { + "description": "Request to sync a project's installed skills to match its lock file", + "properties": { + "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 +3723,36 @@ }, "type": "object" }, + "pkg_api_v1.upgradeSkillsRequest": { + "description": "Request to check for and install newer content for locked skills", + "properties": { + "clients": { + "description": "Clients lists target client identifiers, or omit for every detected client", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + }, + "dry_run": { + "description": "DryRun reports what would change without installing anything", + "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 + }, + "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 +6357,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..21ed43e20d 100644 --- a/docs/server/swagger.yaml +++ b/docs/server/swagger.yaml @@ -1780,6 +1780,96 @@ 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 + type: object + github_com_stacklok_toolhive_pkg_skills.SyncResult: + properties: + 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 + pruned: + description: Pruned lists unmanaged skills that were uninstalled because + Prune was set. + items: + type: string + type: array + uniqueItems: false + unmanaged: + description: Unmanaged lists project-scoped skills present on disk but absent + from the lock file. + items: + type: string + type: array + uniqueItems: false + up_to_date: + description: UpToDate lists skills that already matched the lock file's + pinned digest. + 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 + old_digest: + description: OldDigest is the digest pinned in the lock file before this + operation. + type: string + 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 + - failed + type: string + x-enum-varnames: + - UpgradeStatusUpgraded + - UpgradeStatusUpToDate + - UpgradeStatusNotUpgradable + - UpgradeStatusFailed github_com_stacklok_toolhive_pkg_skills.ValidationResult: properties: errors: @@ -3077,6 +3167,26 @@ 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: + 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 +3347,31 @@ components: type: array uniqueItems: false type: object + pkg_api_v1.upgradeSkillsRequest: + description: Request to check for and install newer content for locked skills + properties: + clients: + description: Clients lists target client identifiers, or omit for every + detected client + items: + type: string + type: array + uniqueItems: false + dry_run: + description: DryRun reports what would change without installing anything + 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 + 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 +5265,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/pkg/api/v1/skills.go b/pkg/api/v1/skills.go index b0f58c9001..a4b82b468e 100644 --- a/pkg/api/v1/skills.go +++ b/pkg/api/v1/skills.go @@ -37,6 +37,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 } @@ -365,3 +367,75 @@ 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.skillService.Sync(r.Context(), skills.SyncOptions{ + ProjectRoot: req.ProjectRoot, + Clients: req.Clients, + Prune: req.Prune, + }) + 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.skillService.Upgrade(r.Context(), skills.UpgradeOptions{ + ProjectRoot: req.ProjectRoot, + Names: req.Names, + DryRun: req.DryRun, + 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..faed829b0f 100644 --- a/pkg/api/v1/skills_test.go +++ b/pkg/api/v1/skills_test.go @@ -575,6 +575,115 @@ func TestSkillsRouter(t *testing.T) { expectedStatus: http.StatusInternalServerError, expectedBody: "Internal Server Error", }, + // syncSkills + { + name: "sync skills success", + method: "POST", + path: "/sync", + body: `{"project_root":"{{project_root}}"}`, + setupMock: func(svc *skillsmocks.MockSkillService, projectRoot string) { + svc.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(svc *skillsmocks.MockSkillService, projectRoot string) { + svc.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, _ 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(svc *skillsmocks.MockSkillService, _ string) { + svc.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(svc *skillsmocks.MockSkillService, projectRoot string) { + svc.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(svc *skillsmocks.MockSkillService, projectRoot string) { + svc.EXPECT().Upgrade(gomock.Any(), skills.UpgradeOptions{ + ProjectRoot: projectRoot, + Names: []string{"my-skill"}, + 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, _ 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(svc *skillsmocks.MockSkillService, projectRoot string) { + svc.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 { diff --git a/pkg/api/v1/skills_types.go b/pkg/api/v1/skills_types.go index 833a9b0aa5..e28a07d47d 100644 --- a/pkg/api/v1/skills_types.go +++ b/pkg/api/v1/skills_types.go @@ -76,3 +76,29 @@ 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"` +} + +// 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"` + // DryRun reports what would change without installing anything + DryRun bool `json:"dry_run,omitempty"` + // Clients lists target client identifiers, or omit for every detected client + Clients []string `json:"clients,omitempty"` +} diff --git a/pkg/skills/client/client.go b/pkg/skills/client/client.go index 4c5c69a606..7cfd640098 100644 --- a/pkg/skills/client/client.go +++ b/pkg/skills/client/client.go @@ -267,6 +267,39 @@ 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, + } + + 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, + DryRun: opts.DryRun, + 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..172997cebe 100644 --- a/pkg/skills/client/dto.go +++ b/pkg/skills/client/dto.go @@ -41,3 +41,16 @@ 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"` +} + +type upgradeRequest struct { + ProjectRoot string `json:"project_root"` + Names []string `json:"names,omitempty"` + DryRun bool `json:"dry_run,omitempty"` + Clients []string `json:"clients,omitempty"` +} diff --git a/pkg/skills/lockfile/lockfile.go b/pkg/skills/lockfile/lockfile.go new file mode 100644 index 0000000000..408f34cc40 --- /dev/null +++ b/pkg/skills/lockfile/lockfile.go @@ -0,0 +1,176 @@ +// 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 + +// 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"` +} + +// 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) + 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) + }) +} + +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..0adc06f037 --- /dev/null +++ b/pkg/skills/lockfile/lockfile_test.go @@ -0,0 +1,127 @@ +// 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:abc123", + } + 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", Digest: "sha256:1"} + c := Entry{Name: "a-skill", Digest: "sha256:2"} + 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:1", got.Digest) + + // Upsert replaces an existing entry rather than duplicating it. + updated := Entry{Name: "b-skill", Digest: "sha256:new"} + lf.Upsert(updated) + require.Len(t, lf.Skills, 2) + got, ok = lf.Get("b-skill") + require.True(t, ok) + assert.Equal(t, "sha256:new", 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", Digest: "sha256:abc"} + 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", Digest: "sha256:def"})) + 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")) +} diff --git a/pkg/skills/mocks/mock_service.go b/pkg/skills/mocks/mock_service.go index e176f22f44..cf3100035d 100644 --- a/pkg/skills/mocks/mock_service.go +++ b/pkg/skills/mocks/mock_service.go @@ -159,6 +159,21 @@ func (mr *MockSkillServiceMockRecorder) Push(ctx, opts any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Push", reflect.TypeOf((*MockSkillService)(nil).Push), ctx, opts) } +// Sync mocks base method. +func (m *MockSkillService) 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 *MockSkillServiceMockRecorder) Sync(ctx, opts any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Sync", reflect.TypeOf((*MockSkillService)(nil).Sync), ctx, opts) +} + // Uninstall mocks base method. func (m *MockSkillService) Uninstall(ctx context.Context, opts skills.UninstallOptions) error { m.ctrl.T.Helper() @@ -173,6 +188,21 @@ func (mr *MockSkillServiceMockRecorder) Uninstall(ctx, opts any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Uninstall", reflect.TypeOf((*MockSkillService)(nil).Uninstall), ctx, opts) } +// Upgrade mocks base method. +func (m *MockSkillService) 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 *MockSkillServiceMockRecorder) Upgrade(ctx, opts any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Upgrade", reflect.TypeOf((*MockSkillService)(nil).Upgrade), ctx, opts) +} + // Validate mocks base method. func (m *MockSkillService) Validate(ctx context.Context, path string) (*skills.ValidationResult, error) { m.ctrl.T.Helper() diff --git a/pkg/skills/options.go b/pkg/skills/options.go index a822c8918f..5d993b07a8 100644 --- a/pkg/skills/options.go +++ b/pkg/skills/options.go @@ -133,6 +133,90 @@ type PushOptions struct { Reference string `json:"reference"` } +// 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"` +} + +// SyncFailure describes a single skill that failed to sync. +type SyncFailure struct { + // Name is the skill name that failed. + Name string `json:"name"` + // 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"` + // UpToDate lists skills that already matched the lock file's pinned digest. + UpToDate []string `json:"up_to_date,omitempty"` + // Unmanaged lists project-scoped skills present on disk but absent from the lock file. + Unmanaged []string `json:"unmanaged,omitempty"` + // Pruned lists unmanaged 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"` + // DryRun reports what would change without installing anything. + DryRun bool `json:"dry_run,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" + // 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"` + // 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. type LocalBuild struct { // Tag is the OCI tag or name used to reference the artifact. diff --git a/pkg/skills/service.go b/pkg/skills/service.go index 53dcce75c4..34826df0db 100644 --- a/pkg/skills/service.go +++ b/pkg/skills/service.go @@ -30,4 +30,13 @@ type SkillService interface { // GetContent retrieves the SKILL.md body and file listing from an OCI artifact // without installing it. Works for both remote registry references and local build tags. GetContent(ctx context.Context, opts ContentOptions) (*SkillContent, error) + // Sync installs the exact name/digest pinned in the project's lock file + // for every entry, restoring drifted or missing skills. Skills installed + // at project scope but absent from the lock file are reported as + // unmanaged, or removed when opts.Prune is set. + 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/skillsvc/install.go b/pkg/skills/skillsvc/install.go index 028f78530f..b5155ce5d1 100644 --- a/pkg/skills/skillsvc/install.go +++ b/pkg/skills/skillsvc/install.go @@ -14,6 +14,7 @@ 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/lockfile" ) // Install installs a skill. When the Name field contains an OCI reference @@ -21,7 +22,49 @@ 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.Name + result, err := s.installInternal(ctx, opts) + if err != nil { + return nil, err + } + recordLockEntry(source, result.Skill) + return result, nil +} + +// recordLockEntry upserts a project-scope lock file entry after a successful +// install. It is a no-op for user-scoped installs. Lock-file errors are +// logged but never fail the install — the skill's files and DB record are +// already correct at this point, so surfacing an error here would be +// misleading (and a subsequent sync/upgrade can repair the lock file). +func recordLockEntry(source string, sk skills.InstalledSkill) { + if sk.Scope != skills.ScopeProject || sk.ProjectRoot == "" { + return + } + entry := lockfile.Entry{ + Name: sk.Metadata.Name, + Version: sk.Metadata.Version, + Source: source, + ResolvedReference: sk.Reference, + Digest: sk.Digest, + } + if err := lockfile.UpsertEntry(sk.ProjectRoot, entry); err != nil { + slog.Warn("failed to update skills lock file", + "skill", sk.Metadata.Name, "project_root", sk.ProjectRoot, "error", err) + } +} + +// 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 diff --git a/pkg/skills/skillsvc/lock_test.go b/pkg/skills/skillsvc/lock_test.go new file mode 100644 index 0000000000..1701a05a7b --- /dev/null +++ b/pkg/skills/skillsvc/lock_test.go @@ -0,0 +1,163 @@ +// 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, WithPathResolver(pr)) + result, err := svc.Install(t.Context(), skills.InstallOptions{ + Name: "my-skill", + LayerData: layerData, + Digest: "sha256:abc", + Reference: "ghcr.io/org/my-skill:v1", + Version: "1.0.0", + Scope: skills.ScopeProject, + ProjectRoot: projectRoot, + }) + 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:abc", 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:abc", + }) + 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:abc", + })) + require.NoError(t, lockfile.UpsertEntry(projectRoot, lockfile.Entry{ + Name: "other-skill", + Source: "other-skill", + Digest: "sha256:def", + })) + + 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, WithPathResolver(pr)).(*service) + _, err := svc.installInternal(context.Background(), skills.InstallOptions{ + Name: "my-skill", + LayerData: layerData, + Digest: "sha256:abc", + Scope: skills.ScopeProject, + ProjectRoot: projectRoot, + }) + 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/sync.go b/pkg/skills/skillsvc/sync.go new file mode 100644 index 0000000000..59fed038fb --- /dev/null +++ b/pkg/skills/skillsvc/sync.go @@ -0,0 +1,109 @@ +// 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" + "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 digest. Project-scoped skills that are installed but absent from +// the lock file are reported as unmanaged, or removed when opts.Prune is set. +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{}{} + s.syncEntry(ctx, projectRoot, entry, opts.Clients, result) + } + + if err := s.syncUnmanaged(ctx, projectRoot, locked, opts.Prune, result); err != nil { + return nil, err + } + + return result, nil +} + +// syncEntry restores a single lock entry, appending its outcome to result. +func (s *service) syncEntry( + ctx context.Context, projectRoot string, entry lockfile.Entry, clients []string, result *skills.SyncResult, +) { + existing, storeErr := s.store.Get(ctx, entry.Name, skills.ScopeProject, projectRoot) + if storeErr == nil && existing.Digest == entry.Digest { + result.UpToDate = append(result.UpToDate, entry.Name) + return + } + + pinnedRef, err := buildPinnedReference(entry) + if err != nil { + result.Failed = append(result.Failed, skills.SyncFailure{Name: entry.Name, Error: err.Error()}) + return + } + + // installInternal (not Install) is used deliberately: pinnedRef is a + // digest-pinned reference derived from the entry, not the user-facing + // source, so it must not overwrite the lock entry's Source field. Since + // we install exactly the pinned digest, the entry itself never needs to + // change as a result of a sync. + if _, err := s.installInternal(ctx, skills.InstallOptions{ + Name: pinnedRef, + Scope: skills.ScopeProject, + ProjectRoot: projectRoot, + Clients: clients, + Force: true, + }); err != nil { + result.Failed = append(result.Failed, skills.SyncFailure{Name: entry.Name, Error: err.Error()}) + return + } + result.Installed = append(result.Installed, entry.Name) +} + +// syncUnmanaged finds project-scoped skills installed outside of the lock +// file and either reports or prunes them. +func (s *service) syncUnmanaged( + ctx context.Context, projectRoot string, locked map[string]struct{}, prune 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 !prune { + result.Unmanaged = append(result.Unmanaged, name) + 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, Error: err.Error()}) + continue + } + result.Pruned = append(result.Pruned, name) + } + return nil +} diff --git a/pkg/skills/skillsvc/sync_test.go b/pkg/skills/skillsvc/sync_test.go new file mode 100644 index 0000000000..b5ca5e27d2 --- /dev/null +++ b/pkg/skills/skillsvc/sync_test.go @@ -0,0 +1,130 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package skillsvc + +import ( + "net/http" + "path/filepath" + "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 TestSyncInstallsDriftedReportsUpToDateAndUnmanaged(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(), + })) + require.NoError(t, lockfile.UpsertEntry(projectRoot, lockfile.Entry{ + Name: "already-current", + Source: "already-current", + ResolvedReference: "ghcr.io/org/already-current:v1", + Digest: "sha256:matches", + })) + + 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:matches", + }, 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, WithPathResolver(pr), WithRegistryClient(reg), WithOCIStore(ociStore)) + result, err := svc.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.UpToDate) + assert.Equal(t, []string{"extra-skill"}, result.Unmanaged) + 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}, + }, 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) + result, err := svc.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.Unmanaged) +} + +func TestSyncRejectsInvalidProjectRoot(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + store := storemocks.NewMockSkillStore(ctrl) + + svc := New(store) + _, err := svc.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/uninstall.go b/pkg/skills/skillsvc/uninstall.go index 4c74d8ec98..a6a4193b8f 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,6 +15,7 @@ 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" ) // Uninstall removes an installed skill and cleans up files for all clients. @@ -70,6 +72,8 @@ func (s *service) Uninstall(ctx context.Context, opts skills.UninstallOptions) e return err } + removeLockEntry(scope, opts.ProjectRoot, opts.Name) + // 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 { @@ -79,3 +83,16 @@ func (s *service) Uninstall(ctx context.Context, opts skills.UninstallOptions) e return errors.Join(cleanupErrs...) } + +// removeLockEntry removes a project's lock file entry, if any — best-effort, +// same pattern as file cleanup. A no-op for user scope or for skills that +// predate the lock file. +func removeLockEntry(scope skills.Scope, projectRoot, name string) { + if scope != skills.ScopeProject || projectRoot == "" { + return + } + if err := lockfile.RemoveEntry(projectRoot, name); err != nil { + slog.Warn("failed to update skills lock file", + "skill", name, "project_root", projectRoot, "error", err) + } +} diff --git a/pkg/skills/skillsvc/upgrade.go b/pkg/skills/skillsvc/upgrade.go new file mode 100644 index 0000000000..6e23b926ec --- /dev/null +++ b/pkg/skills/skillsvc/upgrade.go @@ -0,0 +1,203 @@ +// 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" +) + +// 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. Entries pinned to an immutable reference (an +// OCI digest or a full git commit hash) are reported as not upgradable. +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) + } + + 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 { + result.Outcomes = append(result.Outcomes, s.upgradeEntry(ctx, projectRoot, entry, opts)) + } + return result, nil +} + +// selectUpgradeTargets returns the subset of entries named in names, in the +// order requested, or all entries when names is empty. Requesting a name +// absent from the lock file is a 404, not a silent skip. +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, +) skills.UpgradeOutcome { + if isImmutableSource(entry.Source) { + return skills.UpgradeOutcome{Name: entry.Name, Status: skills.UpgradeStatusNotUpgradable, OldDigest: entry.Digest} + } + + if opts.DryRun { + return s.upgradeEntryDryRun(ctx, entry) + } + + // installInternal (not Install) is used deliberately: re-installing from + // entry.Source must not change the lock entry's Source field, and this + // function decides for itself whether and how to rewrite the entry below. + result, err := s.installInternal(ctx, skills.InstallOptions{ + Name: entry.Source, + Scope: skills.ScopeProject, + ProjectRoot: projectRoot, + Clients: opts.Clients, + Force: true, + }) + if err != nil { + return skills.UpgradeOutcome{ + Name: entry.Name, Status: skills.UpgradeStatusFailed, OldDigest: entry.Digest, Error: err.Error(), + } + } + + if result.Skill.Digest == entry.Digest { + return skills.UpgradeOutcome{ + Name: entry.Name, Status: skills.UpgradeStatusUpToDate, OldDigest: entry.Digest, NewDigest: result.Skill.Digest, + } + } + + newEntry := lockfile.Entry{ + Name: entry.Name, + Version: result.Skill.Metadata.Version, + Source: entry.Source, + ResolvedReference: result.Skill.Reference, + Digest: result.Skill.Digest, + } + if lockErr := lockfile.UpsertEntry(projectRoot, newEntry); lockErr != nil { + return skills.UpgradeOutcome{ + Name: entry.Name, Status: skills.UpgradeStatusFailed, OldDigest: entry.Digest, NewDigest: result.Skill.Digest, + Error: fmt.Sprintf("skill upgraded but failed to update lock file: %v", lockErr), + } + } + return skills.UpgradeOutcome{ + Name: entry.Name, Status: skills.UpgradeStatusUpgraded, OldDigest: entry.Digest, NewDigest: result.Skill.Digest, + } +} + +// upgradeEntryDryRun reports what an upgrade would do without installing +// anything or modifying the lock file. +func (s *service) upgradeEntryDryRun(ctx context.Context, entry lockfile.Entry) skills.UpgradeOutcome { + newDigest, err := s.resolveLatestDigest(ctx, entry.Source) + if err != nil { + return skills.UpgradeOutcome{ + Name: entry.Name, Status: skills.UpgradeStatusFailed, OldDigest: entry.Digest, Error: err.Error(), + } + } + if newDigest == entry.Digest { + return skills.UpgradeOutcome{ + Name: entry.Name, Status: skills.UpgradeStatusUpToDate, OldDigest: entry.Digest, NewDigest: newDigest, + } + } + return skills.UpgradeOutcome{ + Name: entry.Name, Status: skills.UpgradeStatusUpgraded, OldDigest: entry.Digest, NewDigest: newDigest, + } +} + +// resolveLatestDigest resolves source to the digest or commit hash it +// currently points to, without installing it or writing any files or DB +// records. Used for --dry-run upgrade checks. +// +// There is no lightweight "peek" API for either backend, so this still pulls +// the OCI artifact into the local cache, or clones the git repository — but +// it performs none of the extraction, filesystem, or database writes that a +// real install does. +func (s *service) resolveLatestDigest(ctx context.Context, source string) (string, error) { + if gitresolver.IsGitReference(source) { + return s.resolveGitDigest(ctx, source) + } + + ref, isOCI, err := parseOCIReference(source) + if err != nil { + return "", httperr.WithCode(fmt.Errorf("invalid OCI reference %q: %w", source, err), http.StatusBadRequest) + } + if isOCI { + return s.resolveOCIDigest(ctx, ref) + } + + // Plain registry name — resolve through the catalog to find the current package. + 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 { + return s.resolveOCIDigest(ctx, resolved.OCIRef) + } + return s.resolveGitDigest(ctx, resolved.GitURL) +} + +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..bdd8aedaaa --- /dev/null +++ b/pkg/skills/skillsvc/upgrade_test.go @@ -0,0 +1,164 @@ +// 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:old", + })) + + 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:old", + 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, WithPathResolver(pr), WithRegistryClient(reg), WithOCIStore(ociStore)) + result, err := svc.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:old", 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", + Digest: "sha256:old", + })) + + 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, WithRegistryClient(reg), WithOCIStore(ociStore)) + result, err := svc.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:old", 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:pinned", + })) + + store := storemocks.NewMockSkillStore(gomock.NewController(t)) + svc := New(store) + result, err := svc.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:pinned", result.Outcomes[0].OldDigest) + }) + } +} + +func TestUpgradeUnknownNameReturns404(t *testing.T) { + t.Parallel() + projectRoot := makeProjectRoot(t) + + store := storemocks.NewMockSkillStore(gomock.NewController(t)) + svc := New(store) + _, err := svc.Upgrade(t.Context(), skills.UpgradeOptions{ + ProjectRoot: projectRoot, + Names: []string{"missing-skill"}, + }) + require.Error(t, err) + assert.Equal(t, http.StatusNotFound, httperr.Code(err)) +} From 6cd5f9a4efe4dc7915907df7515a44dc64c44991 Mon Sep 17 00:00:00 2001 From: Samuele Verzi Date: Tue, 7 Jul 2026 12:01:40 +0200 Subject: [PATCH 02/20] Extend lockfile schema with contentDigest and validation Add requiredBy and explicit fields, load-time validation, and a deterministic dirhash helper for on-disk integrity verification. Co-authored-by: Cursor --- pkg/skills/lockfile/contentdigest.go | 91 +++++++++++++++++++++ pkg/skills/lockfile/contentdigest_test.go | 38 +++++++++ pkg/skills/lockfile/lockfile.go | 60 ++++++++++++++ pkg/skills/lockfile/lockfile_test.go | 16 ++-- pkg/skills/lockfile/validation.go | 99 +++++++++++++++++++++++ 5 files changed, 296 insertions(+), 8 deletions(-) create mode 100644 pkg/skills/lockfile/contentdigest.go create mode 100644 pkg/skills/lockfile/contentdigest_test.go create mode 100644 pkg/skills/lockfile/validation.go 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 index 408f34cc40..58687972a8 100644 --- a/pkg/skills/lockfile/lockfile.go +++ b/pkg/skills/lockfile/lockfile.go @@ -43,6 +43,14 @@ type Entry struct { // 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"` } // Lockfile is the parsed contents of a project's toolhive.lock.yaml. @@ -77,6 +85,9 @@ func Load(projectRoot string) (*Lockfile, error) { lf.Version = CurrentVersion } sortEntries(lf.Skills) + if err := validateLockfile(&lf); err != nil { + return nil, err + } return &lf, nil } @@ -171,6 +182,55 @@ func RemoveEntry(projectRoot string, name string) error { }) } +// 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 index 0adc06f037..59668d21d3 100644 --- a/pkg/skills/lockfile/lockfile_test.go +++ b/pkg/skills/lockfile/lockfile_test.go @@ -49,7 +49,7 @@ func TestSaveAndLoadRoundTrip(t *testing.T) { Version: "1.0.0", Source: "code-review", ResolvedReference: "ghcr.io/org/code-review:1.0.0", - Digest: "sha256:abc123", + Digest: "sha256:abcdef0123456789", } lf.Upsert(entry) require.NoError(t, lf.Save(dir)) @@ -68,8 +68,8 @@ func TestLockfileGetUpsertRemove(t *testing.T) { _, ok := lf.Get("missing") assert.False(t, ok) - a := Entry{Name: "b-skill", Digest: "sha256:1"} - c := Entry{Name: "a-skill", Digest: "sha256:2"} + 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) @@ -80,15 +80,15 @@ func TestLockfileGetUpsertRemove(t *testing.T) { got, ok := lf.Get("b-skill") require.True(t, ok) - assert.Equal(t, "sha256:1", got.Digest) + assert.Equal(t, "sha256:abcdef0123456789", got.Digest) // Upsert replaces an existing entry rather than duplicating it. - updated := Entry{Name: "b-skill", Digest: "sha256:new"} + 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:new", got.Digest) + assert.Equal(t, "sha256:fedcba0987654321", got.Digest) removed := lf.Remove("b-skill") assert.True(t, removed) @@ -102,7 +102,7 @@ func TestUpsertEntryAndRemoveEntry(t *testing.T) { t.Parallel() dir := resolvedTempDir(t) - entry := Entry{Name: "my-skill", Digest: "sha256:abc"} + entry := Entry{Name: "my-skill", Source: "my-skill", Digest: "sha256:abcdef0123456789"} require.NoError(t, UpsertEntry(dir, entry)) lf, err := Load(dir) @@ -111,7 +111,7 @@ func TestUpsertEntryAndRemoveEntry(t *testing.T) { 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", Digest: "sha256:def"})) + 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) diff --git a/pkg/skills/lockfile/validation.go b/pkg/skills/lockfile/validation.go new file mode 100644 index 0000000000..56e8cf0252 --- /dev/null +++ b/pkg/skills/lockfile/validation.go @@ -0,0 +1,99 @@ +// 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) + } + } + 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 +} From 66eaa9bf099c7f3872b0a780589a78c1c9807fa9 Mon Sep 17 00:00:00 2001 From: Samuele Verzi Date: Tue, 7 Jul 2026 12:01:44 +0200 Subject: [PATCH 03/20] Split SkillLockService from SkillService Move sync and upgrade behind a dedicated lock service interface with typed options, results, and regenerated mocks. Co-authored-by: Cursor --- pkg/skills/lock_service.go | 19 +++++++ pkg/skills/mocks/mock_lock_service.go | 72 +++++++++++++++++++++++++++ pkg/skills/mocks/mock_service.go | 30 ----------- pkg/skills/options.go | 63 +++++++++++++++++++++-- pkg/skills/service.go | 9 ---- 5 files changed, 150 insertions(+), 43 deletions(-) create mode 100644 pkg/skills/lock_service.go create mode 100644 pkg/skills/mocks/mock_lock_service.go 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/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/mocks/mock_service.go b/pkg/skills/mocks/mock_service.go index cf3100035d..e176f22f44 100644 --- a/pkg/skills/mocks/mock_service.go +++ b/pkg/skills/mocks/mock_service.go @@ -159,21 +159,6 @@ func (mr *MockSkillServiceMockRecorder) Push(ctx, opts any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Push", reflect.TypeOf((*MockSkillService)(nil).Push), ctx, opts) } -// Sync mocks base method. -func (m *MockSkillService) 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 *MockSkillServiceMockRecorder) Sync(ctx, opts any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Sync", reflect.TypeOf((*MockSkillService)(nil).Sync), ctx, opts) -} - // Uninstall mocks base method. func (m *MockSkillService) Uninstall(ctx context.Context, opts skills.UninstallOptions) error { m.ctrl.T.Helper() @@ -188,21 +173,6 @@ func (mr *MockSkillServiceMockRecorder) Uninstall(ctx, opts any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Uninstall", reflect.TypeOf((*MockSkillService)(nil).Uninstall), ctx, opts) } -// Upgrade mocks base method. -func (m *MockSkillService) 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 *MockSkillServiceMockRecorder) Upgrade(ctx, opts any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Upgrade", reflect.TypeOf((*MockSkillService)(nil).Upgrade), ctx, opts) -} - // Validate mocks base method. func (m *MockSkillService) Validate(ctx context.Context, path string) (*skills.ValidationResult, error) { m.ctrl.T.Helper() diff --git a/pkg/skills/options.go b/pkg/skills/options.go index 5d993b07a8..0a15140c4d 100644 --- a/pkg/skills/options.go +++ b/pkg/skills/options.go @@ -37,12 +37,29 @@ 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:"-"` } // 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:"-"` } // UninstallOptions configures the behavior of the Uninstall operation. @@ -143,12 +160,32 @@ type SyncOptions struct { // 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" + 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"` } @@ -157,11 +194,17 @@ type SyncFailure struct { type SyncResult struct { // Installed lists skills that were installed or reinstalled to match the lock file. Installed []string `json:"installed,omitempty"` - // UpToDate lists skills that already matched the lock file's pinned digest. + // Drifted lists skills whose on-disk contentDigest differed before reinstall. + Drifted []string `json:"drifted,omitempty"` + // UpToDate lists skills that already matched the lock file. UpToDate []string `json:"up_to_date,omitempty"` - // Unmanaged lists project-scoped skills present on disk but absent from the lock file. + // 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 unmanaged skills that were uninstalled because Prune was set. + // 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"` @@ -174,8 +217,14 @@ type UpgradeOptions struct { // Names restricts the upgrade to specific skill names. Empty means every // entry in the lock file. Names []string `json:"names,omitempty"` - // DryRun reports what would change without installing anything. + // 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"` // Clients lists target clients (e.g., "claude-code"). Empty means every // skill-supporting client detected on this host. Clients []string `json:"clients,omitempty"` @@ -192,6 +241,8 @@ const ( // 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" // UpgradeStatusFailed indicates the upgrade attempt failed. UpgradeStatusFailed UpgradeStatus = "failed" ) @@ -207,6 +258,10 @@ type UpgradeOutcome struct { // 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"` } diff --git a/pkg/skills/service.go b/pkg/skills/service.go index 34826df0db..53dcce75c4 100644 --- a/pkg/skills/service.go +++ b/pkg/skills/service.go @@ -30,13 +30,4 @@ type SkillService interface { // GetContent retrieves the SKILL.md body and file listing from an OCI artifact // without installing it. Works for both remote registry references and local build tags. GetContent(ctx context.Context, opts ContentOptions) (*SkillContent, error) - // Sync installs the exact name/digest pinned in the project's lock file - // for every entry, restoring drifted or missing skills. Skills installed - // at project scope but absent from the lock file are reported as - // unmanaged, or removed when opts.Prune is set. - 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) } From 03a3d8e9bf29a435d5c3459ac0d1edf183d9706b Mon Sep 17 00:00:00 2001 From: Samuele Verzi Date: Tue, 7 Jul 2026 12:01:56 +0200 Subject: [PATCH 04/20] Add managed flag for lock-managed skill installs Track whether a skill was installed by the lock file so sync and uninstall can distinguish managed from ad-hoc installations. Co-authored-by: Cursor --- pkg/skills/types.go | 3 +++ .../migrations/003_add_managed_flag.sql | 5 +++++ pkg/storage/sqlite/skill_store.go | 21 ++++++++++++++----- 3 files changed, 24 insertions(+), 5 deletions(-) create mode 100644 pkg/storage/sqlite/migrations/003_add_managed_flag.sql diff --git a/pkg/skills/types.go b/pkg/skills/types.go index 224e7b8d00..3f4ad3018f 100644 --- a/pkg/skills/types.go +++ b/pkg/skills/types.go @@ -167,6 +167,9 @@ 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"` } // SkillIndexEntry represents a single skill entry in a remote skill index. 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/skill_store.go b/pkg/storage/sqlite/skill_store.go index 87d197d849..a4cd2cec11 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` // 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 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, jsonb(?), jsonb(?), ?, ?)`, entryID, string(skill.Scope), skill.ProjectRoot, @@ -99,6 +99,7 @@ func (s *SkillStore) Create(ctx context.Context, skill skills.InstalledSkill) er tagsJSON, clientsJSON, string(skill.Status), + boolToInt(skill.Managed), ) if err != nil { if isUniqueViolation(err) { @@ -266,7 +267,7 @@ 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 = ? WHERE id = ?`, skill.Reference, skill.Tag, @@ -277,6 +278,7 @@ func (s *SkillStore) Update(ctx context.Context, skill skills.InstalledSkill) er tagsJSON, clientsJSON, string(skill.Status), + boolToInt(skill.Managed), installedSkillID, ); err != nil { return fmt.Errorf("updating installed skill: %w", err) @@ -370,12 +372,13 @@ func scanSkillFields(sc scanner) (skills.InstalledSkill, int64, error) { clientsBlob []byte status string installedAtStr string + managed int ) err := sc.Scan( &installedSkillID, &name, &scope, &projectRoot, &reference, &tag, &digest, &version, &description, &author, &tagsBlob, - &clientsBlob, &status, &installedAtStr, + &clientsBlob, &status, &installedAtStr, &managed, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { @@ -412,6 +415,7 @@ func scanSkillFields(sc scanner) (skills.InstalledSkill, int64, error) { Status: skills.InstallStatus(status), InstalledAt: installedAt, Clients: clients, + Managed: managed != 0, } return sk, installedSkillID, nil @@ -493,5 +497,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() } From 9aa81e31b9be7049fa083526391029ed46efc8c6 Mon Sep 17 00:00:00 2001 From: Samuele Verzi Date: Tue, 7 Jul 2026 12:02:01 +0200 Subject: [PATCH 05/20] Harden install hooks and materialize toolhive.requires Fail on lock write errors, record content digests, recursively install transitive dependencies, and cascade uninstall orphaned lock entries. Co-authored-by: Cursor --- pkg/skills/skillsvc/content_digest.go | 65 ++++++++ pkg/skills/skillsvc/install.go | 58 ++++--- pkg/skills/skillsvc/install_extraction.go | 54 +++++- pkg/skills/skillsvc/install_git.go | 51 ++++-- pkg/skills/skillsvc/lock.go | 193 ++++++++++++++++++++++ pkg/skills/skillsvc/lock_test.go | 12 +- pkg/skills/skillsvc/uninstall.go | 124 +++++++++----- 7 files changed, 470 insertions(+), 87 deletions(-) create mode 100644 pkg/skills/skillsvc/content_digest.go create mode 100644 pkg/skills/skillsvc/lock.go 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 b5155ce5d1..e603a6e3e3 100644 --- a/pkg/skills/skillsvc/install.go +++ b/pkg/skills/skillsvc/install.go @@ -14,7 +14,6 @@ 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/lockfile" ) // Install installs a skill. When the Name field contains an OCI reference @@ -29,35 +28,50 @@ import ( // 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.Name + 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 } - recordLockEntry(source, result.Skill) - return result, nil -} -// recordLockEntry upserts a project-scope lock file entry after a successful -// install. It is a no-op for user-scoped installs. Lock-file errors are -// logged but never fail the install — the skill's files and DB record are -// already correct at this point, so surfacing an error here would be -// misleading (and a subsequent sync/upgrade can repair the lock file). -func recordLockEntry(source string, sk skills.InstalledSkill) { - if sk.Scope != skills.ScopeProject || sk.ProjectRoot == "" { - return + 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) + } } - entry := lockfile.Entry{ - Name: sk.Metadata.Name, - Version: sk.Metadata.Version, - Source: source, - ResolvedReference: sk.Reference, - Digest: sk.Digest, + + contentDigest := result.ContentDigest + if contentDigest == "" { + var cdErr error + contentDigest, cdErr = s.contentDigestForSkill(ctx, opts, result.Skill) + if cdErr != nil { + return nil, cdErr + } } - if err := lockfile.UpsertEntry(sk.ProjectRoot, entry); err != nil { - slog.Warn("failed to update skills lock file", - "skill", sk.Metadata.Name, "project_root", sk.ProjectRoot, "error", err) + + 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 diff --git a/pkg/skills/skillsvc/install_extraction.go b/pkg/skills/skillsvc/install_extraction.go index 60a4da681b..4ecc54cfda 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. @@ -206,5 +230,23 @@ func buildInstalledSkill( Status: skills.InstallStatusInstalled, InstalledAt: time.Now().UTC(), Clients: clients, + Managed: opts.Managed, + } +} + +func enrichInstallResult(result *skills.InstallResult, opts skills.InstallOptions) error { + 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..48cb418428 100644 --- a/pkg/skills/skillsvc/install_git.go +++ b/pkg/skills/skillsvc/install_git.go @@ -82,7 +82,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 +95,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 +103,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 +116,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 +127,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 +162,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 +172,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 +186,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 +202,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 +243,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/lock.go b/pkg/skills/skillsvc/lock.go new file mode 100644 index 0000000000..ae41af18ed --- /dev/null +++ b/pkg/skills/skillsvc/lock.go @@ -0,0 +1,193 @@ +// 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, + } + + 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 string, source string, sk skills.InstalledSkill, contentDigest string) 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}, + }) + 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) +} + +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 index 1701a05a7b..5677e52d59 100644 --- a/pkg/skills/skillsvc/lock_test.go +++ b/pkg/skills/skillsvc/lock_test.go @@ -41,7 +41,7 @@ func TestInstallRecordsProjectScopeLockEntry(t *testing.T) { result, err := svc.Install(t.Context(), skills.InstallOptions{ Name: "my-skill", LayerData: layerData, - Digest: "sha256:abc", + Digest: "sha256:abcdef0123456789", Reference: "ghcr.io/org/my-skill:v1", Version: "1.0.0", Scope: skills.ScopeProject, @@ -60,7 +60,7 @@ func TestInstallRecordsProjectScopeLockEntry(t *testing.T) { // 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:abc", entry.Digest) + assert.Equal(t, "sha256:abcdef0123456789", entry.Digest) } func TestInstallUserScopeDoesNotWriteLockFile(t *testing.T) { @@ -83,7 +83,7 @@ func TestInstallUserScopeDoesNotWriteLockFile(t *testing.T) { _, err := svc.Install(t.Context(), skills.InstallOptions{ Name: "my-skill", LayerData: layerData, - Digest: "sha256:abc", + Digest: "sha256:abcdef0123456789", }) require.NoError(t, err) @@ -98,12 +98,12 @@ func TestUninstallRemovesProjectScopeLockEntry(t *testing.T) { require.NoError(t, lockfile.UpsertEntry(projectRoot, lockfile.Entry{ Name: "my-skill", Source: "my-skill", - Digest: "sha256:abc", + Digest: "sha256:abcdef0123456789", })) require.NoError(t, lockfile.UpsertEntry(projectRoot, lockfile.Entry{ Name: "other-skill", Source: "other-skill", - Digest: "sha256:def", + Digest: "sha256:1234567890abcdef", })) ctrl := gomock.NewController(t) @@ -152,7 +152,7 @@ func TestInstallInternalDoesNotTouchLockFile(t *testing.T) { _, err := svc.installInternal(context.Background(), skills.InstallOptions{ Name: "my-skill", LayerData: layerData, - Digest: "sha256:abc", + Digest: "sha256:abcdef0123456789", Scope: skills.ScopeProject, ProjectRoot: projectRoot, }) diff --git a/pkg/skills/skillsvc/uninstall.go b/pkg/skills/skillsvc/uninstall.go index a6a4193b8f..b43b169656 100644 --- a/pkg/skills/skillsvc/uninstall.go +++ b/pkg/skills/skillsvc/uninstall.go @@ -16,17 +16,26 @@ import ( "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 @@ -34,65 +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 - } - - // Determine the boundary directory for empty-parent cleanup. - stopDir := opts.ProjectRoot - if scope == skills.ScopeUser { - if homeDir, err := os.UserHomeDir(); err == nil { - stopDir = homeDir - } + return nil, err } - // 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) - } - } - } + cleanupErrs := s.cleanupSkillFiles(existing, opts.Name, scope, opts.ProjectRoot) if err := s.store.Delete(ctx, opts.Name, scope, opts.ProjectRoot); err != nil { - return err + return nil, err } - removeLockEntry(scope, opts.ProjectRoot, opts.Name) + cascaded, lockErrs := s.uninstallLockAndCascade(ctx, scope, projectRoot, opts.Name, allowCascade) + cleanupErrs = append(cleanupErrs, lockErrs...) - // 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)) } } - return errors.Join(cleanupErrs...) + return cascaded, errors.Join(cleanupErrs...) } -// removeLockEntry removes a project's lock file entry, if any — best-effort, -// same pattern as file cleanup. A no-op for user scope or for skills that -// predate the lock file. -func removeLockEntry(scope skills.Scope, projectRoot, name string) { +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 + } + } + var cleanupErrs []error + 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 +} + +func (s *service) uninstallLockAndCascade( + ctx context.Context, scope skills.Scope, projectRoot, name string, allowCascade bool, +) ([]string, []error) { if scope != skills.ScopeProject || projectRoot == "" { - return + return nil, nil } - if err := lockfile.RemoveEntry(projectRoot, name); err != nil { - slog.Warn("failed to update skills lock file", - "skill", name, "project_root", projectRoot, "error", err) + 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 +} + +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 } From 519b8d3f5fc89415ed7c3c1c96e3857e69804964 Mon Sep 17 00:00:00 2001 From: Samuele Verzi Date: Tue, 7 Jul 2026 12:02:05 +0200 Subject: [PATCH 06/20] Implement RFC sync with check, adopt, and prune Re-hash content digests, reinstall drifted skills, harden pruning, and support dry-run check plus divergent client directory adoption. Co-authored-by: Cursor --- pkg/skills/skillsvc/sync.go | 295 ++++++++++++++++++++++++++++--- pkg/skills/skillsvc/sync_test.go | 21 ++- 2 files changed, 280 insertions(+), 36 deletions(-) diff --git a/pkg/skills/skillsvc/sync.go b/pkg/skills/skillsvc/sync.go index 59fed038fb..65b1a46f9f 100644 --- a/pkg/skills/skillsvc/sync.go +++ b/pkg/skills/skillsvc/sync.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "net/http" + "strings" "github.com/stacklok/toolhive-core/httperr" "github.com/stacklok/toolhive/pkg/skills" @@ -17,8 +18,8 @@ import ( // 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 digest. Project-scoped skills that are installed but absent from -// the lock file are reported as unmanaged, or removed when opts.Prune is set. +// 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 { @@ -34,54 +35,172 @@ func (s *service) Sync(ctx context.Context, opts skills.SyncOptions) (*skills.Sy locked := make(map[string]struct{}, len(lf.Skills)) for _, entry := range lf.Skills { locked[entry.Name] = struct{}{} - s.syncEntry(ctx, projectRoot, entry, opts.Clients, result) } - if err := s.syncUnmanaged(ctx, projectRoot, locked, opts.Prune, result); err != nil { + 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 } -// syncEntry restores a single lock entry, appending its outcome to result. func (s *service) syncEntry( - ctx context.Context, projectRoot string, entry lockfile.Entry, clients []string, result *skills.SyncResult, + ctx context.Context, projectRoot string, entry lockfile.Entry, opts skills.SyncOptions, result *skills.SyncResult, ) { - existing, storeErr := s.store.Get(ctx, entry.Name, skills.ScopeProject, projectRoot) - if storeErr == nil && existing.Digest == entry.Digest { + 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 syncStatusUpToDate: + result.UpToDate = append(result.UpToDate, 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 syncStatusUpToDate: result.UpToDate = append(result.UpToDate, 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, Error: err.Error()}) + result.Failed = append(result.Failed, skills.SyncFailure{ + Name: entry.Name, Reason: skills.FailureReasonValidationRejected, Error: err.Error(), + }) return } - // installInternal (not Install) is used deliberately: pinnedRef is a - // digest-pinned reference derived from the entry, not the user-facing - // source, so it must not overwrite the lock entry's Source field. Since - // we install exactly the pinned digest, the entry itself never needs to - // change as a result of a sync. - if _, err := s.installInternal(ctx, skills.InstallOptions{ + installOpts := skills.InstallOptions{ Name: pinnedRef, + LockSource: entry.Source, Scope: skills.ScopeProject, ProjectRoot: projectRoot, - Clients: clients, + Clients: opts.Clients, Force: true, - }); err != nil { - result.Failed = append(result.Failed, skills.SyncFailure{Name: entry.Name, Error: err.Error()}) + Managed: true, + } + 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) } -// syncUnmanaged finds project-scoped skills installed outside of the lock -// file and either reports or prunes them. +type syncEntryStatus int + +const ( + syncStatusUpToDate 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 syncStatusUpToDate, 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 + } + return syncStatusUpToDate, nil +} + +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, locked map[string]struct{}, prune bool, result *skills.SyncResult, + 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 { @@ -93,17 +212,137 @@ func (s *service) syncUnmanaged( if _, ok := locked[name]; ok { continue } - if !prune { - result.Unmanaged = append(result.Unmanaged, name) + if isStillRequiredByLockedParent(name, lf, locked) { 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, Error: err.Error()}) + + 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.Pruned = append(result.Pruned, name) + 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 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, "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 index b5ca5e27d2..17fbbf39fe 100644 --- a/pkg/skills/skillsvc/sync_test.go +++ b/pkg/skills/skillsvc/sync_test.go @@ -6,6 +6,7 @@ package skillsvc import ( "net/http" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -44,7 +45,7 @@ func TestSyncInstallsDriftedReportsUpToDateAndUnmanaged(t *testing.T) { Name: "already-current", Source: "already-current", ResolvedReference: "ghcr.io/org/already-current:v1", - Digest: "sha256:matches", + Digest: "sha256:" + strings.Repeat("d", 64), })) ctrl := gomock.NewController(t) @@ -59,7 +60,7 @@ func TestSyncInstallsDriftedReportsUpToDateAndUnmanaged(t *testing.T) { store.EXPECT().Get(gomock.Any(), "already-current", skills.ScopeProject, projectRoot). Return(skills.InstalledSkill{ Metadata: skills.SkillMetadata{Name: "already-current"}, - Digest: "sha256:matches", + 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}). @@ -74,14 +75,15 @@ func TestSyncInstallsDriftedReportsUpToDateAndUnmanaged(t *testing.T) { pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeProject, projectRoot).Return(targetDir, nil) svc := New(store, WithPathResolver(pr), WithRegistryClient(reg), WithOCIStore(ociStore)) - result, err := svc.Sync(t.Context(), skills.SyncOptions{ + 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.UpToDate) - assert.Equal(t, []string{"extra-skill"}, result.Unmanaged) + assert.Equal(t, []string{"extra-skill"}, result.NeverManaged) assert.Empty(t, result.Pruned) assert.Empty(t, result.Failed) @@ -101,7 +103,7 @@ func TestSyncPrunesUnmanagedSkills(t *testing.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}, + {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{ @@ -112,10 +114,12 @@ func TestSyncPrunesUnmanagedSkills(t *testing.T) { store.EXPECT().Delete(gomock.Any(), "extra-skill", skills.ScopeProject, projectRoot).Return(nil) svc := New(store) - result, err := svc.Sync(t.Context(), skills.SyncOptions{ProjectRoot: projectRoot, Prune: true}) + 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.Unmanaged) + assert.Empty(t, result.NeverManaged) + assert.Equal(t, []string{"extra-skill"}, result.RemovedFromLock) } func TestSyncRejectsInvalidProjectRoot(t *testing.T) { @@ -124,7 +128,8 @@ func TestSyncRejectsInvalidProjectRoot(t *testing.T) { store := storemocks.NewMockSkillStore(ctrl) svc := New(store) - _, err := svc.Sync(t.Context(), skills.SyncOptions{ProjectRoot: "relative/path"}) + 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)) } From ceefa501c169c48652a86f3714a1ae6a5e617f49 Mon Sep 17 00:00:00 2001 From: Samuele Verzi Date: Tue, 7 Jul 2026 12:02:10 +0200 Subject: [PATCH 07/20] Implement RFC upgrade with preview and ref-change guard Add preview mode, fail-on-changes, allow-ref-change, and typed failure reasons for blocked reference changes and content drift. Co-authored-by: Cursor --- pkg/skills/skillsvc/upgrade.go | 155 ++++++++++++++++++++-------- pkg/skills/skillsvc/upgrade_test.go | 31 +++--- 2 files changed, 128 insertions(+), 58 deletions(-) diff --git a/pkg/skills/skillsvc/upgrade.go b/pkg/skills/skillsvc/upgrade.go index 6e23b926ec..454e1b14f9 100644 --- a/pkg/skills/skillsvc/upgrade.go +++ b/pkg/skills/skillsvc/upgrade.go @@ -19,14 +19,15 @@ import ( // 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. Entries pinned to an immutable reference (an -// OCI digest or a full git commit hash) are reported as not upgradable. +// 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) @@ -39,14 +40,26 @@ func (s *service) Upgrade(ctx context.Context, opts skills.UpgradeOptions) (*ski result := &skills.UpgradeResult{} for _, entry := range targets { - result.Outcomes = append(result.Outcomes, s.upgradeEntry(ctx, projectRoot, entry, opts)) + 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. Requesting a name -// absent from the lock file is a 404, not a silent skip. +// 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 @@ -69,63 +82,106 @@ func selectUpgradeTargets(entries []lockfile.Entry, names []string) ([]lockfile. // upgradeEntry attempts to upgrade a single lock entry. func (s *service) upgradeEntry( - ctx context.Context, projectRoot string, entry lockfile.Entry, opts skills.UpgradeOptions, + 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 opts.DryRun { - return s.upgradeEntryDryRun(ctx, entry) + if preview { + return s.upgradeEntryPreview(ctx, entry) } - // installInternal (not Install) is used deliberately: re-installing from - // entry.Source must not change the lock entry's Source field, and this - // function decides for itself whether and how to rewrite the entry below. - result, err := s.installInternal(ctx, skills.InstallOptions{ - Name: entry.Source, - Scope: skills.ScopeProject, - ProjectRoot: projectRoot, - Clients: opts.Clients, - Force: true, - }) + installOpts := skills.InstallOptions{ + Name: entry.Source, + LockSource: entry.Source, + Scope: skills.ScopeProject, + ProjectRoot: projectRoot, + Clients: opts.Clients, + Force: true, + Managed: true, + ExplicitLock: entry.Explicit, + } + result, err := s.installInternal(ctx, installOpts) if err != nil { return skills.UpgradeOutcome{ - Name: entry.Name, Status: skills.UpgradeStatusFailed, OldDigest: entry.Digest, Error: err.Error(), + Name: entry.Name, Status: skills.UpgradeStatusFailed, OldDigest: entry.Digest, + Reason: classifyUpgradeError(err), Error: err.Error(), } } - if result.Skill.Digest == entry.Digest { + 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, + 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), + } + } + + 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, } if lockErr := lockfile.UpsertEntry(projectRoot, newEntry); lockErr != nil { return skills.UpgradeOutcome{ Name: entry.Name, Status: skills.UpgradeStatusFailed, OldDigest: entry.Digest, NewDigest: result.Skill.Digest, - Error: fmt.Sprintf("skill upgraded but failed to update lock file: %v", lockErr), + 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: skills.UpgradeStatusUpgraded, OldDigest: entry.Digest, NewDigest: result.Skill.Digest, + Name: entry.Name, Status: status, + OldDigest: entry.Digest, NewDigest: result.Skill.Digest, + NewResolvedReference: result.Skill.Reference, } } -// upgradeEntryDryRun reports what an upgrade would do without installing -// anything or modifying the lock file. -func (s *service) upgradeEntryDryRun(ctx context.Context, entry lockfile.Entry) skills.UpgradeOutcome { - newDigest, err := s.resolveLatestDigest(ctx, entry.Source) +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, Error: err.Error(), + 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 { @@ -138,39 +194,48 @@ func (s *service) upgradeEntryDryRun(ctx context.Context, entry lockfile.Entry) } } -// resolveLatestDigest resolves source to the digest or commit hash it -// currently points to, without installing it or writing any files or DB -// records. Used for --dry-run upgrade checks. -// -// There is no lightweight "peek" API for either backend, so this still pulls -// the OCI artifact into the local cache, or clones the git repository — but -// it performs none of the extraction, filesystem, or database writes that a -// real install does. -func (s *service) resolveLatestDigest(ctx context.Context, source string) (string, error) { +func (s *service) resolveLatestDigestAndRef(ctx context.Context, source string) (string, string, error) { if gitresolver.IsGitReference(source) { - return s.resolveGitDigest(ctx, 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) + return "", "", httperr.WithCode(fmt.Errorf("invalid OCI reference %q: %w", source, err), http.StatusBadRequest) } if isOCI { - return s.resolveOCIDigest(ctx, ref) + d, err := s.resolveOCIDigest(ctx, ref) + return d, qualifiedOCIRef(ref), err } - // Plain registry name — resolve through the catalog to find the current package. resolved, err := s.resolveFromRegistry(source) if err != nil { - return "", err + return "", "", err } if resolved == nil { - return "", httperr.WithCode(fmt.Errorf("skill %q not found in registry", source), http.StatusNotFound) + return "", "", httperr.WithCode(fmt.Errorf("skill %q not found in registry", source), http.StatusNotFound) } if resolved.OCIRef != nil { - return s.resolveOCIDigest(ctx, resolved.OCIRef) + d, err := s.resolveOCIDigest(ctx, resolved.OCIRef) + return d, qualifiedOCIRef(resolved.OCIRef), err } - return s.resolveGitDigest(ctx, resolved.GitURL) + 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) { diff --git a/pkg/skills/skillsvc/upgrade_test.go b/pkg/skills/skillsvc/upgrade_test.go index bdd8aedaaa..657a3e9ac5 100644 --- a/pkg/skills/skillsvc/upgrade_test.go +++ b/pkg/skills/skillsvc/upgrade_test.go @@ -35,7 +35,7 @@ func TestUpgradeInstallsNewDigestAndRewritesLockEntry(t *testing.T) { Version: "1.0.0", Source: "ghcr.io/org/my-skill:v1", ResolvedReference: "ghcr.io/org/my-skill:v1", - Digest: "sha256:old", + Digest: "sha256:" + strings.Repeat("a", 64), })) ctrl := gomock.NewController(t) @@ -47,7 +47,7 @@ func TestUpgradeInstallsNewDigestAndRewritesLockEntry(t *testing.T) { Metadata: skills.SkillMetadata{Name: "my-skill", Version: "1.0.0"}, Scope: skills.ScopeProject, ProjectRoot: projectRoot, - Digest: "sha256:old", + Digest: "sha256:" + strings.Repeat("a", 64), Clients: []string{"claude-code"}, } store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeProject, projectRoot).Return(existing, nil) @@ -58,7 +58,8 @@ func TestUpgradeInstallsNewDigestAndRewritesLockEntry(t *testing.T) { pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeProject, projectRoot).Return(targetDir, nil) svc := New(store, WithPathResolver(pr), WithRegistryClient(reg), WithOCIStore(ociStore)) - result, err := svc.Upgrade(t.Context(), skills.UpgradeOptions{ + lockSvc := svc.(skills.SkillLockService) + result, err := lockSvc.Upgrade(t.Context(), skills.UpgradeOptions{ ProjectRoot: projectRoot, Clients: []string{"claude-code"}, }) @@ -66,7 +67,7 @@ func TestUpgradeInstallsNewDigestAndRewritesLockEntry(t *testing.T) { require.Len(t, result.Outcomes, 1) outcome := result.Outcomes[0] assert.Equal(t, skills.UpgradeStatusUpgraded, outcome.Status) - assert.Equal(t, "sha256:old", outcome.OldDigest) + assert.Equal(t, "sha256:"+strings.Repeat("a", 64), outcome.OldDigest) assert.Equal(t, newDigest.String(), outcome.NewDigest) lf, err := lockfile.Load(projectRoot) @@ -88,9 +89,10 @@ func TestUpgradeDryRunReportsWithoutInstallingOrRewritingLock(t *testing.T) { 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", - Digest: "sha256:old", + 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) @@ -102,7 +104,8 @@ func TestUpgradeDryRunReportsWithoutInstallingOrRewritingLock(t *testing.T) { store := storemocks.NewMockSkillStore(ctrl) svc := New(store, WithRegistryClient(reg), WithOCIStore(ociStore)) - result, err := svc.Upgrade(t.Context(), skills.UpgradeOptions{ProjectRoot: projectRoot, DryRun: true}) + 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) @@ -111,7 +114,7 @@ func TestUpgradeDryRunReportsWithoutInstallingOrRewritingLock(t *testing.T) { lf, err := lockfile.Load(projectRoot) require.NoError(t, err) require.Len(t, lf.Skills, 1) - assert.Equal(t, "sha256:old", lf.Skills[0].Digest) + assert.Equal(t, "sha256:"+strings.Repeat("a", 64), lf.Skills[0].Digest) } func TestUpgradeImmutableSourceReportsNotUpgradable(t *testing.T) { @@ -135,16 +138,17 @@ func TestUpgradeImmutableSourceReportsNotUpgradable(t *testing.T) { require.NoError(t, lockfile.UpsertEntry(projectRoot, lockfile.Entry{ Name: "my-skill", Source: tt.source, - Digest: "sha256:pinned", + Digest: "sha256:" + strings.Repeat("c", 64), })) store := storemocks.NewMockSkillStore(gomock.NewController(t)) svc := New(store) - result, err := svc.Upgrade(t.Context(), skills.UpgradeOptions{ProjectRoot: projectRoot}) + 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:pinned", result.Outcomes[0].OldDigest) + assert.Equal(t, "sha256:"+strings.Repeat("c", 64), result.Outcomes[0].OldDigest) }) } } @@ -155,7 +159,8 @@ func TestUpgradeUnknownNameReturns404(t *testing.T) { store := storemocks.NewMockSkillStore(gomock.NewController(t)) svc := New(store) - _, err := svc.Upgrade(t.Context(), skills.UpgradeOptions{ + lockSvc := svc.(skills.SkillLockService) + _, err := lockSvc.Upgrade(t.Context(), skills.UpgradeOptions{ ProjectRoot: projectRoot, Names: []string{"missing-skill"}, }) From bead6ba13fdaad6f1cd40dd95f063d584d1fcc90 Mon Sep 17 00:00:00 2001 From: Samuele Verzi Date: Tue, 7 Jul 2026 12:02:16 +0200 Subject: [PATCH 08/20] Wire lock service through API, client, and CLI Expose sync and upgrade on the lock service with typed request bodies, pre-install confirmation gates, and exit codes 2/3/4 for partial failure. Co-authored-by: Cursor --- cmd/thv/app/exitcode.go | 49 ++++++++++++++ cmd/thv/app/skill_confirm.go | 44 +++++++++++++ cmd/thv/app/skill_sync.go | 112 ++++++++++++++++++++++++++++---- cmd/thv/app/skill_upgrade.go | 101 ++++++++++++++++++++++++----- cmd/thv/main.go | 3 + pkg/api/server.go | 4 +- pkg/api/v1/skills.go | 21 ++++-- pkg/api/v1/skills_test.go | 121 ++++++++++++++++++----------------- pkg/api/v1/skills_types.go | 12 +++- pkg/skills/client/client.go | 16 +++-- pkg/skills/client/dto.go | 13 ++-- 11 files changed, 391 insertions(+), 105 deletions(-) create mode 100644 cmd/thv/app/exitcode.go create mode 100644 cmd/thv/app/skill_confirm.go 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_sync.go b/cmd/thv/app/skill_sync.go index 2ee9680b46..9aa9a80c51 100644 --- a/cmd/thv/app/skill_sync.go +++ b/cmd/thv/app/skill_sync.go @@ -16,6 +16,9 @@ var ( skillSyncProjectRoot string skillSyncClientsRaw string skillSyncPrune bool + skillSyncCheck bool + skillSyncAdopt bool + skillSyncYes bool skillSyncFormat string ) @@ -23,12 +26,17 @@ 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, restoring skills that are missing or have drifted from the -pinned state. Project-scoped skills installed outside of the lock file are -reported as unmanaged, or removed with --prune. +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.`, +enclosing git repository) unless --project-root is given. + +Pin history: git log -p -- toolhive.lock.yaml`, PreRunE: chainPreRunE(ValidateFormat(&skillSyncFormat)), RunE: skillSyncCmdFunc, } @@ -39,7 +47,13 @@ func init() { 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 project-scoped skills that are not present in the lock file") + "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) @@ -52,16 +66,45 @@ func skillSyncCmdFunc(cmd *cobra.Command, _ []string) error { } c := newSkillClient(cmd.Context()) - result, err := c.Sync(cmd.Context(), skills.SyncOptions{ + 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) } - switch skillSyncFormat { + 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 { @@ -71,22 +114,67 @@ func skillSyncCmdFunc(cmd *cobra.Command, _ []string) error { 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.UpToDate) + 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.UpToDate) - printSkillNameList("Unmanaged (not in lock file; re-run with --prune to remove)", result.Unmanaged) + 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 { - fmt.Printf(" %s: %s\n", f.Name, f.Error) + 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.UpToDate) == 0 && len(result.Unmanaged) == 0 && - len(result.Pruned) == 0 && len(result.Failed) == 0 { + if len(result.Installed) == 0 && len(result.UpToDate) == 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") } } diff --git a/cmd/thv/app/skill_upgrade.go b/cmd/thv/app/skill_upgrade.go index 561bf74154..4d662ca247 100644 --- a/cmd/thv/app/skill_upgrade.go +++ b/cmd/thv/app/skill_upgrade.go @@ -15,10 +15,13 @@ import ( ) var ( - skillUpgradeProjectRoot string - skillUpgradeClientsRaw string - skillUpgradeDryRun bool - skillUpgradeFormat string + skillUpgradeProjectRoot string + skillUpgradeClientsRaw string + skillUpgradePreview bool + skillUpgradeFailOnChanges bool + skillUpgradeAllowRefChange bool + skillUpgradeYes bool + skillUpgradeFormat string ) var skillUpgradeCmd = &cobra.Command{ @@ -29,8 +32,10 @@ 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. -With no arguments, every entry in the lock file is checked. Use --dry-run to -see what would change without installing anything. +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.`, @@ -43,8 +48,14 @@ func init() { skillUpgradeCmd.Flags().StringVar(&skillUpgradeClientsRaw, "clients", "", `Comma-separated target client apps (e.g. claude-code,opencode), or "all" for every available client`) - skillUpgradeCmd.Flags().BoolVar(&skillUpgradeDryRun, "dry-run", false, - "Report what would change without installing anything") + 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(&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) @@ -57,17 +68,45 @@ func skillUpgradeCmdFunc(cmd *cobra.Command, args []string) error { } c := newSkillClient(cmd.Context()) - result, err := c.Upgrade(cmd.Context(), skills.UpgradeOptions{ - ProjectRoot: projectRoot, - Names: args, - DryRun: skillUpgradeDryRun, - Clients: parseSkillInstallClients(skillUpgradeClientsRaw), - }) + opts := skills.UpgradeOptions{ + ProjectRoot: projectRoot, + Names: args, + Preview: skillUpgradePreview, + FailOnChanges: skillUpgradeFailOnChanges, + AllowRefChange: skillUpgradeAllowRefChange, + 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) } - switch skillUpgradeFormat { + 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 { @@ -77,9 +116,39 @@ func skillUpgradeCmdFunc(cmd *cobra.Command, args []string) error { 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: + 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") @@ -93,7 +162,7 @@ func printUpgradeResultText(result *skills.UpgradeResult) { _ = w.Flush() for _, o := range result.Outcomes { - if o.Status == skills.UpgradeStatusFailed && o.Error != "" { + if o.Error != "" { fmt.Printf(" %s: %s\n", o.Name, o.Error) } } 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/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 a4b82b468e..fad2cc819b 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() @@ -390,10 +392,12 @@ func (s *SkillsRoutes) syncSkills(w http.ResponseWriter, r *http.Request) error ) } - result, err := s.skillService.Sync(r.Context(), skills.SyncOptions{ + 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 @@ -426,11 +430,14 @@ func (s *SkillsRoutes) upgradeSkills(w http.ResponseWriter, r *http.Request) err ) } - result, err := s.skillService.Upgrade(r.Context(), skills.UpgradeOptions{ - ProjectRoot: req.ProjectRoot, - Names: req.Names, - DryRun: req.DryRun, - Clients: req.Clients, + 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, + Clients: req.Clients, }) if err != nil { return err diff --git a/pkg/api/v1/skills_test.go b/pkg/api/v1/skills_test.go index faed829b0f..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,7 +568,7 @@ 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)) }, @@ -581,8 +581,8 @@ func TestSkillsRouter(t *testing.T) { method: "POST", path: "/sync", body: `{"project_root":"{{project_root}}"}`, - setupMock: func(svc *skillsmocks.MockSkillService, projectRoot string) { - svc.EXPECT().Sync(gomock.Any(), skills.SyncOptions{ProjectRoot: projectRoot}). + 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, @@ -593,8 +593,8 @@ func TestSkillsRouter(t *testing.T) { method: "POST", path: "/sync", body: `{"project_root":"{{project_root}}","clients":["claude-code"],"prune":true}`, - setupMock: func(svc *skillsmocks.MockSkillService, projectRoot string) { - svc.EXPECT().Sync(gomock.Any(), skills.SyncOptions{ + setupMock: func(_ *skillsmocks.MockSkillService, lock *skillsmocks.MockSkillLockService, projectRoot string) { + lock.EXPECT().Sync(gomock.Any(), skills.SyncOptions{ ProjectRoot: projectRoot, Clients: []string{"claude-code"}, Prune: true, @@ -608,7 +608,7 @@ func TestSkillsRouter(t *testing.T) { method: "POST", path: "/sync", body: `{invalid`, - setupMock: func(_ *skillsmocks.MockSkillService, _ string) {}, + setupMock: func(_ *skillsmocks.MockSkillService, _ *skillsmocks.MockSkillLockService, _ string) {}, expectedStatus: http.StatusBadRequest, expectedBody: "invalid request body", }, @@ -617,8 +617,8 @@ func TestSkillsRouter(t *testing.T) { method: "POST", path: "/sync", body: `{"project_root":"/not/a/repo"}`, - setupMock: func(svc *skillsmocks.MockSkillService, _ string) { - svc.EXPECT().Sync(gomock.Any(), skills.SyncOptions{ProjectRoot: "/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, @@ -630,8 +630,8 @@ func TestSkillsRouter(t *testing.T) { method: "POST", path: "/upgrade", body: `{"project_root":"{{project_root}}"}`, - setupMock: func(svc *skillsmocks.MockSkillService, projectRoot string) { - svc.EXPECT().Upgrade(gomock.Any(), skills.UpgradeOptions{ProjectRoot: projectRoot}). + 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"}, @@ -646,10 +646,11 @@ func TestSkillsRouter(t *testing.T) { method: "POST", path: "/upgrade", body: `{"project_root":"{{project_root}}","names":["my-skill"],"dry_run":true,"clients":["claude-code"]}`, - setupMock: func(svc *skillsmocks.MockSkillService, projectRoot string) { - svc.EXPECT().Upgrade(gomock.Any(), skills.UpgradeOptions{ + 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{ @@ -666,7 +667,7 @@ func TestSkillsRouter(t *testing.T) { method: "POST", path: "/upgrade", body: `{invalid`, - setupMock: func(_ *skillsmocks.MockSkillService, _ string) {}, + setupMock: func(_ *skillsmocks.MockSkillService, _ *skillsmocks.MockSkillLockService, _ string) {}, expectedStatus: http.StatusBadRequest, expectedBody: "invalid request body", }, @@ -675,8 +676,8 @@ func TestSkillsRouter(t *testing.T) { method: "POST", path: "/upgrade", body: `{"project_root":"{{project_root}}","names":["missing-skill"]}`, - setupMock: func(svc *skillsmocks.MockSkillService, projectRoot string) { - svc.EXPECT().Upgrade(gomock.Any(), skills.UpgradeOptions{ + 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)) @@ -706,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") @@ -730,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{ @@ -742,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 e28a07d47d..1ef4de503d 100644 --- a/pkg/api/v1/skills_types.go +++ b/pkg/api/v1/skills_types.go @@ -87,6 +87,10 @@ type syncSkillsRequest struct { 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. @@ -97,8 +101,14 @@ type upgradeSkillsRequest struct { ProjectRoot string `json:"project_root"` // Names restricts the upgrade to specific skill names, or omit for every locked skill Names []string `json:"names,omitempty"` - // DryRun reports what would change without installing anything + // 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"` // Clients lists target client identifiers, or omit for every detected client Clients []string `json:"clients,omitempty"` } diff --git a/pkg/skills/client/client.go b/pkg/skills/client/client.go index 7cfd640098..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 { @@ -274,6 +275,8 @@ func (c *Client) Sync(ctx context.Context, opts skills.SyncOptions) (*skills.Syn ProjectRoot: opts.ProjectRoot, Clients: opts.Clients, Prune: opts.Prune, + Check: opts.Check, + Adopt: opts.Adopt, } var result skills.SyncResult @@ -287,10 +290,13 @@ func (c *Client) Sync(ctx context.Context, opts skills.SyncOptions) (*skills.Syn // 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, - DryRun: opts.DryRun, - Clients: opts.Clients, + 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 diff --git a/pkg/skills/client/dto.go b/pkg/skills/client/dto.go index 172997cebe..b38e3af222 100644 --- a/pkg/skills/client/dto.go +++ b/pkg/skills/client/dto.go @@ -46,11 +46,16 @@ 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"` - DryRun bool `json:"dry_run,omitempty"` - Clients []string `json:"clients,omitempty"` + 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"` + Clients []string `json:"clients,omitempty"` } From 6b114b3542bd43f9eeae201e32516d9169e5195b Mon Sep 17 00:00:00 2001 From: Samuele Verzi Date: Tue, 7 Jul 2026 12:02:21 +0200 Subject: [PATCH 09/20] Document skills lock file sync and upgrade behavior Update architecture notes and regenerate CLI and swagger docs for new flags, exit codes, and lock file integrity semantics. Co-authored-by: Cursor --- docs/arch/12-skills-system.md | 37 ++++++++++---- docs/cli/thv_skill_sync.md | 16 +++++-- docs/cli/thv_skill_upgrade.md | 11 +++-- docs/server/docs.go | 90 +++++++++++++++++++++++++++++++++-- docs/server/swagger.json | 90 +++++++++++++++++++++++++++++++++-- docs/server/swagger.yaml | 85 ++++++++++++++++++++++++++++++--- 6 files changed, 298 insertions(+), 31 deletions(-) diff --git a/docs/arch/12-skills-system.md b/docs/arch/12-skills-system.md index 18ff828724..6bb64f348b 100644 --- a/docs/arch/12-skills-system.md +++ b/docs/arch/12-skills-system.md @@ -265,7 +265,9 @@ Removes the skill files from the filesystem, deletes the database record, and re ## 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**, and **digest** of each project-scoped skill 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. +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, a committed lock entry is an **assertion** verified against itself at install/sync time; PR review of the lock diff is the trust root until external attestation lands in a follow-on milestone. ```yaml version: 1 @@ -275,30 +277,47 @@ skills: source: code-review # what the user/registry resolver originally typed resolvedReference: ghcr.io/org/code-review:1.0.0 digest: sha256:9f2b1e... + contentDigest: sha256:a1b2c3d4... # deterministic dirhash of materialized files + - name: testing-conventions + source: ghcr.io/org/testing-conventions:1.0.0 + resolvedReference: ghcr.io/org/testing-conventions:1.0.0 + digest: sha256:... + contentDigest: sha256:... + requiredBy: [code-review] # transitive dependency provenance + explicit: true # user-requested install; exempt from cascade removal ``` +`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 at its exact digest -thv skill sync --prune # also uninstall project-scoped skills absent from the lock file +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 the currently installed digest (if any) against the pinned digest. A mismatch or missing install triggers a fresh install pinned to `resolvedReference@digest` — an OCI digest reference, or a git reference pinned to the exact commit hash — so the installed content is byte-for-byte reproducible regardless of what a mutable tag or branch currently points to. Project-scoped skills installed outside the lock file are reported as unmanaged, or removed when `--prune` is set. A sync never rewrites the lock file itself: it only makes the filesystem and database match what is already pinned. +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 for newer content -thv skill upgrade code-review # check specific skills only -thv skill upgrade --dry-run # report without installing or writing the lock file +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` exactly as a fresh `thv skill install ` would (registry name -> catalog lookup, git branch/tag -> current head, OCI tag -> current digest) and compares the result against the pinned digest. A different digest installs the new content and rewrites the entry's `resolvedReference`, `digest`, and `version` — `source` never changes, so future upgrades keep re-resolving the same catalog name or ref. Entries already pinned to an immutable reference (an OCI `@sha256:...` digest or a full 40-character git commit hash) are reported as `not-upgradable` without contacting the network, since re-resolving them can never surface different content. +`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. -**Implementation:** `pkg/skills/lockfile/` (schema, file-locked read/write/upsert/remove), `pkg/skills/skillsvc/sync.go` (Sync), `pkg/skills/skillsvc/upgrade.go` (Upgrade), `pkg/skills/skillsvc/pin.go` (digest-pinning and immutability helpers) +**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 diff --git a/docs/cli/thv_skill_sync.md b/docs/cli/thv_skill_sync.md index 57d217bad6..3d8760dcb2 100644 --- a/docs/cli/thv_skill_sync.md +++ b/docs/cli/thv_skill_sync.md @@ -16,13 +16,18 @@ 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, restoring skills that are missing or have drifted from the -pinned state. Project-scoped skills installed outside of the lock file are -reported as unmanaged, or removed with --prune. +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] ``` @@ -30,11 +35,14 @@ 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 project-scoped skills that are not present in the lock file + --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 diff --git a/docs/cli/thv_skill_upgrade.md b/docs/cli/thv_skill_upgrade.md index 4e7d058813..da89b28ae8 100644 --- a/docs/cli/thv_skill_upgrade.md +++ b/docs/cli/thv_skill_upgrade.md @@ -20,8 +20,10 @@ 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. -With no arguments, every entry in the lock file is checked. Use --dry-run to -see what would change without installing anything. +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. @@ -33,11 +35,14 @@ thv skill upgrade [skill-name...] [flags] ### Options ``` + --allow-ref-change Allow resolvedReference changes during upgrade --clients string Comma-separated target client apps (e.g. claude-code,opencode), or "all" for every available client - --dry-run Report what would change without installing anything + --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 diff --git a/docs/server/docs.go b/docs/server/docs.go index 0f6b68887d..85a229065b 100644 --- a/docs/server/docs.go +++ b/docs/server/docs.go @@ -1615,6 +1615,28 @@ 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", + "unknown" + ], + "type": "string", + "x-enum-varnames": [ + "FailureReasonRegistryUnreachable", + "FailureReasonDigestMissing", + "FailureReasonValidationRejected", + "FailureReasonLockWriteFailed", + "FailureReasonRefChangeBlocked", + "FailureReasonContentMismatch", + "FailureReasonUnknown" + ] + }, "github_com_stacklok_toolhive_pkg_skills.InstallStatus": { "description": "Status is the current installation status.", "enum": [ @@ -1656,6 +1678,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" }, @@ -1813,12 +1839,23 @@ const docTemplate = `{ "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": { @@ -1835,8 +1872,24 @@ const docTemplate = `{ "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 unmanaged skills that were uninstalled because Prune was set.", + "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" }, @@ -1844,7 +1897,7 @@ const docTemplate = `{ "uniqueItems": false }, "unmanaged": { - "description": "Unmanaged lists project-scoped skills present on disk but absent from the lock file.", + "description": "Unmanaged is deprecated; use NeverManaged and RemovedFromLock.", "items": { "type": "string" }, @@ -1852,7 +1905,7 @@ const docTemplate = `{ "uniqueItems": false }, "up_to_date": { - "description": "UpToDate lists skills that already matched the lock file's pinned digest.", + "description": "UpToDate lists skills that already matched the lock file.", "items": { "type": "string" }, @@ -1876,10 +1929,17 @@ const docTemplate = `{ "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" } @@ -1905,6 +1965,7 @@ const docTemplate = `{ "upgraded", "up-to-date", "not-upgradable", + "ref-change-blocked", "failed" ], "type": "string", @@ -1912,6 +1973,7 @@ const docTemplate = `{ "UpgradeStatusUpgraded", "UpgradeStatusUpToDate", "UpgradeStatusNotUpgradable", + "UpgradeStatusRefChangeBlocked", "UpgradeStatusFailed" ] }, @@ -3511,6 +3573,14 @@ const docTemplate = `{ "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": { @@ -3733,6 +3803,10 @@ const docTemplate = `{ "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" + }, "clients": { "description": "Clients lists target client identifiers, or omit for every detected client", "items": { @@ -3742,7 +3816,11 @@ const docTemplate = `{ "uniqueItems": false }, "dry_run": { - "description": "DryRun reports what would change without installing anything", + "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": { @@ -3753,6 +3831,10 @@ const docTemplate = `{ "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" diff --git a/docs/server/swagger.json b/docs/server/swagger.json index 5cb9d4c057..a215761440 100644 --- a/docs/server/swagger.json +++ b/docs/server/swagger.json @@ -1608,6 +1608,28 @@ }, "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", + "unknown" + ], + "type": "string", + "x-enum-varnames": [ + "FailureReasonRegistryUnreachable", + "FailureReasonDigestMissing", + "FailureReasonValidationRejected", + "FailureReasonLockWriteFailed", + "FailureReasonRefChangeBlocked", + "FailureReasonContentMismatch", + "FailureReasonUnknown" + ] + }, "github_com_stacklok_toolhive_pkg_skills.InstallStatus": { "description": "Status is the current installation status.", "enum": [ @@ -1649,6 +1671,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" }, @@ -1806,12 +1832,23 @@ "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": { @@ -1828,8 +1865,24 @@ "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 unmanaged skills that were uninstalled because Prune was set.", + "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" }, @@ -1837,7 +1890,7 @@ "uniqueItems": false }, "unmanaged": { - "description": "Unmanaged lists project-scoped skills present on disk but absent from the lock file.", + "description": "Unmanaged is deprecated; use NeverManaged and RemovedFromLock.", "items": { "type": "string" }, @@ -1845,7 +1898,7 @@ "uniqueItems": false }, "up_to_date": { - "description": "UpToDate lists skills that already matched the lock file's pinned digest.", + "description": "UpToDate lists skills that already matched the lock file.", "items": { "type": "string" }, @@ -1869,10 +1922,17 @@ "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" } @@ -1898,6 +1958,7 @@ "upgraded", "up-to-date", "not-upgradable", + "ref-change-blocked", "failed" ], "type": "string", @@ -1905,6 +1966,7 @@ "UpgradeStatusUpgraded", "UpgradeStatusUpToDate", "UpgradeStatusNotUpgradable", + "UpgradeStatusRefChangeBlocked", "UpgradeStatusFailed" ] }, @@ -3504,6 +3566,14 @@ "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": { @@ -3726,6 +3796,10 @@ "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" + }, "clients": { "description": "Clients lists target client identifiers, or omit for every detected client", "items": { @@ -3735,7 +3809,11 @@ "uniqueItems": false }, "dry_run": { - "description": "DryRun reports what would change without installing anything", + "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": { @@ -3746,6 +3824,10 @@ "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" diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml index 21ed43e20d..68a9e29d60 100644 --- a/docs/server/swagger.yaml +++ b/docs/server/swagger.yaml @@ -1636,6 +1636,25 @@ 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 + - unknown + type: string + x-enum-varnames: + - FailureReasonRegistryUnreachable + - FailureReasonDigestMissing + - FailureReasonValidationRejected + - FailureReasonLockWriteFailed + - FailureReasonRefChangeBlocked + - FailureReasonContentMismatch + - FailureReasonUnknown github_com_stacklok_toolhive_pkg_skills.InstallStatus: description: Status is the current installation status. enum: @@ -1670,6 +1689,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: @@ -1788,9 +1812,18 @@ components: 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. @@ -1805,23 +1838,35 @@ components: 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 unmanaged skills that were uninstalled because - Prune was set. + 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 lists project-scoped skills present on disk but absent - from the lock file. + description: Unmanaged is deprecated; use NeverManaged and RemovedFromLock. items: type: string type: array uniqueItems: false up_to_date: - description: UpToDate lists skills that already matched the lock file's - pinned digest. + description: UpToDate lists skills that already matched the lock file. items: type: string type: array @@ -1841,10 +1886,15 @@ components: 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 @@ -1863,12 +1913,14 @@ components: - upgraded - up-to-date - not-upgradable + - ref-change-blocked - failed type: string x-enum-varnames: - UpgradeStatusUpgraded - UpgradeStatusUpToDate - UpgradeStatusNotUpgradable + - UpgradeStatusRefChangeBlocked - UpgradeStatusFailed github_com_stacklok_toolhive_pkg_skills.ValidationResult: properties: @@ -3171,6 +3223,14 @@ components: 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 @@ -3350,6 +3410,9 @@ components: 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 clients: description: Clients lists target client identifiers, or omit for every detected client @@ -3358,7 +3421,11 @@ components: type: array uniqueItems: false dry_run: - description: DryRun reports what would change without installing anything + 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 @@ -3367,6 +3434,10 @@ components: 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 From b56cae5ea419bb08bc9df0787c8d43076996b237 Mon Sep 17 00:00:00 2001 From: Samuele Verzi Date: Tue, 7 Jul 2026 12:16:28 +0200 Subject: [PATCH 10/20] Rename SyncResult UpToDate field to fix codespell Codespell flags UpToDate as a misspelling; rename the Go field to AlreadyCurrent while keeping the JSON key up_to_date unchanged. Co-authored-by: Cursor --- cmd/thv/app/skill_sync.go | 6 +++--- docs/server/docs.go | 2 +- docs/server/swagger.json | 2 +- docs/server/swagger.yaml | 2 +- pkg/skills/options.go | 4 ++-- pkg/skills/skillsvc/sync.go | 14 +++++++------- pkg/skills/skillsvc/sync_test.go | 4 ++-- 7 files changed, 17 insertions(+), 17 deletions(-) diff --git a/cmd/thv/app/skill_sync.go b/cmd/thv/app/skill_sync.go index 9aa9a80c51..8bc5fb54e8 100644 --- a/cmd/thv/app/skill_sync.go +++ b/cmd/thv/app/skill_sync.go @@ -135,7 +135,7 @@ func syncExitCode(result *skills.SyncResult) int { 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.UpToDate) + printSkillNameList(" Up to date", result.AlreadyCurrent) printSkillNameList(" Never managed", result.NeverManaged) printSkillNameList(" Removed from lock", result.RemovedFromLock) if prune { @@ -158,7 +158,7 @@ func diffInstalled(result *skills.SyncResult) []string { func printSyncResultText(result *skills.SyncResult) { printSkillNameList("Installed", result.Installed) printSkillNameList("Drifted (reinstalled)", result.Drifted) - printSkillNameList("Up to date", result.UpToDate) + printSkillNameList("Up to date", result.AlreadyCurrent) printSkillNameList("Never managed", result.NeverManaged) printSkillNameList("Removed from lock", result.RemovedFromLock) printSkillNameList("Unmanaged (deprecated)", result.Unmanaged) @@ -173,7 +173,7 @@ func printSyncResultText(result *skills.SyncResult) { } } } - if len(result.Installed) == 0 && len(result.UpToDate) == 0 && len(result.NeverManaged) == 0 && + 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") } diff --git a/docs/server/docs.go b/docs/server/docs.go index 85a229065b..651e31cd13 100644 --- a/docs/server/docs.go +++ b/docs/server/docs.go @@ -1905,7 +1905,7 @@ const docTemplate = `{ "uniqueItems": false }, "up_to_date": { - "description": "UpToDate lists skills that already matched the lock file.", + "description": "AlreadyCurrent lists skills that already matched the lock file.", "items": { "type": "string" }, diff --git a/docs/server/swagger.json b/docs/server/swagger.json index a215761440..ee0e5fc73b 100644 --- a/docs/server/swagger.json +++ b/docs/server/swagger.json @@ -1898,7 +1898,7 @@ "uniqueItems": false }, "up_to_date": { - "description": "UpToDate lists skills that already matched the lock file.", + "description": "AlreadyCurrent lists skills that already matched the lock file.", "items": { "type": "string" }, diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml index 68a9e29d60..7e7429f892 100644 --- a/docs/server/swagger.yaml +++ b/docs/server/swagger.yaml @@ -1866,7 +1866,7 @@ components: type: array uniqueItems: false up_to_date: - description: UpToDate lists skills that already matched the lock file. + description: AlreadyCurrent lists skills that already matched the lock file. items: type: string type: array diff --git a/pkg/skills/options.go b/pkg/skills/options.go index 0a15140c4d..f744283a8d 100644 --- a/pkg/skills/options.go +++ b/pkg/skills/options.go @@ -196,8 +196,8 @@ type SyncResult struct { Installed []string `json:"installed,omitempty"` // Drifted lists skills whose on-disk contentDigest differed before reinstall. Drifted []string `json:"drifted,omitempty"` - // UpToDate lists skills that already matched the lock file. - UpToDate []string `json:"up_to_date,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. diff --git a/pkg/skills/skillsvc/sync.go b/pkg/skills/skillsvc/sync.go index 65b1a46f9f..4d44f505db 100644 --- a/pkg/skills/skillsvc/sync.go +++ b/pkg/skills/skillsvc/sync.go @@ -71,8 +71,8 @@ func (s *service) syncEntry( if opts.Check { switch status { - case syncStatusUpToDate: - result.UpToDate = append(result.UpToDate, entry.Name) + 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, @@ -83,8 +83,8 @@ func (s *service) syncEntry( } switch status { - case syncStatusUpToDate: - result.UpToDate = append(result.UpToDate, entry.Name) + case syncStatusAlreadyCurrent: + result.AlreadyCurrent = append(result.AlreadyCurrent, entry.Name) return case syncStatusDrifted: result.Drifted = append(result.Drifted, entry.Name) @@ -121,7 +121,7 @@ func (s *service) syncEntry( type syncEntryStatus int const ( - syncStatusUpToDate syncEntryStatus = iota + syncStatusAlreadyCurrent syncEntryStatus = iota syncStatusDrifted syncStatusMissing ) @@ -139,7 +139,7 @@ func (s *service) verifyLockEntry( if entry.ContentDigest == "" { if existing.Digest == entry.Digest { - return syncStatusUpToDate, nil + return syncStatusAlreadyCurrent, nil } return syncStatusDrifted, nil } @@ -169,7 +169,7 @@ func (s *service) verifyLockEntry( if existing.Digest != entry.Digest { return syncStatusDrifted, nil } - return syncStatusUpToDate, nil + return syncStatusAlreadyCurrent, nil } func (s *service) contentDigestsForClients( diff --git a/pkg/skills/skillsvc/sync_test.go b/pkg/skills/skillsvc/sync_test.go index 17fbbf39fe..5dd1889869 100644 --- a/pkg/skills/skillsvc/sync_test.go +++ b/pkg/skills/skillsvc/sync_test.go @@ -23,7 +23,7 @@ import ( storemocks "github.com/stacklok/toolhive/pkg/storage/mocks" ) -func TestSyncInstallsDriftedReportsUpToDateAndUnmanaged(t *testing.T) { +func TestSyncInstallsDriftedReportsAlreadyCurrentAndUnmanaged(t *testing.T) { t.Parallel() projectRoot := makeProjectRoot(t) @@ -82,7 +82,7 @@ func TestSyncInstallsDriftedReportsUpToDateAndUnmanaged(t *testing.T) { }) require.NoError(t, err) assert.Equal(t, []string{"my-skill"}, result.Installed) - assert.Equal(t, []string{"already-current"}, result.UpToDate) + 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) From b47acc9214ff60f40986dfb41bcd1c5bc0398f85 Mon Sep 17 00:00:00 2001 From: Samuele Verzi Date: Wed, 8 Jul 2026 16:24:27 +0200 Subject: [PATCH 11/20] Add provenance and unsigned fields to skills lockfile Co-authored-by: Cursor --- pkg/skills/lockfile/lockfile.go | 16 +++++++ pkg/skills/lockfile/lockfile_test.go | 65 ++++++++++++++++++++++++++++ pkg/skills/lockfile/validation.go | 18 ++++++++ 3 files changed, 99 insertions(+) diff --git a/pkg/skills/lockfile/lockfile.go b/pkg/skills/lockfile/lockfile.go index 58687972a8..8fea434460 100644 --- a/pkg/skills/lockfile/lockfile.go +++ b/pkg/skills/lockfile/lockfile.go @@ -27,6 +27,18 @@ 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. @@ -51,6 +63,10 @@ type Entry struct { // 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. diff --git a/pkg/skills/lockfile/lockfile_test.go b/pkg/skills/lockfile/lockfile_test.go index 59668d21d3..718a7befd1 100644 --- a/pkg/skills/lockfile/lockfile_test.go +++ b/pkg/skills/lockfile/lockfile_test.go @@ -125,3 +125,68 @@ func TestUpsertEntryAndRemoveEntry(t *testing.T) { // 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 index 56e8cf0252..07c969149c 100644 --- a/pkg/skills/lockfile/validation.go +++ b/pkg/skills/lockfile/validation.go @@ -59,6 +59,24 @@ func validateEntry(entry Entry) error { 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 } From 6075f32137185115c095ff6e45ccb14f56c7cdb2 Mon Sep 17 00:00:00 2001 From: Samuele Verzi Date: Wed, 8 Jul 2026 16:24:27 +0200 Subject: [PATCH 12/20] Add Sigstore verifier and signer packages for skills Co-authored-by: Cursor --- go.mod | 9 +- go.sum | 12 + pkg/skills/signer/cosign_attach.go | 119 +++++++++ pkg/skills/signer/key.go | 92 +++++++ pkg/skills/signer/signer.go | 120 +++++++++ pkg/skills/verifier/errors.go | 17 ++ pkg/skills/verifier/git.go | 63 +++++ pkg/skills/verifier/identity.go | 48 ++++ pkg/skills/verifier/mocks/mock_verifier.go | 102 ++++++++ pkg/skills/verifier/oci.go | 58 +++++ pkg/skills/verifier/oci_bundle.go | 284 +++++++++++++++++++++ pkg/skills/verifier/offline.go | 54 ++++ pkg/skills/verifier/sigstore.go | 53 ++++ pkg/skills/verifier/types.go | 55 ++++ pkg/skills/verifier/types_test.go | 64 +++++ pkg/skills/verifier/verifier.go | 42 +++ 16 files changed, 1189 insertions(+), 3 deletions(-) create mode 100644 pkg/skills/signer/cosign_attach.go create mode 100644 pkg/skills/signer/key.go create mode 100644 pkg/skills/signer/signer.go create mode 100644 pkg/skills/verifier/errors.go create mode 100644 pkg/skills/verifier/git.go create mode 100644 pkg/skills/verifier/identity.go create mode 100644 pkg/skills/verifier/mocks/mock_verifier.go create mode 100644 pkg/skills/verifier/oci.go create mode 100644 pkg/skills/verifier/oci_bundle.go create mode 100644 pkg/skills/verifier/offline.go create mode 100644 pkg/skills/verifier/sigstore.go create mode 100644 pkg/skills/verifier/types.go create mode 100644 pkg/skills/verifier/types_test.go create mode 100644 pkg/skills/verifier/verifier.go 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/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/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) From d891e15f46ad4c8d4ea3772b2c8b6fb8c9f1d3d3 Mon Sep 17 00:00:00 2001 From: Samuele Verzi Date: Wed, 8 Jul 2026 16:24:27 +0200 Subject: [PATCH 13/20] Store verified Sigstore bundles on installed skills Co-authored-by: Cursor --- pkg/skills/types.go | 2 ++ .../004_add_skill_sigstore_bundle.sql | 5 +++ pkg/storage/sqlite/skill_store.go | 33 +++++++++++-------- 3 files changed, 26 insertions(+), 14 deletions(-) create mode 100644 pkg/storage/sqlite/migrations/004_add_skill_sigstore_bundle.sql diff --git a/pkg/skills/types.go b/pkg/skills/types.go index 3f4ad3018f..fc11213112 100644 --- a/pkg/skills/types.go +++ b/pkg/skills/types.go @@ -170,6 +170,8 @@ type InstalledSkill struct { // 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/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 a4cd2cec11..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, is_.managed` + 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, managed - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, jsonb(?), jsonb(?), ?, ?)`, + version, description, author, tags, client_apps, status, managed, sigstore_bundle + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, jsonb(?), jsonb(?), ?, ?, ?)`, entryID, string(skill.Scope), skill.ProjectRoot, @@ -100,6 +100,7 @@ func (s *SkillStore) Create(ctx context.Context, skill skills.InstalledSkill) er clientsJSON, string(skill.Status), boolToInt(skill.Managed), + skill.SigstoreBundle, ) if err != nil { if isUniqueViolation(err) { @@ -267,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 = ?, managed = ? + author = ?, tags = jsonb(?), client_apps = jsonb(?), status = ?, managed = ?, + sigstore_bundle = ? WHERE id = ?`, skill.Reference, skill.Tag, @@ -279,6 +281,7 @@ func (s *SkillStore) Update(ctx context.Context, skill skills.InstalledSkill) er clientsJSON, string(skill.Status), boolToInt(skill.Managed), + skill.SigstoreBundle, installedSkillID, ); err != nil { return fmt.Errorf("updating installed skill: %w", err) @@ -373,12 +376,13 @@ func scanSkillFields(sc scanner) (skills.InstalledSkill, int64, error) { 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, &managed, + &clientsBlob, &status, &installedAtStr, &managed, &sigstoreBundle, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { @@ -407,15 +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, - Managed: managed != 0, + 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 From f020fd793ba1dc444e0109aabfa28d0e404347f4 Mon Sep 17 00:00:00 2001 From: Samuele Verzi Date: Wed, 8 Jul 2026 16:24:27 +0200 Subject: [PATCH 14/20] Verify Sigstore signatures during project-scope installs Co-authored-by: Cursor --- pkg/git/client.go | 19 ++ pkg/skills/gitresolver/resolver.go | 10 +- pkg/skills/gitresolver/resolver_test.go | 4 + pkg/skills/options.go | 31 +++ pkg/skills/skillsvc/install.go | 1 + pkg/skills/skillsvc/install_extraction.go | 19 +- pkg/skills/skillsvc/install_git.go | 10 + pkg/skills/skillsvc/install_oci.go | 16 ++ pkg/skills/skillsvc/lock.go | 13 +- pkg/skills/skillsvc/service.go | 18 ++ pkg/skills/skillsvc/test_verifier.go | 36 ++++ pkg/skills/skillsvc/verify.go | 241 ++++++++++++++++++++++ pkg/skills/skillsvc/verify_test.go | 37 ++++ 13 files changed, 442 insertions(+), 13 deletions(-) create mode 100644 pkg/skills/skillsvc/test_verifier.go create mode 100644 pkg/skills/skillsvc/verify.go create mode 100644 pkg/skills/skillsvc/verify_test.go 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/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/options.go b/pkg/skills/options.go index f744283a8d..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. @@ -50,6 +58,14 @@ type InstallOptions struct { 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. @@ -60,6 +76,10 @@ type InstallResult struct { 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. @@ -148,6 +168,10 @@ 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. @@ -177,6 +201,9 @@ const ( 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" ) @@ -225,6 +252,8 @@ type UpgradeOptions struct { 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"` @@ -243,6 +272,8 @@ const ( 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" ) diff --git a/pkg/skills/skillsvc/install.go b/pkg/skills/skillsvc/install.go index e603a6e3e3..88e3f465b2 100644 --- a/pkg/skills/skillsvc/install.go +++ b/pkg/skills/skillsvc/install.go @@ -86,6 +86,7 @@ func (s *service) installInternal(ctx context.Context, opts skills.InstallOption 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 4ecc54cfda..b1f75b2be9 100644 --- a/pkg/skills/skillsvc/install_extraction.go +++ b/pkg/skills/skillsvc/install_extraction.go @@ -223,18 +223,21 @@ 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, - Managed: opts.Managed, + 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 } diff --git a/pkg/skills/skillsvc/install_git.go b/pkg/skills/skillsvc/install_git.go index 48cb418428..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 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 index ae41af18ed..92d4dbdb00 100644 --- a/pkg/skills/skillsvc/lock.go +++ b/pkg/skills/skillsvc/lock.go @@ -30,6 +30,8 @@ func recordLockEntry(opts skills.InstallOptions, source string, sk skills.Instal Digest: sk.Digest, ContentDigest: contentDigest, Explicit: opts.ExplicitLock, + Provenance: provenanceInfoToLock(opts.Provenance), + Unsigned: opts.Unsigned, } if opts.RequiredByParent != "" { @@ -46,7 +48,12 @@ func recordLockEntry(opts skills.InstallOptions, source string, sk skills.Instal } // recordDepLockEntry merges a transitive dependency into the lock file. -func recordDepLockEntry(projectRoot, parent string, source string, sk skills.InstalledSkill, contentDigest string) error { +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 { @@ -69,6 +76,8 @@ func recordDepLockEntry(projectRoot, parent string, source string, sk skills.Ins Digest: sk.Digest, ContentDigest: contentDigest, RequiredBy: []string{parent}, + Provenance: provenanceInfoToLock(opts.Provenance), + Unsigned: opts.Unsigned, }) return nil }) @@ -163,7 +172,7 @@ func (s *service) materializeOneDependency( if cdErr != nil { return cdErr } - return recordDepLockEntry(baseOpts.ProjectRoot, parentName, depRef, result.Skill, contentDigest) + return recordDepLockEntry(baseOpts.ProjectRoot, parentName, depRef, result.Skill, contentDigest, depOpts) } func (s *service) contentDigestForSkill( 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/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/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) +} From e2d41aabf7daf2bd50620a86dec098d8d2e5c90d Mon Sep 17 00:00:00 2001 From: Samuele Verzi Date: Wed, 8 Jul 2026 16:24:27 +0200 Subject: [PATCH 15/20] Re-verify stored bundles offline during skill sync Co-authored-by: Cursor --- pkg/skills/skillsvc/sync.go | 45 +++++++++++++++++++++++++++----- pkg/skills/skillsvc/sync_test.go | 3 ++- 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/pkg/skills/skillsvc/sync.go b/pkg/skills/skillsvc/sync.go index 4d44f505db..037752eb80 100644 --- a/pkg/skills/skillsvc/sync.go +++ b/pkg/skills/skillsvc/sync.go @@ -101,13 +101,14 @@ func (s *service) syncEntry( } installOpts := skills.InstallOptions{ - Name: pinnedRef, - LockSource: entry.Source, - Scope: skills.ScopeProject, - ProjectRoot: projectRoot, - Clients: opts.Clients, - Force: true, - Managed: true, + 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{ @@ -169,9 +170,26 @@ func (s *service) verifyLockEntry( 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) { @@ -307,6 +325,15 @@ func (s *service) syncAdopt( 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(), @@ -336,6 +363,10 @@ func classifySyncError(err error) skills.FailureReason { 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"): diff --git a/pkg/skills/skillsvc/sync_test.go b/pkg/skills/skillsvc/sync_test.go index 5dd1889869..fb62eac9c9 100644 --- a/pkg/skills/skillsvc/sync_test.go +++ b/pkg/skills/skillsvc/sync_test.go @@ -40,6 +40,7 @@ func TestSyncInstallsDriftedReportsAlreadyCurrentAndUnmanaged(t *testing.T) { 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", @@ -74,7 +75,7 @@ func TestSyncInstallsDriftedReportsAlreadyCurrentAndUnmanaged(t *testing.T) { targetDir := filepath.Join(tempDir(t), "installed", "my-skill") pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeProject, projectRoot).Return(targetDir, nil) - svc := New(store, WithPathResolver(pr), WithRegistryClient(reg), WithOCIStore(ociStore)) + svc := New(store, withTestVerifier(), WithPathResolver(pr), WithRegistryClient(reg), WithOCIStore(ociStore)) lockSvc := svc.(skills.SkillLockService) result, err := lockSvc.Sync(t.Context(), skills.SyncOptions{ ProjectRoot: projectRoot, From c51def2915242feea8b5782403eba352e8b349ed Mon Sep 17 00:00:00 2001 From: Samuele Verzi Date: Wed, 8 Jul 2026 16:24:27 +0200 Subject: [PATCH 16/20] Block skill upgrades on signer identity changes Co-authored-by: Cursor --- pkg/skills/skillsvc/upgrade.go | 87 ++++++++++++++++++++++++++--- pkg/skills/skillsvc/upgrade_test.go | 5 +- 2 files changed, 82 insertions(+), 10 deletions(-) diff --git a/pkg/skills/skillsvc/upgrade.go b/pkg/skills/skillsvc/upgrade.go index 454e1b14f9..b417feb841 100644 --- a/pkg/skills/skillsvc/upgrade.go +++ b/pkg/skills/skillsvc/upgrade.go @@ -15,6 +15,7 @@ import ( "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 @@ -93,14 +94,15 @@ func (s *service) upgradeEntry( } installOpts := skills.InstallOptions{ - Name: entry.Source, - LockSource: entry.Source, - Scope: skills.ScopeProject, - ProjectRoot: projectRoot, - Clients: opts.Clients, - Force: true, - Managed: true, - ExplicitLock: entry.Explicit, + 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 { @@ -127,6 +129,10 @@ func (s *service) upgradeEntry( } } + if blocked := upgradeSignerGuard(entry, result, opts.AllowSignerChange); blocked != nil { + return *blocked + } + contentDigest := result.ContentDigest if contentDigest == "" { cd, cdErr := s.contentDigestForSkill(ctx, installOpts, result.Skill) @@ -148,6 +154,8 @@ func (s *service) upgradeEntry( 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{ @@ -168,6 +176,32 @@ func (s *service) upgradeEntry( } } +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 { @@ -189,11 +223,48 @@ func (s *service) upgradeEntryPreview(ctx context.Context, entry lockfile.Entry) 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 { diff --git a/pkg/skills/skillsvc/upgrade_test.go b/pkg/skills/skillsvc/upgrade_test.go index 657a3e9ac5..207e61165b 100644 --- a/pkg/skills/skillsvc/upgrade_test.go +++ b/pkg/skills/skillsvc/upgrade_test.go @@ -36,6 +36,7 @@ func TestUpgradeInstallsNewDigestAndRewritesLockEntry(t *testing.T) { 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) @@ -57,7 +58,7 @@ func TestUpgradeInstallsNewDigestAndRewritesLockEntry(t *testing.T) { targetDir := filepath.Join(tempDir(t), "installed", "my-skill") pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeProject, projectRoot).Return(targetDir, nil) - svc := New(store, WithPathResolver(pr), WithRegistryClient(reg), WithOCIStore(ociStore)) + svc := New(store, withTestVerifier(), WithPathResolver(pr), WithRegistryClient(reg), WithOCIStore(ociStore)) lockSvc := svc.(skills.SkillLockService) result, err := lockSvc.Upgrade(t.Context(), skills.UpgradeOptions{ ProjectRoot: projectRoot, @@ -103,7 +104,7 @@ func TestUpgradeDryRunReportsWithoutInstallingOrRewritingLock(t *testing.T) { // installed state. store := storemocks.NewMockSkillStore(ctrl) - svc := New(store, WithRegistryClient(reg), WithOCIStore(ociStore)) + 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) From 382ab01d23b1e79f73a8bf345a9c7c39cf3ea776 Mon Sep 17 00:00:00 2001 From: Samuele Verzi Date: Wed, 8 Jul 2026 16:24:27 +0200 Subject: [PATCH 17/20] Sign OCI artifacts after skill push Co-authored-by: Cursor --- pkg/skills/skillsvc/build.go | 9 +++++++++ pkg/skills/skillsvc/build_test.go | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) 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) From 9d588668e0302515ddbe2a9c720ab7ad24ea0d58 Mon Sep 17 00:00:00 2001 From: Samuele Verzi Date: Wed, 8 Jul 2026 16:24:27 +0200 Subject: [PATCH 18/20] Expose Sigstore install and push flags in API and CLI Co-authored-by: Cursor --- cmd/thv/app/skill_install.go | 26 +++++++++++++++----------- cmd/thv/app/skill_push.go | 11 ++++++++++- cmd/thv/app/skill_upgrade.go | 32 ++++++++++++++++++-------------- docs/cli/thv_skill_install.md | 1 + docs/cli/thv_skill_push.md | 4 +++- docs/cli/thv_skill_upgrade.md | 1 + docs/server/docs.go | 24 ++++++++++++++++++++++++ docs/server/swagger.json | 24 ++++++++++++++++++++++++ docs/server/swagger.yaml | 21 +++++++++++++++++++++ pkg/api/v1/skills.go | 34 +++++++++++++++++++--------------- pkg/api/v1/skills_types.go | 8 ++++++++ pkg/skills/client/dto.go | 34 +++++++++++++++++++--------------- 12 files changed, 163 insertions(+), 57 deletions(-) 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_upgrade.go b/cmd/thv/app/skill_upgrade.go index 4d662ca247..27451b3b36 100644 --- a/cmd/thv/app/skill_upgrade.go +++ b/cmd/thv/app/skill_upgrade.go @@ -15,13 +15,14 @@ import ( ) var ( - skillUpgradeProjectRoot string - skillUpgradeClientsRaw string - skillUpgradePreview bool - skillUpgradeFailOnChanges bool - skillUpgradeAllowRefChange bool - skillUpgradeYes bool - skillUpgradeFormat string + skillUpgradeProjectRoot string + skillUpgradeClientsRaw string + skillUpgradePreview bool + skillUpgradeFailOnChanges bool + skillUpgradeAllowRefChange bool + skillUpgradeAllowSignerChange bool + skillUpgradeYes bool + skillUpgradeFormat string ) var skillUpgradeCmd = &cobra.Command{ @@ -54,6 +55,8 @@ func init() { "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", "", @@ -69,12 +72,13 @@ func skillUpgradeCmdFunc(cmd *cobra.Command, args []string) error { c := newSkillClient(cmd.Context()) opts := skills.UpgradeOptions{ - ProjectRoot: projectRoot, - Names: args, - Preview: skillUpgradePreview, - FailOnChanges: skillUpgradeFailOnChanges, - AllowRefChange: skillUpgradeAllowRefChange, - Clients: parseSkillInstallClients(skillUpgradeClientsRaw), + ProjectRoot: projectRoot, + Names: args, + Preview: skillUpgradePreview, + FailOnChanges: skillUpgradeFailOnChanges, + AllowRefChange: skillUpgradeAllowRefChange, + AllowSignerChange: skillUpgradeAllowSignerChange, + Clients: parseSkillInstallClients(skillUpgradeClientsRaw), } if skillUpgradePreview { @@ -128,7 +132,7 @@ func upgradeExitCode(result *skills.UpgradeResult) int { switch o.Status { case skills.UpgradeStatusFailed: return ExitCodePartialFailure - case skills.UpgradeStatusRefChangeBlocked: + case skills.UpgradeStatusRefChangeBlocked, skills.UpgradeStatusSignerChangeBlocked: return ExitCodePolicyRejection case skills.UpgradeStatusUpgraded, skills.UpgradeStatusUpToDate, skills.UpgradeStatusNotUpgradable: // continue 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_upgrade.md b/docs/cli/thv_skill_upgrade.md index da89b28ae8..d2da4f7f6e 100644 --- a/docs/cli/thv_skill_upgrade.md +++ b/docs/cli/thv_skill_upgrade.md @@ -36,6 +36,7 @@ thv skill upgrade [skill-name...] [flags] ``` --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") diff --git a/docs/server/docs.go b/docs/server/docs.go index 651e31cd13..9d857d9645 100644 --- a/docs/server/docs.go +++ b/docs/server/docs.go @@ -1624,6 +1624,9 @@ const docTemplate = `{ "lock-write-failed", "ref-change-blocked", "content-mismatch", + "signature-invalid", + "signer-mismatch", + "unsigned-rejected", "unknown" ], "type": "string", @@ -1634,6 +1637,9 @@ const docTemplate = `{ "FailureReasonLockWriteFailed", "FailureReasonRefChangeBlocked", "FailureReasonContentMismatch", + "FailureReasonSignatureInvalid", + "FailureReasonSignerMismatch", + "FailureReasonUnsignedRejected", "FailureReasonUnknown" ] }, @@ -1966,6 +1972,7 @@ const docTemplate = `{ "up-to-date", "not-upgradable", "ref-change-blocked", + "signer-change-blocked", "failed" ], "type": "string", @@ -1974,6 +1981,7 @@ const docTemplate = `{ "UpgradeStatusUpToDate", "UpgradeStatusNotUpgradable", "UpgradeStatusRefChangeBlocked", + "UpgradeStatusSignerChangeBlocked", "UpgradeStatusFailed" ] }, @@ -3182,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": { @@ -3346,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" @@ -3807,6 +3827,10 @@ const docTemplate = `{ "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": { diff --git a/docs/server/swagger.json b/docs/server/swagger.json index ee0e5fc73b..e37cafca5c 100644 --- a/docs/server/swagger.json +++ b/docs/server/swagger.json @@ -1617,6 +1617,9 @@ "lock-write-failed", "ref-change-blocked", "content-mismatch", + "signature-invalid", + "signer-mismatch", + "unsigned-rejected", "unknown" ], "type": "string", @@ -1627,6 +1630,9 @@ "FailureReasonLockWriteFailed", "FailureReasonRefChangeBlocked", "FailureReasonContentMismatch", + "FailureReasonSignatureInvalid", + "FailureReasonSignerMismatch", + "FailureReasonUnsignedRejected", "FailureReasonUnknown" ] }, @@ -1959,6 +1965,7 @@ "up-to-date", "not-upgradable", "ref-change-blocked", + "signer-change-blocked", "failed" ], "type": "string", @@ -1967,6 +1974,7 @@ "UpgradeStatusUpToDate", "UpgradeStatusNotUpgradable", "UpgradeStatusRefChangeBlocked", + "UpgradeStatusSignerChangeBlocked", "UpgradeStatusFailed" ] }, @@ -3175,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": { @@ -3339,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" @@ -3800,6 +3820,10 @@ "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": { diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml index 7e7429f892..c7ff1d4497 100644 --- a/docs/server/swagger.yaml +++ b/docs/server/swagger.yaml @@ -1645,6 +1645,9 @@ components: - lock-write-failed - ref-change-blocked - content-mismatch + - signature-invalid + - signer-mismatch + - unsigned-rejected - unknown type: string x-enum-varnames: @@ -1654,6 +1657,9 @@ components: - FailureReasonLockWriteFailed - FailureReasonRefChangeBlocked - FailureReasonContentMismatch + - FailureReasonSignatureInvalid + - FailureReasonSignerMismatch + - FailureReasonUnsignedRejected - FailureReasonUnknown github_com_stacklok_toolhive_pkg_skills.InstallStatus: description: Status is the current installation status. @@ -1914,6 +1920,7 @@ components: - up-to-date - not-upgradable - ref-change-blocked + - signer-change-blocked - failed type: string x-enum-varnames: @@ -1921,6 +1928,7 @@ components: - UpgradeStatusUpToDate - UpgradeStatusNotUpgradable - UpgradeStatusRefChangeBlocked + - UpgradeStatusSignerChangeBlocked - UpgradeStatusFailed github_com_stacklok_toolhive_pkg_skills.ValidationResult: properties: @@ -2925,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"), @@ -3047,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 @@ -3413,6 +3431,9 @@ components: 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 diff --git a/pkg/api/v1/skills.go b/pkg/api/v1/skills.go index fad2cc819b..152076fed1 100644 --- a/pkg/api/v1/skills.go +++ b/pkg/api/v1/skills.go @@ -107,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 @@ -282,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 } @@ -431,13 +434,14 @@ func (s *SkillsRoutes) upgradeSkills(w http.ResponseWriter, r *http.Request) err } 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, - Clients: req.Clients, + 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 diff --git a/pkg/api/v1/skills_types.go b/pkg/api/v1/skills_types.go index 1ef4de503d..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. @@ -109,6 +115,8 @@ type upgradeSkillsRequest struct { 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/skills/client/dto.go b/pkg/skills/client/dto.go index b38e3af222..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 { @@ -51,11 +54,12 @@ type syncRequest struct { } 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"` - Clients []string `json:"clients,omitempty"` + 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"` } From 1f1db152315c2309418c0424f6f988694e731fe4 Mon Sep 17 00:00:00 2001 From: Samuele Verzi Date: Wed, 8 Jul 2026 16:24:27 +0200 Subject: [PATCH 19/20] Document skills lock Sigstore trust model and update tests Co-authored-by: Cursor --- docs/arch/12-skills-system.md | 28 +++++++++++++++++++--------- pkg/skills/skillsvc/lock_test.go | 30 ++++++++++++++++-------------- 2 files changed, 35 insertions(+), 23 deletions(-) diff --git a/docs/arch/12-skills-system.md b/docs/arch/12-skills-system.md index 6bb64f348b..fecb829934 100644 --- a/docs/arch/12-skills-system.md +++ b/docs/arch/12-skills-system.md @@ -267,26 +267,36 @@ Removes the skill files from the filesystem, deletes the database record, and re 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, a committed lock entry is an **assertion** verified against itself at install/sync time; PR review of the lock diff is the trust root until external attestation lands in a follow-on milestone. +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 # what the user/registry resolver originally typed + source: code-review resolvedReference: ghcr.io/org/code-review:1.0.0 digest: sha256:9f2b1e... - contentDigest: sha256:a1b2c3d4... # deterministic dirhash of materialized files - - name: testing-conventions - source: ghcr.io/org/testing-conventions:1.0.0 - resolvedReference: ghcr.io/org/testing-conventions:1.0.0 + 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:... - requiredBy: [code-review] # transitive dependency provenance - explicit: true # user-requested install; exempt from cascade removal + 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. @@ -315,7 +325,7 @@ thv skill upgrade --fail-on-changes # CI freshness gate: exit non-zero if 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. +`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) diff --git a/pkg/skills/skillsvc/lock_test.go b/pkg/skills/skillsvc/lock_test.go index 5677e52d59..ce1f1aca71 100644 --- a/pkg/skills/skillsvc/lock_test.go +++ b/pkg/skills/skillsvc/lock_test.go @@ -37,15 +37,16 @@ func TestInstallRecordsProjectScopeLockEntry(t *testing.T) { Return(skills.InstalledSkill{}, storage.ErrNotFound) store.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil) - svc := New(store, WithPathResolver(pr)) + 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, + 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) @@ -148,13 +149,14 @@ func TestInstallInternalDoesNotTouchLockFile(t *testing.T) { Return(skills.InstalledSkill{}, storage.ErrNotFound) store.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil) - svc := New(store, WithPathResolver(pr)).(*service) + 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, + Name: "my-skill", + LayerData: layerData, + Digest: "sha256:abcdef0123456789", + Scope: skills.ScopeProject, + ProjectRoot: projectRoot, + AllowUnsigned: true, }) require.NoError(t, err) From 18d7dd152ade09b1fd1df5240427d799c9a20d91 Mon Sep 17 00:00:00 2001 From: Samuele Verzi Date: Wed, 8 Jul 2026 17:21:37 +0200 Subject: [PATCH 20/20] Skip signing in registry-lookup install E2E push CI has no cosign key or keyless OIDC configured, so the push-then- install E2E test failed once push started signing by default. Co-authored-by: Cursor --- test/e2e/api_skills_test.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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