Skip to content

fix(skills): follow symlinks with cycle guard across all skill discovery paths - #7634

Open
graysonzeng wants to merge 1 commit into
NousResearch:mainfrom
graysonzeng:fix/skill-scan-follow-symlinks
Open

fix(skills): follow symlinks with cycle guard across all skill discovery paths#7634
graysonzeng wants to merge 1 commit into
NousResearch:mainfrom
graysonzeng:fix/skill-scan-follow-symlinks

Conversation

@graysonzeng

@graysonzeng graysonzeng commented Apr 11, 2026

Copy link
Copy Markdown

Summary

Skills installed via directory symlink (e.g. ln -s /path/to/skill ~/.hermes/skills/my-skill) are silently ignored because Python's pathlib.Path.rglob() does not follow symlinked directories by default. This affects all skill discovery paths — not just slash command registration but also skills list, skills categories, skill_view fallback search, skill_manager operations, and gateway unavailable-skill hints.

Root Cause

Path.rglob() skips symlinked directories during traversal (documented Python behavior). When a skill directory itself is a symlink, rglob("SKILL.md") never enters it, so the skill is invisible to the entire system.

Fix

Introduce a single shared walk_skill_files() function in agent/skill_utils.py that:

  1. Uses os.walk(followlinks=True) to traverse into symlinked directories
  2. Maintains a (st_dev, st_ino) visited set to detect and break symlink cycles
  3. Excludes .git, .github, .hub directories (consistent with existing behavior)

Then replace all rglob("SKILL.md") calls across the skill discovery surface:

File Function Change
agent/skill_utils.py iter_skill_index_files Delegates to walk_skill_files
agent/skill_commands.py scan_skill_commands rglobwalk_skill_files
tools/skills_tool.py _find_all_skills rglobwalk_skill_files
tools/skills_tool.py skills_categories rglobwalk_skill_files
tools/skills_tool.py skill_view (name search) rglobwalk_skill_files
tools/skill_manager_tool.py _find_skill rglobwalk_skill_files
gateway/run.py _check_unavailable_skill rglobwalk_skill_files
hermes_cli/profiles.py _count_skills rglobwalk_skill_files

Not changed (repo-internal dirs, no user symlinks expected):

  • tools/skills_hub.py — scans optional-skills/ bundled in repo
  • tools/skills_sync.py — scans bundled skills
  • hermes_cli/dump.py — export utility

Reproduction

# Create a symlinked skill
ln -s /path/to/my-skill ~/.hermes/skills/my-skill

# Before fix: skill invisible everywhere
# After fix: skill discovered in all paths (slash commands, list, categories, etc.)

Cycle Safety

A symlink cycle (e.g. skill-dir/loop -> ~/.hermes/skills/) would cause os.walk(followlinks=True) to recurse infinitely. The walk_skill_files function tracks visited directories by (st_dev, st_ino) and prunes already-seen directories, preventing infinite loops.

Test Plan

Two new tests added to tests/agent/test_skill_commands.py:

  • test_finds_symlinked_skill — symlinked skill directory is discovered by scan_skill_commands()
  • test_symlink_cycle_does_not_hang — directory cycle does not cause infinite recursion

Existing 26 tests continue to pass.

Known Limitation

Symlinked skills whose resolved path falls outside ~/.hermes/skills/ will trigger a security warning log ("skill file is outside the trusted skills directory"). This is cosmetic and does not affect functionality. Addressing this is out of scope for this PR.

@graysonzeng
graysonzeng force-pushed the fix/skill-scan-follow-symlinks branch from e18682e to 9971ddd Compare April 11, 2026 09:05
@graysonzeng graysonzeng changed the title fix(skills): follow symlinks when scanning skill directories fix(skills): follow symlinks with cycle guard across all skill discovery paths Apr 11, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

Likely duplicate of #12624 — same fix: os.walk(followlinks=True) with cycle guard for symlinked skill directories.

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for addressing a real remaining gap in symlinked skill discovery. Current main already follows symlinks for the primary iterator (agent/skill_utils.py:796), but gateway/run.py:2253, tools/skill_manager_tool.py:597, and hermes_cli/profiles.py:789 still use rglob().

Problems

  • Proposed agent/skill_utils.py:455 prunes only EXCLUDED_SKILL_DIRS. Current main also stops walking references/, templates/, assets/, and scripts/ below a skill root (agent/skill_utils.py:797-803). Removing that rule would rediscover support-package SKILL.md files as active skills, contrary to tests/agent/test_skill_utils.py:195-218.
  • The new symlink setup at tests/agent/test_skill_commands.py:58 and :72 is not portable where symlink creation is unavailable. Existing main coverage from a884f6d5d catches OSError/NotImplementedError and skips.

Suggested changes

  • Add the inode cycle guard to the existing shared iterator while preserving its current support-directory pruning, then cover both cycle safety and the retained exclusion contract in tests/agent/test_skill_utils.py.
  • Use the established skip-on-unavailable-symlink test helper.
  • Audit remaining discovery-specific rglob() paths before claiming all-path coverage.

Automated hermes-sweeper review.

Comment thread agent/skill_utils.py
dirs[:] = []
continue
seen_real.add(ident)
dirs[:] = [d for d in dirs if d not in EXCLUDED_SKILL_DIRS]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EXCLUDED_SKILL_DIRS intentionally does not contain references, templates, assets, or scripts, because they can be valid category names. Current main additionally prunes those only when they are support directories below a skill root (agent/skill_utils.py:797-803). Please preserve that condition here; otherwise archived/support-package SKILL.md files become active skills.

)
skills_dir = tmp_path / "skills"
skills_dir.mkdir()
(skills_dir / "linked-skill").symlink_to(real_dir, target_is_directory=True)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please catch OSError and NotImplementedError here and skip when symlink creation is unavailable. Main's existing symlink coverage uses that pattern (a884f6d5d) so native Windows runners without symlink privilege do not fail.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

P3 Low — cosmetic, nice to have sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows tool/skills Skills system (list, view, manage) type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants