Source code for oct.mcp.sandbox

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# oct/mcp/sandbox.py

"""
Purpose
-------
Execution Sandbox (Layer 4, outer wrapper) for the ``oct-mcp``
six-layer pipeline. Provides environment sanitisation, working-directory
pinning, timeout enforcement, and output-size limiting for subprocess
execution.

Phase 5C adds pluggable OS-level sandbox *backends*:

* :class:`NativeBackend` — existing behavior (subprocess + env
  sanitization). Always available; default on Windows.
* :class:`BubblewrapBackend` — Linux only. Wraps every command in
  ``bwrap --unshare-net --die-with-parent``. Requires ``bwrap`` in PATH.
* :class:`SandboxExecBackend` — macOS only. Prepends
  ``sandbox-exec -p "(deny network*)(allow default)"``. Requires
  ``sandbox-exec`` in PATH.

Use :func:`make_sandbox_backend` to select the appropriate backend based
on :class:`~oct.mcp.config.McpConfig`.

Responsibilities
----------------
- Strip sensitive environment variables before forking (``PYTHONPATH``,
  common secret-named vars).
- Pin the subprocess working directory to the validated project root.
- Enforce per-tool timeouts and kill the process on expiry.
- Cap combined stdout+stderr output to ``max_output_bytes``.
- Never use ``shell=True``.
- Return a :class:`SandboxResult` — never raise for subprocess errors.

Diagnostics
-----------
Domain: MCP
Levels:
    L1 — errors: process launch failures, unexpected errors
    L2 — lifecycle: process start, process end, backend selection
    L3 — command details (argv)
    L4 — deep trace: per-line output, env keys

Contracts
---------
- ``shell=False`` is enforced unconditionally in all backends — no
  caller path can override it.
- :meth:`SandboxExecutor.run` never raises. Any ``OSError`` or
  ``subprocess.SubprocessError`` is caught, logged at level 1, and
  returned as a ``SandboxResult`` with a non-zero exit code.
- The sanitised environment always preserves ``PATH``, ``HOME``,
  ``LANG``, ``TERM``, ``USER``, ``USERNAME``, ``LOGNAME``,
  ``VIRTUAL_ENV``, ``CONDA_DEFAULT_ENV``, and
  ``OCT_MCP_SESSION_ID`` / ``OCT_MCP_REQUEST_ID`` correlation IDs.
- :func:`make_sandbox_backend` never raises — it always returns a
  usable backend (falls back to :class:`NativeBackend`).
"""

from __future__ import annotations

import os
import shutil
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Protocol, runtime_checkable

try:
    from oc_diagnostics import _dbg as _real_dbg

    def _dbg(*args, **kwargs) -> None:
        _real_dbg(*args, **kwargs)
except ImportError:  # pragma: no cover
    def _dbg(*args, **kwargs) -> None:
        return None


# ---------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------

#: Environment variable keys (or key substrings, case-insensitive) that
#: must be stripped before passing the environment to a subprocess.
#: Full key names are exact-matched first; substrings are checked second.
_SENSITIVE_ENV_EXACT: frozenset[str] = frozenset({
    "PYTHONPATH",
})

#: Case-insensitive substrings. Any env key containing one of these
#: tokens is stripped.
_SENSITIVE_ENV_SUBSTRINGS: tuple[str, ...] = (
    "api_key", "apikey",
    "password", "passwd", "pwd",
    "secret",
    "token",
    "credentials",
    "private_key",
    "auth",
    "access_key",
)

#: Environment variable keys that are always preserved regardless of
#: name-based filtering.
_SAFE_ENV_KEYS: frozenset[str] = frozenset({
    "PATH",
    "HOME",
    "LANG",
    "LC_ALL",
    "LC_CTYPE",
    "TERM",
    "COLORTERM",
    "USER",
    "USERNAME",
    "LOGNAME",
    "USERPROFILE",         # Windows home directory
    "SYSTEMROOT",          # Windows system root
    "VIRTUAL_ENV",
    "CONDA_DEFAULT_ENV",
    "CONDA_PREFIX",
    "OCT_MCP_SESSION_ID",
    "OCT_MCP_REQUEST_ID",
    # Diagnostics env vars
    "OCT_DBG_LEVEL",
    "OCT_DBG_FILE",
})


# ---------------------------------------------------------------------
# Environment sanitisation (shared by all backends)
# ---------------------------------------------------------------------


