can1357/oh-my-pi · error · ValueError

{scheme}:// path escapes its root: {path}

Error message

{scheme}:// path escapes its root: {path}

What it means

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.

Source

Thrown at packages/coding-agent/src/eval/py/prelude.py:115

            roots = json.loads(os.environ.get("PI_EVAL_LOCAL_ROOTS") or "{}")
        except (ValueError, TypeError):
            roots = {}
        root = roots.get(scheme) if isinstance(roots, dict) else None
        if not root:
            raise ValueError(f"Protocol paths are not supported by this helper: {path}")
        relative = unquote(match.group(2).replace("\\", "/"))
        # Mirror the host `path.resolve`/`resolveLocalUrlToPath`: normalize and
        # make absolute WITHOUT realpath'ing symlinks (Path.resolve would turn
        # /tmp into /private/tmp and diverge from the read-side resolution).
        root_path = os.path.abspath(root)
        if relative == "":
            return Path(root_path)
        rel_path = Path(relative)
        if rel_path.is_absolute() or ".." in rel_path.parts:
            raise ValueError(f"Unsafe {scheme}:// path (absolute or traversal): {path}")
        resolved = os.path.abspath(os.path.join(root_path, relative))
        if resolved != root_path and not resolved.startswith(root_path + os.sep):
            raise ValueError(f"{scheme}:// path escapes its root: {path}")
        return Path(resolved)

    def read(path: str | Path, offset: int = 1, limit: int | None = None) -> str:
        """Read file or read-tool URI contents. offset/limit are 1-indexed lines."""
        if _should_delegate_read(path):
            if limit is not None and limit <= 0:
                return ""
            selector = _read_line_selector(offset, limit)
            tool_path = path if selector is None else f"{path}:{selector}"
            return _read_tool_text(tool_path)
        p = _resolve_omp_path(path)
        data = p.read_text(encoding="utf-8")
        lines = data.splitlines(keepends=True)
        if offset > 1 or limit is not None:
            start = max(0, offset - 1)
            end = start + limit if limit else len(lines)
            lines = lines[start:end]
            data = "".join(lines)

View on GitHub (pinned to 9690622007)

Solutions

  1. Use a simple relative path confined to the scheme root (no encoded or symlink-traversing segments).
  2. Place the target file directly under the configured root and reference it relatively.
  3. If symlinks inside the root redirect outside, point PI_EVAL_LOCAL_ROOTS at the real (non-symlink) directory.

Example fix

# before
read("omp://link/../../etc/hosts")  # link is a symlink out of the root
# after
shutil.copy("/etc/hosts", "hosts.txt")  # host-side copy into root
read("omp://hosts.txt")
Defensive patterns

Strategy: validation

Validate before calling

import os
root = os.path.abspath(os.environ.get('OMP_ROOT', '.'))
target = os.path.abspath(os.path.join(root, rel))
assert target == root or target.startswith(root + os.sep), 'path would escape root'

Type guard

def contained(root, p): return os.path.abspath(p) == root or os.path.abspath(p).startswith(root + os.sep)

Try / catch

try:
    data = read(uri)
except ValueError as e:
    if 'escapes its root' in str(e):
        # symlink or encoded traversal — resolve host-side and copy into the root
        raise PermissionError(str(e)) from e
    raise

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Related errors


AI-assisted analysis of can1357/oh-my-pi@9690622007 (2026-08-31). Data as JSON: /api/errors/f76b4ad80d397aba. Report an issue: GitHub.