Option C Tools
Loading...
Searching...
No Matches
test_mcp_hitl.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# tests/tests_mcp/test_mcp_hitl.py
4
5"""
6Purpose
7-------
8Integration tests for the Phase 5B HITL (Human-in-the-Loop) pipeline.
9
10Responsibilities
11----------------
12- Verify write-tool first-call (confirm=False) returns confirmation
13 string; second-call (confirm=True) executes via mock sandbox.
14- Verify oct_format double-guard (apply + confirm) and dry-run path.
15- Verify audit records for both HITL calls.
16- Verify policy denial and validation failure short-circuit before HITL.
17- Verify rate-limit state preserved across calls.
18- Verify T-06 privilege escalation and T-06b strict-profile denial.
19
20Diagnostics
21-----------
22Domain: MCP-TESTS
23Levels:
24 L2 — test lifecycle
25 L3 — assertion details
26 L4 — deep tracing
27
28Contracts
29---------
30- Tests use mock sandbox and ``tmp_path``; no real subprocess is
31 spawned.
32"""
33
34from __future__ import annotations
35
36import json
37from pathlib import Path
38from unittest.mock import MagicMock, patch
39
40import pytest
41from pydantic import ValidationError
42
43from oct.mcp.audit import McpAuditLogger
44from oct.mcp.config import McpConfig
45from oct.mcp.executor import ToolExecutor
46from oct.mcp.metrics import McpMetrics, PROMETHEUS_AVAILABLE
47from oct.mcp.policy import McpPolicy
48from oct.mcp.redactor import Redactor
49from oct.mcp.safety import HITL_SENTINEL, SafetyGate
50from oct.mcp.sandbox import SandboxExecutor, SandboxResult
51from oct.mcp.server import RateLimiter, ServerState
52from oct.mcp.tools import _run_tool_write
53
54
55# -------------------------------------------------------------------
56# Fixtures / helpers
57# -------------------------------------------------------------------
58
59def _isolated_metrics() -> McpMetrics:
60 """McpMetrics on a private CollectorRegistry — avoids
61 ``Duplicated timeseries`` collisions across tests."""
62 if not PROMETHEUS_AVAILABLE:
63 return McpMetrics()
64 from prometheus_client import CollectorRegistry
65 return McpMetrics(registry=CollectorRegistry())
66
67
69 tmp_path: Path,
70 sandbox_output: str = "Done.",
71 sandbox_exit_code: int = 0,
72 profile: str = "default",
73 safe_mode: bool = False,
74 rate_limit: int = 30,
75 session_id: str = "hitl-test-session",
76) -> tuple[ServerState, McpAuditLogger]:
77 """Build a ServerState with SafetyGate for HITL integration tests."""
78 (tmp_path / "pyproject.toml").write_text(
79 '[project]\nname="test"\nversion="0.1"\n'
80 )
81 mock_sandbox = MagicMock(spec=SandboxExecutor)
82 mock_sandbox.run.return_value = SandboxResult(
83 exit_code=sandbox_exit_code,
84 stdout=sandbox_output,
85 stderr="",
86 )
87 audit_log = tmp_path / "audit.log"
88 audit_logger = McpAuditLogger(audit_log_path=audit_log)
89 config = McpConfig()
90 config.safe_mode = safe_mode
91 state = ServerState(
92 project_root=tmp_path,
93 config=config,
94 policy=McpPolicy(profile=profile, safe_mode=safe_mode),
95 executor=ToolExecutor(sandbox=mock_sandbox),
96 redactor=Redactor(),
97 audit_logger=audit_logger,
98 rate_limiter=RateLimiter(limit_per_minute=rate_limit),
99 safety=SafetyGate(config),
100 metrics=_isolated_metrics(),
101 session_id=session_id,
102 )
103 return state, audit_logger
104
105
106def _last_audit_record(logger: McpAuditLogger) -> dict:
107 """Read the last line from the audit log as a dict."""
108 assert logger.current_path is not None
109 lines = logger.current_path.read_text(encoding="utf-8").strip().splitlines()
110 return json.loads(lines[-1])
111
112
113# -------------------------------------------------------------------
114# First call (confirm=False) → requires_confirmation
115# -------------------------------------------------------------------
116
118 @pytest.mark.parametrize("tool_name,extra_args", [
119 ("oct_clean", {}),
120 ("oct_scaffold", {"name": "myproject"}),
121 ("oct_docs", {}),
122 ("oct_export_source", {}),
123 ("oct_install_hooks", {}),
124 ("oct_git_hooks_install", {}),
125 ("oct_git_init", {}),
126 ("oct_git_changelog", {}),
127 ])
129 self, tool_name, extra_args, tmp_path: Path
130 ):
131 state, _ = _make_write_state(tmp_path)
132 import oct.mcp.server as _server_module
133 _server_module._server_state = state
134 args = {"confirm": False, **extra_args}
135 result = _run_tool_write(tool_name, args)
136 assert result.startswith(HITL_SENTINEL), (
137 f"{tool_name}: expected HITL_SENTINEL, got: {result!r}"
138 )
139
141 state, logger = _make_write_state(tmp_path)
142 import oct.mcp.server as _server_module
143 _server_module._server_state = state
144 _run_tool_write("oct_clean", {"confirm": False})
145 record = _last_audit_record(logger)
146 assert record["decision"] == "requires_confirmation"
147
148 def test_first_call_sandbox_not_called(self, tmp_path: Path):
149 state, _ = _make_write_state(tmp_path)
150 import oct.mcp.server as _server_module
151 _server_module._server_state = state
152 _run_tool_write("oct_clean", {"confirm": False})
153 # The sandbox mock should NOT have been called.
154 state.executor._sandbox.run.assert_not_called()
155
157 state, _ = _make_write_state(tmp_path)
158 import oct.mcp.server as _server_module
159 _server_module._server_state = state
160 result = _run_tool_write("oct_format", {"confirm": False, "apply": False})
161 assert result.startswith(HITL_SENTINEL)
162
164 state, _ = _make_write_state(tmp_path)
165 import oct.mcp.server as _server_module
166 _server_module._server_state = state
167 result = _run_tool_write("oct_git_commit", {"confirm": False, "message": "feat: test"})
168 assert result.startswith(HITL_SENTINEL)
169
170
171# -------------------------------------------------------------------
172# Second call (confirm=True) → executes
173# -------------------------------------------------------------------
174
177 state, _ = _make_write_state(tmp_path, sandbox_output="Files cleaned.")
178 import oct.mcp.server as _server_module
179 _server_module._server_state = state
180 result = _run_tool_write("oct_clean", {"confirm": True, "dry_run": True})
181 assert result == "Files cleaned."
182
183 def test_second_call_writes_allowed_audit(self, tmp_path: Path):
184 state, logger = _make_write_state(tmp_path, sandbox_output="Done.")
185 import oct.mcp.server as _server_module
186 _server_module._server_state = state
187 _run_tool_write("oct_clean", {"confirm": True, "dry_run": True})
188 record = _last_audit_record(logger)
189 assert record["decision"] == "allowed"
190
191 def test_second_call_sandbox_called(self, tmp_path: Path):
192 state, _ = _make_write_state(tmp_path)
193 import oct.mcp.server as _server_module
194 _server_module._server_state = state
195 _run_tool_write("oct_clean", {"confirm": True})
196 state.executor._sandbox.run.assert_called_once()
197
198 def test_git_commit_second_call_executes(self, tmp_path: Path):
199 state, _ = _make_write_state(tmp_path, sandbox_output='{"exit_code": 0}')
200 import oct.mcp.server as _server_module
201 _server_module._server_state = state
202 result = _run_tool_write("oct_git_commit", {"confirm": True, "message": "feat: test"})
203 assert isinstance(result, str)
204 state.executor._sandbox.run.assert_called_once()
205
206
207# -------------------------------------------------------------------
208# oct_format double guard
209# -------------------------------------------------------------------
210
213 """E2E: apply=False, confirm=True → executor sees --dry-run."""
214 state, _ = _make_write_state(tmp_path, sandbox_output="[DRY RUN] no changes")
215 import oct.mcp.server as _server_module
216 _server_module._server_state = state
217 _run_tool_write("oct_format", {"confirm": True, "apply": False})
218 # Extract the cmd that was passed to the sandbox mock.
219 call_args = state.executor._sandbox.run.call_args
220 cmd = call_args[1]["cmd"] if "cmd" in call_args[1] else call_args[0][0]
221 assert "--dry-run" in cmd
222 assert "--fix" not in cmd
223
225 """E2E: apply=True, confirm=True → executor sees --fix."""
226 state, _ = _make_write_state(tmp_path, sandbox_output="Fixed 3 files.")
227 import oct.mcp.server as _server_module
228 _server_module._server_state = state
229 _run_tool_write("oct_format", {"confirm": True, "apply": True})
230 call_args = state.executor._sandbox.run.call_args
231 cmd = call_args[1]["cmd"] if "cmd" in call_args[1] else call_args[0][0]
232 assert "--fix" in cmd
233 assert "--dry-run" not in cmd
234
236 """E2E: confirm=False → sandbox never called."""
237 state, _ = _make_write_state(tmp_path)
238 import oct.mcp.server as _server_module
239 _server_module._server_state = state
240 _run_tool_write("oct_format", {"confirm": False, "apply": True})
241 state.executor._sandbox.run.assert_not_called()
242
243
244# -------------------------------------------------------------------
245# Both calls write audit records
246# -------------------------------------------------------------------
247
250 state, logger = _make_write_state(tmp_path, sandbox_output="Done.")
251 import oct.mcp.server as _server_module
252 _server_module._server_state = state
253
254 # First call (confirm=False)
255 _run_tool_write("oct_clean", {"confirm": False})
256 assert logger.current_path is not None
257 lines = logger.current_path.read_text(encoding="utf-8").strip().splitlines()
258 assert len(lines) == 1
259 r1 = json.loads(lines[0])
260 assert r1["decision"] == "requires_confirmation"
261
262 # Second call (confirm=True)
263 _run_tool_write("oct_clean", {"confirm": True})
264 lines = logger.current_path.read_text(encoding="utf-8").strip().splitlines()
265 assert len(lines) == 2
266 r2 = json.loads(lines[1])
267 assert r2["decision"] == "allowed"
268
269
270# -------------------------------------------------------------------
271# Policy denial short-circuits before HITL gate
272# -------------------------------------------------------------------
273
275 def test_strict_profile_denies_before_hitl(self, tmp_path: Path):
276 state, logger = _make_write_state(tmp_path, profile="strict")
277 import oct.mcp.server as _server_module
278 _server_module._server_state = state
279 result = _run_tool_write("oct_format", {"confirm": True, "apply": True})
280 assert "[oct-mcp error]" in result
281 record = _last_audit_record(logger)
282 assert record["decision"] == "denied"
283 assert record["policy_rule"] == "strict_no_destructive"
284 state.executor._sandbox.run.assert_not_called()
285
286 def test_safe_mode_denies_before_hitl(self, tmp_path: Path):
287 state, logger = _make_write_state(tmp_path, safe_mode=True)
288 import oct.mcp.server as _server_module
289 _server_module._server_state = state
290 result = _run_tool_write("oct_clean", {"confirm": True})
291 assert "[oct-mcp error]" in result
292 record = _last_audit_record(logger)
293 assert record["decision"] == "denied"
294 state.executor._sandbox.run.assert_not_called()
295
296
297# -------------------------------------------------------------------
298# Validation failure short-circuits before HITL gate
299# -------------------------------------------------------------------
300
302 def test_validation_failure_returns_error(self, tmp_path: Path):
303 state, logger = _make_write_state(tmp_path)
304 import oct.mcp.server as _server_module
305 _server_module._server_state = state
306 # no_verify is not a valid field for oct_git_commit
307 result = _run_tool_write("oct_git_commit", {"no_verify": True, "confirm": True})
308 assert "[oct-mcp error]" in result.lower() or "validation" in result.lower()
309
310 def test_validation_failure_writes_audit(self, tmp_path: Path):
311 state, logger = _make_write_state(tmp_path)
312 import oct.mcp.server as _server_module
313 _server_module._server_state = state
314 _run_tool_write("oct_git_commit", {"no_verify": True, "confirm": True})
315 record = _last_audit_record(logger)
316 assert record["decision"] == "denied"
317
319 state, _ = _make_write_state(tmp_path)
320 import oct.mcp.server as _server_module
321 _server_module._server_state = state
322 # oct_new requires 'path'
323 result = _run_tool_write("oct_new", {"confirm": True})
324 assert "[oct-mcp error]" in result.lower() or "validation" in result.lower()
325
326
327# -------------------------------------------------------------------
328# Rate limit preserved across HITL round trips
329# -------------------------------------------------------------------
330
332 def test_rate_limit_counts_both_calls(self, tmp_path: Path):
333 """Rate limiter counts both the first (confirmation) and second (execute) calls."""
334 state, _ = _make_write_state(tmp_path, rate_limit=2)
335 import oct.mcp.server as _server_module
336 _server_module._server_state = state
337
338 # Call 1: confirmation
339 r1 = _run_tool_write("oct_clean", {"confirm": False})
340 assert r1.startswith(HITL_SENTINEL)
341
342 # Call 2: execute
343 r2 = _run_tool_write("oct_clean", {"confirm": True})
344 assert not r2.startswith(HITL_SENTINEL) # executed
345
346 # Call 3: rate limited
347 r3 = _run_tool_write("oct_clean", {"confirm": True})
348 assert "rate limit" in r3.lower() or "[oct-mcp error]" in r3.lower()
349
350
351# -------------------------------------------------------------------
352# T-06: privilege escalation attempt
353# -------------------------------------------------------------------
354
357 """T-06: no_verify in git commit → ValidationError → error response."""
358 state, _ = _make_write_state(tmp_path)
359 import oct.mcp.server as _server_module
360 _server_module._server_state = state
361 result = _run_tool_write(
362 "oct_git_commit",
363 {"message": "feat: test", "confirm": True, "no_verify": True},
364 )
365 # Must be an error, not executed
366 assert "[oct-mcp error]" in result or "validation" in result.lower()
367 state.executor._sandbox.run.assert_not_called()
368
369 def test_t06b_strict_profile_blocks_write(self, tmp_path: Path):
370 """T-06b: write tools in strict profile → denied."""
371 state, _ = _make_write_state(tmp_path, profile="strict")
372 import oct.mcp.server as _server_module
373 _server_module._server_state = state
374 for tool_name, extra in [
375 ("oct_format", {"apply": True}),
376 ("oct_clean", {}),
377 ("oct_git_commit", {"message": "feat: test"}),
378 ]:
379 result = _run_tool_write(tool_name, {"confirm": True, **extra})
380 assert "[oct-mcp error]" in result, f"{tool_name} should be denied in strict"
381 state.executor._sandbox.run.assert_not_called()
test_format_confirm_true_apply_false_dry_run_in_argv(self, Path tmp_path)
test_format_confirm_true_apply_true_fix_in_argv(self, Path tmp_path)
test_first_call_returns_confirmation_string(self, tool_name, extra_args, Path tmp_path)
test_strict_profile_denies_before_hitl(self, Path tmp_path)
test_t06_no_verify_field_rejected_at_validation(self, Path tmp_path)
McpMetrics _isolated_metrics()
tuple[ServerState, McpAuditLogger] _make_write_state(Path tmp_path, str sandbox_output="Done.", int sandbox_exit_code=0, str profile="default", bool safe_mode=False, int rate_limit=30, str session_id="hitl-test-session")
dict _last_audit_record(McpAuditLogger logger)