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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 15 additions & 11 deletions cmd/thv/app/skill_install.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand All @@ -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,
"Allow installing a project-scoped skill without a verified signature (recorded in the lock file)")
Comment thread
samuv marked this conversation as resolved.
}

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)
Expand Down
1 change: 1 addition & 0 deletions docs/cli/thv_skill_install.md

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

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

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

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

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

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

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

15 changes: 8 additions & 7 deletions pkg/api/v1/skills.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,13 +113,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
Expand Down
4 changes: 4 additions & 0 deletions pkg/api/v1/skills_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ type installSkillRequest struct {
Clients []string `json:"clients,omitempty"`
// Force allows overwriting unmanaged skill directories
Force bool `json:"force,omitempty"`
// AllowUnsigned permits installing a project-scoped skill without a
// verified signature; the exception is recorded in the project's lock
// file.
AllowUnsigned bool `json:"allow_unsigned,omitempty"`
// Group is the group name to add the skill to after installation
Group string `json:"group,omitempty"`
}
Expand Down
15 changes: 8 additions & 7 deletions pkg/skills/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,13 +164,14 @@ func (c *Client) List(ctx context.Context, opts skills.ListOptions) ([]skills.In
// Install installs a skill from a remote source.
func (c *Client) Install(ctx context.Context, opts skills.InstallOptions) (*skills.InstallResult, error) {
body := installRequest{
Name: opts.Name,
Version: opts.Version,
Scope: opts.Scope,
ProjectRoot: opts.ProjectRoot,
Clients: opts.Clients,
Force: opts.Force,
Group: opts.Group,
Name: opts.Name,
Version: opts.Version,
Scope: opts.Scope,
ProjectRoot: opts.ProjectRoot,
Clients: opts.Clients,
Force: opts.Force,
Group: opts.Group,
AllowUnsigned: opts.AllowUnsigned,
}

var resp installResponse
Expand Down
26 changes: 26 additions & 0 deletions pkg/skills/client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -740,3 +740,29 @@ type failReader struct{}
func (*failReader) Read([]byte) (int, error) {
return 0, errors.New("simulated read error")
}

// TestInstallCarriesAllowUnsigned round-trips the unsigned exception through
// the client's request body — without this, the CLI flag silently never
// reaches the server (every --allow-unsigned install would 403 telling the
// user to pass the flag they passed).
func TestInstallCarriesAllowUnsigned(t *testing.T) {
t.Parallel()

var got installRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.NoError(t, json.NewDecoder(r.Body).Decode(&got))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(installResponse{})
}))
t.Cleanup(srv.Close)

_, err := newTestClient(t, srv).Install(t.Context(), skills.InstallOptions{
Name: "my-skill",
Scope: skills.ScopeProject,
ProjectRoot: "/tmp/project",
AllowUnsigned: true,
})
require.NoError(t, err)
assert.True(t, got.AllowUnsigned, "allow_unsigned must reach the server")
}
3 changes: 3 additions & 0 deletions pkg/skills/client/dto.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ type installRequest struct {
Clients []string `json:"clients,omitempty"`
Force bool `json:"force,omitempty"`
Group string `json:"group,omitempty"`
// AllowUnsigned mirrors skills.InstallOptions.AllowUnsigned; without it
// here the CLI flag would silently never reach the server.
AllowUnsigned bool `json:"allow_unsigned,omitempty"`
}

