Skip to content

Commit d43dfae

Browse files
committed
Avoid looping forever on symlinks
Signed-off-by: David Gageot <david.gageot@docker.com>
1 parent 6b613a2 commit d43dfae

File tree

2 files changed

+64
-0
lines changed

2 files changed

+64
-0
lines changed

pkg/skills/skills.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,22 @@ 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 {
@@ -222,6 +237,17 @@ func loadSkillsRecursive(dir string) []Skill {
222237
if path != dir && isHidden(d) {
223238
return fs.SkipDir
224239
}
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+
}
225251
return nil
226252
}
227253

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)