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
10 changes: 10 additions & 0 deletions cmd/thv/app/skill_info.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,16 @@ func printSkillInfoText(info *skills.SkillInfo) {

_, _ = fmt.Fprintf(w, "Name:\t%s\n", info.Metadata.Name)
_, _ = fmt.Fprintf(w, "Version:\t%s\n", info.Metadata.Version)
switch {
case info.Provenance != nil && info.Provenance.Provisional:
_, _ = fmt.Fprintf(w, "Signed by:\t%s (provisional)\n", info.Provenance.SignerIdentity)
_, _ = fmt.Fprintf(w, "Cert issuer:\t%s\n", info.Provenance.CertIssuer)
case info.Provenance != nil:
_, _ = fmt.Fprintf(w, "Signed by:\t%s\n", info.Provenance.SignerIdentity)
_, _ = fmt.Fprintf(w, "Cert issuer:\t%s\n", info.Provenance.CertIssuer)
case info.Unsigned:
_, _ = fmt.Fprintf(w, "Signed by:\t(unsigned — explicit exception)\n")
}
_, _ = fmt.Fprintf(w, "Description:\t%s\n", info.Metadata.Description)

if s := info.InstalledSkill; s != nil {
Expand Down
25 changes: 24 additions & 1 deletion cmd/thv/app/skill_install.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package app

import (
"fmt"
"strings"

"github.com/spf13/cobra"
Expand Down Expand Up @@ -50,7 +51,7 @@ func init() {
func skillInstallCmdFunc(cmd *cobra.Command, args []string) error {
c := newSkillClient(cmd.Context())

_, err := c.Install(cmd.Context(), skills.InstallOptions{
result, err := c.Install(cmd.Context(), skills.InstallOptions{
Name: args[0],
Scope: skills.Scope(skillInstallScope),
Clients: parseSkillInstallClients(skillInstallClientsRaw),
Expand All @@ -63,9 +64,31 @@ func skillInstallCmdFunc(cmd *cobra.Command, args []string) error {
return formatSkillError("install skill", err)
}

printInstallTrust(result)
return nil
}

// printInstallTrust shows the trust state the install recorded — RFC
// THV-0080 wants the pinned identity displayed prominently, not discovered
// weeks later inside a signer-mismatch error.
func printInstallTrust(result *skills.InstallResult) {
if result == nil {
return
}
name := result.Skill.Metadata.Name
switch {
case result.Provenance != nil && result.Provenance.Provisional:
fmt.Printf("Installed %s (signed by %s; verification provisional — see lock file)\n",
name, result.Provenance.SignerIdentity)
case result.Provenance != nil:
fmt.Printf("Installed %s (signed by %s)\n", name, result.Provenance.SignerIdentity)
case result.Unsigned:
fmt.Printf("Installed %s (unsigned — recorded as an explicit exception in the lock file)\n", name)
default:
fmt.Printf("Installed %s\n", name)
}
}

// parseSkillInstallClients splits a comma-separated --clients flag value.
// Empty input yields nil so the server applies its default client.
func parseSkillInstallClients(raw string) []string {
Expand Down
33 changes: 33 additions & 0 deletions docs/server/docs.go

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

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

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

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

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

23 changes: 19 additions & 4 deletions pkg/skills/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,14 +102,17 @@ type InstallOptions struct {
type ProvenanceInfo struct {
// SignerIdentity is the certificate subject identity (workflow path for
// GitHub Actions certificates, SAN verbatim otherwise).
SignerIdentity string `json:"-"`
SignerIdentity string `json:"signer_identity"`
// CertIssuer is the OIDC issuer that authenticated the signer.
CertIssuer string `json:"-"`
CertIssuer string `json:"cert_issuer"`
// RepositoryURI is the source repository from the certificate
// extensions, when present.
RepositoryURI string `json:"-"`
RepositoryURI string `json:"repository_uri,omitempty"`
// SigstoreURL is the Sigstore instance the signature chains to.
SigstoreURL string `json:"-"`
SigstoreURL string `json:"sigstore_url,omitempty"`
// Provisional marks provenance with a documented verification gap
// (git signatures until transparency-log validation lands).
Provisional bool `json:"provisional,omitempty"`
}

// InstallResult contains the outcome of an Install operation.
Expand All @@ -121,6 +124,12 @@ type InstallResult struct {
// previous state instead of destructively deleting a record this call
// did not create. Internal use only — NOT exposed via HTTP API.
PreExisting *InstalledSkill `json:"-"`
// Provenance is the verified signer identity this install recorded —
// surfaced so callers can display what trust-on-first-use pinned.
Provenance *ProvenanceInfo `json:"provenance,omitempty"`
// Unsigned reports that the install was recorded as an explicit
// unsigned exception.
Unsigned bool `json:"unsigned,omitempty"`
}

// UninstallOptions configures the behavior of the Uninstall operation.
Expand Down Expand Up @@ -155,6 +164,12 @@ type SkillInfo struct {
Metadata SkillMetadata `json:"metadata"`
// InstalledSkill contains the full installation record.
InstalledSkill *InstalledSkill `json:"installed_skill,omitempty"`
// Provenance is the signer identity the project's lock file records
// for this skill, when project-scoped and lock-managed.
Provenance *ProvenanceInfo `json:"provenance,omitempty"`
// Unsigned reports that the lock file records an explicit unsigned
// exception for this skill.
Unsigned bool `json:"unsigned,omitempty"`
}

// ContentOptions configures the behavior of the GetContent operation.
Expand Down
4 changes: 4 additions & 0 deletions pkg/skills/skillsvc/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,10 @@ func (s *service) installAndRegister(
scope skills.Scope,
) (*skills.InstallResult, error) {
lockScoped := scope == skills.ScopeProject && skills.LockFileFeatureEnabled()
// Surface the verification decision on the result so callers can show
// what trust state this install recorded.
result.Provenance = opts.Provenance
result.Unsigned = opts.Unsigned

// Snapshot the prior lock entry before anything below can write one, so
// rollback can reinstate it (RequiredBy links from other parents
Expand Down
13 changes: 11 additions & 2 deletions pkg/skills/skillsvc/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,17 @@ func (s *service) Info(ctx context.Context, opts skills.InfoOptions) (*skills.Sk
return nil, err
}

return &skills.SkillInfo{
info := &skills.SkillInfo{
Metadata: skill.Metadata,
InstalledSkill: &skill,
}, nil
}
// Project-scoped, lock-managed skills carry the lock file's recorded
// trust state so callers can display what installs are checked against.
if scope == skills.ScopeProject && projectRoot != "" && skills.LockFileFeatureEnabled() {
if expected, expectUnsigned, trustErr := expectedLockTrust(projectRoot, opts.Name); trustErr == nil {
info.Provenance = provenanceInfoFromLock(expected)
info.Unsigned = expectUnsigned
}
}
return info, nil
}
16 changes: 16 additions & 0 deletions pkg/skills/skillsvc/verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,20 @@ func classifySignatureError(err error) skills.FailureReason {
}
}

// provenanceInfoFromLock converts a lock provenance block to the API shape.
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,
Provisional: p.Provisional,
}
}

// provenanceInfoToLock converts the internal provenance shape to the lock
// file's.
func provenanceInfoToLock(p *skills.ProvenanceInfo) *lockfile.Provenance {
Expand All @@ -283,6 +297,7 @@ func provenanceInfoToLock(p *skills.ProvenanceInfo) *lockfile.Provenance {
CertIssuer: p.CertIssuer,
RepositoryURI: p.RepositoryURI,
SigstoreURL: p.SigstoreURL,
Provisional: p.Provisional,
}
}

Expand All @@ -297,5 +312,6 @@ func provenanceInfoFromResult(r *verifier.Result) *skills.ProvenanceInfo {
CertIssuer: r.CertIssuer,
RepositoryURI: r.RepositoryURI,
SigstoreURL: r.SigstoreURL,
Provisional: r.Provisional,
}
}
Loading