8Phase 5C programmatic sign-off of blueprint section 12 hardening
13- Verify gateway controls (rate limit, tool manifest).
14- Verify input validation (extra="forbid", path traversal, shell
16- Verify policy enforcement (write-tool confirm, oct_format double-guard,
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).
28 L3 — assertion details
33- Tests use mock sandbox and ``tmp_path``; no real subprocess is
37from __future__
import annotations
40from pathlib
import Path
41from unittest.mock
import MagicMock, patch
44from pydantic
import ConfigDict, ValidationError
53from oct.mcp.sandbox import SandboxExecutor, SandboxResult, _sanitise_env
64 """McpMetrics on a private CollectorRegistry — avoids
65 ``Duplicated timeseries`` collisions across tests."""
66 if not PROMETHEUS_AVAILABLE:
68 from prometheus_client
import CollectorRegistry
69 return McpMetrics(registry=CollectorRegistry())
74 sandbox_output: str =
'{"exit_code": 0}',
75 sandbox_exit_code: int = 0,
76 profile: str =
"default",
77 safe_mode: bool =
False,
79) -> tuple[ServerState, McpAuditLogger]:
80 (tmp_path /
"pyproject.toml").write_text(
81 '[project]\nname="test"\nversion="0.1"\n'
83 mock_sandbox = MagicMock(spec=SandboxExecutor)
85 exit_code=sandbox_exit_code, stdout=sandbox_output, stderr=
""
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=
"hardening-test",
103 return state, audit_logger
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])
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
124 """RateLimiter.consume() returns False after token bucket is drained."""
128 assert not limiter.consume()
132 assert limiter.consume()
is True
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)
149 """All 20 tool Pydantic models must have model_config = ConfigDict(extra='forbid')."""
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)
159 """Passing an undeclared field should raise ValidationError for every model."""
161 for tool_name, model_cls
in _TOOL_MODEL_MAP.items():
163 model_cls.model_validate({
"__evil_field__":
"injected"})
164 errors.append(f
"{tool_name}: did not raise ValidationError for extra field")
165 except ValidationError:
167 assert not errors,
"\n".join(errors)
170 with pytest.raises((ValidationError, ValueError)):
173 {
"paths": [
"../../etc/passwd"]},
174 project_root=tmp_path,
178 with pytest.raises((ValidationError, ValueError)):
181 {
"paths": [
"src; rm -rf /"]},
182 project_root=tmp_path,
186 with pytest.raises((ValidationError, ValueError)):
189 {
"paths": [
"src | cat /etc/passwd"]},
190 project_root=tmp_path,
194 """T-06: no_verify is not a declared field → ValidationError."""
195 with pytest.raises(ValidationError):
198 {
"message":
"feat: test",
"no_verify":
True},
199 project_root=tmp_path,
203 """OI-502: FormatArgs.jobs was removed (CLI has no --jobs flag)."""
204 with pytest.raises(ValidationError):
208 project_root=tmp_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)
223 (
"oct_format", {
"apply":
True}),
225 (
"oct_git_commit", {
"message":
"feat: test"}),
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", {}),
236 """All Phase 5B write tools return HITL_SENTINEL when confirm=False."""
239 _server_module._server_state = state
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)
249 """oct_format with apply=False, confirm=True → --dry-run flag sent to executor."""
250 state, _ =
_make_state(tmp_path, sandbox_output=
"[DRY RUN]")
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
260 """oct_format with apply=True, confirm=True → --fix flag sent to executor."""
261 state, _ =
_make_state(tmp_path, sandbox_output=
"Fixed.")
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
271 """In strict profile, all write tools are denied before the HITL gate."""
274 _server_module._server_state = state
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)
284 """All 11 Phase 5B tools must have destructive=True in TOOL_MANIFEST."""
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",
291 for name
in write_tools:
292 spec = TOOL_MANIFEST.get(name)
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)
300 """All Phase 5B write tools must have auto_approve=False in TOOL_MANIFEST."""
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",
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)
320 """Static AST check: no actual shell=True keyword arg in any oct/mcp/*.py call."""
322 mcp_dir = Path(__file__).parent.parent.parent /
"oct" /
"mcp"
324 for py_file
in sorted(mcp_dir.glob(
"*.py")):
326 tree = _ast.parse(py_file.read_text(encoding=
"utf-8"))
329 for node
in _ast.walk(tree):
330 if isinstance(node, _ast.Call):
331 for kw
in node.keywords:
334 and isinstance(kw.value, _ast.Constant)
335 and kw.value.value
is True
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)
344 """SandboxExecutor must pin cwd to the project root."""
345 mock_backend = MagicMock()
346 mock_backend.run.return_value = (0,
"ok",
"")
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
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
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
366 """SandboxExecutor catches TimeoutExpired and sets timed_out=True."""
368 mock_backend = MagicMock()
369 mock_backend.run.side_effect = subprocess.TimeoutExpired(cmd=
"echo", timeout=1)
372 result = executor.run(cmd=[
"echo"], cwd=tmp_path, timeout=1)
373 assert result.timed_out
is True
374 assert result.exit_code == 1
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,
"")
382 result = executor.run(cmd=[
"echo"], cwd=tmp_path, timeout=10, max_output_bytes=100)
383 assert result.output_truncated
is True
392 """T-03: Secrets in sandbox stdout must be redacted before returning."""
394 tmp_path, sandbox_output=
"Running lint\nAPI_KEY=s3cr3t_value\nDone"
397 _server_module._server_state = state
398 result = _run_tool(
"oct_lint", {})
399 assert "s3cr3t_value" not in result
402 """T-07: Absolute project path in output is anonymised."""
404 tmp_path, sandbox_output=f
"Error in {tmp_path}/src/module.py line 42"
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
413 """Output > max_output_bytes is truncated (output_truncated flag set)."""
414 large =
"safe_content " * 100_000
415 mock_backend = MagicMock()
416 mock_backend.run.return_value = (0, large,
"")
419 result = executor.run(cmd=[
"echo"], cwd=tmp_path, timeout=10,
420 max_output_bytes=1_048_576)
422 total_bytes = len((result.stdout + result.stderr).encode(
"utf-8", errors=
"replace"))
423 assert total_bytes <= 1_048_576
432 """Every _run_tool() call must write exactly one JSONL audit record."""
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"
444 """_run_tool() returns a plain string, not the audit record dict."""
445 state, logger =
_make_state(tmp_path, sandbox_output=
"lint output")
447 _server_module._server_state = state
448 result = _run_tool(
"oct_lint", {})
449 assert isinstance(result, str)
451 assert '"session_id"' not in result
452 assert '"request_id"' not in result
453 assert '"policy_rule"' not in result
458 _server_module._server_state = state
459 _run_tool(
"oct_lint", {})
461 assert record.get(
"session_id") ==
"hardening-test"
466 _server_module._server_state = state
467 _run_tool(
"oct_lint", {})
469 assert record.get(
"request_id")
472 state, logger =
_make_state(tmp_path, safe_mode=
True)
474 _server_module._server_state = state
475 _run_tool(
"oct_lint", {})
477 assert record[
"decision"] ==
"denied"
482 _server_module._server_state = state
483 _run_tool(
"oct_lint", {
"paths": [
"../../etc/passwd"]})
485 assert record[
"decision"] ==
"denied"
486 assert record[
"policy_rule"] ==
"validation_failure"
491 _server_module._server_state = state
492 _run_tool_write(
"oct_clean", {
"confirm":
False})
494 assert record[
"decision"] ==
"requires_confirmation"
503 """confirm: bool = False must be the default on all write tool models."""
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",
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")
521 fields = model_cls.model_fields
522 if "confirm" not in fields:
523 errors.append(f
"{tool_name}: no 'confirm' field")
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)
533 """TOOL_MANIFEST must contain exactly 20 tools (9 read + 11 write)."""
534 assert len(TOOL_MANIFEST) == 20
537 """_TOOL_MODEL_MAP must contain exactly 20 tools."""
538 assert len(_TOOL_MODEL_MAP) == 20
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}"
550 """All 9 Phase 5A read-only tools must have destructive=False."""
552 "oct_lint",
"oct_health",
"oct_skeleton",
"oct_deps",
553 "oct_test",
"oct_typecheck",
"oct_diag",
554 "oct_git_status",
"oct_git_check",
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)
564 """All 9 Phase 5A read-only tools must have auto_approve=True."""
566 "oct_lint",
"oct_health",
"oct_skeleton",
"oct_deps",
567 "oct_test",
"oct_typecheck",
"oct_diag",
568 "oct_git_status",
"oct_git_check",
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_audit_record_not_returned_to_caller(self, Path tmp_path)
test_audit_record_has_session_id(self, Path tmp_path)
test_policy_denial_writes_denied_record(self, Path tmp_path)
test_validation_failure_writes_denied_record(self, Path tmp_path)
test_hitl_confirmation_writes_requires_confirmation_record(self, Path tmp_path)
test_every_tool_call_writes_jsonl_record(self, Path tmp_path)
test_audit_record_has_request_id(self, Path tmp_path)
test_rate_limiter_returns_true_within_limit(self)
test_unknown_tool_error_returned_not_raised(self, Path tmp_path)
test_rate_limiter_returns_false_when_exhausted(self)
test_unknown_tool_denied_by_policy(self, Path tmp_path)
test_confirm_default_is_false_on_all_write_models(self)
test_read_only_tools_auto_approve_true(self)
test_tool_manifest_has_20_entries(self)
test_read_only_tools_are_non_destructive(self)
test_tool_manifest_and_model_map_keys_match(self)
test_tool_model_map_has_20_entries(self)
test_oct_format_apply_false_confirm_true_dry_run(self, Path tmp_path)
test_strict_profile_denies_all_write_tools(self, Path tmp_path)
test_all_write_tools_require_confirm_true(self, Path tmp_path)
test_all_write_tools_auto_approve_false(self)
test_all_write_tools_are_destructive_in_manifest(self)
test_oct_format_apply_true_confirm_true_fix(self, Path tmp_path)
test_project_root_path_replaced_with_placeholder(self, Path tmp_path)
test_api_key_in_output_redacted(self, Path tmp_path)
test_large_output_truncated_with_marker(self, Path tmp_path)
test_sandbox_executor_passes_cwd_project_root(self, Path tmp_path)
test_sanitised_env_excludes_api_key(self, Path tmp_path)
test_sandbox_executor_timeout_returns_timed_out(self, Path tmp_path)
test_sandbox_executor_output_truncated_when_exceeded(self, Path tmp_path)
test_sanitised_env_excludes_pythonpath(self, Path tmp_path)
test_no_shell_true_in_mcp_source(self)
dict _last_audit(McpAuditLogger logger)
McpMetrics _isolated_metrics()
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)