hermes - ✅(Solved) Fix [Bug]: os.walk(followlinks=True) in iter_skill_index_files may infinite-loop on cyclic symlinks [2 pull requests, 2 comments, 2 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
NousResearch/hermes-agent#18809Fetched 2026-05-03 04:54:11
View on GitHub
Comments
2
Participants
2
Timeline
9
Reactions
0
Timeline (top)
labeled ×4commented ×2cross-referenced ×2referenced ×1

Fix Action

Fixed

PR fix notes

PR #18815: fix(skills): prevent infinite loop on cyclic symlinks in iter_skill_index_files

Description (problem / solution / changelog)

Problem

iter_skill_index_files() uses os.walk(followlinks=True) but has no cycle detection. When the skill directory tree contains cyclic symlinks (e.g. A → B → A or self-referencing directories), os.walk enters an infinite loop, causing the agent to hang during skill scanning.

Fixes #18809

Fix

Track visited real paths via os.path.realpath() before descending into each directory. If the canonical path has already been processed, skip that directory entirely. Non-cyclic symlinks are still followed as before.

2 files changed: agent/skill_utils.py (+8 lines), tests/agent/test_skill_utils.py (+97 lines)

Tests

6 new test cases covering:

  • Baseline: normal file discovery
  • Cyclic symlink (A→B→A) terminates without hanging
  • Self-referencing symlink directory terminates
  • Non-cyclic symlinks are still followed correctly
  • Excluded dirs (.git, etc.) are still skipped
  • Diamond symlink pattern deduplicates correctly

Changed files

  • agent/skill_utils.py (modified, +8/-0)
  • scripts/release.py (modified, +1/-0)
  • tests/agent/test_skill_utils.py (modified, +99/-1)

PR #18937: fix(skills): exclude hidden dirs from skill search, guard cyclic symlinks, handle non-dict quick_commands

Description (problem / solution / changelog)

Fixes #18900, #18809, #18816

#18900 -- _find_skill rglob leaks into .archive, .git, .github, .hub Replace bare rglob in _find_skill with iter_skill_index_files which already excludes those dirs.

#18809 -- os.walk(followlinks=True) infinite-loop on cyclic symlinks Add seen-inode tracking to iter_skill_index_files. If a directory inode pair has already been visited the subtree is pruned. Non-cyclic symlinks are still followed.

#18816 -- quick_commands with non-dict values crashes slash command dispatch Add isinstance(qcmd, dict) guard in both gateway/run.py and cli.py before calling .get(). Misconfigured entries return a clear error message instead of crashing with AttributeError.

14 new tests in tests/test_skill_rglob_and_quick_commands.py, all passing.

Changed files

  • agent/skill_utils.py (modified, +17/-0)
  • cli.py (modified, +6/-0)
  • gateway/run.py (modified, +5/-0)
  • tests/test_skill_rglob_and_quick_commands.py (added, +139/-0)
  • tools/skill_manager_tool.py (modified, +7/-2)

Code Example

def iter_skill_index_files(skills_dir: Path, filename: str):
    matches = []
    for root, dirs, files in os.walk(skills_dir, followlinks=True):
        dirs[:] = [d for d in dirs if d not in EXCLUDED_SKILL_DIRS]
        if filename in files:
            matches.append(Path(root) / filename)
    for path in sorted(matches, key=lambda p: str(p.relative_to(skills_dir))):
        yield path

---

# Setup a skills dir with a cyclic symlink
SKILLS_DIR=~/.hermes/skills
mkdir -p "$SKILLS_DIR/test-cycle"
ln -s "$SKILLS_DIR" "$SKILLS_DIR/test-cycle/circular"

# Trigger the walk (e.g., via skills listing or /model command)
# The agent will loop infinitely

---

def iter_skill_index_files(skills_dir: Path, filename: str):
    matches = []
    visited = set()
    for root, dirs, files in os.walk(skills_dir, followlinks=True):
        real_root = os.path.realpath(root)
        if real_root in visited:
            dirs[:] = []
            continue
        visited.add(real_root)
        dirs[:] = [d for d in dirs if d not in EXCLUDED_SKILL_DIRS]
        if filename in files:
            matches.append(Path(root) / filename)
    for path in sorted(matches, key=lambda p: str(p.relative_to(skills_dir))):
        yield path
RAW_BUFFERClick to expand / collapse

Bug Description

agent/skill_utils.py:440-451iter_skill_index_files() uses os.walk(..., followlinks=True) without any cycle detection:

def iter_skill_index_files(skills_dir: Path, filename: str):
    matches = []
    for root, dirs, files in os.walk(skills_dir, followlinks=True):
        dirs[:] = [d for d in dirs if d not in EXCLUDED_SKILL_DIRS]
        if filename in files:
            matches.append(Path(root) / filename)
    for path in sorted(matches, key=lambda p: str(p.relative_to(skills_dir))):
        yield path

Python's os.walk(followlinks=True) does no cycle detection. If a symlink in skills_dir creates a cycle (e.g., a subdirectory symlink pointing to an ancestor), the walk recurses indefinitely until the OS rejects the path with OSError (ENAMETOOLONG or ELOOP).

EXCLUDED_SKILL_DIRS only filters .git, .github, .hub, .archive — it provides zero protection against symlink cycles.

Steps to Reproduce

# Setup a skills dir with a cyclic symlink
SKILLS_DIR=~/.hermes/skills
mkdir -p "$SKILLS_DIR/test-cycle"
ln -s "$SKILLS_DIR" "$SKILLS_DIR/test-cycle/circular"

# Trigger the walk (e.g., via skills listing or /model command)
# The agent will loop infinitely

Expected Behavior

The function should detect symlink cycles and terminate cleanly.

Actual Behavior

Infinite recursion until OSError (path too long or too many symlink levels). This blocks the agent's startup or skill discovery path.

Affected Component

  • agent/skill_utils.py:440-451iter_skill_index_files()

Called from:

  • agent/skill_commands.py
  • agent/prompt_builder.py
  • tools/skills_tool.py

Debug Report

N/A — no crash or stack trace. The bug manifests as an infinite loop that eventually raises OSError when the path exceeds OS limits.

Proposed Fix

Track resolved real paths with a visited set:

def iter_skill_index_files(skills_dir: Path, filename: str):
    matches = []
    visited = set()
    for root, dirs, files in os.walk(skills_dir, followlinks=True):
        real_root = os.path.realpath(root)
        if real_root in visited:
            dirs[:] = []
            continue
        visited.add(real_root)
        dirs[:] = [d for d in dirs if d not in EXCLUDED_SKILL_DIRS]
        if filename in files:
            matches.append(Path(root) / filename)
    for path in sorted(matches, key=lambda p: str(p.relative_to(skills_dir))):
        yield path

Operating System

All (behavior is inherent to Python's os.walk on any OS with symlink support).

extent analysis

TL;DR

The most likely fix is to track resolved real paths with a visited set to detect and prevent symlink cycles in the iter_skill_index_files function.

Guidance

  • Implement the proposed fix by adding a visited set to store real paths and check for cycles before recursing.
  • Verify the fix by testing with a cyclic symlink setup, as described in the "Steps to Reproduce" section.
  • Consider adding additional logging or error handling to detect and report potential issues with symlink cycles.
  • Review the code for other potential uses of os.walk with followlinks=True to ensure similar cycle detection is implemented.

Example

The proposed fix code snippet is already provided in the issue:

def iter_skill_index_files(skills_dir: Path, filename: str):
    matches = []
    visited = set()
    for root, dirs, files in os.walk(skills_dir, followlinks=True):
        real_root = os.path.realpath(root)
        if real_root in visited:
            dirs[:] = []
            continue
        visited.add(real_root)
        dirs[:] = [d for d in dirs if d not in EXCLUDED_SKILL_DIRS]
        if filename in files:
            matches.append(Path(root) / filename)
    for path in sorted(matches, key=lambda p: str(p.relative_to(skills_dir))):
        yield path

Notes

This fix should work on all operating systems with symlink support, as the behavior is inherent to Python's os.walk function.

Recommendation

Apply the proposed workaround by implementing the visited set to detect and prevent symlink cycles, as it provides a clean and effective solution to the issue.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

hermes - ✅(Solved) Fix [Bug]: os.walk(followlinks=True) in iter_skill_index_files may infinite-loop on cyclic symlinks [2 pull requests, 2 comments, 2 participants]