diff --git a/cmd/thv/app/skill_info.go b/cmd/thv/app/skill_info.go index 0daa5c2dc5..b17939c0b3 100644 --- a/cmd/thv/app/skill_info.go +++ b/cmd/thv/app/skill_info.go @@ -78,6 +78,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 f1d4c4f253..1d958265f9 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" @@ -55,7 +56,7 @@ func skillInstallCmdFunc(cmd *cobra.Command, args []string) error { return err } - _, 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), @@ -68,9 +69,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 50b61eab8c..f7259ebc76 100644 --- a/docs/server/docs.go +++ b/docs/server/docs.go @@ -1774,6 +1774,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": [ @@ -1839,6 +1865,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" @@ -3231,8 +3264,15 @@ const docTemplate = `{ "pkg_api_v1.installSkillResponse": { "description": "Response after successfully installing a skill", "properties": { + "provenance": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_skills.ProvenanceInfo" + }, "skill": { "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_skills.InstalledSkill" + }, + "unsigned": { + "description": "Whether the install was recorded as an explicit unsigned exception.", + "type": "boolean" } }, "type": "object" diff --git a/docs/server/swagger.json b/docs/server/swagger.json index 4c89dfe6cd..d174b1bdc6 100644 --- a/docs/server/swagger.json +++ b/docs/server/swagger.json @@ -1767,6 +1767,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": [ @@ -1832,6 +1858,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" @@ -3224,8 +3257,15 @@ "pkg_api_v1.installSkillResponse": { "description": "Response after successfully installing a skill", "properties": { + "provenance": { + "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_skills.ProvenanceInfo" + }, "skill": { "$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_skills.InstalledSkill" + }, + "unsigned": { + "description": "Whether the install was recorded as an explicit unsigned exception.", + "type": "boolean" } }, "type": "object" diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml index 56c7bac5de..e3db6df53f 100644 --- a/docs/server/swagger.yaml +++ b/docs/server/swagger.yaml @@ -1863,6 +1863,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: @@ -1912,6 +1939,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. @@ -3019,8 +3053,13 @@ components: pkg_api_v1.installSkillResponse: description: Response after successfully installing a skill properties: + provenance: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_skills.ProvenanceInfo' skill: $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_skills.InstalledSkill' + unsigned: + description: Whether the install was recorded as an explicit unsigned exception. + type: boolean type: object pkg_api_v1.listSecretsResponse: description: Response containing a list of secret keys diff --git a/pkg/api/v1/skills.go b/pkg/api/v1/skills.go index 2e39fea8ca..0ea5eba7a1 100644 --- a/pkg/api/v1/skills.go +++ b/pkg/api/v1/skills.go @@ -137,7 +137,11 @@ func (s *SkillsRoutes) installSkill(w http.ResponseWriter, r *http.Request) erro w.Header().Set("Content-Type", "application/json") w.Header().Set("Location", fmt.Sprintf("/api/v1beta/skills/%s", result.Skill.Metadata.Name)) w.WriteHeader(http.StatusCreated) - return json.NewEncoder(w).Encode(installSkillResponse{Skill: result.Skill}) + return json.NewEncoder(w).Encode(installSkillResponse{ + Skill: result.Skill, + Provenance: result.Provenance, + Unsigned: result.Unsigned, + }) } // uninstallSkill removes an installed skill. diff --git a/pkg/api/v1/skills_types.go b/pkg/api/v1/skills_types.go index 755b06d2d6..3afc2f8470 100644 --- a/pkg/api/v1/skills_types.go +++ b/pkg/api/v1/skills_types.go @@ -45,6 +45,12 @@ type installSkillRequest struct { type installSkillResponse struct { // The installed skill Skill skills.InstalledSkill `json:"skill"` + // The signer identity trust-on-first-use pinned for this install, when + // the artifact was verified. Omitted for unsigned or non-lock-managed + // installs. + Provenance *skills.ProvenanceInfo `json:"provenance,omitempty"` + // Whether the install was recorded as an explicit unsigned exception. + Unsigned bool `json:"unsigned,omitempty"` } // validateSkillRequest represents the request to validate a skill. diff --git a/pkg/skills/client/client.go b/pkg/skills/client/client.go index 7fbc5b9a37..d5285adf7e 100644 --- a/pkg/skills/client/client.go +++ b/pkg/skills/client/client.go @@ -218,7 +218,11 @@ func (c *Client) Install(ctx context.Context, opts skills.InstallOptions) (*skil if err := c.doJSONRequest(ctx, http.MethodPost, "", nil, body, &resp); err != nil { return nil, err } - return &skills.InstallResult{Skill: resp.Skill}, nil + return &skills.InstallResult{ + Skill: resp.Skill, + Provenance: resp.Provenance, + Unsigned: resp.Unsigned, + }, nil } // Uninstall removes an installed skill. diff --git a/pkg/skills/client/client_test.go b/pkg/skills/client/client_test.go index 0ba894bf45..786a9592f3 100644 --- a/pkg/skills/client/client_test.go +++ b/pkg/skills/client/client_test.go @@ -947,3 +947,73 @@ func TestCallerCancellationIsNeitherSentinel(t *testing.T) { assert.NotErrorIs(t, err, ErrRequestTimeout) assert.NotErrorIs(t, err, ErrServerUnreachable) } + +// TestInstallCarriesTrustStateBackToCaller pins the full wire round-trip for +// the fields the CLI uses to report trust. The CLI is a pure HTTP client, so a +// field the server sets but the response DTO omits is invisible in production +// even though a direct unit test of the printer would still pass — every +// install would silently render as untracked. +func TestInstallCarriesTrustStateBackToCaller(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + response installResponse + }{ + { + name: "signed install reports the pinned identity", + response: installResponse{ + Skill: skills.InstalledSkill{Metadata: skills.SkillMetadata{Name: "signed-skill"}}, + Provenance: &skills.ProvenanceInfo{ + SignerIdentity: "/.github/workflows/build-skills.yml", + CertIssuer: "https://token.actions.githubusercontent.com", + RepositoryURI: "https://github.com/stacklok/dockyard", + }, + }, + }, + { + name: "provisional provenance survives the round trip", + response: installResponse{ + Skill: skills.InstalledSkill{Metadata: skills.SkillMetadata{Name: "git-skill"}}, + Provenance: &skills.ProvenanceInfo{ + SignerIdentity: "someone@example.com", + CertIssuer: "https://accounts.google.com", + Provisional: true, + }, + }, + }, + { + name: "explicit unsigned exception is reported as such", + response: installResponse{ + Skill: skills.InstalledSkill{Metadata: skills.SkillMetadata{Name: "unsigned-skill"}}, + Unsigned: true, + }, + }, + { + name: "an install with neither reports neither", + response: installResponse{ + Skill: skills.InstalledSkill{Metadata: skills.SkillMetadata{Name: "plain-skill"}}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + require.NoError(t, json.NewEncoder(w).Encode(tt.response)) + })) + defer srv.Close() + + got, err := newTestClient(t, srv).Install(t.Context(), skills.InstallOptions{Name: "x"}) + require.NoError(t, err) + + assert.Equal(t, tt.response.Provenance, got.Provenance, + "provenance must survive the HTTP boundary — the CLI has no other source for it") + assert.Equal(t, tt.response.Unsigned, got.Unsigned) + }) + } +} diff --git a/pkg/skills/client/dto.go b/pkg/skills/client/dto.go index c92dad6417..743091a452 100644 --- a/pkg/skills/client/dto.go +++ b/pkg/skills/client/dto.go @@ -39,6 +39,12 @@ type listResponse struct { type installResponse struct { Skill skills.InstalledSkill `json:"skill"` + // Provenance and Unsigned mirror installSkillResponse. Without them the + // CLI — which is a pure HTTP client — could never report the trust state + // the server recorded, and would silently print every install as if it + // were untracked. + Provenance *skills.ProvenanceInfo `json:"provenance,omitempty"` + Unsigned bool `json:"unsigned,omitempty"` } type listBuildsResponse struct { diff --git a/pkg/skills/options.go b/pkg/skills/options.go index 6975fcbec3..a87923024d 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, } }