Option C Tools
Loading...
Searching...
No Matches
test_mcp_write_tools.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# tests/tests_mcp/test_mcp_write_tools.py
4
5"""
6Purpose
7-------
8Regression tests for Phase 5B write-tool Pydantic models
9(:mod:`oct.mcp.validator`) and argv builders (:mod:`oct.mcp.executor`).
10
11Responsibilities
12----------------
13- Verify all 11 write-tool models (defaults, unknown field rejection,
14 confirm field).
15- Verify ``FormatArgs`` double guard and ``GitCommitArgs`` safety.
16- Verify path traversal (T-02) and shell metacharacter rejection.
17- Verify strict/airgapped profile blocks write tools.
18- Verify argv builder spot-checks (format dry-run, format fix, git
19 commit).
20- Verify T-06 privilege escalation and T-06b strict-profile denial.
21
22Diagnostics
23-----------
24Domain: MCP-TESTS
25Levels:
26 L2 — test lifecycle
27 L3 — assertion details
28 L4 — deep tracing
29
30Contracts
31---------
32- Tests are in-memory validation and policy checks; no subprocess or
33 filesystem writes.
34"""
35
36from __future__ import annotations
37
38import sys
39from pathlib import Path
40
41import pytest
42from pydantic import ValidationError
43
44from oct.mcp.executor import (
45 _build_argv_format,
46 _build_argv_git_commit,
47 _build_argv_git_changelog,
48 _build_argv_git_init,
49 _build_argv_new,
50 _build_argv_scaffold,
51 _build_argv_clean,
52 _build_argv_docs,
53)
54from oct.mcp.policy import McpPolicy, TOOL_MANIFEST
55from oct.mcp.validator import (
56 FormatArgs,
57 DocsArgs,
58 ExportSourceArgs,
59 NewArgs,
60 ScaffoldArgs,
61 CleanArgs,
62 InstallHooksArgs,
63 GitCommitArgs,
64 GitHooksInstallArgs,
65 GitInitArgs,
66 GitChangelogArgs,
67 validate_tool_args,
68)
69
70
71# -------------------------------------------------------------------
72# FormatArgs
73# -------------------------------------------------------------------
74
76 def test_defaults(self):
77 m = FormatArgs.model_validate({})
78 assert m.paths == []
79 assert m.apply is False
80 assert m.confirm is False
81 assert m.changed is False
82 assert m.verbose is False
83 assert m.json_output is True
84
86 with pytest.raises(ValidationError):
87 FormatArgs.model_validate({"evil_flag": True})
88
90 # OI-502: ``jobs`` was removed from FormatArgs because the CLI
91 # formatter does not accept ``--jobs``.
92 with pytest.raises(ValidationError):
93 FormatArgs.model_validate({"jobs": 1})
94
95 def test_path_traversal_rejected(self, tmp_path: Path):
96 with pytest.raises(ValidationError):
97 FormatArgs.model_validate(
98 {"paths": ["../../../etc/passwd"]},
99 context={"project_root": tmp_path},
100 )
101
102 def test_shell_metachar_in_path_rejected(self, tmp_path: Path):
103 with pytest.raises(ValidationError):
104 FormatArgs.model_validate(
105 {"paths": ["src; rm -rf /"]},
106 context={"project_root": tmp_path},
107 )
108
110 m = FormatArgs.model_validate({"apply": True, "confirm": True})
111 assert m.apply is True
112 assert m.confirm is True
113
115 """T-06: validator accepts apply=True, confirm=False — HITL gate catches this."""
116 m = FormatArgs.model_validate({"apply": True, "confirm": False})
117 assert m.apply is True
118 assert m.confirm is False # safety gate will intercept
119
120
121# -------------------------------------------------------------------
122# DocsArgs
123# -------------------------------------------------------------------
124
126 def test_defaults(self):
127 m = DocsArgs.model_validate({})
128 assert m.sphinx is False
129 assert m.doxygen is False
130 assert m.all_docs is False
131 assert m.dry_run is True
132 assert m.confirm is False
133
135 with pytest.raises(ValidationError):
136 DocsArgs.model_validate({"unknown": True})
137
139 m = DocsArgs.model_validate({"sphinx": True, "confirm": True, "dry_run": False})
140 assert m.sphinx is True
141 assert m.confirm is True
142 assert m.dry_run is False
143
144
145# -------------------------------------------------------------------
146# ExportSourceArgs
147# -------------------------------------------------------------------
148
150 def test_defaults(self):
151 m = ExportSourceArgs.model_validate({})
152 assert m.confirm is False
153 assert m.verbose is False
154 assert m.single_dir is False
155 assert m.warn_secrets is True
156 assert m.block_secrets is False
157
159 with pytest.raises(ValidationError):
160 ExportSourceArgs.model_validate({"output_dir": "/tmp"})
161
162
163# -------------------------------------------------------------------
164# NewArgs
165# -------------------------------------------------------------------
166
169 with pytest.raises(ValidationError):
170 NewArgs.model_validate({}) # path is required
171
172 def test_defaults_with_path(self, tmp_path: Path):
173 (tmp_path / "src").mkdir()
174 m = NewArgs.model_validate(
175 {"path": "src/foo.py"},
176 context={"project_root": tmp_path},
177 )
178 assert m.path == "src/foo.py"
179 assert m.confirm is False
180 assert m.dry_run is True
181 assert m.force is False
182 assert m.create_test is False
183
184 def test_t02_path_traversal_rejected(self, tmp_path: Path):
185 """T-02: path traversal in write tool."""
186 with pytest.raises(ValidationError):
187 NewArgs.model_validate(
188 {"path": "../../../etc/cron.d/evil"},
189 context={"project_root": tmp_path},
190 )
191
193 with pytest.raises(ValidationError):
194 NewArgs.model_validate({"path": "src/foo; rm -rf /"})
195
196 def test_unknown_field_rejected(self, tmp_path: Path):
197 with pytest.raises(ValidationError):
198 NewArgs.model_validate({"path": "src/foo.py", "evil": True})
199
200
201# -------------------------------------------------------------------
202# ScaffoldArgs
203# -------------------------------------------------------------------
204
207 with pytest.raises(ValidationError):
208 ScaffoldArgs.model_validate({})
209
211 m = ScaffoldArgs.model_validate({"name": "myproject"})
212 assert m.name == "myproject"
213 assert m.confirm is False
214 assert m.dry_run is True
215
217 with pytest.raises(ValidationError):
218 ScaffoldArgs.model_validate({"name": "proj; rm -rf /"})
219
221 with pytest.raises(ValidationError):
222 ScaffoldArgs.model_validate({"name": "myproject", "evil": True})
223
224
225# -------------------------------------------------------------------
226# CleanArgs
227# -------------------------------------------------------------------
228
230 def test_defaults(self):
231 m = CleanArgs.model_validate({})
232 assert m.path is None
233 assert m.confirm is False
234 assert m.dry_run is True
235
236 def test_path_traversal_rejected(self, tmp_path: Path):
237 with pytest.raises(ValidationError):
238 CleanArgs.model_validate(
239 {"path": "../../../etc"},
240 context={"project_root": tmp_path},
241 )
242
244 with pytest.raises(ValidationError):
245 CleanArgs.model_validate({"path": "/tmp; rm -rf /"})
246
248 m = CleanArgs.model_validate({"path": None})
249 assert m.path is None
250
252 with pytest.raises(ValidationError):
253 CleanArgs.model_validate({"evil": True})
254
255
256# -------------------------------------------------------------------
257# InstallHooksArgs
258# -------------------------------------------------------------------
259
261 def test_defaults(self):
262 m = InstallHooksArgs.model_validate({})
263 assert m.confirm is False
264 assert m.force is False
265
267 with pytest.raises(ValidationError):
268 InstallHooksArgs.model_validate({"no_verify": True})
269
270
271# -------------------------------------------------------------------
272# GitCommitArgs
273# -------------------------------------------------------------------
274
277 with pytest.raises(ValidationError):
278 GitCommitArgs.model_validate({})
279
281 m = GitCommitArgs.model_validate({"message": "feat: add thing"})
282 assert m.message == "feat: add thing"
283 assert m.confirm is False
284 assert m.force is False
285
287 """T-06: no_verify must not be accepted — bypassing hooks not permitted."""
288 with pytest.raises(ValidationError):
289 GitCommitArgs.model_validate({"message": "feat: test", "no_verify": True})
290
292 """OI-502: scoped Conventional Commits used to be rejected by the
293 shell-metachar validator because ``(`` / ``)`` are shell metas.
294 The new validator delegates to :mod:`oct.git.conventional` so
295 scoped types round-trip correctly.
296 """
297 m = GitCommitArgs.model_validate(
298 {"message": "feat(core): add validator parity"}
299 )
300 assert m.message == "feat(core): add validator parity"
301
303 """OI-502: ``feat!:`` is valid Conventional Commits."""
304 m = GitCommitArgs.model_validate(
305 {"message": "feat!: drop legacy config"}
306 )
307 assert m.message.startswith("feat!:")
308
310 """Messages that don't match ``type[(scope)]: subject`` are rejected."""
311 with pytest.raises(ValidationError):
312 GitCommitArgs.model_validate({"message": "just some random text"})
313
315 with pytest.raises(ValidationError):
316 GitCommitArgs.model_validate({"message": "badtype: add x"})
317
319 with pytest.raises(ValidationError):
320 GitCommitArgs.model_validate({"message": "feat: Add a thing"})
321
323 m = GitCommitArgs.model_validate({"message": "feat: add new feature"})
324 assert m.message == "feat: add new feature"
325
327 with pytest.raises(ValidationError):
328 GitCommitArgs.model_validate({"message": "feat: test", "evil_flag": True})
329
330
331# -------------------------------------------------------------------
332# GitHooksInstallArgs
333# -------------------------------------------------------------------
334
336 def test_defaults(self):
337 m = GitHooksInstallArgs.model_validate({})
338 assert m.confirm is False
339 assert m.force is False
340
342 with pytest.raises(ValidationError):
343 GitHooksInstallArgs.model_validate({"no_verify": True})
344
345
346# -------------------------------------------------------------------
347# GitInitArgs
348# -------------------------------------------------------------------
349
351 def test_defaults(self):
352 m = GitInitArgs.model_validate({})
353 assert m.confirm is False
354 assert m.dry_run is True
355 assert m.github is False
356 assert m.no_hooks is False
357
359 with pytest.raises(ValidationError):
360 GitInitArgs.model_validate({"evil": True})
361
362
363# -------------------------------------------------------------------
364# GitChangelogArgs
365# -------------------------------------------------------------------
366
368 def test_defaults(self):
369 m = GitChangelogArgs.model_validate({})
370 assert m.confirm is False
371 assert m.dry_run is True
372 assert m.since is None
373 assert m.version is None
374
376 m = GitChangelogArgs.model_validate({"since": "v0.14.0"})
377 assert m.since == "v0.14.0"
378
380 with pytest.raises(ValidationError):
381 GitChangelogArgs.model_validate({"since": "v0.14.0; rm -rf /"})
382
384 with pytest.raises(ValidationError):
385 GitChangelogArgs.model_validate({"version": "v0.15.0 && evil"})
386
388 with pytest.raises(ValidationError):
389 GitChangelogArgs.model_validate({"evil": True})
390
391
392# -------------------------------------------------------------------
393# validate_tool_args() — write tool integration
394# -------------------------------------------------------------------
395
398 write_tools = [
399 ("oct_format", {}),
400 ("oct_docs", {}),
401 ("oct_export_source", {}),
402 ("oct_new", {"path": "src/foo.py"}),
403 ("oct_scaffold", {"name": "myproject"}),
404 ("oct_clean", {}),
405 ("oct_install_hooks", {}),
406 ("oct_git_commit", {"message": "feat: add thing"}),
407 ("oct_git_hooks_install", {}),
408 ("oct_git_init", {}),
409 ("oct_git_changelog", {}),
410 ]
411 for tool_name, args in write_tools:
412 result = validate_tool_args(tool_name, args)
413 assert result is not None, f"{tool_name} should validate"
414
415 def test_write_tool_path_traversal_blocked(self, tmp_path: Path):
416 with pytest.raises(ValidationError):
417 validate_tool_args(
418 "oct_new",
419 {"path": "../../evil.py"},
420 project_root=tmp_path,
421 )
422
424 with pytest.raises(ValidationError):
425 validate_tool_args("oct_clean", {"evil_key": True})
426
427
428# -------------------------------------------------------------------
429# Policy: write tools blocked in strict / airgapped
430# -------------------------------------------------------------------
431
434 """T-06b: strict profile must deny all destructive tools."""
435 policy = McpPolicy(profile="strict")
436 for tool_name in [
437 "oct_format", "oct_docs", "oct_export_source", "oct_new",
438 "oct_scaffold", "oct_clean", "oct_install_hooks",
439 "oct_git_commit", "oct_git_hooks_install", "oct_git_init",
440 "oct_git_changelog",
441 ]:
442 decision = policy.check(tool_name)
443 assert not decision.allowed, f"{tool_name} should be denied in strict profile"
444 assert decision.policy_rule == "strict_no_destructive"
445
447 policy = McpPolicy(profile="airgapped")
448 for tool_name in TOOL_MANIFEST:
449 decision = policy.check(tool_name)
450 assert not decision.allowed
451 assert decision.policy_rule == "airgapped_profile"
452
454 policy = McpPolicy(profile="default")
455 for tool_name in [
456 "oct_format", "oct_clean", "oct_git_commit",
457 ]:
458 decision = policy.check(tool_name)
459 assert decision.allowed
460
461
462# -------------------------------------------------------------------
463# Argv builder spot-checks
464# -------------------------------------------------------------------
465
468 args = {"apply": False, "confirm": True, "paths": [], "jobs": 1}
469 cmd = _build_argv_format(args, Path("/project"))
470 assert "--dry-run" in cmd
471 assert "--fix" not in cmd
472
474 args = {"apply": True, "confirm": True, "paths": [], "jobs": 1}
475 cmd = _build_argv_format(args, Path("/project"))
476 assert "--fix" in cmd
477 assert "--dry-run" not in cmd
478
480 """_dry_run_override=True forces --dry-run even when apply=True."""
481 args = {"apply": True, "confirm": True, "_dry_run_override": True, "paths": [], "jobs": 1}
482 cmd = _build_argv_format(args, Path("/project"))
483 assert "--dry-run" in cmd
484 assert "--fix" not in cmd
485
487 args = {"apply": False, "json_output": True, "paths": [], "jobs": 1}
488 cmd = _build_argv_format(args, Path("/project"))
489 assert "--json" in cmd
490
492 args = {"message": "feat: add thing", "confirm": True, "json_output": True}
493 cmd = _build_argv_git_commit(args, Path("/project"))
494 assert "--no-verify" not in cmd
495 assert "-m" in cmd
496 assert "feat: add thing" in cmd
497
499 args = {"message": "feat: test", "force": True, "json_output": True}
500 cmd = _build_argv_git_commit(args, Path("/project"))
501 assert "--force" in cmd
502
504 args = {"dry_run": True, "json_output": True, "confirm": True}
505 cmd = _build_argv_git_changelog(args, Path("/project"))
506 assert "--dry-run" in cmd
507
509 args = {"dry_run": False, "json_output": True, "confirm": True}
510 cmd = _build_argv_git_changelog(args, Path("/project"))
511 assert "--dry-run" not in cmd
512
514 args = {"dry_run": True, "github": False, "no_hooks": False}
515 cmd = _build_argv_git_init(args, Path("/project"))
516 assert "--dry-run" in cmd
517
519 args = {"dry_run": False, "github": False, "no_hooks": False}
520 cmd = _build_argv_git_init(args, Path("/project"))
521 assert "--dry-run" not in cmd
522
524 args = {"path": "src/foo.py", "dry_run": True, "force": False, "create_test": False}
525 cmd = _build_argv_new(args, Path("/project"))
526 assert "--dry-run" in cmd
527 assert "src/foo.py" in cmd
528
530 args = {"name": "myproject", "dry_run": True}
531 cmd = _build_argv_scaffold(args, Path("/project"))
532 assert "--dry-run" in cmd
533 assert "myproject" in cmd
534
536 args = {"path": None, "dry_run": True}
537 cmd = _build_argv_clean(args, Path("/project"))
538 assert "--dry-run" in cmd
539
541 args = {"path": None, "dry_run": False}
542 cmd = _build_argv_clean(args, Path("/project"))
543 assert "--dry-run" not in cmd
544
546 args = {"all_docs": True, "dry_run": False, "sphinx": False, "doxygen": False, "clean": False}
547 cmd = _build_argv_docs(args, Path("/project"))
548 assert "--all" in cmd
549
551 args = {"all_docs": False, "dry_run": True, "sphinx": True, "doxygen": False, "clean": False}
552 cmd = _build_argv_docs(args, Path("/project"))
553 assert "--dry-run" in cmd
554 assert "--sphinx" in cmd
555
557 """All builders must use subprocess isolation (sys.executable -m oct)."""
558 args = {"apply": False, "paths": [], "jobs": 1}
559 cmd = _build_argv_format(args, Path("/project"))
560 assert cmd[0] == sys.executable
561 assert cmd[1] == "-m"
562 assert cmd[2] == "oct"