Require edits during repair audits
Authored by
mfwolffe <wolffemf@dukes.jmu.edu>
- SHA
7b1c3380b91870e0bc4f654b0ad887525fd570ea- Parents
-
b69a3fe - Tree
1f79d21
7b1c338
7b1c3380b91870e0bc4f654b0ad887525fd570eab69a3fe
1f79d21| Status | File | + | - |
|---|---|---|---|
| M |
src/loader/runtime/hooks.py
|
21 | 3 |
| M |
src/loader/runtime/safeguard_services.py
|
47 | 0 |
| M |
tests/test_permissions.py
|
87 | 0 |
| M |
tests/test_safeguard_services.py
|
44 | 0 |
src/loader/runtime/hooks.pymodified@@ -1014,14 +1014,32 @@ class LateReferenceDriftHook(BaseToolHook): | ||
| 1014 | 1014 | roots_preview = ", ".join(f"`{root}`" for root in completed_scope[:2]) |
| 1015 | 1015 | if len(completed_scope) > 2: |
| 1016 | 1016 | roots_preview += ", ..." |
| 1017 | + repair = extract_active_repair_context( | |
| 1018 | + getattr(self.session, "messages", []) | |
| 1019 | + ) | |
| 1020 | + if repair is not None and repair.allowed_paths: | |
| 1021 | + repair_preview = ", ".join( | |
| 1022 | + f"`{path}`" for path in repair.allowed_paths[:3] | |
| 1023 | + ) | |
| 1024 | + if len(repair.allowed_paths) > 3: | |
| 1025 | + repair_preview += ", ..." | |
| 1026 | + suggestion = ( | |
| 1027 | + "make one concrete edit, patch, or write to the active " | |
| 1028 | + f"repair file(s) {repair_preview}. Do not finish with a " | |
| 1029 | + "final response while these verification repair targets remain." | |
| 1030 | + ) | |
| 1031 | + else: | |
| 1032 | + suggestion = ( | |
| 1033 | + "finish with a final response so Loader can verify automatically, " | |
| 1034 | + "or make one concrete edit for a specific mismatch inside " | |
| 1035 | + f"{roots_preview} instead of more rereads." | |
| 1036 | + ) | |
| 1017 | 1037 | return HookResult( |
| 1018 | 1038 | decision=HookDecision.DENY, |
| 1019 | 1039 | message=( |
| 1020 | 1040 | "[Blocked - post-build audit loop: all explicitly planned artifacts " |
| 1021 | 1041 | "already exist and the current output set has already been inspected " |
| 1022 | - "several times.] Suggestion: finish with a final response so Loader " | |
| 1023 | - "can verify automatically, or make one concrete edit for a specific mismatch inside " | |
| 1024 | - f"{roots_preview} instead of more rereads." | |
| 1042 | + f"several times.] Suggestion: {suggestion}" | |
| 1025 | 1043 | ), |
| 1026 | 1044 | terminal_state="blocked", |
| 1027 | 1045 | ) |
src/loader/runtime/safeguard_services.pymodified@@ -793,6 +793,13 @@ class PreActionValidator: | ||
| 793 | 793 | severity="block", |
| 794 | 794 | ) |
| 795 | 795 | |
| 796 | + html_link_scope_result = self._validate_html_write_local_link_scope( | |
| 797 | + str(file_path), | |
| 798 | + str(content), | |
| 799 | + ) | |
| 800 | + if not html_link_scope_result.valid: | |
| 801 | + return html_link_scope_result | |
| 802 | + | |
| 796 | 803 | html_declared_target_result = self._validate_html_declared_target_set( |
| 797 | 804 | str(file_path), |
| 798 | 805 | str(content), |
@@ -1008,6 +1015,46 @@ class PreActionValidator: | ||
| 1008 | 1015 | "\n".join(added_fragments), |
| 1009 | 1016 | ) |
| 1010 | 1017 | |
| 1018 | + def _validate_html_write_local_link_scope( | |
| 1019 | + self, | |
| 1020 | + file_path: str, | |
| 1021 | + content: str, | |
| 1022 | + ) -> ValidationResult: | |
| 1023 | + normalized = Path(file_path).expanduser() | |
| 1024 | + if normalized.suffix.lower() not in {".html", ".htm"}: | |
| 1025 | + return ValidationResult(valid=True) | |
| 1026 | + | |
| 1027 | + root = ( | |
| 1028 | + normalized.parent | |
| 1029 | + if normalized.name.lower() in {"index.html", "index.htm"} | |
| 1030 | + else self._resolve_html_artifact_root(normalized) | |
| 1031 | + ) | |
| 1032 | + outside_missing: list[str] = [] | |
| 1033 | + for href, resolved in self._collect_local_html_targets(normalized, content): | |
| 1034 | + if resolved.exists(): | |
| 1035 | + continue | |
| 1036 | + if self._relative_html_target(root, resolved) is not None: | |
| 1037 | + continue | |
| 1038 | + if href not in outside_missing: | |
| 1039 | + outside_missing.append(href) | |
| 1040 | + | |
| 1041 | + if not outside_missing: | |
| 1042 | + return ValidationResult(valid=True) | |
| 1043 | + | |
| 1044 | + preview = ", ".join(outside_missing[:3]) | |
| 1045 | + if len(outside_missing) > 3: | |
| 1046 | + preview += ", ..." | |
| 1047 | + return ValidationResult( | |
| 1048 | + valid=False, | |
| 1049 | + reason="HTML page links outside the current artifact root", | |
| 1050 | + suggestion=( | |
| 1051 | + "Keep local HTML href values inside the generated artifact root. " | |
| 1052 | + f"Missing out-of-scope href(s): {preview}. Remove the parent/outside " | |
| 1053 | + "link or replace it with an existing in-scope local target." | |
| 1054 | + ), | |
| 1055 | + severity="error", | |
| 1056 | + ) | |
| 1057 | + | |
| 1011 | 1058 | def _validate_html_local_asset_links( |
| 1012 | 1059 | self, |
| 1013 | 1060 | file_path: str, |
tests/test_permissions.pymodified@@ -1898,6 +1898,93 @@ async def test_late_reference_drift_hook_blocks_excessive_post_build_self_audits | ||
| 1898 | 1898 | assert "post-build audit loop" in blocked.message |
| 1899 | 1899 | |
| 1900 | 1900 | |
| 1901 | +@pytest.mark.asyncio | |
| 1902 | +async def test_late_reference_drift_hook_requires_edit_during_active_repair_audit_loop( | |
| 1903 | + temp_dir: Path, | |
| 1904 | +) -> None: | |
| 1905 | + registry = create_default_registry(temp_dir) | |
| 1906 | + policy = build_permission_policy( | |
| 1907 | + active_mode=PermissionMode.WORKSPACE_WRITE, | |
| 1908 | + workspace_root=temp_dir, | |
| 1909 | + tool_requirements=registry.get_tool_requirements(), | |
| 1910 | + ) | |
| 1911 | + dod_store = DefinitionOfDoneStore(temp_dir) | |
| 1912 | + dod = create_definition_of_done("Create a multi-file guide from a reference") | |
| 1913 | + dod.status = "in_progress" | |
| 1914 | + guide_root = temp_dir / "guide" | |
| 1915 | + chapters = guide_root / "chapters" | |
| 1916 | + chapters.mkdir(parents=True, exist_ok=True) | |
| 1917 | + index_path = guide_root / "index.html" | |
| 1918 | + intro_path = chapters / "01-introduction.html" | |
| 1919 | + config_path = chapters / "03-basic-configuration.html" | |
| 1920 | + index_path.write_text("<h1>Nginx Guide</h1>\n") | |
| 1921 | + intro_path.write_text("<h1>Introduction</h1>\n") | |
| 1922 | + config_path.write_text("<h1>Configuration</h1>\n") | |
| 1923 | + plan_path = temp_dir / "implementation.md" | |
| 1924 | + plan_path.write_text( | |
| 1925 | + "\n".join( | |
| 1926 | + [ | |
| 1927 | + "# Implementation Plan", | |
| 1928 | + "", | |
| 1929 | + "## File Changes", | |
| 1930 | + f"- `{index_path}`", | |
| 1931 | + f"- `{chapters}/`", | |
| 1932 | + "", | |
| 1933 | + ] | |
| 1934 | + ) | |
| 1935 | + ) | |
| 1936 | + dod.implementation_plan = str(plan_path) | |
| 1937 | + dod_path = dod_store.save(dod) | |
| 1938 | + session = FakeSession( | |
| 1939 | + active_dod_path=str(dod_path), | |
| 1940 | + messages=[ | |
| 1941 | + Message( | |
| 1942 | + role=Role.USER, | |
| 1943 | + content=( | |
| 1944 | + "Repair focus:\n" | |
| 1945 | + f"- Improve `{index_path}`: insufficient structured content.\n" | |
| 1946 | + f"- Improve `{intro_path}`: insufficient structured content.\n" | |
| 1947 | + f"- Improve `{config_path}`: thin content.\n" | |
| 1948 | + f"- Immediate next step: edit `{index_path}` with a substantial expansion.\n" | |
| 1949 | + ), | |
| 1950 | + ) | |
| 1951 | + ], | |
| 1952 | + ) | |
| 1953 | + hook = LateReferenceDriftHook( | |
| 1954 | + dod_store=dod_store, | |
| 1955 | + project_root=temp_dir, | |
| 1956 | + session=session, | |
| 1957 | + ) | |
| 1958 | + | |
| 1959 | + def make_context(index: int) -> HookContext: | |
| 1960 | + return HookContext( | |
| 1961 | + tool_call=ToolCall( | |
| 1962 | + id=f"read-{index}", | |
| 1963 | + name="read", | |
| 1964 | + arguments={"file_path": str(index_path)}, | |
| 1965 | + ), | |
| 1966 | + tool=registry.get("read"), | |
| 1967 | + registry=registry, | |
| 1968 | + permission_policy=policy, | |
| 1969 | + source="native", | |
| 1970 | + ) | |
| 1971 | + | |
| 1972 | + for index in range(1, 5): | |
| 1973 | + context = make_context(index) | |
| 1974 | + result = await hook.pre_tool_use(context) | |
| 1975 | + assert result.decision == HookDecision.CONTINUE | |
| 1976 | + await hook.post_tool_use(context) | |
| 1977 | + | |
| 1978 | + blocked = await hook.pre_tool_use(make_context(5)) | |
| 1979 | + | |
| 1980 | + assert blocked.decision == HookDecision.DENY | |
| 1981 | + assert blocked.message is not None | |
| 1982 | + assert "post-build audit loop" in blocked.message | |
| 1983 | + assert "make one concrete edit, patch, or write" in blocked.message | |
| 1984 | + assert "Do not finish with a final response" in blocked.message | |
| 1985 | + assert str(index_path.resolve(strict=False)) in blocked.message | |
| 1986 | + | |
| 1987 | + | |
| 1901 | 1988 | @pytest.mark.asyncio |
| 1902 | 1989 | async def test_late_reference_drift_hook_allows_post_build_self_audits_during_verification( |
| 1903 | 1990 | temp_dir: Path, |
tests/test_safeguard_services.pymodified@@ -461,6 +461,50 @@ def test_pre_action_validator_allows_existing_local_html_asset_href( | ||
| 461 | 461 | assert result.valid is True |
| 462 | 462 | |
| 463 | 463 | |
| 464 | +def test_pre_action_validator_blocks_new_root_index_parent_html_link( | |
| 465 | + tmp_path: Path, | |
| 466 | +) -> None: | |
| 467 | + validator = PreActionValidator() | |
| 468 | + guide = tmp_path / "guides" / "nginx" | |
| 469 | + guide.mkdir(parents=True) | |
| 470 | + | |
| 471 | + result = validator.validate( | |
| 472 | + "write", | |
| 473 | + { | |
| 474 | + "file_path": str(guide / "index.html"), | |
| 475 | + "content": ( | |
| 476 | + '<html><body><a href="chapters/01-introduction.html">Intro</a>' | |
| 477 | + '<p><a href="../index.html">Back to Main Index</a></p></body></html>' | |
| 478 | + ), | |
| 479 | + }, | |
| 480 | + ) | |
| 481 | + | |
| 482 | + assert result.valid is False | |
| 483 | + assert result.reason == "HTML page links outside the current artifact root" | |
| 484 | + assert "../index.html" in result.suggestion | |
| 485 | + | |
| 486 | + | |
| 487 | +def test_pre_action_validator_allows_new_root_index_to_seed_child_html_links( | |
| 488 | + tmp_path: Path, | |
| 489 | +) -> None: | |
| 490 | + validator = PreActionValidator() | |
| 491 | + guide = tmp_path / "guides" / "nginx" | |
| 492 | + guide.mkdir(parents=True) | |
| 493 | + | |
| 494 | + result = validator.validate( | |
| 495 | + "write", | |
| 496 | + { | |
| 497 | + "file_path": str(guide / "index.html"), | |
| 498 | + "content": ( | |
| 499 | + '<html><body><a href="chapters/01-introduction.html">Intro</a>' | |
| 500 | + '<a href="chapters/02-installation.html">Install</a></body></html>' | |
| 501 | + ), | |
| 502 | + }, | |
| 503 | + ) | |
| 504 | + | |
| 505 | + assert result.valid is True | |
| 506 | + | |
| 507 | + | |
| 464 | 508 | def test_pre_action_validator_blocks_shell_text_rewrite_for_html_target() -> None: |
| 465 | 509 | validator = PreActionValidator() |
| 466 | 510 | |