Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions cmd/thv/app/skill_push.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -19,13 +24,19 @@ var skillPushCmd = &cobra.Command{

func init() {
skillCmd.AddCommand(skillPushCmd)
skillPushCmd.Flags().StringVar(&skillPushKey, "key", "",
"Path to a cosign private key to sign the pushed artifact (COSIGN_PASSWORD decrypts encrypted keys)")
skillPushCmd.Flags().BoolVar(&skillPushNoSign, "no-sign", false,
"Push without signing (consumers will need an explicit unsigned exception to install project-scoped)")
}

func skillPushCmdFunc(cmd *cobra.Command, args []string) error {
c := newSkillClient(cmd.Context())

err := c.Push(cmd.Context(), skills.PushOptions{
Reference: args[0],
Key: skillPushKey,
NoSign: skillPushNoSign,
})
if err != nil {
return formatSkillError("push skill", err)
Expand Down
5 changes: 1 addition & 4 deletions cmd/thv/app/skill_sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,9 @@ var (

var skillSyncCmd = &cobra.Command{
Use: "sync",
Short: "Restore project skills to match the lock file (experimental)",
Short: "Restore project skills to match the lock file",
Long: `Restore a project's installed skills to match toolhive.lock.yaml.

Experimental: requires TOOLHIVE_SKILLS_LOCK_ENABLED=true on the ToolHive
server while the lock file feature rolls out.

Missing or drifted skills are reinstalled at their pinned digest. Use
--check to report drift without installing anything (suitable for CI).
Use --adopt to record lock entries for existing unmanaged installs, and
Expand Down
5 changes: 1 addition & 4 deletions cmd/thv/app/skill_upgrade.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,9 @@ var (

var skillUpgradeCmd = &cobra.Command{
Use: "upgrade [skill-name...]",
Short: "Upgrade project skills to newer pinned content (experimental)",
Short: "Upgrade project skills to newer pinned content",
Long: `Re-resolve a project's lock entries and install newer content where available.

Experimental: requires TOOLHIVE_SKILLS_LOCK_ENABLED=true on the ToolHive
server while the lock file feature rolls out.

Skills pinned to an immutable reference (an OCI digest or a full git commit
hash) are reported not-upgradable — there is nothing newer to resolve to.
Use --preview to see what would change without persisting anything (OCI
Expand Down
10 changes: 7 additions & 3 deletions docs/arch/12-skills-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -329,11 +329,15 @@ oci_tags table (reserved; not currently populated)

RFC [THV-0080](https://github.com/stacklok/toolhive-rfcs/blob/main/rfcs/THV-0080-skills-lock-file.md) adds a project-level `toolhive.lock.yaml`, committed at the project root, that pins the exact content of every project-scoped skill install — the same guarantee `package-lock.json`, `Cargo.lock`, and `go.sum` provide elsewhere. Two teammates (or a CI runner) cloning the same repo restore identical skill content via `thv skill sync`, rather than whatever the source currently resolves to.

**Trust model, stated plainly:** until the RFC's Sigstore signing/verification stack lands, the lock file provides *reproducibility and drift detection over a repository-editable file* — not verified integrity. A committed digest is an unauthenticated trust root: anyone who can change `toolhive.lock.yaml` in the repository controls what `sync` installs. Reviewing lock-file diffs (especially `digest` and `resolvedReference` changes) carries the same weight as reviewing the AI-executed skill content itself. Signature verification on consume is what upgrades this from drift detection to integrity, and is why the whole feature ships gated until that half exists.
**Trust model, stated plainly:** project-scoped installs are verified against Sigstore signatures, and the lock file records the trust decisions those verifications produce. On first install of a signed skill the observed signer identity is recorded (trust on first use) as the entry's `provenance:` block and **displayed to the user**; every later install, sync, and upgrade enforces that identity *inside* the Sigstore verification policy — OCI artifacts through their attached signature bundles, git commits through gitsign signature-and-chain verification (currently `provisional: true`: the transparency-log proof of signing time is not yet validated, so the replay window is unbounded until that lands). Sync additionally re-verifies each entry's stored signature bundle offline (embedded trust root, no network) before counting it current, and upgrade refuses to move to an artifact signed by a different identity — or unsigned — without an explicit `--allow-signer-change`.

### Rollout
What is still trusted on faith, deliberately and visibly:

The feature is gated behind the `TOOLHIVE_SKILLS_LOCK_ENABLED` environment variable (`skills.LockFileFeatureEnabled()`) while it lands across a stack of PRs, following the existing `TOOLHIVE_DEV` precedent for staged rollouts (`pkg/skills/gitresolver/reference.go`). With the flag unset, project-scope installs behave exactly as they did before this RFC — no lock file is written, no `toolhive.requires` materialization happens, `sync`/`upgrade` refuse with a clear "experimental" error.
- **Unsigned skills** install only with an explicit `--allow-unsigned`, recorded as `unsigned: true` in the lock entry. That entry is a standing exception: lock-driven operations (sync restores, upgrade re-pins) honor it without re-asking.
- **The lock file itself** remains a repository-editable policy document. A diff converting a `provenance:` block to `unsigned: true` is a trust downgrade that sync will honor — it cannot happen without a lock file edit, which is exactly what lock-file review must catch. Reviewing `provenance`, `unsigned`, `digest`, and `resolvedReference` changes carries the same weight as reviewing the AI-executed skill content itself.
- **First use** anchors trust to whatever identity signed the artifact at that moment; verify the printed identity is the publisher you expect.

Publishing is signed by default: `thv skill push` requires either `--key` (a cosign private key; the signature manifest is attached next to the artifact and the bundle is retrievable at install) or an explicit `--no-sign`.

### Schema

Expand Down
4 changes: 2 additions & 2 deletions docs/cli/thv_skill.md

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

4 changes: 3 additions & 1 deletion docs/cli/thv_skill_push.md

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

5 changes: 1 addition & 4 deletions docs/cli/thv_skill_sync.md

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

5 changes: 1 addition & 4 deletions docs/cli/thv_skill_upgrade.md

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

8 changes: 8 additions & 0 deletions docs/server/docs.go

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

8 changes: 8 additions & 0 deletions docs/server/swagger.json

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

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

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

2 changes: 2 additions & 0 deletions pkg/api/v1/skills.go
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,8 @@ func (s *SkillsRoutes) pushSkill(w http.ResponseWriter, r *http.Request) error {

if err := s.skillService.Push(r.Context(), skills.PushOptions{
Reference: req.Reference,
Key: req.Key,
NoSign: req.NoSign,
}); err != nil {
return err
}
Expand Down
5 changes: 5 additions & 0 deletions pkg/api/v1/skills_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ type buildSkillRequest struct {
type pushSkillRequest struct {
// OCI reference to push
Reference string `json:"reference"`
// Key is the path to a cosign private key used to sign the pushed
// artifact
Key string `json:"key,omitempty"`
// NoSign pushes without signing
NoSign bool `json:"no_sign,omitempty"`
}

// syncSkillsRequest represents the request to sync a project's skills.
Expand Down
2 changes: 1 addition & 1 deletion pkg/skills/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ func (c *Client) Build(ctx context.Context, opts skills.BuildOptions) (*skills.B

// Push pushes a built skill artifact to a remote registry.
func (c *Client) Push(ctx context.Context, opts skills.PushOptions) error {
body := pushRequest{Reference: opts.Reference}
body := pushRequest{Reference: opts.Reference, Key: opts.Key, NoSign: opts.NoSign}
return c.doJSONRequest(ctx, http.MethodPost, "/push", nil, body, nil)
}

Expand Down
2 changes: 2 additions & 0 deletions pkg/skills/client/dto.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ type buildRequest struct {

type pushRequest struct {
Reference string `json:"reference"`
Key string `json:"key,omitempty"`
NoSign bool `json:"no_sign,omitempty"`
}

type listResponse struct {
Expand Down
27 changes: 0 additions & 27 deletions pkg/skills/feature_gate.go

This file was deleted.

33 changes: 0 additions & 33 deletions pkg/skills/feature_gate_test.go

This file was deleted.

7 changes: 7 additions & 0 deletions pkg/skills/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,13 @@ type BuildResult struct {
type PushOptions struct {
// Reference is the OCI reference to push.
Reference string `json:"reference"`
// Key is the path to a cosign PEM private key used to sign the pushed
// artifact (COSIGN_PASSWORD decrypts encrypted keys). Empty with
// NoSign false is an error: unsigned pushes must be explicit.
Key string `json:"key,omitempty"`
// NoSign pushes without signing. Consumers installing the artifact
// project-scoped will need an explicit unsigned exception.
NoSign bool `json:"no_sign,omitempty"`
}

// SyncOptions configures the behavior of the Sync operation.
Expand Down
57 changes: 57 additions & 0 deletions pkg/skills/signer/mocks/mock_signer.go

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

2 changes: 2 additions & 0 deletions pkg/skills/signer/signer.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import (
"github.com/sigstore/sigstore-go/pkg/sign"
)

//go:generate mockgen -destination=mocks/mock_signer.go -package=mocks -source=signer.go Signer

// ErrKeyRequired indicates no signing key was provided. Keyless (OIDC)
// signing is not implemented yet, so a cosign private key is the only
// supported signing method.
Expand Down
Loading
Loading