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>
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
# 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)
|
||||
Reference in New Issue
Block a user