8Regression tests for Phase 5B write-tool Pydantic models
9(:mod:`oct.mcp.validator`) and argv builders (:mod:`oct.mcp.executor`).
13- Verify all 11 write-tool models (defaults, unknown field rejection,
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
20- Verify T-06 privilege escalation and T-06b strict-profile denial.
27 L3 — assertion details
32- Tests are in-memory validation and policy checks; no subprocess or
36from __future__
import annotations
39from pathlib
import Path
42from pydantic
import ValidationError
46 _build_argv_git_commit,
47 _build_argv_git_changelog,
77 m = FormatArgs.model_validate({})
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
86 with pytest.raises(ValidationError):
87 FormatArgs.model_validate({
"evil_flag":
True})
92 with pytest.raises(ValidationError):
93 FormatArgs.model_validate({
"jobs": 1})
96 with pytest.raises(ValidationError):
97 FormatArgs.model_validate(
98 {
"paths": [
"../../../etc/passwd"]},
99 context={
"project_root": tmp_path},
103 with pytest.raises(ValidationError):
104 FormatArgs.model_validate(
105 {
"paths": [
"src; rm -rf /"]},
106 context={
"project_root": tmp_path},
110 m = FormatArgs.model_validate({
"apply":
True,
"confirm":
True})
111 assert m.apply
is True
112 assert m.confirm
is True
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
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
135 with pytest.raises(ValidationError):
136 DocsArgs.model_validate({
"unknown":
True})
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
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
159 with pytest.raises(ValidationError):
160 ExportSourceArgs.model_validate({
"output_dir":
"/tmp"})
169 with pytest.raises(ValidationError):
170 NewArgs.model_validate({})
173 (tmp_path /
"src").mkdir()
174 m = NewArgs.model_validate(
175 {
"path":
"src/foo.py"},
176 context={
"project_root": tmp_path},
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
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},
193 with pytest.raises(ValidationError):
194 NewArgs.model_validate({
"path":
"src/foo; rm -rf /"})
197 with pytest.raises(ValidationError):
198 NewArgs.model_validate({
"path":
"src/foo.py",
"evil":
True})
207 with pytest.raises(ValidationError):
208 ScaffoldArgs.model_validate({})
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
217 with pytest.raises(ValidationError):
218 ScaffoldArgs.model_validate({
"name":
"proj; rm -rf /"})
221 with pytest.raises(ValidationError):
222 ScaffoldArgs.model_validate({
"name":
"myproject",
"evil":
True})
231 m = CleanArgs.model_validate({})
232 assert m.path
is None
233 assert m.confirm
is False
234 assert m.dry_run
is True
237 with pytest.raises(ValidationError):
238 CleanArgs.model_validate(
239 {
"path":
"../../../etc"},
240 context={
"project_root": tmp_path},
244 with pytest.raises(ValidationError):
245 CleanArgs.model_validate({
"path":
"/tmp; rm -rf /"})
248 m = CleanArgs.model_validate({
"path":
None})
249 assert m.path
is None
252 with pytest.raises(ValidationError):
253 CleanArgs.model_validate({
"evil":
True})
262 m = InstallHooksArgs.model_validate({})
263 assert m.confirm
is False
264 assert m.force
is False
267 with pytest.raises(ValidationError):
268 InstallHooksArgs.model_validate({
"no_verify":
True})
277 with pytest.raises(ValidationError):
278 GitCommitArgs.model_validate({})
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
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})
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.
297 m = GitCommitArgs.model_validate(
298 {
"message":
"feat(core): add validator parity"}
300 assert m.message ==
"feat(core): add validator parity"
303 """OI-502: ``feat!:`` is valid Conventional Commits."""
304 m = GitCommitArgs.model_validate(
305 {
"message":
"feat!: drop legacy config"}
307 assert m.message.startswith(
"feat!:")
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"})
315 with pytest.raises(ValidationError):
316 GitCommitArgs.model_validate({
"message":
"badtype: add x"})
319 with pytest.raises(ValidationError):
320 GitCommitArgs.model_validate({
"message":
"feat: Add a thing"})
323 m = GitCommitArgs.model_validate({
"message":
"feat: add new feature"})
324 assert m.message ==
"feat: add new feature"
327 with pytest.raises(ValidationError):
328 GitCommitArgs.model_validate({
"message":
"feat: test",
"evil_flag":
True})
337 m = GitHooksInstallArgs.model_validate({})
338 assert m.confirm
is False
339 assert m.force
is False
342 with pytest.raises(ValidationError):
343 GitHooksInstallArgs.model_validate({
"no_verify":
True})
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
359 with pytest.raises(ValidationError):
360 GitInitArgs.model_validate({
"evil":
True})
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
376 m = GitChangelogArgs.model_validate({
"since":
"v0.14.0"})
377 assert m.since ==
"v0.14.0"
380 with pytest.raises(ValidationError):
381 GitChangelogArgs.model_validate({
"since":
"v0.14.0; rm -rf /"})
384 with pytest.raises(ValidationError):
385 GitChangelogArgs.model_validate({
"version":
"v0.15.0 && evil"})
388 with pytest.raises(ValidationError):
389 GitChangelogArgs.model_validate({
"evil":
True})
401 (
"oct_export_source", {}),
402 (
"oct_new", {
"path":
"src/foo.py"}),
403 (
"oct_scaffold", {
"name":
"myproject"}),
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", {}),
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"
416 with pytest.raises(ValidationError):
419 {
"path":
"../../evil.py"},
420 project_root=tmp_path,
424 with pytest.raises(ValidationError):
425 validate_tool_args(
"oct_clean", {
"evil_key":
True})
434 """T-06b: strict profile must deny all destructive tools."""
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",
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"
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"
456 "oct_format",
"oct_clean",
"oct_git_commit",
458 decision = policy.check(tool_name)
459 assert decision.allowed
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
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
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
487 args = {
"apply":
False,
"json_output":
True,
"paths": [],
"jobs": 1}
488 cmd = _build_argv_format(args, Path(
"/project"))
489 assert "--json" in cmd
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
496 assert "feat: add thing" in cmd
499 args = {
"message":
"feat: test",
"force":
True,
"json_output":
True}
500 cmd = _build_argv_git_commit(args, Path(
"/project"))
501 assert "--force" in cmd
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
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
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
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
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
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
536 args = {
"path":
None,
"dry_run":
True}
537 cmd = _build_argv_clean(args, Path(
"/project"))
538 assert "--dry-run" in cmd
541 args = {
"path":
None,
"dry_run":
False}
542 cmd = _build_argv_clean(args, Path(
"/project"))
543 assert "--dry-run" not in cmd
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
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
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"