{"repo":"NousResearch/hermes-agent","public_id":"220","display_label":"Path traversal vulnerability fixes in skill_view command","summary":"Multiple fixes and reports addressing a path traversal vulnerability in the skill_view command that could allow reading files outside the trusted skills directory. The issues describe the problem and corresponding patches blocking malicious name/file_path arguments.","subsystem":"skills","counts":{"members":10,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":7},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":40566},"resolution":{"state":"partially_resolved","by":"pr-40566"},"freshness":{"state":"verified","last_verified_at":"2026-06-07T01:29:52+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.18577777777777776},"fix_edges":[{"issue_number":38643,"pr_number":40566,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":220,"title":"Path traversal in skill_view allows reading arbitrary files including API keys","url":null,"state":"closed"},{"kind":"issue","number":38643,"title":"[Security] Hermes `skill_view` name traversal discloses files outside the trusted skills directory","url":null,"state":"closed"},{"kind":"pr","number":221,"title":"fix(security): block path traversal in skill_view file_path parameter","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"1cb2311bad5d10ce7de66f6c0ac5e91956a3ce34","number":0},{"kind":"pr","value":"#242","number":242}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":242,"title":"fix(security): prevent path traversal in skill_view","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":38707,"title":"fix(security): reject skill_view name traversal that escapes trusted skills dir","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#40566","number":40566},{"kind":"pr","value":"#38643","number":38643}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":39145,"title":"fix(security): block path traversal in skill_view name parameter (CWE-22)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"56f833efa427ccb444c0f9ad1759af1012f2124d","number":0},{"kind":"pr","value":"#40566","number":40566},{"kind":"pr","value":"#38643","number":38643},{"kind":"pr","value":"#40521","number":40521},{"kind":"pr","value":"#40566","number":40566}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":39578,"title":"fix(tools): block path traversal in skill_view name resolution (#38643)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#38707","number":38707},{"kind":"pr","value":"#38707","number":38707},{"kind":"pr","value":"#39145","number":39145}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":40521,"title":"fix: block skill_view name traversal","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":40566,"title":"fix(skills): block path traversal via skill_view name argument","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tools/test_skill_view_traversal.py","tools/skills_tool.py"],"fix_hunks":[{"file":"tests/tools/test_skill_view_traversal.py","hunks":["@@ -34,6 +34,44 @@ def fake_skills(tmp_path):\n \n \n class TestPathTraversalBlocked:\n+    def test_dotdot_in_skill_name_blocked(self, fake_skills):\n+        \"\"\"A traversal skill name must not escape the skills search root.\n+\n+        Regression: `name` was joined onto each search dir to build the lookup\n+        path with no `..`/absolute guard (while file_path WAS validated), so\n+        name=\"../outside-skill\" could select a sibling dir outside SKILLS_DIR.\n+        \"\"\"\n+        tmp_path = fake_skills[\"tmp_path\"]\n+        outside_skill = tmp_path / \"outside-skill\"\n+        outside_skill.mkdir()\n+        (outside_skill / \"SKILL.md\").write_text(\"# Outside Skill\\n\")\n+        (outside_skill / \".env\").write_text(\"ESCAPED_SECRET=[redacted:secret-assignment]\")\n+\n+        result = json.loads(skill_view(\"../outside-skill\", file_path=\".env\"))\n+\n+        assert result[\"success\"] is False\n+        assert \"traversal\" in result[\"error\"].lower()\n+        assert \"do-not-leak\" not in json.dumps(result)\n+\n+    def test_absolute_skill_name_blocked(self, fake_skills):\n+        \"\"\"An absolute skill name must not bypass the trusted search root.\"\"\"\n+        tmp_path = fake_skills[\"tmp_path\"]\n+        outside_skill = tmp_path / \"outside-absolute\"\n+        outside_skill.mkdir()\n+        (outside_skill / \"SKILL.md\").write_text(\"# Outside Absolute\\n\")\n+        (outside_skill / \".env\").write_text(\"ABSOLUTE_SECRET=[redacted:secret-assignment]\")\n+\n+        result = json.loads(skill_view(str(outside_skill), file_path=\".env\"))\n+\n+        assert result[\"success\"] is False\n+        assert \"relative path\" in result[\"error\"].lower()\n+        assert \"do-not-leak\" not in json.dumps(result)\n+\n+    def test_legit_skill_name_still_works(self, fake_skills):\n+        \"\"\"A normal skill name must still resolve after the name guard.\"\"\"\n+        result = json.loads(skill_view(\"test-skill\"))\n+        assert result[\"success\"] is True\n+\n     def test_dotdot_in_file_path(self, fake_skills):\n         \"\"\"Direct .. traversal should be rejected.\"\"\"\n         result = json.loads(skill_view(\"test-skill\", file_path=\"../../.env\"))"]},{"file":"tools/skills_tool.py","hunks":["@@ -73,7 +73,7 @@\n import os\n import re\n from enum import Enum\n-from pathlib import Path\n+from pathlib import Path, PurePosixPath, PureWindowsPath\n from typing import Dict, Any, List, Optional, Set, Tuple\n \n from tools.registry import registry, tool_error","@@ -108,6 +108,33 @@\n _secret_capture_callback = None\n \n \n+def _skill_lookup_path_error(name: str) -> Optional[str]:\n+    \"\"\"Return an error if a local skill lookup *name* can escape search roots.\n+\n+    The skill ``name`` is joined onto each trusted search dir to build the\n+    on-disk lookup path, so it must stay relative and free of ``..`` segments —\n+    otherwise ``name=\"../outside\"`` or an absolute path could select a skill\n+    (and read files) outside the skills directory. Mirrors the ``file_path``\n+    validation done later via ``tools.path_security``. We also reject Windows\n+    drive paths (e.g. ``C:\\\\skills``), whose ``:`` would otherwise be misread as\n+    a plugin namespace separator.\n+    \"\"\"\n+    from tools.path_security import has_traversal_component\n+\n+    if not isinstance(name, str):\n+        return \"Skill name must be a string.\"\n+    candidate = name.strip()\n+    if (\n+        PurePosixPath(candidate).is_absolute()\n+        or PureWindowsPath(candidate).is_absolute()\n+        or PureWindowsPath(candidate).drive\n+    ):\n+        return \"Skill name must be a relative path within the skills directory.\"\n+    if has_traversal_component(candidate):\n+        return \"Skill name cannot contain '..' path traversal components.\"\n+    return None\n+\n+\n def load_env() -> Dict[str, str]:\n     \"\"\"Load profile-scoped environment variables from HERMES_HOME/.env.\"\"\"\n     env_path = get_hermes_home() / \".env\"","@@ -847,6 +874,21 @@ def skill_view(\n         JSON string with skill content or error message\n     \"\"\"\n     try:\n+        # Validate before the ':' qualified-name dispatch so a Windows drive\n+        # path (e.g. C:\\skills\\foo) can't be reinterpreted as a plugin\n+        # namespace, and so a traversal/absolute name never reaches the\n+        # search-dir join that builds direct_path below.\n+        lookup_error = _skill_lookup_path_error(name)\n+        if lookup_error:\n+            return json.dumps(\n+                {\n+                    \"success\": False,\n+                    \"error\": lookup_error,\n+                    \"hint\": \"Use a skill name or relative path within the skills directory.\",\n+                },\n+                ensure_ascii=False,\n+            )\n+\n         local_category_name: str | None = None\n         # ── Qualified name dispatch (plugin skills) ──────────────────\n         # Names containing ':' are routed to the plugin skill registry.","@@ -917,6 +959,20 @@ def skill_view(\n \n         from agent.skill_utils import get_external_skills_dirs\n \n+        # The categorized fall-through form (namespace/bare) joins onto each\n+        # search dir too; re-validate it since `bare` is not namespace-checked.\n+        if local_category_name:\n+            lookup_error = _skill_lookup_path_error(local_category_name)\n+            if lookup_error:\n+                return json.dumps(\n+                    {\n+                        \"success\": False,\n+                        \"error\": lookup_error,\n+                        \"hint\": \"Use a skill name or relative path within the skills directory.\",\n+                    },\n+                    ensure_ascii=False,\n+                )\n+\n         # Build list of all skill directories to search\n         all_dirs = []\n         if SKILLS_DIR.exists():\n"]}]},{"kind":"pr","number":40920,"title":"fix(security): prevent path traversal in skill_view command","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}