diff --git a/agent/skills/fsskills/source.go b/agent/skills/fsskills/source.go index 95e01f58..c47c6af4 100644 --- a/agent/skills/fsskills/source.go +++ b/agent/skills/fsskills/source.go @@ -204,8 +204,11 @@ func discoverSkillDirectories(filesystems []fs.FS) []discoveredSkillDir { // script discovery within an already-discovered skill directory. This matches // the .NET SDK, which bounds the two concerns separately. func searchForSkills(filesystem fs.FS, dir string, results *[]discoveredSkillDir, currentDepth int) { - skillPath := path.Join(dir, skillFileName) - if _, err := fs.Stat(filesystem, skillPath); err == nil { + entries, err := fs.ReadDir(filesystem, dir) + if err != nil { + return + } + if hasNonSymlinkSkillFile(entries) { sub := filesystem var subErr error if dir != "." { @@ -219,10 +222,6 @@ func searchForSkills(filesystem fs.FS, dir string, results *[]discoveredSkillDir if currentDepth >= defaultSearchDepth { return } - entries, err := fs.ReadDir(filesystem, dir) - if err != nil { - return - } for _, entry := range entries { if entry.IsDir() { searchForSkills(filesystem, path.Join(dir, entry.Name()), results, currentDepth+1) @@ -501,6 +500,10 @@ func (s *Source) scanForFiles( } for _, entry := range entries { + if isSymlinkEntry(entry) { + continue + } + entryPath := path.Join(dir, entry.Name()) if entry.IsDir() { if currentDepth < s.searchDepth { @@ -534,6 +537,26 @@ func (s *Source) scanForFiles( } } +func hasNonSymlinkSkillFile(entries []fs.DirEntry) bool { + for _, entry := range entries { + if entry.Name() == skillFileName && !isSymlinkEntry(entry) { + return true + } + } + return false +} + +func isSymlinkEntry(entry fs.DirEntry) bool { + if entry.Type()&fs.ModeSymlink != 0 { + return true + } + info, err := entry.Info() + if err != nil { + return false + } + return info.Mode()&fs.ModeSymlink != 0 +} + func buildExtensionSet(extensions []string, defaults []string) map[string]bool { if extensions == nil { extensions = defaults diff --git a/agent/skills/fsskills/source_script_test.go b/agent/skills/fsskills/source_script_test.go index 744fd889..d9650bcb 100644 --- a/agent/skills/fsskills/source_script_test.go +++ b/agent/skills/fsskills/source_script_test.go @@ -342,6 +342,30 @@ func TestFileSource_ScriptFilter_IncludesOnlyMatchingScripts(t *testing.T) { } } +func TestFileSource_SymlinkedScript_IsNotDiscovered(t *testing.T) { + root := t.TempDir() + outside := t.TempDir() + createSkillDir(t, root, "symlink-script-skill", "Symlink script test", "Body.") + outsideScript := filepath.Join(outside, "run.py") + if err := os.WriteFile(outsideScript, []byte("print('secret')"), 0o644); err != nil { + t.Fatal(err) + } + mustCreateSymlink(t, outsideScript, filepath.Join(root, "symlink-script-skill", "scripts", "run.py")) + + source := fsskills.NewSourceOptions(fsskills.SourceOptions{ + ScriptRunner: func(context.Context, *skills.Skill, *skills.Script, []string) (any, error) { + return nil, nil + }, + }, os.DirFS(root)) + loaded, err := source.Skills(t.Context()) + if err != nil { + t.Fatal(err) + } + if len(loaded[0].Scripts) != 0 { + t.Fatalf("expected symlinked script to be ignored, got %d scripts", len(loaded[0].Scripts)) + } +} + func TestFileScript_RunWithNonFileSkill_ReturnsError(t *testing.T) { root := t.TempDir() createSkillDir(t, root, "script-owner", "Script owner", "Body.") diff --git a/agent/skills/fsskills/source_test.go b/agent/skills/fsskills/source_test.go index 06b69d1f..67bb487b 100644 --- a/agent/skills/fsskills/source_test.go +++ b/agent/skills/fsskills/source_test.go @@ -36,6 +36,29 @@ func TestFileSource_NonExistentPath_ReturnsEmptyList(t *testing.T) { } } +func TestFileSource_SymlinkedSkillFile_IsNotDiscovered(t *testing.T) { + root := t.TempDir() + outside := t.TempDir() + linkedSkillDir := filepath.Join(root, "linked-skill") + if err := os.MkdirAll(linkedSkillDir, 0o755); err != nil { + t.Fatal(err) + } + outsideSkillFile := filepath.Join(outside, "SKILL.md") + if err := os.WriteFile(outsideSkillFile, []byte("---\nname: linked-skill\ndescription: Linked\n---\nBody."), 0o644); err != nil { + t.Fatal(err) + } + mustCreateSymlink(t, outsideSkillFile, filepath.Join(linkedSkillDir, "SKILL.md")) + + source := fsskills.NewSource(os.DirFS(root)) + loaded, err := source.Skills(t.Context()) + if err != nil { + t.Fatal(err) + } + if len(loaded) != 0 { + t.Fatalf("expected symlinked SKILL.md to be ignored, got %d skills", len(loaded)) + } +} + func TestFileSource_NoResourceFiles_ReturnsEmptyResources(t *testing.T) { root := t.TempDir() createSkillDir(t, root, "no-resources", "A skill", "No resources here.") @@ -548,6 +571,26 @@ func TestFileSource_NoDuplicateResourcesFromSamePath(t *testing.T) { } } +func TestFileSource_SymlinkedResource_IsNotDiscovered(t *testing.T) { + root := t.TempDir() + outside := t.TempDir() + createSkillDir(t, root, "symlink-resource-skill", "Symlink resource test", "Body.") + outsideResource := filepath.Join(outside, "secret.md") + if err := os.WriteFile(outsideResource, []byte("secret"), 0o644); err != nil { + t.Fatal(err) + } + mustCreateSymlink(t, outsideResource, filepath.Join(root, "symlink-resource-skill", "references", "secret.md")) + + source := fsskills.NewSource(os.DirFS(root)) + loaded, err := source.Skills(t.Context()) + if err != nil { + t.Fatal(err) + } + if len(loaded[0].Resources) != 0 { + t.Fatalf("expected symlinked resource to be ignored, got %d resources", len(loaded[0].Resources)) + } +} + func createSkillDir(t *testing.T, root, name, description, body string) { t.Helper() skillDir := filepath.Join(root, name) @@ -594,3 +637,13 @@ func createRelativeFile(t *testing.T, root, relativePath, content string) { t.Fatal(err) } } + +func mustCreateSymlink(t *testing.T, target, link string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(link), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, link); err != nil { + t.Skipf("symlinks not supported: %v", err) + } +}