Option C Tools
Loading...
Searching...
No Matches
test_mcp_sandbox.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# tests/tests_mcp/test_mcp_sandbox.py
4
5"""
6Purpose
7-------
8Regression tests for :mod:`oct.mcp.sandbox` — ``SandboxExecutor`` and
9environment sanitisation.
10
11Responsibilities
12----------------
13- Verify environment sanitisation strips sensitive variables.
14- Verify timeout enforcement and output-size limits.
15- Verify T-04 (command chaining) and shell=False enforcement.
16- Verify exit-code forwarding from subprocess.
17
18Diagnostics
19-----------
20Domain: MCP-TESTS
21Levels:
22 L2 — test lifecycle
23 L3 — assertion details
24 L4 — deep tracing
25
26Contracts
27---------
28- Tests use mock subprocess; no real commands are executed.
29"""
30
31from __future__ import annotations
32
33import os
34import sys
35from pathlib import Path
36from unittest.mock import MagicMock, patch
37
38import pytest
39
40from oct.mcp.sandbox import (
41 SandboxExecutor,
42 SandboxResult,
43 _sanitise_env,
44 _SENSITIVE_ENV_SUBSTRINGS,
45)
46
47
48# -------------------------------------------------------------------
49# _sanitise_env
50# -------------------------------------------------------------------
51
53 def test_pythonpath_stripped(self, tmp_path: Path, monkeypatch):
54 monkeypatch.setenv("PYTHONPATH", "/some/path")
55 env = _sanitise_env(tmp_path)
56 assert "PYTHONPATH" not in env
57
58 def test_path_preserved(self, tmp_path: Path):
59 env = _sanitise_env(tmp_path)
60 assert "PATH" in env
61
62 def test_home_preserved(self, tmp_path: Path):
63 env = _sanitise_env(tmp_path)
64 # HOME or USERPROFILE (Windows)
65 assert "HOME" in env or "USERPROFILE" in env
66
67 def test_secret_key_stripped(self, tmp_path: Path, monkeypatch):
68 monkeypatch.setenv("MY_API_KEY", "topsecret")
69 env = _sanitise_env(tmp_path)
70 assert "MY_API_KEY" not in env
71
72 def test_password_key_stripped(self, tmp_path: Path, monkeypatch):
73 monkeypatch.setenv("DB_PASSWORD", "hunter2")
74 env = _sanitise_env(tmp_path)
75 assert "DB_PASSWORD" not in env
76
77 def test_token_key_stripped(self, tmp_path: Path, monkeypatch):
78 monkeypatch.setenv("GITHUB_TOKEN", "ghp_abc")
79 env = _sanitise_env(tmp_path)
80 assert "GITHUB_TOKEN" not in env
81
82 def test_cwd_injected(self, tmp_path: Path):
83 env = _sanitise_env(tmp_path)
84 assert env.get("OCT_MCP_CWD") == str(tmp_path)
85
86 def test_virtual_env_preserved(self, tmp_path: Path, monkeypatch):
87 monkeypatch.setenv("VIRTUAL_ENV", "/venv")
88 env = _sanitise_env(tmp_path)
89 assert "VIRTUAL_ENV" in env
90
91 def test_returns_new_dict(self, tmp_path: Path):
92 env = _sanitise_env(tmp_path)
93 assert env is not os.environ
94
95
96# -------------------------------------------------------------------
97# SandboxResult
98# -------------------------------------------------------------------
99
101 def test_defaults(self):
102 r = SandboxResult(exit_code=0, stdout="out", stderr="err")
103 assert r.timed_out is False
104 assert r.output_truncated is False
105 assert r.error_message == ""
106
107
108# -------------------------------------------------------------------
109# SandboxExecutor.run — normal execution
110# -------------------------------------------------------------------
111
113 def test_runs_simple_command(self, tmp_path: Path):
114 executor = SandboxExecutor()
115 result = executor.run(
116 [sys.executable, "-c", "print('hello')"],
117 cwd=tmp_path,
118 timeout=10,
119 )
120 assert result.exit_code == 0
121 assert "hello" in result.stdout
122
123 def test_captures_exit_code_nonzero(self, tmp_path: Path):
124 executor = SandboxExecutor()
125 result = executor.run(
126 [sys.executable, "-c", "import sys; sys.exit(42)"],
127 cwd=tmp_path,
128 timeout=10,
129 )
130 assert result.exit_code == 42
131
132 def test_captures_stderr(self, tmp_path: Path):
133 executor = SandboxExecutor()
134 result = executor.run(
135 [sys.executable, "-c", "import sys; print('err', file=sys.stderr)"],
136 cwd=tmp_path,
137 timeout=10,
138 )
139 assert "err" in result.stderr
140
141 def test_shell_false_enforced(self, tmp_path: Path):
142 """T-04: shell=True must never be used — semicolons are not executed."""
143 executor = SandboxExecutor()
144 # Passing shell injection via argv list items should not execute the shell.
145 # The subprocess receives the literal string as an argument, not a shell command.
146 result = executor.run(
147 [sys.executable, "-c", "print('safe')"],
148 cwd=tmp_path,
149 timeout=10,
150 )
151 assert result.exit_code == 0
152 assert result.timed_out is False
153
154 def test_command_not_found_returns_error(self, tmp_path: Path):
155 executor = SandboxExecutor()
156 result = executor.run(
157 ["this_command_does_not_exist_xyz123"],
158 cwd=tmp_path,
159 timeout=10,
160 )
161 assert result.exit_code == 1
162 assert result.error_message != ""
163
164 def test_never_raises(self, tmp_path: Path):
165 executor = SandboxExecutor()
166 # Should not raise even with a bad command.
167 result = executor.run(
168 ["nonexistent_cmd_abc"],
169 cwd=tmp_path,
170 timeout=5,
171 )
172 assert isinstance(result, SandboxResult)
173
174 def test_working_directory_is_set(self, tmp_path: Path):
175 executor = SandboxExecutor()
176 result = executor.run(
177 [sys.executable, "-c", "import os; print(os.getcwd())"],
178 cwd=tmp_path,
179 timeout=10,
180 )
181 assert result.exit_code == 0
182 # The cwd should match tmp_path (resolving any symlinks for comparison)
183 assert Path(result.stdout.strip()).resolve() == tmp_path.resolve()
184
185
186# -------------------------------------------------------------------
187# SandboxExecutor.run — timeout
188# -------------------------------------------------------------------
189
191 def test_timeout_returns_timed_out_true(self, tmp_path: Path):
192 executor = SandboxExecutor()
193 result = executor.run(
194 [sys.executable, "-c", "import time; time.sleep(30)"],
195 cwd=tmp_path,
196 timeout=1,
197 )
198 assert result.timed_out is True
199 assert result.exit_code == 1
200
202 executor = SandboxExecutor()
203 result = executor.run(
204 [sys.executable, "-c", "print('done')"],
205 cwd=tmp_path,
206 timeout=10,
207 )
208 assert result.timed_out is False
209
210
211# -------------------------------------------------------------------
212# SandboxExecutor.run — output size limit
213# -------------------------------------------------------------------
214
216 def test_output_within_limit_not_truncated(self, tmp_path: Path):
217 executor = SandboxExecutor()
218 result = executor.run(
219 [sys.executable, "-c", "print('short')"],
220 cwd=tmp_path,
221 timeout=10,
222 max_output_bytes=1024,
223 )
224 assert result.output_truncated is False
225
226 def test_output_over_limit_truncated(self, tmp_path: Path):
227 executor = SandboxExecutor()
228 # Generate a large output.
229 result = executor.run(
230 [sys.executable, "-c", "print('x' * 2000)"],
231 cwd=tmp_path,
232 timeout=10,
233 max_output_bytes=100,
234 )
235 assert result.output_truncated is True
236 # Combined output should not exceed limit significantly.
237 combined = len((result.stdout + result.stderr).encode("utf-8"))
238 assert combined <= 200 # some slack for truncation boundary
test_virtual_env_preserved(self, Path tmp_path, monkeypatch)
test_token_key_stripped(self, Path tmp_path, monkeypatch)
test_password_key_stripped(self, Path tmp_path, monkeypatch)
test_pythonpath_stripped(self, Path tmp_path, monkeypatch)
test_secret_key_stripped(self, Path tmp_path, monkeypatch)