Option C Tools
Loading...
Searching...
No Matches
test_mcp_sandbox_backends.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# tests/tests_mcp/test_mcp_sandbox_backends.py
4
5"""
6Purpose
7-------
8Phase 5C tests for pluggable sandbox backends (``NativeBackend``,
9``BubblewrapBackend``, ``SandboxExecBackend``) and the
10``make_sandbox_backend`` factory.
11
12Responsibilities
13----------------
14- Verify ``NativeBackend.run()`` returns correctly and passes sanitised
15 env.
16- Verify ``BubblewrapBackend`` and ``SandboxExecBackend`` command
17 structure.
18- Verify ``make_sandbox_backend`` factory for all backend names and
19 auto-detection per platform.
20- Verify ``SandboxExecutor`` delegation, memory-limit conversion, output
21 capping, and error handling (timeout, OSError, FileNotFoundError).
22- Verify ``_sanitise_env`` strips sensitive variables and adds
23 ``OCT_MCP_CWD``.
24
25Diagnostics
26-----------
27Domain: MCP-TESTS
28Levels:
29 L2 — test lifecycle
30 L3 — assertion details
31 L4 — deep tracing
32
33Contracts
34---------
35- Tests use mock subprocess and platform stubs; no real sandbox commands
36 are executed.
37"""
38
39from __future__ import annotations
40
41import os
42import subprocess
43import sys
44from pathlib import Path
45from unittest.mock import MagicMock, patch
46
47import pytest
48
49from oct.mcp.sandbox import (
50 BubblewrapBackend,
51 NativeBackend,
52 SandboxBackend,
53 SandboxExecBackend,
54 SandboxExecutor,
55 _sanitise_env,
56 make_sandbox_backend,
57)
58
59
60# ---------------------------------------------------------------------------
61# Helpers
62# ---------------------------------------------------------------------------
63
65 sandbox_backend: str = "auto",
66 memory_limit_mb: int = 0,
67) -> object:
68 """Minimal stand-in for McpConfig."""
69 cfg = MagicMock()
70 cfg.sandbox_backend = sandbox_backend
71 cfg.memory_limit_mb = memory_limit_mb
72 return cfg
73
74
75# ---------------------------------------------------------------------------
76# SandboxBackend Protocol
77# ---------------------------------------------------------------------------
78
81 assert isinstance(NativeBackend(), SandboxBackend)
82
84 assert isinstance(BubblewrapBackend(), SandboxBackend)
85
87 assert isinstance(SandboxExecBackend(), SandboxBackend)
88
89
90# ---------------------------------------------------------------------------
91# NativeBackend
92# ---------------------------------------------------------------------------
93
95 def test_run_returns_tuple_of_three(self, tmp_path: Path):
96 backend = NativeBackend()
97 result = backend.run(
98 cmd=[sys.executable, "-c", "print('hello')"],
99 env={"PATH": os.environ.get("PATH", "")},
100 cwd=tmp_path,
101 timeout=10,
102 memory_limit_bytes=None,
103 )
104 assert isinstance(result, tuple)
105 assert len(result) == 3
106 exit_code, stdout, stderr = result
107 assert isinstance(exit_code, int)
108 assert isinstance(stdout, str)
109 assert isinstance(stderr, str)
110
111 def test_run_exit_code_zero_on_success(self, tmp_path: Path):
112 backend = NativeBackend()
113 exit_code, _, _ = backend.run(
114 cmd=[sys.executable, "-c", "import sys; sys.exit(0)"],
115 env={"PATH": os.environ.get("PATH", "")},
116 cwd=tmp_path,
117 timeout=10,
118 memory_limit_bytes=None,
119 )
120 assert exit_code == 0
121
122 def test_run_nonzero_exit_code(self, tmp_path: Path):
123 backend = NativeBackend()
124 exit_code, _, _ = backend.run(
125 cmd=[sys.executable, "-c", "import sys; sys.exit(42)"],
126 env={"PATH": os.environ.get("PATH", "")},
127 cwd=tmp_path,
128 timeout=10,
129 memory_limit_bytes=None,
130 )
131 assert exit_code == 42
132
133 def test_run_captures_stdout(self, tmp_path: Path):
134 backend = NativeBackend()
135 _, stdout, _ = backend.run(
136 cmd=[sys.executable, "-c", "print('captured_output')"],
137 env={"PATH": os.environ.get("PATH", "")},
138 cwd=tmp_path,
139 timeout=10,
140 memory_limit_bytes=None,
141 )
142 assert "captured_output" in stdout
143
144 def test_run_captures_stderr(self, tmp_path: Path):
145 backend = NativeBackend()
146 _, _, stderr = backend.run(
147 cmd=[sys.executable, "-c", "import sys; print('err', file=sys.stderr)"],
148 env={"PATH": os.environ.get("PATH", "")},
149 cwd=tmp_path,
150 timeout=10,
151 memory_limit_bytes=None,
152 )
153 assert "err" in stderr
154
155 def test_preexec_fn_is_none_on_windows(self, tmp_path: Path):
156 """On Windows, memory limit is ignored (preexec_fn must be None)."""
157 backend = NativeBackend()
158 captured = {}
159
160 original_run = subprocess.run
161
162 def mock_run(*args, **kwargs):
163 captured["preexec_fn"] = kwargs.get("preexec_fn")
164 return original_run(*args, **kwargs)
165
166 with patch("oct.mcp.sandbox.subprocess.run", side_effect=mock_run):
167 with patch("oct.mcp.sandbox.os.name", "nt"):
168 backend.run(
169 cmd=[sys.executable, "-c", "pass"],
170 env={"PATH": os.environ.get("PATH", "")},
171 cwd=tmp_path,
172 timeout=10,
173 memory_limit_bytes=536_870_912,
174 )
175 assert captured["preexec_fn"] is None
176
177 @pytest.mark.skipif(
178 os.name == "nt",
179 reason=(
180 "POSIX-only: NativeBackend uses preexec_fn (a fork-only "
181 "subprocess feature) to apply setrlimit before exec; the "
182 "Windows subprocess module rejects preexec_fn entirely. "
183 "Behaviour is exercised by Linux/macOS CI."
184 ),
185 )
187 """On POSIX with memory_limit_bytes set, preexec_fn should be non-None."""
188 backend = NativeBackend()
189 captured = {}
190
191 original_run = subprocess.run
192
193 def mock_run(*args, **kwargs):
194 captured["preexec_fn"] = kwargs.get("preexec_fn")
195 # Don't actually run to avoid setrlimit side effects.
196 m = MagicMock()
197 m.returncode = 0
198 m.stdout = ""
199 m.stderr = ""
200 return m
201
202 with patch("oct.mcp.sandbox.subprocess.run", side_effect=mock_run):
203 backend.run(
204 cmd=[sys.executable, "-c", "pass"],
205 env={"PATH": os.environ.get("PATH", "")},
206 cwd=tmp_path,
207 timeout=10,
208 memory_limit_bytes=536_870_912,
209 )
210 assert captured["preexec_fn"] is not None
211 assert callable(captured["preexec_fn"])
212
214 """No memory limit → preexec_fn must be None regardless of platform."""
215 backend = NativeBackend()
216 captured = {}
217
218 original_run = subprocess.run
219
220 def mock_run(*args, **kwargs):
221 captured["preexec_fn"] = kwargs.get("preexec_fn")
222 m = MagicMock()
223 m.returncode = 0
224 m.stdout = ""
225 m.stderr = ""
226 return m
227
228 with patch("oct.mcp.sandbox.subprocess.run", side_effect=mock_run):
229 backend.run(
230 cmd=[sys.executable, "-c", "pass"],
231 env={"PATH": os.environ.get("PATH", "")},
232 cwd=tmp_path,
233 timeout=10,
234 memory_limit_bytes=None,
235 )
236 assert captured["preexec_fn"] is None
237
238 def test_shell_is_always_false(self, tmp_path: Path):
239 """shell=True must never be passed to subprocess.run."""
240 backend = NativeBackend()
241 captured = {}
242
243 original_run = subprocess.run
244
245 def mock_run(*args, **kwargs):
246 captured["shell"] = kwargs.get("shell", False)
247 m = MagicMock()
248 m.returncode = 0
249 m.stdout = ""
250 m.stderr = ""
251 return m
252
253 with patch("oct.mcp.sandbox.subprocess.run", side_effect=mock_run):
254 backend.run(
255 cmd=[sys.executable, "-c", "pass"],
256 env={},
257 cwd=tmp_path,
258 timeout=10,
259 memory_limit_bytes=None,
260 )
261 assert captured["shell"] is False
262
263
264# ---------------------------------------------------------------------------
265# BubblewrapBackend
266# ---------------------------------------------------------------------------
267
269 def test_bwrap_cmd_starts_with_bwrap(self, tmp_path: Path):
270 backend = BubblewrapBackend()
271 result = backend._bwrap_cmd(["echo", "hello"], tmp_path)
272 assert result[0] == "bwrap"
273
274 def test_bwrap_cmd_contains_unshare_net(self, tmp_path: Path):
275 backend = BubblewrapBackend()
276 result = backend._bwrap_cmd(["echo", "hello"], tmp_path)
277 assert "--unshare-net" in result
278
280 backend = BubblewrapBackend()
281 result = backend._bwrap_cmd(["echo", "hello"], tmp_path)
282 assert "--die-with-parent" in result
283
284 def test_bwrap_cmd_binds_cwd(self, tmp_path: Path):
285 backend = BubblewrapBackend()
286 result = backend._bwrap_cmd(["echo", "hello"], tmp_path)
287 assert "--bind" in result
288 bind_idx = result.index("--bind")
289 # bind source and target should both be the cwd
290 assert result[bind_idx + 1] == str(tmp_path)
291 assert result[bind_idx + 2] == str(tmp_path)
292
293 def test_bwrap_cmd_contains_chdir(self, tmp_path: Path):
294 backend = BubblewrapBackend()
295 result = backend._bwrap_cmd(["echo", "hello"], tmp_path)
296 assert "--chdir" in result
297 chdir_idx = result.index("--chdir")
298 assert result[chdir_idx + 1] == str(tmp_path)
299
301 backend = BubblewrapBackend()
302 cmd = ["echo", "hello"]
303 result = backend._bwrap_cmd(cmd, tmp_path)
304 assert "--" in result
305 sep_idx = result.index("--")
306 assert result[sep_idx + 1:] == cmd
307
308 def test_bwrap_cmd_proc_dev_tmpfs(self, tmp_path: Path):
309 backend = BubblewrapBackend()
310 result = backend._bwrap_cmd(["echo"], tmp_path)
311 assert "--proc" in result
312 assert "--dev" in result
313 assert "--tmpfs" in result
314
315 def test_shell_is_always_false(self, tmp_path: Path):
316 backend = BubblewrapBackend()
317 captured = {}
318
319 original_run = subprocess.run
320
321 def mock_run(*args, **kwargs):
322 captured["shell"] = kwargs.get("shell", False)
323 m = MagicMock()
324 m.returncode = 0
325 m.stdout = ""
326 m.stderr = ""
327 return m
328
329 with patch("oct.mcp.sandbox.subprocess.run", side_effect=mock_run):
330 backend.run(
331 cmd=["echo", "hi"],
332 env={},
333 cwd=tmp_path,
334 timeout=10,
335 memory_limit_bytes=None,
336 )
337 assert captured["shell"] is False
338
339
340# ---------------------------------------------------------------------------
341# SandboxExecBackend
342# ---------------------------------------------------------------------------
343
346 backend = SandboxExecBackend()
347 result = backend._sandboxed_cmd(["echo", "hi"])
348 assert result[0] == "sandbox-exec"
349
351 backend = SandboxExecBackend()
352 result = backend._sandboxed_cmd(["echo", "hi"])
353 assert "-p" in result
354
356 backend = SandboxExecBackend()
357 result = backend._sandboxed_cmd(["echo", "hi"])
358 profile_idx = result.index("-p") + 1
359 assert "deny network" in result[profile_idx]
360
362 backend = SandboxExecBackend()
363 cmd = ["python", "-c", "pass"]
364 result = backend._sandboxed_cmd(cmd)
365 assert result[-len(cmd):] == cmd
366
368 backend = SandboxExecBackend()
369 assert "deny network" in backend._PROFILE
370
371 def test_shell_is_always_false(self, tmp_path: Path):
372 backend = SandboxExecBackend()
373 captured = {}
374
375 original_run = subprocess.run
376
377 def mock_run(*args, **kwargs):
378 captured["shell"] = kwargs.get("shell", False)
379 m = MagicMock()
380 m.returncode = 0
381 m.stdout = ""
382 m.stderr = ""
383 return m
384
385 with patch("oct.mcp.sandbox.subprocess.run", side_effect=mock_run):
386 backend.run(
387 cmd=["echo", "hi"],
388 env={},
389 cwd=tmp_path,
390 timeout=10,
391 memory_limit_bytes=None,
392 )
393 assert captured["shell"] is False
394
395
396# ---------------------------------------------------------------------------
397# make_sandbox_backend factory
398# ---------------------------------------------------------------------------
399
402 cfg = _fake_config(sandbox_backend="native")
403 result = make_sandbox_backend(cfg)
404 assert isinstance(result, NativeBackend)
405
407 cfg = _fake_config(sandbox_backend="auto")
408 with patch("oct.mcp.sandbox.sys.platform", "win32"):
409 result = make_sandbox_backend(cfg)
410 assert isinstance(result, NativeBackend)
411
413 cfg = _fake_config(sandbox_backend="auto")
414 with patch("oct.mcp.sandbox.sys.platform", "linux"):
415 with patch("oct.mcp.sandbox.shutil.which", return_value="/usr/bin/bwrap"):
416 result = make_sandbox_backend(cfg)
417 assert isinstance(result, BubblewrapBackend)
418
420 cfg = _fake_config(sandbox_backend="auto")
421 with patch("oct.mcp.sandbox.sys.platform", "linux"):
422 with patch("oct.mcp.sandbox.shutil.which", return_value=None):
423 result = make_sandbox_backend(cfg)
424 assert isinstance(result, NativeBackend)
425
427 cfg = _fake_config(sandbox_backend="auto")
428 with patch("oct.mcp.sandbox.sys.platform", "darwin"):
429 with patch("oct.mcp.sandbox.shutil.which", return_value="/usr/bin/sandbox-exec"):
430 result = make_sandbox_backend(cfg)
431 assert isinstance(result, SandboxExecBackend)
432
434 cfg = _fake_config(sandbox_backend="auto")
435 with patch("oct.mcp.sandbox.sys.platform", "darwin"):
436 with patch("oct.mcp.sandbox.shutil.which", return_value=None):
437 result = make_sandbox_backend(cfg)
438 assert isinstance(result, NativeBackend)
439
441 cfg = _fake_config(sandbox_backend="bubblewrap")
442 with patch("oct.mcp.sandbox.shutil.which", return_value=None):
443 with pytest.raises(RuntimeError, match="bwrap"):
444 make_sandbox_backend(cfg)
445
447 cfg = _fake_config(sandbox_backend="bubblewrap")
448 with patch("oct.mcp.sandbox.shutil.which", return_value="/usr/bin/bwrap"):
449 result = make_sandbox_backend(cfg)
450 assert isinstance(result, BubblewrapBackend)
451
453 cfg = _fake_config(sandbox_backend="sandbox_exec")
454 with patch("oct.mcp.sandbox.shutil.which", return_value=None):
455 with pytest.raises(RuntimeError, match="sandbox-exec"):
456 make_sandbox_backend(cfg)
457
459 cfg = _fake_config(sandbox_backend="sandbox_exec")
460 with patch("oct.mcp.sandbox.shutil.which", return_value="/usr/bin/sandbox-exec"):
461 result = make_sandbox_backend(cfg)
462 assert isinstance(result, SandboxExecBackend)
463
465 """Unknown sandbox_backend value (e.g. config validation missed it) → NativeBackend."""
466 cfg = _fake_config(sandbox_backend="unknown_backend")
467 result = make_sandbox_backend(cfg)
468 assert isinstance(result, NativeBackend)
469
470
471# ---------------------------------------------------------------------------
472# SandboxExecutor — backend delegation
473# ---------------------------------------------------------------------------
474
476 def test_executor_calls_backend_run(self, tmp_path: Path):
477 mock_backend = MagicMock()
478 mock_backend.run.return_value = (0, "ok", "")
479 executor = SandboxExecutor(backend=mock_backend)
480 executor.run(cmd=["echo"], cwd=tmp_path, timeout=10)
481 mock_backend.run.assert_called_once()
482
483 def test_executor_passes_cwd_to_backend(self, tmp_path: Path):
484 mock_backend = MagicMock()
485 mock_backend.run.return_value = (0, "", "")
486 executor = SandboxExecutor(backend=mock_backend)
487 executor.run(cmd=["echo"], cwd=tmp_path, timeout=10)
488 call_kwargs = mock_backend.run.call_args[1]
489 assert call_kwargs["cwd"] == tmp_path
490
492 mock_backend = MagicMock()
493 mock_backend.run.return_value = (0, "", "")
494 executor = SandboxExecutor(backend=mock_backend)
495 executor.run(cmd=["echo"], cwd=tmp_path, timeout=42)
496 call_kwargs = mock_backend.run.call_args[1]
497 assert call_kwargs["timeout"] == 42
498
500 mock_backend = MagicMock()
501 mock_backend.run.return_value = (0, "", "")
502 executor = SandboxExecutor(backend=mock_backend, memory_limit_mb=0)
503 executor.run(cmd=["echo"], cwd=tmp_path, timeout=10)
504 call_kwargs = mock_backend.run.call_args[1]
505 assert call_kwargs["memory_limit_bytes"] is None
506
508 mock_backend = MagicMock()
509 mock_backend.run.return_value = (0, "", "")
510 executor = SandboxExecutor(backend=mock_backend, memory_limit_mb=512)
511 executor.run(cmd=["echo"], cwd=tmp_path, timeout=10)
512 call_kwargs = mock_backend.run.call_args[1]
513 assert call_kwargs["memory_limit_bytes"] == 512 * 1024 * 1024
514
516 executor = SandboxExecutor()
517 assert isinstance(executor._backend, NativeBackend)
518
520 large_output = "x" * 2000
521 mock_backend = MagicMock()
522 mock_backend.run.return_value = (0, large_output, "")
523 executor = SandboxExecutor(backend=mock_backend)
524 result = executor.run(cmd=["echo"], cwd=tmp_path, timeout=10, max_output_bytes=100)
525 assert len(result.stdout) <= 100
526 assert result.output_truncated is True
527
529 from oct.mcp.sandbox import SandboxResult
530 mock_backend = MagicMock()
531 mock_backend.run.return_value = (0, "output", "")
532 executor = SandboxExecutor(backend=mock_backend)
533 result = executor.run(cmd=["echo"], cwd=tmp_path, timeout=10)
534 assert isinstance(result, SandboxResult)
535 assert result.exit_code == 0
536 assert result.stdout == "output"
537
538 def test_executor_handles_timeout_expired(self, tmp_path: Path):
539 mock_backend = MagicMock()
540 mock_backend.run.side_effect = subprocess.TimeoutExpired(cmd="echo", timeout=5)
541 executor = SandboxExecutor(backend=mock_backend)
542 result = executor.run(cmd=["echo"], cwd=tmp_path, timeout=5)
543 assert result.timed_out is True
544 assert result.exit_code == 1
545
546 def test_executor_handles_file_not_found(self, tmp_path: Path):
547 mock_backend = MagicMock()
548 mock_backend.run.side_effect = FileNotFoundError("no such file")
549 executor = SandboxExecutor(backend=mock_backend)
550 result = executor.run(cmd=["nonexistent_command"], cwd=tmp_path, timeout=10)
551 assert result.exit_code == 1
552 assert result.error_message != ""
553
554 def test_executor_handles_os_error(self, tmp_path: Path):
555 mock_backend = MagicMock()
556 mock_backend.run.side_effect = OSError("permission denied")
557 executor = SandboxExecutor(backend=mock_backend)
558 result = executor.run(cmd=["echo"], cwd=tmp_path, timeout=10)
559 assert result.exit_code == 1
560 assert result.error_message != ""
561
562
563# ---------------------------------------------------------------------------
564# _sanitise_env
565# ---------------------------------------------------------------------------
566
568 def test_strips_pythonpath(self, tmp_path: Path):
569 with patch.dict(os.environ, {"PYTHONPATH": "/some/path"}, clear=False):
570 env = _sanitise_env(tmp_path)
571 assert "PYTHONPATH" not in env
572
573 def test_strips_api_key(self, tmp_path: Path):
574 with patch.dict(os.environ, {"MY_API_KEY": "super_secret"}, clear=False):
575 env = _sanitise_env(tmp_path)
576 assert "MY_API_KEY" not in env
577
578 def test_strips_password_key(self, tmp_path: Path):
579 with patch.dict(os.environ, {"DB_PASSWORD": "pass123"}, clear=False):
580 env = _sanitise_env(tmp_path)
581 assert "DB_PASSWORD" not in env
582
583 def test_strips_secret_key(self, tmp_path: Path):
584 with patch.dict(os.environ, {"AWS_SECRET_ACCESS_KEY": "xxx"}, clear=False):
585 env = _sanitise_env(tmp_path)
586 assert "AWS_SECRET_ACCESS_KEY" not in env
587
588 def test_preserves_path(self, tmp_path: Path):
589 env = _sanitise_env(tmp_path)
590 # PATH should be present (or at least not stripped) if it existed
591 if "PATH" in os.environ:
592 assert "PATH" in env
593
594 def test_preserves_home(self, tmp_path: Path):
595 if "HOME" in os.environ:
596 env = _sanitise_env(tmp_path)
597 assert "HOME" in env
598
599 def test_adds_oct_mcp_cwd(self, tmp_path: Path):
600 env = _sanitise_env(tmp_path)
601 assert "OCT_MCP_CWD" in env
602 assert env["OCT_MCP_CWD"] == str(tmp_path)
603
604 def test_returns_new_dict_not_os_environ(self, tmp_path: Path):
605 env = _sanitise_env(tmp_path)
606 assert env is not os.environ
object _fake_config(str sandbox_backend="auto", int memory_limit_mb=0)