{"record":{"id":"ef04813134d38182","repo":"odysseus-dev/odysseus","slug":"unsafe-path-rel-r","errorCode":null,"errorMessage":"unsafe path: {rel!r}","messagePattern":"unsafe path: (.+?)","errorType":"validation","errorClass":"SkillImportError","httpStatus":null,"severity":"critical","filePath":"services/memory/skill_importer.py","lineNumber":60,"sourceCode":"        )\n\n\n@dataclass\nclass ResolvedSource:\n    owner: str\n    repo: str\n    ref: str\n    path: str  # directory or file path inside repo (no leading slash)\n\n\nclass SkillImportError(ValueError):\n    pass\n\n\ndef _safe_relpath(rel: str) -> str:\n    rel = (rel or \"\").replace(\"\\\\\", \"/\").strip().lstrip(\"/\")\n    if not rel or rel.startswith(\"..\") or \"/../\" in f\"/{rel}/\":\n        raise SkillImportError(f\"unsafe path: {rel!r}\")\n    parts = [p for p in rel.split(\"/\") if p and p != \".\"]\n    if any(p == \"..\" for p in parts):\n        raise SkillImportError(f\"unsafe path: {rel!r}\")\n    return \"/\".join(parts)\n\n\ndef _is_text_file(name: str) -> bool:\n    low = name.lower()\n    if low in TEXT_NAMES:\n        return True\n    return any(low.endswith(s) for s in ALLOWED_SUFFIXES)\n\n\n# Max redirect hops to follow manually while re-validating each one.\n_MAX_FETCH_REDIRECTS = 5\n\n\ndef _validated_ips(raw_ips: List[str]) -> List[ipaddress._BaseAddress]:","sourceCodeStart":42,"sourceCodeEnd":78,"githubUrl":"https://github.com/odysseus-dev/odysseus/blob/f9235ebbf13f693a6fd29ce70b097f6ec83705bf/services/memory/skill_importer.py#L42-L78","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"# before (SKILL.md metadata)\npath: \"../shared/utils\"\nSkillImportError: unsafe path: '../shared/utils'\n\n# after\npath: \"shared/utils\"   # or move the folder inside the skill directory","handlingStrategy":"validation","validationCode":"from pathlib import PurePosixPath\n\ndef is_safe_relpath(rel: str) -> bool:\n    r = (rel or '').replace('\\\\', '/').strip().lstrip('/')\n    if not r:\n        return False\n    parts = [p for p in r.split('/') if p and p != '.']\n    return not any(p == '..' for p in parts)","typeGuard":"def safe_or_none(rel: str) -> str | None:\n    return rel if is_safe_relpath(rel) else None","tryCatchPattern":"from services.memory.skill_importer import SkillImportError, _safe_relpath\n\ntry:\n    clean = _safe_relpath(entry_path)\nexcept SkillImportError:\n    logging.warning('skipping unsafe bundle entry %r', entry_path)\n    continue  # skip the entry; never write it","preventionTips":["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)."],"tags":["security","path-traversal","validation","skill-import"],"backgroundTag":null,"analyzedSha":"f9235ebbf13f693a6fd29ce70b097f6ec83705bf","analyzedAt":"2026-08-14T21:47:48.359Z","schemaVersion":2},"datasetVersion":"2026-08-15T17:31:12.345Z"}