Skip to content

Commit 96d4e9e

Browse files
authored
Merge pull request #1875 from dgageot/skills2
Skip hidden directories in recursive skill loading
2 parents 3bdad45 + d43dfae commit 96d4e9e

2 files changed

Lines changed: 81 additions & 12 deletions

File tree

pkg/skills/skills.go

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ func loadSkillsFlat(dir string) []Skill {
194194

195195
var skills []Skill
196196
for _, entry := range entries {
197-
if !entry.IsDir() || isHiddenOrSymlink(entry) {
197+
if !entry.IsDir() || (isHidden(entry) || isSymlink(entry)) {
198198
continue
199199
}
200200

@@ -210,14 +210,48 @@ func loadSkillsFlat(dir string) []Skill {
210210
}
211211

212212
// loadSkillsRecursive loads skills from all subdirectories (Codex format).
213+
// It tracks visited real directory paths to avoid infinite loops caused by
214+
// symlinks that form cycles.
213215
func loadSkillsRecursive(dir string) []Skill {
216+
visited := make(map[string]bool)
217+
218+
// Resolve the root so cycles back to it are detected.
219+
if realDir, err := filepath.EvalSymlinks(dir); err == nil {
220+
visited[realDir] = true
221+
}
222+
223+
return walkSkillsRecursive(dir, visited)
224+
}
225+
226+
// walkSkillsRecursive walks dir for SKILL.md files, using visited to skip
227+
// directories whose real path has already been traversed.
228+
func walkSkillsRecursive(dir string, visited map[string]bool) []Skill {
214229
var skills []Skill
215230

216231
_ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
217-
if err != nil || d.IsDir() {
232+
if err != nil {
233+
return nil
234+
}
235+
236+
if d.IsDir() {
237+
if path != dir && isHidden(d) {
238+
return fs.SkipDir
239+
}
240+
241+
// Resolve and de-duplicate real directory paths to catch
242+
// cycles introduced through symlinks higher up.
243+
if path != dir {
244+
if realPath, err := filepath.EvalSymlinks(path); err == nil {
245+
if visited[realPath] {
246+
return fs.SkipDir
247+
}
248+
visited[realPath] = true
249+
}
250+
}
218251
return nil
219252
}
220-
if isHiddenOrSymlink(d) || d.Name() != skillFile {
253+
254+
if d.Name() != skillFile {
221255
return nil
222256
}
223257

@@ -277,17 +311,14 @@ func parseFrontmatter(content string) (Skill, bool) {
277311
return skill, true
278312
}
279313

280-
// isValidSkill validates skill constraints.
281314
func isValidSkill(skill Skill) bool {
282-
// Description and name is required
283-
if skill.Description == "" || skill.Name == "" {
284-
return false
285-
}
315+
return skill.Description != "" && skill.Name != ""
316+
}
286317

287-
return true
318+
func isHidden(entry fs.DirEntry) bool {
319+
return strings.HasPrefix(entry.Name(), ".")
288320
}
289321

290-
// isHiddenOrSymlink returns true for hidden files/dirs or symlinks.
291-
func isHiddenOrSymlink(entry fs.DirEntry) bool {
292-
return strings.HasPrefix(entry.Name(), ".") || entry.Type()&os.ModeSymlink != 0
322+
func isSymlink(entry fs.DirEntry) bool {
323+
return entry.Type()&os.ModeSymlink != 0
293324
}

pkg/skills/skills_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,44 @@ description: A flat global agents skill
353353
assert.True(t, foundFlat, "Expected to find flat-skill from ~/.agents/skills/flat-skill")
354354
}
355355

356+
func TestLoadSkillsFromDir_RecursiveSymlinkCycle(t *testing.T) {
357+
tmpDir := t.TempDir()
358+
359+
// Create a skill in a subdirectory.
360+
skillDir := filepath.Join(tmpDir, "real-skill")
361+
require.NoError(t, os.MkdirAll(skillDir, 0o755))
362+
363+
skillContent := `---
364+
name: real-skill
365+
description: A real skill
366+
---
367+
368+
# Real Skill
369+
`
370+
require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(skillContent), 0o644))
371+
372+
// Create a symlink cycle: tmpDir/real-skill/link -> tmpDir
373+
require.NoError(t, os.Symlink(tmpDir, filepath.Join(skillDir, "link")))
374+
375+
// loadSkillsRecursive must return without looping forever.
376+
skills := loadSkillsFromDir(tmpDir, true)
377+
378+
require.Len(t, skills, 1)
379+
assert.Equal(t, "real-skill", skills[0].Name)
380+
assert.Equal(t, "A real skill", skills[0].Description)
381+
}
382+
383+
func TestLoadSkillsFromDir_RecursiveSymlinkSelfReference(t *testing.T) {
384+
tmpDir := t.TempDir()
385+
386+
// Create a directory that symlinks to itself.
387+
require.NoError(t, os.Symlink(tmpDir, filepath.Join(tmpDir, "self")))
388+
389+
// Must not loop forever.
390+
skills := loadSkillsFromDir(tmpDir, true)
391+
assert.Empty(t, skills)
392+
}
393+
356394
func TestLoad_AgentsSkillsProjectFromNestedDir(t *testing.T) {
357395
// Create a fake git repo with .agents/skills at the root
358396
tmpRepo := t.TempDir()

0 commit comments

Comments
 (0)