"""The 3D MCP server must refuse every path outside the caller's session dir.
``navi-3d`` runs as its own process and resolves paths itself, so the sandbox in
``navi/tools/_internal/areas.py`` never applies to it: whatever ``Settings``
accepts, the tool opens, creates or overwrites. A regular user reaches it
through the ``designer_3d`` profile, where the server is granted to both the
agent and its subagents — so a ``session_id`` or ``source_path`` that walks out
of ``session_files/`` is host-wide read and write for an ordinary user.
These tests live in the root suite rather than in ``mcp-servers/navi-3d/tests/``
because ``pyproject.toml`` collects ``testpaths = ["tests"]`` only.
"""
import importlib.util
from pathlib import Path
import pytest
_SERVER_CONFIG = (
Path(__file__).resolve().parents[3]
/ "mcp-servers"
/ "navi-3d"
/ "app"
/ "config.py"
)
@pytest.fixture
def settings(tmp_path, monkeypatch):
"""The server's ``Settings``, rooted in a temporary ``session_files``."""
monkeypatch.setenv("SESSION_FILES_DIR", str(tmp_path / "session_files"))
# The shipped config sets no FS_ALLOWED_PATHS, so the default is what runs.
monkeypatch.delenv("FS_ALLOWED_PATHS", raising=False)
spec = importlib.util.spec_from_file_location("navi_3d_config_under_test", _SERVER_CONFIG)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
settings = module.Settings()
settings.sessions = tmp_path / "session_files"
return settings
class TestSessionId:
"""A session id is one path segment; it may not be a way out."""
def test_honest_id_resolves_inside_session_files(self, settings):
assert settings.resolve_path("sess-1", "model.scad") == (
settings.sessions / "sess-1" / "model.scad"
)
def test_relative_traversal_is_refused(self, settings):
with pytest.raises(ValueError):
settings.resolve_path("../..", "navi-1/.env")
def test_traversal_to_the_filesystem_root_is_refused(self, settings):
# Enough ``..`` reaches ``/``, after which any relative path is inside.
with pytest.raises(ValueError):
settings.resolve_path("../../../../..", "etc/passwd")
def test_separator_is_refused(self, settings):
with pytest.raises(ValueError):
settings.resolve_path("sess/1", "model.scad")
def test_empty_id_is_refused(self, settings):
with pytest.raises(ValueError):
settings.resolve_path("", "model.scad")
class TestPathResolution:
"""Paths resolve inside the session dir and leave it only by absolute path."""
def test_relative_path_comes_back_resolved(self, settings):
resolved = settings.resolve_path("sess-1", "model.scad")
assert ".." not in resolved.parts
assert resolved == (settings.sessions / "sess-1" / "model.scad")
def test_relative_traversal_is_refused(self, settings):
with pytest.raises(ValueError):
settings.resolve_path("sess-1", "../sess-2/model.scad")
def test_cross_session_path_is_refused(self, settings):
"""``is_relative_to`` is lexical, so the ``..`` must be resolved first."""
(settings.sessions / "sess-2").mkdir(parents=True)
with pytest.raises(ValueError):
settings.resolve_path("sess-1", "../sess-2/leaked.scad")
def test_symlink_out_of_the_session_is_refused(self, settings, tmp_path):
outside = tmp_path / "outside.scad"
outside.write_text("cube([1,1,1]);\n")
session = settings.sessions / "sess-1"
session.mkdir(parents=True)
(session / "link.scad").symlink_to(outside)
with pytest.raises(ValueError):
settings.resolve_path("sess-1", "link.scad")
def test_absolute_path_outside_is_refused(self, settings):
with pytest.raises(ValueError):
settings.resolve_path("sess-1", "/etc/passwd")
def test_absolute_path_inside_the_session_is_allowed(self, settings):
inside = settings.sessions / "sess-1" / "model.stl"
assert settings.resolve_path("sess-1", str(inside)) == inside