Source code for phenotypic.gui.shell._sandbox
"""``SandboxRoot`` dataclass + safe-resolve helpers.
A ``SandboxRoot`` is a frozen-at-launch handle on the directory that the GUI is
allowed to read. Every path that comes from a request (sidebar URL, JSON-API
``?path=`` parameter, ``/runs/<rel>/...`` route) is run through
``SandboxRoot.resolve(...)`` before being touched on disk. Out-of-root paths
and symlinks pointing outside the sandbox raise ``ValueError``.
Single-user, in-process model — there is no auth gate. Cloud mode (multi-user,
per-session roots) is a non-goal for v1; see the TODO at the top.
TODO(cloud-deploy): when ``--mode=cloud`` ships, wire an auth gate via a Flask
``@before_request`` hook on every ``/sandbox/api/*`` and ``/runs/*`` route
(touch one place, not every Dash callback). The sandbox is currently
frozen-at-launch (single-user); cloud mode must make the root selectable
per session.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Iterator
__all__ = ["SandboxRoot"]
[docs]
@dataclass(frozen=True)
class SandboxRoot:
"""Frozen-at-launch sandbox containment primitive.
Attributes:
root: Absolute, fully-resolved directory the GUI is allowed to read
from. Symlinks in the path are resolved at construction time so
``contains`` / ``resolve`` comparisons are stable for the lifetime
of the process.
"""
root: Path
[docs]
@classmethod
def from_path(cls, root: str | os.PathLike[str]) -> "SandboxRoot":
"""Construct a sandbox from a user-supplied root path.
Resolves symlinks and verifies the root exists and is a directory.
Args:
root: A directory path. May be relative; will be resolved.
Returns:
A ``SandboxRoot`` with ``.root`` fully resolved and absolute.
Raises:
FileNotFoundError: If ``root`` does not exist.
NotADirectoryError: If ``root`` exists but is not a directory.
RuntimeError: If ``root`` resolution encounters a symlink loop
(CPython's :meth:`Path.resolve` raises ``RuntimeError`` for
cycles, not :class:`OSError`). The launcher catches and
re-presents this as a startup error rather than a stack
trace.
"""
resolved = Path(root).expanduser().resolve(strict=True)
if not resolved.is_dir():
raise NotADirectoryError(
f"sandbox root must be a directory: {resolved}"
)
return cls(root=resolved)
[docs]
def resolve(self, candidate: str | os.PathLike[str]) -> Path:
"""Resolve ``candidate`` to an absolute path inside the sandbox.
``candidate`` may be absolute or relative. Relative paths are joined
against ``self.root``. Symlinks are followed; the resulting absolute
path is verified to be inside ``self.root``. This catches both
``..``-style traversal and symlinks pointing outside the sandbox.
Args:
candidate: A path supplied by an untrusted source (URL, query
string, JSON body).
Returns:
Absolute, fully-resolved ``Path`` guaranteed to be inside (or
equal to) ``self.root``.
Raises:
ValueError: If ``candidate`` resolves outside ``self.root`` or
follows a symlink that escapes ``self.root``.
"""
cand = Path(candidate)
joined = cand if cand.is_absolute() else self.root / cand
# ``resolve(strict=False)`` is intentional: we want to apply the
# same containment check whether or not the file currently exists,
# otherwise probes for "does X exist?" leak whether the path traverses
# out of root. ``strict=False`` resolves symlinks for path components
# that do exist and leaves the rest as-is.
resolved = joined.resolve(strict=False)
if not self._contains(resolved):
raise ValueError(
f"path escapes sandbox root: {candidate!r} -> {resolved}"
)
return resolved
[docs]
def contains(self, candidate: str | os.PathLike[str]) -> bool:
"""Return ``True`` iff ``candidate`` resolves inside the sandbox.
Convenience predicate that wraps ``resolve`` and swallows the
``ValueError`` raised on escapes. Useful for "should I render this
at all?" checks where raising would be inconvenient.
"""
try:
self.resolve(candidate)
except ValueError:
return False
return True
[docs]
def list_children(
self,
directory: str | os.PathLike[str] | None = None,
*,
include_hidden: bool = False,
include_external_symlinks: bool = False,
) -> Iterator[Path]:
"""Yield direct children of ``directory`` (default: ``self.root``).
Args:
directory: Path to list. Defaults to the sandbox root. Validated
via ``resolve`` first; out-of-root paths raise ``ValueError``.
include_hidden: Yield dotfiles when ``True``. Default ``False``
matches the sidebar's "Hidden files" toggle (off by default).
include_external_symlinks: Yield symlinks whose targets escape
``self.root`` when ``True``. Default ``False`` matches the
sidebar's "External symlinks" toggle. Non-symlink children
are unaffected.
Yields:
Absolute ``Path`` objects, one per child. Symlinks are yielded
unless they target outside the sandbox and the toggle is off.
Raises:
ValueError: If ``directory`` resolves outside the sandbox.
PermissionError: If the directory cannot be listed; callers may
choose to swallow this and surface the ``bad_perms`` capability
badge instead.
"""
target = self.resolve(directory) if directory is not None else self.root
for child in target.iterdir():
if not include_hidden and child.name.startswith("."):
continue
if child.is_symlink() and not include_external_symlinks:
if not self._symlink_target_in_root(child):
continue
yield child
# ------------------------------------------------------------------
# Internals
# ------------------------------------------------------------------
def _contains(self, resolved: Path) -> bool:
"""Pure-comparison containment check on an already-resolved path."""
try:
resolved.relative_to(self.root)
except ValueError:
return False
return True
def _symlink_target_in_root(self, link: Path) -> bool:
"""Return ``True`` iff ``link`` is a symlink whose target is inside.
Used by ``list_children`` to filter out external symlinks. Broken
symlinks are evaluated against their unresolved target via
``Path.resolve(strict=False)`` — if the target string lands outside
the sandbox, the link is filtered; if the target string would have
landed inside the sandbox, the link is yielded even though it
cannot currently be followed (the consumer surfaces the broken
state via :func:`classify`).
"""
try:
target = link.resolve(strict=False)
except (OSError, RuntimeError):
# ``RuntimeError`` is raised by CPython on symlink cycles
# (``"Symlink loop from..."``). Treat the link as external so
# the caller filters it rather than crashing the render.
return False
return self._contains(target)