Python · 29648 bytes Raw Blame History
1 """Tests for runtime-owned safeguard services."""
2
3 from __future__ import annotations
4
5 import tempfile
6 from pathlib import Path
7
8 import loader.agent.safeguards as agent_safeguards
9 from loader.agent.safeguards import RuntimeSafeguards as AgentRuntimeSafeguards
10 from loader.runtime.safeguard_services import (
11 ActionTracker,
12 PreActionValidator,
13 ValidationResult,
14 )
15 from loader.runtime.safeguards import RuntimeSafeguards
16 from loader.runtime.semantic_rules.html_toc import (
17 build_html_toc_edit_call_template,
18 build_html_toc_replacement_block,
19 format_html_inventory_entry,
20 task_targets_html_toc,
21 validate_html_toc,
22 )
23
24
25 def test_action_tracker_detects_duplicate_write_after_recording(tmp_path) -> None:
26 tracker = ActionTracker()
27 file_path = tmp_path / "notes.txt"
28 arguments = {"file_path": str(file_path), "content": "alpha\n"}
29
30 assert tracker.check_tool_call("write", arguments) == (False, "")
31
32 tracker.record_tool_call("write", arguments)
33
34 is_duplicate, reason = tracker.check_tool_call("write", arguments)
35
36 assert is_duplicate is True
37 assert str(file_path) in reason
38
39
40 def test_task_targets_html_toc_requires_explicit_repair_intent() -> None:
41 prompt = (
42 "Have a look at ~/Loader/guides/fortran and chapters/ within. Get a feel "
43 "for the structure and cadence of the guide. We are going to make an all "
44 "new equally thorough guide on how to use the nginx tool. It will live in "
45 "~/Loader/guides/nginx/index.html and ~/Loader/guides/nginx/chapters/."
46 )
47
48 assert task_targets_html_toc(prompt) is False
49
50
51 def test_build_html_toc_replacement_block_uses_verified_inventory(tmp_path) -> None:
52 chapters = tmp_path / "chapters"
53 chapters.mkdir()
54 (chapters / "01-introduction.html").write_text(
55 "<h1>Chapter 1: Introduction to Fortran</h1>\n"
56 )
57 (chapters / "02-setup.html").write_text(
58 "<h1>Chapter 2: Setting Up Your Environment</h1>\n"
59 )
60 index_path = tmp_path / "index.html"
61 index_path.write_text(
62 "<h2>Table of Contents</h2>\n"
63 "<ul class=\"chapter-list\">\n"
64 " <li><a href=\"chapters/01-old.html\">Chapter 1: Old</a></li>\n"
65 "</ul>\n"
66 )
67
68 replacement = build_html_toc_replacement_block(index_path)
69
70 assert replacement is not None
71 assert "<h2>Table of Contents</h2>" in replacement
72 assert '<li><a href="chapters/01-introduction.html">Chapter 1: Introduction to Fortran</a></li>' in replacement
73 assert '<li><a href="chapters/02-setup.html">Chapter 2: Setting Up Your Environment</a></li>' in replacement
74
75
76 def test_build_html_toc_edit_call_template_uses_current_and_replacement_blocks(tmp_path) -> None:
77 chapters = tmp_path / "chapters"
78 chapters.mkdir()
79 (chapters / "01-introduction.html").write_text(
80 "<h1>Chapter 1: Introduction to Fortran</h1>\n"
81 )
82 index_path = tmp_path / "index.html"
83 index_path.write_text(
84 "<h2>Table of Contents</h2>\n"
85 '<ul class="chapter-list">\n'
86 ' <li><a href="chapters/01-old.html">Chapter 1: Old</a></li>\n'
87 "</ul>\n"
88 )
89
90 template = build_html_toc_edit_call_template(index_path)
91
92 assert template is not None
93 assert template.startswith("edit(")
94 assert f'file_path="{index_path}"' in template
95 assert 'old_string="""' in template
96 assert 'new_string="""' in template
97 assert '<li><a href="chapters/01-introduction.html">Chapter 1: Introduction to Fortran</a></li>' in template
98
99
100 def test_validate_html_toc_reports_missing_and_mismatched_links(tmp_path) -> None:
101 chapters = tmp_path / "chapters"
102 chapters.mkdir()
103 (chapters / "01-introduction.html").write_text(
104 "<h1>Chapter 1: Introduction to Fortran</h1>\n"
105 )
106 index_path = tmp_path / "index.html"
107 index_path.write_text(
108 '<ul class="chapter-list">\n'
109 ' <li><a href="chapters/01-introduction.html">Chapter 1: Wrong Title</a></li>\n'
110 ' <li><a href="chapters/02-missing.html">Chapter 2: Missing</a></li>\n'
111 "</ul>\n"
112 )
113
114 result = validate_html_toc(index_path)
115
116 assert result is not None
117 assert result.valid is False
118 assert result.link_count == 2
119 assert result.missing == ("chapters/02-missing.html -> missing",)
120 assert (
121 result.mismatched
122 == (
123 "chapters/01-introduction.html -> Chapter 1: Wrong Title != Chapter 1: Introduction to Fortran",
124 )
125 )
126
127
128 def test_validate_html_toc_reports_success(tmp_path) -> None:
129 chapters = tmp_path / "chapters"
130 chapters.mkdir()
131 (chapters / "01-introduction.html").write_text(
132 "<h1>Chapter 1: Introduction to Fortran</h1>\n"
133 )
134 (chapters / "02-setup.html").write_text(
135 "<h1>Chapter 2: Setting Up Your Environment</h1>\n"
136 )
137 index_path = tmp_path / "index.html"
138 index_path.write_text(
139 '<ul class="chapter-list">\n'
140 ' <li><a href="chapters/01-introduction.html">Chapter 1: Introduction to Fortran</a></li>\n'
141 ' <li><a href="chapters/02-setup.html">Chapter 2: Setting Up Your Environment</a></li>\n'
142 "</ul>\n"
143 )
144
145 result = validate_html_toc(index_path)
146
147 assert result is not None
148 assert result.valid is True
149 assert result.link_count == 2
150 assert result.missing == ()
151 assert result.mismatched == ()
152
153
154 def test_action_tracker_preserves_loop_description_format() -> None:
155 tracker = ActionTracker()
156
157 tracker.record_tool_call("read", {"file_path": "a.txt"})
158 tracker.record_tool_call("grep", {"pattern": "alpha"})
159 tracker.record_tool_call("read", {"file_path": "b.txt"})
160 tracker.record_tool_call("grep", {"pattern": "beta"})
161
162 is_loop, description = tracker.detect_loop()
163
164 assert is_loop is True
165 assert description == "Repeating pattern detected (2x): read → grep"
166
167
168 def test_action_tracker_blocks_repeated_bash_observation_without_changes() -> None:
169 tracker = ActionTracker()
170 arguments = {"command": "ls -la ~/Loader/guides/fortran/chapters/"}
171
172 tracker.record_tool_call("bash", arguments)
173
174 is_duplicate, reason = tracker.check_tool_call("bash", arguments)
175
176 assert is_duplicate is True
177 assert "read-only shell probe" in reason
178
179
180 def test_action_tracker_allows_repeated_bash_observation_after_mutation() -> None:
181 tracker = ActionTracker()
182 bash_args = {"command": "ls -la ~/Loader/guides/fortran/chapters/"}
183 patch_args = {
184 "file_path": "index.html",
185 "hunks": [
186 {
187 "old_start": 1,
188 "old_lines": 1,
189 "new_start": 1,
190 "new_lines": 1,
191 "lines": ["-old", "+new"],
192 }
193 ],
194 }
195
196 tracker.record_tool_call("bash", bash_args)
197 tracker.record_tool_call("patch", patch_args)
198
199 assert tracker.check_tool_call("bash", bash_args) == (False, "")
200
201
202 def test_action_tracker_blocks_repeated_read_without_changes(tmp_path) -> None:
203 tracker = ActionTracker()
204 file_path = tmp_path / "index.html"
205 arguments = {"file_path": str(file_path)}
206
207 tracker.record_tool_call("read", arguments)
208
209 is_duplicate, reason = tracker.check_tool_call("read", arguments)
210
211 assert is_duplicate is True
212 assert str(file_path) in reason
213
214
215 def test_action_tracker_allows_one_interleaved_reread_without_changes(tmp_path) -> None:
216 tracker = ActionTracker()
217 index_path = tmp_path / "index.html"
218 chapter_path = tmp_path / "chapter-1.html"
219
220 tracker.record_tool_call("read", {"file_path": str(index_path)})
221 tracker.record_tool_call("read", {"file_path": str(chapter_path)})
222
223 assert tracker.check_tool_call("read", {"file_path": str(index_path)}) == (False, "")
224
225
226 def test_action_tracker_allows_reading_a_different_slice_of_the_same_file(tmp_path) -> None:
227 tracker = ActionTracker()
228 index_path = tmp_path / "index.html"
229
230 tracker.record_tool_call("read", {"file_path": str(index_path)})
231
232 assert tracker.check_tool_call(
233 "read",
234 {"file_path": str(index_path), "offset": 1, "limit": 50},
235 ) == (False, "")
236
237
238 def test_action_tracker_blocks_fourth_interleaved_reread_without_changes(tmp_path) -> None:
239 tracker = ActionTracker()
240 index_path = tmp_path / "index.html"
241 chapter_a = tmp_path / "chapter-1.html"
242 chapter_b = tmp_path / "chapter-2.html"
243 chapter_c = tmp_path / "chapter-3.html"
244
245 tracker.record_tool_call("read", {"file_path": str(index_path)})
246 tracker.record_tool_call("read", {"file_path": str(chapter_a)})
247 tracker.record_tool_call("read", {"file_path": str(index_path)})
248 tracker.record_tool_call("read", {"file_path": str(chapter_b)})
249 tracker.record_tool_call("read", {"file_path": str(index_path)})
250 tracker.record_tool_call("read", {"file_path": str(chapter_c)})
251
252 is_duplicate, reason = tracker.check_tool_call("read", {"file_path": str(index_path)})
253
254 assert is_duplicate is True
255 assert str(index_path) in reason
256
257
258 def test_action_tracker_allows_one_target_index_reread_after_chapter_discovery(tmp_path) -> None:
259 tracker = ActionTracker()
260 index_path = tmp_path / "index.html"
261 chapters = tmp_path / "chapters"
262 chapter_a = chapters / "01-introduction.html"
263 chapter_b = chapters / "02-setup.html"
264 chapter_c = chapters / "03-basics.html"
265
266 tracker.record_tool_call("read", {"file_path": str(index_path)})
267 tracker.record_tool_call("read", {"file_path": str(chapter_a)})
268 tracker.record_tool_call("read", {"file_path": str(chapter_b)})
269 tracker.record_tool_call("read", {"file_path": str(chapter_c)})
270
271 is_duplicate, reason = tracker.check_tool_call("read", {"file_path": str(index_path)})
272
273 assert is_duplicate is False
274 assert reason == ""
275
276
277 def test_action_tracker_blocks_second_target_index_reread_after_chapter_discovery(tmp_path) -> None:
278 tracker = ActionTracker()
279 index_path = tmp_path / "index.html"
280 chapters = tmp_path / "chapters"
281
282 tracker.record_tool_call("read", {"file_path": str(index_path)})
283 tracker.record_tool_call("read", {"file_path": str(chapters / "01-introduction.html")})
284 tracker.record_tool_call("read", {"file_path": str(chapters / "02-setup.html")})
285 tracker.record_tool_call("read", {"file_path": str(chapters / "03-basics.html")})
286 tracker.record_tool_call("read", {"file_path": str(index_path)})
287
288 is_duplicate, reason = tracker.check_tool_call("read", {"file_path": str(index_path)})
289
290 assert is_duplicate is True
291 assert "reuse the earlier read result instead of rereading" in reason
292
293
294 def test_action_tracker_blocks_repeated_chapter_directory_search_once_titles_are_known(
295 tmp_path,
296 ) -> None:
297 tracker = ActionTracker()
298 chapters = tmp_path / "chapters"
299 search_args = {"pattern": "*.html", "path": str(chapters)}
300
301 tracker.record_tool_call("glob", search_args)
302 tracker.record_tool_call("glob", search_args)
303
304 is_duplicate, reason = tracker.check_tool_call("glob", search_args)
305
306 assert is_duplicate is True
307 assert "reuse the earlier search result instead of rerunning it" in reason
308
309
310 def test_action_tracker_allows_repeated_read_after_mutation(tmp_path) -> None:
311 tracker = ActionTracker()
312 file_path = tmp_path / "index.html"
313 read_args = {"file_path": str(file_path)}
314 edit_args = {
315 "file_path": str(file_path),
316 "old_string": "old",
317 "new_string": "new",
318 }
319
320 tracker.record_tool_call("read", read_args)
321 tracker.record_tool_call("edit", edit_args)
322
323 assert tracker.check_tool_call("read", read_args) == (False, "")
324
325
326 def test_pre_action_validator_blocks_patch_without_hunks() -> None:
327 validator = PreActionValidator()
328
329 result = validator.validate(
330 "patch",
331 {"file_path": "notes.txt", "hunks": []},
332 )
333
334 assert result == ValidationResult(
335 valid=False,
336 reason="Patch hunks are missing",
337 suggestion="Provide structured patch hunks or a unified diff patch string",
338 severity="error",
339 )
340
341
342 def test_pre_action_validator_allows_patch_string_without_hunks() -> None:
343 validator = PreActionValidator()
344
345 result = validator.validate(
346 "patch",
347 {
348 "file_path": "notes.txt",
349 "patch": "--- a/notes.txt\n+++ b/notes.txt\n@@ -1,1 +1,1 @@\n-old\n+new\n",
350 },
351 )
352
353 assert result == ValidationResult(valid=True)
354
355
356 def test_pre_action_validator_blocks_shell_text_rewrite_for_html_target() -> None:
357 validator = PreActionValidator()
358
359 result = validator.validate(
360 "bash",
361 {
362 "command": (
363 "cd /tmp/fortran-qwen-recovery-check && "
364 "sed -i '1,3c\\<li>updated</li>' index.html"
365 )
366 },
367 )
368
369 assert result.valid is False
370 assert result.reason == (
371 "Shell-based text rewrites are brittle and bypass Loader's safer file tools"
372 )
373 assert "edit/patch/write" in result.suggestion
374 assert "index.html" in result.suggestion
375
376
377 def test_pre_action_validator_allows_non_mutating_sed_probe() -> None:
378 validator = PreActionValidator()
379
380 result = validator.validate(
381 "bash",
382 {"command": "sed -n '1,20p' index.html"},
383 )
384
385 assert result == ValidationResult(valid=True)
386
387
388 def test_pre_action_validator_blocks_index_edit_with_missing_chapter_href(tmp_path) -> None:
389 validator = PreActionValidator()
390 index = tmp_path / "index.html"
391 chapters = tmp_path / "chapters"
392 chapters.mkdir()
393 (chapters / "05-input-output.html").write_text(
394 "<h1>Chapter 5: Input and Output</h1>\n"
395 )
396
397 result = validator.validate(
398 "edit",
399 {
400 "file_path": str(index),
401 "old_string": '<li><a href="chapters/05-input-output.html">Chapter 5: Input and Output</a></li>',
402 "new_string": '<li><a href="chapters/05-control-structures.html">Chapter 5: Control Structures</a></li>',
403 },
404 )
405
406 assert result.valid is False
407 assert result.reason == "Edited HTML links point to files that do not exist"
408 assert "chapters/05-control-structures.html" in result.suggestion
409
410
411 def test_pre_action_validator_allows_incomplete_root_index_to_reshape_missing_child_target(
412 tmp_path: Path,
413 ) -> None:
414 validator = PreActionValidator()
415 guide = tmp_path / "guide"
416 chapters = guide / "chapters"
417 chapters.mkdir(parents=True)
418 index = guide / "index.html"
419 index.write_text(
420 "\n".join(
421 [
422 '<li><a href="chapters/01-introduction.html">Chapter 1: Introduction to Nginx</a></li>',
423 '<li><a href="chapters/02-installation.html">Chapter 2: Installation on POSIX Systems</a></li>',
424 '<li><a href="chapters/03-configuration-basics.html">Chapter 3: Configuration Basics</a></li>',
425 "",
426 ]
427 )
428 )
429 (chapters / "01-introduction.html").write_text("<html></html>\n")
430 (chapters / "02-installation.html").write_text("<html></html>\n")
431
432 result = validator.validate(
433 "edit",
434 {
435 "file_path": str(index),
436 "old_string": '<li><a href="chapters/03-configuration-basics.html">Chapter 3: Configuration Basics</a></li>',
437 "new_string": '<li><a href="chapters/03-configuration.html">Chapter 3: Configuration Basics</a></li>',
438 },
439 )
440
441 assert result.valid is True
442
443
444 def test_pre_action_validator_allows_root_index_to_add_next_ordered_missing_sibling(
445 tmp_path: Path,
446 ) -> None:
447 validator = PreActionValidator()
448 guide = tmp_path / "guide"
449 chapters = guide / "chapters"
450 chapters.mkdir(parents=True)
451 index = guide / "index.html"
452 index.write_text(
453 "\n".join(
454 [
455 '<li><a href="chapters/01-introduction.html">Chapter 1: Introduction</a></li>',
456 '<li><a href="chapters/02-installation.html">Chapter 2: Installation</a></li>',
457 '<li><a href="chapters/03-configuration.html">Chapter 3: Configuration</a></li>',
458 '<li><a href="chapters/04-usage.html">Chapter 4: Usage</a></li>',
459 '<li><a href="chapters/05-advanced-topics.html">Chapter 5: Advanced Topics</a></li>',
460 "",
461 ]
462 )
463 )
464 for name in [
465 "01-introduction.html",
466 "02-installation.html",
467 "03-configuration.html",
468 "04-usage.html",
469 "05-advanced-topics.html",
470 ]:
471 (chapters / name).write_text("<html></html>\n")
472
473 result = validator.validate(
474 "edit",
475 {
476 "file_path": str(index),
477 "old_string": (
478 '<li><a href="chapters/05-advanced-topics.html">'
479 "Chapter 5: Advanced Topics</a></li>"
480 ),
481 "new_string": (
482 '<li><a href="chapters/05-advanced-topics.html">'
483 "Chapter 5: Advanced Topics</a></li>\n"
484 '<li><a href="chapters/06-troubleshooting.html">'
485 "Chapter 6: Troubleshooting</a></li>"
486 ),
487 },
488 )
489
490 assert result.valid is True
491
492
493 def test_pre_action_validator_blocks_root_index_from_skipping_to_far_missing_sibling(
494 tmp_path: Path,
495 ) -> None:
496 validator = PreActionValidator()
497 guide = tmp_path / "guide"
498 chapters = guide / "chapters"
499 chapters.mkdir(parents=True)
500 index = guide / "index.html"
501 index.write_text(
502 "\n".join(
503 [
504 '<li><a href="chapters/01-introduction.html">Chapter 1: Introduction</a></li>',
505 '<li><a href="chapters/02-installation.html">Chapter 2: Installation</a></li>',
506 '<li><a href="chapters/03-configuration.html">Chapter 3: Configuration</a></li>',
507 "",
508 ]
509 )
510 )
511 for name in [
512 "01-introduction.html",
513 "02-installation.html",
514 "03-configuration.html",
515 ]:
516 (chapters / name).write_text("<html></html>\n")
517
518 result = validator.validate(
519 "edit",
520 {
521 "file_path": str(index),
522 "old_string": (
523 '<li><a href="chapters/03-configuration.html">'
524 "Chapter 3: Configuration</a></li>"
525 ),
526 "new_string": (
527 '<li><a href="chapters/03-configuration.html">'
528 "Chapter 3: Configuration</a></li>\n"
529 '<li><a href="chapters/08-troubleshooting.html">'
530 "Chapter 8: Troubleshooting</a></li>"
531 ),
532 },
533 )
534
535 assert result.valid is False
536 assert result.reason == "Edited HTML links point to files that do not exist"
537 assert "chapters/08-troubleshooting.html" in result.suggestion
538
539
540 def test_pre_action_validator_blocks_index_edit_with_title_mismatch(tmp_path) -> None:
541 validator = PreActionValidator()
542 index = tmp_path / "index.html"
543 chapters = tmp_path / "chapters"
544 chapters.mkdir()
545 (chapters / "12-troubleshooting-tips.html").write_text(
546 "<h1>Chapter 12: Troubleshooting and Tips</h1>\n"
547 )
548
549 result = validator.validate(
550 "edit",
551 {
552 "file_path": str(index),
553 "old_string": '<li><a href="chapters/12-troubleshooting-tips.html">Chapter 12: Troubleshooting and Tips</a></li>',
554 "new_string": '<li><a href="chapters/12-troubleshooting-tips.html">Chapter 12: Troubleshooting Tips</a></li>',
555 },
556 )
557
558 assert result.valid is True
559
560
561 def test_pre_action_validator_allows_chapter_write_with_future_target_declared_by_index(
562 tmp_path: Path,
563 ) -> None:
564 validator = PreActionValidator()
565 guide = tmp_path / "guide"
566 chapters = guide / "chapters"
567 chapters.mkdir(parents=True)
568 (guide / "index.html").write_text(
569 "\n".join(
570 [
571 '<a href="chapters/introduction.html">Introduction</a>',
572 '<a href="chapters/installation.html">Installation</a>',
573 "",
574 ]
575 )
576 )
577
578 result = validator.validate(
579 "write",
580 {
581 "file_path": str(chapters / "introduction.html"),
582 "content": '<a href="installation.html">Next</a>\n',
583 },
584 )
585
586 assert result.valid is True
587
588
589 def test_pre_action_validator_blocks_undeclared_chapter_file_creation_after_root_seed(
590 tmp_path: Path,
591 ) -> None:
592 validator = PreActionValidator()
593 guide = tmp_path / "guide"
594 chapters = guide / "chapters"
595 chapters.mkdir(parents=True)
596 (guide / "index.html").write_text(
597 "\n".join(
598 [
599 '<a href="chapters/01-introduction.html">Introduction</a>',
600 '<a href="chapters/02-installation.html">Installation</a>',
601 '<a href="chapters/03-configuration.html">Configuration</a>',
602 "",
603 ]
604 )
605 )
606 (chapters / "01-introduction.html").write_text("<html></html>\n")
607 (chapters / "02-installation.html").write_text("<html></html>\n")
608
609 result = validator.validate(
610 "write",
611 {
612 "file_path": str(chapters / "09-monitoring.html"),
613 "content": "<html></html>\n",
614 },
615 )
616
617 assert result.valid is False
618 assert result.reason == "HTML file creation falls outside the current declared artifact set"
619 assert "09-monitoring.html" in result.suggestion
620 assert str((guide / "index.html").resolve(strict=False)) in result.suggestion
621
622
623 def test_pre_action_validator_allows_declared_missing_chapter_file_creation(
624 tmp_path: Path,
625 ) -> None:
626 validator = PreActionValidator()
627 guide = tmp_path / "guide"
628 chapters = guide / "chapters"
629 chapters.mkdir(parents=True)
630 (guide / "index.html").write_text(
631 "\n".join(
632 [
633 '<a href="chapters/01-introduction.html">Introduction</a>',
634 '<a href="chapters/02-installation.html">Installation</a>',
635 '<a href="chapters/03-configuration.html">Configuration</a>',
636 "",
637 ]
638 )
639 )
640 (chapters / "01-introduction.html").write_text("<html></html>\n")
641
642 result = validator.validate(
643 "write",
644 {
645 "file_path": str(chapters / "02-installation.html"),
646 "content": "<html></html>\n",
647 },
648 )
649
650 assert result.valid is True
651
652
653 def test_pre_action_validator_blocks_chapter_write_with_undeclared_missing_sibling(
654 tmp_path: Path,
655 ) -> None:
656 validator = PreActionValidator()
657 guide = tmp_path / "guide"
658 chapters = guide / "chapters"
659 chapters.mkdir(parents=True)
660 (guide / "index.html").write_text(
661 "\n".join(
662 [
663 '<a href="chapters/introduction.html">Introduction</a>',
664 '<a href="chapters/installation.html">Installation</a>',
665 '<a href="chapters/configuration.html">Configuration</a>',
666 '<a href="chapters/usage.html">Usage</a>',
667 '<a href="chapters/troubleshooting.html">Troubleshooting</a>',
668 "",
669 ]
670 )
671 )
672 (chapters / "introduction.html").write_text('<a href="installation.html">Next</a>\n')
673 (chapters / "installation.html").write_text('<a href="configuration.html">Next</a>\n')
674 (chapters / "configuration.html").write_text('<a href="usage.html">Next</a>\n')
675
676 result = validator.validate(
677 "write",
678 {
679 "file_path": str(chapters / "usage.html"),
680 "content": '<a href="advanced.html">Next</a>\n',
681 },
682 )
683
684 assert result.valid is False
685 assert (
686 result.reason
687 == "HTML page introduces new local targets outside the current declared artifact set"
688 )
689 assert "advanced.html" in result.suggestion
690
691
692 def test_pre_action_validator_blocks_chapter_write_with_existing_but_undeclared_sibling(
693 tmp_path: Path,
694 ) -> None:
695 validator = PreActionValidator()
696 guide = tmp_path / "guide"
697 chapters = guide / "chapters"
698 chapters.mkdir(parents=True)
699 (guide / "index.html").write_text(
700 "\n".join(
701 [
702 '<a href="chapters/01-introduction.html">Introduction</a>',
703 '<a href="chapters/02-installation.html">Installation</a>',
704 '<a href="chapters/03-basic-configuration.html">Basic Configuration</a>',
705 '<a href="chapters/04-advanced-configuration.html">Advanced Configuration</a>',
706 "",
707 ]
708 )
709 )
710 (chapters / "01-introduction.html").write_text('<a href="02-installation.html">Next</a>\n')
711 (chapters / "02-installation.html").write_text(
712 '<a href="03-basic-configuration.html">Next</a>\n'
713 )
714 (chapters / "04-locations-and-servers.html").write_text(
715 '<a href="05-static-content.html">Next</a>\n'
716 )
717
718 result = validator.validate(
719 "write",
720 {
721 "file_path": str(chapters / "03-basic-configuration.html"),
722 "content": '<a href="04-locations-and-servers.html">Next</a>\n',
723 },
724 )
725
726 assert result.valid is False
727 assert (
728 result.reason
729 == "HTML page introduces new local targets outside the current declared artifact set"
730 )
731 assert "04-locations-and-servers.html" in result.suggestion
732 assert "04-advanced-configuration.html" in result.suggestion
733
734
735 def test_pre_action_validator_does_not_suggest_unrelated_declared_html_target(
736 tmp_path: Path,
737 ) -> None:
738 validator = PreActionValidator()
739 guide = tmp_path / "guide"
740 chapters = guide / "chapters"
741 chapters.mkdir(parents=True)
742 (guide / "index.html").write_text(
743 "\n".join(
744 [
745 '<a href="chapters/introduction.html">Introduction</a>',
746 '<a href="chapters/installation.html">Installation</a>',
747 '<a href="chapters/configuration.html">Configuration</a>',
748 '<a href="chapters/basic-usage.html">Basic Usage</a>',
749 '<a href="chapters/advanced-topics.html">Advanced Topics</a>',
750 "",
751 ]
752 )
753 )
754
755 result = validator.validate(
756 "write",
757 {
758 "file_path": str(chapters / "introduction.html"),
759 "content": '<a href="troubleshooting.html">Troubleshooting</a>\n',
760 },
761 )
762
763 assert result.valid is False
764 assert "troubleshooting.html" in result.suggestion
765 assert "Closest declared local targets include:" not in result.suggestion
766
767
768 def test_pre_action_validator_allows_chapter_write_with_root_declared_sibling_and_index_link(
769 tmp_path: Path,
770 ) -> None:
771 validator = PreActionValidator()
772 guide = tmp_path / "guide"
773 chapters = guide / "chapters"
774 chapters.mkdir(parents=True)
775 (guide / "index.html").write_text(
776 "\n".join(
777 [
778 '<a href="chapters/01-introduction.html">Introduction</a>',
779 '<a href="chapters/02-installation.html">Installation</a>',
780 '<a href="chapters/03-basic-configuration.html">Basic Configuration</a>',
781 "",
782 ]
783 )
784 )
785
786 result = validator.validate(
787 "write",
788 {
789 "file_path": str(chapters / "02-installation.html"),
790 "content": (
791 '<a href="../index.html">Back to guide</a>\n'
792 '<a href="03-basic-configuration.html">Next</a>\n'
793 ),
794 },
795 )
796
797 assert result.valid is True
798
799
800 def test_pre_action_validator_blocks_missing_numbered_read_with_existing_sibling(
801 tmp_path: Path,
802 ) -> None:
803 validator = PreActionValidator()
804 chapters = tmp_path / "chapters"
805 chapters.mkdir()
806 (chapters / "01-getting-started.html").write_text("<h1>Getting Started</h1>\n")
807
808 result = validator.validate(
809 "read",
810 {"file_path": str(chapters / "01-introduction.html")},
811 )
812
813 assert result.valid is False
814 assert result.reason == "Read target conflicts with an existing numbered sibling"
815 assert "01-getting-started.html" in result.suggestion
816
817
818 def test_pre_action_validator_blocks_new_numbered_sibling_drift(tmp_path) -> None:
819 validator = PreActionValidator()
820 chapters = tmp_path / "chapters"
821 chapters.mkdir()
822 (chapters / "01-getting-started.html").write_text("<h1>Getting Started</h1>\n")
823
824 result = validator.validate(
825 "write",
826 {
827 "file_path": str(chapters / "01-intro.html"),
828 "content": "<h1>Intro</h1>\n",
829 },
830 )
831
832 assert result.valid is False
833 assert result.reason == "New file conflicts with an existing numbered sibling"
834 assert "01-getting-started.html" in result.suggestion
835
836
837 def test_format_html_inventory_entry_handles_tmp_alias_paths() -> None:
838 root = Path(tempfile.mkdtemp(dir="/tmp"))
839 chapters = root / "chapters"
840 chapters.mkdir()
841 candidate = chapters / "05-input-output.html"
842 candidate.write_text("<h1>Chapter 5: Input and Output</h1>\n")
843
844 entry = format_html_inventory_entry(root, candidate.resolve(strict=False))
845
846 assert entry == "chapters/05-input-output.html = Chapter 5: Input and Output"
847
848
849 def test_runtime_safeguards_wrap_runtime_owned_services() -> None:
850 safeguards = RuntimeSafeguards()
851
852 assert isinstance(safeguards.action_tracker, ActionTracker)
853 assert isinstance(safeguards.validator, PreActionValidator)
854
855
856 def test_agent_safeguards_reexport_runtime_safeguards() -> None:
857 assert AgentRuntimeSafeguards is RuntimeSafeguards
858
859
860 def test_agent_safeguards_exports_curated_compatibility_surface() -> None:
861 assert agent_safeguards.__all__ == [
862 "ActionTracker",
863 "CodeBlockFilter",
864 "FilterResult",
865 "PatternDetector",
866 "PatternMatch",
867 "PreActionValidator",
868 "RuntimeSafeguards",
869 "ValidationResult",
870 ]