Skip to content
Closed
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
90 changes: 90 additions & 0 deletions tests/tools/test_skill_usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,96 @@ def test_is_agent_created(skills_home):
assert is_agent_created("hubbed") is False


def test_agent_created_excludes_hub_skill_with_nonascii_display_name(skills_home):
"""Hub skill whose SKILL.md name differs from the hub slug (e.g. "Get笔记" vs
"getnote") must not appear in list_agent_created_skill_names(). Regression
for GitHub issue #19293."""
from tools.skill_usage import list_agent_created_skill_names
skills_dir = skills_home / "skills"
# Hub skill: slug "getnote", SKILL.md name "Get笔记", nested under productivity/
hub_skill_dir = skills_dir / "productivity" / "getnote"
hub_skill_dir.mkdir(parents=True)
(hub_skill_dir / "SKILL.md").write_text(
"---\nname: Get笔记\ndescription: note tool\n---\n", encoding="utf-8"
)
_write_skill(skills_dir, "my-skill")
hub_dir = skills_dir / ".hub"
hub_dir.mkdir()
(hub_dir / "lock.json").write_text(
json.dumps({
"version": 1,
"installed": {
"getnote": {
"source": "taps/main",
"install_path": "productivity/getnote",
}
},
}),
encoding="utf-8",
)
names = list_agent_created_skill_names()
assert "my-skill" in names
assert "Get笔记" not in names
assert "getnote" not in names


def test_is_agent_created_nonascii_hub_display_name(skills_home):
"""is_agent_created() must return False for a hub skill's non-ASCII display
name, not just its slug. Regression for GitHub issue #19293."""
from tools.skill_usage import is_agent_created
skills_dir = skills_home / "skills"
hub_skill_dir = skills_dir / "productivity" / "getnote"
hub_skill_dir.mkdir(parents=True)
(hub_skill_dir / "SKILL.md").write_text(
"---\nname: Get笔记\ndescription: note tool\n---\n", encoding="utf-8"
)
hub_dir = skills_dir / ".hub"
hub_dir.mkdir()
(hub_dir / "lock.json").write_text(
json.dumps({
"version": 1,
"installed": {
"getnote": {
"source": "taps/main",
"install_path": "productivity/getnote",
}
},
}),
encoding="utf-8",
)
assert is_agent_created("getnote") is False # slug
assert is_agent_created("Get笔记") is False # display name
assert is_agent_created("my-other-skill") is True # unrelated skill


def test_agent_created_excludes_hub_skill_dir_when_skill_md_unreadable(skills_home):
"""Directory-path guard must block a hub skill even when SKILL.md is absent
(belt-and-suspenders for the install_path check)."""
from tools.skill_usage import list_agent_created_skill_names
skills_dir = skills_home / "skills"
_write_skill(skills_dir, "my-skill")
hub_dir = skills_dir / ".hub"
hub_dir.mkdir()
# lock.json records an install_path but we do NOT create the SKILL.md
(hub_dir / "lock.json").write_text(
json.dumps({
"version": 1,
"installed": {
"ghost-skill": {
"source": "taps/main",
"install_path": "productivity/ghost-skill",
}
},
}),
encoding="utf-8",
)
# Create the directory but deliberately no SKILL.md inside it
(skills_dir / "productivity" / "ghost-skill").mkdir(parents=True)
names = list_agent_created_skill_names()
assert "my-skill" in names
assert "ghost-skill" not in names


def test_agent_created_skips_archive_and_hub_dirs(skills_home):
from tools.skill_usage import list_agent_created_skill_names
skills_dir = skills_home / "skills"
Expand Down
71 changes: 66 additions & 5 deletions tools/skill_usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,12 @@ def _read_bundled_manifest_names() -> Set[str]:


def _read_hub_installed_names() -> Set[str]:
"""Return the set of skill names installed via the Skills Hub.
"""Return identifiers for skills installed via the Skills Hub.

Returns both the hub slug (lock.json key, e.g. ``"getnote"``) and the
SKILL.md ``name`` field resolved via ``install_path`` (e.g. ``"Get笔记"``).
Including the display name ensures skills with localized or otherwise
non-slug names are correctly protected from curator processing.

Reads ~/.hermes/skills/.hub/lock.json (see tools/skills_hub.py :: HubLockFile).
"""
Expand All @@ -139,10 +144,61 @@ def _read_hub_installed_names() -> Set[str]:
return set()
try:
data = json.loads(lock_path.read_text(encoding="utf-8"))
if isinstance(data, dict):
installed = data.get("installed") or {}
if isinstance(installed, dict):
return {str(k) for k in installed.keys()}
if not isinstance(data, dict):
return set()
installed = data.get("installed") or {}
if not isinstance(installed, dict):
return set()
names: Set[str] = {str(k) for k in installed.keys()}
skills_dir = _skills_dir()
for entry in installed.values():
if not isinstance(entry, dict):
continue
ip = entry.get("install_path")
if not ip:
continue
rel = Path(str(ip).strip("/"))
if ".." in rel.parts:
continue
skill_md = skills_dir / rel / "SKILL.md"
if skill_md.exists():
display = _read_skill_name(skill_md, fallback="")
if display:
names.add(display)
return names
except (OSError, json.JSONDecodeError) as e:
logger.debug("Failed to read hub lock file: %s", e)
return set()


def _read_hub_installed_dirs() -> Set[str]:
"""Return normalized install_path values for hub-installed skills.

Used alongside _read_hub_installed_names() in list_agent_created_skill_names()
to catch skills where the SKILL.md is unreadable and the display name cannot
be resolved, but the directory is still a known hub install location.
"""
lock_path = _skills_dir() / ".hub" / "lock.json"
if not lock_path.exists():
return set()
try:
data = json.loads(lock_path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
return set()
installed = data.get("installed") or {}
if not isinstance(installed, dict):
return set()
dirs: Set[str] = set()
for entry in installed.values():
if not isinstance(entry, dict):
continue
ip = entry.get("install_path")
if not ip:
continue
rel = Path(str(ip).strip("/"))
if ".." not in rel.parts:
dirs.add(rel.as_posix())
return dirs
except (OSError, json.JSONDecodeError) as e:
logger.debug("Failed to read hub lock file: %s", e)
return set()
Expand All @@ -160,6 +216,7 @@ def list_agent_created_skill_names() -> List[str]:
return []
bundled = _read_bundled_manifest_names()
hub = _read_hub_installed_names()
hub_dirs = _read_hub_installed_dirs()
off_limits = bundled | hub

names: List[str] = []
Expand All @@ -173,6 +230,10 @@ def list_agent_created_skill_names() -> List[str]:
parts = rel.parts
if parts and (parts[0].startswith(".") or parts[0] == "node_modules"):
continue
# Belt-and-suspenders: skip by install_path directory match, for cases
# where the SKILL.md display name could not be resolved from the lock.
if rel.parent.as_posix() in hub_dirs:
continue
name = _read_skill_name(skill_md, fallback=skill_md.parent.name)
if name in off_limits:
continue
Expand Down
Loading