8Phase 5C tests for pluggable sandbox backends (``NativeBackend``,
9``BubblewrapBackend``, ``SandboxExecBackend``) and the
10``make_sandbox_backend`` factory.
14- Verify ``NativeBackend.run()`` returns correctly and passes sanitised
16- Verify ``BubblewrapBackend`` and ``SandboxExecBackend`` command
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
30 L3 — assertion details
35- Tests use mock subprocess and platform stubs; no real sandbox commands
39from __future__
import annotations
44from pathlib
import Path
45from unittest.mock
import MagicMock, patch
65 sandbox_backend: str =
"auto",
66 memory_limit_mb: int = 0,
68 """Minimal stand-in for McpConfig."""
70 cfg.sandbox_backend = sandbox_backend
71 cfg.memory_limit_mb = memory_limit_mb
98 cmd=[sys.executable,
"-c",
"print('hello')"],
99 env={
"PATH": os.environ.get(
"PATH",
"")},
102 memory_limit_bytes=
None,
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)
113 exit_code, _, _ = backend.run(
114 cmd=[sys.executable,
"-c",
"import sys; sys.exit(0)"],
115 env={
"PATH": os.environ.get(
"PATH",
"")},
118 memory_limit_bytes=
None,
120 assert exit_code == 0
124 exit_code, _, _ = backend.run(
125 cmd=[sys.executable,
"-c",
"import sys; sys.exit(42)"],
126 env={
"PATH": os.environ.get(
"PATH",
"")},
129 memory_limit_bytes=
None,
131 assert exit_code == 42
135 _, stdout, _ = backend.run(
136 cmd=[sys.executable,
"-c",
"print('captured_output')"],
137 env={
"PATH": os.environ.get(
"PATH",
"")},
140 memory_limit_bytes=
None,
142 assert "captured_output" in stdout
146 _, _, stderr = backend.run(
147 cmd=[sys.executable,
"-c",
"import sys; print('err', file=sys.stderr)"],
148 env={
"PATH": os.environ.get(
"PATH",
"")},
151 memory_limit_bytes=
None,
153 assert "err" in stderr
156 """On Windows, memory limit is ignored (preexec_fn must be None)."""
160 original_run = subprocess.run
162 def mock_run(*args, **kwargs):
163 captured[
"preexec_fn"] = kwargs.get(
"preexec_fn")
164 return original_run(*args, **kwargs)
166 with patch(
"oct.mcp.sandbox.subprocess.run", side_effect=mock_run):
167 with patch(
"oct.mcp.sandbox.os.name",
"nt"):
169 cmd=[sys.executable,
"-c",
"pass"],
170 env={
"PATH": os.environ.get(
"PATH",
"")},
173 memory_limit_bytes=536_870_912,
175 assert captured[
"preexec_fn"]
is None
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."
187 """On POSIX with memory_limit_bytes set, preexec_fn should be non-None."""
191 original_run = subprocess.run
193 def mock_run(*args, **kwargs):
194 captured[
"preexec_fn"] = kwargs.get(
"preexec_fn")
202 with patch(
"oct.mcp.sandbox.subprocess.run", side_effect=mock_run):
204 cmd=[sys.executable,
"-c",
"pass"],
205 env={
"PATH": os.environ.get(
"PATH",
"")},
208 memory_limit_bytes=536_870_912,
210 assert captured[
"preexec_fn"]
is not None
211 assert callable(captured[
"preexec_fn"])
214 """No memory limit → preexec_fn must be None regardless of platform."""
218 original_run = subprocess.run
220 def mock_run(*args, **kwargs):
221 captured[
"preexec_fn"] = kwargs.get(
"preexec_fn")
228 with patch(
"oct.mcp.sandbox.subprocess.run", side_effect=mock_run):
230 cmd=[sys.executable,
"-c",
"pass"],
231 env={
"PATH": os.environ.get(
"PATH",
"")},
234 memory_limit_bytes=
None,
236 assert captured[
"preexec_fn"]
is None
239 """shell=True must never be passed to subprocess.run."""
243 original_run = subprocess.run
245 def mock_run(*args, **kwargs):
246 captured[
"shell"] = kwargs.get(
"shell",
False)
253 with patch(
"oct.mcp.sandbox.subprocess.run", side_effect=mock_run):
255 cmd=[sys.executable,
"-c",
"pass"],
259 memory_limit_bytes=
None,
261 assert captured[
"shell"]
is False
271 result = backend._bwrap_cmd([
"echo",
"hello"], tmp_path)
272 assert result[0] ==
"bwrap"
276 result = backend._bwrap_cmd([
"echo",
"hello"], tmp_path)
277 assert "--unshare-net" in result
281 result = backend._bwrap_cmd([
"echo",
"hello"], tmp_path)
282 assert "--die-with-parent" in result
286 result = backend._bwrap_cmd([
"echo",
"hello"], tmp_path)
287 assert "--bind" in result
288 bind_idx = result.index(
"--bind")
290 assert result[bind_idx + 1] == str(tmp_path)
291 assert result[bind_idx + 2] == str(tmp_path)
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)
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
310 result = backend._bwrap_cmd([
"echo"], tmp_path)
311 assert "--proc" in result
312 assert "--dev" in result
313 assert "--tmpfs" in result
319 original_run = subprocess.run
321 def mock_run(*args, **kwargs):
322 captured[
"shell"] = kwargs.get(
"shell",
False)
329 with patch(
"oct.mcp.sandbox.subprocess.run", side_effect=mock_run):
335 memory_limit_bytes=
None,
337 assert captured[
"shell"]
is False
347 result = backend._sandboxed_cmd([
"echo",
"hi"])
348 assert result[0] ==
"sandbox-exec"
352 result = backend._sandboxed_cmd([
"echo",
"hi"])
353 assert "-p" in result
357 result = backend._sandboxed_cmd([
"echo",
"hi"])
358 profile_idx = result.index(
"-p") + 1
359 assert "deny network" in result[profile_idx]
363 cmd = [
"python",
"-c",
"pass"]
364 result = backend._sandboxed_cmd(cmd)
365 assert result[-len(cmd):] == cmd
369 assert "deny network" in backend._PROFILE
375 original_run = subprocess.run
377 def mock_run(*args, **kwargs):
378 captured[
"shell"] = kwargs.get(
"shell",
False)
385 with patch(
"oct.mcp.sandbox.subprocess.run", side_effect=mock_run):
391 memory_limit_bytes=
None,
393 assert captured[
"shell"]
is False
403 result = make_sandbox_backend(cfg)
404 assert isinstance(result, NativeBackend)
408 with patch(
"oct.mcp.sandbox.sys.platform",
"win32"):
409 result = make_sandbox_backend(cfg)
410 assert isinstance(result, NativeBackend)
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)
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)
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)
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)
442 with patch(
"oct.mcp.sandbox.shutil.which", return_value=
None):
443 with pytest.raises(RuntimeError, match=
"bwrap"):
444 make_sandbox_backend(cfg)
448 with patch(
"oct.mcp.sandbox.shutil.which", return_value=
"/usr/bin/bwrap"):
449 result = make_sandbox_backend(cfg)
450 assert isinstance(result, BubblewrapBackend)
454 with patch(
"oct.mcp.sandbox.shutil.which", return_value=
None):
455 with pytest.raises(RuntimeError, match=
"sandbox-exec"):
456 make_sandbox_backend(cfg)
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)
465 """Unknown sandbox_backend value (e.g. config validation missed it) → NativeBackend."""
467 result = make_sandbox_backend(cfg)
468 assert isinstance(result, NativeBackend)
477 mock_backend = MagicMock()
478 mock_backend.run.return_value = (0,
"ok",
"")
480 executor.run(cmd=[
"echo"], cwd=tmp_path, timeout=10)
481 mock_backend.run.assert_called_once()
484 mock_backend = MagicMock()
485 mock_backend.run.return_value = (0,
"",
"")
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
492 mock_backend = MagicMock()
493 mock_backend.run.return_value = (0,
"",
"")
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
500 mock_backend = MagicMock()
501 mock_backend.run.return_value = (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
508 mock_backend = MagicMock()
509 mock_backend.run.return_value = (0,
"",
"")
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
517 assert isinstance(executor._backend, NativeBackend)
520 large_output =
"x" * 2000
521 mock_backend = MagicMock()
522 mock_backend.run.return_value = (0, large_output,
"")
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
530 mock_backend = MagicMock()
531 mock_backend.run.return_value = (0,
"output",
"")
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"
539 mock_backend = MagicMock()
540 mock_backend.run.side_effect = subprocess.TimeoutExpired(cmd=
"echo", timeout=5)
542 result = executor.run(cmd=[
"echo"], cwd=tmp_path, timeout=5)
543 assert result.timed_out
is True
544 assert result.exit_code == 1
547 mock_backend = MagicMock()
548 mock_backend.run.side_effect = FileNotFoundError(
"no such file")
550 result = executor.run(cmd=[
"nonexistent_command"], cwd=tmp_path, timeout=10)
551 assert result.exit_code == 1
552 assert result.error_message !=
""
555 mock_backend = MagicMock()
556 mock_backend.run.side_effect = OSError(
"permission denied")
558 result = executor.run(cmd=[
"echo"], cwd=tmp_path, timeout=10)
559 assert result.exit_code == 1
560 assert result.error_message !=
""
569 with patch.dict(os.environ, {
"PYTHONPATH":
"/some/path"}, clear=
False):
570 env = _sanitise_env(tmp_path)
571 assert "PYTHONPATH" not in env
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
579 with patch.dict(os.environ, {
"DB_PASSWORD":
"pass123"}, clear=
False):
580 env = _sanitise_env(tmp_path)
581 assert "DB_PASSWORD" not in env
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
589 env = _sanitise_env(tmp_path)
591 if "PATH" in os.environ:
595 if "HOME" in os.environ:
596 env = _sanitise_env(tmp_path)
600 env = _sanitise_env(tmp_path)
601 assert "OCT_MCP_CWD" in env
602 assert env[
"OCT_MCP_CWD"] == str(tmp_path)
605 env = _sanitise_env(tmp_path)
606 assert env
is not os.environ
test_bwrap_cmd_contains_die_with_parent(self, Path tmp_path)
test_bwrap_cmd_contains_unshare_net(self, Path tmp_path)
test_bwrap_cmd_ends_with_separator_then_cmd(self, Path tmp_path)
test_bwrap_cmd_binds_cwd(self, Path tmp_path)
test_bwrap_cmd_proc_dev_tmpfs(self, Path tmp_path)
test_bwrap_cmd_starts_with_bwrap(self, Path tmp_path)
test_shell_is_always_false(self, Path tmp_path)
test_bwrap_cmd_contains_chdir(self, Path tmp_path)
test_forced_bubblewrap_raises_when_bwrap_absent(self)
test_unknown_name_falls_through_to_native(self)
test_auto_returns_sandbox_exec_on_macos_when_found(self)
test_auto_returns_native_on_linux_when_bwrap_missing(self)
test_forced_sandbox_exec_raises_when_absent(self)
test_forced_sandbox_exec_returns_backend_when_present(self)
test_auto_returns_native_on_windows(self)
test_forced_bubblewrap_returns_backend_when_bwrap_present(self)
test_auto_returns_bubblewrap_on_linux_when_bwrap_found(self)
test_native_always_returns_native_backend(self)
test_auto_returns_native_on_macos_when_sandbox_exec_missing(self)
test_run_captures_stderr(self, Path tmp_path)
test_preexec_fn_is_none_on_windows(self, Path tmp_path)
test_run_exit_code_zero_on_success(self, Path tmp_path)
test_run_nonzero_exit_code(self, Path tmp_path)
test_run_returns_tuple_of_three(self, Path tmp_path)
test_run_captures_stdout(self, Path tmp_path)
test_shell_is_always_false(self, Path tmp_path)
test_preexec_fn_set_on_posix_with_limit(self, Path tmp_path)
test_preexec_fn_none_when_no_memory_limit(self, Path tmp_path)
test_native_backend_is_sandbox_backend(self)
test_sandbox_exec_backend_is_sandbox_backend(self)
test_bubblewrap_backend_is_sandbox_backend(self)
test_profile_denies_network(self)
test_sandboxed_cmd_has_p_flag(self)
test_shell_is_always_false(self, Path tmp_path)
test_sandboxed_cmd_contains_deny_network(self)
test_sandboxed_cmd_starts_with_sandbox_exec(self)
test_sandboxed_cmd_ends_with_original_cmd(self)
test_executor_calls_backend_run(self, Path tmp_path)
test_executor_passes_cwd_to_backend(self, Path tmp_path)
test_executor_output_capped_at_max_output_bytes(self, Path tmp_path)
test_executor_memory_limit_zero_passes_none(self, Path tmp_path)
test_executor_handles_timeout_expired(self, Path tmp_path)
test_executor_returns_sandbox_result_on_success(self, Path tmp_path)
test_executor_memory_limit_512mb_passes_bytes(self, Path tmp_path)
test_executor_handles_file_not_found(self, Path tmp_path)
test_executor_passes_timeout_to_backend(self, Path tmp_path)
test_executor_handles_os_error(self, Path tmp_path)
test_executor_defaults_to_native_backend(self)
test_strips_api_key(self, Path tmp_path)
test_strips_password_key(self, Path tmp_path)
test_preserves_path(self, Path tmp_path)
test_strips_pythonpath(self, Path tmp_path)
test_strips_secret_key(self, Path tmp_path)
test_preserves_home(self, Path tmp_path)
test_adds_oct_mcp_cwd(self, Path tmp_path)
test_returns_new_dict_not_os_environ(self, Path tmp_path)
object _fake_config(str sandbox_backend="auto", int memory_limit_mb=0)