unslothai/unsloth · error · ValueError

path escapes root: {resolved!s} -> {resolved_real!s} is not

Error message

path escapes root: {resolved!s} -> {resolved_real!s} is not under {root_real!s}

What it means

Raised by _assert_contained in storage_roots.py:388-392 when the realpath of the resolved candidate does not live under the realpath of the root — the classic path-traversal containment check. Even though a relative path is joined onto root, symlinked directories inside the root can point outside, and realpath exposes that. The message shows resolved -> resolved_real vs root_real.

Source

Thrown at studio/backend/utils/paths/storage_roots.py:388

def _is_absolute_user_path(path: Path) -> bool:
    expanded = str(path)
    if os.name == "nt":
        return path.is_absolute() and PureWindowsPath(expanded).is_absolute()
    return path.is_absolute() and PurePosixPath(expanded).is_absolute()


def _assert_contained(resolved: Path, root: Path) -> None:
    """Raise ValueError if ``resolved`` realpaths outside ``root``."""
    try:
        resolved_real = Path(os.path.realpath(resolved))
        root_real = Path(os.path.realpath(root))
    except OSError as exc:
        raise ValueError(f"path resolution failed: {exc}") from exc
    try:
        resolved_real.relative_to(root_real)
    except ValueError as exc:
        raise ValueError(
            f"path escapes root: {resolved!s} -> {resolved_real!s} " f"is not under {root_real!s}"
        ) from exc


def resolve_under_root(
    path_value: str | None,
    *,
    root: Path,
    strip_prefixes: tuple[str, ...] = (),
) -> Path:
    """Resolve ``path_value`` and assert the result is under ``root``.

    Absolutes are accepted only if already contained (so pre-resolved
    internal paths re-enter idempotently); schemas reject absolutes upstream.
    """
    if not path_value or not str(path_value).strip():
        return root

View on GitHub (pinned to 203007d190)

Solutions

  1. Point the resolver at the realpath of the location: configure the root to the actual target of the symlink, or move data physically under the root
  2. If the user needs files on another volume, mount/bind that volume directly under the root rather than symlinking out
  3. For legitimate absolute paths, use resolve_export_write_dir which intentionally passes absolutes through
  4. Catch ValueError and surface a clear 'paths must stay under <root>' message to the user

Example fix

# before (root/models -> symlink to /data/models)
resolve_under_root("models/llama.gguf", root=Path("/srv/studio"))
# ValueError: path escapes root: /srv/studio/models/llama.gguf -> /data/models/llama.gguf ...

# after: mount the volume under the root, or configure the real target
resolve_under_root("llama.gguf", root=Path("/data/models").resolve())
Defensive patterns

Strategy: validation

Validate before calling

def contained_under(path_str: str, root) -> bool:
    try:
        p = Path(os.path.realpath(Path(root) / path_str))
        r = Path(os.path.realpath(root))
        p.relative_to(r)
        return True
    except (ValueError, OSError):
        return False

Try / catch

try:
    path = resolve_under_root(value, root=root)
except ValueError as exc:
    if "path escapes root" in str(exc):
        raise HTTPException(400, f"paths must stay under {root}") from exc
    raise

Prevention

When it happens

Trigger: resolve_under_root("models/foo", root=...) where root/models is a symlink to /etc; absolute inputs that are inside string-wise but resolve elsewhere after realpath; Windows subst/junction mappings under the root; also a user-supplied absolute path outside the root passed to a resolver that accepts absolutes only when contained.

Common situations: Users symlink their model folder to another drive to save space and the containment check correctly blocks it; security scanners probing traversal; roots configured with symlinked children; docker volume mounts whose realpath differs from the configured root string.

Related errors


AI-assisted analysis of unslothai/unsloth@203007d190 (2026-08-15). Data as JSON: /api/errors/96079d1dcf9c2c70. Report an issue: GitHub.