From 62972060caaa9f7f3fc5a58688757fdd59b9a117 Mon Sep 17 00:00:00 2001 From: HexLab98 Date: Tue, 7 Jul 2026 02:59:03 +0700 Subject: [PATCH] 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. --- agent/skill_commands.py | 32 ++------------------------------ agent/skill_utils.py | 40 ++++++++++++++++++++++++++++++++++++++++ cron/scheduler.py | 3 ++- 3 files changed, 44 insertions(+), 31 deletions(-) diff --git a/agent/skill_commands.py b/agent/skill_commands.py index 5823a443dd9..f4f470c4c01 100644 --- a/agent/skill_commands.py +++ b/agent/skill_commands.py @@ -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/ 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("/") + normalized = normalize_skill_lookup_name(raw_identifier) loaded_skill = json.loads( skill_view(normalized, task_id=task_id, preprocess=False) diff --git a/agent/skill_utils.py b/agent/skill_utils.py index 187c47d7030..4436f7749bf 100644 --- a/agent/skill_utils.py +++ b/agent/skill_utils.py @@ -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/ 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: + 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: diff --git a/cron/scheduler.py b/cron/scheduler.py index b4e6001d19a..21baab05482 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -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)