def _sanitise_env(project_root: Path) -> dict[str, str]:
    """Build a sanitised environment for subprocess execution.

    Starts from ``os.environ``, preserves :data:`_SAFE_ENV_KEYS`,
    strips :data:`_SENSITIVE_ENV_EXACT` exact keys and anything whose
    key contains a :data:`_SENSITIVE_ENV_SUBSTRINGS` token (case-
    insensitive), and adds ``OCT_MCP_CWD`` so the subprocess knows
    its sandboxed working directory.

    Returns
    -------
    A new ``dict[str, str]`` — never modifies ``os.environ``.
    """
    func = "_sanitise_env"
    clean: dict[str, str] = {}

    for key, value in os.environ.items():
        key_upper = key.upper()

        # Always keep safe keys.
        if key_upper in {k.upper() for k in _SAFE_ENV_KEYS}:
            clean[key] = value
            continue

        # Strip exact sensitive keys.
        if key_upper in {k.upper() for k in _SENSITIVE_ENV_EXACT}:
            _dbg("MCP", func, f"stripped env key={key}", 4)
            continue

        # Strip substring-matched keys.
        if any(sub in key_upper.lower() for sub in _SENSITIVE_ENV_SUBSTRINGS):
            _dbg("MCP", func, f"stripped env key={key} (substring match)", 4)
            continue

        clean[key] = value

    # Pin working directory as an env var for informational purposes.
    clean["OCT_MCP_CWD"] = str(project_root)

    return clean


# ---------------------------------------------------------------------
# Result type
# ---------------------------------------------------------------------


