Adds the files_mcp/ sibling package and core/path_guard.py which resolves arbitrary input paths and rejects any that escape the FILES_MCP_ROOT sandbox (handles .., ~, symlinks, NUL, empty). Adds tests/unit/test_path_guard.py covering accept, escape modes, symlink escape, and _init_root failure paths. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
84 lines
2.6 KiB
Python
84 lines
2.6 KiB
Python
# 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
|
|
"<root>/inside.txt", not "<cwd>/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
|
|
# "<root>/inside.txt", not "<cwd>/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)
|