"""Configuration and path resolution for the navi-3d MCP server.
Paths are resolved inside ``session_files/<session_id>/`` when relative.
Absolute paths are accepted only if they sit inside ``FS_ALLOWED_PATHS``.
"""
from __future__ import annotations
import os
import re
from pathlib import Path
# A session id becomes one path segment under ``session_files/``. Real ids are
# uuid4 strings (``navi/core/session.py``), so this admits every one of them and
# nothing that could walk out of the directory.
_SESSION_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._ -]{0,127}$")
class Settings:
"""Lightweight env-based settings."""
def __init__(self) -> None:
self.session_files_dir = Path(
os.environ.get("SESSION_FILES_DIR", "session_files")
).resolve()
self.fs_allowed_paths = self._parse_allowed_paths(
os.environ.get("FS_ALLOWED_PATHS", "*")
)
self.openscad = os.environ.get("OPENSCAD_PATH", "openscad")
def _parse_allowed_paths(self, raw: str) -> list[Path]:
if raw == "*":
return []
return [Path(p.strip()).resolve() for p in raw.split(",") if p.strip()]
def session_dir(self, session_id: str) -> Path:
"""The session directory for *session_id*, or ``ValueError``.
``session_id`` arrives as a tool argument, so it is caller-controlled
and may not be trusted to be an id: it is joined onto
``session_files/`` as a path segment, and ``../..`` in it would resolve
to the whole filesystem before any containment check could see it.
"""
sid = session_id.strip()
if ".." in sid or not _SESSION_ID_RE.match(sid):
raise ValueError(f"Invalid session_id: {session_id!r}")
return (self.session_files_dir / sid).resolve()
def resolve_path(self, session_id: str, raw_path: str) -> Path:
"""Resolve a path that may be relative to the session directory.
- Relative paths are resolved inside ``session_files/<session_id>/``.
- Absolute paths are accepted only if they are inside one of the
allowed roots (or ``*`` is set).
Raises ``ValueError`` on anti-escape or disallowed absolute path.
"""
path = Path(raw_path).expanduser()
session_dir = self.session_dir(session_id)
if not path.is_absolute():
# Resolve before the containment check below. A lexically inside
# path can still point outside — ``../<other session>/x.scad``
# passes ``is_relative_to`` with the ``..`` intact and only the OS
# collapses it at open time — and resolving is also what catches a
# symlink leading out of the session directory.
path = (session_dir / path).resolve()
else:
path = path.resolve()
if self.fs_allowed_paths:
if not any(
path == allowed or path.is_relative_to(allowed)
for allowed in self.fs_allowed_paths
):
raise ValueError(
f"Absolute path {path} is outside allowed paths."
)
# Anti-escape: the resolved path must still be inside the session dir
# or inside an allowed root.
if not (path == session_dir or path.is_relative_to(session_dir)):
if self.fs_allowed_paths:
if not any(
path == allowed or path.is_relative_to(allowed)
for allowed in self.fs_allowed_paths
):
raise ValueError(f"Path escapes permitted directories: {path}")
else:
raise ValueError(f"Path escapes session directory: {path}")
return path