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:
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
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.
30 L2 — lifecycle: validation outcome
31 L4 — deep trace: individual field values
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.
44from __future__
import annotations
47from pathlib
import Path
48from typing
import Annotated, Literal
51 from pydantic
import BaseModel, ConfigDict, Field, field_validator, model_validator
52 from pydantic
import ValidationInfo
53except ImportError
as _pydantic_err:
55 "pydantic>=2.0 is required for oct-mcp. "
56 "Install with: pip install 'oct[mcp]'"
60 from oc_diagnostics
import _dbg
as _real_dbg
62 def _dbg(*args, **kwargs) -> None:
63 _real_dbg(*args, **kwargs)
65 def _dbg(*args, **kwargs) -> None:
69 from oct.core.project_root
import is_within_project_root
73 return Path(path).resolve().is_relative_to(Path(project_root).resolve())
84_SHELL_METACHARACTERS: frozenset[str] = frozenset(
89_SHELL_META_RE: re.Pattern[str] = re.compile(
90 r"[;&|`$><(){}!\\\"']"
95 """Raise ``ValueError`` if *value* contains any shell metacharacter.
97 Called by field validators in every model that handles free-form
98 string input. Never called on boolean or integer fields.
100 if _SHELL_META_RE.search(value):
102 f
"Shell metacharacters are not allowed in MCP tool arguments. "
103 f
"Rejected value: {value!r}"
114 """Validate that *path_str* resolves inside *project_root*.
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.
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).
128 resolved = p.resolve()
130 resolved = (project_root / p).resolve()
133 f
"Path {path_str!r} resolves outside the project root "
134 f
"{project_root!r}. Path traversal is not permitted."
144 """Combined: shell-metachar check for a single string value."""
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] = []
164_LINT_PROFILES = Literal[
"proto",
"compact",
"strict"]
168 """Arguments for the ``oct_lint`` tool (``oct lint``)."""
170 model_config = ConfigDict(extra=
"forbid")
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
179 @field_validator("paths", mode="after")
186 """Arguments for the ``oct_health`` tool (``oct health``)."""
188 model_config = ConfigDict(extra=
"forbid")
190 json_output: bool =
True
191 verbose: bool =
False
195 """Arguments for the ``oct_skeleton`` tool (``oct export-skeleton``).
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.
202 model_config = ConfigDict(extra=
"forbid")
204 json_output: bool =
True
208 """Arguments for the ``oct_deps`` tool (``oct deps``).
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).
215 model_config = ConfigDict(extra=
"forbid")
217 json_output: bool =
True
221 """Arguments for the ``oct_test`` tool (``oct test``).
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.
232 model_config = ConfigDict(extra=
"forbid")
234 json_output: bool =
True
235 paths: list[str] = []
236 changed: bool =
False
238 @field_validator("paths", mode="after")
245 """Arguments for the ``oct_typecheck`` tool (``oct typecheck``)."""
247 model_config = ConfigDict(extra=
"forbid")
249 json_output: bool =
True
250 paths: list[str] = []
252 @field_validator("paths", mode="after")
259 """Arguments for the ``oct_diag`` tool (``oct diag``).
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.
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).
272 model_config = ConfigDict(extra=
"forbid")
274 json_output: bool =
True
275 subcommand: Literal[
"validate-config",
"list-domains"] =
"validate-config"
279 """Arguments for the ``oct_git_status`` tool (``oct git status``)."""
281 model_config = ConfigDict(extra=
"forbid")
283 json_output: bool =
True
284 verbose: bool =
False
288 """Arguments for the ``oct_git_check`` tool (``oct git check``)."""
290 model_config = ConfigDict(extra=
"forbid")
292 json_output: bool =
True
293 verbose: bool =
False
303 """Arguments for the ``oct_format`` tool (``oct format``).
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``.
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.
314 model_config = ConfigDict(extra=
"forbid")
316 paths: list[str] = []
318 confirm: bool =
False
319 changed: bool =
False
320 verbose: bool =
False
321 json_output: bool =
True
323 @field_validator("paths", mode="after")
330 """Arguments for the ``oct_docs`` tool (``oct docs``).
332 OI-502: ``json_output`` added for model uniformity; the CLI does not
333 accept ``--json`` so the executor silently omits it.
336 model_config = ConfigDict(extra=
"forbid")
339 doxygen: bool =
False
340 all_docs: bool =
False
343 confirm: bool =
False
344 json_output: bool =
True
348 """Arguments for the ``oct_export_source`` tool (``oct export-source``).
350 OI-502: ``json_output`` added for model uniformity.
353 model_config = ConfigDict(extra=
"forbid")
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
364 """Arguments for the ``oct_new`` tool (``oct new``).
366 OI-502: ``json_output`` added for model uniformity.
369 model_config = ConfigDict(extra=
"forbid")
372 confirm: bool =
False
374 create_test: bool =
False
376 verbose: bool =
False
377 json_output: bool =
True
379 @field_validator("path", mode="after")
381 def path_safe(cls, v: str, info: ValidationInfo) -> str:
383 root = (info.context
or {}).get(
"project_root")
388 """Arguments for the ``oct_scaffold`` tool (``oct scaffold``).
390 OI-502: ``json_output`` added for model uniformity.
393 model_config = ConfigDict(extra=
"forbid")
396 confirm: bool =
False
398 json_output: bool =
True
400 @field_validator("name", mode="after")
407 """Arguments for the ``oct_clean`` tool (``oct clean``).
409 OI-502: ``json_output`` added for model uniformity.
412 model_config = ConfigDict(extra=
"forbid")
414 path: str |
None =
None
415 confirm: bool =
False
417 json_output: bool =
True
419 @field_validator("path", mode="after")
421 def path_safe(cls, v: str |
None, info: ValidationInfo) -> str |
None:
425 root = (info.context
or {}).get(
"project_root")
430 """Arguments for the ``oct_install_hooks`` tool (``oct install-hooks``).
432 OI-502: ``json_output`` added for model uniformity.
435 model_config = ConfigDict(extra=
"forbid")
437 confirm: bool =
False
439 json_output: bool =
True
443 """Arguments for the ``oct_git_commit`` tool (``oct git commit``).
445 Note: there is deliberately no ``no_verify`` field. Bypassing
446 pre-commit hooks is never permitted via MCP (D-5B-6).
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.
459 model_config = ConfigDict(extra=
"forbid")
462 confirm: bool =
False
464 json_output: bool =
True
466 @field_validator("message", mode="after")
473 errors = validate_commit_message(v)
476 "Commit message is not valid Conventional Commits: "
483 """Arguments for the ``oct_git_hooks_install`` tool (``oct git hooks install``).
485 OI-502: ``json_output`` added for model uniformity.
488 model_config = ConfigDict(extra=
"forbid")
490 confirm: bool =
False
492 json_output: bool =
True
496 """Arguments for the ``oct_git_init`` tool (``oct git init``).
498 OI-502: ``json_output`` added for model uniformity.
501 model_config = ConfigDict(extra=
"forbid")
503 confirm: bool =
False
506 no_hooks: bool =
False
507 json_output: bool =
True
511 """Arguments for the ``oct_git_changelog`` tool (``oct git changelog``)."""
513 model_config = ConfigDict(extra=
"forbid")
515 confirm: bool =
False
517 since: str |
None =
None
518 version: str |
None =
None
520 @field_validator("since", "version", mode="after")
533_TOOL_MODEL_MAP: dict[str, type[BaseModel]] = {
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,
545 "oct_format": FormatArgs,
546 "oct_docs": DocsArgs,
547 "oct_export_source": ExportSourceArgs,
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,
562 project_root: Path |
None =
None,
564 """Validate *raw_args* against the model for *tool_name*.
569 One of the 20 tool names in the manifest (e.g. ``"oct_lint"``).
571 Unvalidated dict from the MCP client.
573 Absolute project root path, threaded through to path-containment
574 validators via Pydantic's ``model_validate`` context.
578 A fully validated Pydantic model instance.
583 If *tool_name* is not in the tool manifest.
584 pydantic.ValidationError
585 If *raw_args* fails any field validator.
587 func =
"validate_tool_args"
588 model_cls = _TOOL_MODEL_MAP.get(tool_name)
589 if model_cls
is None:
591 f
"Unknown MCP tool: {tool_name!r}. "
592 f
"Valid tools: {sorted(_TOOL_MODEL_MAP)}"
595 if project_root
is not None:
596 context[
"project_root"] = project_root
598 validated = model_cls.model_validate(raw_args, context=context)
599 _dbg(
"MCP", func, f
"validated tool={tool_name}", 4)
str|None path_safe(cls, str|None v, ValidationInfo info)
str|None ref_shell_safe(cls, str|None v)
str message_conventional(cls, str v)
list[str] paths_inside_project(cls, list[str] v, ValidationInfo info)
str path_safe(cls, str v, ValidationInfo info)
list[str] paths_inside_project(cls, list[str] v, ValidationInfo info)
str name_shell_safe(cls, str v)
list[str] paths_inside_project(cls, list[str] v, ValidationInfo info)
BaseModel validate_tool_args(str tool_name, dict raw_args, Path|None project_root=None)
str check_shell_metacharacters(str value)
str validate_path_in_project(str path_str, Path|None project_root)
bool is_within_project_root(Path path, Path project_root)
None _dbg(*args, **kwargs)
list[str] _check_paths(list[str] paths, ValidationInfo info)