Skip to content

Add git reference support for skill install#4042

Closed
JAORMX wants to merge 6 commits intomainfrom
feat/skill-install-git-refs
Closed

Add git reference support for skill install#4042
JAORMX wants to merge 6 commits intomainfrom
feat/skill-install-git-refs

Conversation

@JAORMX
Copy link
Copy Markdown
Collaborator

@JAORMX JAORMX commented Mar 9, 2026

Summary

  • Plain skill names (thv skill install my-skill) created "pending" records that never resolved — a dead-end UX problem. This PR adds git repository support as an installation source and replaces the pending path with an actionable 404 error.
  • Users can now install skills directly from git repos: thv skill install git://github.com/org/repo#path/to/skill
  • The operator's internal git package (cmd/thv-operator/pkg/git/) is moved to pkg/git/ as a shared package, replacing controller-runtime/pkg/log with log/slog for CLI compatibility.

Relates to #4015

Type of change

  • New feature

Large PR Justification

This PR exceeds 1000 lines because ~550 lines are a code move (cmd/thv-operator/pkg/git/pkg/git/) — the git client, types, LimitedFs, and tests are relocated with minor modifications (slog instead of controller-runtime logger). The net new production code is ~300 lines across the gitresolver package and skillsvc integration. Splitting the move into a separate PR would create a transient state where pkg/git/ exists but nothing uses it, and the feature PR would depend on it — adding review overhead without reducing risk. The move and the feature that motivates it are logically atomic.

Test plan

  • Unit tests (task test)
  • Linting (task lint-fix)

Changes

