Option C Tools
Loading...
Searching...
No Matches
test_mcp_hardening.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# tests/tests_mcp/test_mcp_hardening.py
4
5"""
6Purpose
7-------
8Phase 5C programmatic sign-off of blueprint section 12 hardening
9checklist.
10
11Responsibilities
12----------------
13- Verify gateway controls (rate limit, tool manifest).
14- Verify input validation (extra="forbid", path traversal, shell
15 metacharacters).
16- Verify policy enforcement (write-tool confirm, oct_format double-guard,
17 strict profile).
18- Verify sandbox constraints (no shell=True, cwd pinning, env
19 sanitisation, timeout, output cap).
20- Verify redaction (secrets, project path, truncation).
21- Verify audit records (JSONL per call, session/request IDs).
22
23Diagnostics
24-----------
25Domain: MCP-TESTS
26Levels:
27 L2 — test lifecycle
28 L3 — assertion details
29 L4 — deep tracing
30
31Contracts
32---------
33- Tests use mock sandbox and ``tmp_path``; no real subprocess is
34 spawned.
35"""
36
37from __future__ import annotations
38
39import json
40from pathlib import Path
41from unittest.mock import MagicMock, patch
42
43import pytest
44from pydantic import ConfigDict, ValidationError
45
46from oct.mcp.audit import McpAuditLogger
47from oct.mcp.config import McpConfig
48from oct.mcp.executor import ToolExecutor
49from oct.mcp.metrics import McpMetrics, PROMETHEUS_AVAILABLE
50from oct.mcp.policy import McpPolicy, TOOL_MANIFEST
51from oct.mcp.redactor import Redactor
52from oct.mcp.safety import HITL_SENTINEL, SafetyGate
53from oct.mcp.sandbox import SandboxExecutor, SandboxResult, _sanitise_env
54from oct.mcp.server import RateLimiter, ServerState
55from oct.mcp.tools import _run_tool, _run_tool_write
56from oct.mcp.validator import _TOOL_MODEL_MAP, validate_tool_args
57
58
59# ---------------------------------------------------------------------------
60# Helpers
61# ---------------------------------------------------------------------------
62
63def _isolated_metrics() -> McpMetrics:
64 """McpMetrics on a private CollectorRegistry — avoids
65 ``Duplicated timeseries`` collisions across tests."""
66 if not PROMETHEUS_AVAILABLE:
67 return McpMetrics()
68 from prometheus_client import CollectorRegistry
69 return McpMetrics(registry=CollectorRegistry())
70
71
73 tmp_path: Path,
74 sandbox_output: str = '{"exit_code": 0}',
75 sandbox_exit_code: int = 0,
76 profile: str = "default",
77 safe_mode: bool = False,
78 rate_limit: int = 30,
79) -> tuple[ServerState, McpAuditLogger]:
80 (tmp_path / "pyproject.toml").write_text(
81 '[project]\nname="test"\nversion="0.1"\n'
82 )
83 mock_sandbox = MagicMock(spec=SandboxExecutor)
84 mock_sandbox.run.return_value = SandboxResult(
85 exit_code=sandbox_exit_code, stdout=sandbox_output, 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="hardening-test",
102 )
103 return state, audit_logger
104
105
106def _last_audit(logger: McpAuditLogger) -> dict:
107 assert logger.current_path is not None
108 lines = logger.current_path.read_text(encoding="utf-8").strip().splitlines()
109 return json.loads(lines[-1])
110
111
112# ---------------------------------------------------------------------------
113# §12.1 Gateway
114# ---------------------------------------------------------------------------
115
117 def test_unknown_tool_denied_by_policy(self, tmp_path: Path):
118 """Deny-by-default: unknown tool names are rejected by McpPolicy."""
119 policy = McpPolicy(profile="default", safe_mode=False)
120 decision = policy.check("evil_unknown_tool")
121 assert not decision.allowed
122
124 """RateLimiter.consume() returns False after token bucket is drained."""
125 limiter = RateLimiter(limit_per_minute=2)
126 limiter.consume()
127 limiter.consume()
128 assert not limiter.consume()
129
131 limiter = RateLimiter(limit_per_minute=10)
132 assert limiter.consume() is True
133
135 state, _ = _make_state(tmp_path)
136 import oct.mcp.server as _server_module
137 _server_module._server_state = state
138 result = _run_tool("oct_evil_unknown_tool", {})
139 assert "[oct-mcp error]" in result
140 assert isinstance(result, str)
141
142
143# ---------------------------------------------------------------------------
144# §12.2 Input Validation
145# ---------------------------------------------------------------------------
146
149 """All 20 tool Pydantic models must have model_config = ConfigDict(extra='forbid')."""
150 errors = []
151 for tool_name, model_cls in _TOOL_MODEL_MAP.items():
152 cfg = getattr(model_cls, "model_config", {})
153 extra = cfg.get("extra", None)
154 if extra != "forbid":
155 errors.append(f"{tool_name}: model_config.extra={extra!r} (expected 'forbid')")
156 assert not errors, "\n".join(errors)
157
159 """Passing an undeclared field should raise ValidationError for every model."""
160 errors = []
161 for tool_name, model_cls in _TOOL_MODEL_MAP.items():
162 try:
163 model_cls.model_validate({"__evil_field__": "injected"})
164 errors.append(f"{tool_name}: did not raise ValidationError for extra field")
165 except ValidationError:
166 pass # expected
167 assert not errors, "\n".join(errors)
168
170 with pytest.raises((ValidationError, ValueError)):
171 validate_tool_args(
172 "oct_lint",
173 {"paths": ["../../etc/passwd"]},
174 project_root=tmp_path,
175 )
176
178 with pytest.raises((ValidationError, ValueError)):
179 validate_tool_args(
180 "oct_lint",
181 {"paths": ["src; rm -rf /"]},
182 project_root=tmp_path,
183 )
184
186 with pytest.raises((ValidationError, ValueError)):
187 validate_tool_args(
188 "oct_lint",
189 {"paths": ["src | cat /etc/passwd"]},
190 project_root=tmp_path,
191 )
192
193 def test_no_verify_rejected_for_git_commit(self, tmp_path: Path):
194 """T-06: no_verify is not a declared field → ValidationError."""
195 with pytest.raises(ValidationError):
196 validate_tool_args(
197 "oct_git_commit",
198 {"message": "feat: test", "no_verify": True},
199 project_root=tmp_path,
200 )
201
202 def test_format_jobs_field_removed(self, tmp_path: Path):
203 """OI-502: FormatArgs.jobs was removed (CLI has no --jobs flag)."""
204 with pytest.raises(ValidationError):
205 validate_tool_args(
206 "oct_format",
207 {"jobs": 1},
208 project_root=tmp_path,
209 )
210
211 def test_missing_required_field_rejected(self, tmp_path: Path):
212 """oct_new requires 'path'; omitting it raises ValidationError."""
213 with pytest.raises((ValidationError, ValueError)):
214 validate_tool_args("oct_new", {}, project_root=tmp_path)
215
216
217# ---------------------------------------------------------------------------
218# §12.3 Policy enforcement
219# ---------------------------------------------------------------------------
220
222 _write_tools = [
223 ("oct_format", {"apply": True}),
224 ("oct_clean", {}),
225 ("oct_git_commit", {"message": "feat: test"}),
226 ("oct_docs", {}),
227 ("oct_scaffold", {"name": "myproject"}),
228 ("oct_export_source", {}),
229 ("oct_install_hooks", {}),
230 ("oct_git_hooks_install", {}),
231 ("oct_git_init", {}),
232 ("oct_git_changelog", {}),
233 ]
234
236 """All Phase 5B write tools return HITL_SENTINEL when confirm=False."""
237 state, _ = _make_state(tmp_path)
238 import oct.mcp.server as _server_module
239 _server_module._server_state = state
240
241 errors = []
242 for tool_name, extra in self._write_tools:
243 result = _run_tool_write(tool_name, {"confirm": False, **extra})
244 if not result.startswith(HITL_SENTINEL):
245 errors.append(f"{tool_name}: expected HITL sentinel, got: {result!r}")
246 assert not errors, "\n".join(errors)
247
249 """oct_format with apply=False, confirm=True → --dry-run flag sent to executor."""
250 state, _ = _make_state(tmp_path, sandbox_output="[DRY RUN]")
251 import oct.mcp.server as _server_module
252 _server_module._server_state = state
253 _run_tool_write("oct_format", {"confirm": True, "apply": False})
254 call_args = state.executor._sandbox.run.call_args
255 cmd = call_args[1]["cmd"] if "cmd" in call_args[1] else call_args[0][0]
256 assert "--dry-run" in cmd
257 assert "--fix" not in cmd
258
260 """oct_format with apply=True, confirm=True → --fix flag sent to executor."""
261 state, _ = _make_state(tmp_path, sandbox_output="Fixed.")
262 import oct.mcp.server as _server_module
263 _server_module._server_state = state
264 _run_tool_write("oct_format", {"confirm": True, "apply": True})
265 call_args = state.executor._sandbox.run.call_args
266 cmd = call_args[1]["cmd"] if "cmd" in call_args[1] else call_args[0][0]
267 assert "--fix" in cmd
268 assert "--dry-run" not in cmd
269
271 """In strict profile, all write tools are denied before the HITL gate."""
272 state, _ = _make_state(tmp_path, profile="strict")
273 import oct.mcp.server as _server_module
274 _server_module._server_state = state
275
276 errors = []
277 for tool_name, extra in self._write_tools:
278 result = _run_tool_write(tool_name, {"confirm": True, **extra})
279 if "[oct-mcp error]" not in result:
280 errors.append(f"{tool_name} should be denied in strict profile")
281 assert not errors, "\n".join(errors)
282
284 """All 11 Phase 5B tools must have destructive=True in TOOL_MANIFEST."""
285 write_tools = [
286 "oct_format", "oct_docs", "oct_export_source", "oct_new", "oct_scaffold",
287 "oct_clean", "oct_install_hooks", "oct_git_commit",
288 "oct_git_hooks_install", "oct_git_init", "oct_git_changelog",
289 ]
290 errors = []
291 for name in write_tools:
292 spec = TOOL_MANIFEST.get(name)
293 if spec is None:
294 errors.append(f"{name}: not in TOOL_MANIFEST")
295 elif not spec.destructive:
296 errors.append(f"{name}: destructive=False (expected True)")
297 assert not errors, "\n".join(errors)
298
300 """All Phase 5B write tools must have auto_approve=False in TOOL_MANIFEST."""
301 write_tools = [
302 "oct_format", "oct_docs", "oct_export_source", "oct_new", "oct_scaffold",
303 "oct_clean", "oct_install_hooks", "oct_git_commit",
304 "oct_git_hooks_install", "oct_git_init", "oct_git_changelog",
305 ]
306 errors = []
307 for name in write_tools:
308 spec = TOOL_MANIFEST.get(name)
309 if spec and spec.auto_approve:
310 errors.append(f"{name}: auto_approve=True (expected False)")
311 assert not errors, "\n".join(errors)
312
313
314# ---------------------------------------------------------------------------
315# §12.4 Sandbox
316# ---------------------------------------------------------------------------
317
320 """Static AST check: no actual shell=True keyword arg in any oct/mcp/*.py call."""
321 import ast as _ast
322 mcp_dir = Path(__file__).parent.parent.parent / "oct" / "mcp"
323 violations = []
324 for py_file in sorted(mcp_dir.glob("*.py")):
325 try:
326 tree = _ast.parse(py_file.read_text(encoding="utf-8"))
327 except SyntaxError:
328 continue
329 for node in _ast.walk(tree):
330 if isinstance(node, _ast.Call):
331 for kw in node.keywords:
332 if (
333 kw.arg == "shell"
334 and isinstance(kw.value, _ast.Constant)
335 and kw.value.value is True
336 ):
337 violations.append(f"{py_file.name}:{node.lineno}")
338 assert not violations, (
339 "shell=True keyword arg found in MCP source calls:\n"
340 + "\n".join(violations)
341 )
342
344 """SandboxExecutor must pin cwd to the project root."""
345 mock_backend = MagicMock()
346 mock_backend.run.return_value = (0, "ok", "")
347 from oct.mcp.sandbox import SandboxExecutor
348 executor = SandboxExecutor(backend=mock_backend)
349 executor.run(cmd=["echo"], cwd=tmp_path, timeout=10)
350 call_kwargs = mock_backend.run.call_args[1]
351 assert call_kwargs["cwd"] == tmp_path
352
353 def test_sanitised_env_excludes_pythonpath(self, tmp_path: Path):
354 """PYTHONPATH must be stripped from the sandbox environment."""
355 with patch("os.environ", {**__import__("os").environ, "PYTHONPATH": "/evil"}):
356 env = _sanitise_env(tmp_path)
357 assert "PYTHONPATH" not in env
358
359 def test_sanitised_env_excludes_api_key(self, tmp_path: Path):
360 """API_KEY (secret substring) must be stripped from the sandbox environment."""
361 with patch("os.environ", {**__import__("os").environ, "API_KEY": "exposed"}):
362 env = _sanitise_env(tmp_path)
363 assert "API_KEY" not in env
364
366 """SandboxExecutor catches TimeoutExpired and sets timed_out=True."""
367 import subprocess
368 mock_backend = MagicMock()
369 mock_backend.run.side_effect = subprocess.TimeoutExpired(cmd="echo", timeout=1)
370 from oct.mcp.sandbox import SandboxExecutor
371 executor = SandboxExecutor(backend=mock_backend)
372 result = executor.run(cmd=["echo"], cwd=tmp_path, timeout=1)
373 assert result.timed_out is True
374 assert result.exit_code == 1
375
377 """SandboxResult.output_truncated is True when output exceeds max_output_bytes."""
378 mock_backend = MagicMock()
379 mock_backend.run.return_value = (0, "x" * 5000, "")
380 from oct.mcp.sandbox import SandboxExecutor
381 executor = SandboxExecutor(backend=mock_backend)
382 result = executor.run(cmd=["echo"], cwd=tmp_path, timeout=10, max_output_bytes=100)
383 assert result.output_truncated is True
384
385
386# ---------------------------------------------------------------------------
387# §12.5 Redaction
388# ---------------------------------------------------------------------------
389
391 def test_api_key_in_output_redacted(self, tmp_path: Path):
392 """T-03: Secrets in sandbox stdout must be redacted before returning."""
393 state, _ = _make_state(
394 tmp_path, sandbox_output="Running lint\nAPI_KEY=s3cr3t_value\nDone"
395 )
396 import oct.mcp.server as _server_module
397 _server_module._server_state = state
398 result = _run_tool("oct_lint", {})
399 assert "s3cr3t_value" not in result
400
402 """T-07: Absolute project path in output is anonymised."""
403 state, _ = _make_state(
404 tmp_path, sandbox_output=f"Error in {tmp_path}/src/module.py line 42"
405 )
406 import oct.mcp.server as _server_module
407 _server_module._server_state = state
408 result = _run_tool("oct_lint", {})
409 assert str(tmp_path) not in result
410 assert "[PROJECT]" in result
411
413 """Output > max_output_bytes is truncated (output_truncated flag set)."""
414 large = "safe_content " * 100_000 # ~1.3 MB
415 mock_backend = MagicMock()
416 mock_backend.run.return_value = (0, large, "")
417 from oct.mcp.sandbox import SandboxExecutor
418 executor = SandboxExecutor(backend=mock_backend)
419 result = executor.run(cmd=["echo"], cwd=tmp_path, timeout=10,
420 max_output_bytes=1_048_576)
421 # If output exceeds limit, it should be truncated
422 total_bytes = len((result.stdout + result.stderr).encode("utf-8", errors="replace"))
423 assert total_bytes <= 1_048_576
424
425
426# ---------------------------------------------------------------------------
427# §12.6 Audit
428# ---------------------------------------------------------------------------
429
432 """Every _run_tool() call must write exactly one JSONL audit record."""
433 state, logger = _make_state(tmp_path)
434 import oct.mcp.server as _server_module
435 _server_module._server_state = state
436 _run_tool("oct_lint", {})
437 assert logger.current_path is not None
438 lines = logger.current_path.read_text(encoding="utf-8").strip().splitlines()
439 assert len(lines) == 1
440 record = json.loads(lines[0])
441 assert record["tool"] == "oct_lint"
442
444 """_run_tool() returns a plain string, not the audit record dict."""
445 state, logger = _make_state(tmp_path, sandbox_output="lint output")
446 import oct.mcp.server as _server_module
447 _server_module._server_state = state
448 result = _run_tool("oct_lint", {})
449 assert isinstance(result, str)
450 # The result should NOT contain audit-record keys
451 assert '"session_id"' not in result
452 assert '"request_id"' not in result
453 assert '"policy_rule"' not in result
454
455 def test_audit_record_has_session_id(self, tmp_path: Path):
456 state, logger = _make_state(tmp_path)
457 import oct.mcp.server as _server_module
458 _server_module._server_state = state
459 _run_tool("oct_lint", {})
460 record = _last_audit(logger)
461 assert record.get("session_id") == "hardening-test"
462
463 def test_audit_record_has_request_id(self, tmp_path: Path):
464 state, logger = _make_state(tmp_path)
465 import oct.mcp.server as _server_module
466 _server_module._server_state = state
467 _run_tool("oct_lint", {})
468 record = _last_audit(logger)
469 assert record.get("request_id") # non-empty UUID string
470
472 state, logger = _make_state(tmp_path, safe_mode=True)
473 import oct.mcp.server as _server_module
474 _server_module._server_state = state
475 _run_tool("oct_lint", {})
476 record = _last_audit(logger)
477 assert record["decision"] == "denied"
478
480 state, logger = _make_state(tmp_path)
481 import oct.mcp.server as _server_module
482 _server_module._server_state = state
483 _run_tool("oct_lint", {"paths": ["../../etc/passwd"]})
484 record = _last_audit(logger)
485 assert record["decision"] == "denied"
486 assert record["policy_rule"] == "validation_failure"
487
489 state, logger = _make_state(tmp_path)
490 import oct.mcp.server as _server_module
491 _server_module._server_state = state
492 _run_tool_write("oct_clean", {"confirm": False})
493 record = _last_audit(logger)
494 assert record["decision"] == "requires_confirmation"
495
496
497# ---------------------------------------------------------------------------
498# §12.7 General / Miscellaneous
499# ---------------------------------------------------------------------------
500
503 """confirm: bool = False must be the default on all write tool models."""
504 write_tools = [
505 "oct_format", "oct_docs", "oct_export_source",
506 "oct_scaffold", "oct_clean", "oct_install_hooks",
507 "oct_git_commit", "oct_git_hooks_install", "oct_git_init",
508 "oct_git_changelog",
509 ]
510 errors = []
511 for tool_name in write_tools:
512 model_cls = _TOOL_MODEL_MAP.get(tool_name)
513 if model_cls is None:
514 errors.append(f"{tool_name}: not in _TOOL_MODEL_MAP")
515 continue
516 # Instantiate with bare minimum required fields (none besides confirm)
517 # and check confirm defaults to False.
518 try:
519 # Find minimum required fields by attempting empty construction.
520 # We only care about the 'confirm' default here.
521 fields = model_cls.model_fields
522 if "confirm" not in fields:
523 errors.append(f"{tool_name}: no 'confirm' field")
524 continue
525 default = fields["confirm"].default
526 if default is not False:
527 errors.append(f"{tool_name}: confirm default={default!r} (expected False)")
528 except Exception as exc:
529 errors.append(f"{tool_name}: error checking confirm default: {exc}")
530 assert not errors, "\n".join(errors)
531
533 """TOOL_MANIFEST must contain exactly 20 tools (9 read + 11 write)."""
534 assert len(TOOL_MANIFEST) == 20
535
537 """_TOOL_MODEL_MAP must contain exactly 20 tools."""
538 assert len(_TOOL_MODEL_MAP) == 20
539
541 """Every key in TOOL_MANIFEST must also be in _TOOL_MODEL_MAP and vice versa."""
542 manifest_keys = set(TOOL_MANIFEST.keys())
543 model_keys = set(_TOOL_MODEL_MAP.keys())
544 assert manifest_keys == model_keys, (
545 f"Mismatch — manifest only: {manifest_keys - model_keys}, "
546 f"model_map only: {model_keys - manifest_keys}"
547 )
548
550 """All 9 Phase 5A read-only tools must have destructive=False."""
551 read_tools = [
552 "oct_lint", "oct_health", "oct_skeleton", "oct_deps",
553 "oct_test", "oct_typecheck", "oct_diag",
554 "oct_git_status", "oct_git_check",
555 ]
556 errors = []
557 for name in read_tools:
558 spec = TOOL_MANIFEST.get(name)
559 if spec and spec.destructive:
560 errors.append(f"{name}: destructive=True (expected False)")
561 assert not errors, "\n".join(errors)
562
564 """All 9 Phase 5A read-only tools must have auto_approve=True."""
565 read_tools = [
566 "oct_lint", "oct_health", "oct_skeleton", "oct_deps",
567 "oct_test", "oct_typecheck", "oct_diag",
568 "oct_git_status", "oct_git_check",
569 ]
570 errors = []
571 for name in read_tools:
572 spec = TOOL_MANIFEST.get(name)
573 if spec and not spec.auto_approve:
574 errors.append(f"{name}: auto_approve=False (expected True)")
575 assert not errors, "\n".join(errors)
test_hitl_confirmation_writes_requires_confirmation_record(self, Path tmp_path)
test_unknown_tool_error_returned_not_raised(self, Path tmp_path)
dict _last_audit(McpAuditLogger logger)
tuple[ServerState, McpAuditLogger] _make_state(Path tmp_path, str sandbox_output='{"exit_code":0}', int sandbox_exit_code=0, str profile="default", bool safe_mode=False, int rate_limit=30)