| 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 |