Files
ClaudeandClaude Fable 5 d8d541f50d feat(files-mcp): scaffold package + path_guard sandbox
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>
2026-06-30 17:39:23 +08:00

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)