[docs] @dataclass class SandboxResult: """Result of a sandboxed subprocess execution. Attributes ---------- exit_code The subprocess return code, or ``1`` on launch failure. stdout Captured standard output (possibly truncated). stderr Captured standard error (possibly truncated). timed_out ``True`` if the process was killed due to timeout. output_truncated ``True`` if combined stdout+stderr exceeded ``max_output_bytes``. error_message Non-empty if the process could not be launched (e.g. not found on PATH). Distinct from stderr — set on ``OSError``. """ exit_code: int stdout: str stderr: str timed_out: bool = False output_truncated: bool = False error_message: str = ""
# --------------------------------------------------------------------- # Backend protocol # ---------------------------------------------------------------------
[docs] @runtime_checkable class SandboxBackend(Protocol): """Protocol for OS-level sandbox backends. Each backend wraps the subprocess call with platform-specific isolation (network namespace, resource limits, etc.). The backend receives a sanitised environment and returns raw ``(exit_code, stdout, stderr)`` — output capping and error handling live in :class:`SandboxExecutor`. """
[docs] def run( self, cmd: list[str], env: dict[str, str], cwd: Path, timeout: int, memory_limit_bytes: int | None, ) -> tuple[int, str, str]: """Run *cmd* and return ``(exit_code, stdout, stderr)``. Parameters ---------- cmd Command + arguments list. Must not use ``shell=True``. env Sanitised environment dict. cwd Working directory (validated project root). timeout Maximum wall-clock seconds. memory_limit_bytes Virtual memory ceiling in bytes, or ``None`` to skip. Implementations on POSIX apply this via ``setrlimit``; on Windows it is silently ignored. """ ...
# --------------------------------------------------------------------- # Concrete backends # ---------------------------------------------------------------------
[docs] class NativeBackend: """Subprocess-only backend — existing Phase 5A/5B behavior. On POSIX, when *memory_limit_bytes* is non-None and non-zero, applies ``resource.setrlimit(RLIMIT_AS, ...)`` via ``preexec_fn`` to limit virtual address space. On Windows the limit is silently ignored (use Docker for Windows memory enforcement). """
[docs] def run( self, cmd: list[str], env: dict[str, str], cwd: Path, timeout: int, memory_limit_bytes: int | None, ) -> tuple[int, str, str]: preexec = None if memory_limit_bytes and os.name != "nt": limit = memory_limit_bytes def _preexec() -> None: import resource resource.setrlimit( resource.RLIMIT_AS, (limit, limit) ) preexec = _preexec result = subprocess.run( cmd, capture_output=True, text=True, encoding="utf-8", errors="replace", cwd=str(cwd), env=env, timeout=timeout, shell=False, # NEVER shell=True preexec_fn=preexec, ) return result.returncode, result.stdout or "", result.stderr or ""
[docs] class BubblewrapBackend: """Linux bubblewrap backend — network-isolated, die-with-parent. Wraps every command in ``bwrap --unshare-net --die-with-parent``. Requires ``bwrap`` to be available in PATH. Network isolation is provided by ``--unshare-net``. The project root is bind-mounted read-write; system paths are read-only. """ def _bwrap_cmd(self, cmd: list[str], cwd: Path) -> list[str]: """Build the full ``bwrap ... -- cmd`` invocation.""" bwrap = ["bwrap"] # Read-only system mounts (common paths; skip missing ones silently). for ro_path in ("/usr", "/lib", "/lib64", "/bin", "/sbin", "/etc", "/run", "/opt"): if Path(ro_path).exists(): bwrap += ["--ro-bind", ro_path, ro_path] bwrap += [ "--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp", # Project root bind-mounted read-write. "--bind", str(cwd), str(cwd), "--chdir", str(cwd), # Network isolation. "--unshare-net", # Kill sandbox when the parent exits. "--die-with-parent", "--", ] return bwrap + cmd
[docs] def run( self, cmd: list[str], env: dict[str, str], cwd: Path, timeout: int, memory_limit_bytes: int | None, ) -> tuple[int, str, str]: wrapped = self._bwrap_cmd(cmd, cwd) result = subprocess.run( wrapped, capture_output=True, text=True, encoding="utf-8", errors="replace", cwd=str(cwd), env=env, timeout=timeout, shell=False, ) return result.returncode, result.stdout or "", result.stderr or ""
[docs] class SandboxExecBackend: """macOS ``sandbox-exec`` backend — network-denied policy. Prepends ``sandbox-exec -p "<profile>"`` to every command. Requires ``sandbox-exec`` to be available in PATH. The policy denies all network access while allowing everything else (file I/O, process creation, etc.) needed to run ``oct`` commands. """ _PROFILE: str = "(version 1)(deny network*)(allow default)" def _sandboxed_cmd(self, cmd: list[str]) -> list[str]: """Return ``["sandbox-exec", "-p", PROFILE] + cmd``.""" return ["sandbox-exec", "-p", self._PROFILE] + cmd
[docs] def run( self, cmd: list[str], env: dict[str, str], cwd: Path, timeout: int, memory_limit_bytes: int | None, ) -> tuple[int, str, str]: wrapped = self._sandboxed_cmd(cmd) result = subprocess.run( wrapped, capture_output=True, text=True, encoding="utf-8", errors="replace", cwd=str(cwd), env=env, timeout=timeout, shell=False, ) return result.returncode, result.stdout or "", result.stderr or ""
# --------------------------------------------------------------------- # Backend factory # ---------------------------------------------------------------------
[docs] def make_sandbox_backend(config: object) -> SandboxBackend: """Select and return the best available :class:`SandboxBackend`. Parameters ---------- config A :class:`~oct.mcp.config.McpConfig` instance. Reads the ``sandbox_backend`` field (``"auto"`` / ``"native"`` / ``"bubblewrap"`` / ``"sandbox_exec"``) and ``memory_limit_mb``. Returns ------- A concrete backend instance. On ``"auto"``, picks: * :class:`BubblewrapBackend` on Linux when ``bwrap`` is in PATH. * :class:`SandboxExecBackend` on macOS when ``sandbox-exec`` is in PATH. * :class:`NativeBackend` otherwise (always safe). When a *specific* backend is forced (e.g. ``"bubblewrap"``) but its binary is not found, raises :exc:`RuntimeError`. Never raises on ``"auto"`` or ``"native"`` — always returns a usable backend. """ func = "make_sandbox_backend" name: str = getattr(config, "sandbox_backend", "auto") if name == "bubblewrap": if not shutil.which("bwrap"): raise RuntimeError( "sandbox_backend='bubblewrap' requested but 'bwrap' was not " "found in PATH. Install bubblewrap or set sandbox_backend='auto'." ) _dbg("MCP", func, "backend=BubblewrapBackend (forced)", 2) return BubblewrapBackend() if name == "sandbox_exec": if not shutil.which("sandbox-exec"): raise RuntimeError( "sandbox_backend='sandbox_exec' requested but 'sandbox-exec' " "was not found in PATH. This backend requires macOS." ) _dbg("MCP", func, "backend=SandboxExecBackend (forced)", 2) return SandboxExecBackend() if name == "auto": if sys.platform == "linux" and shutil.which("bwrap"): _dbg("MCP", func, "backend=BubblewrapBackend (auto, Linux)", 2) return BubblewrapBackend() if sys.platform == "darwin" and shutil.which("sandbox-exec"): _dbg("MCP", func, "backend=SandboxExecBackend (auto, macOS)", 2) return SandboxExecBackend() # "native" or auto-fallback (e.g. Windows, or no bwrap/sandbox-exec found). _dbg("MCP", func, "backend=NativeBackend", 2) return NativeBackend()
# --------------------------------------------------------------------- # Sandbox executor # ---------------------------------------------------------------------
[docs] class SandboxExecutor: """Sandboxed subprocess runner for the MCP execution layer. Wraps a :class:`SandboxBackend` with environment sanitisation, output capping, and error handling. A single :class:`SandboxExecutor` instance may be reused across multiple tool calls within the same server session. Parameters ---------- backend OS-level isolation backend. Defaults to :class:`NativeBackend` when not supplied (Phase 5A/5B backward compatibility). memory_limit_mb Per-subprocess virtual memory limit in megabytes. ``0`` means no limit. Forwarded to the backend as bytes. POSIX only — silently ignored on Windows. """ def __init__( self, backend: SandboxBackend | None = None, memory_limit_mb: int = 0, ) -> None: self._backend: SandboxBackend = backend if backend is not None else NativeBackend() self._memory_limit_bytes: int | None = ( memory_limit_mb * 1024 * 1024 if memory_limit_mb > 0 else None )
[docs] def run( self, cmd: list[str], cwd: Path, timeout: int, max_output_bytes: int = 1_048_576, ) -> SandboxResult: """Execute *cmd* in a sanitised environment under *cwd*. Parameters ---------- cmd The command and arguments to run. Must not contain shell metacharacters — this is enforced by passing ``shell=False`` in every backend. cwd Working directory, pinned to the validated project root. timeout Maximum wall-clock seconds before the process is killed. max_output_bytes Combined stdout+stderr cap. When exceeded, output is truncated and :attr:`SandboxResult.output_truncated` is set. Returns ------- :class:`SandboxResult` — never raises. """ func = "SandboxExecutor.run" _dbg("MCP", func, f"cmd={cmd!r} cwd={cwd} timeout={timeout}s", 3) env = _sanitise_env(cwd) try: exit_code, stdout, stderr = self._backend.run( cmd=cmd, env=env, cwd=cwd, timeout=timeout, memory_limit_bytes=self._memory_limit_bytes, ) truncated = False # Enforce combined output cap. combined_bytes = len( (stdout + stderr).encode("utf-8", errors="replace") ) if combined_bytes > max_output_bytes: stdout_bytes = stdout.encode("utf-8", errors="replace") if len(stdout_bytes) > max_output_bytes: stdout = stdout_bytes[:max_output_bytes].decode( "utf-8", errors="replace" ) stderr = "" else: remaining = max_output_bytes - len(stdout_bytes) stderr = ( stderr.encode("utf-8", errors="replace")[:remaining] .decode("utf-8", errors="replace") ) truncated = True _dbg( "MCP", func, f"output truncated combined_bytes={combined_bytes} " f"limit={max_output_bytes}", 2, ) _dbg( "MCP", func, f"exit_code={exit_code} truncated={truncated}", 2, ) return SandboxResult( exit_code=exit_code, stdout=stdout, stderr=stderr, timed_out=False, output_truncated=truncated, ) except subprocess.TimeoutExpired: _dbg("MCP", func, f"SYSTEM_ERROR: timeout after {timeout}s cmd={cmd!r}", 1) return SandboxResult( exit_code=1, stdout="", stderr=f"Process timed out after {timeout} seconds.", timed_out=True, ) except FileNotFoundError: msg = f"Command not found: {cmd[0]!r}. Is 'oct' installed on PATH?" _dbg("MCP", func, f"SYSTEM_ERROR: {msg}", 1) return SandboxResult( exit_code=1, stdout="", stderr=msg, error_message=msg, ) except OSError as exc: msg = f"Failed to launch subprocess: {exc}" _dbg("MCP", func, f"SYSTEM_ERROR: {msg}", 1) return SandboxResult( exit_code=1, stdout="", stderr=msg, error_message=msg, )