odysseus-dev/odysseus · critical · SkillImportError
unsafe path: {rel!r}
Error message
unsafe path: {rel!r} What it means
First of two guards in _safe_relpath (services/memory/skill_importer.py): it normalizes a repo-relative path (backslashes to slashes, strip whitespace and leading slashes) and rejects it outright if it is empty, starts with '..', or contains '/../' in the '/'-padded form. This is path-traversal defense for paths extracted from remote SKILL.md frontmatter or bundle listings, ensuring extracted files cannot escape the destination directory.
Source
Thrown at services/memory/skill_importer.py:60
)
@dataclass
class ResolvedSource:
owner: str
repo: str
ref: str
path: str # directory or file path inside repo (no leading slash)
class SkillImportError(ValueError):
pass
def _safe_relpath(rel: str) -> str:
rel = (rel or "").replace("\\", "/").strip().lstrip("/")
if not rel or rel.startswith("..") or "/../" in f"/{rel}/":
raise SkillImportError(f"unsafe path: {rel!r}")
parts = [p for p in rel.split("/") if p and p != "."]
if any(p == ".." for p in parts):
raise SkillImportError(f"unsafe path: {rel!r}")
return "/".join(parts)
def _is_text_file(name: str) -> bool:
low = name.lower()
if low in TEXT_NAMES:
return True
return any(low.endswith(s) for s in ALLOWED_SUFFIXES)
# Max redirect hops to follow manually while re-validating each one.
_MAX_FETCH_REDIRECTS = 5
def _validated_ips(raw_ips: List[str]) -> List[ipaddress._BaseAddress]:View on GitHub (pinned to f9235ebbf1)
Solutions
- Inspect the offending path printed in the error (repr shows exact characters including hidden ones).
- Fix the source metadata so every path stays inside the skill folder: remove '..' segments entirely.
- If this fires on a bundle you do not control, do not import it — the path is a traversal attempt or a corrupt bundle; report it upstream.
- Re-verify after fixing by calling the normalizer: _safe_relpath('skills/foo/SKILL.md') should return the same clean path.
Example fix
# before (SKILL.md metadata) path: "../shared/utils" SkillImportError: unsafe path: '../shared/utils' # after path: "shared/utils" # or move the folder inside the skill directory
Defensive patterns
Strategy: validation
Validate before calling
from pathlib import PurePosixPath
def is_safe_relpath(rel: str) -> bool:
r = (rel or '').replace('\\', '/').strip().lstrip('/')
if not r:
return False
parts = [p for p in r.split('/') if p and p != '.']
return not any(p == '..' for p in parts) Type guard
def safe_or_none(rel: str) -> str | None:
return rel if is_safe_relpath(rel) else None Try / catch
from services.memory.skill_importer import SkillImportError, _safe_relpath
try:
clean = _safe_relpath(entry_path)
except SkillImportError:
logging.warning('skipping unsafe bundle entry %r', entry_path)
continue # skip the entry; never write it Prevention
- Sanitize every path extracted from remote metadata before any filesystem write.
- Treat repeated traversal attempts from one source as malicious and abort the whole import.
- Write extracted files only under a freshly created root and verify the resolved path stays inside it (Path.resolve() + is_relative_to).
When it happens
Trigger: A skill bundle entry named '../escape.md', a path like 'skills/../../../etc/cron.d/x', an empty/whitespace-only path after normalization, or a Windows-style '..\..\evil' converted to '../../evil' and caught by the same check. Raised before any filesystem write happens.
Common situations: Malicious or corrupted skill bundles published with traversal paths; hand-authored SKILL.md metadata with a leading ../ in a path field; CI fixtures accidentally using relative-up paths; a tampered tarball-like listing.
Related errors
- d && d.detail ? d.detail : ('HTTP ' + res.status)
- result.detail || 'Failed to rename session'
- Invalid backup file: ' + e.message
- Import failed: server returned ${res.status}
- Invalid SSH remote host
AI-assisted analysis of odysseus-dev/odysseus@f9235ebbf1 (2026-08-14).
Data as JSON: /api/errors/ef04813134d38182.
Report an issue: GitHub.