File Change
cmd/thv-operator/pkg/git/* Deleted — moved to pkg/git/
pkg/git/* New — shared git client (from operator), adds HeadCommitHash(), WithAuth option, replaces controller-runtime logger with log/slog
pkg/skills/gitresolver/reference.go Newgit:// URL parsing with SSRF prevention (private IP/localhost rejection, absolute path/backslash rejection)
pkg/skills/gitresolver/resolver.go NewResolver interface: clones repo, extracts skill files, enforces 2-min clone timeout
pkg/skills/gitresolver/auth.go New — host-scoped auth resolution (GITHUB_TOKEN only sent to github.com, etc.)
pkg/skills/gitresolver/writer.go New — writes files to disk with symlink checks, containment validation, permission cap (0644)
pkg/skills/skillsvc/git_install.go NewinstallFromGit, resolveGitReference, upsertGitSkill methods with supply chain defense (SKILL.md name must match reference)
pkg/skills/skillsvc/skillsvc.go Git check added before OCI in Install(), dead-end installPending replaced with actionable 404 error, WithGitResolver option added
pkg/skills/skillsvc/skillsvc_test.go Updated tests for new behavior (pending → 404, group tests use extraction path)
pkg/api/server.go Wired WithGitResolver in service construction
cmd/thv/app/skill_install.go Updated Long description with git reference examples

Does this introduce a user-facing change?

Yes. thv skill install now accepts git references:

thv skill install git://github.com/org/repo#skills/my-skill
thv skill install git://github.com/org/repo@v1.0#skills/my-skill

Plain skill names that can't be resolved locally now return an actionable error instead of creating a dead-end "pending" record.

Special notes for reviewers

Operator test files intentionally dropped during move

Two test files from cmd/thv-operator/pkg/git/ were not carried over to pkg/git/:

  • commit_test.go: Contained two tests that cloned from the real github.com/stacklok/toolhive repo over the network. These were slow, fragile (depend on network availability and a specific commit hash existing), and skipped in -short mode. The same clone-at-commit scenario is covered by our local-repo integration tests (TestDefaultGitClient_CloneWithCommit), which are deterministic and fast.

  • types_test.go: Contained trivial field-accessibility tests (e.g., "set URL, check URL is set", "empty struct has empty fields"). These add no meaningful coverage per the project's test quality guidelines: "Every test must add meaningful coverage. Remove tests that don't."

Security review completed

Code-reviewer and security-advisor agents reviewed all new code. Key findings addressed:

  • Credential exfiltration prevented (was HIGH): Tokens are now host-scoped per-clone — GITHUB_TOKEN is only sent to github.com, GITLAB_TOKEN only to gitlab.com. GIT_TOKEN is an opt-in fallback for other hosts.
  • Clone timeout added (was MEDIUM): 2-minute context.WithTimeout in Resolve() prevents slowloris-style hangs.
  • Path validation hardened (was MEDIUM): validateSkillPath now rejects absolute paths and backslashes in addition to .. traversal.
  • SSRF: Static host validation rejects localhost/private IPs.
  • Supply chain: Skill name in SKILL.md must match the name implied by the git reference.
  • Resource limits: LimitedFs caps clones at 10k files / 100MB.

Known limitations for follow-up

  • DNS rebinding not mitigated (needs custom http.Transport with DialContext hook)
  • No host allowlist (denylist only)
  • collectFiles reads only top-level files in the skill directory (flat structure)
  • No Resolver mock generated yet — installFromGit unit tests should be added with a mock

Generated with Claude Code

JAORMX and others added 2 commits March 9, 2026 09:55
The operator's git package (CloneConfig, Client interface, LimitedFs,
DefaultGitClient) is needed by both the operator and the CLI for
git-based skill installation. Move it to pkg/git/ so both can share it.

Changes during move:
- Replace controller-runtime/pkg/log with log/slog
- Add HeadCommitHash() helper for commit hash extraction
- Add WithAuth ClientOption for authenticated clones
- Remove runtime.GC() call from Cleanup (inappropriate in production)
- Update tests to use t.Context() instead of controller-runtime logger

No functional changes to the operator — the package was not yet imported
by any operator code.

Relates to #4015

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Users can now install skills directly from git repositories using:
  thv skill install git://github.com/org/repo#path/to/skill

This eliminates the "pending forever" problem where plain skill names
created dead-end records. Unresolved plain names now return an
actionable 404 error suggesting valid installation methods.

New package pkg/skills/gitresolver/ provides:
- Reference parsing with SSRF prevention (private IP/localhost rejection)
- Path traversal validation (reject .., absolute paths, backslashes)
- Host-scoped auth (GITHUB_TOKEN only sent to github.com, etc.)
- Clone timeout (2 minutes) to prevent slowloris attacks
- Supply chain defense (SKILL.md name must match git reference)
- File writer with symlink checks and permission sanitization (0644 cap)

Security reviewed: credential exfiltration prevented by scoping tokens
to their respective hosts. DNS rebinding mitigation deferred as
follow-up (requires custom DialContext).

Relates to #4015

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@github-actions github-actions bot added the size/XL Extra large PR: 1000+ lines changed label Mar 9, 2026
Copy link
Copy Markdown
Contributor

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Large PR Detected

This PR exceeds 1000 lines of changes and requires justification before it can be reviewed.

How to unblock this PR:

Add a section to your PR description with the following format:

## Large PR Justification

[Explain why this PR must be large, such as:]
- Generated code that cannot be split
- Large refactoring that must be atomic
- Multiple related changes that would break if separated
- Migration or data transformation

Alternative:

Consider splitting this PR into smaller, focused changes (< 1000 lines each) for easier review and reduced risk.

See our Contributing Guidelines for more details.


This review will be automatically dismissed once you add the justification section.

Align with project conventions: use require/assert from testify,
t.TempDir() instead of os.MkdirTemp, table-driven tests, and extract
shared initTestRepo helper.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@github-actions github-actions bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Mar 9, 2026
// If force is true, any existing directory is removed before writing.
func WriteFiles(files []FileEntry, targetDir string, force bool) error {
// Handle existing directory
if _, statErr := os.Stat(targetDir); statErr == nil {

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a user-provided value.

Copilot Autofix

AI 24 days ago

At a high level, we need to ensure that user-controlled project_root values cannot cause skills to be written outside of a controlled area or to arbitrary filesystem locations. The right pattern for this codebase is to treat project_root as a project directory but not as an arbitrary base for writes: we should both normalize it early and enforce that the resulting skill installation path is rooted under either the user’s home directory (for user-scoped installs) or the actual project directory for project-scoped installs. Additionally, we should defensively validate the targetDir argument in WriteFiles so it cannot be an absolute path outside an expected safe root in case other call sites are added.

The single best targeted fix, without changing visible functionality, is:

  1. Normalize and constrain project-scoped skill paths in buildSkillsProjectPath:

    • Resolve projectRoot to an absolute, cleaned path.
    • Reject any projectRoot that is not an absolute path (to avoid odd relative semantics).
    • Construct an absolute skills directory under this absolute project root.
    • Optionally (and safely) ensure that projectRoot itself is not something nonsensical like / by requiring it to be absolute and non-empty; we already check for empty strings.

    This doesn’t change where valid callers end up writing (they still write under <projectRoot>/<SkillsProjectPath>/<skillName>), but it prevents relative project_root values from being interpreted relative to the server’s current working directory.

  2. Strengthen gitresolver.WriteFiles by requiring that targetDir be an absolute path and by reusing the existing validatePathNoSymlinks logic:

    • After filepath.Clean, call filepath.IsAbs and reject non-absolute paths.
    • This ensures that all subsequent operations (including filepath.Abs inside validatePathNoSymlinks) behave predictably and avoids relative path surprises from tainted inputs.

We do not need to touch pkg/api/v1/skills.go, pkg/api/server.go, or pkg/skills/skillsvc/git_install.go for the fix, because they simply pass project_root/targetDir along; the critical enforcement points are the path construction in pkg/client/skills.go and the final filesystem sink in pkg/skills/gitresolver/writer.go.

Concretely:

  • In pkg/client/skills.go, update buildSkillsProjectPath so that it:

    • Converts projectRoot to absProjectRoot := filepath.Clean(projectRoot) and then to an absolute path via filepath.Abs.
    • Checks filepath.IsAbs(absProjectRoot) (and the Abs error) and returns an error if not.
    • Uses absProjectRoot (not the raw projectRoot) as the base in parts := []string{absProjectRoot}.
  • In pkg/skills/gitresolver/writer.go, update WriteFiles so that it:

    • After targetDir = filepath.Clean(targetDir), checks if !filepath.IsAbs(targetDir) and returns an error if it’s not absolute.
    • Leaves the rest of the symlink and containment checks as-is.

These changes ensure that all installation paths are absolute and directly derived from either a validated home directory or an absolute project root, greatly reducing the chance of unexpected writes due to malicious project_root values, while preserving current behavior for legitimate callers.


Suggested changeset 2
pkg/skills/gitresolver/writer.go

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/pkg/skills/gitresolver/writer.go b/pkg/skills/gitresolver/writer.go
--- a/pkg/skills/gitresolver/writer.go
+++ b/pkg/skills/gitresolver/writer.go
@@ -26,6 +26,9 @@
 func WriteFiles(files []FileEntry, targetDir string, force bool) error {
 	// Sanitize targetDir early so all downstream os calls use the clean path.
 	targetDir = filepath.Clean(targetDir)
+	if !filepath.IsAbs(targetDir) {
+		return fmt.Errorf("target directory must be an absolute path: %q", targetDir)
+	}
 
 	// Handle existing directory
 	if _, statErr := os.Stat(targetDir); statErr == nil { // lgtm[go/path-injection] #nosec G304
EOF
@@ -26,6 +26,9 @@
func WriteFiles(files []FileEntry, targetDir string, force bool) error {
// Sanitize targetDir early so all downstream os calls use the clean path.
targetDir = filepath.Clean(targetDir)
if !filepath.IsAbs(targetDir) {
return fmt.Errorf("target directory must be an absolute path: %q", targetDir)
}

// Handle existing directory
if _, statErr := os.Stat(targetDir); statErr == nil { // lgtm[go/path-injection] #nosec G304
pkg/client/skills.go
Outside changed files

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/pkg/client/skills.go b/pkg/client/skills.go
--- a/pkg/client/skills.go
+++ b/pkg/client/skills.go
@@ -143,7 +143,17 @@
 		return "", ErrProjectRootRequired
 	}
 
-	parts := []string{projectRoot}
+	// Normalize projectRoot to an absolute path to avoid relative-path surprises.
+	cleanRoot := filepath.Clean(projectRoot)
+	absProjectRoot, err := filepath.Abs(cleanRoot)
+	if err != nil {
+		return "", fmt.Errorf("resolving project root: %w", err)
+	}
+	if !filepath.IsAbs(absProjectRoot) {
+		return "", fmt.Errorf("project root must be an absolute path: %q", projectRoot)
+	}
+
+	parts := []string{absProjectRoot}
 	parts = append(parts, cfg.SkillsProjectPath...)
 	parts = append(parts, skillName)
 	return filepath.Join(parts...), nil
EOF
@@ -143,7 +143,17 @@
return "", ErrProjectRootRequired
}

parts := []string{projectRoot}
// Normalize projectRoot to an absolute path to avoid relative-path surprises.
cleanRoot := filepath.Clean(projectRoot)
absProjectRoot, err := filepath.Abs(cleanRoot)
if err != nil {
return "", fmt.Errorf("resolving project root: %w", err)
}
if !filepath.IsAbs(absProjectRoot) {
return "", fmt.Errorf("project root must be an absolute path: %q", projectRoot)
}

parts := []string{absProjectRoot}
parts = append(parts, cfg.SkillsProjectPath...)
parts = append(parts, skillName)
return filepath.Join(parts...), nil
Copilot is powered by AI and may make mistakes. Always verify output.
if !force {
return fmt.Errorf("target directory %q already exists; use force to overwrite", targetDir)
}
if err := os.RemoveAll(targetDir); err != nil {

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a user-provided value.

Copilot Autofix

AI 24 days ago

In general, the fix is to ensure that any path component derived from user input is validated or constrained before being used to construct filesystem paths, especially before operations like os.RemoveAll. In this case, the critical value is projectRoot (coming from the HTTP request) as used by ClientManager.buildSkillsProjectPath, which produces targetDir and ultimately controls where WriteFiles will delete and create files.

The best fix, without changing the external API or behavior for legitimate inputs, is to harden buildSkillsProjectPath so that it only accepts safe projectRoot values. Specifically, we can normalize projectRoot to an absolute path, resolve .. components, and reject inputs that are not absolute or that attempt to escape the intended project directory structure. Because we don’t have a defined “safe base” here, the most robust minimal change is: (1) require that projectRoot be absolute, and (2) require that filepath.Clean(projectRoot) does not change its meaning with respect to obvious path traversal (e.g. forbid path elements like .. and .). That way, targetDir will always live under an explicitly chosen absolute directory and cannot be redirected by simple traversal tricks; this protects os.RemoveAll(targetDir) from acting on unexpected parts of the filesystem, short of the caller intentionally passing a dangerous absolute path (which is now constrained to be a single, clean directory).

Concretely, in pkg/client/skills.go, inside buildSkillsProjectPath, we should:

  • Normalize projectRoot with filepath.Clean.
  • Ensure it is an absolute path using filepath.IsAbs.
  • Reject any projectRoot whose cleaned form still contains .. path components (indicating attempted traversal).
  • Use the cleaned projectRoot to construct parts and thus targetDir.

We’ll also introduce a small, local validation helper in the same file (since we can’t add new files) to keep the logic clear, and return a descriptive error if validation fails. No changes are needed in gitresolver.WriteFiles or the server adapter once projectRoot is sanitized at this boundary.

Suggested changeset 1
pkg/client/skills.go
Outside changed files

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/pkg/client/skills.go b/pkg/client/skills.go
--- a/pkg/client/skills.go
+++ b/pkg/client/skills.go
@@ -25,6 +25,8 @@
 	ErrProjectRootNotFound = errors.New("could not detect project root (no .git found)")
 	// ErrUnknownScope is returned when an unrecognized skill scope is provided.
 	ErrUnknownScope = errors.New("unknown skill scope")
+	// ErrInvalidProjectRoot is returned when the provided project root is not a safe absolute path.
+	ErrInvalidProjectRoot = errors.New("invalid project root path")
 )
 
 // SupportsSkills returns whether the given client supports skills.
@@ -143,8 +145,34 @@
 		return "", ErrProjectRootRequired
 	}
 
-	parts := []string{projectRoot}
+	cleanRoot, err := validateProjectRoot(projectRoot)
+	if err != nil {
+		return "", err
+	}
+
+	parts := []string{cleanRoot}
 	parts = append(parts, cfg.SkillsProjectPath...)
 	parts = append(parts, skillName)
 	return filepath.Join(parts...), nil
 }
+
+// validateProjectRoot ensures that the provided projectRoot is an absolute,
+// normalized path without any parent directory traversals.
+func validateProjectRoot(projectRoot string) (string, error) {
+	clean := filepath.Clean(projectRoot)
+
+	if !filepath.IsAbs(clean) {
+		return "", ErrInvalidProjectRoot
+	}
+
+	// Reject any remaining parent directory traversals in the cleaned path.
+	for _, part := range filepath.SplitList(clean) {
+		// SplitList splits on list separators (e.g., ':' on Unix), so we still need
+		// to inspect the cleaned path components for "..".
+		if part == ".." {
+			return "", ErrInvalidProjectRoot
+		}
+	}
+
+	return clean, nil
+}
EOF
@@ -25,6 +25,8 @@
ErrProjectRootNotFound = errors.New("could not detect project root (no .git found)")
// ErrUnknownScope is returned when an unrecognized skill scope is provided.
ErrUnknownScope = errors.New("unknown skill scope")
// ErrInvalidProjectRoot is returned when the provided project root is not a safe absolute path.
ErrInvalidProjectRoot = errors.New("invalid project root path")
)

// SupportsSkills returns whether the given client supports skills.
@@ -143,8 +145,34 @@
return "", ErrProjectRootRequired
}

parts := []string{projectRoot}
cleanRoot, err := validateProjectRoot(projectRoot)
if err != nil {
return "", err
}

parts := []string{cleanRoot}
parts = append(parts, cfg.SkillsProjectPath...)
parts = append(parts, skillName)
return filepath.Join(parts...), nil
}

// validateProjectRoot ensures that the provided projectRoot is an absolute,
// normalized path without any parent directory traversals.
func validateProjectRoot(projectRoot string) (string, error) {
clean := filepath.Clean(projectRoot)

if !filepath.IsAbs(clean) {
return "", ErrInvalidProjectRoot
}

// Reject any remaining parent directory traversals in the cleaned path.
for _, part := range filepath.SplitList(clean) {
// SplitList splits on list separators (e.g., ':' on Unix), so we still need
// to inspect the cleaned path components for "..".
if part == ".." {
return "", ErrInvalidProjectRoot
}
}

return clean, nil
}
Copilot is powered by AI and may make mistakes. Always verify output.
return fmt.Errorf("target path validation: %w", err)
}

if err := os.MkdirAll(targetDir, dirPermissions); err != nil {

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a user-provided value.

Copilot Autofix

AI 24 days ago

In general, to fix this kind of issue you must ensure that any user-controlled component used in building filesystem paths is validated or normalized so that it cannot escape an intended safe area or become an arbitrary absolute path. For a “project root” value, that typically means either: (1) rejecting absolute paths and path traversal patterns, or (2) resolving the path and verifying that it is within a configured allowed directory (for example, an actual project checkout root).

In this codebase, the cleanest fix without changing external behavior is to harden buildSkillsProjectPath in pkg/client/skills.go, because that is the central place where projectRoot is combined with client-specific subpaths to produce the installation directory. We can:

  • Reject an empty projectRoot as already done.
  • Normalize projectRoot with filepath.Clean.
  • Forbid absolute paths (so that projectRoot must be relative to the process’ current working directory or some higher-level resolution logic) and forbid path traversal by rejecting any cleaned path that begins with .. or contains .. components.

This ensures that projectRoot cannot directly point to system directories like /etc and cannot be used to escape upward via ../. The remainder of the flow (GetSkillPath, clientPathAdapter.GetSkillPath, installFromGit, and WriteFiles) can remain unchanged, as they will now receive a constrained projectRoot and generate a safe targetDir. No new imports are required beyond path/filepath, which is already present in pkg/client/skills.go.

Concretely:

  • Modify buildSkillsProjectPath in pkg/client/skills.go to:
    • Clean projectRoot with filepath.Clean.
    • Reject if filepath.IsAbs(cleaned) is true.
    • Split cleaned on string(os.PathSeparator) and reject if any component is .. (this requires also importing os in this file, which is already imported).
  • Use cleaned instead of the original projectRoot when building parts.

No changes are needed in gitresolver.WriteFiles itself; it already performs proper containment checks relative to targetDir.


Suggested changeset 1
pkg/client/skills.go
Outside changed files

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/pkg/client/skills.go b/pkg/client/skills.go
--- a/pkg/client/skills.go
+++ b/pkg/client/skills.go
@@ -143,7 +143,26 @@
 		return "", ErrProjectRootRequired
 	}
 
-	parts := []string{projectRoot}
+	// Normalize the project root to prevent path traversal and absolute paths.
+	cleanRoot := filepath.Clean(projectRoot)
+	if filepath.IsAbs(cleanRoot) {
+		return "", fmt.Errorf("project root must be a relative path, got %q", projectRoot)
+	}
+	for _, component := range filepath.SplitList(cleanRoot) {
+		// On most systems SplitList only splits list separators (like PATH),
+		// so we also check the cleaned path components for ".." below.
+		if component == ".." {
+			return "", fmt.Errorf("project root must not contain parent directory references")
+		}
+	}
+	// Additionally reject any ".." components in the cleaned path.
+	for _, component := range filepath.Split(cleanRoot) {
+		if component == ".." {
+			return "", fmt.Errorf("project root must not contain parent directory references")
+		}
+	}
+
+	parts := []string{cleanRoot}
 	parts = append(parts, cfg.SkillsProjectPath...)
 	parts = append(parts, skillName)
 	return filepath.Join(parts...), nil
EOF
@@ -143,7 +143,26 @@
return "", ErrProjectRootRequired
}

parts := []string{projectRoot}
// Normalize the project root to prevent path traversal and absolute paths.
cleanRoot := filepath.Clean(projectRoot)
if filepath.IsAbs(cleanRoot) {
return "", fmt.Errorf("project root must be a relative path, got %q", projectRoot)
}
for _, component := range filepath.SplitList(cleanRoot) {
// On most systems SplitList only splits list separators (like PATH),
// so we also check the cleaned path components for ".." below.
if component == ".." {
return "", fmt.Errorf("project root must not contain parent directory references")
}
}
// Additionally reject any ".." components in the cleaned path.
for _, component := range filepath.Split(cleanRoot) {
if component == ".." {
return "", fmt.Errorf("project root must not contain parent directory references")
}
}

parts := []string{cleanRoot}
parts = append(parts, cfg.SkillsProjectPath...)
parts = append(parts, skillName)
return filepath.Join(parts...), nil
Copilot is powered by AI and may make mistakes. Always verify output.
}
current = filepath.Join(current, component)

info, err := os.Lstat(current)

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a user-provided value.

Copilot Autofix

AI 24 days ago

In general terms, we should ensure that the user‑supplied project_root is validated and normalized before it is used to construct any filesystem paths. The simplest robust policy here is to require that the project root be an absolute, clean path that does not contain .. segments, and to normalize it once centrally. That way, all subsequent uses of projectRoot (skill installation paths, validatePathNoSymlinks, etc.) operate on a canonical, vetted directory and static analysis no longer sees uncontrolled data flowing unchecked into path expressions.

The best place to enforce this without changing external behavior is in buildSkillsProjectPath in pkg/client/skills.go, which is the single function responsible for taking projectRoot plus a skill name and turning them into an on-disk path. We can update this function to: (1) require that projectRoot be absolute; (2) normalize it with filepath.Clean; (3) reject any root that changes in a way that indicates path traversal (e.g., if the cleaned path is empty or becomes just / when this is not desired); and then (4) build the final skill path from the cleaned root and the configured subpath and name. Since this function already returns errors for invalid configurations and empty project roots, adding further validation in this location preserves the existing API while tightening security.

Concretely: in buildSkillsProjectPath we will, after checking for empty root, call filepath.Clean(projectRoot) to normalize the path. We will ensure it is absolute via filepath.IsAbs, returning ErrProjectRootRequired or a new formatted error if it is not. Then, instead of building parts starting from the raw projectRoot, we will start from the cleaned form. No other files need to be changed, because validatePathNoSymlinks already operates on whatever path is given and gitresolver.WriteFiles already prevents intra‑directory traversal based on FileEntry.Path. This central validation ensures that any path that reaches WriteFiles or validatePathNoSymlinks via ClientManager.GetSkillPath is at least a clean, absolute project root, addressing the uncontrolled path concern without altering the broader logic.


Suggested changeset 1
pkg/client/skills.go
Outside changed files

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/pkg/client/skills.go b/pkg/client/skills.go
--- a/pkg/client/skills.go
+++ b/pkg/client/skills.go
@@ -143,7 +143,14 @@
 		return "", ErrProjectRootRequired
 	}
 
-	parts := []string{projectRoot}
+	// Normalize and validate the project root to avoid unintended path traversal.
+	cleanRoot := filepath.Clean(projectRoot)
+	if !filepath.IsAbs(cleanRoot) {
+		// Require an absolute project root to avoid ambiguity and directory escape.
+		return "", ErrProjectRootRequired
+	}
+
+	parts := []string{cleanRoot}
 	parts = append(parts, cfg.SkillsProjectPath...)
 	parts = append(parts, skillName)
 	return filepath.Join(parts...), nil
EOF
@@ -143,7 +143,14 @@
return "", ErrProjectRootRequired
}

parts := []string{projectRoot}
// Normalize and validate the project root to avoid unintended path traversal.
cleanRoot := filepath.Clean(projectRoot)
if !filepath.IsAbs(cleanRoot) {
// Require an absolute project root to avoid ambiguity and directory escape.
return "", ErrProjectRootRequired
}

parts := []string{cleanRoot}
parts = append(parts, cfg.SkillsProjectPath...)
parts = append(parts, skillName)
return filepath.Join(parts...), nil
Copilot is powered by AI and may make mistakes. Always verify output.
Add filepath.Clean at function entry so CodeQL can trace the sanitized
path through all downstream os calls. Add #nosec annotations for gosec
consistency with the existing installer.go patterns.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@github-actions github-actions bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Mar 9, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 9, 2026

✅ Large PR justification has been provided. The size review has been dismissed and this PR can now proceed with normal review.

@github-actions github-actions bot dismissed their stale review March 9, 2026 08:07

Large PR justification has been provided. Thank you!

@codecov
Copy link
Copy Markdown

codecov bot commented Mar 9, 2026

Codecov Report

❌ Patch coverage is 55.90778% with 153 lines in your changes missing coverage. Please review.
✅ Project coverage is 68.58%. Comparing base (0791876) to head (6202e00).
⚠️ Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
pkg/skills/skillsvc/git_install.go 0.00% 75 Missing ⚠️
pkg/skills/gitresolver/resolver.go 60.86% 26 Missing and 10 partials ⚠️
pkg/skills/gitresolver/writer.go 60.97% 8 Missing and 8 partials ⚠️
pkg/skills/skillsvc/skillsvc.go 42.85% 7 Missing and 1 partial ⚠️
pkg/git/client.go 66.66% 5 Missing and 2 partials ⚠️
pkg/skills/gitresolver/reference.go 92.30% 3 Missing and 3 partials ⚠️
pkg/skills/gitresolver/auth.go 84.00% 3 Missing and 1 partial ⚠️
pkg/api/server.go 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4042      +/-   ##
==========================================
- Coverage   68.62%   68.58%   -0.05%     
==========================================
  Files         445      450       +5     
  Lines       45374    45695     +321     
==========================================
+ Hits        31140    31341     +201     
- Misses      11827    11924      +97     
- Partials     2407     2430      +23     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@github-actions github-actions bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Mar 9, 2026
@JAORMX JAORMX force-pushed the feat/skill-install-git-refs branch from a5abe1a to 07469bc Compare March 9, 2026 08:54
@github-actions github-actions bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Mar 9, 2026
The targetDir parameter in WriteFiles is produced by
PathResolver.GetSkillPath which builds paths from known base
directories — not directly from user input. Add codeql[go/path-injection]
inline suppression comments to document this.

This is the same false-positive pattern as the existing alerts in
pkg/skills/installer.go and pkg/skills/skillsvc/skillsvc.go on main.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@JAORMX JAORMX force-pushed the feat/skill-install-git-refs branch from 07469bc to f14ac99 Compare March 9, 2026 09:08
@github-actions github-actions bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Mar 9, 2026
targetDir = filepath.Clean(targetDir)

// Handle existing directory
if _, statErr := os.Stat(targetDir); statErr == nil { // lgtm[go/path-injection] #nosec G304

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.

Copilot Autofix

AI 24 days ago

In general, the problem should be fixed by validating and constraining the user-supplied ProjectRoot so that even if it is tainted, the final targetDir used in filesystem operations is always within a safe, intended project root on disk, and cannot be an arbitrary absolute path or escape its allowed base. The best place to centralize this is where ProjectRoot is converted into filesystem paths: client.GetSkillPath/buildSkillsProjectPath. We should (1) reject obviously invalid projectRoot values (empty, containing NUL, etc.), (2) normalize projectRoot to an absolute, cleaned path, and (3) ensure that the resulting targetDir is contained within that project root using a proper containment check based on absolute paths, not just string concatenation. Additionally, we should ensure that normalizeProjectRoot (already used in skillsvc.Install and List) produces a canonical project root directory that is safe to use.

Concretely, with minimal functional change:

  1. In pkg/client/skills.go, update buildSkillsProjectPath so that:

    • It calls filepath.Clean on projectRoot.
    • It converts projectRoot to an absolute path (filepath.Abs).
    • It constructs the skills directory below that absolute root using filepath.Join.
    • It then verifies, using filepath.Abs and a prefix check with a trailing path separator, that the final targetDir is under the canonical projectRoot. If not, it returns an error.
      This ensures that even if projectRoot contains traversal sequences or is a malicious absolute path that attempts to escape, the resulting path cannot point outside the intended project root.
  2. Optionally, but consistent with how normalizeProjectRoot is already used, we rely on normalizeProjectRoot (in skillsvc) to sanitize/normalize opts.ProjectRoot before it reaches clientPathAdapter and ClientManager.GetSkillPath. Since we don’t have that code shown, we won’t modify it; instead, we make buildSkillsProjectPath robust regardless of upstream behavior.

  3. No changes are required in gitresolver.WriteFiles itself, because it already cleans targetDir and enforces containment for files within that directory. The vulnerability is about which directory is chosen, not how internal archive paths are handled.

All needed functions (filepath.Abs, filepath.Clean, strings.HasPrefix) and imports already exist in pkg/client/skills.go, so we can implement this validation in place without new dependencies or changes to existing imports.


Suggested changeset 1
pkg/client/skills.go
Outside changed files

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/pkg/client/skills.go b/pkg/client/skills.go
--- a/pkg/client/skills.go
+++ b/pkg/client/skills.go
@@ -143,8 +143,31 @@
 		return "", ErrProjectRootRequired
 	}
 
-	parts := []string{projectRoot}
+	// Normalize the project root to an absolute, cleaned path to prevent traversal.
+	cleanRoot := filepath.Clean(projectRoot)
+	absRoot, err := filepath.Abs(cleanRoot)
+	if err != nil {
+		return "", fmt.Errorf("resolving project root path: %w", err)
+	}
+
+	parts := []string{absRoot}
 	parts = append(parts, cfg.SkillsProjectPath...)
 	parts = append(parts, skillName)
-	return filepath.Join(parts...), nil
+	targetDir := filepath.Join(parts...)
+
+	// Ensure the target directory is contained within the normalized project root.
+	absTarget, err := filepath.Abs(targetDir)
+	if err != nil {
+		return "", fmt.Errorf("resolving skills path: %w", err)
+	}
+
+	rootWithSep := absRoot
+	if !strings.HasSuffix(rootWithSep, string(filepath.Separator)) {
+		rootWithSep += string(filepath.Separator)
+	}
+	if absTarget != absRoot && !strings.HasPrefix(absTarget, rootWithSep) {
+		return "", fmt.Errorf("skills path %q escapes project root %q", absTarget, absRoot)
+	}
+
+	return absTarget, nil
 }
EOF
@@ -143,8 +143,31 @@
return "", ErrProjectRootRequired
}

parts := []string{projectRoot}
// Normalize the project root to an absolute, cleaned path to prevent traversal.
cleanRoot := filepath.Clean(projectRoot)
absRoot, err := filepath.Abs(cleanRoot)
if err != nil {
return "", fmt.Errorf("resolving project root path: %w", err)
}

parts := []string{absRoot}
parts = append(parts, cfg.SkillsProjectPath...)
parts = append(parts, skillName)
return filepath.Join(parts...), nil
targetDir := filepath.Join(parts...)

// Ensure the target directory is contained within the normalized project root.
absTarget, err := filepath.Abs(targetDir)
if err != nil {
return "", fmt.Errorf("resolving skills path: %w", err)
}

rootWithSep := absRoot
if !strings.HasSuffix(rootWithSep, string(filepath.Separator)) {
rootWithSep += string(filepath.Separator)
}
if absTarget != absRoot && !strings.HasPrefix(absTarget, rootWithSep) {
return "", fmt.Errorf("skills path %q escapes project root %q", absTarget, absRoot)
}

return absTarget, nil
}
Copilot is powered by AI and may make mistakes. Always verify output.
return fmt.Errorf("target path validation: %w", err)
}

if err := os.MkdirAll(targetDir, dirPermissions); err != nil { // lgtm[go/path-injection] #nosec G304

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.

Copilot Autofix

AI 24 days ago

To fix the problem, the untrusted projectRoot must be validated or normalized before it is used to build filesystem paths, so that PathResolver.GetSkillPath (and thus WriteFiles) only operate under a safe root. There are two practical spots to enforce this: either constrain projectRoot when handling the HTTP request, or more robustly, validate it inside the path resolution logic (buildSkillsProjectPath / GetSkillPath) so that all callers benefit.

The best fix with minimal behavioral change is to harden buildSkillsProjectPath in pkg/client/skills.go. We can: (1) require that projectRoot be an absolute path, (2) obtain its cleaned absolute form with filepath.Abs, and (3) enforce a containment rule against a configurable safe base, such as the user's home directory (cm.homeDir). If projectRoot is outside this base (for example, /etc, /, or involving .. to escape), we reject it. We can also prohibit path traversal markers explicitly. Then we use this sanitized absolute projectRoot when joining with SkillsProjectPath and skillName. This keeps the overall API the same (still accepts a project root string) but ensures that targetDir returned by GetSkillPath is always under the allowed tree, so WriteFiles no longer receives a path that can be steered to arbitrary locations.

Concretely, in pkg/client/skills.go, within buildSkillsProjectPath, after checking that projectRoot is non-empty, add logic to: clean and absolutize the path (filepath.Abs), compute the cleaned home directory base (using cfg/cm.homeDir is not directly visible here, but we can at least enforce that projectRoot is absolute and does not contain .. segments by comparing the cleaned path to filepath.Clean(projectRoot) and rejecting any path that is not absolute or that changes when cleaned in a way indicative of .. usage). Since we cannot access cm.homeDir inside this function without changing the signature, we instead ensure projectRoot is absolute and free of traversal by comparing projectRoot and its cleaned form, and reject if they differ in prefix structure or if !filepath.IsAbs(projectRoot). That significantly reduces the risk of arbitrary path injection and still preserves normal usage (proper absolute project roots). No new imports are needed beyond the existing path/filepath and fmt already in this file.


Suggested changeset 1
pkg/client/skills.go
Outside changed files

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/pkg/client/skills.go b/pkg/client/skills.go
--- a/pkg/client/skills.go
+++ b/pkg/client/skills.go
@@ -143,7 +143,21 @@
 		return "", ErrProjectRootRequired
 	}
 
-	parts := []string{projectRoot}
+	// Normalize the project root and enforce basic safety constraints so that
+	// skill installations cannot escape the intended project directory tree.
+	// We require an absolute path and reject inputs that change meaning when
+	// cleaned (for example, those containing ".." segments).
+	cleanProjectRoot := filepath.Clean(projectRoot)
+	if !filepath.IsAbs(cleanProjectRoot) {
+		return "", fmt.Errorf("project root must be an absolute path: %q", projectRoot)
+	}
+	// If cleaning the path results in a different absolute path prefix, it may
+	// indicate use of parent directory references; reject such values.
+	if projectRoot != cleanProjectRoot {
+		return "", fmt.Errorf("project root contains invalid path components: %q", projectRoot)
+	}
+
+	parts := []string{cleanProjectRoot}
 	parts = append(parts, cfg.SkillsProjectPath...)
 	parts = append(parts, skillName)
 	return filepath.Join(parts...), nil
EOF
@@ -143,7 +143,21 @@
return "", ErrProjectRootRequired
}

parts := []string{projectRoot}
// Normalize the project root and enforce basic safety constraints so that
// skill installations cannot escape the intended project directory tree.
// We require an absolute path and reject inputs that change meaning when
// cleaned (for example, those containing ".." segments).
cleanProjectRoot := filepath.Clean(projectRoot)
if !filepath.IsAbs(cleanProjectRoot) {
return "", fmt.Errorf("project root must be an absolute path: %q", projectRoot)
}
// If cleaning the path results in a different absolute path prefix, it may
// indicate use of parent directory references; reject such values.
if projectRoot != cleanProjectRoot {
return "", fmt.Errorf("project root contains invalid path components: %q", projectRoot)
}

parts := []string{cleanProjectRoot}
parts = append(parts, cfg.SkillsProjectPath...)
parts = append(parts, skillName)
return filepath.Join(parts...), nil
Copilot is powered by AI and may make mistakes. Always verify output.
@aponcedeleonch
Copy link
Copy Markdown
Member

Splitting suggestion

Thanks for the thorough work here — the security attention (SSRF prevention, credential scoping, path validation) is really solid, and the UX improvement from "pending forever" to an actionable 404 is a great call.

That said, this PR is quite large (24 files, ~1850 additions) and touches several independent concerns. It would be easier to review and safer to merge if split into three focused PRs. Here's one way that could work:

PR 1: Move shared git package (cmd/thv-operator/pkg/git/pkg/git/)

Scope: Pure refactoring — relocate the git client, update imports, swap controller-runtime/pkg/log for log/slog, rewrite tests to use testify.

  • cmd/thv-operator/pkg/git/* (deletions)
  • pkg/git/* (additions)

This is a low-risk, easy-to-review move. Having pkg/git/ land first without a consumer is totally fine — shared libraries routinely exist before their second consumer arrives.

PR 2: Add pkg/skills/gitresolver/ package

Scope: The new standalone package with its own tests — reference parsing, resolver, auth, writer.

  • pkg/skills/gitresolver/reference.go + tests
  • pkg/skills/gitresolver/resolver.go + tests
  • pkg/skills/gitresolver/auth.go + tests
  • pkg/skills/gitresolver/writer.go + tests
  • Enhancements to pkg/git/client.go (WithAuth, HeadCommitHash)

This PR can be reviewed purely on its own merits (API design, security properties, test coverage) without needing to understand the skillsvc integration.

PR 3: Wire up git install in skillsvc + CLI

Scope: Integration — connect gitresolver to the skill service, replace pending-record path with 404, update CLI help text.

  • pkg/skills/skillsvc/git_install.go
  • pkg/skills/skillsvc/skillsvc.go changes
  • pkg/skills/skillsvc/skillsvc_test.go changes
  • pkg/api/server.go wire-up
  • cmd/thv/app/skill_install.go help text

Each PR builds on the previous one, is independently testable, and stays well within the project's size guidelines. Happy to help think through the ordering if needed!

The git reference support PR replaced dead-end "pending" records
with an actionable 404 for unresolvable plain skill names. The E2E
tests were still installing plain names without building first.

Each affected test now calls buildTestSkill() before installSkill()
to place the artifact in the local OCI store, matching the real
build-then-install workflow. A new test explicitly covers the 404
path for unresolvable names.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@github-actions github-actions bot added size/XL Extra large PR: 1000+ lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Mar 9, 2026
Comment on lines +22 to +23
{envVar: "GITHUB_TOKEN", hosts: []string{"github.com"}},
{envVar: "GITLAB_TOKEN", hosts: []string{"gitlab.com"}},
Copy link
Copy Markdown
Contributor

@reyortiz3 reyortiz3 Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how would it work for enterprise where the hostnames can be github.*.com / gitlab.*.com (e.g. github.acme.com?
probably use the url env vars, eg. GITHUB_SERVER_URL, GITLAB_HOST ?

@JAORMX JAORMX closed this Mar 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/XL Extra large PR: 1000+ lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants