oct
Loading...
Searching...
No Matches
sandbox.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/mcp/sandbox.py
4
5# OCT-LINT: disable=no_hardcoded_secrets reason="Module docstring prose, not a secret"
6"""
7Purpose
8-------
9Execution Sandbox (Layer 4, outer wrapper) for the ``oct-mcp``
10six-layer pipeline. Provides environment sanitisation, working-directory
11pinning, timeout enforcement, and output-size limiting for subprocess
12execution.
13
14Phase 5C adds pluggable OS-level sandbox *backends*:
15
16* :class:`NativeBackend` - existing behavior (subprocess + env
17 sanitization). Always available; default on Windows.
18* :class:`BubblewrapBackend` - Linux only. Wraps every command in
19 ``bwrap --unshare-net --die-with-parent``. Requires ``bwrap`` in PATH.
20* :class:`SandboxExecBackend` - macOS only. Prepends
21 ``sandbox-exec -p "(deny network*)(allow default)"``. Requires
22 ``sandbox-exec`` in PATH.
23
24Use :func:`make_sandbox_backend` to select the appropriate backend based
25on :class:`~oct.mcp.config.McpConfig`.
26
27Responsibilities
28----------------
29- Strip sensitive environment variables before forking (``PYTHONPATH``,
30 common secret-named vars).
31- Pin the subprocess working directory to the validated project root.
32- Enforce per-tool timeouts and kill the process on expiry.
33- Cap combined stdout+stderr output to ``max_output_bytes``.
34- Never use ``shell=True``.
35- Return a :class:`SandboxResult` - never raise for subprocess errors.
36
37Diagnostics
38-----------
39Domain: MCP
40Levels:
41 L1 - errors: process launch failures, unexpected errors
42 L2 - lifecycle: process start, process end, backend selection
43 L3 - command details (argv)
44 L4 - deep trace: per-line output, env keys
45
46Contracts
47---------
48- ``shell=False`` is enforced unconditionally in all backends - no
49 caller path can override it.
50- :meth:`SandboxExecutor.run` never raises. Any ``OSError`` or
51 ``subprocess.SubprocessError`` is caught, logged at level 1, and
52 returned as a ``SandboxResult`` with a non-zero exit code.
53- The sanitised environment always preserves ``PATH``, ``HOME``,
54 ``LANG``, ``TERM``, ``USER``, ``USERNAME``, ``LOGNAME``,
55 ``VIRTUAL_ENV``, ``CONDA_DEFAULT_ENV``, and
56 ``OCT_MCP_SESSION_ID`` / ``OCT_MCP_REQUEST_ID`` correlation IDs.
57- :func:`make_sandbox_backend` never raises - it always returns a
58 usable backend (falls back to :class:`NativeBackend`).
59
60Dependencies
61------------
62- oct.core.diagnostics (_dbg structured logger)
63- bwrap (not imported; the bubblewrap CLI that BubblewrapBackend wraps every command in on Linux)
64"""
65
66from __future__ import annotations
67
68import os
69import shutil
70import subprocess
71import sys
72from dataclasses import dataclass
73from pathlib import Path
74from typing import Protocol, runtime_checkable
75
76from oct.core.diagnostics import _dbg
77
78
79# ---------------------------------------------------------------------
80# Constants
81# ---------------------------------------------------------------------
82
83#: Environment variable keys (or key substrings, case-insensitive) that
84#: must be stripped before passing the environment to a subprocess.
85#: Full key names are exact-matched first; substrings are checked second.
86_SENSITIVE_ENV_EXACT: frozenset[str] = frozenset({
87 "PYTHONPATH",
88})
89
90#: Case-insensitive substrings. Any env key containing one of these
91#: tokens is stripped.
92_SENSITIVE_ENV_SUBSTRINGS: tuple[str, ...] = (
93 "api_key", "apikey",
94 "password", "passwd", "pwd",
95 "secret",
96 "token",
97 "credentials",
98 "private_key",
99 "auth",
100 "access_key",
101)
102
103#: Environment variable keys that are always preserved regardless of
104#: name-based filtering.
105_SAFE_ENV_KEYS: frozenset[str] = frozenset({
106 "PATH",
107 "HOME",
108 "LANG",
109 "LC_ALL",
110 "LC_CTYPE",
111 "TERM",
112 "COLORTERM",
113 "USER",
114 "USERNAME",
115 "LOGNAME",
116 "USERPROFILE", # Windows home directory
117 "SYSTEMROOT", # Windows system root
118 "VIRTUAL_ENV",
119 "CONDA_DEFAULT_ENV",
120 "CONDA_PREFIX",
121 "OCT_MCP_SESSION_ID",
122 "OCT_MCP_REQUEST_ID",
123 # Diagnostics env vars
124 "OCT_DBG_LEVEL",
125 "OCT_DBG_FILE",
126})
127
128
129# ---------------------------------------------------------------------
130# Environment sanitisation (shared by all backends)
131# ---------------------------------------------------------------------
132
133
134def _sanitise_env(project_root: Path) -> dict[str, str]:
135 """Build a sanitised environment for subprocess execution.
136
137 Starts from ``os.environ``, preserves :data:`_SAFE_ENV_KEYS`,
138 strips :data:`_SENSITIVE_ENV_EXACT` exact keys and anything whose
139 key contains a :data:`_SENSITIVE_ENV_SUBSTRINGS` token (case-
140 insensitive), and adds ``OCT_MCP_CWD`` so the subprocess knows
141 its sandboxed working directory.
142
143 Returns
144 -------
145 A new ``dict[str, str]`` - never modifies ``os.environ``.
146 """
147 func = "_sanitise_env"
148 clean: dict[str, str] = {}
149
150 for key, value in os.environ.items():
151 key_upper = key.upper()
152
153 # Always keep safe keys.
154 if key_upper in {k.upper() for k in _SAFE_ENV_KEYS}:
155 clean[key] = value
156 continue
157
158 # Strip exact sensitive keys.
159 if key_upper in {k.upper() for k in _SENSITIVE_ENV_EXACT}:
160 _dbg("MCP", func, f"stripped env key={key}", 4)
161 continue
162
163 # Strip substring-matched keys.
164 if any(sub in key_upper.lower() for sub in _SENSITIVE_ENV_SUBSTRINGS):
165 _dbg("MCP", func, f"stripped env key={key} (substring match)", 4)
166 continue
167
168 clean[key] = value
169
170 # Pin working directory as an env var for informational purposes.
171 clean["OCT_MCP_CWD"] = str(project_root)
172
173 return clean
174
175
176# ---------------------------------------------------------------------
177# Result type
178# ---------------------------------------------------------------------
179
180
181@dataclass
183 """Result of a sandboxed subprocess execution.
184
185 Attributes
186 ----------
187 exit_code
188 The subprocess return code, or ``1`` on launch failure.
189 stdout
190 Captured standard output (possibly truncated).
191 stderr
192 Captured standard error (possibly truncated).
193 timed_out
194 ``True`` if the process was killed due to timeout.
195 output_truncated
196 ``True`` if combined stdout+stderr exceeded ``max_output_bytes``.
197 error_message
198 Non-empty if the process could not be launched (e.g. not found
199 on PATH). Distinct from stderr - set on ``OSError``.
200 """
201
202 exit_code: int
203 stdout: str
204 stderr: str
205 timed_out: bool = False
206 output_truncated: bool = False
207 error_message: str = ""
208
209
210# ---------------------------------------------------------------------
211# Backend protocol
212# ---------------------------------------------------------------------
213
214
215@runtime_checkable
216class SandboxBackend(Protocol):
217 """Protocol for OS-level sandbox backends.
218
219 Each backend wraps the subprocess call with platform-specific
220 isolation (network namespace, resource limits, etc.). The backend
221 receives a sanitised environment and returns raw
222 ``(exit_code, stdout, stderr)`` - output capping and error handling
223 live in :class:`SandboxExecutor`.
224 """
225
226 def run(
227 self,
228 cmd: list[str],
229 env: dict[str, str],
230 cwd: Path,
231 timeout: int,
232 memory_limit_bytes: int | None,
233 ) -> tuple[int, str, str]:
234 """Run *cmd* and return ``(exit_code, stdout, stderr)``.
235
236 Parameters
237 ----------
238 cmd
239 Command + arguments list. Must not use ``shell=True``.
240 env
241 Sanitised environment dict.
242 cwd
243 Working directory (validated project root).
244 timeout
245 Maximum wall-clock seconds.
246 memory_limit_bytes
247 Virtual memory ceiling in bytes, or ``None`` to skip.
248 Implementations on POSIX apply this via ``setrlimit``; on
249 Windows it is silently ignored.
250 """
251 ...
252
253
254# ---------------------------------------------------------------------
255# Concrete backends
256# ---------------------------------------------------------------------
257
258
260 """Subprocess-only backend - existing Phase 5A/5B behavior.
261
262 On POSIX, when *memory_limit_bytes* is non-None and non-zero,
263 applies ``resource.setrlimit(RLIMIT_AS, ...)`` via ``preexec_fn``
264 to limit virtual address space. On Windows the limit is silently
265 ignored (use Docker for Windows memory enforcement).
266 """
267
268 def run(
269 self,
270 cmd: list[str],
271 env: dict[str, str],
272 cwd: Path,
273 timeout: int,
274 memory_limit_bytes: int | None,
275 ) -> tuple[int, str, str]:
276 preexec = None
277 if memory_limit_bytes and os.name != "nt":
278 limit = memory_limit_bytes
279
280 def _preexec() -> None:
281 import resource
282 resource.setrlimit(
283 resource.RLIMIT_AS, (limit, limit)
284 )
285
286 preexec = _preexec
287
288 result = subprocess.run(
289 cmd,
290 capture_output=True,
291 text=True,
292 encoding="utf-8",
293 errors="replace",
294 cwd=str(cwd),
295 env=env,
296 timeout=timeout,
297 shell=False, # NEVER shell=True
298 preexec_fn=preexec,
299 )
300 return result.returncode, result.stdout or "", result.stderr or ""
301
302
304 """Linux bubblewrap backend - network-isolated, die-with-parent.
305
306 Wraps every command in ``bwrap --unshare-net --die-with-parent``.
307 Requires ``bwrap`` to be available in PATH.
308
309 Network isolation is provided by ``--unshare-net``. The project
310 root is bind-mounted read-write; system paths are read-only.
311 """
312
313 def _bwrap_cmd(self, cmd: list[str], cwd: Path) -> list[str]:
314 """Build the full ``bwrap ... -- cmd`` invocation."""
315 bwrap = ["bwrap"]
316
317 # Read-only system mounts (common paths; skip missing ones silently).
318 for ro_path in ("/usr", "/lib", "/lib64", "/bin", "/sbin", "/etc",
319 "/run", "/opt"):
320 if Path(ro_path).exists():
321 bwrap += ["--ro-bind", ro_path, ro_path]
322
323 bwrap += [
324 "--proc", "/proc",
325 "--dev", "/dev",
326 "--tmpfs", "/tmp",
327 # Project root bind-mounted read-write.
328 "--bind", str(cwd), str(cwd),
329 "--chdir", str(cwd),
330 # Network isolation.
331 "--unshare-net",
332 # Kill sandbox when the parent exits.
333 "--die-with-parent",
334 "--",
335 ]
336 return bwrap + cmd
337
338 def run(
339 self,
340 cmd: list[str],
341 env: dict[str, str],
342 cwd: Path,
343 timeout: int,
344 memory_limit_bytes: int | None,
345 ) -> tuple[int, str, str]:
346 wrapped = self._bwrap_cmd(cmd, cwd)
347 result = subprocess.run(
348 wrapped,
349 capture_output=True,
350 text=True,
351 encoding="utf-8",
352 errors="replace",
353 cwd=str(cwd),
354 env=env,
355 timeout=timeout,
356 shell=False,
357 )
358 return result.returncode, result.stdout or "", result.stderr or ""
359
360
362 """macOS ``sandbox-exec`` backend - network-denied policy.
363
364 Prepends ``sandbox-exec -p "<profile>"`` to every command.
365 Requires ``sandbox-exec`` to be available in PATH.
366
367 The policy denies all network access while allowing everything else
368 (file I/O, process creation, etc.) needed to run ``oct`` commands.
369 """
370
371 _PROFILE: str = "(version 1)(deny network*)(allow default)"
372
373 def _sandboxed_cmd(self, cmd: list[str]) -> list[str]:
374 """Return ``["sandbox-exec", "-p", PROFILE] + cmd``."""
375 return ["sandbox-exec", "-p", self._PROFILE] + cmd
376
377 def run(
378 self,
379 cmd: list[str],
380 env: dict[str, str],
381 cwd: Path,
382 timeout: int,
383 memory_limit_bytes: int | None,
384 ) -> tuple[int, str, str]:
385 wrapped = self._sandboxed_cmd(cmd)
386 result = subprocess.run(
387 wrapped,
388 capture_output=True,
389 text=True,
390 encoding="utf-8",
391 errors="replace",
392 cwd=str(cwd),
393 env=env,
394 timeout=timeout,
395 shell=False,
396 )
397 return result.returncode, result.stdout or "", result.stderr or ""
398
399
400# ---------------------------------------------------------------------
401# Backend factory
402# ---------------------------------------------------------------------
403
404
405def make_sandbox_backend(config: object) -> SandboxBackend:
406 """Select and return the best available :class:`SandboxBackend`.
407
408 Parameters
409 ----------
410 config
411 A :class:`~oct.mcp.config.McpConfig` instance. Reads the
412 ``sandbox_backend`` field (``"auto"`` / ``"native"`` /
413 ``"bubblewrap"`` / ``"sandbox_exec"``) and ``memory_limit_mb``.
414
415 Returns
416 -------
417 A concrete backend instance. On ``"auto"``, picks:
418
419 * :class:`BubblewrapBackend` on Linux when ``bwrap`` is in PATH.
420 * :class:`SandboxExecBackend` on macOS when ``sandbox-exec`` is in PATH.
421 * :class:`NativeBackend` otherwise (always safe).
422
423 When a *specific* backend is forced (e.g. ``"bubblewrap"``) but its
424 binary is not found, raises :exc:`RuntimeError`.
425
426 Never raises on ``"auto"`` or ``"native"`` - always returns a
427 usable backend.
428 """
429 func = "make_sandbox_backend"
430 name: str = getattr(config, "sandbox_backend", "auto")
431
432 if name == "bubblewrap":
433 if not shutil.which("bwrap"):
434 raise RuntimeError(
435 "sandbox_backend='bubblewrap' requested but 'bwrap' was not "
436 "found in PATH. Install bubblewrap or set sandbox_backend='auto'."
437 )
438 _dbg("MCP", func, "backend=BubblewrapBackend (forced)", 2)
439 return BubblewrapBackend()
440
441 if name == "sandbox_exec":
442 if not shutil.which("sandbox-exec"):
443 raise RuntimeError(
444 # OCT-LINT: disable=no_hardcoded_secrets reason="Error message naming the missing sandbox-exec binary, not a secret"
445 "sandbox_backend='sandbox_exec' requested but 'sandbox-exec' "
446 "was not found in PATH. This backend requires macOS."
447 )
448 _dbg("MCP", func, "backend=SandboxExecBackend (forced)", 2)
449 return SandboxExecBackend()
450
451 if name == "auto":
452 if sys.platform == "linux" and shutil.which("bwrap"):
453 _dbg("MCP", func, "backend=BubblewrapBackend (auto, Linux)", 2)
454 return BubblewrapBackend()
455 if sys.platform == "darwin" and shutil.which("sandbox-exec"):
456 _dbg("MCP", func, "backend=SandboxExecBackend (auto, macOS)", 2)
457 return SandboxExecBackend()
458
459 # "native" or auto-fallback (e.g. Windows, or no bwrap/sandbox-exec found).
460 _dbg("MCP", func, "backend=NativeBackend", 2)
461 return NativeBackend()
462
463
464# ---------------------------------------------------------------------
465# Sandbox executor
466# ---------------------------------------------------------------------
467
468
470 """Sandboxed subprocess runner for the MCP execution layer.
471
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
475 server session.
476
477 Parameters
478 ----------
479 backend
480 OS-level isolation backend. Defaults to :class:`NativeBackend`
481 when not supplied (Phase 5A/5B backward compatibility).
482 memory_limit_mb
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.
486 """
487
489 self,
490 backend: SandboxBackend | None = None,
491 memory_limit_mb: int = 0,
492 ) -> None:
493 self._backend: SandboxBackend = backend if backend is not None else NativeBackend()
494 self._memory_limit_bytes: int | None = (
495 memory_limit_mb * 1024 * 1024 if memory_limit_mb > 0 else None
496 )
497
498 def run(
499 self,
500 cmd: list[str],
501 cwd: Path,
502 timeout: int,
503 max_output_bytes: int = 1_048_576,
504 ) -> SandboxResult:
505 """Execute *cmd* in a sanitised environment under *cwd*.
506
507 Parameters
508 ----------
509 cmd
510 The command and arguments to run. Must not contain shell
511 metacharacters - this is enforced by passing ``shell=False``
512 in every backend.
513 cwd
514 Working directory, pinned to the validated project root.
515 timeout
516 Maximum wall-clock seconds before the process is killed.
517 max_output_bytes
518 Combined stdout+stderr cap. When exceeded, output is
519 truncated and :attr:`SandboxResult.output_truncated` is set.
520
521 Returns
522 -------
523 :class:`SandboxResult` - never raises.
524 """
525 func = "SandboxExecutor.run"
526 _dbg("MCP", func, f"cmd={cmd!r} cwd={cwd} timeout={timeout}s", 3)
527
528 env = _sanitise_env(cwd)
529
530 try:
531 exit_code, stdout, stderr = self._backend.run(
532 cmd=cmd,
533 env=env,
534 cwd=cwd,
535 timeout=timeout,
536 memory_limit_bytes=self._memory_limit_bytes,
537 )
538 truncated = False
539
540 # Enforce combined output cap.
541 combined_bytes = len(
542 (stdout + stderr).encode("utf-8", errors="replace")
543 )
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"
549 )
550 stderr = ""
551 else:
552 remaining = max_output_bytes - len(stdout_bytes)
553 stderr = (
554 stderr.encode("utf-8", errors="replace")[:remaining]
555 .decode("utf-8", errors="replace")
556 )
557 truncated = True
558 _dbg(
559 "MCP", func,
560 f"output truncated combined_bytes={combined_bytes} "
561 f"limit={max_output_bytes}",
562 2,
563 )
564
565 _dbg(
566 "MCP", func,
567 f"exit_code={exit_code} truncated={truncated}",
568 2,
569 )
570 return SandboxResult(
571 exit_code=exit_code,
572 stdout=stdout,
573 stderr=stderr,
574 timed_out=False,
575 output_truncated=truncated,
576 )
577
578 except subprocess.TimeoutExpired:
579 _dbg("MCP", func, f"SYSTEM_ERROR: timeout after {timeout}s cmd={cmd!r}", 1)
580 return SandboxResult(
581 exit_code=1,
582 stdout="",
583 stderr=f"Process timed out after {timeout} seconds.",
584 timed_out=True,
585 )
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)
589 return SandboxResult(
590 exit_code=1,
591 stdout="",
592 stderr=msg,
593 error_message=msg,
594 )
595 except OSError as exc:
596 msg = f"Failed to launch subprocess: {exc}"
597 _dbg("MCP", func, f"SYSTEM_ERROR: {msg}", 1)
598 return SandboxResult(
599 exit_code=1,
600 stdout="",
601 stderr=msg,
602 error_message=msg,
603 )
tuple[int, str, str] run(self, list[str] cmd, dict[str, str] env, Path cwd, int timeout, int|None memory_limit_bytes)
Definition sandbox.py:345
list[str] _bwrap_cmd(self, list[str] cmd, Path cwd)
Definition sandbox.py:313
tuple[int, str, str] run(self, list[str] cmd, dict[str, str] env, Path cwd, int timeout, int|None memory_limit_bytes)
Definition sandbox.py:275
tuple[int, str, str] run(self, list[str] cmd, dict[str, str] env, Path cwd, int timeout, int|None memory_limit_bytes)
Definition sandbox.py:233
list[str] _sandboxed_cmd(self, list[str] cmd)
Definition sandbox.py:373
tuple[int, str, str] run(self, list[str] cmd, dict[str, str] env, Path cwd, int timeout, int|None memory_limit_bytes)
Definition sandbox.py:384
None __init__(self, SandboxBackend|None backend=None, int memory_limit_mb=0)
Definition sandbox.py:492
SandboxResult run(self, list[str] cmd, Path cwd, int timeout, int max_output_bytes=1_048_576)
Definition sandbox.py:504
SandboxBackend make_sandbox_backend(object config)
Definition sandbox.py:405
dict[str, str] _sanitise_env(Path project_root)
Definition sandbox.py:134