8Integration tests for the Phase 5B HITL (Human-in-the-Loop) pipeline.
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.
25 L3 — assertion details
30- Tests use mock sandbox and ``tmp_path``; no real subprocess is
34from __future__
import annotations
37from pathlib
import Path
38from unittest.mock
import MagicMock, patch
41from pydantic
import ValidationError
60 """McpMetrics on a private CollectorRegistry — avoids
61 ``Duplicated timeseries`` collisions across tests."""
62 if not PROMETHEUS_AVAILABLE:
64 from prometheus_client
import CollectorRegistry
65 return McpMetrics(registry=CollectorRegistry())
70 sandbox_output: str =
"Done.",
71 sandbox_exit_code: int = 0,
72 profile: str =
"default",
73 safe_mode: bool =
False,
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'
81 mock_sandbox = MagicMock(spec=SandboxExecutor)
83 exit_code=sandbox_exit_code,
84 stdout=sandbox_output,
87 audit_log = tmp_path /
"audit.log"
90 config.safe_mode = safe_mode
92 project_root=tmp_path,
94 policy=
McpPolicy(profile=profile, safe_mode=safe_mode),
97 audit_logger=audit_logger,
98 rate_limiter=
RateLimiter(limit_per_minute=rate_limit),
101 session_id=session_id,
103 return state, audit_logger
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])
118 @pytest.mark.parametrize(
"tool_name,extra_args", [
120 (
"oct_scaffold", {
"name":
"myproject"}),
122 (
"oct_export_source", {}),
123 (
"oct_install_hooks", {}),
124 (
"oct_git_hooks_install", {}),
125 (
"oct_git_init", {}),
126 (
"oct_git_changelog", {}),
129 self, tool_name, extra_args, tmp_path: Path
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}"
143 _server_module._server_state = state
144 _run_tool_write(
"oct_clean", {
"confirm":
False})
146 assert record[
"decision"] ==
"requires_confirmation"
151 _server_module._server_state = state
152 _run_tool_write(
"oct_clean", {
"confirm":
False})
154 state.executor._sandbox.run.assert_not_called()
159 _server_module._server_state = state
160 result = _run_tool_write(
"oct_format", {
"confirm":
False,
"apply":
False})
161 assert result.startswith(HITL_SENTINEL)
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)
179 _server_module._server_state = state
180 result = _run_tool_write(
"oct_clean", {
"confirm":
True,
"dry_run":
True})
181 assert result ==
"Files cleaned."
186 _server_module._server_state = state
187 _run_tool_write(
"oct_clean", {
"confirm":
True,
"dry_run":
True})
189 assert record[
"decision"] ==
"allowed"
194 _server_module._server_state = state
195 _run_tool_write(
"oct_clean", {
"confirm":
True})
196 state.executor._sandbox.run.assert_called_once()
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()
213 """E2E: apply=False, confirm=True → executor sees --dry-run."""
216 _server_module._server_state = state
217 _run_tool_write(
"oct_format", {
"confirm":
True,
"apply":
False})
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
225 """E2E: apply=True, confirm=True → executor sees --fix."""
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
236 """E2E: confirm=False → sandbox never called."""
239 _server_module._server_state = state
240 _run_tool_write(
"oct_format", {
"confirm":
False,
"apply":
True})
241 state.executor._sandbox.run.assert_not_called()
252 _server_module._server_state = state
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"
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"
278 _server_module._server_state = state
279 result = _run_tool_write(
"oct_format", {
"confirm":
True,
"apply":
True})
280 assert "[oct-mcp error]" in result
282 assert record[
"decision"] ==
"denied"
283 assert record[
"policy_rule"] ==
"strict_no_destructive"
284 state.executor._sandbox.run.assert_not_called()
289 _server_module._server_state = state
290 result = _run_tool_write(
"oct_clean", {
"confirm":
True})
291 assert "[oct-mcp error]" in result
293 assert record[
"decision"] ==
"denied"
294 state.executor._sandbox.run.assert_not_called()
305 _server_module._server_state = state
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()
313 _server_module._server_state = state
314 _run_tool_write(
"oct_git_commit", {
"no_verify":
True,
"confirm":
True})
316 assert record[
"decision"] ==
"denied"
321 _server_module._server_state = state
323 result = _run_tool_write(
"oct_new", {
"confirm":
True})
324 assert "[oct-mcp error]" in result.lower()
or "validation" in result.lower()
333 """Rate limiter counts both the first (confirmation) and second (execute) calls."""
336 _server_module._server_state = state
339 r1 = _run_tool_write(
"oct_clean", {
"confirm":
False})
340 assert r1.startswith(HITL_SENTINEL)
343 r2 = _run_tool_write(
"oct_clean", {
"confirm":
True})
344 assert not r2.startswith(HITL_SENTINEL)
347 r3 = _run_tool_write(
"oct_clean", {
"confirm":
True})
348 assert "rate limit" in r3.lower()
or "[oct-mcp error]" in r3.lower()
357 """T-06: no_verify in git commit → ValidationError → error response."""
360 _server_module._server_state = state
361 result = _run_tool_write(
363 {
"message":
"feat: test",
"confirm":
True,
"no_verify":
True},
366 assert "[oct-mcp error]" in result
or "validation" in result.lower()
367 state.executor._sandbox.run.assert_not_called()
370 """T-06b: write tools in strict profile → denied."""
373 _server_module._server_state = state
374 for tool_name, extra
in [
375 (
"oct_format", {
"apply":
True}),
377 (
"oct_git_commit", {
"message":
"feat: test"}),
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_first_call_audit_then_second_call_audit(self, Path tmp_path)
test_format_first_call_requires_confirmation(self, Path tmp_path)
test_first_call_writes_requires_confirmation_audit(self, Path tmp_path)
test_first_call_sandbox_not_called(self, Path tmp_path)
test_git_commit_first_call_requires_confirmation(self, Path tmp_path)
test_first_call_returns_confirmation_string(self, tool_name, extra_args, Path tmp_path)
test_second_call_sandbox_called(self, Path tmp_path)
test_second_call_executes_and_returns_output(self, Path tmp_path)
test_git_commit_second_call_executes(self, Path tmp_path)
test_second_call_writes_allowed_audit(self, Path tmp_path)
test_safe_mode_denies_before_hitl(self, 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)
test_t06b_strict_profile_blocks_write(self, Path tmp_path)
test_rate_limit_counts_both_calls(self, Path tmp_path)
test_validation_failure_returns_error(self, Path tmp_path)
test_validation_failure_writes_audit(self, Path tmp_path)
test_missing_required_field_returns_error(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)