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
- 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.
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
- 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.
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
- ${scheme}:// path escapes its root: ${rawPath}
- Unsafe {scheme}:// path (absolute or traversal): {path}
- #{scheme}:// path escapes its root: #{path}
- local:// URL escapes local root
- Path traversal is not allowed
AI-assisted analysis of can1357/oh-my-pi@9690622007 (2026-08-31).
Data as JSON: /api/errors/f76b4ad80d397aba.
Report an issue: GitHub.