diff --git a/agent/skill_commands.py b/agent/skill_commands.py index 42e7c857434..018d84865cd 100644 --- a/agent/skill_commands.py +++ b/agent/skill_commands.py @@ -58,13 +58,35 @@ def _load_skill_payload(skill_identifier: str, task_id: str | None = None) -> tu try: from tools.skills_tool import SKILLS_DIR, skill_view + from agent.skill_utils import get_external_skills_dirs identifier_path = Path(raw_identifier).expanduser() if identifier_path.is_absolute(): + normalized = None + trusted_roots = [SKILLS_DIR] try: - normalized = str(identifier_path.resolve().relative_to(SKILLS_DIR.resolve())) + trusted_roots.extend(get_external_skills_dirs()) except Exception: - normalized = raw_identifier + pass + + # Prefer the lexical path under a trusted skill root before + # resolving symlinks. Slash-command discovery can legitimately + # find a skill via ~/.hermes/skills/ where is a + # symlink to a checked-out skill elsewhere. Resolving first turns + # that trusted visible path into an arbitrary absolute path that + # skill_view() refuses to load. + for root in trusted_roots: + try: + normalized = str(identifier_path.relative_to(root)) + break + except ValueError: + continue + + if normalized is None: + try: + normalized = str(identifier_path.resolve().relative_to(SKILLS_DIR.resolve())) + except Exception: + normalized = raw_identifier else: normalized = raw_identifier.lstrip("/") diff --git a/tests/agent/test_skill_commands.py b/tests/agent/test_skill_commands.py index c11976ef978..a206348c0da 100644 --- a/tests/agent/test_skill_commands.py +++ b/tests/agent/test_skill_commands.py @@ -4,6 +4,8 @@ import os from pathlib import Path from unittest.mock import patch +import pytest + import tools.skills_tool as skills_tool_module from agent.skill_commands import ( build_preloaded_skills_prompt, @@ -125,6 +127,30 @@ class TestScanSkillCommands: assert "/knowledge-brain" in result assert result["/knowledge-brain"]["name"] == "knowledge-brain" + def test_loads_skill_invocation_from_symlinked_skill_dir(self, tmp_path): + """Slash commands should load skills symlinked under the local skills dir.""" + external_root = tmp_path / "external" + skills_root = tmp_path / "skills" + skills_root.mkdir() + real_skill_dir = _make_skill( + external_root, + "impeccable", + body="Apply impeccable design craft.", + ) + symlink_path = skills_root / "impeccable" + try: + symlink_path.symlink_to(real_skill_dir, target_is_directory=True) + except (OSError, NotImplementedError) as exc: + pytest.skip(f"symlinks unavailable in test environment: {exc}") + + with patch("tools.skills_tool.SKILLS_DIR", skills_root): + result = scan_skill_commands() + message = build_skill_invocation_message("/impeccable") + + assert "/impeccable" in result + assert message is not None + assert "Apply impeccable design craft." in message + def test_get_skill_commands_rescans_when_platform_scope_changes(self, tmp_path): """Platform-specific disabled-skill caches must not leak across platforms.