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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -671,7 +671,7 @@ Only the given path is searched. The [agentskills.io implementation guide](https
- Subdirectories are searched up to 6 levels deep by default. Use `--max-depth=N` to change this; `--max-depth=1` searches only the immediate subdirectories, and `--max-depth=0` treats the given path as a single skill and nothing else.
- A directory that contains a `SKILL.md` is a skill, and nothing below it is searched, so a `SKILL.md` inside a skill's examples or references does not count as a second skill.
- Hidden directories (names starting with `.`, which covers `.git/`) and dependency directories (`node_modules/`, `__pycache__/`) are skipped. `.gitignore` is not consulted: honoring it would mean locating the repository root and evaluating nested ignore rules, and the directory budget already bounds the cost of scanning build output.
- Symlinks are followed, so a skills directory assembled from links to skill folders kept elsewhere works. Each real directory is examined once, which makes symlink cycles harmless and keeps a skill from being reported twice under two paths.
- Symlinks and, on Windows, directory junctions are followed, so a skills directory assembled from links to skill folders kept elsewhere works. Each real directory is examined once, which makes link cycles harmless and keeps a skill from being reported twice under two paths.
- At most 2000 directories are listed. A skill is found as soon as its parent directory is listed, and the search is breadth-first, so shallow skills are found before a large subtree can use up that budget. If the limit is reached, a warning on stderr says that skills below that point were not found.

Each skill is validated independently. The text output separates skills with a line and appends an overall summary. The JSON output wraps individual skill reports in a `skills` array:
Expand Down
102 changes: 102 additions & 0 deletions cmd/junction_windows_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
//go:build windows

package cmd_test

import (
"bytes"
"encoding/json"
"errors"
"io/fs"
"os"
"os/exec"
"path/filepath"
"testing"

"github.com/agent-ecosystem/skill-validator/internal/testutil"
)

// copySkill copies the skill fixture at src to dst and renames it after its
// new directory, since a skill's name must match the directory it lives in.
func copySkill(t *testing.T, src, dst string) {
t.Helper()
err := filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(src, path)
if err != nil {
return err
}
target := filepath.Join(dst, rel)
if d.IsDir() {
return os.MkdirAll(target, 0o755)
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
if rel == "SKILL.md" {
data = bytes.Replace(data, []byte("name: "+filepath.Base(src)), []byte("name: "+filepath.Base(dst)), 1)
}
return os.WriteFile(target, data, 0o644)
})
if err != nil {
t.Fatal(err)
}
}

// Issue #101 end to end: a skills directory made of junctions, the layout
// that was reported, must validate every skill rather than silently only
// the ones that are real directories.
func TestCheckJunctionedCatalog(t *testing.T) {
bin := buildBinary(t)
valid := fixture(t, "valid-skill")

source := t.TempDir()
catalog := t.TempDir()
names := []string{"alpha", "beta", "gamma"}
for _, name := range names {
copySkill(t, valid, filepath.Join(source, name))
testutil.Junction(t, filepath.Join(source, name), filepath.Join(catalog, name))
}
copySkill(t, valid, filepath.Join(catalog, "plain"))
wantSkills := len(names) + 1

out, err := exec.Command(bin, "check", "-o", "json", catalog).Output()
var exitErr *exec.ExitError
if err != nil && !errors.As(err, &exitErr) {
t.Fatalf("running check: %v", err)
}

var report struct {
Skills []struct {
SkillDir string `json:"skill_dir"`
Errors int `json:"errors"`
Results []struct {
Level string `json:"level"`
Message string `json:"message"`
} `json:"results"`
} `json:"skills"`
}
if err := json.Unmarshal(out, &report); err != nil {
t.Fatalf("parsing report: %v\n%s", err, out)
}
if len(report.Skills) != wantSkills {
var dirs []string
for _, s := range report.Skills {
dirs = append(dirs, s.SkillDir)
}
t.Fatalf("validated %d skills %v, want %d", len(report.Skills), dirs, wantSkills)
}
for _, s := range report.Skills {
if s.Errors == 0 {
continue
}
t.Errorf("%s: %d errors", s.SkillDir, s.Errors)
for _, r := range s.Results {
if r.Level == "error" {
t.Logf(" %s", r.Message)
}
}
}
}
19 changes: 19 additions & 0 deletions internal/testutil/junction_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
//go:build windows

