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

85 lines
3.1 KiB
Python

# coding=utf-8
"""
@Time :2026/6/30
@Author :tao.chen
"""
from pathlib import Path
import pytest
from files_mcp.core import path_guard
@pytest.fixture
def sandbox_root(tmp_path, monkeypatch):
"""Fresh tmp_path-based sandbox root for each test.
Pre-populates a regular file, a nested file inside a subdir, and a
symlink that points outside the sandbox (for the symlink-escape test).
"""
monkeypatch.setenv("FILES_MCP_ROOT", str(tmp_path))
path_guard.set_root_for_testing(tmp_path.resolve())
(tmp_path / "inside.txt").write_text("hi", encoding="utf-8")
(tmp_path / "subdir").mkdir()
(tmp_path / "subdir" / "nested.txt").write_text("nested", encoding="utf-8")
(tmp_path / "escape_link").symlink_to("/etc/passwd")
return tmp_path.resolve()
class TestResolveAndCheck:
def test_accepts_inside_path(self, sandbox_root):
p = path_guard.resolve_and_check("inside.txt")
assert p == sandbox_root / "inside.txt"
def test_accepts_nested_path(self, sandbox_root):
p = path_guard.resolve_and_check("subdir/nested.txt")
assert p == sandbox_root / "subdir" / "nested.txt"
def test_accepts_dot_segments(self, sandbox_root):
p = path_guard.resolve_and_check("subdir/../inside.txt")
assert p == sandbox_root / "inside.txt"
def test_rejects_double_dot_escape(self, sandbox_root):
with pytest.raises(ValueError, match="escapes sandbox"):
path_guard.resolve_and_check("../etc/passwd")
def test_rejects_absolute_outside_path(self, sandbox_root):
with pytest.raises(ValueError, match="escapes sandbox"):
path_guard.resolve_and_check("/etc/passwd")
def test_rejects_mixed_escape(self, sandbox_root):
with pytest.raises(ValueError, match="escapes sandbox"):
path_guard.resolve_and_check("subdir/../../etc/passwd")
def test_rejects_symlink_escape(self, sandbox_root):
# escape_link -> /etc/passwd; resolve() follows the link first.
with pytest.raises(ValueError, match="escapes sandbox"):
path_guard.resolve_and_check("escape_link")
def test_rejects_empty(self, sandbox_root):
with pytest.raises(ValueError, match="path is empty"):
path_guard.resolve_and_check("")
def test_rejects_nul_byte(self, sandbox_root):
with pytest.raises(ValueError, match="NUL byte"):
path_guard.resolve_and_check("foo\x00bar")
class TestInitRoot:
def test_init_root_raises_if_unset(self, monkeypatch):
monkeypatch.delenv("FILES_MCP_ROOT", raising=False)
path_guard._ROOT = None
with pytest.raises(RuntimeError, match="FILES_MCP_ROOT"):
path_guard._init_root()
def test_init_root_raises_if_not_directory(self, tmp_path, monkeypatch):
monkeypatch.setenv("FILES_MCP_ROOT", str(tmp_path / "missing"))
path_guard._ROOT = None
with pytest.raises(RuntimeError, match="not an existing directory"):
path_guard._init_root()
def test_init_root_is_idempotent(self, sandbox_root):
first = path_guard._ROOT
path_guard._init_root()
assert path_guard._ROOT == first