# NousResearch/hermes-agent · Path traversal vulnerability fixes in skill_view command

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 10 member(s): 0 open issue(s), 2 closed issue(s), 0 open PR(s), 1 merged PR(s), 7 PR(s) closed without merging.
- A fix was merged to main in PR #40566. Verify it addresses your case.
- **Recommended:** read PR #40566 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-40566`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.19`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #220: Path traversal in skill_view allows reading arbitrary files including API keys
- #38643: \[Security\] Hermes \`skill_view\` name traversal discloses files outside the trusted skills directory

## Prior & competing PRs

- **#242: fix(security): prevent path traversal in skill_view**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#40566: fix(skills): block path traversal via skill_view name argument**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `tests/tools/test_skill_view_traversal.py`, `tools/skills_tool.py`
  - fix hunks:
  - `tests/tools/test_skill_view_traversal.py`

    ```diff
    @@ -34,6 +34,44 @@ def fake_skills(tmp_path):
     
     
     class TestPathTraversalBlocked:
    +    def test_dotdot_in_skill_name_blocked(self, fake_skills):
    +        """A traversal skill name must not escape the skills search root.
    +
    +        Regression: `name` was joined onto each search dir to build the lookup
    +        path with no `..`/absolute guard (while file_path WAS validated), so
    +        name="../outside-skill" could select a sibling dir outside SKILLS_DIR.
    +        """
    +        tmp_path = fake_skills["tmp_path"]
    +        outside_skill = tmp_path / "outside-skill"
    +        outside_skill.mkdir()
    +        (outside_skill / "SKILL.md").write_text("# Outside Skill\n")
    +        (outside_skill / ".env").write_text("ESCAPED_SECRET=[redacted:secret-assignment]")
    +
    +        result = json.loads(skill_view("../outside-skill", file_path=".env"))
    +
    +        assert result["success"] is False
    +        assert "traversal" in result["error"].lower()
    +        assert "do-not-leak" not in json.dumps(result)
    +
    +    def test_absolute_skill_name_blocked(self, fake_skills):
    +        """An absolute skill name must not bypass the trusted search root."""
    +        tmp_path = fake_skills["tmp_path"]
    +        outside_skill = tmp_path / "outside-absolute"
    +        outside_skill.mkdir()
    +        (outside_skill / "SKILL.md").write_text("# Outside Absolute\n")
    +        (outside_skill / ".env").write_text("ABSOLUTE_SECRET=[redacted:secret-assignment]")
    +
    +        result = json.loads(skill_view(str(outside_skill), file_path=".env"))
    +
    +        assert result["success"] is False
    +        assert "relative path" in result["error"].lower()
    +        assert "do-not-leak" not in json.dumps(result)
    +
    +    def test_legit_skill_name_still_works(self, fake_skills):
    +        """A normal skill name must still resolve after the name guard."""
    +        result = json.loads(skill_view("test-skill"))
    +        assert result["success"] is True
    +
         def test_dotdot_in_file_path(self, fake_skills):
             """Direct .. traversal should be rejected."""
             result = json.loads(skill_view("test-skill", file_path="../../.env"))
    ```
  - `tools/skills_tool.py`

    ```diff
    @@ -73,7 +73,7 @@
     import os
     import re
     from enum import Enum
    -from pathlib import Path
    +from pathlib import Path, PurePosixPath, PureWindowsPath
     from typing import Dict, Any, List, Optional, Set, Tuple
     
     from tools.registry import registry, tool_error
    ```

    ```diff
    @@ -108,6 +108,33 @@
     _secret_capture_callback = None
     
     
    +def _skill_lookup_path_error(name: str) -> Optional[str]:
    +    """Return an error if a local skill lookup *name* can escape search roots.
    +
    +    The skill ``name`` is joined onto each trusted search dir to build the
    +    on-disk lookup path, so it must stay relative and free of ``..`` segments —
    +    otherwise ``name="../outside"`` or an absolute path could select a skill
    +    (and read files) outside the skills directory. Mirrors the ``file_path``
    +    validation done later via ``tools.path_security``. We also reject Windows
    +    drive paths (e.g. ``C:\\skills``), whose ``:`` would otherwise be misread as
    +    a plugin namespace separator.
    +    """
    +    from tools.path_security import has_traversal_component
    +
    +    if not isinstance(name, str):
    +        return "Skill name must be a string."
    +    candidate = name.strip()
    +    if (
    +        PurePosixPath(candidate).is_absolute()
    +        or PureWindowsPath(candidate).is_absolute()
    +        or PureWindowsPath(candidate).drive
    +    ):
    +        return "Skill name must be a relative path within the skills directory."
    +    if has_traversal_component(candidate):
    +        return "Skill name cannot contain '..' path traversal components."
    +    return None
    +
    +
     def load_env() -> Dict[str, str]:
         """Load profile-scoped environment variables from HERMES_HOME/.env."""
         env_path = get_hermes_home() / ".env"
    ```

    ```diff
    @@ -847,6 +874,21 @@ def skill_view(
             JSON string with skill content or error message
         """
         try:
    +        # Validate before the ':' qualified-name dispatch so a Windows drive
    +        # path (e.g. C:\skills\foo) can't be reinterpreted as a plugin
    +        # namespace, and so a traversal/absolute name never reaches the
    +        # search-dir join that builds direct_path below.
    +        lookup_error = _skill_lookup_path_error(name)
    +        if lookup_error:
    +            return json.dumps(
    +                {
    +                    "success": False,
    +                    "error": lookup_error,
    +                    "hint": "Use a skill name or relative path within the skills directory.",
    +                },
    +                ensure_ascii=False,
    +            )
    +
             local_category_name: str | None = None
             # ── Qualified name dispatch (plugin skills) ──────────────────
             # Names containing ':' are routed to the plugin skill registry.
    ```

    ```diff
    @@ -917,6 +959,20 @@ def skill_view(
     
             from agent.skill_utils import get_external_skills_dirs
     
    +        # The categorized fall-through form (namespace/bare) joins onto each
    +        # search dir too; re-validate it since `bare` is not namespace-checked.
    +        if local_category_name:
    +            lookup_error = _skill_lookup_path_error(local_category_name)
    +            if lookup_error:
    +                return json.dumps(
    +                    {
    +                        "success": False,
    +                        "error": lookup_error,
    +                        "hint": "Use a skill name or relative path within the skills directory.",
    +                    },
    +                    ensure_ascii=False,
    +                )
    +
             # Build list of all skill directories to search
             all_dirs = []
             if SKILLS_DIR.exists():
    ```
- **#221: fix(security): block path traversal in skill_view file_path parameter**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `1cb2311bad5d10ce7de66f6c0ac5e91956a3ce34`, `#242`
- **#38707: fix(security): reject skill_view name traversal that escapes trusted skills dir**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#40566`, `#38643`
- **#39145: fix(security): block path traversal in skill_view name parameter (CWE-22)**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `56f833efa427ccb444c0f9ad1759af1012f2124d`, `#40566`, `#38643`, `#40521`, `#40566`
- **#39578: fix(tools): block path traversal in skill_view name resolution (#38643)**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#38707`, `#38707`, `#39145`
- **#40521: fix: block skill_view name traversal**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`
- **#40920: fix(security): prevent path traversal in skill_view command**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`

## Fix status

- Merged to main in PR #40566 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-07`.
- Subsystem: `skills`
