{"repo":"NousResearch/hermes-agent","public_id":"59824","display_label":"Cron absolute skill path security check correction","summary":"A bug caused the cron scheduler to silently produce no Discord delivery when absolute skill paths were rejected by a path-security check in skill_view. Multiple correction attempts address normalizing or permitting absolute paths that resolve within trusted directories.","subsystem":null,"counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":60109},"resolution":{"state":"resolved","by":"pr-60109"},"freshness":{"state":"verified","last_verified_at":"2026-07-07T09:10:57+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.4},"fix_edges":[{"issue_number":59824,"pr_number":60109,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":59824,"title":"[Bug] Cron scheduler silently fails (no Discord delivery) — absolute skill paths rejected by skill_view path-security check","url":null,"state":"closed"},{"kind":"pr","number":59829,"title":"fix(cron): normalize absolute skill paths before skill_view (#59824)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#60109","number":60109}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":59966,"title":"fix(skills-tool): accept absolute skill paths under trusted HERMES skills roots","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":60109,"title":"fix(cron): normalize absolute skill paths before skill_view (#59824)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/skill_commands.py","agent/skill_utils.py","cron/scheduler.py","tests/agent/test_skill_utils.py","tests/cron/test_scheduler.py"],"fix_hunks":[{"file":"agent/skill_commands.py","hunks":["@@ -143,37 +143,9 @@ def _load_skill_payload(skill_identifier: str, task_id: str | None = None) -> tu\n \n     try:\n         from tools.skills_tool import SKILLS_DIR, skill_view\n-        from agent.skill_utils import get_external_skills_dirs\n-\n-        identifier_path = Path(raw_identifier).expanduser()\n-        if identifier_path.is_absolute():\n-            normalized = None\n-            trusted_roots = [SKILLS_DIR]\n-            try:\n-                trusted_roots.extend(get_external_skills_dirs())\n-            except Exception:\n-                pass\n-\n-            # Prefer the lexical path under a trusted skill root before\n-            # resolving symlinks.  Slash-command discovery can legitimately\n-            # find a skill via ~/.hermes/skills/<name> where <name> is a\n-            # symlink to a checked-out skill elsewhere.  Resolving first turns\n-            # that trusted visible path into an arbitrary absolute path that\n-            # skill_view() refuses to load.\n-            for root in trusted_roots:\n-                try:\n-                    normalized = str(identifier_path.relative_to(root))\n-                    break\n-                except ValueError:\n-                    continue\n+        from agent.skill_utils import normalize_skill_lookup_name\n \n-            if normalized is None:\n-                try:\n-                    normalized = str(identifier_path.resolve().relative_to(SKILLS_DIR.resolve()))\n-                except Exception:\n-                    normalized = raw_identifier\n-        else:\n-            normalized = raw_identifier.lstrip(\"/\")\n+        normalized = normalize_skill_lookup_name(raw_identifier)\n \n         loaded_skill = json.loads(\n             skill_view(normalized, task_id=task_id, preprocess=False)"]},{"file":"agent/skill_utils.py","hunks":["@@ -507,6 +507,63 @@ def get_all_skills_dirs() -> List[Path]:\n     return dirs\n \n \n+def normalize_skill_lookup_name(identifier: str) -> str:\n+    \"\"\"Normalize a skill identifier to a ``skill_view()``-safe relative path.\n+\n+    Slash commands and cron jobs may store absolute paths to skills that live\n+    under ``~/.hermes/skills/`` (including via symlinks) or configured\n+    ``skills.external_dirs``. ``skill_view()`` rejects absolute names for\n+    security, so callers must translate trusted absolute paths to their\n+    relative form first.\n+    \"\"\"\n+    raw_identifier = (identifier or \"\").strip()\n+    if not raw_identifier:\n+        return raw_identifier\n+\n+    identifier_path = Path(raw_identifier).expanduser()\n+    if not identifier_path.is_absolute():\n+        return raw_identifier.lstrip(\"/\")\n+\n+    # Look the primary skills root up on tools.skills_tool at CALL time\n+    # (not via get_skills_dir()): callers and tests patch\n+    # ``tools.skills_tool.SKILLS_DIR`` and skill_view() itself resolves\n+    # against that module attribute, so normalization must agree with the\n+    # exact root skill_view() will enforce.  Import deferred to avoid a\n+    # module cycle (tools.skills_tool imports agent.skill_utils).\n+    try:\n+        from tools import skills_tool as _skills_tool\n+        primary_root = Path(_skills_tool.SKILLS_DIR)\n+    except Exception:\n+        primary_root = get_skills_dir()\n+\n+    trusted_roots = [primary_root]\n+    try:\n+        trusted_roots.extend(get_external_skills_dirs())\n+    except Exception:\n+        pass\n+\n+    # Prefer the lexical path under a trusted skill root before resolving\n+    # symlinks. Slash-command discovery can legitimately find a skill via\n+    # ~/.hermes/skills/<name> where <name> is a symlink to a checked-out\n+    # skill elsewhere. Resolving first turns that trusted visible path into\n+    # an arbitrary absolute path that skill_view() refuses to load.\n+    for root in trusted_roots:\n+        try:\n+            return str(identifier_path.relative_to(root))\n+        except ValueError:\n+            continue\n+\n+    try:\n+        return str(identifier_path.resolve().relative_to(primary_root.resolve()))\n+    except Exception:\n+        logger.debug(\n+            \"Skill identifier %r is an absolute path outside trusted skills \"\n+            \"roots — passing through unchanged (skill_view will reject it)\",\n+            raw_identifier,\n+        )\n+        return raw_identifier\n+\n+\n def _resolve_for_skill_ownership(path) -> Path:\n     path_obj = path if isinstance(path, Path) else Path(str(path))\n     try:"]},{"file":"cron/scheduler.py","hunks":["@@ -2195,6 +2195,7 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:\n     from tools.skills_tool import skill_view\n     from tools.skill_usage import bump_use\n     from agent.skill_bundles import build_bundle_invocation_message, resolve_bundle_command_key\n+    from agent.skill_utils import normalize_skill_lookup_name\n \n     parts = []\n     skipped: list[str] = []","@@ -2225,7 +2226,7 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:\n             continue\n \n         try:\n-            loaded = json.loads(skill_view(skill_name))\n+            loaded = json.loads(skill_view(normalize_skill_lookup_name(skill_name)))\n         except (json.JSONDecodeError, TypeError):\n             logger.warning(\"Cron job '%s': skill '%s' returned invalid JSON, skipping\", job.get(\"name\", job.get(\"id\")), skill_name)\n             skipped.append(skill_name)"]},{"file":"tests/agent/test_skill_utils.py","hunks":["@@ -333,3 +333,45 @@ def test_macos_skill_on_real_macos_unaffected(self):\n             \"agent.skill_utils.is_termux\", return_value=False\n         ):\n             assert skill_matches_platform(fm) is True\n+\n+\n+class TestNormalizeSkillLookupName:\n+    def test_relative_path_unchanged(self, tmp_path, monkeypatch):\n+        from agent.skill_utils import normalize_skill_lookup_name\n+\n+        # Relative identifiers early-return before any root lookup.\n+        assert normalize_skill_lookup_name(\"foo/bar\") == \"foo/bar\"\n+\n+    def test_absolute_under_skills_dir_becomes_relative(self, tmp_path, monkeypatch):\n+        from agent.skill_utils import normalize_skill_lookup_name\n+\n+        skills_dir = tmp_path / \"skills\"\n+        skill_dir = skills_dir / \"category\" / \"my-skill\"\n+        skill_dir.mkdir(parents=True)\n+        # Patch the root skill_view() itself enforces — normalization reads\n+        # tools.skills_tool.SKILLS_DIR at call time so the two stay in sync.\n+        monkeypatch.setattr(\"tools.skills_tool.SKILLS_DIR\", skills_dir)\n+        assert normalize_skill_lookup_name(str(skill_dir)) == \"category/my-skill\"\n+\n+    def test_absolute_via_symlink_uses_lexical_relative_path(self, tmp_path, monkeypatch):\n+        from agent.skill_utils import normalize_skill_lookup_name\n+\n+        skills_dir = tmp_path / \"skills\"\n+        skills_dir.mkdir()\n+        external = tmp_path / \"external\" / \"my-skill\"\n+        external.mkdir(parents=True)\n+        link = skills_dir / \"my-skill\"\n+        try:\n+            link.symlink_to(external)\n+        except OSError:\n+            pytest.skip(\"Symlinks not supported\")\n+        monkeypatch.setattr(\"tools.skills_tool.SKILLS_DIR\", skills_dir)\n+        assert normalize_skill_lookup_name(str(link)) == \"my-skill\"\n+\n+    def test_untrusted_absolute_returned_unchanged(self, tmp_path, monkeypatch):\n+        from agent.skill_utils import normalize_skill_lookup_name\n+\n+        monkeypatch.setattr(\"tools.skills_tool.SKILLS_DIR\", tmp_path / \"skills\")\n+        monkeypatch.setattr(\"agent.skill_utils.get_skills_dir\", lambda: tmp_path / \"skills\")\n+        outside = str(tmp_path / \"outside\" / \"skill\")\n+        assert normalize_skill_lookup_name(outside) == outside"]},{"file":"tests/cron/test_scheduler.py","hunks":["@@ -2885,6 +2885,31 @@ def _mixed_skill_view(name: str) -> str:\n         assert \"go\" in result\n \n \n+class TestBuildJobPromptAbsoluteSkillPath:\n+    \"\"\"Cron jobs may store absolute skill paths; normalize before skill_view.\"\"\"\n+\n+    def test_absolute_skill_path_normalized_before_skill_view(self, tmp_path):\n+        skills_dir = tmp_path / \"skills\"\n+        skill_dir = skills_dir / \"alpha-skill\"\n+        skill_dir.mkdir(parents=True)\n+        (skill_dir / \"SKILL.md\").write_text(\"# Alpha\\nDo alpha.\")\n+        absolute_path = str(skill_dir)\n+        seen_names: list[str] = []\n+\n+        def _skill_view(name: str) -> str:\n+            seen_names.append(name)\n+            if name == \"alpha-skill\":\n+                return json.dumps({\"success\": True, \"content\": \"# Alpha\\nDo alpha.\"})\n+            return json.dumps({\"success\": False, \"error\": f\"Skill '{name}' not found.\"})\n+\n+        with patch(\"tools.skills_tool.SKILLS_DIR\", skills_dir), \\\n+             patch(\"tools.skills_tool.skill_view\", side_effect=_skill_view):\n+            result = _build_job_prompt({\"skills\": [absolute_path], \"prompt\": \"go\"})\n+\n+        assert seen_names == [\"alpha-skill\"]\n+        assert \"Do alpha.\" in result\n+\n+\n class TestBuildJobPromptBumpUse:\n     \"\"\"Verify that cron jobs bump skill usage counters so the curator sees them as active.\"\"\"\n \n"]}]},{"kind":"pr","number":60195,"title":"fix(skills): allow absolute skill paths that resolve within trusted directories (#59824)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"62972060caaa9f7f3fc5a58688757fdd59b9a117","number":0},{"kind":"pr","value":"#59824","number":59824}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}