tenseleyflow/loader / 8b65eda

Browse files

Filter reference verification commands

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
8b65edab3e1a3c3365ec7b1d070398052d310d17
Parents
add1107
Tree
0a61056

3 changed files

StatusFile+-
M src/loader/runtime/dod.py 72 0
M src/loader/runtime/finalization.py 25 7
M tests/test_finalization.py 78 0
src/loader/runtime/dod.pymodified
@@ -359,6 +359,43 @@ def derive_verification_commands(
359359
     return commands
360360
 
361361
 
362
+def sanitize_verification_commands(
363
+    commands: list[str],
364
+    *,
365
+    dod: DefinitionOfDone,
366
+    project_root: Path,
367
+) -> list[str]:
368
+    """Drop verification commands that reopen reference paths outside the planned output roots."""
369
+
370
+    if not commands:
371
+        return []
372
+
373
+    planned_targets = collect_planned_artifact_targets(
374
+        dod,
375
+        project_root=project_root,
376
+    )
377
+    if not planned_targets:
378
+        return list(dict.fromkeys(command for command in commands if command))
379
+
380
+    allowed_roots = {
381
+        str(target if expect_directory else target.parent)
382
+        for target, expect_directory in planned_targets
383
+    }
384
+    sanitized: list[str] = []
385
+    for command in commands:
386
+        normalized = str(command or "").strip()
387
+        if not normalized:
388
+            continue
389
+        detected_paths = _extract_verification_command_paths(normalized)
390
+        if detected_paths and any(
391
+            not _path_is_within_allowed_verification_roots(path, allowed_roots)
392
+            for path in detected_paths
393
+        ):
394
+            continue
395
+        _append_unique(sanitized, normalized)
396
+    return sanitized
397
+
398
+
362399
 def build_verification_summary(evidence: list[VerificationEvidence]) -> str:
363400
     """Create a short evidence summary for the final user response."""
364401
 
@@ -525,6 +562,41 @@ def _path_is_within_root(path: Path, root: Path) -> bool:
525562
         return False
526563
 
527564
 
565
+def _extract_verification_command_paths(command: str) -> list[Path]:
566
+    try:
567
+        parts = shlex.split(command)
568
+    except ValueError:
569
+        return []
570
+
571
+    detected: list[Path] = []
572
+    for token in parts:
573
+        candidate = str(token or "").strip().strip("\"'")
574
+        if not candidate or not candidate.startswith(("/", "~")):
575
+            continue
576
+        try:
577
+            path = Path(candidate).expanduser()
578
+        except (OSError, RuntimeError, ValueError):
579
+            continue
580
+        detected.append(path)
581
+    return detected
582
+
583
+
584
+def _path_is_within_allowed_verification_roots(path: Path, allowed_roots: set[str]) -> bool:
585
+    try:
586
+        candidate = path.expanduser().resolve(strict=False)
587
+    except (OSError, RuntimeError, ValueError):
588
+        candidate = path.expanduser()
589
+
590
+    for raw_root in allowed_roots:
591
+        try:
592
+            root = Path(raw_root).expanduser().resolve(strict=False)
593
+        except (OSError, RuntimeError, ValueError):
594
+            root = Path(raw_root).expanduser()
595
+        if candidate == root or str(candidate).startswith(f"{root}/"):
596
+            return True
597
+    return False
598
+
599
+
528600
 def synthesize_todo_items(dod: DefinitionOfDone) -> list[dict[str, str]]:
529601
     """Build a todo item list from the current DoD state.
530602
 
src/loader/runtime/finalization.pymodified
@@ -19,6 +19,7 @@ from .dod import (
1919
     derive_verification_commands,
2020
     ensure_active_verification_attempt,
2121
     planned_artifact_target_satisfied,
22
+    sanitize_verification_commands,
2223
     synthesize_todo_items,
2324
 )
2425
 from .events import AgentEvent, TurnSummary
@@ -354,8 +355,12 @@ class TurnFinalizer:
354355
             and dod.verification_plan
355356
             and Path(dod.verification_plan).exists()
356357
         ):
357
-            dod.verification_commands = extract_verification_commands_from_markdown(
358
-                Path(dod.verification_plan).read_text()
358
+            dod.verification_commands = sanitize_verification_commands(
359
+                extract_verification_commands_from_markdown(
360
+                    Path(dod.verification_plan).read_text()
361
+                ),
362
+                dod=dod,
363
+                project_root=self.context.project_root,
359364
             )
360365
 
361366
         if (
@@ -363,15 +368,23 @@ class TurnFinalizer:
363368
             and dod.implementation_plan
364369
             and Path(dod.implementation_plan).exists()
365370
         ):
366
-            dod.verification_commands = extract_verification_commands_from_markdown(
367
-                Path(dod.implementation_plan).read_text()
371
+            dod.verification_commands = sanitize_verification_commands(
372
+                extract_verification_commands_from_markdown(
373
+                    Path(dod.implementation_plan).read_text()
374
+                ),
375
+                dod=dod,
376
+                project_root=self.context.project_root,
368377
             )
369378
 
370379
         if not dod.verification_commands:
371
-            dod.verification_commands = derive_verification_commands(
372
-                dod,
380
+            dod.verification_commands = sanitize_verification_commands(
381
+                derive_verification_commands(
382
+                    dod,
383
+                    project_root=self.context.project_root,
384
+                    task_statement=dod.task_statement,
385
+                ),
386
+                dod=dod,
373387
                 project_root=self.context.project_root,
374
-                task_statement=dod.task_statement,
375388
             )
376389
         else:
377390
             for command in derive_verification_commands(
@@ -382,6 +395,11 @@ class TurnFinalizer:
382395
             ):
383396
                 if command not in dod.verification_commands:
384397
                     dod.verification_commands.append(command)
398
+            dod.verification_commands = sanitize_verification_commands(
399
+                dod.verification_commands,
400
+                dod=dod,
401
+                project_root=self.context.project_root,
402
+            )
385403
 
386404
         await self.set_workflow_mode(
387405
             ModeDecision.transition(
tests/test_finalization.pymodified
@@ -605,6 +605,84 @@ async def test_turn_finalizer_does_not_append_repo_defaults_to_external_verifica
605605
     ]
606606
 
607607
 
608
+@pytest.mark.asyncio
609
+async def test_turn_finalizer_filters_reference_side_verification_commands(
610
+    temp_dir: Path,
611
+) -> None:
612
+    guide_root = temp_dir / "Loader" / "guides" / "nginx"
613
+    chapters = guide_root / "chapters"
614
+    chapters.mkdir(parents=True)
615
+    index_path = guide_root / "index.html"
616
+    chapter_one = chapters / "01-introduction.html"
617
+    index_path.write_text("<html><body><h1>Guide</h1></body></html>\n")
618
+    chapter_one.write_text("<html><body><h1>Intro</h1></body></html>\n")
619
+
620
+    reference_root = temp_dir / "Loader" / "guides" / "fortran"
621
+    reference_root.mkdir(parents=True)
622
+
623
+    implementation_plan = temp_dir / "implementation.md"
624
+    implementation_plan.write_text(
625
+        "\n".join(
626
+            [
627
+                "# Implementation Plan",
628
+                "",
629
+                "## File Changes",
630
+                f"- `{guide_root}`",
631
+                f"- `{chapters}`",
632
+                f"- `{index_path}`",
633
+                f"- `{chapter_one}`",
634
+                "",
635
+            ]
636
+        )
637
+    )
638
+    verification_plan = temp_dir / "verification.md"
639
+    verification_plan.write_text(
640
+        "\n".join(
641
+            [
642
+                "# Verification Plan",
643
+                "",
644
+                "## Verification Commands",
645
+                "```bash",
646
+                f"ls -la {guide_root}",
647
+                f"ls -la {reference_root}",
648
+                "```",
649
+                "",
650
+            ]
651
+        )
652
+    )
653
+
654
+    session = FakeSession()
655
+    context = build_context(temp_dir, session)
656
+    finalizer = TurnFinalizer(
657
+        context,
658
+        RuntimeTracer(),
659
+        DefinitionOfDoneStore(temp_dir),
660
+        set_workflow_mode=_noop_set_workflow_mode,
661
+    )
662
+    dod = create_definition_of_done("Create an nginx guide from an external reference.")
663
+    dod.mutating_actions.append("write")
664
+    dod.touched_files.extend([str(index_path), str(chapter_one)])
665
+    dod.implementation_plan = str(implementation_plan)
666
+    dod.verification_plan = str(verification_plan)
667
+    summary = TurnSummary(final_response="")
668
+    executor = RecordingExecutor()
669
+
670
+    async def capture(event) -> None:
671
+        return None
672
+
673
+    result = await finalizer.run_definition_of_done_gate(
674
+        dod=dod,
675
+        candidate_response="Created the nginx guide.",
676
+        emit=capture,
677
+        summary=summary,
678
+        executor=executor,  # type: ignore[arg-type]
679
+    )
680
+
681
+    assert result.should_continue is False
682
+    assert any(str(guide_root) in command for command in executor.commands)
683
+    assert all(str(reference_root) not in command for command in executor.commands)
684
+
685
+
608686
 @pytest.mark.asyncio
609687
 async def test_turn_finalizer_blocks_completion_when_planned_artifacts_are_missing(
610688
     temp_dir: Path,