Skip to content
Closed
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
9 changes: 7 additions & 2 deletions cmd/thv/app/skill_install.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,13 @@ var (
var skillInstallCmd = &cobra.Command{
Use: "install [skill-name]",
Short: "Install a skill",
Long: `Install a skill by name or OCI reference.
The skill will be fetched from a remote registry and installed locally.`,
Long: `Install a skill by name, OCI reference, or git reference.

Examples:
thv skill install my-skill # from local build
thv skill install ghcr.io/org/my-skill:v1 # from OCI registry
thv skill install git://github.com/org/repo#skills/my-skill # from git repo
thv skill install git://github.com/org/repo@v1.0#skills/my-skill # from git ref`,
Args: cobra.ExactArgs(1),
PreRunE: chainPreRunE(
validateSkillScope(&skillInstallScope),
Expand Down
2 changes: 2 additions & 0 deletions pkg/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import (
"github.com/stacklok/toolhive/pkg/groups"
"github.com/stacklok/toolhive/pkg/recovery"
"github.com/stacklok/toolhive/pkg/skills"
"github.com/stacklok/toolhive/pkg/skills/gitresolver"
"github.com/stacklok/toolhive/pkg/skills/skillsvc"
"github.com/stacklok/toolhive/pkg/storage/sqlite"
"github.com/stacklok/toolhive/pkg/updates"
Expand Down Expand Up @@ -263,6 +264,7 @@ func (b *ServerBuilder) createDefaultManagers(ctx context.Context) error {
skillsvc.WithPackager(packager),
skillsvc.WithRegistryClient(registry),
skillsvc.WithGroupManager(b.groupManager),
skillsvc.WithGitResolver(gitresolver.NewResolver()),
)
}

Expand Down
82 changes: 82 additions & 0 deletions pkg/skills/gitresolver/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package gitresolver

import (
"log/slog"
"net/url"
"os"
"strings"

"github.com/go-git/go-git/v5/plumbing/transport"
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
)

// tokenMapping maps environment variable names to the git hosts they are scoped to.
// Tokens are only sent to their matching host to prevent credential exfiltration.
var tokenMapping = []struct {
envVar string
hosts []string // empty means the token is sent to any host (user opt-in)
}{
{envVar: "GITHUB_TOKEN", hosts: []string{"github.com"}},
{envVar: "GITLAB_TOKEN", hosts: []string{"gitlab.com"}},
{envVar: "GIT_TOKEN", hosts: nil}, // fallback: sent to any host
}

// EnvFunc is a function that looks up an environment variable.
// The default is os.Getenv; tests can inject a custom implementation.
type EnvFunc func(string) string

// ResolveAuth attempts to find authentication credentials from the environment
// scoped to the given clone URL. Returns nil if no credentials match.
//
// Security: tokens are only sent to their designated hosts. GITHUB_TOKEN is
// only sent to github.com, GITLAB_TOKEN only to gitlab.com. GIT_TOKEN is a
// fallback sent to any host.
func ResolveAuth(cloneURL string) transport.AuthMethod {
return ResolveAuthWith(os.Getenv, cloneURL)
}

// ResolveAuthWith is like ResolveAuth but uses the provided function to look up
// environment variables, making it testable without modifying process state.
func ResolveAuthWith(getenv EnvFunc, cloneURL string) transport.AuthMethod {
host := extractHost(cloneURL)

for _, mapping := range tokenMapping {
token := getenv(mapping.envVar)
if token == "" {
continue
}
// If hosts are specified, only send the token to matching hosts.
if len(mapping.hosts) > 0 && !hostMatches(host, mapping.hosts) {
continue
}
slog.Debug("Using git authentication from environment", "env_var", mapping.envVar)
return &githttp.BasicAuth{
Username: "x-access-token",
Password: token,
}
}

return nil
}

// extractHost returns the lowercase hostname from a URL, or empty string on failure.
func extractHost(rawURL string) string {
parsed, err := url.Parse(rawURL)
if err != nil {
return ""
}
return strings.ToLower(parsed.Hostname())
}

// hostMatches checks if host matches any of the allowed hosts (case-insensitive).
func hostMatches(host string, allowed []string) bool {
for _, h := range allowed {
if strings.EqualFold(host, h) {
return true
}
}
return false
}
110 changes: 110 additions & 0 deletions pkg/skills/gitresolver/auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package gitresolver

import (
"testing"

githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// fakeEnv builds an EnvFunc that returns values from the given map.
func fakeEnv(vars map[string]string) EnvFunc {
return func(key string) string {
return vars[key]
}
}

func TestResolveAuthWith(t *testing.T) {
t.Parallel()

tests := []struct {
name string
cloneURL string
envVars map[string]string
expectNil bool
expectToken string
}{
{
name: "no env vars set",
cloneURL: "https://github.com/org/repo",
envVars: map[string]string{},
expectNil: true,
},
{
name: "GITHUB_TOKEN sent to github.com",
cloneURL: "https://github.com/org/repo",
envVars: map[string]string{"GITHUB_TOKEN": "ghp_test123"},
expectToken: "ghp_test123",
},
{
name: "GITHUB_TOKEN NOT sent to gitlab.com",
cloneURL: "https://gitlab.com/org/repo",
envVars: map[string]string{"GITHUB_TOKEN": "ghp_test123"},
expectNil: true,
},
{
name: "GITHUB_TOKEN NOT sent to evil host",
cloneURL: "https://evil.com/org/repo",
envVars: map[string]string{"GITHUB_TOKEN": "ghp_secret"},
expectNil: true,
},
{
name: "GITLAB_TOKEN sent to gitlab.com",
cloneURL: "https://gitlab.com/org/repo",
envVars: map[string]string{"GITLAB_TOKEN": "glpat-test123"},
expectToken: "glpat-test123",
},
{
name: "GITLAB_TOKEN NOT sent to github.com",
cloneURL: "https://github.com/org/repo",
envVars: map[string]string{"GITLAB_TOKEN": "glpat-test123"},
expectNil: true,
},
{
name: "GIT_TOKEN sent to any host",
cloneURL: "https://custom-git.example.com/org/repo",
envVars: map[string]string{"GIT_TOKEN": "token123"},
expectToken: "token123",
},
{
name: "GITHUB_TOKEN takes precedence over GIT_TOKEN on github.com",
cloneURL: "https://github.com/org/repo",
envVars: map[string]string{
"GITHUB_TOKEN": "ghp_first",
"GIT_TOKEN": "fallback",
},
expectToken: "ghp_first",
},
{
name: "GIT_TOKEN used on github.com when GITHUB_TOKEN absent",
cloneURL: "https://github.com/org/repo",
envVars: map[string]string{
"GIT_TOKEN": "fallback",
},
expectToken: "fallback",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

auth := ResolveAuthWith(fakeEnv(tt.envVars), tt.cloneURL)

if tt.expectNil {
assert.Nil(t, auth)
return
}

require.NotNil(t, auth)
basicAuth, ok := auth.(*githttp.BasicAuth)
require.True(t, ok, "expected *githttp.BasicAuth")
assert.Equal(t, "x-access-token", basicAuth.Username)
assert.Equal(t, tt.expectToken, basicAuth.Password)
})
}
}
Loading
Loading