{"record":{"id":"f76b4ad80d397aba","repo":"can1357/oh-my-pi","slug":"scheme-path-escapes-its-root-path","errorCode":null,"errorMessage":"{scheme}:// path escapes its root: {path}","messagePattern":"(.+?):// path escapes its root: (.+?)","errorType":"exception","errorClass":"ValueError","httpStatus":null,"severity":"error","filePath":"packages/coding-agent/src/eval/py/prelude.py","lineNumber":115,"sourceCode":"            roots = json.loads(os.environ.get(\"PI_EVAL_LOCAL_ROOTS\") or \"{}\")\n        except (ValueError, TypeError):\n            roots = {}\n        root = roots.get(scheme) if isinstance(roots, dict) else None\n        if not root:\n            raise ValueError(f\"Protocol paths are not supported by this helper: {path}\")\n        relative = unquote(match.group(2).replace(\"\\\\\", \"/\"))\n        # Mirror the host `path.resolve`/`resolveLocalUrlToPath`: normalize and\n        # make absolute WITHOUT realpath'ing symlinks (Path.resolve would turn\n        # /tmp into /private/tmp and diverge from the read-side resolution).\n        root_path = os.path.abspath(root)\n        if relative == \"\":\n            return Path(root_path)\n        rel_path = Path(relative)\n        if rel_path.is_absolute() or \"..\" in rel_path.parts:\n            raise ValueError(f\"Unsafe {scheme}:// path (absolute or traversal): {path}\")\n        resolved = os.path.abspath(os.path.join(root_path, relative))\n        if resolved != root_path and not resolved.startswith(root_path + os.sep):\n            raise ValueError(f\"{scheme}:// path escapes its root: {path}\")\n        return Path(resolved)\n\n    def read(path: str | Path, offset: int = 1, limit: int | None = None) -> str:\n        \"\"\"Read file or read-tool URI contents. offset/limit are 1-indexed lines.\"\"\"\n        if _should_delegate_read(path):\n            if limit is not None and limit <= 0:\n                return \"\"\n            selector = _read_line_selector(offset, limit)\n            tool_path = path if selector is None else f\"{path}:{selector}\"\n            return _read_tool_text(tool_path)\n        p = _resolve_omp_path(path)\n        data = p.read_text(encoding=\"utf-8\")\n        lines = data.splitlines(keepends=True)\n        if offset > 1 or limit is not None:\n            start = max(0, offset - 1)\n            end = start + limit if limit else len(lines)\n            lines = lines[start:end]\n            data = \"\".join(lines)","sourceCodeStart":97,"sourceCodeEnd":133,"githubUrl":"https://github.com/can1357/oh-my-pi/blob/969062200754ea02cfac922e5ebb8c608c079e15/packages/coding-agent/src/eval/py/prelude.py#L97-L133","documentation":"After joining the relative path onto the scheme's root, _resolve_omp_path verifies with abspath + startswith(root + os.sep) that the final resolved path still lies under the root (defending against symlinks or odd encodings that slip past the parts check). If it escapes, ValueError '<scheme>:// path escapes its root' is raised. This is the final containment check before read/write proceed.","triggerScenarios":"A relative path that, once normalized/joined (e.g. via symlinked directories inside the root or URL-encoded segments decoded by unquote), resolves outside root_path — including the edge where resolved equals neither the root nor a root-prefixed path.","commonSituations":"Root directory itself being a symlink whose target changes the prefix; encoded '..%2F' segments that decode into traversal after the parts check; deeply nested joins like 'a/../../b' with unusual separators on Windows.","solutions":["Use a simple relative path confined to the scheme root (no encoded or symlink-traversing segments).","Place the target file directly under the configured root and reference it relatively.","If symlinks inside the root redirect outside, point PI_EVAL_LOCAL_ROOTS at the real (non-symlink) directory."],"exampleFix":"# before\nread(\"omp://link/../../etc/hosts\")  # link is a symlink out of the root\n# after\nshutil.copy(\"/etc/hosts\", \"hosts.txt\")  # host-side copy into root\nread(\"omp://hosts.txt\")","handlingStrategy":"validation","validationCode":"import os\nroot = os.path.abspath(os.environ.get('OMP_ROOT', '.'))\ntarget = os.path.abspath(os.path.join(root, rel))\nassert target == root or target.startswith(root + os.sep), 'path would escape root'","typeGuard":"def contained(root, p): return os.path.abspath(p) == root or os.path.abspath(p).startswith(root + os.sep)","tryCatchPattern":"try:\n    data = read(uri)\nexcept ValueError as e:\n    if 'escapes its root' in str(e):\n        # symlink or encoded traversal — resolve host-side and copy into the root\n        raise PermissionError(str(e)) from e\n    raise","preventionTips":["Avoid symlinks inside the root that point outside; use real directories in PI_EVAL_LOCAL_ROOTS.","Percent-encode path segments intentionally and avoid '%2E%2E'-style payloads.","Keep relative paths shallow and free of '..'.","Run a containment check host-side before handing URIs to model code."],"tags":["python","path-traversal","security","symlink"],"backgroundTag":"path-escapes-root","analyzedSha":"969062200754ea02cfac922e5ebb8c608c079e15","analyzedAt":"2026-08-31T10:29:35.737Z","schemaVersion":2},"datasetVersion":"2026-08-31T14:17:45.589Z"}