package testutil

import (
"os/exec"
"testing"
)

// Junction creates a directory junction at link leading to target. Unlike a
// symlink, a junction needs no privilege, which is why it is the common way
// to alias a directory on Windows and why Windows CI can always exercise it.
func Junction(t testing.TB, target, link string) {
t.Helper()
out, err := exec.Command("cmd", "/c", "mklink", "/J", link, target).CombinedOutput()
if err != nil {
t.Fatalf("mklink /J %s %s: %v\n%s", link, target, err, out)
}
}
5 changes: 5 additions & 0 deletions internal/testutil/privilege_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
//go:build !windows

package testutil

func isPrivilegeError(error) bool { return false }
12 changes: 12 additions & 0 deletions internal/testutil/privilege_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
//go:build windows

package testutil

import (
"errors"
"syscall"
)

func isPrivilegeError(err error) bool {
return errors.Is(err, syscall.ERROR_PRIVILEGE_NOT_HELD)
}
24 changes: 24 additions & 0 deletions internal/testutil/symlink.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Package testutil holds helpers shared by the test suites.
package testutil

import (
"os"
"testing"
)

// Symlink creates link pointing at target, or skips the test when the host
// refuses. Windows grants symlink creation only to elevated processes and to
// accounts with Developer Mode on. The suites used to skip every symlink
// test on Windows outright, which kept the platform's link handling out of
// CI even though the hosted Windows runners are elevated (#101).
func Symlink(t testing.TB, target, link string) {
t.Helper()
err := os.Symlink(target, link)
if err == nil {
return
}
if isPrivilegeError(err) {
t.Skipf("cannot create symlinks on this host: %v", err)
}
t.Fatal(err)
}
9 changes: 2 additions & 7 deletions skill/skill_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import (
"errors"
"os"
"path/filepath"
"runtime"
"testing"

"github.com/agent-ecosystem/skill-validator/internal/testutil"
"github.com/agent-ecosystem/skill-validator/util"
)

Expand Down Expand Up @@ -127,17 +127,12 @@ func TestLoad(t *testing.T) {
})

