tenseleyflow/loader / 7e3369e

Browse files

Enforce post-build verify scope

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
7e3369e75f80072dd903282d8853135cce457831
Parents
5f3c87e
Tree
be55f2f

2 changed files

StatusFile+-
M src/loader/runtime/hooks.py 6 6
M tests/test_permissions.py 74 3
src/loader/runtime/hooks.pymodified
@@ -903,8 +903,6 @@ class LateReferenceDriftHook(BaseToolHook):
903903
     async def pre_tool_use(self, context: HookContext) -> HookResult:
904904
         if context.tool_call.name not in _OBSERVATION_TOOLS:
905905
             return HookResult()
906
-        if context.source == "verification":
907
-            return HookResult()
908906
 
909907
         completed_scope = self._completed_artifact_scope()
910908
         if completed_scope is not None:
@@ -914,8 +912,7 @@ class LateReferenceDriftHook(BaseToolHook):
914912
             if all(path_within_allowed_roots(path, completed_scope) for path in observed_paths):
915913
                 self._sync_completed_scope_state(completed_scope)
916914
                 if (
917
-                    context.source != "verification"
918
-                    and self._completed_scope_observation_count
915
+                    self._completed_scope_observation_count
919916
                     >= self._MAX_COMPLETED_SCOPE_OBSERVATIONS
920917
                 ):
921918
                     roots_preview = ", ".join(f"`{root}`" for root in completed_scope[:2])
@@ -948,6 +945,9 @@ class LateReferenceDriftHook(BaseToolHook):
948945
                 terminal_state="blocked",
949946
             )
950947
 
948
+        if context.source == "verification":
949
+            return HookResult()
950
+
951951
         late_stage = self._late_stage_missing_artifact()
952952
         if late_stage is None:
953953
             return HookResult()
@@ -1038,11 +1038,11 @@ class LateReferenceDriftHook(BaseToolHook):
10381038
             return HookResult()
10391039
         if context.tool_call.name not in _OBSERVATION_TOOLS:
10401040
             return HookResult()
1041
-        if context.source == "verification":
1042
-            return HookResult()
10431041
 
10441042
         completed_scope = self._completed_artifact_scope()
10451043
         if completed_scope is None:
1044
+            if context.source == "verification":
1045
+                return HookResult()
10461046
             self._reset_completed_scope_state()
10471047
             return HookResult()
10481048
 
tests/test_permissions.pymodified
@@ -1606,7 +1606,7 @@ async def test_late_reference_drift_hook_blocks_reference_reads_after_artifacts_
16061606
 
16071607
 
16081608
 @pytest.mark.asyncio
1609
-async def test_late_reference_drift_hook_allows_verification_reference_reads_after_artifacts_exist(
1609
+async def test_late_reference_drift_hook_blocks_verification_reference_reads_after_artifacts_exist(
16101610
     temp_dir: Path,
16111611
 ) -> None:
16121612
     registry = create_default_registry(temp_dir)
@@ -1662,8 +1662,10 @@ async def test_late_reference_drift_hook_allows_verification_reference_reads_aft
16621662
         )
16631663
     )
16641664
 
1665
-    assert result.decision == HookDecision.CONTINUE
1666
-    assert result.message is None
1665
+    assert result.decision == HookDecision.DENY
1666
+    assert result.terminal_state == "blocked"
1667
+    assert result.message is not None
1668
+    assert "completed artifact set scope" in result.message
16671669
 
16681670
 
16691671
 @pytest.mark.asyncio
@@ -1735,6 +1737,75 @@ async def test_late_reference_drift_hook_blocks_excessive_post_build_self_audits
17351737
     assert "post-build audit loop" in blocked.message
17361738
 
17371739
 
1740
+@pytest.mark.asyncio
1741
+async def test_late_reference_drift_hook_blocks_excessive_post_build_self_audits_during_verification(
1742
+    temp_dir: Path,
1743
+) -> None:
1744
+    registry = create_default_registry(temp_dir)
1745
+    policy = build_permission_policy(
1746
+        active_mode=PermissionMode.WORKSPACE_WRITE,
1747
+        workspace_root=temp_dir,
1748
+        tool_requirements=registry.get_tool_requirements(),
1749
+    )
1750
+    dod_store = DefinitionOfDoneStore(temp_dir)
1751
+    dod = create_definition_of_done("Create a multi-file guide from a reference")
1752
+    dod.status = "in_progress"
1753
+    plan_path = temp_dir / "implementation.md"
1754
+    plan_path.write_text(
1755
+        "\n".join(
1756
+            [
1757
+                "# Implementation Plan",
1758
+                "",
1759
+                "## File Changes",
1760
+                f"- `{temp_dir / 'guide' / 'index.html'}`",
1761
+                f"- `{temp_dir / 'guide' / 'chapters' / '01-getting-started.html'}`",
1762
+                f"- `{temp_dir / 'guide' / 'chapters' / '02-installation.html'}`",
1763
+                "",
1764
+            ]
1765
+        )
1766
+    )
1767
+    dod.implementation_plan = str(plan_path)
1768
+    guide_dir = temp_dir / "guide" / "chapters"
1769
+    guide_dir.mkdir(parents=True, exist_ok=True)
1770
+    target = guide_dir / "02-installation.html"
1771
+    (temp_dir / "guide" / "index.html").write_text("<h1>Nginx Guide</h1>\n")
1772
+    (guide_dir / "01-getting-started.html").write_text("<h1>Getting Started</h1>\n")
1773
+    target.write_text("<h1>Installation</h1>\n")
1774
+    dod_path = dod_store.save(dod)
1775
+    session = FakeSession(active_dod_path=str(dod_path), messages=[])
1776
+    hook = LateReferenceDriftHook(
1777
+        dod_store=dod_store,
1778
+        project_root=temp_dir,
1779
+        session=session,
1780
+    )
1781
+
1782
+    def make_context(index: int) -> HookContext:
1783
+        return HookContext(
1784
+            tool_call=ToolCall(
1785
+                id=f"read-verify-{index}",
1786
+                name="read",
1787
+                arguments={"file_path": str(target)},
1788
+            ),
1789
+            tool=registry.get("read"),
1790
+            registry=registry,
1791
+            permission_policy=policy,
1792
+            source="verification",
1793
+        )
1794
+
1795
+    for index in range(1, 5):
1796
+        context = make_context(index)
1797
+        result = await hook.pre_tool_use(context)
1798
+        assert result.decision == HookDecision.CONTINUE
1799
+        await hook.post_tool_use(context)
1800
+
1801
+    blocked = await hook.pre_tool_use(make_context(5))
1802
+
1803
+    assert blocked.decision == HookDecision.DENY
1804
+    assert blocked.terminal_state == "blocked"
1805
+    assert blocked.message is not None
1806
+    assert "post-build audit loop" in blocked.message
1807
+
1808
+
17381809
 @pytest.mark.asyncio
17391810
 async def test_late_reference_drift_hook_does_not_treat_empty_output_dir_as_complete_artifact_set(
17401811
     temp_dir: Path,