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

  1. Inspect the offending path printed in the error (repr shows exact characters including hidden ones).
  2. Fix the source metadata so every path stays inside the skill folder: remove '..' segments entirely.
  3. 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.
  4. 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

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


AI-assisted analysis of odysseus-dev/odysseus@f9235ebbf1 (2026-08-14). Data as JSON: /api/errors/ef04813134d38182. Report an issue: GitHub.