t.Run("refuses SKILL.md symlink", func(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlinks require admin on Windows")
}
dir := t.TempDir()
secret := filepath.Join(dir, "secret.md")
if err := os.WriteFile(secret, []byte("---\nname: x\ndescription: x\n---\nLEAK"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(secret, filepath.Join(dir, "SKILL.md")); err != nil {
t.Fatal(err)
}
testutil.Symlink(t, secret, filepath.Join(dir, "SKILL.md"))
_, err := Load(dir)
if !errors.Is(err, util.ErrUnsafeFile) {
t.Fatalf("expected ErrUnsafeFile, got %v", err)
Expand Down
107 changes: 107 additions & 0 deletions skillcheck/discovery_windows_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
//go:build windows

package skillcheck

import (
"os"
"path/filepath"
"reflect"
"testing"

"github.com/agent-ecosystem/skill-validator/internal/testutil"
)

// Issue #101: Go reports a directory junction as an irregular file, neither
// a directory nor a symlink, so discovery dropped every junctioned skill
// without a word. Junctions need no privilege, so these run on any Windows
// host.
func TestDiscoverSkills_Junctions(t *testing.T) {
t.Run("junctioned catalog is discovered", func(t *testing.T) {
// The reported layout: a real source tree elsewhere and a skills
// directory made of junctions into it, beside one ordinary skill.
source := t.TempDir()
dir := t.TempDir()
var want []string
for _, name := range []string{"alpha", "beta", "gamma"} {
writeSkill(t, filepath.Join(source, name), "---\nname: "+name+"\n---\n")
testutil.Junction(t, filepath.Join(source, name), filepath.Join(dir, name))
want = append(want, filepath.Join(dir, name))
}
writeSkill(t, filepath.Join(dir, "plain"), "---\nname: plain\n---\n")
want = append(want, filepath.Join(dir, "plain"))

d := DiscoverSkills(dir, DefaultMaxDepth)
if !reflect.DeepEqual(d.Dirs, want) {
t.Errorf("dirs = %v, want %v", d.Dirs, want)
}
})

t.Run("junction to a grouping directory is searched", func(t *testing.T) {
source := t.TempDir()
writeSkill(t, filepath.Join(source, "team", "skill"), "---\nname: s\n---\n")
dir := t.TempDir()
testutil.Junction(t, source, filepath.Join(dir, "linked"))

d := DiscoverSkills(dir, DefaultMaxDepth)
if want := []string{filepath.Join(dir, "linked", "team", "skill")}; !reflect.DeepEqual(d.Dirs, want) {
t.Errorf("dirs = %v, want %v", d.Dirs, want)
}
})

t.Run("alias of a skill is reported once", func(t *testing.T) {
dir := t.TempDir()
writeSkill(t, filepath.Join(dir, "real"), "---\nname: s\n---\n")
testutil.Junction(t, filepath.Join(dir, "real"), filepath.Join(dir, "alias"))

d := DiscoverSkills(dir, DefaultMaxDepth)
if len(d.Dirs) != 1 {
t.Errorf("dirs = %v, want exactly one", d.Dirs)
}
})

t.Run("cycle to root", func(t *testing.T) {
dir := t.TempDir()
writeSkill(t, filepath.Join(dir, "team", "skill"), "---\nname: s\n---\n")
testutil.Junction(t, dir, filepath.Join(dir, "loop"))

d := DiscoverSkills(dir, DefaultMaxDepth)
if want := []string{filepath.Join(dir, "team", "skill")}; !reflect.DeepEqual(d.Dirs, want) {
t.Errorf("dirs = %v, want %v", d.Dirs, want)
}
if d.Truncated {
t.Error("a cycle must not consume the directory budget")
}
})

t.Run("symlink to a skill behind a junction", func(t *testing.T) {
source := t.TempDir()
writeSkill(t, filepath.Join(source, "skill"), "---\nname: s\n---\n")
junction := filepath.Join(t.TempDir(), "junction")
testutil.Junction(t, source, junction)
dir := t.TempDir()
testutil.Symlink(t, filepath.Join(junction, "skill"), filepath.Join(dir, "alias"))

d := DiscoverSkills(dir, DefaultMaxDepth)
if want := []string{filepath.Join(dir, "alias")}; !reflect.DeepEqual(d.Dirs, want) {
t.Errorf("dirs = %v, want %v", d.Dirs, want)
}
})

t.Run("dangling junction is ignored", func(t *testing.T) {
dir := t.TempDir()
writeSkill(t, filepath.Join(dir, "skill"), "---\nname: s\n---\n")
gone := filepath.Join(t.TempDir(), "gone")
if err := os.Mkdir(gone, 0o755); err != nil {
t.Fatal(err)
}
testutil.Junction(t, gone, filepath.Join(dir, "dangling"))
if err := os.Remove(gone); err != nil {
t.Fatal(err)
}

d := DiscoverSkills(dir, DefaultMaxDepth)
if want := []string{filepath.Join(dir, "skill")}; !reflect.DeepEqual(d.Dirs, want) {
t.Errorf("dirs = %v, want %v", d.Dirs, want)
}
})
}
10 changes: 6 additions & 4 deletions skillcheck/validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ func discoverSkills(root string, maxDepth, maxDirs int) Discovery {
if err != nil {
return Discovery{Mode: types.NoSkill}
}
realRoot, err := filepath.EvalSymlinks(absRoot)
realRoot, err := util.RealPath(absRoot)
if err != nil {
return Discovery{Mode: types.NoSkill}
}
Expand Down Expand Up @@ -152,10 +152,12 @@ func discoverSkills(root string, maxDepth, maxDirs int) Discovery {
switch {
case entry.IsDir():
real = filepath.Join(cur.real, name)
case entry.Type()&os.ModeSymlink != 0:
// Follow the link, but only into a directory that exists.
case entry.Type()&(os.ModeSymlink|os.ModeIrregular) != 0:
// A symlink, or on Windows a directory junction, which Go
// reports as irregular rather than as a symlink (#101).
// Follow it, but only into a directory that exists.
// Resolving through cur.real keeps the key absolute.
resolved, err := filepath.EvalSymlinks(filepath.Join(cur.real, name))
resolved, err := util.RealPath(filepath.Join(cur.real, name))
if err != nil {
continue
}
Expand Down
Loading
Loading