From 967415082a7a1c9f8deee5bae828364ee2a8bff0 Mon Sep 17 00:00:00 2001 From: Samuele Verzi Date: Wed, 29 Jul 2026 11:35:28 +0200 Subject: [PATCH 1/2] Display recorded trust state to the user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC THV-0080 wants the identity pinned at trust-on-first-use displayed prominently, not discovered weeks later inside a signer-mismatch error. ProvenanceInfo becomes part of API responses: install results carry the provenance or unsigned exception that was just recorded (the CLI prints it — a deliberate exception to silent success, since a recorded trust anchor is security-relevant information, not progress chatter), and skill info surfaces the lock file's recorded trust state for project-scoped skills, provisional markers included. Part of #5899. Co-Authored-By: Claude Fable 5 --- cmd/thv/app/skill_info.go | 10 ++++++++++ cmd/thv/app/skill_install.go | 25 ++++++++++++++++++++++++- docs/server/docs.go | 33 +++++++++++++++++++++++++++++++++ docs/server/swagger.json | 33 +++++++++++++++++++++++++++++++++ docs/server/swagger.yaml | 34 ++++++++++++++++++++++++++++++++++ pkg/skills/options.go | 23 +++++++++++++++++++---- pkg/skills/skillsvc/install.go | 4 ++++ pkg/skills/skillsvc/list.go | 13 +++++++++++-- pkg/skills/skillsvc/verify.go | 16 ++++++++++++++++ 9 files changed, 184 insertions(+), 7 deletions(-) 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..3bee757e4d 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" diff --git a/docs/server/swagger.json b/docs/server/swagger.json index 4c89dfe6cd..3628414d95 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" diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml index 56c7bac5de..8695ccb778 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. 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, } } From b3917719f6f4406c5dc20916c62d7bdb12c31015 Mon Sep 17 00:00:00 2001 From: Samuele Verzi Date: Wed, 12 Aug 2026 11:52:43 +0200 Subject: [PATCH 2/2] Carry install trust state across the HTTP boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI is a pure HTTP client, so a field the service records but the install response omits can never reach the user. Provenance and the unsigned exception were set on InstallResult server-side and dropped at the wire, leaving printInstallTrust to report every install as untracked regardless of what was verified. Add both to the install response and the client DTO, and map them back. The round-trip test fails without the mapping — a direct test of the printer passes either way, which is how the gap survived. --- docs/server/docs.go | 7 ++++ docs/server/swagger.json | 7 ++++ docs/server/swagger.yaml | 5 +++ pkg/api/v1/skills.go | 6 ++- pkg/api/v1/skills_types.go | 6 +++ pkg/skills/client/client.go | 6 ++- pkg/skills/client/client_test.go | 70 ++++++++++++++++++++++++++++++++ pkg/skills/client/dto.go | 6 +++ 8 files changed, 111 insertions(+), 2 deletions(-) diff --git a/docs/server/docs.go b/docs/server/docs.go index 3bee757e4d..f7259ebc76 100644 --- a/docs/server/docs.go +++ b/docs/server/docs.go @@ -3264,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 3628414d95..d174b1bdc6 100644 --- a/docs/server/swagger.json +++ b/docs/server/swagger.json @@ -3257,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 8695ccb778..e3db6df53f 100644 --- a/docs/server/swagger.yaml +++ b/docs/server/swagger.yaml @@ -3053,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 {