fix(cron): normalize absolute skill paths before skill_view (#59824)

Cron jobs may store absolute paths to skills under HERMES_HOME/skills or
external_dirs, but skill_view rejects absolute names for security. Extract
the slash-command normalization into agent.skill_utils and reuse it when
cron loads job skills.
This commit is contained in:
HexLab98 2026-07-07 02:59:03 +07:00 committed by kshitij
parent 07d93413e5
commit 62972060ca
3 changed files with 44 additions and 31 deletions

View file

@ -143,37 +143,9 @@ 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
from agent.skill_utils import normalize_skill_lookup_name
identifier_path = Path(raw_identifier).expanduser()
if identifier_path.is_absolute():
normalized = None
trusted_roots = [SKILLS_DIR]
try:
trusted_roots.extend(get_external_skills_dirs())
except Exception:
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("/")
normalized = normalize_skill_lookup_name(raw_identifier)
loaded_skill = json.loads(
skill_view(normalized, task_id=task_id, preprocess=False)

View file

@ -507,6 +507,46 @@ def get_all_skills_dirs() -> List[Path]:
return dirs
def normalize_skill_lookup_name(identifier: str) -> str:
"""Normalize a skill identifier to a ``skill_view()``-safe relative path.
Slash commands and cron jobs may store absolute paths to skills that live
under ``~/.hermes/skills/`` (including via symlinks) or configured
``skills.external_dirs``. ``skill_view()`` rejects absolute names for
security, so callers must translate trusted absolute paths to their
relative form first.
"""
raw_identifier = (identifier or "").strip()
if not raw_identifier:
return raw_identifier
identifier_path = Path(raw_identifier).expanduser()
if not identifier_path.is_absolute():
return raw_identifier.lstrip("/")
trusted_roots = [get_skills_dir()]
try:
trusted_roots.extend(get_external_skills_dirs())
except Exception:
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:
return str(identifier_path.relative_to(root))
except ValueError:
continue
try:
return str(identifier_path.resolve().relative_to(get_skills_dir().resolve()))
except Exception:
return raw_identifier
def _resolve_for_skill_ownership(path) -> Path:
path_obj = path if isinstance(path, Path) else Path(str(path))
try:

View file

@ -2195,6 +2195,7 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:
from tools.skills_tool import skill_view
from tools.skill_usage import bump_use
from agent.skill_bundles import build_bundle_invocation_message, resolve_bundle_command_key
from agent.skill_utils import normalize_skill_lookup_name
parts = []
skipped: list[str] = []
@ -2225,7 +2226,7 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:
continue
try:
loaded = json.loads(skill_view(skill_name))
loaded = json.loads(skill_view(normalize_skill_lookup_name(skill_name)))
except (json.JSONDecodeError, TypeError):
logger.warning("Cron job '%s': skill '%s' returned invalid JSON, skipping", job.get("name", job.get("id")), skill_name)
skipped.append(skill_name)