Option C Tools
Loading...
Searching...
No Matches
validator.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/mcp/validator.py
4
5"""
6Purpose
7-------
8Input Validator (Layer 2) for the ``oct-mcp`` six-layer pipeline.
9Defines one Pydantic model per MCP tool (9 models for the Phase 5A
10read-only tool manifest). All models enforce:
11
12- Unknown-field rejection (``model_config = ConfigDict(extra="forbid")``)
13- Integer field clamping via ``ge``/``le`` constraints
14- Enum allowlists (e.g. for ``profile``)
15- Shell-metacharacter rejection for all string fields
16- Path containment enforcement for all ``paths`` fields
17
18Responsibilities
19----------------
20- Provide :func:`validate_tool_args` as the single entry point: takes a
21 tool name and a raw ``dict`` and returns a validated ``BaseModel``
22 instance, or raises :class:`pydantic.ValidationError` on failure.
23- Define :func:`check_shell_metacharacters` for reuse by tests.
24- Define :func:`validate_path_in_project` for reuse by tests.
25
26Diagnostics
27-----------
28Domain: MCP
29Levels:
30 L2 — lifecycle: validation outcome
31 L4 — deep trace: individual field values
32
33Contracts
34---------
35- This module does not import ``click`` or the MCP SDK.
36- All models are instantiated with ``model_validate(data, context=context)``
37 where ``context`` carries ``{"project_root": Path}``.
38- :func:`validate_tool_args` raises ``ValueError`` for unknown tool names
39 before Pydantic even runs.
40- Shell metacharacters are rejected by a @field_validator applied to
41 every ``str`` field in every model via the ``_ShellSafe`` mixin.
42"""
43
44from __future__ import annotations
45
46import re
47from pathlib import Path
48from typing import Annotated, Literal
49
50try:
51 from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
52 from pydantic import ValidationInfo
53except ImportError as _pydantic_err: # pragma: no cover
54 raise ImportError(
55 "pydantic>=2.0 is required for oct-mcp. "
56 "Install with: pip install 'oct[mcp]'"
57 ) from _pydantic_err
58
59try:
60 from oc_diagnostics import _dbg as _real_dbg
61
62 def _dbg(*args, **kwargs) -> None:
63 _real_dbg(*args, **kwargs)
64except ImportError: # pragma: no cover
65 def _dbg(*args, **kwargs) -> None:
66 return None
67
68try:
69 from oct.core.project_root import is_within_project_root
70except ImportError: # pragma: no cover - defensive fallback
71 def is_within_project_root(path: Path, project_root: Path) -> bool:
72 try:
73 return Path(path).resolve().is_relative_to(Path(project_root).resolve())
74 except Exception:
75 return False
76
77
78# ---------------------------------------------------------------------
79# Shell metacharacter guard
80# ---------------------------------------------------------------------
81
82#: Characters that have special meaning in shells and must never appear
83#: in validated arg strings. This list covers POSIX and cmd.exe.
84_SHELL_METACHARACTERS: frozenset[str] = frozenset(
85 ";&|`$><(){}!\\\"'"
86)
87
88#: Compiled pattern for quick detection of any shell metacharacter.
89_SHELL_META_RE: re.Pattern[str] = re.compile(
90 r"[;&|`$><(){}!\\\"']"
91)
92
93
94def check_shell_metacharacters(value: str) -> str:
95 """Raise ``ValueError`` if *value* contains any shell metacharacter.
96
97 Called by field validators in every model that handles free-form
98 string input. Never called on boolean or integer fields.
99 """
100 if _SHELL_META_RE.search(value):
101 raise ValueError(
102 f"Shell metacharacters are not allowed in MCP tool arguments. "
103 f"Rejected value: {value!r}"
104 )
105 return value
106
107
108# ---------------------------------------------------------------------
109# Path containment helper
110# ---------------------------------------------------------------------
111
112
113def validate_path_in_project(path_str: str, project_root: Path | None) -> str:
114 """Validate that *path_str* resolves inside *project_root*.
115
116 Relative paths are resolved against *project_root* (not the process
117 cwd) so that ``"src"`` is interpreted as ``<project_root>/src``.
118 Absolute paths are resolved as-is.
119
120 Raises ``ValueError`` on path traversal attempts. When
121 ``project_root`` is ``None``, the check is skipped (permissive
122 fallback for contexts where the root is not yet bound).
123 """
124 if not project_root:
125 return path_str
126 p = Path(path_str)
127 if p.is_absolute():
128 resolved = p.resolve()
129 else:
130 resolved = (project_root / p).resolve()
131 if not is_within_project_root(resolved, project_root):
132 raise ValueError(
133 f"Path {path_str!r} resolves outside the project root "
134 f"{project_root!r}. Path traversal is not permitted."
135 )
136 return path_str
137
138
139# ---------------------------------------------------------------------
140# Shared validation helpers
141# ---------------------------------------------------------------------
142
143def _check_str(v: str) -> str:
144 """Combined: shell-metachar check for a single string value."""
146
147
148def _check_paths(paths: list[str], info: ValidationInfo) -> list[str]:
149 """Shell-safe + path-containment check for a list of paths."""
150 root: Path | None = (info.context or {}).get("project_root")
151 result: list[str] = []
152 for p in paths:
154 p = validate_path_in_project(p, root)
155 result.append(p)
156 return result
157
158
159# ---------------------------------------------------------------------
160# Tool argument models (one per tool in the Phase 5A manifest)
161# ---------------------------------------------------------------------
162
163#: Valid lint profiles (matches oct linter --profile options).
164_LINT_PROFILES = Literal["proto", "compact", "strict"]
165
166
167class LintArgs(BaseModel):
168 """Arguments for the ``oct_lint`` tool (``oct lint``)."""
169
170 model_config = ConfigDict(extra="forbid")
171
172 paths: list[str] = []
173 json_output: bool = True
174 jobs: int = Field(default=1, ge=1, le=8)
175 changed: bool = False
176 fix_headers: bool = False
177 profile: _LINT_PROFILES | None = None
178
179 @field_validator("paths", mode="after")
180 @classmethod
181 def paths_inside_project(cls, v: list[str], info: ValidationInfo) -> list[str]:
182 return _check_paths(v, info)
183
184
185class HealthArgs(BaseModel):
186 """Arguments for the ``oct_health`` tool (``oct health``)."""
187
188 model_config = ConfigDict(extra="forbid")
189
190 json_output: bool = True
191 verbose: bool = False
192
193
194class SkeletonArgs(BaseModel):
195 """Arguments for the ``oct_skeleton`` tool (``oct export-skeleton``).
196
197 OI-502: previously exposed an ``output_dir`` field that the CLI does
198 not accept. Removed to restore model/CLI parity; the CLI always writes
199 into ``_skeleton_code-*`` under the project root.
200 """
201
202 model_config = ConfigDict(extra="forbid")
203
204 json_output: bool = True
205
206
207class DepsArgs(BaseModel):
208 """Arguments for the ``oct_deps`` tool (``oct deps``).
209
210 OI-502: previously exposed a ``check`` boolean that the CLI does not
211 accept. Removed to restore parity; callers that want pin-auditing
212 should use ``oct deps --audit-pins`` directly (not yet modelled).
213 """
214
215 model_config = ConfigDict(extra="forbid")
216
217 json_output: bool = True
218
219
220class OctTestArgs(BaseModel):
221 """Arguments for the ``oct_test`` tool (``oct test``).
222
223 OI-502: previously exposed a ``jobs`` integer that the CLI does not
224 accept. Removed to restore parity. Note that ``pytest-xdist`` is
225 now declared in the ``oct[test]`` and ``oct[dev]`` extras, so
226 parallel runs are available — invoke them via the existing
227 pass-through ``args`` (e.g. ``oct test -- -n auto``). A first-class
228 ``jobs`` field can be re-added if/when MCP clients need to drive
229 parallelism programmatically.
230 """
231
232 model_config = ConfigDict(extra="forbid")
233
234 json_output: bool = True
235 paths: list[str] = []
236 changed: bool = False
237
238 @field_validator("paths", mode="after")
239 @classmethod
240 def paths_inside_project(cls, v: list[str], info: ValidationInfo) -> list[str]:
241 return _check_paths(v, info)
242
243
244class TypecheckArgs(BaseModel):
245 """Arguments for the ``oct_typecheck`` tool (``oct typecheck``)."""
246
247 model_config = ConfigDict(extra="forbid")
248
249 json_output: bool = True
250 paths: list[str] = []
251
252 @field_validator("paths", mode="after")
253 @classmethod
254 def paths_inside_project(cls, v: list[str], info: ValidationInfo) -> list[str]:
255 return _check_paths(v, info)
256
257
258class DiagArgs(BaseModel):
259 """Arguments for the ``oct_diag`` tool (``oct diag``).
260
261 OI-502: the historical ``Literal`` included ``show-config`` and
262 ``list-checks`` but the CLI exposes only ``validate-config``,
263 ``list-domains``, and ``set-level``. Narrowed to the two
264 zero-argument subcommands; ``set-level`` takes positional arguments
265 that are out of scope for this read-only tool.
266
267 ``json_output`` is retained for model uniformity but the executor
268 does not emit ``--json`` because the diag subcommands don't accept
269 it (yet — see OI-534 for strict-mode output).
270 """
271
272 model_config = ConfigDict(extra="forbid")
273
274 json_output: bool = True
275 subcommand: Literal["validate-config", "list-domains"] = "validate-config"
276
277
278class GitStatusArgs(BaseModel):
279 """Arguments for the ``oct_git_status`` tool (``oct git status``)."""
280
281 model_config = ConfigDict(extra="forbid")
282
283 json_output: bool = True
284 verbose: bool = False
285
286
287class GitCheckArgs(BaseModel):
288 """Arguments for the ``oct_git_check`` tool (``oct git check``)."""
289
290 model_config = ConfigDict(extra="forbid")
291
292 json_output: bool = True
293 verbose: bool = False
294
295
296# ---------------------------------------------------------------------
297# Phase 5B — write tool models (11)
298# All include confirm: bool = False for HITL gating.
299# ---------------------------------------------------------------------
300
301
302class FormatArgs(BaseModel):
303 """Arguments for the ``oct_format`` tool (``oct format``).
304
305 Double-guard: ``confirm=True`` AND ``apply=True`` are both required
306 before changes are written. With ``apply=False`` the executor passes
307 ``--dry-run``; with ``apply=True`` it passes ``--fix``.
308
309 OI-502: ``jobs`` removed (CLI argparser exposes no ``--jobs``).
310 ``json_output`` added so the executor can request ``--json`` output
311 consistently with other read models.
312 """
313
314 model_config = ConfigDict(extra="forbid")
315
316 paths: list[str] = []
317 apply: bool = False # must be True + confirm=True to write
318 confirm: bool = False
319 changed: bool = False
320 verbose: bool = False
321 json_output: bool = True
322
323 @field_validator("paths", mode="after")
324 @classmethod
325 def paths_inside_project(cls, v: list[str], info: ValidationInfo) -> list[str]:
326 return _check_paths(v, info)
327
328
329class DocsArgs(BaseModel):
330 """Arguments for the ``oct_docs`` tool (``oct docs``).
331
332 OI-502: ``json_output`` added for model uniformity; the CLI does not
333 accept ``--json`` so the executor silently omits it.
334 """
335
336 model_config = ConfigDict(extra="forbid")
337
338 sphinx: bool = False
339 doxygen: bool = False
340 all_docs: bool = False # maps to --all flag
341 clean: bool = False
342 dry_run: bool = True
343 confirm: bool = False
344 json_output: bool = True
345
346
347class ExportSourceArgs(BaseModel):
348 """Arguments for the ``oct_export_source`` tool (``oct export-source``).
349
350 OI-502: ``json_output`` added for model uniformity.
351 """
352
353 model_config = ConfigDict(extra="forbid")
354
355 confirm: bool = False
356 verbose: bool = False
357 single_dir: bool = False
358 warn_secrets: bool = True
359 block_secrets: bool = False
360 json_output: bool = True
361
362
363class NewArgs(BaseModel):
364 """Arguments for the ``oct_new`` tool (``oct new``).
365
366 OI-502: ``json_output`` added for model uniformity.
367 """
368
369 model_config = ConfigDict(extra="forbid")
370
371 path: str # required; shell-safe + path-validated
372 confirm: bool = False
373 force: bool = False
374 create_test: bool = False
375 dry_run: bool = True
376 verbose: bool = False
377 json_output: bool = True
378
379 @field_validator("path", mode="after")
380 @classmethod
381 def path_safe(cls, v: str, info: ValidationInfo) -> str:
383 root = (info.context or {}).get("project_root")
384 return validate_path_in_project(v, root)
385
386
387class ScaffoldArgs(BaseModel):
388 """Arguments for the ``oct_scaffold`` tool (``oct scaffold``).
389
390 OI-502: ``json_output`` added for model uniformity.
391 """
392
393 model_config = ConfigDict(extra="forbid")
394
395 name: str # required; shell-safe (no path traversal)
396 confirm: bool = False
397 dry_run: bool = True
398 json_output: bool = True
399
400 @field_validator("name", mode="after")
401 @classmethod
402 def name_shell_safe(cls, v: str) -> str:
404
405
406class CleanArgs(BaseModel):
407 """Arguments for the ``oct_clean`` tool (``oct clean``).
408
409 OI-502: ``json_output`` added for model uniformity.
410 """
411
412 model_config = ConfigDict(extra="forbid")
413
414 path: str | None = None # optional; path-validated if provided
415 confirm: bool = False
416 dry_run: bool = True
417 json_output: bool = True
418
419 @field_validator("path", mode="after")
420 @classmethod
421 def path_safe(cls, v: str | None, info: ValidationInfo) -> str | None:
422 if v is None:
423 return v
425 root = (info.context or {}).get("project_root")
426 return validate_path_in_project(v, root)
427
428
429class InstallHooksArgs(BaseModel):
430 """Arguments for the ``oct_install_hooks`` tool (``oct install-hooks``).
431
432 OI-502: ``json_output`` added for model uniformity.
433 """
434
435 model_config = ConfigDict(extra="forbid")
436
437 confirm: bool = False
438 force: bool = False
439 json_output: bool = True
440
441
442class GitCommitArgs(BaseModel):
443 """Arguments for the ``oct_git_commit`` tool (``oct git commit``).
444
445 Note: there is deliberately no ``no_verify`` field. Bypassing
446 pre-commit hooks is never permitted via MCP (D-5B-6).
447
448 OI-502 / FS-502: ``message`` was previously run through
449 :func:`check_shell_metacharacters`, which rejected Conventional
450 Commits syntax such as scoped types (``feat(core): add x``) and
451 breaking-change markers (``feat!: drop y``). Git receives the
452 message as a single ``-m <message>`` argv element — never as a
453 shell-interpreted string — so shell-metacharacter filtering was
454 both over-broad and protection-for-nothing. Validation now delegates
455 to :func:`oct.git.conventional.validate_commit_message`, which is
456 also what ``oct git commit`` enforces at the CLI.
457 """
458
459 model_config = ConfigDict(extra="forbid")
460
461 message: str # required; Conventional Commits compliant
462 confirm: bool = False
463 force: bool = False # maps to --force (skip lint/format checks)
464 json_output: bool = True
465
466 @field_validator("message", mode="after")
467 @classmethod
468 def message_conventional(cls, v: str) -> str:
469 # Import locally so that importing the validator module does not
470 # pull in the entire git helper graph unless this model is used.
471 from oct.git.conventional import validate_commit_message
472
473 errors = validate_commit_message(v)
474 if errors:
475 raise ValueError(
476 "Commit message is not valid Conventional Commits: "
477 + "; ".join(errors)
478 )
479 return v
480
481
482class GitHooksInstallArgs(BaseModel):
483 """Arguments for the ``oct_git_hooks_install`` tool (``oct git hooks install``).
484
485 OI-502: ``json_output`` added for model uniformity.
486 """
487
488 model_config = ConfigDict(extra="forbid")
489
490 confirm: bool = False
491 force: bool = False
492 json_output: bool = True
493
494
495class GitInitArgs(BaseModel):
496 """Arguments for the ``oct_git_init`` tool (``oct git init``).
497
498 OI-502: ``json_output`` added for model uniformity.
499 """
500
501 model_config = ConfigDict(extra="forbid")
502
503 confirm: bool = False
504 dry_run: bool = True
505 github: bool = False # also generate GitHub Actions workflow
506 no_hooks: bool = False # skip hook installation
507 json_output: bool = True
508
509
510class GitChangelogArgs(BaseModel):
511 """Arguments for the ``oct_git_changelog`` tool (``oct git changelog``)."""
512
513 model_config = ConfigDict(extra="forbid")
514
515 confirm: bool = False
516 dry_run: bool = True # True → print to stdout, do not write CHANGELOG.md
517 since: str | None = None # e.g. ``"v0.14.0"``; shell-safe
518 version: str | None = None # release version tag, e.g. ``"v0.15.0"``
519
520 @field_validator("since", "version", mode="after")
521 @classmethod
522 def ref_shell_safe(cls, v: str | None) -> str | None:
523 if v is not None:
525 return v
526
527
528# ---------------------------------------------------------------------
529# Tool → model registry + entry point
530# ---------------------------------------------------------------------
531
532#: Maps MCP tool names to their Pydantic model class.
533_TOOL_MODEL_MAP: dict[str, type[BaseModel]] = {
534 # Phase 5A — read-only
535 "oct_lint": LintArgs,
536 "oct_health": HealthArgs,
537 "oct_skeleton": SkeletonArgs,
538 "oct_deps": DepsArgs,
539 "oct_test": OctTestArgs,
540 "oct_typecheck": TypecheckArgs,
541 "oct_diag": DiagArgs,
542 "oct_git_status": GitStatusArgs,
543 "oct_git_check": GitCheckArgs,
544 # Phase 5B — write tools
545 "oct_format": FormatArgs,
546 "oct_docs": DocsArgs,
547 "oct_export_source": ExportSourceArgs,
548 "oct_new": NewArgs,
549 "oct_scaffold": ScaffoldArgs,
550 "oct_clean": CleanArgs,
551 "oct_install_hooks": InstallHooksArgs,
552 "oct_git_commit": GitCommitArgs,
553 "oct_git_hooks_install": GitHooksInstallArgs,
554 "oct_git_init": GitInitArgs,
555 "oct_git_changelog": GitChangelogArgs,
556}
557
558
560 tool_name: str,
561 raw_args: dict,
562 project_root: Path | None = None,
563) -> BaseModel:
564 """Validate *raw_args* against the model for *tool_name*.
565
566 Parameters
567 ----------
568 tool_name
569 One of the 20 tool names in the manifest (e.g. ``"oct_lint"``).
570 raw_args
571 Unvalidated dict from the MCP client.
572 project_root
573 Absolute project root path, threaded through to path-containment
574 validators via Pydantic's ``model_validate`` context.
575
576 Returns
577 -------
578 A fully validated Pydantic model instance.
579
580 Raises
581 ------
582 ValueError
583 If *tool_name* is not in the tool manifest.
584 pydantic.ValidationError
585 If *raw_args* fails any field validator.
586 """
587 func = "validate_tool_args"
588 model_cls = _TOOL_MODEL_MAP.get(tool_name)
589 if model_cls is None:
590 raise ValueError(
591 f"Unknown MCP tool: {tool_name!r}. "
592 f"Valid tools: {sorted(_TOOL_MODEL_MAP)}"
593 )
594 context: dict = {}
595 if project_root is not None:
596 context["project_root"] = project_root
597
598 validated = model_cls.model_validate(raw_args, context=context)
599 _dbg("MCP", func, f"validated tool={tool_name}", 4)
600 return validated
str|None path_safe(cls, str|None v, ValidationInfo info)
Definition validator.py:421
list[str] paths_inside_project(cls, list[str] v, ValidationInfo info)
Definition validator.py:325
str|None ref_shell_safe(cls, str|None v)
Definition validator.py:522
str message_conventional(cls, str v)
Definition validator.py:468
list[str] paths_inside_project(cls, list[str] v, ValidationInfo info)
Definition validator.py:181
str path_safe(cls, str v, ValidationInfo info)
Definition validator.py:381
list[str] paths_inside_project(cls, list[str] v, ValidationInfo info)
Definition validator.py:240
str name_shell_safe(cls, str v)
Definition validator.py:402
list[str] paths_inside_project(cls, list[str] v, ValidationInfo info)
Definition validator.py:254
BaseModel validate_tool_args(str tool_name, dict raw_args, Path|None project_root=None)
Definition validator.py:563
str check_shell_metacharacters(str value)
Definition validator.py:94
str validate_path_in_project(str path_str, Path|None project_root)
Definition validator.py:113
str _check_str(str v)
Definition validator.py:143
bool is_within_project_root(Path path, Path project_root)
Definition validator.py:71
None _dbg(*args, **kwargs)
Definition validator.py:62
list[str] _check_paths(list[str] paths, ValidationInfo info)
Definition validator.py:148