8Execution Sandbox (Layer 4, outer wrapper) for the ``oct-mcp``
9six-layer pipeline. Provides environment sanitisation, working-directory
10pinning, timeout enforcement, and output-size limiting for subprocess
13Phase 5C adds pluggable OS-level sandbox *backends*:
15* :class:`NativeBackend` — existing behavior (subprocess + env
16 sanitization). Always available; default on Windows.
17* :class:`BubblewrapBackend` — Linux only. Wraps every command in
18 ``bwrap --unshare-net --die-with-parent``. Requires ``bwrap`` in PATH.
19* :class:`SandboxExecBackend` — macOS only. Prepends
20 ``sandbox-exec -p "(deny network*)(allow default)"``. Requires
21 ``sandbox-exec`` in PATH.
23Use :func:`make_sandbox_backend` to select the appropriate backend based
24on :class:`~oct.mcp.config.McpConfig`.
28- Strip sensitive environment variables before forking (``PYTHONPATH``,
29 common secret-named vars).
30- Pin the subprocess working directory to the validated project root.
31- Enforce per-tool timeouts and kill the process on expiry.
32- Cap combined stdout+stderr output to ``max_output_bytes``.
33- Never use ``shell=True``.
34- Return a :class:`SandboxResult` — never raise for subprocess errors.
40 L1 — errors: process launch failures, unexpected errors
41 L2 — lifecycle: process start, process end, backend selection
42 L3 — command details (argv)
43 L4 — deep trace: per-line output, env keys
47- ``shell=False`` is enforced unconditionally in all backends — no
48 caller path can override it.
49- :meth:`SandboxExecutor.run` never raises. Any ``OSError`` or
50 ``subprocess.SubprocessError`` is caught, logged at level 1, and
51 returned as a ``SandboxResult`` with a non-zero exit code.
52- The sanitised environment always preserves ``PATH``, ``HOME``,
53 ``LANG``, ``TERM``, ``USER``, ``USERNAME``, ``LOGNAME``,
54 ``VIRTUAL_ENV``, ``CONDA_DEFAULT_ENV``, and
55 ``OCT_MCP_SESSION_ID`` / ``OCT_MCP_REQUEST_ID`` correlation IDs.
56- :func:`make_sandbox_backend` never raises — it always returns a
57 usable backend (falls back to :class:`NativeBackend`).
60from __future__
import annotations
66from dataclasses
import dataclass
67from pathlib
import Path
68from typing
import Protocol, runtime_checkable
71 from oc_diagnostics
import _dbg
as _real_dbg
73 def _dbg(*args, **kwargs) -> None:
74 _real_dbg(*args, **kwargs)
76 def _dbg(*args, **kwargs) -> None:
87_SENSITIVE_ENV_EXACT: frozenset[str] = frozenset({
93_SENSITIVE_ENV_SUBSTRINGS: tuple[str, ...] = (
95 "password",
"passwd",
"pwd",
106_SAFE_ENV_KEYS: frozenset[str] = frozenset({
122 "OCT_MCP_SESSION_ID",
123 "OCT_MCP_REQUEST_ID",
136 """Build a sanitised environment for subprocess execution.
138 Starts from ``os.environ``, preserves :data:`_SAFE_ENV_KEYS`,
139 strips :data:`_SENSITIVE_ENV_EXACT` exact keys and anything whose
140 key contains a :data:`_SENSITIVE_ENV_SUBSTRINGS` token (case-
141 insensitive), and adds ``OCT_MCP_CWD`` so the subprocess knows
142 its sandboxed working directory.
146 A new ``dict[str, str]`` — never modifies ``os.environ``.
148 func =
"_sanitise_env"
149 clean: dict[str, str] = {}
151 for key, value
in os.environ.items():
152 key_upper = key.upper()
155 if key_upper
in {k.upper()
for k
in _SAFE_ENV_KEYS}:
160 if key_upper
in {k.upper()
for k
in _SENSITIVE_ENV_EXACT}:
161 _dbg(
"MCP", func, f
"stripped env key={key}", 4)
165 if any(sub
in key_upper.lower()
for sub
in _SENSITIVE_ENV_SUBSTRINGS):
166 _dbg(
"MCP", func, f
"stripped env key={key} (substring match)", 4)
172 clean[
"OCT_MCP_CWD"] = str(project_root)
184 """Result of a sandboxed subprocess execution.
189 The subprocess return code, or ``1`` on launch failure.
191 Captured standard output (possibly truncated).
193 Captured standard error (possibly truncated).
195 ``True`` if the process was killed due to timeout.
197 ``True`` if combined stdout+stderr exceeded ``max_output_bytes``.
199 Non-empty if the process could not be launched (e.g. not found
200 on PATH). Distinct from stderr — set on ``OSError``.
206 timed_out: bool =
False
207 output_truncated: bool =
False
208 error_message: str =
""
218 """Protocol for OS-level sandbox backends.
220 Each backend wraps the subprocess call with platform-specific
221 isolation (network namespace, resource limits, etc.). The backend
222 receives a sanitised environment and returns raw
223 ``(exit_code, stdout, stderr)`` — output capping and error handling
224 live in :class:`SandboxExecutor`.
233 memory_limit_bytes: int |
None,
234 ) -> tuple[int, str, str]:
235 """Run *cmd* and return ``(exit_code, stdout, stderr)``.
240 Command + arguments list. Must not use ``shell=True``.
242 Sanitised environment dict.
244 Working directory (validated project root).
246 Maximum wall-clock seconds.
248 Virtual memory ceiling in bytes, or ``None`` to skip.
249 Implementations on POSIX apply this via ``setrlimit``; on
250 Windows it is silently ignored.
261 """Subprocess-only backend — existing Phase 5A/5B behavior.
263 On POSIX, when *memory_limit_bytes* is non-None and non-zero,
264 applies ``resource.setrlimit(RLIMIT_AS, ...)`` via ``preexec_fn``
265 to limit virtual address space. On Windows the limit is silently
266 ignored (use Docker for Windows memory enforcement).
275 memory_limit_bytes: int |
None,
276 ) -> tuple[int, str, str]:
278 if memory_limit_bytes
and os.name !=
"nt":
279 limit = memory_limit_bytes
281 def _preexec() -> None:
284 resource.RLIMIT_AS, (limit, limit)
289 result = subprocess.run(
301 return result.returncode, result.stdout
or "", result.stderr
or ""
305 """Linux bubblewrap backend — network-isolated, die-with-parent.
307 Wraps every command in ``bwrap --unshare-net --die-with-parent``.
308 Requires ``bwrap`` to be available in PATH.
310 Network isolation is provided by ``--unshare-net``. The project
311 root is bind-mounted read-write; system paths are read-only.
314 def _bwrap_cmd(self, cmd: list[str], cwd: Path) -> list[str]:
315 """Build the full ``bwrap ... -- cmd`` invocation."""
319 for ro_path
in (
"/usr",
"/lib",
"/lib64",
"/bin",
"/sbin",
"/etc",
321 if Path(ro_path).exists():
322 bwrap += [
"--ro-bind", ro_path, ro_path]
329 "--bind", str(cwd), str(cwd),
345 memory_limit_bytes: int |
None,
346 ) -> tuple[int, str, str]:
348 result = subprocess.run(
359 return result.returncode, result.stdout
or "", result.stderr
or ""
363 """macOS ``sandbox-exec`` backend — network-denied policy.
365 Prepends ``sandbox-exec -p "<profile>"`` to every command.
366 Requires ``sandbox-exec`` to be available in PATH.
368 The policy denies all network access while allowing everything else
369 (file I/O, process creation, etc.) needed to run ``oct`` commands.
372 _PROFILE: str =
"(version 1)(deny network*)(allow default)"
375 """Return ``["sandbox-exec", "-p", PROFILE] + cmd``."""
376 return [
"sandbox-exec",
"-p", self.
_PROFILE] + cmd
384 memory_limit_bytes: int |
None,
385 ) -> tuple[int, str, str]:
387 result = subprocess.run(
398 return result.returncode, result.stdout
or "", result.stderr
or ""
407 """Select and return the best available :class:`SandboxBackend`.
412 A :class:`~oct.mcp.config.McpConfig` instance. Reads the
413 ``sandbox_backend`` field (``"auto"`` / ``"native"`` /
414 ``"bubblewrap"`` / ``"sandbox_exec"``) and ``memory_limit_mb``.
418 A concrete backend instance. On ``"auto"``, picks:
420 * :class:`BubblewrapBackend` on Linux when ``bwrap`` is in PATH.
421 * :class:`SandboxExecBackend` on macOS when ``sandbox-exec`` is in PATH.
422 * :class:`NativeBackend` otherwise (always safe).
424 When a *specific* backend is forced (e.g. ``"bubblewrap"``) but its
425 binary is not found, raises :exc:`RuntimeError`.
427 Never raises on ``"auto"`` or ``"native"`` — always returns a
430 func =
"make_sandbox_backend"
431 name: str = getattr(config,
"sandbox_backend",
"auto")
433 if name ==
"bubblewrap":
434 if not shutil.which(
"bwrap"):
436 "sandbox_backend='bubblewrap' requested but 'bwrap' was not "
437 "found in PATH. Install bubblewrap or set sandbox_backend='auto'."
439 _dbg(
"MCP", func,
"backend=BubblewrapBackend (forced)", 2)
442 if name ==
"sandbox_exec":
443 if not shutil.which(
"sandbox-exec"):
445 "sandbox_backend='sandbox_exec' requested but 'sandbox-exec' "
446 "was not found in PATH. This backend requires macOS."
448 _dbg(
"MCP", func,
"backend=SandboxExecBackend (forced)", 2)
452 if sys.platform ==
"linux" and shutil.which(
"bwrap"):
453 _dbg(
"MCP", func,
"backend=BubblewrapBackend (auto, Linux)", 2)
455 if sys.platform ==
"darwin" and shutil.which(
"sandbox-exec"):
456 _dbg(
"MCP", func,
"backend=SandboxExecBackend (auto, macOS)", 2)
460 _dbg(
"MCP", func,
"backend=NativeBackend", 2)
470 """Sandboxed subprocess runner for the MCP execution layer.
472 Wraps a :class:`SandboxBackend` with environment sanitisation,
473 output capping, and error handling. A single :class:`SandboxExecutor`
474 instance may be reused across multiple tool calls within the same
480 OS-level isolation backend. Defaults to :class:`NativeBackend`
481 when not supplied (Phase 5A/5B backward compatibility).
483 Per-subprocess virtual memory limit in megabytes. ``0`` means
484 no limit. Forwarded to the backend as bytes. POSIX only —
485 silently ignored on Windows.
490 backend: SandboxBackend |
None =
None,
491 memory_limit_mb: int = 0,
495 memory_limit_mb * 1024 * 1024
if memory_limit_mb > 0
else None
503 max_output_bytes: int = 1_048_576,
505 """Execute *cmd* in a sanitised environment under *cwd*.
510 The command and arguments to run. Must not contain shell
511 metacharacters — this is enforced by passing ``shell=False``
514 Working directory, pinned to the validated project root.
516 Maximum wall-clock seconds before the process is killed.
518 Combined stdout+stderr cap. When exceeded, output is
519 truncated and :attr:`SandboxResult.output_truncated` is set.
523 :class:`SandboxResult` — never raises.
525 func =
"SandboxExecutor.run"
526 _dbg(
"MCP", func, f
"cmd={cmd!r} cwd={cwd} timeout={timeout}s", 3)
541 combined_bytes = len(
542 (stdout + stderr).encode(
"utf-8", errors=
"replace")
544 if combined_bytes > max_output_bytes:
545 stdout_bytes = stdout.encode(
"utf-8", errors=
"replace")
546 if len(stdout_bytes) > max_output_bytes:
547 stdout = stdout_bytes[:max_output_bytes].decode(
548 "utf-8", errors=
"replace"
552 remaining = max_output_bytes - len(stdout_bytes)
554 stderr.encode(
"utf-8", errors=
"replace")[:remaining]
555 .decode(
"utf-8", errors=
"replace")
560 f
"output truncated combined_bytes={combined_bytes} "
561 f
"limit={max_output_bytes}",
567 f
"exit_code={exit_code} truncated={truncated}",
575 output_truncated=truncated,
578 except subprocess.TimeoutExpired:
579 _dbg(
"MCP", func, f
"SYSTEM_ERROR: timeout after {timeout}s cmd={cmd!r}", 1)
583 stderr=f
"Process timed out after {timeout} seconds.",
586 except FileNotFoundError:
587 msg = f
"Command not found: {cmd[0]!r}. Is 'oct' installed on PATH?"
588 _dbg(
"MCP", func, f
"SYSTEM_ERROR: {msg}", 1)
595 except OSError
as exc:
596 msg = f
"Failed to launch subprocess: {exc}"
597 _dbg(
"MCP", func, f
"SYSTEM_ERROR: {msg}", 1)
tuple[int, str, str] run(self, list[str] cmd, dict[str, str] env, Path cwd, int timeout, int|None memory_limit_bytes)
list[str] _bwrap_cmd(self, list[str] cmd, Path cwd)
tuple[int, str, str] run(self, list[str] cmd, dict[str, str] env, Path cwd, int timeout, int|None memory_limit_bytes)
tuple[int, str, str] run(self, list[str] cmd, dict[str, str] env, Path cwd, int timeout, int|None memory_limit_bytes)
list[str] _sandboxed_cmd(self, list[str] cmd)
tuple[int, str, str] run(self, list[str] cmd, dict[str, str] env, Path cwd, int timeout, int|None memory_limit_bytes)
None __init__(self, SandboxBackend|None backend=None, int memory_limit_mb=0)
tuple _memory_limit_bytes
SandboxResult run(self, list[str] cmd, Path cwd, int timeout, int max_output_bytes=1_048_576)
None _dbg(*args, **kwargs)
SandboxBackend make_sandbox_backend(object config)
dict[str, str] _sanitise_env(Path project_root)