diff --git a/cmd/thv/app/skill_info.go b/cmd/thv/app/skill_info.go index 440429085d..3da4ec2210 100644 --- a/cmd/thv/app/skill_info.go +++ b/cmd/thv/app/skill_info.go @@ -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 { diff --git a/cmd/thv/app/skill_install.go b/cmd/thv/app/skill_install.go index 0bfbc69394..ffedd12eb7 100644 --- a/cmd/thv/app/skill_install.go +++ b/cmd/thv/app/skill_install.go @@ -4,6 +4,7 @@ package app import ( + "fmt" "strings" "github.com/spf13/cobra" @@ -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), @@ -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 { diff --git a/docs/server/docs.go b/docs/server/docs.go index 30aaeab404..550862c022 100644 --- a/docs/server/docs.go +++ b/docs/server/docs.go @@ -1410,6 +1410,32 @@ const docTemplate = `{ }, "type": "object" }, + "github_com_stacklok_toolhive_pkg_skills.ProvenanceInfo": { + "description": "Provenance is the signer identity the project's lock file records\nfor this skill, when project-scoped and lock-managed.", + "properties": { + "cert_issuer": { + "description": "CertIssuer is the OIDC issuer that authenticated the signer.", + "type": "string" + }, + "provisional": { + "description": "Provisional marks provenance with a documented verification gap\n(git signatures until transparency-log validation lands).", + "type": "boolean" + }, + "repository_uri": { + "description": "RepositoryURI is the source repository from the certificate\nextensions, when present.", + "type": "string" + }, + "signer_identity": { + "description": "SignerIdentity is the certificate subject identity (workflow path for\nGitHub Actions certificates, SAN verbatim otherwise).", + "type": "string" + }, + "sigstore_url": { + "description": "SigstoreURL is the Sigstore instance the signature chains to.", + "type": "string" + } + }, + "type": "object" + }, "github_com_stacklok_toolhive_pkg_skills.Scope": { "description": "Scope for the installation", "enum": [ @@ -1475,6 +1501,13 @@ const docTemplate = `{ }, "metadata": { "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_skills.SkillMetadata" + }, + "provenance": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_skills.ProvenanceInfo" + }, + "unsigned": { + "description": "Unsigned reports that the lock file records an explicit unsigned\nexception for this skill.", + "type": "boolean" } }, "type": "object" diff --git a/docs/server/swagger.json b/docs/server/swagger.json index a7cafbdf7d..d2779ee73b 100644 --- a/docs/server/swagger.json +++ b/docs/server/swagger.json @@ -1403,6 +1403,32 @@ }, "type": "object" }, + "github_com_stacklok_toolhive_pkg_skills.ProvenanceInfo": { + "description": "Provenance is the signer identity the project's lock file records\nfor this skill, when project-scoped and lock-managed.", + "properties": { + "cert_issuer": { + "description": "CertIssuer is the OIDC issuer that authenticated the signer.", + "type": "string" + }, + "provisional": { + "description": "Provisional marks provenance with a documented verification gap\n(git signatures until transparency-log validation lands).", + "type": "boolean" + }, + "repository_uri": { + "description": "RepositoryURI is the source repository from the certificate\nextensions, when present.", + "type": "string" + }, + "signer_identity": { + "description": "SignerIdentity is the certificate subject identity (workflow path for\nGitHub Actions certificates, SAN verbatim otherwise).", + "type": "string" + }, + "sigstore_url": { + "description": "SigstoreURL is the Sigstore instance the signature chains to.", + "type": "string" + } + }, + "type": "object" + }, "github_com_stacklok_toolhive_pkg_skills.Scope": { "description": "Scope for the installation", "enum": [ @@ -1468,6 +1494,13 @@ }, "metadata": { "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_skills.SkillMetadata" + }, + "provenance": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_skills.ProvenanceInfo" + }, + "unsigned": { + "description": "Unsigned reports that the lock file records an explicit unsigned\nexception for this skill.", + "type": "boolean" } }, "type": "object" diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml index 72f0ddf0f4..f6430ffd48 100644 --- a/docs/server/swagger.yaml +++ b/docs/server/swagger.yaml @@ -1475,6 +1475,33 @@ components: if available. type: string type: object + github_com_stacklok_toolhive_pkg_skills.ProvenanceInfo: + description: |- + Provenance is the signer identity the project's lock file records + for this skill, when project-scoped and lock-managed. + properties: + cert_issuer: + description: CertIssuer is the OIDC issuer that authenticated the signer. + type: string + provisional: + description: |- + Provisional marks provenance with a documented verification gap + (git signatures until transparency-log validation lands). + type: boolean + repository_uri: + description: |- + RepositoryURI is the source repository from the certificate + extensions, when present. + type: string + signer_identity: + description: |- + SignerIdentity is the certificate subject identity (workflow path for + GitHub Actions certificates, SAN verbatim otherwise). + type: string + sigstore_url: + description: SigstoreURL is the Sigstore instance the signature chains to. + type: string + type: object github_com_stacklok_toolhive_pkg_skills.Scope: description: Scope for the installation enum: @@ -1524,6 +1551,13 @@ components: $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_skills.InstalledSkill' metadata: $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_skills.SkillMetadata' + provenance: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_skills.ProvenanceInfo' + unsigned: + description: |- + Unsigned reports that the lock file records an explicit unsigned + exception for this skill. + type: boolean type: object github_com_stacklok_toolhive_pkg_skills.SkillMetadata: description: Metadata contains the skill's metadata. diff --git a/pkg/skills/options.go b/pkg/skills/options.go index 0f2ef9e00e..bc77188f8e 100644 --- a/pkg/skills/options.go +++ b/pkg/skills/options.go @@ -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. @@ -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. @@ -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. diff --git a/pkg/skills/skillsvc/install.go b/pkg/skills/skillsvc/install.go index f0ac760916..e493d9f61c 100644 --- a/pkg/skills/skillsvc/install.go +++ b/pkg/skills/skillsvc/install.go @@ -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 diff --git a/pkg/skills/skillsvc/list.go b/pkg/skills/skillsvc/list.go index c87488840b..7d2d8a5290 100644 --- a/pkg/skills/skillsvc/list.go +++ b/pkg/skills/skillsvc/list.go @@ -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 } diff --git a/pkg/skills/skillsvc/verify.go b/pkg/skills/skillsvc/verify.go index c11d0d595b..7726a1459a 100644 --- a/pkg/skills/skillsvc/verify.go +++ b/pkg/skills/skillsvc/verify.go @@ -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 { @@ -283,6 +297,7 @@ func provenanceInfoToLock(p *skills.ProvenanceInfo) *lockfile.Provenance { CertIssuer: p.CertIssuer, RepositoryURI: p.RepositoryURI, SigstoreURL: p.SigstoreURL, + Provisional: p.Provisional, } } @@ -297,5 +312,6 @@ func provenanceInfoFromResult(r *verifier.Result) *skills.ProvenanceInfo { CertIssuer: r.CertIssuer, RepositoryURI: r.RepositoryURI, SigstoreURL: r.SigstoreURL, + Provisional: r.Provisional, } }