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