Conversation
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>
There was a problem hiding this comment.
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 transformationAlternative:
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>
pkg/skills/gitresolver/writer.go
Outdated
| // 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
Show autofix suggestion
Hide autofix suggestion
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:
-
Normalize and constrain project-scoped skill paths in
buildSkillsProjectPath:- Resolve
projectRootto an absolute, cleaned path. - Reject any
projectRootthat 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
projectRootitself 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 relativeproject_rootvalues from being interpreted relative to the server’s current working directory. - Resolve
-
Strengthen
gitresolver.WriteFilesby requiring thattargetDirbe an absolute path and by reusing the existingvalidatePathNoSymlinkslogic:- After
filepath.Clean, callfilepath.IsAbsand reject non-absolute paths. - This ensures that all subsequent operations (including
filepath.AbsinsidevalidatePathNoSymlinks) behave predictably and avoids relative path surprises from tainted inputs.
- After
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, updatebuildSkillsProjectPathso that it:- Converts
projectRoottoabsProjectRoot := filepath.Clean(projectRoot)and then to an absolute path viafilepath.Abs. - Checks
filepath.IsAbs(absProjectRoot)(and theAbserror) and returns an error if not. - Uses
absProjectRoot(not the rawprojectRoot) as the base inparts := []string{absProjectRoot}.
- Converts
-
In
pkg/skills/gitresolver/writer.go, updateWriteFilesso that it:- After
targetDir = filepath.Clean(targetDir), checksif !filepath.IsAbs(targetDir)and returns an error if it’s not absolute. - Leaves the rest of the symlink and containment checks as-is.
- After
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.
| @@ -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 |
| @@ -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 |
pkg/skills/gitresolver/writer.go
Outdated
| 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
Show autofix suggestion
Hide autofix suggestion
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
projectRootwithfilepath.Clean. - Ensure it is an absolute path using
filepath.IsAbs. - Reject any
projectRootwhose cleaned form still contains..path components (indicating attempted traversal). - Use the cleaned
projectRootto constructpartsand thustargetDir.
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.
| @@ -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 | ||
| } |
pkg/skills/gitresolver/writer.go
Outdated
| 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
Show autofix suggestion
Hide autofix suggestion
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
projectRootas already done. - Normalize
projectRootwithfilepath.Clean. - Forbid absolute paths (so that
projectRootmust 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
buildSkillsProjectPathinpkg/client/skills.goto:- Clean
projectRootwithfilepath.Clean. - Reject if
filepath.IsAbs(cleaned)is true. - Split
cleanedonstring(os.PathSeparator)and reject if any component is..(this requires also importingosin this file, which is already imported).
- Clean
- Use
cleanedinstead of the originalprojectRootwhen buildingparts.
No changes are needed in gitresolver.WriteFiles itself; it already performs proper containment checks relative to targetDir.
| @@ -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 |
pkg/skills/gitresolver/writer.go
Outdated
| } | ||
| current = filepath.Join(current, component) | ||
|
|
||
| info, err := os.Lstat(current) |
Check failure
Code scanning / CodeQL
Uncontrolled data used in path expression High
Show autofix suggestion
Hide autofix suggestion
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.
| @@ -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 |
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>
|
✅ Large PR justification has been provided. The size review has been dismissed and this PR can now proceed with normal review. |
Large PR justification has been provided. Thank you!
Codecov Report❌ Patch coverage is 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. 🚀 New features to boost your workflow:
|
a5abe1a to
07469bc
Compare
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>
07469bc to
f14ac99
Compare
| 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
Show autofix suggestion
Hide autofix suggestion
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:
-
In
pkg/client/skills.go, updatebuildSkillsProjectPathso that:- It calls
filepath.CleanonprojectRoot. - It converts
projectRootto an absolute path (filepath.Abs). - It constructs the skills directory below that absolute root using
filepath.Join. - It then verifies, using
filepath.Absand a prefix check with a trailing path separator, that the finaltargetDiris under the canonicalprojectRoot. If not, it returns an error.
This ensures that even ifprojectRootcontains traversal sequences or is a malicious absolute path that attempts to escape, the resulting path cannot point outside the intended project root.
- It calls
-
Optionally, but consistent with how
normalizeProjectRootis already used, we rely onnormalizeProjectRoot(inskillsvc) to sanitize/normalizeopts.ProjectRootbefore it reachesclientPathAdapterandClientManager.GetSkillPath. Since we don’t have that code shown, we won’t modify it; instead, we makebuildSkillsProjectPathrobust regardless of upstream behavior. -
No changes are required in
gitresolver.WriteFilesitself, because it already cleanstargetDirand 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.
| @@ -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 | ||
| } |
| 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
Show autofix suggestion
Hide autofix suggestion
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.
| @@ -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 |
Splitting suggestionThanks 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 (
|
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>
| {envVar: "GITHUB_TOKEN", hosts: []string{"github.com"}}, | ||
| {envVar: "GITLAB_TOKEN", hosts: []string{"gitlab.com"}}, |
There was a problem hiding this comment.
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 ?
Summary
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.thv skill install git://github.com/org/repo#path/to/skillcmd/thv-operator/pkg/git/) is moved topkg/git/as a shared package, replacingcontroller-runtime/pkg/logwithlog/slogfor CLI compatibility.Relates to #4015
Type of change
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 wherepkg/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
task test)task lint-fix)Changes
cmd/thv-operator/pkg/git/*pkg/git/pkg/git/*HeadCommitHash(),WithAuthoption, replaces controller-runtime logger withlog/slogpkg/skills/gitresolver/reference.gogit://URL parsing with SSRF prevention (private IP/localhost rejection, absolute path/backslash rejection)pkg/skills/gitresolver/resolver.goResolverinterface: clones repo, extracts skill files, enforces 2-min clone timeoutpkg/skills/gitresolver/auth.goGITHUB_TOKENonly sent to github.com, etc.)pkg/skills/gitresolver/writer.gopkg/skills/skillsvc/git_install.goinstallFromGit,resolveGitReference,upsertGitSkillmethods with supply chain defense (SKILL.md name must match reference)pkg/skills/skillsvc/skillsvc.goInstall(), dead-endinstallPendingreplaced with actionable 404 error,WithGitResolveroption addedpkg/skills/skillsvc/skillsvc_test.gopkg/api/server.goWithGitResolverin service constructioncmd/thv/app/skill_install.goLongdescription with git reference examplesDoes this introduce a user-facing change?
Yes.
thv skill installnow accepts git references: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 topkg/git/:commit_test.go: Contained two tests that cloned from the realgithub.com/stacklok/toolhiverepo over the network. These were slow, fragile (depend on network availability and a specific commit hash existing), and skipped in-shortmode. 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:
GITHUB_TOKENis only sent togithub.com,GITLAB_TOKENonly togitlab.com.GIT_TOKENis an opt-in fallback for other hosts.context.WithTimeoutinResolve()prevents slowloris-style hangs.validateSkillPathnow rejects absolute paths and backslashes in addition to..traversal.SKILL.mdmust match the name implied by the git reference.LimitedFscaps clones at 10k files / 100MB.Known limitations for follow-up
http.TransportwithDialContexthook)collectFilesreads only top-level files in the skill directory (flat structure)Resolvermock generated yet —installFromGitunit tests should be added with a mockGenerated with Claude Code