Skip to content
Merged
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 @@ -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 {
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 @@ -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),
Expand All @@ -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 {
Expand Down
40 changes: 40 additions & 0 deletions docs/server/docs.go

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

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

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

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

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

6 changes: 5 additions & 1 deletion pkg/api/v1/skills.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions pkg/api/v1/skills_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 5 additions & 1 deletion pkg/skills/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
70 changes: 70 additions & 0 deletions pkg/skills/client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}
}
6 changes: 6 additions & 0 deletions pkg/skills/client/dto.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading