Option C Tools
Loading...
Searching...
No Matches
test_mcp_safety.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# tests/tests_mcp/test_mcp_safety.py
4
5"""
6Purpose
7-------
8Regression tests for :mod:`oct.mcp.safety` — ``SafetyGate`` HITL
9enforcement.
10
11Responsibilities
12----------------
13- Verify ``SafetyGate.check()`` HITL rules (rules 1-5).
14- Verify oct_format double guard (apply + confirm).
15- Verify non-destructive tools always proceed; destructive tools
16 require confirmation.
17- Verify ``_format_confirmation_request`` sentinel and content.
18- Verify T-08: no stateful replay surface (each call independent).
19
20Diagnostics
21-----------
22Domain: MCP-TESTS
23Levels:
24 L2 — test lifecycle
25 L3 — assertion details
26 L4 — deep tracing
27
28Contracts
29---------
30- Tests are in-memory safety checks; no subprocess or filesystem writes.
31"""
32
33from __future__ import annotations
34
35import pytest
36
37from oct.mcp.config import McpConfig
38from oct.mcp.policy import TOOL_MANIFEST, ToolSpec
39from oct.mcp.safety import (
40 HITL_SENTINEL,
41 SafetyDecision,
42 SafetyGate,
43 _format_confirmation_request,
44 _TOOL_RISK_DESCRIPTIONS,
45)
46
47
48# -------------------------------------------------------------------
49# Fixtures
50# -------------------------------------------------------------------
51
52@pytest.fixture
53def config():
54 return McpConfig()
55
56
57@pytest.fixture
58def gate(config):
59 return SafetyGate(config)
60
61
63 return ToolSpec(cli="lint", permissions=frozenset({"read"}), destructive=False, auto_approve=True)
64
65
66def _write_spec(tool: str = "oct_format"):
67 return TOOL_MANIFEST[tool]
68
69
70# -------------------------------------------------------------------
71# Rule 1: non-destructive tools always proceed
72# -------------------------------------------------------------------
73
77 d = gate.check("oct_lint", spec, {})
78 assert d.proceed is True
79 assert d.requires_confirmation is False
80 assert d.dry_run is False
81
84 d = gate.check("oct_lint", spec, {"confirm": False})
85 assert d.proceed is True
86
89 d = gate.check("oct_health", spec, {"confirm": True})
90 assert d.proceed is True
91
93 phase5a = [
94 "oct_lint", "oct_health", "oct_skeleton", "oct_deps",
95 "oct_test", "oct_typecheck", "oct_diag", "oct_git_status", "oct_git_check",
96 ]
97 for tool_name in phase5a:
98 spec = TOOL_MANIFEST[tool_name]
99 d = gate.check(tool_name, spec, {})
100 assert d.proceed is True, f"{tool_name} should proceed"
101 assert d.dry_run is False
102
103
104# -------------------------------------------------------------------
105# Rule 2: destructive + confirm=False → requires_confirmation
106# -------------------------------------------------------------------
107
109 @pytest.mark.parametrize("tool_name", [
110 "oct_format", "oct_docs", "oct_export_source", "oct_new",
111 "oct_scaffold", "oct_clean", "oct_install_hooks",
112 "oct_git_commit", "oct_git_hooks_install", "oct_git_init",
113 "oct_git_changelog",
114 ])
116 spec = TOOL_MANIFEST[tool_name]
117 args = {"confirm": False}
118 # Provide required fields for each tool
119 if tool_name == "oct_new":
120 args["path"] = "src/foo.py"
121 if tool_name == "oct_scaffold":
122 args["name"] = "myproject"
123 if tool_name == "oct_git_commit":
124 args["message"] = "feat: add feature"
125 d = gate.check(tool_name, spec, args)
126 assert d.proceed is False, f"{tool_name} should not proceed without confirm"
127 assert d.requires_confirmation is True
128 assert d.dry_run is False
129
131 spec = TOOL_MANIFEST["oct_clean"]
132 d = gate.check("oct_clean", spec, {}) # no confirm key
133 assert d.proceed is False
134 assert d.requires_confirmation is True
135
137 spec = TOOL_MANIFEST["oct_git_init"]
138 d = gate.check("oct_git_init", spec, {"confirm": False})
139 assert d.proceed is False
140 assert d.requires_confirmation is True
141
142
143# -------------------------------------------------------------------
144# Rule 5: destructive + confirm=True → proceed (non-format tools)
145# -------------------------------------------------------------------
146
148 @pytest.mark.parametrize("tool_name", [
149 "oct_docs", "oct_export_source", "oct_new",
150 "oct_scaffold", "oct_clean", "oct_install_hooks",
151 "oct_git_commit", "oct_git_hooks_install", "oct_git_init",
152 "oct_git_changelog",
153 ])
154 def test_write_tools_proceed_with_confirm(self, gate, tool_name):
155 spec = TOOL_MANIFEST[tool_name]
156 args: dict = {"confirm": True}
157 if tool_name == "oct_new":
158 args["path"] = "src/foo.py"
159 if tool_name == "oct_scaffold":
160 args["name"] = "myproject"
161 if tool_name == "oct_git_commit":
162 args["message"] = "feat: add feature"
163 d = gate.check(tool_name, spec, args)
164 assert d.proceed is True, f"{tool_name} should proceed with confirm=True"
165 assert d.requires_confirmation is False
166 assert d.dry_run is False
167
168
169# -------------------------------------------------------------------
170# Rules 3 & 4: oct_format double guard
171# -------------------------------------------------------------------
172
175 spec = TOOL_MANIFEST["oct_format"]
176 d = gate.check("oct_format", spec, {"confirm": False, "apply": False})
177 assert d.proceed is False
178 assert d.requires_confirmation is True
179
181 spec = TOOL_MANIFEST["oct_format"]
182 d = gate.check("oct_format", spec, {"confirm": True, "apply": False})
183 assert d.proceed is True
184 assert d.dry_run is True
185 assert d.requires_confirmation is False
186
188 spec = TOOL_MANIFEST["oct_format"]
189 d = gate.check("oct_format", spec, {"confirm": True, "apply": True})
190 assert d.proceed is True
191 assert d.dry_run is False
192 assert d.requires_confirmation is False
193
195 spec = TOOL_MANIFEST["oct_format"]
196 # apply not provided → defaults to False → dry_run
197 d = gate.check("oct_format", spec, {"confirm": True})
198 assert d.proceed is True
199 assert d.dry_run is True
200
201
202# -------------------------------------------------------------------
203# _format_confirmation_request
204# -------------------------------------------------------------------
205
208 spec = TOOL_MANIFEST["oct_format"]
209 result = _format_confirmation_request("oct_format", spec, {"apply": False, "confirm": False})
210 assert result.startswith(HITL_SENTINEL)
211
213 spec = TOOL_MANIFEST["oct_clean"]
214 result = _format_confirmation_request("oct_clean", spec, {"dry_run": True})
215 assert "oct_clean" in result
216
218 spec = TOOL_MANIFEST["oct_scaffold"]
219 result = _format_confirmation_request("oct_scaffold", spec, {"name": "myproject"})
220 assert "confirm=True" in result
221
223 spec = TOOL_MANIFEST["oct_git_commit"]
224 result = _format_confirmation_request(
225 "oct_git_commit", spec, {"message": "feat: test"}
226 )
227 assert "commit" in result.lower()
228
230 spec = TOOL_MANIFEST["oct_format"]
231 result = _format_confirmation_request(
232 "oct_format", spec, {"confirm": False, "apply": False, "paths": []}
233 )
234 # confirm itself should not appear in the args display section
235 # (the sentinel and re-call instruction contain "confirm" but the args section should not)
236 # At minimum, result must start with sentinel
237 assert result.startswith(HITL_SENTINEL)
238
240 for tool_name in _TOOL_RISK_DESCRIPTIONS:
241 assert _TOOL_RISK_DESCRIPTIONS[tool_name], f"{tool_name} risk desc should not be empty"
242
244 spec = ToolSpec(cli="unknown", permissions=frozenset({"write"}), destructive=True)
245 result = _format_confirmation_request("oct_mystery", spec, {})
246 assert result.startswith(HITL_SENTINEL)
247 assert "oct_mystery" in result
248
249
250# -------------------------------------------------------------------
251# SafetyDecision dataclass
252# -------------------------------------------------------------------
253
256 d = SafetyDecision(proceed=True, requires_confirmation=False, dry_run=False, reason="ok")
257 assert d.proceed is True
258 assert d.requires_confirmation is False
259 assert d.dry_run is False
260 assert d.reason == "ok"
261
263 d = SafetyDecision(proceed=False, requires_confirmation=True, dry_run=False, reason="need confirm")
264 assert d.proceed is False
265 assert d.requires_confirmation is True
266
267
268# -------------------------------------------------------------------
269# T-08: no stateful replay surface
270# -------------------------------------------------------------------
271
274 """T-08: successive calls with same args produce same result (no state)."""
275 spec = TOOL_MANIFEST["oct_clean"]
276 args = {"confirm": True, "dry_run": True}
277 d1 = gate.check("oct_clean", spec, args)
278 d2 = gate.check("oct_clean", spec, args)
279 assert d1.proceed == d2.proceed
280 assert d1.requires_confirmation == d2.requires_confirmation
281
283 """Calling with confirm=False then confirm=True works correctly (no stale state)."""
284 spec = TOOL_MANIFEST["oct_git_init"]
285 d1 = gate.check("oct_git_init", spec, {"confirm": False})
286 assert d1.proceed is False
287 d2 = gate.check("oct_git_init", spec, {"confirm": True})
288 assert d2.proceed is True
289
291 """Two SafetyGate instances with the same config are both stateless."""
292 cfg = McpConfig()
293 gate1 = SafetyGate(cfg)
294 gate2 = SafetyGate(cfg)
295 spec = TOOL_MANIFEST["oct_scaffold"]
296 args = {"confirm": True, "name": "myproject"}
297 d1 = gate1.check("oct_scaffold", spec, args)
298 d2 = gate2.check("oct_scaffold", spec, args)
299 assert d1.proceed == d2.proceed
_write_spec(str tool="oct_format")