fix(agent): detect symlink cycles in iter_skill_index_files - #20658
fix(agent): detect symlink cycles in iter_skill_index_files#20658haosenwang1018 wants to merge 1 commit into
Conversation
Closes NousResearch#18809 ``os.walk(skills_dir, followlinks=True)`` performs no cycle detection, so a self-referencing skills tree (e.g. a stray symlink such as ``~/.hermes/skills/test-cycle/circular -> ~/.hermes/skills``) caused infinite recursion until the OS rejected the path with ``ENAMETOOLONG`` / ``ELOOP``. This blocked agent startup and any ``/skill`` discovery path because skill listing happens during init. Track each visited subdirectory's resolved (canonical) path via ``os.path.realpath`` and skip any directory we've already entered. This is the standard cycle-safe idiom for ``os.walk(followlinks=True)`` and matches how ``shutil`` and similar stdlib walkers handle the case. ``EXCLUDED_SKILL_DIRS`` filtering is preserved as the first pass so behavior on non-cyclic trees is identical. Tests cover: - A self-referencing subdir cycle terminates and still returns the legitimate skill once. - A two-cycle through cross-linked siblings terminates and returns both real ``SKILL.md`` files exactly once. - A plain non-symlinked tree is unaffected (regression guard). Symlink-specific tests are skipped on Windows where the semantics differ. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Duplicate of #18815 — both add cycle detection to |
teknium1
left a comment
There was a problem hiding this comment.
Thanks for addressing the real cycle risk in skill discovery.
Problems
- The guard only covers
iter_skill_index_files(), but current main also has an unguardedos.walk(..., followlinks=True)inagent/prompt_builder.py:1281._load_skills_snapshot()reaches it atagent/prompt_builder.py:1314, and cold snapshot construction reaches it atagent/prompt_builder.py:1566; a cyclic skills tree can still block prompt construction. - The added tests cover only the iterator. Please cover both prompt snapshot paths as well.
- The new tests call
symlink_to()directly. Current main handles unavailable symlinks with anOSErrorskip attests/agent/test_skill_utils.py:375-378.
Suggested changes
- Share one cycle-safe traversal strategy between
agent/skill_utils.pyandagent/prompt_builder.py, preserving symlinked-skill discovery and existing pruning. - Add cold-path and snapshot-validation regressions through
build_skills_system_prompt(). - Make symlink setup skip cleanly when unavailable.
Automated hermes-sweeper review.
| root_real = os.path.realpath(skills_dir) | ||
| except OSError: | ||
| root_real = str(skills_dir) | ||
| visited_realpaths = {root_real} |
There was a problem hiding this comment.
Please make this cycle-safe traversal reusable by _build_skills_manifest() too. Current main has a second unguarded os.walk(..., followlinks=True) at agent/prompt_builder.py:1281, reached during snapshot validation and cold-path snapshot creation, so guarding this iterator alone does not prevent startup hangs.
|
Ran into this today on the latest Hermes Desktop for Windows 11. A stale or broken skill link in the Hermes skills directory caused global skill discovery to fail, so unrelated installed skills and slash commands were reported as missing with |
Issue
Closes #18809
Root cause
os.walk(skills_dir, followlinks=True)performs no cycle detection on its own — it relies on the caller to filter cyclic entries. A stray symlink under `~/.hermes/skills` that pointed back to an ancestor (the bug report's reproduction does this withtest-cycle/circular -> ~/.hermes/skills) causediter_skill_index_filesto recurse indefinitely until the OS rejected the path withENAMETOOLONG/ELOOP. Because skill discovery runs during agent init, this blocked startup entirely, not just the/skillcommand.The pre-existing
EXCLUDED_SKILL_DIRSfilter only covers.git/.github/.hub/.archive— it provides no defense against a generic symlink loop.Fix
Track each visited subdirectory's resolved canonical path via
os.path.realpathand drop any candidate whose realpath has already been entered. This is the standard cycle-safe idiom foros.walk(followlinks=True). TheEXCLUDED_SKILL_DIRSfilter still runs first so behavior on plain non-cyclic trees is identical to before.OSErrorduringrealpathresolution is treated as "skip" rather than propagated, so a single broken or unreadable symlink can't take down skill discovery.Tests
Added in
tests/agent/test_skill_utils.py:test_self_referencing_subdir_does_not_loop— reproduces the bug report's scenario; without the fix this test never returns.test_cycle_via_symlinked_sibling_does_not_loop— covers the harder case of a two-cycle formed by sibling cross-links.test_normal_tree_unchanged— regression guard for plain trees.The symlink tests are skipped on Windows because POSIX
ELOOPsemantics don't apply.