# coding=utf-8 """ @Time :2026/6/30 @Author :tao.chen Sandbox: every user-supplied path is resolve()d and checked against a single frozen root directory. resolve() follows symlinks and normalizes .., ~, and duplicate slashes, so a single relative_to() check is enough. Relative inputs are anchored at the root: a bare "inside.txt" means "/inside.txt", not "/inside.txt". Absolute paths and paths that already contain a separator (e.g. "~/x") are left intact so ~ and leading-slash inputs behave as expected. """ import os from pathlib import Path from common.logging import logger _ROOT: Path | None = None def _init_root() -> Path: """Resolve and freeze the sandbox root from FILES_MCP_ROOT. Called once at package import time. Raises RuntimeError if the env var is unset, points at a missing path, or points at a non-directory. Idempotent: subsequent calls return the cached root. """ global _ROOT if _ROOT is not None: return _ROOT raw = os.environ.get("FILES_MCP_ROOT") if not raw: raise RuntimeError( "FILES_MCP_ROOT env var is required (e.g. export " "FILES_MCP_ROOT=/srv/files-mcp-sandbox)" ) p = Path(raw).expanduser().resolve() if not p.is_dir(): raise RuntimeError( f"FILES_MCP_ROOT={raw!r} resolves to {p} which is not an existing directory" ) _ROOT = p logger.info(f"Files MCP sandbox root: {_ROOT}") return _ROOT def resolve_and_check(path: str) -> Path: """Resolve an arbitrary input path and verify it lies inside the sandbox. Raises ValueError if the path is empty, contains NUL, or escapes the root after symlink/.. resolution. """ if _ROOT is None: _init_root() assert _ROOT is not None if not path: raise ValueError("path is empty") if "\x00" in path: raise ValueError("path contains NUL byte") # Anchor bare relative names at the sandbox root so "inside.txt" means # "/inside.txt", not "/inside.txt". Keep user home ("~...") and # absolute paths untouched so they still resolve / reject as expected. if not os.path.isabs(path) and not path.startswith("~"): candidate = _ROOT / path else: candidate = Path(path) p = candidate.expanduser().resolve() try: p.relative_to(_ROOT) except ValueError: raise ValueError( f"path escapes sandbox root {_ROOT}: got {p}" ) from None return p def set_root_for_testing(p: Path) -> None: """Test-only escape hatch. Never call from production code.""" global _ROOT _ROOT = p.resolve() os.environ["FILES_MCP_ROOT"] = str(_ROOT)