type validateRequest struct {
Expand Down
16 changes: 15 additions & 1 deletion pkg/skills/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ type InstallOptions struct {
// normal "same digest means content is already correct" fast path must
// not apply. Internal use only — NOT exposed via HTTP API.
SyncRestore bool `json:"-"`
// Unsigned records the trust decision that this install proceeded
// without a verified signature (via AllowUnsigned). Set internally by
// install-time verification; recorded as `unsigned: true` in the lock
// entry.
Unsigned bool `json:"-"`
// Provenance carries the verified signer identity established during
// install-time verification, for recording into the lock entry. Set by
// the verification step, nil when the artifact is unsigned or
Expand Down Expand Up @@ -240,7 +245,16 @@ const (
FailureReasonDigestMissing FailureReason = "digest-missing"
FailureReasonValidationRejected FailureReason = "validation-rejected"
FailureReasonLockWriteFailed FailureReason = "lock-write-failed"
FailureReasonUnknown FailureReason = "unknown"
// FailureReasonSignatureInvalid means the artifact carries signature
// material that failed cryptographic verification.
FailureReasonSignatureInvalid FailureReason = "signature-invalid"
// FailureReasonSignerMismatch means the artifact verifies, but against
// an identity other than the one recorded in the lock file.
FailureReasonSignerMismatch FailureReason = "signer-mismatch"
// FailureReasonUnsignedRejected means the artifact is unsigned and the
// operation did not permit unsigned installs.
FailureReasonUnsignedRejected FailureReason = "unsigned-rejected"
FailureReasonUnknown FailureReason = "unknown"
)

// SyncFailure describes a single skill that failed to sync.
Expand Down
19 changes: 15 additions & 4 deletions pkg/skills/skillsvc/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,14 @@ func (s *service) Install(ctx context.Context, opts skills.InstallOptions) (*ski
// re-resolution uses, so the two cannot drift.
return dispatchSource(ctx, s, opts.Name, sourceOps[*skills.InstallResult]{
git: func(ctx context.Context, _ string) (*skills.InstallResult, error) {
result, err := s.installFromGit(ctx, opts, scope)
result, err := s.installFromGit(ctx, &opts, scope)
if err != nil {
return nil, err
}
return s.installAndRegister(ctx, opts, originalName, result, opts.Group, result.Skill.Metadata.Name, scope)
},
oci: func(ctx context.Context, ref nameref.Reference) (*skills.InstallResult, error) {
result, err := s.installFromOCI(ctx, opts, scope, ref)
result, err := s.installFromOCI(ctx, &opts, scope, ref)
if err != nil {
slog.Debug("OCI pull failed, registry fallback may apply", "name", opts.Name, "error", err)
return nil, err
Expand Down Expand Up @@ -124,6 +124,17 @@ func (s *service) installByName(
// resolved: opts hydrated, fall through to installWithExtraction
}

// Local-store artifacts and raw layer data carry no registry signature
// to verify — installing them project-scoped is an unsigned trust
// decision that must be explicit.
if shouldVerifyInstall(opts, scope) {
decision, verifyErr := verifyLocalInstall(opts, opts.Name)
if verifyErr != nil {
return nil, verifyErr
}
applyDecisionToOpts(&opts, decision)
}

result, err := s.installWithExtraction(ctx, opts, scope)
if err != nil {
return nil, err
Expand Down Expand Up @@ -168,7 +179,7 @@ func (s *service) installFromResolvedRegistry(
case resolved.OCIRef != nil:
slog.Info("resolved skill from registry (OCI)", "name", opts.Name, "oci_reference", resolved.OCIRef.String())
opts.Name = resolved.OCIRef.String()
result, ociErr := s.installFromOCI(ctx, opts, scope, resolved.OCIRef)
result, ociErr := s.installFromOCI(ctx, &opts, scope, resolved.OCIRef)
if ociErr != nil {
return nil, ociErr
}
Expand All @@ -179,7 +190,7 @@ func (s *service) installFromResolvedRegistry(
case resolved.GitURL != "":
slog.Info("resolved skill from registry (git)", "name", opts.Name, "git_url", resolved.GitURL)
opts.Name = resolved.GitURL
result, gitErr := s.installFromGit(ctx, opts, scope)
result, gitErr := s.installFromGit(ctx, &opts, scope)
if gitErr != nil {
return nil, gitErr
}
Expand Down
15 changes: 8 additions & 7 deletions pkg/skills/skillsvc/install_extraction.go
Original file line number Diff line number Diff line change
Expand Up @@ -220,12 +220,13 @@ func buildInstalledSkill(
Name: opts.Name,
Version: opts.Version,
},
Scope: scope,
ProjectRoot: opts.ProjectRoot,
Reference: opts.Reference,
Digest: opts.Digest,
Status: skills.InstallStatusInstalled,
InstalledAt: time.Now().UTC(),
Clients: clients,
Scope: scope,
ProjectRoot: opts.ProjectRoot,
Reference: opts.Reference,
Digest: opts.Digest,
Status: skills.InstallStatusInstalled,
InstalledAt: time.Now().UTC(),
Clients: clients,
SigstoreBundle: opts.SigstoreBundle,
}
}
20 changes: 17 additions & 3 deletions pkg/skills/skillsvc/install_git.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import (
// same-commit no-op and upgrade detection.
func (s *service) installFromGit(
ctx context.Context,
opts skills.InstallOptions,
opts *skills.InstallOptions,
scope skills.Scope,
) (*skills.InstallResult, error) {
if s.gitResolver == nil {
Expand Down Expand Up @@ -70,19 +70,33 @@ func (s *service) installFromGit(
opts.Name = resolved.SkillConfig.Name
opts.Reference = gitURL
opts.Digest = resolved.CommitHash

if opts.Version == "" && resolved.SkillConfig.Version != "" {
opts.Version = resolved.SkillConfig.Version
}

unlock := s.locks.lock(opts.Name, scope, opts.ProjectRoot)
defer unlock()

clientTypes, clientDirs, err := s.resolveAndValidateClients(opts, opts.Name, scope, opts.ProjectRoot)
// Verify the commit signature before anything is written or recorded.
// This runs under the per-skill lock so concurrent first installs
// cannot both read an absent lock entry and race their TOFU anchors.
if shouldVerifyInstall(*opts, scope) {
decision, verifyErr := s.verifyGitInstall(
ctx, *opts, resolved.SkillConfig.Name, resolved.CommitPayload, resolved.CommitSignature,
)
if verifyErr != nil {
return nil, verifyErr
}
applyDecisionToOpts(opts, decision)
}

clientTypes, clientDirs, err := s.resolveAndValidateClients(*opts, opts.Name, scope, opts.ProjectRoot)
if err != nil {
return nil, err
}

return s.applyGitInstall(ctx, opts, scope, clientTypes, clientDirs, resolved.Files)
return s.applyGitInstall(ctx, *opts, scope, clientTypes, clientDirs, resolved.Files)
}

// applyGitInstall handles the create/upgrade/no-op logic for a git-based skill
Expand Down
Loading
Loading