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 L3 - (none): the outcome is reported at L2, field values at L4
32 L4 - deep trace: individual field values
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.
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)
52from __future__
import annotations
55from pathlib
import Path
56from typing
import Annotated, Literal
59 from pydantic
import BaseModel, ConfigDict, Field, field_validator, model_validator
60 from pydantic
import ValidationInfo
61except ImportError
as _pydantic_err:
64 "pydantic>=2.0 is required for oct-mcp. "
65 "Install with: pip install 'oct[mcp]'"
68from oct.core.diagnostics
import _dbg
71 from oct.core.project_root
import is_within_project_root
75 return Path(path).resolve().is_relative_to(Path(project_root).resolve())
86_SHELL_METACHARACTERS: frozenset[str] = frozenset(
91_SHELL_META_RE: re.Pattern[str] = re.compile(
92 r"[;&|`$><(){}!\\\"']"
97 """Raise ``ValueError`` if *value* contains any shell metacharacter.
99 Called by field validators in every model that handles free-form
100 string input. Never called on boolean or integer fields.
102 if _SHELL_META_RE.search(value):
104 f
"Shell metacharacters are not allowed in MCP tool arguments. "
105 f
"Rejected value: {value!r}"
116 """Validate that *path_str* resolves inside *project_root*.
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.
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).
130 resolved = p.resolve()
132 resolved = (project_root / p).resolve()
135 f
"Path {path_str!r} resolves outside the project root "
136 f
"{project_root!r}. Path traversal is not permitted."
146 """Combined: shell-metachar check for a single string value."""
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] = []
166_LINT_PROFILES = Literal[
"proto",
"compact",
"strict"]
170 """Arguments for the ``oct_lint`` tool (``oct lint``)."""
172 model_config = ConfigDict(extra=
"forbid")
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
181 @field_validator("paths", mode="after")
188 """Arguments for the ``oct_health`` tool (``oct health``)."""
190 model_config = ConfigDict(extra=
"forbid")
192 json_output: bool =
True
193 verbose: bool =
False
197 """Arguments for the ``oct_skeleton`` tool (``oct export-skeleton``).
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.
204 model_config = ConfigDict(extra=
"forbid")
206 json_output: bool =
True
210 """Arguments for the ``oct_deps`` tool (``oct deps``).
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).
217 model_config = ConfigDict(extra=
"forbid")
219 json_output: bool =
True
223 """Arguments for the ``oct_test`` tool (``oct test``).
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.
234 model_config = ConfigDict(extra=
"forbid")
236 json_output: bool =
True
237 paths: list[str] = []
238 changed: bool =
False
240 @field_validator("paths", mode="after")
247 """Arguments for the ``oct_typecheck`` tool (``oct typecheck``)."""
249 model_config = ConfigDict(extra=
"forbid")
251 json_output: bool =
True
252 paths: list[str] = []
254 @field_validator("paths", mode="after")
261 """Arguments for the ``oct_diag`` tool (``oct diag``).
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.
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).
274 model_config = ConfigDict(extra=
"forbid")
276 json_output: bool =
True
277 subcommand: Literal[
"validate-config",
"list-domains"] =
"validate-config"
281 """Arguments for the ``oct_git_status`` tool (``oct git status``)."""
283 model_config = ConfigDict(extra=
"forbid")
285 json_output: bool =
True
286 verbose: bool =
False
290 """Arguments for the ``oct_git_check`` tool (``oct git check``)."""
292 model_config = ConfigDict(extra=
"forbid")
294 json_output: bool =
True
295 verbose: bool =
False
305 """Arguments for the ``oct_format`` tool (``oct format``).
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``.
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.
316 model_config = ConfigDict(extra=
"forbid")
318 paths: list[str] = []
320 confirm: bool =
False
321 changed: bool =
False
322 verbose: bool =
False
323 json_output: bool =
True
325 @field_validator("paths", mode="after")
332 """Arguments for the ``oct_docs`` tool (``oct docs``).
334 OI-502: ``json_output`` added for model uniformity; the CLI does not
335 accept ``--json`` so the executor silently omits it.
338 model_config = ConfigDict(extra=
"forbid")
341 doxygen: bool =
False
342 all_docs: bool =
False
345 confirm: bool =
False
346 json_output: bool =
True
350 """Arguments for the ``oct_export_source`` tool (``oct export-source``).
352 OI-502: ``json_output`` added for model uniformity.
355 model_config = ConfigDict(extra=
"forbid")
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
366 """Arguments for the ``oct_new`` tool (``oct new``).
368 OI-502: ``json_output`` added for model uniformity.
371 model_config = ConfigDict(extra=
"forbid")
374 confirm: bool =
False
376 create_test: bool =
False
378 verbose: bool =
False
379 json_output: bool =
True
381 @field_validator("path", mode="after")
383 def path_safe(cls, v: str, info: ValidationInfo) -> str:
385 root = (info.context
or {}).get(
"project_root")
390 """Arguments for the ``oct_scaffold`` tool (``oct scaffold``).
392 OI-502: ``json_output`` added for model uniformity.
395 model_config = ConfigDict(extra=
"forbid")
398 confirm: bool =
False
400 json_output: bool =
True
402 @field_validator("name", mode="after")
409 """Arguments for the ``oct_clean`` tool (``oct clean``).
411 OI-502: ``json_output`` added for model uniformity.
414 model_config = ConfigDict(extra=
"forbid")
416 path: str |
None =
None
417 confirm: bool =
False
419 json_output: bool =
True
421 @field_validator("path", mode="after")
423 def path_safe(cls, v: str |
None, info: ValidationInfo) -> str |
None:
427 root = (info.context
or {}).get(
"project_root")
432 """Arguments for the ``oct_install_hooks`` tool (``oct install-hooks``).
434 OI-502: ``json_output`` added for model uniformity.
437 model_config = ConfigDict(extra=
"forbid")
439 confirm: bool =
False
441 json_output: bool =
True
445 """Arguments for the ``oct_git_commit`` tool (``oct git commit``).
447 Note: there is deliberately no ``no_verify`` field. Bypassing
448 pre-commit hooks is never permitted via MCP (D-5B-6).
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.
461 model_config = ConfigDict(extra=
"forbid")
464 confirm: bool =
False
466 json_output: bool =
True
468 @field_validator("message", mode="after")
475 errors = validate_commit_message(v)
478 "Commit message is not valid Conventional Commits: "
485 """Arguments for the ``oct_git_hooks_install`` tool (``oct git hooks install``).
487 OI-502: ``json_output`` added for model uniformity.
490 model_config = ConfigDict(extra=
"forbid")
492 confirm: bool =
False
494 json_output: bool =
True
498 """Arguments for the ``oct_git_init`` tool (``oct git init``).
500 OI-502: ``json_output`` added for model uniformity.
503 model_config = ConfigDict(extra=
"forbid")
505 confirm: bool =
False
508 no_hooks: bool =
False
509 json_output: bool =
True
513 """Arguments for the ``oct_git_changelog`` tool (``oct git changelog``)."""
515 model_config = ConfigDict(extra=
"forbid")
517 confirm: bool =
False
519 since: str |
None =
None
520 version: str |
None =
None
522 @field_validator("since", "version", mode="after")
535_TOOL_MODEL_MAP: dict[str, type[BaseModel]] = {
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,
547 "oct_format": FormatArgs,
548 "oct_docs": DocsArgs,
549 "oct_export_source": ExportSourceArgs,
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,
564 project_root: Path |
None =
None,
566 """Validate *raw_args* against the model for *tool_name*.
571 One of the 20 tool names in the manifest (e.g. ``"oct_lint"``).
573 Unvalidated dict from the MCP client.
575 Absolute project root path, threaded through to path-containment
576 validators via Pydantic's ``model_validate`` context.
580 A fully validated Pydantic model instance.
585 If *tool_name* is not in the tool manifest.
586 pydantic.ValidationError
587 If *raw_args* fails any field validator.
589 func =
"validate_tool_args"
590 model_cls = _TOOL_MODEL_MAP.get(tool_name)
591 if model_cls
is None:
593 f
"Unknown MCP tool: {tool_name!r}. "
594 f
"Valid tools: {sorted(_TOOL_MODEL_MAP)}"
597 if project_root
is not None:
598 context[
"project_root"] = project_root
600 validated = model_cls.model_validate(raw_args, context=context)
601 _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)
list[str] _check_paths(list[str] paths, ValidationInfo info)