Newer
Older
navi-1 / mcp-servers / navi-3d / app / config.py
"""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