Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions agent/skill_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name> where <name> 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("/")

Expand Down
26 changes: 26 additions & 0 deletions tests/agent/test_skill_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
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,
Expand Down Expand Up @@ -125,6 +127,30 @@ def test_finds_skills_in_symlinked_category_dir(self, tmp_path):
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.

Expand Down
Loading