tenseleyflow/loader / dcbd31b

Browse files

Protect root html coverage

Authored by espadonne
SHA
dcbd31b9213071b71fefd65cf336f2e38a79db5a
Parents
aa8600f
Tree
a37a246

2 changed files

StatusFile+-
M src/loader/runtime/safeguard_services.py 64 1
M tests/test_runtime_harness.py 55 0
src/loader/runtime/safeguard_services.pymodified
@@ -749,6 +749,13 @@ class PreActionValidator:
749
         if not html_declared_target_result.valid:
749
         if not html_declared_target_result.valid:
750
             return html_declared_target_result
750
             return html_declared_target_result
751
 
751
 
752
+        html_root_coverage_result = self._validate_html_root_link_coverage(
753
+            str(file_path),
754
+            str(content),
755
+        )
756
+        if not html_root_coverage_result.valid:
757
+            return html_root_coverage_result
758
+
752
         return ValidationResult(valid=True)
759
         return ValidationResult(valid=True)
753
 
760
 
754
     def _validate_edit(self, arguments: dict) -> ValidationResult:
761
     def _validate_edit(self, arguments: dict) -> ValidationResult:
@@ -1148,7 +1155,15 @@ class PreActionValidator:
1148
 
1155
 
1149
     def _relative_html_target(self, root: Path, target: Path) -> str | None:
1156
     def _relative_html_target(self, root: Path, target: Path) -> str | None:
1150
         try:
1157
         try:
1151
-            return str(target.relative_to(root))
1158
+            normalized_root = root.resolve(strict=False)
1159
+        except OSError:
1160
+            normalized_root = root.expanduser()
1161
+        try:
1162
+            normalized_target = target.resolve(strict=False)
1163
+        except OSError:
1164
+            normalized_target = target.expanduser()
1165
+        try:
1166
+            return str(normalized_target.relative_to(normalized_root))
1152
         except ValueError:
1167
         except ValueError:
1153
             return None
1168
             return None
1154
 
1169
 
@@ -1281,3 +1296,51 @@ class PreActionValidator:
1281
             )
1296
             )
1282
 
1297
 
1283
         return ValidationResult(valid=True)
1298
         return ValidationResult(valid=True)
1299
+
1300
+    def _validate_html_root_link_coverage(
1301
+        self,
1302
+        file_path: str,
1303
+        content: str,
1304
+    ) -> ValidationResult:
1305
+        normalized = Path(file_path).expanduser()
1306
+        if normalized.suffix.lower() != ".html" or normalized.name.lower() != "index.html":
1307
+            return ValidationResult(valid=True)
1308
+        if not normalized.exists():
1309
+            return ValidationResult(valid=True)
1310
+
1311
+        root = self._resolve_html_artifact_root(normalized)
1312
+        try:
1313
+            existing_text = normalized.read_text()
1314
+        except OSError:
1315
+            return ValidationResult(valid=True)
1316
+
1317
+        existing_targets = {
1318
+            relative_target
1319
+            for _href, resolved in self._collect_local_html_targets(normalized, existing_text)
1320
+            if (relative_target := self._relative_html_target(root, resolved)) is not None
1321
+            and resolved.exists()
1322
+        }
1323
+        if not existing_targets:
1324
+            return ValidationResult(valid=True)
1325
+
1326
+        new_targets = {
1327
+            relative_target
1328
+            for _href, resolved in self._collect_local_html_targets(normalized, content)
1329
+            if (relative_target := self._relative_html_target(root, resolved)) is not None
1330
+        }
1331
+        dropped_targets = sorted(existing_targets - new_targets)
1332
+        if not dropped_targets:
1333
+            return ValidationResult(valid=True)
1334
+
1335
+        preview = ", ".join(dropped_targets[:3])
1336
+        if len(dropped_targets) > 3:
1337
+            preview += ", ..."
1338
+        return ValidationResult(
1339
+            valid=False,
1340
+            reason="Edited HTML root page drops links to existing local pages",
1341
+            suggestion=(
1342
+                "Keep the existing local page set linked from the root HTML page "
1343
+                f"unless you are intentionally removing those files, for example restore: {preview}"
1344
+            ),
1345
+            severity="error",
1346
+        )
tests/test_runtime_harness.pymodified
@@ -2024,6 +2024,61 @@ async def test_blocked_html_index_edit_queues_inventory_reuse_steering(
2024
     assert steering_messages == []
2024
     assert steering_messages == []
2025
 
2025
 
2026
 
2026
 
2027
+@pytest.mark.asyncio
2028
+async def test_blocked_root_html_write_cannot_drop_existing_local_pages(
2029
+    temp_dir: Path,
2030
+) -> None:
2031
+    guide_root = temp_dir / "guide"
2032
+    chapters = guide_root / "chapters"
2033
+    chapters.mkdir(parents=True)
2034
+    index_file = guide_root / "index.html"
2035
+    (chapters / "introduction.html").write_text("<h1>Introduction</h1>\n")
2036
+    (chapters / "installation.html").write_text("<h1>Installation</h1>\n")
2037
+    index_file.write_text(
2038
+        "\n".join(
2039
+            [
2040
+                '<a href="chapters/introduction.html">Introduction</a>',
2041
+                '<a href="chapters/installation.html">Installation</a>',
2042
+            ]
2043
+        )
2044
+        + "\n"
2045
+    )
2046
+
2047
+    backend = ScriptedBackend(
2048
+        completions=[
2049
+            native_tool_response(
2050
+                ToolCall(
2051
+                    id="write-1",
2052
+                    name="write",
2053
+                    arguments={
2054
+                        "file_path": str(index_file),
2055
+                        "content": (
2056
+                            "<html><body>"
2057
+                            '<a href="chapters/installation.html">Installation</a>'
2058
+                            "</body></html>\n"
2059
+                        ),
2060
+                    },
2061
+                ),
2062
+                content="I'll rewrite the root page.",
2063
+            ),
2064
+            final_response("I'll keep the guide coherent."),
2065
+        ]
2066
+    )
2067
+
2068
+    run = await run_scenario(
2069
+        "Update the guide root page.",
2070
+        backend,
2071
+        config=non_streaming_config(),
2072
+        project_root=temp_dir,
2073
+    )
2074
+
2075
+    messages = tool_result_messages(run)
2076
+    assert any(
2077
+        "Edited HTML root page drops links to existing local pages" in message
2078
+        for message in messages
2079
+    )
2080
+
2081
+
2027
 @pytest.mark.asyncio
2082
 @pytest.mark.asyncio
2028
 async def test_full_path_glob_pattern_still_injects_verified_html_inventory(
2083
 async def test_full_path_glob_pattern_still_injects_verified_html_inventory(
2029
     temp_dir: Path,
2084
     temp_dir: Path,