fix(skills): block path traversal via skill_view name argument (#40566)

Closes #38643.

Salvaged from #40521; cleaned up, re-verified against main, tests added.

Co-authored-by: xy200303 <xy200303@users.noreply.github.com>
This commit is contained in:
Teknium
2026-06-06 18:29:52 -07:00
committed by GitHub
co-authored by xy200303
parent f4a73abbd0
commit 56f833efa4
2 changed files with 95 additions and 1 deletions
+57 -1
View File
@@ -73,7 +73,7 @@ from hermes_constants import get_hermes_home, display_hermes_home
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
@@ -108,6 +108,33 @@ _REMOTE_ENV_BACKENDS = frozenset(
_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"
@@ -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.
@@ -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():