Python · 76253 bytes Raw Blame History
1 """Tests for permission policy and tool lifecycle hooks."""
2
3 from __future__ import annotations
4
5 from pathlib import Path
6
7 import pytest
8
9 from loader.llm.base import Message, Role, ToolCall
10 from loader.runtime.dod import DefinitionOfDoneStore, create_definition_of_done
11 from loader.runtime.executor import ToolExecutionState, ToolExecutor
12 from loader.runtime.hooks import (
13 ActiveRepairMutationScopeHook,
14 ActiveRepairScopeHook,
15 BaseToolHook,
16 FilePathAliasHook,
17 HookContext,
18 HookDecision,
19 HookManager,
20 HookResult,
21 LateReferenceDriftHook,
22 MissingPlannedOutputReadHook,
23 RelativePathContextHook,
24 SearchPathAliasHook,
25 )
26 from loader.runtime.permissions import (
27 PermissionMode,
28 PermissionOverride,
29 PermissionRuleDisposition,
30 PermissionRuleSet,
31 build_permission_policy,
32 )
33 from loader.runtime.safeguard_services import ActionTracker
34 from loader.runtime.tracing import RuntimeTracer
35 from loader.tools.base import create_default_registry
36
37
38 class RecordingHook(BaseToolHook):
39 """Hook that records lifecycle events."""
40
41 def __init__(self, events: list[str]) -> None:
42 self.events = events
43
44 async def pre_tool_use(self, context) -> HookResult:
45 self.events.append("pre_tool_use")
46 return HookResult()
47
48 async def post_tool_use(self, context) -> HookResult:
49 self.events.append("post_tool_use")
50 return HookResult()
51
52 async def post_tool_use_failure(self, context) -> HookResult:
53 self.events.append("post_tool_use_failure")
54 return HookResult()
55
56
57 class DenyInPreHook(BaseToolHook):
58 """Hook that denies execution before the tool runs."""
59
60 def __init__(self, events: list[str]) -> None:
61 self.events = events
62
63 async def pre_tool_use(self, context) -> HookResult:
64 self.events.append("pre_tool_use")
65 return HookResult(
66 decision=HookDecision.DENY,
67 message="[Blocked - denied by test hook]",
68 terminal_state="blocked",
69 )
70
71 async def post_tool_use_failure(self, context) -> HookResult:
72 self.events.append("post_tool_use_failure")
73 return HookResult()
74
75
76 @pytest.mark.asyncio
77 async def test_permission_policy_honors_overrides(temp_dir: Path) -> None:
78 policy = build_permission_policy(
79 active_mode=PermissionMode.READ_ONLY,
80 workspace_root=temp_dir,
81 tool_requirements={"write": PermissionMode.WORKSPACE_WRITE},
82 )
83
84 denied = policy.authorize("write")
85 allowed = policy.authorize("write", override=PermissionOverride.ALLOW)
86 asked = policy.authorize("write", override=PermissionOverride.ASK)
87
88 assert denied.decision.value == "deny"
89 assert allowed.allowed
90 assert asked.decision.value == "ask"
91
92
93 def test_permission_mode_parsing_supports_prompt_and_allow() -> None:
94 assert PermissionMode.from_str("prompt") == PermissionMode.PROMPT
95 assert PermissionMode.from_str("allow") == PermissionMode.ALLOW
96
97
98 def test_permission_policy_honors_rule_precedence(temp_dir: Path) -> None:
99 policy = build_permission_policy(
100 active_mode=PermissionMode.ALLOW,
101 workspace_root=temp_dir,
102 tool_requirements={"write": PermissionMode.WORKSPACE_WRITE},
103 rules=PermissionRuleSet.from_dict(
104 {
105 "allow": [{"tool": "write", "contains": "safe change"}],
106 "deny": [{"tool": "write", "path_contains": "secrets"}],
107 "ask": [{"tool": "write", "path_contains": "README"}],
108 }
109 ),
110 )
111
112 denied = policy.authorize(
113 "write",
114 arguments={
115 "file_path": str(temp_dir / "secrets.txt"),
116 "content": "safe change\n",
117 },
118 )
119 asked = policy.authorize(
120 "write",
121 arguments={
122 "file_path": str(temp_dir / "README.md"),
123 "content": "safe change\n",
124 },
125 )
126 allowed = policy.authorize(
127 "write",
128 arguments={
129 "file_path": str(temp_dir / "notes.txt"),
130 "content": "safe change\n",
131 },
132 )
133
134 assert denied.decision.value == "deny"
135 assert denied.matched_disposition == PermissionRuleDisposition.DENY
136 assert asked.decision.value == "ask"
137 assert asked.matched_disposition == PermissionRuleDisposition.ASK
138 assert allowed.decision.value == "allow"
139 assert allowed.matched_disposition == PermissionRuleDisposition.ALLOW
140
141
142 @pytest.mark.asyncio
143 async def test_prompt_mode_executor_prompts_once_and_respects_denial(
144 temp_dir: Path,
145 ) -> None:
146 prompts: list[tuple[str, str, str]] = []
147 registry = create_default_registry(temp_dir)
148 policy = build_permission_policy(
149 active_mode=PermissionMode.PROMPT,
150 workspace_root=temp_dir,
151 tool_requirements=registry.get_tool_requirements(),
152 )
153 executor = ToolExecutor(registry, RuntimeTracer(), policy)
154 target = temp_dir / "prompted.txt"
155
156 async def deny(tool_name: str, message: str, details: str) -> bool:
157 prompts.append((tool_name, message, details))
158 return False
159
160 outcome = await executor.execute_tool_call(
161 ToolCall(
162 id="write-1",
163 name="write",
164 arguments={"file_path": str(target), "content": "prompted\n"},
165 ),
166 source="native",
167 on_confirmation=deny,
168 )
169
170 assert outcome.state == ToolExecutionState.DECLINED
171 assert not target.exists()
172 assert len(prompts) == 1
173 assert "active_mode=prompt" in prompts[0][2]
174 assert "required_mode=workspace-write" in prompts[0][2]
175
176
177 @pytest.mark.asyncio
178 async def test_allow_mode_executor_skips_prompt_for_destructive_write(
179 temp_dir: Path,
180 ) -> None:
181 prompts: list[str] = []
182 registry = create_default_registry(temp_dir)
183 policy = build_permission_policy(
184 active_mode=PermissionMode.ALLOW,
185 workspace_root=temp_dir,
186 tool_requirements=registry.get_tool_requirements(),
187 )
188 executor = ToolExecutor(registry, RuntimeTracer(), policy)
189 target = temp_dir / "allowed.txt"
190
191 async def unexpected(tool_name: str, message: str, details: str) -> bool:
192 prompts.append(tool_name)
193 return False
194
195 outcome = await executor.execute_tool_call(
196 ToolCall(
197 id="write-1",
198 name="write",
199 arguments={"file_path": str(target), "content": "allowed\n"},
200 ),
201 source="native",
202 on_confirmation=unexpected,
203 )
204
205 assert outcome.state == ToolExecutionState.EXECUTED
206 assert target.read_text() == "allowed\n"
207 assert prompts == []
208
209
210 @pytest.mark.asyncio
211 async def test_ask_rule_prompts_even_when_allow_mode(temp_dir: Path) -> None:
212 prompts: list[str] = []
213 registry = create_default_registry(temp_dir)
214 policy = build_permission_policy(
215 active_mode=PermissionMode.ALLOW,
216 workspace_root=temp_dir,
217 tool_requirements=registry.get_tool_requirements(),
218 rules=PermissionRuleSet.from_dict(
219 {"ask": [{"tool": "write", "path_contains": "README"}]}
220 ),
221 )
222 executor = ToolExecutor(registry, RuntimeTracer(), policy)
223 target = temp_dir / "README.md"
224
225 async def deny(tool_name: str, message: str, details: str) -> bool:
226 prompts.append(details)
227 return False
228
229 outcome = await executor.execute_tool_call(
230 ToolCall(
231 id="write-1",
232 name="write",
233 arguments={"file_path": str(target), "content": "no thanks\n"},
234 ),
235 source="native",
236 on_confirmation=deny,
237 )
238
239 assert outcome.state == ToolExecutionState.DECLINED
240 assert not target.exists()
241 assert len(prompts) == 1
242 assert "matched_ask_rule=tool=write, path_contains=README" in prompts[0]
243
244
245 @pytest.mark.asyncio
246 async def test_hook_lifecycle_runs_in_order_for_success(temp_dir: Path) -> None:
247 events: list[str] = []
248 registry = create_default_registry(temp_dir)
249 policy = build_permission_policy(
250 active_mode=PermissionMode.WORKSPACE_WRITE,
251 workspace_root=temp_dir,
252 tool_requirements=registry.get_tool_requirements(),
253 )
254 executor = ToolExecutor(
255 registry,
256 RuntimeTracer(),
257 policy,
258 hooks=HookManager([RecordingHook(events)]),
259 )
260 target = temp_dir / "hook-success.txt"
261
262 outcome = await executor.execute_tool_call(
263 ToolCall(
264 id="write-1",
265 name="write",
266 arguments={"file_path": str(target), "content": "hook success\n"},
267 ),
268 source="native",
269 skip_confirmation=True,
270 )
271
272 assert outcome.state == ToolExecutionState.EXECUTED
273 assert events == ["pre_tool_use", "post_tool_use"]
274 assert target.read_text() == "hook success\n"
275
276
277 @pytest.mark.asyncio
278 async def test_pre_hook_deny_still_runs_failure_hook_once(temp_dir: Path) -> None:
279 events: list[str] = []
280 registry = create_default_registry(temp_dir)
281 policy = build_permission_policy(
282 active_mode=PermissionMode.WORKSPACE_WRITE,
283 workspace_root=temp_dir,
284 tool_requirements=registry.get_tool_requirements(),
285 )
286 executor = ToolExecutor(
287 registry,
288 RuntimeTracer(),
289 policy,
290 hooks=HookManager([DenyInPreHook(events)]),
291 )
292 target = temp_dir / "hook-denied.txt"
293
294 outcome = await executor.execute_tool_call(
295 ToolCall(
296 id="write-1",
297 name="write",
298 arguments={"file_path": str(target), "content": "should not exist\n"},
299 ),
300 source="native",
301 skip_confirmation=True,
302 )
303
304 assert outcome.state == ToolExecutionState.BLOCKED
305 assert events == ["pre_tool_use", "post_tool_use_failure"]
306 assert not target.exists()
307 assert len(outcome.message.tool_results) == 1
308 assert "denied by test hook" in outcome.event_content
309
310
311 @pytest.mark.asyncio
312 @pytest.mark.parametrize(
313 ("tool_name", "arguments", "expected_path"),
314 [
315 ("read", {"file": "notes.txt"}, "notes.txt"),
316 ("write", {"filepath": "notes.txt", "content": "hello\n"}, "notes.txt"),
317 (
318 "edit",
319 {"filePath": "notes.txt", "old_string": "before", "new_string": "after"},
320 "notes.txt",
321 ),
322 ("patch", {"path": "notes.txt", "hunks": []}, "notes.txt"),
323 ],
324 )
325 async def test_file_path_alias_hook_canonicalizes_common_aliases(
326 temp_dir: Path,
327 tool_name: str,
328 arguments: dict[str, object],
329 expected_path: str,
330 ) -> None:
331 registry = create_default_registry(temp_dir)
332 policy = build_permission_policy(
333 active_mode=PermissionMode.WORKSPACE_WRITE,
334 workspace_root=temp_dir,
335 tool_requirements=registry.get_tool_requirements(),
336 )
337 hook = FilePathAliasHook()
338
339 result = await hook.pre_tool_use(
340 HookContext(
341 tool_call=ToolCall(id=f"{tool_name}-1", name=tool_name, arguments=arguments),
342 tool=registry.get(tool_name),
343 registry=registry,
344 permission_policy=policy,
345 source="native",
346 )
347 )
348
349 assert result.updated_arguments is not None
350 assert result.updated_arguments["file_path"] == expected_path
351 for alias in ("file", "filepath", "filePath", "filename", "path"):
352 assert alias not in result.updated_arguments
353
354
355 @pytest.mark.asyncio
356 @pytest.mark.parametrize(
357 ("tool_name", "arguments", "expected_path"),
358 [
359 ("glob", {"pattern": "*.html", "directory": "chapters"}, "chapters"),
360 ("grep", {"pattern": "alpha", "dir": "src"}, "src"),
361 ],
362 )
363 async def test_search_path_alias_hook_canonicalizes_common_aliases(
364 temp_dir: Path,
365 tool_name: str,
366 arguments: dict[str, object],
367 expected_path: str,
368 ) -> None:
369 registry = create_default_registry(temp_dir)
370 policy = build_permission_policy(
371 active_mode=PermissionMode.WORKSPACE_WRITE,
372 workspace_root=temp_dir,
373 tool_requirements=registry.get_tool_requirements(),
374 )
375 hook = SearchPathAliasHook()
376
377 result = await hook.pre_tool_use(
378 HookContext(
379 tool_call=ToolCall(id=f"{tool_name}-1", name=tool_name, arguments=arguments),
380 tool=registry.get(tool_name),
381 registry=registry,
382 permission_policy=policy,
383 source="native",
384 )
385 )
386
387 assert result.updated_arguments is not None
388 assert result.updated_arguments["path"] == expected_path
389 for alias in ("directory", "dir", "folder"):
390 assert alias not in result.updated_arguments
391
392
393 @pytest.mark.asyncio
394 async def test_search_path_alias_hook_splits_full_glob_pattern(
395 temp_dir: Path,
396 ) -> None:
397 registry = create_default_registry(temp_dir)
398 policy = build_permission_policy(
399 active_mode=PermissionMode.WORKSPACE_WRITE,
400 workspace_root=temp_dir,
401 tool_requirements=registry.get_tool_requirements(),
402 )
403 hook = SearchPathAliasHook()
404 chapters = temp_dir / "chapters"
405
406 result = await hook.pre_tool_use(
407 HookContext(
408 tool_call=ToolCall(
409 id="glob-1",
410 name="glob",
411 arguments={"pattern": f"{chapters}/*.html"},
412 ),
413 tool=registry.get("glob"),
414 registry=registry,
415 permission_policy=policy,
416 source="native",
417 )
418 )
419
420 assert result.updated_arguments is not None
421 assert result.updated_arguments["path"] == str(chapters)
422 assert result.updated_arguments["pattern"] == "*.html"
423
424
425 @pytest.mark.asyncio
426 async def test_search_path_alias_hook_splits_implicit_recursive_glob_parent(
427 temp_dir: Path,
428 ) -> None:
429 registry = create_default_registry(temp_dir)
430 policy = build_permission_policy(
431 active_mode=PermissionMode.WORKSPACE_WRITE,
432 workspace_root=temp_dir,
433 tool_requirements=registry.get_tool_requirements(),
434 )
435 hook = SearchPathAliasHook()
436
437 result = await hook.pre_tool_use(
438 HookContext(
439 tool_call=ToolCall(
440 id="glob-implicit-1",
441 name="glob",
442 arguments={"pattern": "**/Loader/guides/nginx/chapters/*.html"},
443 ),
444 tool=registry.get("glob"),
445 registry=registry,
446 permission_policy=policy,
447 source="native",
448 )
449 )
450
451 assert result.updated_arguments is not None
452 assert result.updated_arguments["path"] == "Loader/guides/nginx/chapters"
453 assert result.updated_arguments["pattern"] == "*.html"
454
455
456 @pytest.mark.asyncio
457 async def test_search_path_alias_hook_leaves_fully_generic_recursive_glob_unchanged(
458 temp_dir: Path,
459 ) -> None:
460 registry = create_default_registry(temp_dir)
461 policy = build_permission_policy(
462 active_mode=PermissionMode.WORKSPACE_WRITE,
463 workspace_root=temp_dir,
464 tool_requirements=registry.get_tool_requirements(),
465 )
466 hook = SearchPathAliasHook()
467
468 result = await hook.pre_tool_use(
469 HookContext(
470 tool_call=ToolCall(
471 id="glob-generic-1",
472 name="glob",
473 arguments={"pattern": "**/*.html"},
474 ),
475 tool=registry.get("glob"),
476 registry=registry,
477 permission_policy=policy,
478 source="native",
479 )
480 )
481
482 assert result.updated_arguments is None
483
484
485 @pytest.mark.asyncio
486 async def test_relative_path_context_hook_remaps_workspace_mirror_of_external_root(
487 temp_dir: Path,
488 ) -> None:
489 workspace_root = temp_dir / "workspace"
490 workspace_root.mkdir()
491 external_root = temp_dir / "external-home"
492 external_fortran = external_root / "Loader" / "guides" / "fortran"
493 external_fortran.mkdir(parents=True)
494 (external_fortran / "index.html").write_text("<html></html>\n")
495 (external_root / "Loader" / "guides").mkdir(exist_ok=True)
496
497 registry = create_default_registry(workspace_root)
498 policy = build_permission_policy(
499 active_mode=PermissionMode.WORKSPACE_WRITE,
500 workspace_root=workspace_root,
501 tool_requirements=registry.get_tool_requirements(),
502 )
503 action_tracker = ActionTracker()
504 action_tracker.record_tool_call(
505 "read",
506 {"file_path": str(external_fortran / "index.html")},
507 )
508 hook = RelativePathContextHook(action_tracker, workspace_root)
509
510 mirrored_workspace_path = workspace_root / "Loader" / "guides" / "nginx" / "index.html"
511 expected_external_path = external_root / "Loader" / "guides" / "nginx" / "index.html"
512
513 result = await hook.pre_tool_use(
514 HookContext(
515 tool_call=ToolCall(
516 id="write-1",
517 name="write",
518 arguments={
519 "file_path": str(mirrored_workspace_path),
520 "content": "<html></html>\n",
521 },
522 ),
523 tool=registry.get("write"),
524 registry=registry,
525 permission_policy=policy,
526 source="native",
527 )
528 )
529
530 assert result.updated_arguments is not None
531 assert Path(result.updated_arguments["file_path"]).resolve() == expected_external_path.resolve()
532 resolved_loader_root = (external_root / "Loader").resolve()
533 assert result.injected_messages == [
534 (
535 "[Path anchor correction] A repo-local mirror path was remapped to the "
536 f"established output root under `{resolved_loader_root}`. Keep future "
537 "file/search tool calls on that external root and use `index.html` there "
538 "instead of re-anchoring work to the workspace checkout."
539 )
540 ]
541
542
543 @pytest.mark.asyncio
544 async def test_relative_path_context_hook_prefers_external_search_ancestor_over_workspace_match(
545 temp_dir: Path,
546 ) -> None:
547 workspace_root = temp_dir / "workspace"
548 (workspace_root / "guides").mkdir(parents=True)
549 external_root = temp_dir / "external-home"
550 external_fortran = external_root / "Loader" / "guides" / "fortran"
551 external_fortran.mkdir(parents=True)
552 (external_fortran / "index.html").write_text("<html></html>\n")
553
554 registry = create_default_registry(workspace_root)
555 policy = build_permission_policy(
556 active_mode=PermissionMode.WORKSPACE_WRITE,
557 workspace_root=workspace_root,
558 tool_requirements=registry.get_tool_requirements(),
559 )
560 action_tracker = ActionTracker()
561 action_tracker.record_tool_call(
562 "read",
563 {"file_path": str(external_fortran / "index.html")},
564 )
565 hook = RelativePathContextHook(action_tracker, workspace_root)
566
567 result = await hook.pre_tool_use(
568 HookContext(
569 tool_call=ToolCall(
570 id="glob-ancestor-1",
571 name="glob",
572 arguments={"path": "guides", "pattern": "**"},
573 ),
574 tool=registry.get("glob"),
575 registry=registry,
576 permission_policy=policy,
577 source="native",
578 )
579 )
580
581 assert result.updated_arguments is not None
582 assert Path(result.updated_arguments["path"]).resolve() == (
583 external_root / "Loader" / "guides"
584 ).resolve()
585
586
587 class FakeSession:
588 def __init__(self, *, active_dod_path: str, messages: list[Message]) -> None:
589 self.active_dod_path = active_dod_path
590 self.messages = messages
591
592
593 @pytest.mark.asyncio
594 async def test_active_repair_scope_hook_blocks_reference_reads_while_fixing(
595 temp_dir: Path,
596 ) -> None:
597 registry = create_default_registry(temp_dir)
598 policy = build_permission_policy(
599 active_mode=PermissionMode.WORKSPACE_WRITE,
600 workspace_root=temp_dir,
601 tool_requirements=registry.get_tool_requirements(),
602 )
603 dod_store = DefinitionOfDoneStore(temp_dir)
604 dod = create_definition_of_done("Repair the active artifact set")
605 dod.status = "fixing"
606 dod_path = dod_store.save(dod)
607 repair_target = temp_dir / "guide" / "index.html"
608 session = FakeSession(
609 active_dod_path=str(dod_path),
610 messages=[
611 Message(
612 role=Role.ASSISTANT,
613 content=(
614 "Repair focus:\n"
615 f"- Fix the broken local reference `chapters/01-introduction.html` in `{repair_target}`.\n"
616 f"- Immediate next step: edit `{repair_target}`.\n"
617 f"- If the broken reference should remain, create `{temp_dir / 'guide' / 'chapters' / '01-introduction.html'}`; otherwise remove or replace `chapters/01-introduction.html`.\n"
618 ),
619 )
620 ],
621 )
622 hook = ActiveRepairScopeHook(
623 dod_store=dod_store,
624 project_root=temp_dir,
625 session=session,
626 )
627
628 result = await hook.pre_tool_use(
629 HookContext(
630 tool_call=ToolCall(
631 id="read-1",
632 name="read",
633 arguments={"file_path": str(temp_dir / "reference" / "index.html")},
634 ),
635 tool=registry.get("read"),
636 registry=registry,
637 permission_policy=policy,
638 source="native",
639 )
640 )
641
642 assert result.decision == HookDecision.DENY
643 assert result.terminal_state == "blocked"
644 assert result.message is not None
645 assert "active repair scope" in result.message
646 assert str(repair_target) in result.message
647
648
649 @pytest.mark.asyncio
650 async def test_active_repair_scope_hook_allows_reads_inside_active_artifact_set(
651 temp_dir: Path,
652 ) -> None:
653 registry = create_default_registry(temp_dir)
654 policy = build_permission_policy(
655 active_mode=PermissionMode.WORKSPACE_WRITE,
656 workspace_root=temp_dir,
657 tool_requirements=registry.get_tool_requirements(),
658 )
659 dod_store = DefinitionOfDoneStore(temp_dir)
660 dod = create_definition_of_done("Repair the active artifact set")
661 dod.status = "fixing"
662 dod_path = dod_store.save(dod)
663 repair_target = temp_dir / "guide" / "index.html"
664 chapter_path = temp_dir / "guide" / "chapters" / "01-getting-started.html"
665 session = FakeSession(
666 active_dod_path=str(dod_path),
667 messages=[
668 Message(
669 role=Role.ASSISTANT,
670 content=(
671 "Repair focus:\n"
672 f"- Fix the broken local reference `chapters/01-getting-started.html` in `{repair_target}`.\n"
673 f"- Fix the broken local reference `../styles.css` in `{chapter_path}`.\n"
674 f"- Immediate next step: edit `{repair_target}`.\n"
675 f"- If the broken reference should remain, create `{chapter_path}`; otherwise remove or replace `chapters/01-getting-started.html`.\n"
676 ),
677 )
678 ],
679 )
680 hook = ActiveRepairScopeHook(
681 dod_store=dod_store,
682 project_root=temp_dir,
683 session=session,
684 )
685
686 result = await hook.pre_tool_use(
687 HookContext(
688 tool_call=ToolCall(
689 id="read-1",
690 name="read",
691 arguments={"file_path": str(chapter_path)},
692 ),
693 tool=registry.get("read"),
694 registry=registry,
695 permission_policy=policy,
696 source="native",
697 )
698 )
699
700 assert result.decision == HookDecision.CONTINUE
701
702
703 @pytest.mark.asyncio
704 async def test_active_repair_scope_hook_allows_existing_sibling_reads_with_source_of_truth_hint(
705 temp_dir: Path,
706 ) -> None:
707 registry = create_default_registry(temp_dir)
708 policy = build_permission_policy(
709 active_mode=PermissionMode.WORKSPACE_WRITE,
710 workspace_root=temp_dir,
711 tool_requirements=registry.get_tool_requirements(),
712 )
713 dod_store = DefinitionOfDoneStore(temp_dir)
714 dod = create_definition_of_done("Repair the active artifact set")
715 dod.status = "fixing"
716 dod_path = dod_store.save(dod)
717 repair_target = temp_dir / "guide" / "index.html"
718 chapter_dir = temp_dir / "guide" / "chapters"
719 chapter_dir.mkdir(parents=True, exist_ok=True)
720 sibling = chapter_dir / "03-basic-usage.html"
721 sibling.write_text("<h1>Basic Usage</h1>\n")
722 session = FakeSession(
723 active_dod_path=str(dod_path),
724 messages=[
725 Message(
726 role=Role.ASSISTANT,
727 content=(
728 "Repair focus:\n"
729 f"- Fix the broken local reference `chapters/02-installation.html` in `{repair_target}`.\n"
730 f"- Immediate next step: edit `{repair_target}`.\n"
731 f"- If the broken reference should remain, create `{chapter_dir / '02-installation.html'}`; otherwise remove or replace `chapters/02-installation.html`.\n"
732 "- Use the existing artifact files as the source of truth while repairing this file: "
733 f"`{repair_target}`.\n"
734 "- Do not reread unrelated reference materials or restart discovery while this concrete repair target is unresolved.\n"
735 ),
736 )
737 ],
738 )
739 hook = ActiveRepairScopeHook(
740 dod_store=dod_store,
741 project_root=temp_dir,
742 session=session,
743 )
744
745 result = await hook.pre_tool_use(
746 HookContext(
747 tool_call=ToolCall(
748 id="read-1",
749 name="read",
750 arguments={"file_path": str(sibling)},
751 ),
752 tool=registry.get("read"),
753 registry=registry,
754 permission_policy=policy,
755 source="native",
756 )
757 )
758
759 assert result.decision == HookDecision.CONTINUE
760
761
762 @pytest.mark.asyncio
763 async def test_active_repair_scope_hook_allows_verification_source_outside_repair_target(
764 temp_dir: Path,
765 ) -> None:
766 registry = create_default_registry(temp_dir)
767 policy = build_permission_policy(
768 active_mode=PermissionMode.WORKSPACE_WRITE,
769 workspace_root=temp_dir,
770 tool_requirements=registry.get_tool_requirements(),
771 )
772 dod_store = DefinitionOfDoneStore(temp_dir)
773 dod = create_definition_of_done("Repair the active artifact set")
774 dod.status = "in_progress"
775 dod_path = dod_store.save(dod)
776 repair_target = temp_dir / "guide" / "chapters" / "06-troubleshooting.html"
777 session = FakeSession(
778 active_dod_path=str(dod_path),
779 messages=[
780 Message(
781 role=Role.ASSISTANT,
782 content=(
783 "Repair focus:\n"
784 f"- Fix the broken local reference `01-introduction.html` in `{repair_target}`.\n"
785 f"- Immediate next step: edit `{repair_target}`.\n"
786 "- Do not reread unrelated reference materials or restart discovery while this concrete repair target is unresolved.\n"
787 ),
788 )
789 ],
790 )
791 hook = ActiveRepairScopeHook(
792 dod_store=dod_store,
793 project_root=temp_dir,
794 session=session,
795 )
796
797 result = await hook.pre_tool_use(
798 HookContext(
799 tool_call=ToolCall(
800 id="verify-1",
801 name="read",
802 arguments={"file_path": str(temp_dir / "guide" / "index.html")},
803 ),
804 tool=registry.get("read"),
805 registry=registry,
806 permission_policy=policy,
807 source="verification",
808 )
809 )
810
811 assert result.decision == HookDecision.CONTINUE
812
813
814 @pytest.mark.asyncio
815 async def test_active_repair_scope_hook_blocks_local_rereads_outside_concrete_repair_files(
816 temp_dir: Path,
817 ) -> None:
818 registry = create_default_registry(temp_dir)
819 policy = build_permission_policy(
820 active_mode=PermissionMode.WORKSPACE_WRITE,
821 workspace_root=temp_dir,
822 tool_requirements=registry.get_tool_requirements(),
823 )
824 dod_store = DefinitionOfDoneStore(temp_dir)
825 dod = create_definition_of_done("Repair the active artifact set")
826 dod.status = "in_progress"
827 dod_path = dod_store.save(dod)
828 repair_target = temp_dir / "guide" / "chapters" / "05-advanced-configurations.html"
829 stylesheet = temp_dir / "guide" / "styles.css"
830 other_chapter = temp_dir / "guide" / "chapters" / "01-getting-started.html"
831 session = FakeSession(
832 active_dod_path=str(dod_path),
833 messages=[
834 Message(
835 role=Role.ASSISTANT,
836 content=(
837 "Repair focus:\n"
838 f"- Fix the broken local reference `../styles.css` in `{repair_target}`.\n"
839 f"- Fix the broken local reference `../styles.css` in `{temp_dir / 'guide' / 'chapters' / '06-troubleshooting.html'}`.\n"
840 f"- Immediate next step: edit `{repair_target}`.\n"
841 f"- If the broken reference should remain, create `{stylesheet}`; otherwise remove or replace `../styles.css`.\n"
842 "- Do not reread unrelated reference materials or restart discovery while this concrete repair target is unresolved.\n"
843 ),
844 )
845 ],
846 )
847 hook = ActiveRepairScopeHook(
848 dod_store=dod_store,
849 project_root=temp_dir,
850 session=session,
851 )
852
853 result = await hook.pre_tool_use(
854 HookContext(
855 tool_call=ToolCall(
856 id="read-1",
857 name="read",
858 arguments={"file_path": str(other_chapter)},
859 ),
860 tool=registry.get("read"),
861 registry=registry,
862 permission_policy=policy,
863 source="native",
864 )
865 )
866
867 assert result.decision == HookDecision.DENY
868 assert result.terminal_state == "blocked"
869 assert result.message is not None
870 assert "active repair scope" in result.message
871 assert str(repair_target) in result.message
872 assert str(stylesheet) in result.message
873
874
875 @pytest.mark.asyncio
876 async def test_active_repair_scope_hook_blocks_repair_audit_loop_after_repeated_source_reads(
877 temp_dir: Path,
878 ) -> None:
879 registry = create_default_registry(temp_dir)
880 policy = build_permission_policy(
881 active_mode=PermissionMode.WORKSPACE_WRITE,
882 workspace_root=temp_dir,
883 tool_requirements=registry.get_tool_requirements(),
884 )
885 dod_store = DefinitionOfDoneStore(temp_dir)
886 dod = create_definition_of_done("Repair the active artifact set")
887 dod.status = "fixing"
888 dod_path = dod_store.save(dod)
889 guide_root = temp_dir / "guide"
890 chapter_dir = guide_root / "chapters"
891 chapter_dir.mkdir(parents=True, exist_ok=True)
892 repair_target = guide_root / "index.html"
893 repair_target.write_text("<h1>Guide</h1>\n")
894 intro = chapter_dir / "01-introduction.html"
895 install = chapter_dir / "02-installation.html"
896 intro.write_text("<h1>Intro</h1>\n")
897 install.write_text("<h1>Install</h1>\n")
898 session = FakeSession(
899 active_dod_path=str(dod_path),
900 messages=[
901 Message(
902 role=Role.ASSISTANT,
903 content=(
904 "Repair focus:\n"
905 f"- Fix the broken local reference `chapters/02-installation.html` in `{repair_target}`.\n"
906 f"- Immediate next step: edit `{repair_target}`.\n"
907 f"- If the broken reference should remain, create `{install}`; otherwise remove or replace `chapters/02-installation.html`.\n"
908 "- Use the existing artifact files as the source of truth while repairing this file: "
909 f"`{repair_target}`, `{intro}`, `{install}`.\n"
910 "- Do not reread unrelated reference materials or restart discovery while this concrete repair target is unresolved.\n"
911 ),
912 )
913 ],
914 )
915 hook = ActiveRepairScopeHook(
916 dod_store=dod_store,
917 project_root=temp_dir,
918 session=session,
919 )
920
921 def make_context(index: int) -> HookContext:
922 target = repair_target if index % 2 else intro
923 return HookContext(
924 tool_call=ToolCall(
925 id=f"read-{index}",
926 name="read",
927 arguments={"file_path": str(target)},
928 ),
929 tool=registry.get("read"),
930 registry=registry,
931 permission_policy=policy,
932 source="native",
933 )
934
935 for index in range(1, 5):
936 context = make_context(index)
937 result = await hook.pre_tool_use(context)
938 assert result.decision == HookDecision.CONTINUE
939 await hook.post_tool_use(context)
940
941 blocked = await hook.pre_tool_use(make_context(5))
942
943 assert blocked.decision == HookDecision.DENY
944 assert blocked.terminal_state == "blocked"
945 assert blocked.message is not None
946 assert "repair audit loop" in blocked.message
947
948
949 @pytest.mark.asyncio
950 async def test_active_repair_scope_hook_allows_scoped_glob_within_active_artifact_roots(
951 temp_dir: Path,
952 ) -> None:
953 registry = create_default_registry(temp_dir)
954 policy = build_permission_policy(
955 active_mode=PermissionMode.WORKSPACE_WRITE,
956 workspace_root=temp_dir,
957 tool_requirements=registry.get_tool_requirements(),
958 )
959 dod_store = DefinitionOfDoneStore(temp_dir)
960 dod = create_definition_of_done("Repair the active artifact set")
961 dod.status = "in_progress"
962 dod_path = dod_store.save(dod)
963 repair_target = temp_dir / "guide" / "index.html"
964 guide_root = temp_dir / "guide"
965 session = FakeSession(
966 active_dod_path=str(dod_path),
967 messages=[
968 Message(
969 role=Role.ASSISTANT,
970 content=(
971 "Repair focus:\n"
972 f"- Fix the broken local reference `chapters/troubleshooting.html` in `{repair_target}`.\n"
973 f"- Immediate next step: edit `{repair_target}`.\n"
974 f"- If the broken reference should remain, create `{guide_root / 'chapters' / 'troubleshooting.html'}`; otherwise remove or replace `chapters/troubleshooting.html`.\n"
975 "- Use the existing artifact files as the source of truth while repairing this file: "
976 f"`{guide_root / 'chapters' / 'introduction.html'}`, `{guide_root / 'chapters' / 'installation.html'}`, `{guide_root / 'chapters' / 'configuration.html'}`.\n"
977 "- Do not reread unrelated reference materials or restart discovery while this concrete repair target is unresolved.\n"
978 ),
979 )
980 ],
981 )
982 hook = ActiveRepairScopeHook(
983 dod_store=dod_store,
984 project_root=temp_dir,
985 session=session,
986 )
987
988 result = await hook.pre_tool_use(
989 HookContext(
990 tool_call=ToolCall(
991 id="glob-1",
992 name="glob",
993 arguments={
994 "path": str(temp_dir),
995 "pattern": "**/guide/chapters/*.html",
996 },
997 ),
998 tool=registry.get("glob"),
999 registry=registry,
1000 permission_policy=policy,
1001 source="native",
1002 )
1003 )
1004
1005 assert result.decision == HookDecision.CONTINUE
1006
1007
1008 @pytest.mark.asyncio
1009 async def test_active_repair_scope_hook_allows_declared_missing_sibling_reads(
1010 temp_dir: Path,
1011 ) -> None:
1012 registry = create_default_registry(temp_dir)
1013 policy = build_permission_policy(
1014 active_mode=PermissionMode.WORKSPACE_WRITE,
1015 workspace_root=temp_dir,
1016 tool_requirements=registry.get_tool_requirements(),
1017 )
1018 dod_store = DefinitionOfDoneStore(temp_dir)
1019 dod = create_definition_of_done("Repair the active artifact set")
1020 dod.status = "in_progress"
1021 dod_path = dod_store.save(dod)
1022 guide_root = temp_dir / "guide"
1023 chapters = guide_root / "chapters"
1024 chapters.mkdir(parents=True)
1025 repair_target = guide_root / "index.html"
1026 existing_chapter = chapters / "overview.html"
1027 next_chapter = chapters / "installation.html"
1028 repair_target.write_text(
1029 "\n".join(
1030 [
1031 "<html>",
1032 '<a href="chapters/overview.html">Overview</a>',
1033 '<a href="chapters/installation.html">Installation</a>',
1034 "</html>",
1035 ]
1036 )
1037 + "\n"
1038 )
1039 existing_chapter.write_text("<h1>Overview</h1>\n")
1040
1041 session = FakeSession(
1042 active_dod_path=str(dod_path),
1043 messages=[
1044 Message(
1045 role=Role.ASSISTANT,
1046 content=(
1047 "Repair focus:\n"
1048 f"- Fix the broken local reference `chapters/overview.html` in `{repair_target}`.\n"
1049 f"- Immediate next step: edit `{repair_target}`.\n"
1050 f"- If the broken reference should remain, create `{existing_chapter}`; otherwise remove or replace `chapters/overview.html`.\n"
1051 "- Use the existing artifact files as the source of truth while repairing this file: "
1052 f"`{existing_chapter}`.\n"
1053 "- Do not reread unrelated reference materials or restart discovery while this concrete repair target is unresolved.\n"
1054 ),
1055 )
1056 ],
1057 )
1058 hook = ActiveRepairScopeHook(
1059 dod_store=dod_store,
1060 project_root=temp_dir,
1061 session=session,
1062 )
1063
1064 result = await hook.pre_tool_use(
1065 HookContext(
1066 tool_call=ToolCall(
1067 id="read-allowed-sibling",
1068 name="read",
1069 arguments={"file_path": str(next_chapter)},
1070 ),
1071 tool=registry.get("read"),
1072 registry=registry,
1073 permission_policy=policy,
1074 source="native",
1075 )
1076 )
1077
1078 assert result.decision == HookDecision.CONTINUE
1079
1080
1081 @pytest.mark.asyncio
1082 async def test_active_repair_scope_hook_blocks_reference_reads_during_in_progress_repair(
1083 temp_dir: Path,
1084 ) -> None:
1085 registry = create_default_registry(temp_dir)
1086 policy = build_permission_policy(
1087 active_mode=PermissionMode.WORKSPACE_WRITE,
1088 workspace_root=temp_dir,
1089 tool_requirements=registry.get_tool_requirements(),
1090 )
1091 dod_store = DefinitionOfDoneStore(temp_dir)
1092 dod = create_definition_of_done("Repair the active artifact set")
1093 dod.status = "in_progress"
1094 dod_path = dod_store.save(dod)
1095 repair_target = temp_dir / "guide" / "chapters" / "05-advanced-configurations.html"
1096 session = FakeSession(
1097 active_dod_path=str(dod_path),
1098 messages=[
1099 Message(
1100 role=Role.ASSISTANT,
1101 content=(
1102 "Repair focus:\n"
1103 f"- Fix the broken local reference `../styles.css` in `{repair_target}`.\n"
1104 f"- Immediate next step: edit `{repair_target}`.\n"
1105 f"- If the broken reference should remain, create `{temp_dir / 'guide' / 'styles.css'}`; otherwise remove or replace `../styles.css`.\n"
1106 "- Do not reread unrelated reference materials or restart discovery while this concrete repair target is unresolved.\n"
1107 ),
1108 )
1109 ],
1110 )
1111 hook = ActiveRepairScopeHook(
1112 dod_store=dod_store,
1113 project_root=temp_dir,
1114 session=session,
1115 )
1116
1117 result = await hook.pre_tool_use(
1118 HookContext(
1119 tool_call=ToolCall(
1120 id="read-1",
1121 name="read",
1122 arguments={"file_path": str(temp_dir / "reference" / "index.html")},
1123 ),
1124 tool=registry.get("read"),
1125 registry=registry,
1126 permission_policy=policy,
1127 source="native",
1128 )
1129 )
1130
1131 assert result.decision == HookDecision.DENY
1132 assert result.terminal_state == "blocked"
1133 assert result.message is not None
1134 assert "active repair scope" in result.message
1135
1136
1137 @pytest.mark.asyncio
1138 async def test_active_repair_mutation_scope_hook_blocks_writes_outside_named_repair_files(
1139 temp_dir: Path,
1140 ) -> None:
1141 registry = create_default_registry(temp_dir)
1142 policy = build_permission_policy(
1143 active_mode=PermissionMode.WORKSPACE_WRITE,
1144 workspace_root=temp_dir,
1145 tool_requirements=registry.get_tool_requirements(),
1146 )
1147 dod_store = DefinitionOfDoneStore(temp_dir)
1148 dod = create_definition_of_done("Repair the active artifact set")
1149 dod.status = "in_progress"
1150 dod_path = dod_store.save(dod)
1151 repair_target = temp_dir / "guide" / "chapters" / "05-advanced-configurations.html"
1152 chapter_path = temp_dir / "guide" / "chapters" / "01-getting-started.html"
1153 session = FakeSession(
1154 active_dod_path=str(dod_path),
1155 messages=[
1156 Message(
1157 role=Role.ASSISTANT,
1158 content=(
1159 "Repair focus:\n"
1160 f"- Fix the broken local reference `../styles.css` in `{repair_target}`.\n"
1161 f"- Immediate next step: edit `{repair_target}`.\n"
1162 f"- If the broken reference should remain, create `{temp_dir / 'guide' / 'styles.css'}`; otherwise remove or replace `../styles.css`.\n"
1163 "- Do not reread unrelated reference materials or restart discovery while this concrete repair target is unresolved.\n"
1164 ),
1165 )
1166 ],
1167 )
1168 hook = ActiveRepairMutationScopeHook(
1169 dod_store=dod_store,
1170 project_root=temp_dir,
1171 session=session,
1172 )
1173
1174 result = await hook.pre_tool_use(
1175 HookContext(
1176 tool_call=ToolCall(
1177 id="edit-1",
1178 name="edit",
1179 arguments={"file_path": str(chapter_path), "old_string": "old", "new_string": "new"},
1180 ),
1181 tool=registry.get("edit"),
1182 registry=registry,
1183 permission_policy=policy,
1184 source="native",
1185 )
1186 )
1187
1188 assert result.decision == HookDecision.DENY
1189 assert result.terminal_state == "blocked"
1190 assert result.message is not None
1191 assert "active repair mutation scope" in result.message
1192 assert str(repair_target) in result.message
1193
1194
1195 @pytest.mark.asyncio
1196 async def test_active_repair_mutation_scope_hook_allows_expected_repair_file_writes(
1197 temp_dir: Path,
1198 ) -> None:
1199 registry = create_default_registry(temp_dir)
1200 policy = build_permission_policy(
1201 active_mode=PermissionMode.WORKSPACE_WRITE,
1202 workspace_root=temp_dir,
1203 tool_requirements=registry.get_tool_requirements(),
1204 )
1205 dod_store = DefinitionOfDoneStore(temp_dir)
1206 dod = create_definition_of_done("Repair the active artifact set")
1207 dod.status = "in_progress"
1208 dod_path = dod_store.save(dod)
1209 repair_target = temp_dir / "guide" / "chapters" / "05-advanced-configurations.html"
1210 stylesheet = temp_dir / "guide" / "styles.css"
1211 session = FakeSession(
1212 active_dod_path=str(dod_path),
1213 messages=[
1214 Message(
1215 role=Role.ASSISTANT,
1216 content=(
1217 "Repair focus:\n"
1218 f"- Fix the broken local reference `../styles.css` in `{repair_target}`.\n"
1219 f"- Immediate next step: edit `{repair_target}`.\n"
1220 f"- If the broken reference should remain, create `{stylesheet}`; otherwise remove or replace `../styles.css`.\n"
1221 "- Do not reread unrelated reference materials or restart discovery while this concrete repair target is unresolved.\n"
1222 ),
1223 )
1224 ],
1225 )
1226 hook = ActiveRepairMutationScopeHook(
1227 dod_store=dod_store,
1228 project_root=temp_dir,
1229 session=session,
1230 )
1231
1232 result = await hook.pre_tool_use(
1233 HookContext(
1234 tool_call=ToolCall(
1235 id="write-1",
1236 name="write",
1237 arguments={"file_path": str(stylesheet), "content": "body { color: #222; }\n"},
1238 ),
1239 tool=registry.get("write"),
1240 registry=registry,
1241 permission_policy=policy,
1242 source="native",
1243 )
1244 )
1245
1246 assert result.decision == HookDecision.CONTINUE
1247
1248
1249 @pytest.mark.asyncio
1250 async def test_active_repair_mutation_scope_hook_allows_declared_missing_sibling_outputs(
1251 temp_dir: Path,
1252 ) -> None:
1253 registry = create_default_registry(temp_dir)
1254 policy = build_permission_policy(
1255 active_mode=PermissionMode.WORKSPACE_WRITE,
1256 workspace_root=temp_dir,
1257 tool_requirements=registry.get_tool_requirements(),
1258 )
1259 dod_store = DefinitionOfDoneStore(temp_dir)
1260 dod = create_definition_of_done("Repair the active artifact set")
1261 dod.status = "in_progress"
1262 dod_path = dod_store.save(dod)
1263 guide_root = temp_dir / "guide"
1264 chapters = guide_root / "chapters"
1265 chapters.mkdir(parents=True)
1266 repair_target = guide_root / "index.html"
1267 existing_chapter = chapters / "01-introduction.html"
1268 next_chapter = chapters / "02-installation.html"
1269 repair_target.write_text(
1270 "\n".join(
1271 [
1272 "<html>",
1273 '<a href="chapters/01-introduction.html">Introduction</a>',
1274 '<a href="chapters/02-installation.html">Installation</a>',
1275 "</html>",
1276 ]
1277 )
1278 + "\n"
1279 )
1280 existing_chapter.write_text("<h1>Introduction</h1>\n")
1281
1282 session = FakeSession(
1283 active_dod_path=str(dod_path),
1284 messages=[
1285 Message(
1286 role=Role.ASSISTANT,
1287 content=(
1288 "Repair focus:\n"
1289 f"- Fix the broken local reference `chapters/01-introduction.html` in `{repair_target}`.\n"
1290 f"- Immediate next step: edit `{repair_target}`.\n"
1291 f"- If the broken reference should remain, create `{existing_chapter}`; otherwise remove or replace `chapters/01-introduction.html`.\n"
1292 "- Use the existing artifact files as the source of truth while repairing this file: "
1293 f"`{existing_chapter}`.\n"
1294 "- Do not reread unrelated reference materials or restart discovery while this concrete repair target is unresolved.\n"
1295 ),
1296 )
1297 ],
1298 )
1299 hook = ActiveRepairMutationScopeHook(
1300 dod_store=dod_store,
1301 project_root=temp_dir,
1302 session=session,
1303 )
1304
1305 result = await hook.pre_tool_use(
1306 HookContext(
1307 tool_call=ToolCall(
1308 id="write-2",
1309 name="write",
1310 arguments={"file_path": str(next_chapter), "content": "<h1>Installation</h1>\n"},
1311 ),
1312 tool=registry.get("write"),
1313 registry=registry,
1314 permission_policy=policy,
1315 source="native",
1316 )
1317 )
1318
1319 assert result.decision == HookDecision.CONTINUE
1320
1321
1322 @pytest.mark.asyncio
1323 async def test_active_repair_mutation_scope_hook_blocks_broad_mutating_bash(
1324 temp_dir: Path,
1325 ) -> None:
1326 registry = create_default_registry(temp_dir)
1327 policy = build_permission_policy(
1328 active_mode=PermissionMode.WORKSPACE_WRITE,
1329 workspace_root=temp_dir,
1330 tool_requirements=registry.get_tool_requirements(),
1331 )
1332 dod_store = DefinitionOfDoneStore(temp_dir)
1333 dod = create_definition_of_done("Repair the active artifact set")
1334 dod.status = "in_progress"
1335 dod_path = dod_store.save(dod)
1336 repair_target = temp_dir / "guide" / "chapters" / "05-advanced-configurations.html"
1337 session = FakeSession(
1338 active_dod_path=str(dod_path),
1339 messages=[
1340 Message(
1341 role=Role.ASSISTANT,
1342 content=(
1343 "Repair focus:\n"
1344 f"- Fix the broken local reference `../styles.css` in `{repair_target}`.\n"
1345 f"- Immediate next step: edit `{repair_target}`.\n"
1346 f"- If the broken reference should remain, create `{temp_dir / 'guide' / 'styles.css'}`; otherwise remove or replace `../styles.css`.\n"
1347 "- Do not reread unrelated reference materials or restart discovery while this concrete repair target is unresolved.\n"
1348 ),
1349 )
1350 ],
1351 )
1352 hook = ActiveRepairMutationScopeHook(
1353 dod_store=dod_store,
1354 project_root=temp_dir,
1355 session=session,
1356 )
1357
1358 result = await hook.pre_tool_use(
1359 HookContext(
1360 tool_call=ToolCall(
1361 id="bash-1",
1362 name="bash",
1363 arguments={"command": f"mkdir -p {temp_dir / 'guide' / 'assets'}"},
1364 ),
1365 tool=registry.get("bash"),
1366 registry=registry,
1367 permission_policy=policy,
1368 source="native",
1369 )
1370 )
1371
1372 assert result.decision == HookDecision.DENY
1373 assert result.terminal_state == "blocked"
1374 assert result.message is not None
1375 assert "active repair mutation scope" in result.message
1376 assert str(repair_target) in result.message
1377
1378
1379 @pytest.mark.asyncio
1380 async def test_late_reference_drift_hook_blocks_out_of_scope_reference_reads(
1381 temp_dir: Path,
1382 ) -> None:
1383 registry = create_default_registry(temp_dir)
1384 policy = build_permission_policy(
1385 active_mode=PermissionMode.WORKSPACE_WRITE,
1386 workspace_root=temp_dir,
1387 tool_requirements=registry.get_tool_requirements(),
1388 )
1389 dod_store = DefinitionOfDoneStore(temp_dir)
1390 dod = create_definition_of_done("Create a multi-file guide from a reference")
1391 dod.status = "in_progress"
1392 plan_path = temp_dir / "implementation.md"
1393 plan_path.write_text(
1394 "# File Changes\n"
1395 "- `guide/index.html`\n"
1396 "- `guide/chapters/01-getting-started.html`\n"
1397 "- `guide/chapters/02-installation.html`\n"
1398 "- `guide/chapters/03-first-website.html`\n"
1399 )
1400 dod.implementation_plan = str(plan_path)
1401 dod_path = dod_store.save(dod)
1402 guide_dir = temp_dir / "guide" / "chapters"
1403 guide_dir.mkdir(parents=True, exist_ok=True)
1404 (temp_dir / "guide" / "index.html").write_text("index")
1405 (guide_dir / "01-getting-started.html").write_text("one")
1406 (guide_dir / "02-installation.html").write_text("two")
1407 session = FakeSession(active_dod_path=str(dod_path), messages=[])
1408 hook = LateReferenceDriftHook(
1409 dod_store=dod_store,
1410 project_root=temp_dir,
1411 session=session,
1412 )
1413
1414 result = await hook.pre_tool_use(
1415 HookContext(
1416 tool_call=ToolCall(
1417 id="read-1",
1418 name="read",
1419 arguments={"file_path": str(temp_dir / "reference" / "index.html")},
1420 ),
1421 tool=registry.get("read"),
1422 registry=registry,
1423 permission_policy=policy,
1424 source="native",
1425 )
1426 )
1427
1428 assert result.decision == HookDecision.DENY
1429 assert result.terminal_state == "blocked"
1430 assert result.message is not None
1431 assert "late reference drift" in result.message
1432 assert "03-first-website.html" in result.message
1433
1434
1435 @pytest.mark.asyncio
1436 async def test_late_reference_drift_hook_allows_reads_inside_planned_artifact_set(
1437 temp_dir: Path,
1438 ) -> None:
1439 registry = create_default_registry(temp_dir)
1440 policy = build_permission_policy(
1441 active_mode=PermissionMode.WORKSPACE_WRITE,
1442 workspace_root=temp_dir,
1443 tool_requirements=registry.get_tool_requirements(),
1444 )
1445 dod_store = DefinitionOfDoneStore(temp_dir)
1446 dod = create_definition_of_done("Create a multi-file guide from a reference")
1447 dod.status = "in_progress"
1448 plan_path = temp_dir / "implementation.md"
1449 plan_path.write_text(
1450 "# File Changes\n"
1451 "- `guide/index.html`\n"
1452 "- `guide/chapters/01-getting-started.html`\n"
1453 "- `guide/chapters/02-installation.html`\n"
1454 "- `guide/chapters/03-first-website.html`\n"
1455 )
1456 dod.implementation_plan = str(plan_path)
1457 dod_path = dod_store.save(dod)
1458 guide_dir = temp_dir / "guide" / "chapters"
1459 guide_dir.mkdir(parents=True, exist_ok=True)
1460 target = guide_dir / "02-installation.html"
1461 (temp_dir / "guide" / "index.html").write_text("index")
1462 (guide_dir / "01-getting-started.html").write_text("one")
1463 target.write_text("two")
1464 session = FakeSession(active_dod_path=str(dod_path), messages=[])
1465 hook = LateReferenceDriftHook(
1466 dod_store=dod_store,
1467 project_root=temp_dir,
1468 session=session,
1469 )
1470
1471 result = await hook.pre_tool_use(
1472 HookContext(
1473 tool_call=ToolCall(
1474 id="read-1",
1475 name="read",
1476 arguments={"file_path": str(target)},
1477 ),
1478 tool=registry.get("read"),
1479 registry=registry,
1480 permission_policy=policy,
1481 source="native",
1482 )
1483 )
1484
1485 assert result.decision == HookDecision.CONTINUE
1486
1487
1488 @pytest.mark.asyncio
1489 async def test_late_reference_drift_hook_blocks_reference_reopen_after_study_and_first_output(
1490 temp_dir: Path,
1491 ) -> None:
1492 registry = create_default_registry(temp_dir)
1493 policy = build_permission_policy(
1494 active_mode=PermissionMode.WORKSPACE_WRITE,
1495 workspace_root=temp_dir,
1496 tool_requirements=registry.get_tool_requirements(),
1497 )
1498 dod_store = DefinitionOfDoneStore(temp_dir)
1499 dod = create_definition_of_done("Create a multi-file guide from a reference")
1500 dod.status = "in_progress"
1501 dod.completed_items = [
1502 "First, examine the existing reference guide structure to understand the format and cadence",
1503 ]
1504 plan_path = temp_dir / "implementation.md"
1505 plan_path.write_text(
1506 "# File Changes\n"
1507 "- `guide/index.html`\n"
1508 "- `guide/chapters/01-getting-started.html`\n"
1509 "- `guide/chapters/02-installation.html`\n"
1510 )
1511 dod.implementation_plan = str(plan_path)
1512 guide_dir = temp_dir / "guide" / "chapters"
1513 guide_dir.mkdir(parents=True, exist_ok=True)
1514 (temp_dir / "guide" / "index.html").write_text("index")
1515 dod_path = dod_store.save(dod)
1516 session = FakeSession(active_dod_path=str(dod_path), messages=[])
1517 hook = LateReferenceDriftHook(
1518 dod_store=dod_store,
1519 project_root=temp_dir,
1520 session=session,
1521 )
1522
1523 result = await hook.pre_tool_use(
1524 HookContext(
1525 tool_call=ToolCall(
1526 id="read-reference",
1527 name="read",
1528 arguments={"file_path": str(temp_dir / "reference" / "index.html")},
1529 ),
1530 tool=registry.get("read"),
1531 registry=registry,
1532 permission_policy=policy,
1533 source="native",
1534 )
1535 )
1536
1537 assert result.decision == HookDecision.DENY
1538 assert result.terminal_state == "blocked"
1539 assert result.message is not None
1540 assert "late reference drift" in result.message
1541 assert "01-getting-started.html" in result.message
1542
1543
1544 @pytest.mark.asyncio
1545 async def test_late_reference_drift_hook_blocks_reference_reads_after_artifacts_exist(
1546 temp_dir: Path,
1547 ) -> None:
1548 registry = create_default_registry(temp_dir)
1549 policy = build_permission_policy(
1550 active_mode=PermissionMode.WORKSPACE_WRITE,
1551 workspace_root=temp_dir,
1552 tool_requirements=registry.get_tool_requirements(),
1553 )
1554 dod_store = DefinitionOfDoneStore(temp_dir)
1555 dod = create_definition_of_done("Create a multi-file guide from a reference")
1556 dod.status = "in_progress"
1557 plan_path = temp_dir / "implementation.md"
1558 plan_path.write_text(
1559 "\n".join(
1560 [
1561 "# Implementation Plan",
1562 "",
1563 "## File Changes",
1564 f"- `{temp_dir / 'guide'}`",
1565 f"- `{temp_dir / 'guide' / 'chapters'}`",
1566 f"- `{temp_dir / 'guide' / 'index.html'}`",
1567 f"- `{temp_dir / 'guide' / 'chapters' / '01-getting-started.html'}`",
1568 f"- `{temp_dir / 'guide' / 'chapters' / '02-installation.html'}`",
1569 "",
1570 ]
1571 )
1572 )
1573 dod.implementation_plan = str(plan_path)
1574 guide_dir = temp_dir / "guide" / "chapters"
1575 guide_dir.mkdir(parents=True, exist_ok=True)
1576 (temp_dir / "guide" / "index.html").write_text("index")
1577 (guide_dir / "01-getting-started.html").write_text("one")
1578 (guide_dir / "02-installation.html").write_text("two")
1579 dod_path = dod_store.save(dod)
1580 session = FakeSession(active_dod_path=str(dod_path), messages=[])
1581 hook = LateReferenceDriftHook(
1582 dod_store=dod_store,
1583 project_root=temp_dir,
1584 session=session,
1585 )
1586
1587 result = await hook.pre_tool_use(
1588 HookContext(
1589 tool_call=ToolCall(
1590 id="read-1",
1591 name="read",
1592 arguments={"file_path": str(temp_dir / "reference" / "index.html")},
1593 ),
1594 tool=registry.get("read"),
1595 registry=registry,
1596 permission_policy=policy,
1597 source="native",
1598 )
1599 )
1600
1601 assert result.decision == HookDecision.DENY
1602 assert result.terminal_state == "blocked"
1603 assert result.message is not None
1604 assert "completed artifact set scope" in result.message
1605 assert str(temp_dir / "guide") in result.message
1606
1607
1608 @pytest.mark.asyncio
1609 async def test_late_reference_drift_hook_blocks_verification_reference_reads_after_artifacts_exist(
1610 temp_dir: Path,
1611 ) -> None:
1612 registry = create_default_registry(temp_dir)
1613 policy = build_permission_policy(
1614 active_mode=PermissionMode.WORKSPACE_WRITE,
1615 workspace_root=temp_dir,
1616 tool_requirements=registry.get_tool_requirements(),
1617 )
1618 dod_store = DefinitionOfDoneStore(temp_dir)
1619 dod = create_definition_of_done("Create a multi-file guide from a reference")
1620 dod.status = "in_progress"
1621 plan_path = temp_dir / "implementation.md"
1622 plan_path.write_text(
1623 "\n".join(
1624 [
1625 "# Implementation Plan",
1626 "",
1627 "## File Changes",
1628 f"- `{temp_dir / 'guide'}`",
1629 f"- `{temp_dir / 'guide' / 'chapters'}`",
1630 f"- `{temp_dir / 'guide' / 'index.html'}`",
1631 f"- `{temp_dir / 'guide' / 'chapters' / '01-getting-started.html'}`",
1632 f"- `{temp_dir / 'guide' / 'chapters' / '02-installation.html'}`",
1633 "",
1634 ]
1635 )
1636 )
1637 dod.implementation_plan = str(plan_path)
1638 guide_dir = temp_dir / "guide" / "chapters"
1639 guide_dir.mkdir(parents=True, exist_ok=True)
1640 (temp_dir / "guide" / "index.html").write_text("index")
1641 (guide_dir / "01-getting-started.html").write_text("one")
1642 (guide_dir / "02-installation.html").write_text("two")
1643 dod_path = dod_store.save(dod)
1644 session = FakeSession(active_dod_path=str(dod_path), messages=[])
1645 hook = LateReferenceDriftHook(
1646 dod_store=dod_store,
1647 project_root=temp_dir,
1648 session=session,
1649 )
1650
1651 result = await hook.pre_tool_use(
1652 HookContext(
1653 tool_call=ToolCall(
1654 id="read-verify-1",
1655 name="read",
1656 arguments={"file_path": str(temp_dir / "reference" / "index.html")},
1657 ),
1658 tool=registry.get("read"),
1659 registry=registry,
1660 permission_policy=policy,
1661 source="verification",
1662 )
1663 )
1664
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
1669
1670
1671 @pytest.mark.asyncio
1672 async def test_late_reference_drift_hook_blocks_excessive_post_build_self_audits(
1673 temp_dir: Path,
1674 ) -> None:
1675 registry = create_default_registry(temp_dir)
1676 policy = build_permission_policy(
1677 active_mode=PermissionMode.WORKSPACE_WRITE,
1678 workspace_root=temp_dir,
1679 tool_requirements=registry.get_tool_requirements(),
1680 )
1681 dod_store = DefinitionOfDoneStore(temp_dir)
1682 dod = create_definition_of_done("Create a multi-file guide from a reference")
1683 dod.status = "in_progress"
1684 plan_path = temp_dir / "implementation.md"
1685 plan_path.write_text(
1686 "\n".join(
1687 [
1688 "# Implementation Plan",
1689 "",
1690 "## File Changes",
1691 f"- `{temp_dir / 'guide' / 'index.html'}`",
1692 f"- `{temp_dir / 'guide' / 'chapters' / '01-getting-started.html'}`",
1693 f"- `{temp_dir / 'guide' / 'chapters' / '02-installation.html'}`",
1694 "",
1695 ]
1696 )
1697 )
1698 dod.implementation_plan = str(plan_path)
1699 guide_dir = temp_dir / "guide" / "chapters"
1700 guide_dir.mkdir(parents=True, exist_ok=True)
1701 target = guide_dir / "02-installation.html"
1702 (temp_dir / "guide" / "index.html").write_text("<h1>Nginx Guide</h1>\n")
1703 (guide_dir / "01-getting-started.html").write_text("<h1>Getting Started</h1>\n")
1704 target.write_text("<h1>Installation</h1>\n")
1705 dod_path = dod_store.save(dod)
1706 session = FakeSession(active_dod_path=str(dod_path), messages=[])
1707 hook = LateReferenceDriftHook(
1708 dod_store=dod_store,
1709 project_root=temp_dir,
1710 session=session,
1711 )
1712
1713 def make_context(index: int) -> HookContext:
1714 return HookContext(
1715 tool_call=ToolCall(
1716 id=f"read-{index}",
1717 name="read",
1718 arguments={"file_path": str(target)},
1719 ),
1720 tool=registry.get("read"),
1721 registry=registry,
1722 permission_policy=policy,
1723 source="native",
1724 )
1725
1726 for index in range(1, 5):
1727 context = make_context(index)
1728 result = await hook.pre_tool_use(context)
1729 assert result.decision == HookDecision.CONTINUE
1730 await hook.post_tool_use(context)
1731
1732 blocked = await hook.pre_tool_use(make_context(5))
1733
1734 assert blocked.decision == HookDecision.DENY
1735 assert blocked.terminal_state == "blocked"
1736 assert blocked.message is not None
1737 assert "post-build audit loop" in blocked.message
1738
1739
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
1809 @pytest.mark.asyncio
1810 async def test_late_reference_drift_hook_blocks_relative_bash_reference_reads_after_artifacts_exist(
1811 temp_dir: Path,
1812 ) -> None:
1813 registry = create_default_registry(temp_dir)
1814 policy = build_permission_policy(
1815 active_mode=PermissionMode.WORKSPACE_WRITE,
1816 workspace_root=temp_dir,
1817 tool_requirements=registry.get_tool_requirements(),
1818 )
1819 dod_store = DefinitionOfDoneStore(temp_dir)
1820 dod = create_definition_of_done("Create a multi-file guide from a reference")
1821 dod.status = "in_progress"
1822 plan_path = temp_dir / "implementation.md"
1823 plan_path.write_text(
1824 "\n".join(
1825 [
1826 "# Implementation Plan",
1827 "",
1828 "## File Changes",
1829 f"- `{temp_dir / 'guide'}`",
1830 f"- `{temp_dir / 'guide' / 'chapters'}`",
1831 f"- `{temp_dir / 'guide' / 'index.html'}`",
1832 f"- `{temp_dir / 'guide' / 'chapters' / '01-getting-started.html'}`",
1833 f"- `{temp_dir / 'guide' / 'chapters' / '02-installation.html'}`",
1834 "",
1835 ]
1836 )
1837 )
1838 dod.implementation_plan = str(plan_path)
1839 guide_dir = temp_dir / "guide" / "chapters"
1840 guide_dir.mkdir(parents=True, exist_ok=True)
1841 (temp_dir / "guide" / "index.html").write_text("index")
1842 (guide_dir / "01-getting-started.html").write_text("one")
1843 (guide_dir / "02-installation.html").write_text("two")
1844 dod_path = dod_store.save(dod)
1845 session = FakeSession(active_dod_path=str(dod_path), messages=[])
1846 hook = LateReferenceDriftHook(
1847 dod_store=dod_store,
1848 project_root=temp_dir,
1849 session=session,
1850 )
1851
1852 result = await hook.pre_tool_use(
1853 HookContext(
1854 tool_call=ToolCall(
1855 id="bash-relative-reference-1",
1856 name="bash",
1857 arguments={
1858 "command": f"cd {temp_dir} && ls -la reference/"
1859 },
1860 ),
1861 tool=registry.get("bash"),
1862 registry=registry,
1863 permission_policy=policy,
1864 source="verification",
1865 )
1866 )
1867
1868 assert result.decision == HookDecision.DENY
1869 assert result.terminal_state == "blocked"
1870 assert result.message is not None
1871 assert "completed artifact set scope" in result.message
1872
1873
1874 @pytest.mark.asyncio
1875 async def test_late_reference_drift_hook_blocks_relative_bash_post_build_audit_loop(
1876 temp_dir: Path,
1877 ) -> None:
1878 registry = create_default_registry(temp_dir)
1879 policy = build_permission_policy(
1880 active_mode=PermissionMode.WORKSPACE_WRITE,
1881 workspace_root=temp_dir,
1882 tool_requirements=registry.get_tool_requirements(),
1883 )
1884 dod_store = DefinitionOfDoneStore(temp_dir)
1885 dod = create_definition_of_done("Create a multi-file guide from a reference")
1886 dod.status = "in_progress"
1887 plan_path = temp_dir / "implementation.md"
1888 plan_path.write_text(
1889 "\n".join(
1890 [
1891 "# Implementation Plan",
1892 "",
1893 "## File Changes",
1894 f"- `{temp_dir / 'guide' / 'index.html'}`",
1895 f"- `{temp_dir / 'guide' / 'chapters' / '01-getting-started.html'}`",
1896 f"- `{temp_dir / 'guide' / 'chapters' / '02-installation.html'}`",
1897 "",
1898 ]
1899 )
1900 )
1901 dod.implementation_plan = str(plan_path)
1902 guide_dir = temp_dir / "guide" / "chapters"
1903 guide_dir.mkdir(parents=True, exist_ok=True)
1904 (temp_dir / "guide" / "index.html").write_text("<h1>Guide</h1>\n")
1905 (guide_dir / "01-getting-started.html").write_text("<h1>One</h1>\n")
1906 (guide_dir / "02-installation.html").write_text("<h1>Two</h1>\n")
1907 dod_path = dod_store.save(dod)
1908 session = FakeSession(active_dod_path=str(dod_path), messages=[])
1909 hook = LateReferenceDriftHook(
1910 dod_store=dod_store,
1911 project_root=temp_dir,
1912 session=session,
1913 )
1914
1915 def make_context(index: int) -> HookContext:
1916 return HookContext(
1917 tool_call=ToolCall(
1918 id=f"bash-relative-audit-{index}",
1919 name="bash",
1920 arguments={
1921 "command": f"cd {temp_dir} && ls -la guide/chapters/"
1922 },
1923 ),
1924 tool=registry.get("bash"),
1925 registry=registry,
1926 permission_policy=policy,
1927 source="verification",
1928 )
1929
1930 for index in range(1, 5):
1931 context = make_context(index)
1932 result = await hook.pre_tool_use(context)
1933 assert result.decision == HookDecision.CONTINUE
1934 await hook.post_tool_use(context)
1935
1936 blocked = await hook.pre_tool_use(make_context(5))
1937
1938 assert blocked.decision == HookDecision.DENY
1939 assert blocked.terminal_state == "blocked"
1940 assert blocked.message is not None
1941 assert "post-build audit loop" in blocked.message
1942
1943
1944 @pytest.mark.asyncio
1945 async def test_late_reference_drift_hook_does_not_treat_empty_output_dir_as_complete_artifact_set(
1946 temp_dir: Path,
1947 ) -> None:
1948 registry = create_default_registry(temp_dir)
1949 policy = build_permission_policy(
1950 active_mode=PermissionMode.WORKSPACE_WRITE,
1951 workspace_root=temp_dir,
1952 tool_requirements=registry.get_tool_requirements(),
1953 )
1954 dod_store = DefinitionOfDoneStore(temp_dir)
1955 dod = create_definition_of_done("Create a multi-file guide from a reference")
1956 dod.status = "in_progress"
1957 dod.completed_items = ["Create chapter files with appropriate content"]
1958 plan_path = temp_dir / "implementation.md"
1959 plan_path.write_text(
1960 "\n".join(
1961 [
1962 "# Implementation Plan",
1963 "",
1964 "## File Changes",
1965 f"- `{temp_dir / 'guide' / 'index.html'}`",
1966 f"- `{temp_dir / 'guide' / 'chapters'}/` (directory for chapter files)",
1967 "",
1968 "## Execution Order",
1969 "- Create chapter files with appropriate content",
1970 ]
1971 )
1972 )
1973 dod.implementation_plan = str(plan_path)
1974 guide_dir = temp_dir / "guide" / "chapters"
1975 guide_dir.mkdir(parents=True, exist_ok=True)
1976 (temp_dir / "guide" / "index.html").write_text("index")
1977 dod_path = dod_store.save(dod)
1978 session = FakeSession(active_dod_path=str(dod_path), messages=[])
1979 hook = LateReferenceDriftHook(
1980 dod_store=dod_store,
1981 project_root=temp_dir,
1982 session=session,
1983 )
1984
1985 result = await hook.pre_tool_use(
1986 HookContext(
1987 tool_call=ToolCall(
1988 id="read-1",
1989 name="read",
1990 arguments={"file_path": str(temp_dir / "reference" / "index.html")},
1991 ),
1992 tool=registry.get("read"),
1993 registry=registry,
1994 permission_policy=policy,
1995 source="native",
1996 )
1997 )
1998
1999 assert result.decision == HookDecision.CONTINUE
2000
2001
2002 @pytest.mark.asyncio
2003 async def test_late_reference_drift_hook_does_not_block_when_html_outputs_still_link_to_missing_files(
2004 temp_dir: Path,
2005 ) -> None:
2006 registry = create_default_registry(temp_dir)
2007 policy = build_permission_policy(
2008 active_mode=PermissionMode.WORKSPACE_WRITE,
2009 workspace_root=temp_dir,
2010 tool_requirements=registry.get_tool_requirements(),
2011 )
2012 dod_store = DefinitionOfDoneStore(temp_dir)
2013 dod = create_definition_of_done("Create a multi-file guide from a reference")
2014 dod.status = "in_progress"
2015 dod.completed_items = ["Create chapter files with appropriate content"]
2016 plan_path = temp_dir / "implementation.md"
2017 plan_path.write_text(
2018 "\n".join(
2019 [
2020 "# Implementation Plan",
2021 "",
2022 "## File Changes",
2023 f"- `{temp_dir / 'guide' / 'index.html'}`",
2024 f"- `{temp_dir / 'guide' / 'chapters'}/` (directory for chapter files)",
2025 "",
2026 "## Execution Order",
2027 "- Create chapter files with appropriate content",
2028 ]
2029 )
2030 )
2031 dod.implementation_plan = str(plan_path)
2032 guide_dir = temp_dir / "guide"
2033 chapters = guide_dir / "chapters"
2034 chapters.mkdir(parents=True, exist_ok=True)
2035 index = guide_dir / "index.html"
2036 index.write_text(
2037 '<a href="chapters/01-getting-started.html">One</a>\n'
2038 '<a href="chapters/02-installation.html">Two</a>\n'
2039 )
2040 (chapters / "01-getting-started.html").write_text("one")
2041 dod.touched_files = [str(index), str(chapters / "01-getting-started.html")]
2042 dod_path = dod_store.save(dod)
2043 session = FakeSession(active_dod_path=str(dod_path), messages=[])
2044 hook = LateReferenceDriftHook(
2045 dod_store=dod_store,
2046 project_root=temp_dir,
2047 session=session,
2048 )
2049
2050 result = await hook.pre_tool_use(
2051 HookContext(
2052 tool_call=ToolCall(
2053 id="read-1",
2054 name="read",
2055 arguments={"file_path": str(temp_dir / "reference" / "index.html")},
2056 ),
2057 tool=registry.get("read"),
2058 registry=registry,
2059 permission_policy=policy,
2060 source="native",
2061 )
2062 )
2063
2064 assert result.decision == HookDecision.CONTINUE
2065
2066
2067 @pytest.mark.asyncio
2068 async def test_missing_planned_output_read_hook_blocks_reads_of_declared_missing_output(
2069 temp_dir: Path,
2070 ) -> None:
2071 registry = create_default_registry(temp_dir)
2072 policy = build_permission_policy(
2073 active_mode=PermissionMode.WORKSPACE_WRITE,
2074 workspace_root=temp_dir,
2075 tool_requirements=registry.get_tool_requirements(),
2076 )
2077 dod_store = DefinitionOfDoneStore(temp_dir)
2078 dod = create_definition_of_done("Create a multi-file guide from a reference")
2079 dod.status = "in_progress"
2080 plan_path = temp_dir / "implementation.md"
2081 guide_root = temp_dir / "guide"
2082 chapters = guide_root / "chapters"
2083 plan_path.write_text(
2084 "\n".join(
2085 [
2086 "# Implementation Plan",
2087 "",
2088 "## File Changes",
2089 f"- `{guide_root / 'index.html'}`",
2090 f"- `{chapters}/`",
2091 "",
2092 ]
2093 )
2094 )
2095 dod.implementation_plan = str(plan_path)
2096 chapters.mkdir(parents=True, exist_ok=True)
2097 (guide_root / "index.html").write_text(
2098 "\n".join(
2099 [
2100 "<html>",
2101 '<a href="chapters/01-introduction.html">Chapter 1: Introduction</a>',
2102 '<a href="chapters/02-installation.html">Chapter 2: Installation</a>',
2103 '<a href="chapters/03-configuration-basics.html">Chapter 3: Configuration Basics</a>',
2104 "</html>",
2105 ]
2106 )
2107 + "\n"
2108 )
2109 (chapters / "01-introduction.html").write_text("<h1>Introduction</h1>\n")
2110 (chapters / "02-installation.html").write_text("<h1>Installation</h1>\n")
2111 dod_path = dod_store.save(dod)
2112 session = FakeSession(active_dod_path=str(dod_path), messages=[])
2113 hook = MissingPlannedOutputReadHook(
2114 dod_store=dod_store,
2115 project_root=temp_dir,
2116 session=session,
2117 )
2118 missing_target = chapters / "03-configuration-basics.html"
2119
2120 result = await hook.pre_tool_use(
2121 HookContext(
2122 tool_call=ToolCall(
2123 id="read-missing-output",
2124 name="read",
2125 arguments={"file_path": str(missing_target)},
2126 ),
2127 tool=registry.get("read"),
2128 registry=registry,
2129 permission_policy=policy,
2130 source="native",
2131 )
2132 )
2133
2134 assert result.decision == HookDecision.DENY
2135 assert result.terminal_state == "blocked"
2136 assert result.message is not None
2137 assert "missing planned output artifact" in result.message
2138 assert 'write(file_path="' in result.message
2139 assert "03-configuration-basics.html" in result.message
2140 assert "Chapter 3: Configuration Basics" in result.message
2141 assert "02-installation.html" in result.message