claude-dev-env 2.30.1 → 2.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/.agents/agents/pr-description-writer.md +1 -1
  2. package/AGENTS.md +6 -0
  3. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +37 -0
  4. package/bin/install.prune.test.mjs +4 -2
  5. package/docs/CODE_RULES.md +11 -0
  6. package/hooks/advisory/migration_safety_advisor.py +25 -1
  7. package/hooks/advisory/refactor_guard.py +85 -18
  8. package/hooks/advisory/test_migration_safety_advisor.py +89 -5
  9. package/hooks/advisory/test_refactor_guard.py +87 -0
  10. package/hooks/advisory/test_refactor_guard_advisory.py +72 -1
  11. package/hooks/blocking/code_rules_constants_config.py +7 -6
  12. package/hooks/blocking/code_rules_enforcer.py +57 -31
  13. package/hooks/blocking/codex_apply_patch.py +136 -21
  14. package/hooks/blocking/config/codex_apply_patch_constants.py +13 -0
  15. package/hooks/blocking/conftest.py +70 -0
  16. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +26 -16
  17. package/hooks/blocking/hook_prose_detector_consistency.py +30 -20
  18. package/hooks/blocking/pii_payload_scan.py +61 -0
  19. package/hooks/blocking/pii_prevention_blocker.py +5 -0
  20. package/hooks/blocking/pr_description_writer_gate.py +263 -0
  21. package/hooks/blocking/pre_tool_use_dispatcher.py +1 -1
  22. package/hooks/blocking/sensitive_file_protector.py +18 -3
  23. package/hooks/blocking/shell_substitution_blocker.py +25 -47
  24. package/hooks/blocking/stale_comment_reference_blocker.py +94 -24
  25. package/hooks/blocking/state_description_blocker.py +39 -26
  26. package/hooks/blocking/subprocess_budget_completeness.py +17 -43
  27. package/hooks/blocking/tdd_enforcer.py +57 -14
  28. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +11 -1
  29. package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +180 -0
  30. package/hooks/blocking/tdd_enforcer_parts/decisions.py +6 -2
  31. package/hooks/blocking/tdd_enforcer_parts/freshness.py +18 -3
  32. package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +66 -1
  33. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +143 -0
  34. package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +12 -0
  35. package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +63 -0
  36. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +1 -0
  37. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +81 -2
  38. package/hooks/blocking/test_code_rules_enforcer_codex_apply_patch.py +35 -0
  39. package/hooks/blocking/test_code_rules_enforcer_config_path.py +29 -16
  40. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +2 -0
  41. package/hooks/blocking/test_code_rules_enforcer_multi_edit.py +118 -0
  42. package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +2 -0
  43. package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +2 -0
  44. package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +29 -28
  45. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +2 -0
  46. package/hooks/blocking/test_code_rules_enforcer_stage.py +2 -0
  47. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +2 -0
  48. package/hooks/blocking/test_codex_apply_patch.py +43 -0
  49. package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +68 -0
  50. package/hooks/blocking/test_hook_prose_detector_consistency.py +41 -0
  51. package/hooks/blocking/test_pii_payload_scan.py +65 -2
  52. package/hooks/blocking/test_pii_prevention_blocker.py +80 -10
  53. package/hooks/blocking/test_pr_description_writer_gate.py +216 -0
  54. package/hooks/blocking/test_pre_tool_use_dispatcher.py +215 -25
  55. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +9 -4
  56. package/hooks/blocking/test_sensitive_file_protector.py +42 -2
  57. package/hooks/blocking/test_stale_comment_reference_blocker.py +71 -0
  58. package/hooks/blocking/test_state_description_blocker.py +68 -0
  59. package/hooks/blocking/test_subprocess_budget_completeness.py +67 -0
  60. package/hooks/blocking/test_tdd_enforcer.py +182 -2
  61. package/hooks/blocking/test_windows_rmtree_blocker.py +63 -0
  62. package/hooks/blocking/test_write_existing_file_blocker.py +122 -0
  63. package/hooks/blocking/windows_rmtree_blocker.py +21 -7
  64. package/hooks/blocking/write_existing_file_blocker.py +75 -29
  65. package/hooks/conftest.py +47 -0
  66. package/hooks/hooks.json +7 -12
  67. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +3 -0
  68. package/hooks/hooks_constants/code_rules_enforcer_constants.py +4 -0
  69. package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +3 -0
  70. package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +7 -2
  71. package/hooks/hooks_constants/migration_safety_advisor_constants.py +7 -0
  72. package/hooks/hooks_constants/multi_edit_reconstruction.py +44 -3
  73. package/hooks/hooks_constants/pii_prevention_constants.py +9 -7
  74. package/hooks/hooks_constants/pr_description_writer_gate_constants.py +39 -0
  75. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +31 -14
  76. package/hooks/hooks_constants/session_edit_stage_gate_constants.py +15 -1
  77. package/hooks/hooks_constants/state_description_blocker_constants.py +1 -0
  78. package/hooks/hooks_constants/subprocess_budget_completeness_content.py +71 -0
  79. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +2 -0
  80. package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +2 -0
  81. package/hooks/hooks_constants/test_multi_edit_reconstruction.py +102 -0
  82. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +139 -25
  83. package/hooks/hooks_constants/test_subprocess_budget_completeness_content.py +70 -0
  84. package/hooks/hooks_constants/validation_phase_constants.py +2 -0
  85. package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +7 -3
  86. package/hooks/observability/pr_description_writer_spawn_tracker.py +121 -0
  87. package/hooks/observability/session_file_edit_tracker.py +25 -4
  88. package/hooks/observability/test_pr_description_writer_spawn_tracker.py +146 -0
  89. package/hooks/observability/test_session_file_edit_tracker.py +32 -0
  90. package/hooks/validation/hook_format_validator.py +23 -9
  91. package/hooks/validation/test_hook_format_validator.py +58 -0
  92. package/hooks/validators/README.md +1 -1
  93. package/hooks/validators/abbreviation_checks.py +3 -5
  94. package/hooks/validators/config/abbreviation_checks_constants.py +3 -0
  95. package/hooks/validators/config/fast_save_validator_constants.py +32 -0
  96. package/hooks/validators/config/hook_timing_harness_constants.py +3 -1
  97. package/hooks/validators/config/test_abbreviation_checks_constants.py +11 -0
  98. package/hooks/validators/fast_save_validators.py +332 -0
  99. package/hooks/validators/hook_timing_harness.py +8 -4
  100. package/hooks/validators/python_style_checks.py +34 -60
  101. package/hooks/validators/python_style_helpers.py +16 -2
  102. package/hooks/validators/python_style_import_bootstrap.py +83 -0
  103. package/hooks/validators/run_all_validators.py +42 -41
  104. package/hooks/validators/test_abbreviation_checks.py +20 -0
  105. package/hooks/validators/test_fast_save_validators.py +172 -0
  106. package/hooks/validators/test_python_style_checks.py +9 -0
  107. package/hooks/validators/test_python_style_checks_import_order.py +81 -0
  108. package/hooks/validators/test_python_style_helpers.py +35 -0
  109. package/hooks/validators/test_python_style_import_bootstrap.py +69 -0
  110. package/hooks/validators/test_run_all_validators.py +46 -0
  111. package/hooks/validators/test_run_all_validators_pretooluse.py +2 -0
  112. package/package.json +1 -1
  113. package/rules/docstring-prose-matches-implementation.md +1 -1
  114. package/rules/git-workflow.md +2 -0
  115. package/rules/testing.md +4 -0
  116. package/scripts/codex_compat_materializer.py +5 -0
  117. package/scripts/tests/test_codex_compat_materializer.py +37 -11
  118. package/hooks/blocking/tests/conftest.py +0 -10
@@ -207,6 +207,77 @@ def test_allows_old_string_not_found(tmp_path: Path) -> None:
207
207
  assert outcome.stdout == ""
208
208
 
209
209
 
210
+ def test_multi_edit_denies_when_the_first_edit_writes_the_stale_comment(
211
+ tmp_path: Path,
212
+ ) -> None:
213
+ """The comment the second edit orphans is one the first edit just wrote.
214
+
215
+ A non-cumulative check reads the ORIGINAL "# Old note" comment and finds
216
+ nothing to orphan; only applying the first edit before the second reveals
217
+ "# Mock asyncio" above the removed line.
218
+ """
219
+ written_module_path = _write_module(
220
+ tmp_path,
221
+ "def launch_export() -> None:\n # Old note\n" + PATCHED_SLEEP_LINE,
222
+ )
223
+ all_edits = [
224
+ {"old_string": " # Old note\n", "new_string": KEPT_COMMENT_LINE},
225
+ {
226
+ "old_string": PATCHED_SLEEP_LINE.rstrip("\n"),
227
+ "new_string": PATCHED_POLL_LINE.rstrip("\n"),
228
+ },
229
+ ]
230
+ outcome = _run_hook(
231
+ "MultiEdit", {"file_path": str(written_module_path), "edits": all_edits}
232
+ )
233
+ assert outcome.returncode == 0
234
+ decision = json.loads(outcome.stdout)
235
+ assert decision["hookSpecificOutput"]["permissionDecision"] == "deny"
236
+ assert "asyncio" in decision["hookSpecificOutput"]["permissionDecisionReason"]
237
+
238
+
239
+ def test_multi_edit_allows_two_unrelated_edits(tmp_path: Path) -> None:
240
+ written_module_path = _write_module(
241
+ tmp_path,
242
+ "def launch_export() -> None:\n" + KEPT_COMMENT_LINE + PATCHED_SLEEP_LINE,
243
+ )
244
+ all_edits = [
245
+ {
246
+ "old_string": "def launch_export() -> None:",
247
+ "new_string": "def launch_export_now() -> None:",
248
+ },
249
+ {
250
+ "old_string": PATCHED_SLEEP_LINE,
251
+ "new_string": KEPT_COMMENT_LINE + PATCHED_SLEEP_LINE,
252
+ },
253
+ ]
254
+ outcome = _run_hook(
255
+ "MultiEdit", {"file_path": str(written_module_path), "edits": all_edits}
256
+ )
257
+ assert outcome.returncode == 0
258
+ assert outcome.stdout == ""
259
+
260
+
261
+ def test_multi_edit_skips_a_step_whose_old_string_is_absent(tmp_path: Path) -> None:
262
+ """A MultiEdit step whose old_string never occurs in the current content is skipped."""
263
+ written_module_path = _write_module(
264
+ tmp_path,
265
+ "def launch_export() -> None:\n" + KEPT_COMMENT_LINE + PATCHED_SLEEP_LINE,
266
+ )
267
+ all_edits = [
268
+ {"old_string": "never present anywhere", "new_string": "still never present"},
269
+ {
270
+ "old_string": PATCHED_SLEEP_LINE,
271
+ "new_string": KEPT_COMMENT_LINE + PATCHED_SLEEP_LINE,
272
+ },
273
+ ]
274
+ outcome = _run_hook(
275
+ "MultiEdit", {"file_path": str(written_module_path), "edits": all_edits}
276
+ )
277
+ assert outcome.returncode == 0
278
+ assert outcome.stdout == ""
279
+
280
+
210
281
  def test_deny_payload_logs_the_block(tmp_path: Path) -> None:
211
282
  """One record lands in the block journal for each denial built through
212
283
  evaluate plus build_deny_payload.
@@ -295,6 +295,74 @@ def test_clean_edit_passes():
295
295
  assert result.stdout == ""
296
296
 
297
297
 
298
+ def test_detects_multi_edit_second_new_string() -> None:
299
+ """A violation in the second edit of a MultiEdit is denied, not only the first."""
300
+ result = _run_hook(
301
+ "MultiEdit",
302
+ {
303
+ "file_path": "src/main.py",
304
+ "edits": [
305
+ {"old_string": "a = 1", "new_string": CLEAN_PYTHON},
306
+ {"old_string": "old_comment", "new_string": VIOLATION_PREVIOUSLY_COMMENT},
307
+ ],
308
+ },
309
+ )
310
+ assert result.returncode == 0
311
+ output = json.loads(result.stdout)
312
+ assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
313
+ assert "previously" in output["hookSpecificOutput"]["permissionDecisionReason"]
314
+
315
+
316
+ def test_clean_multi_edit_passes() -> None:
317
+ result = _run_hook(
318
+ "MultiEdit",
319
+ {
320
+ "file_path": "src/main.py",
321
+ "edits": [
322
+ {"old_string": "a = 1", "new_string": CLEAN_PYTHON},
323
+ {"old_string": "b = 2", "new_string": CLEAN_COMMENT},
324
+ ],
325
+ },
326
+ )
327
+ assert result.returncode == 0
328
+ assert result.stdout == ""
329
+
330
+
331
+ def test_multi_edit_new_strings_join_on_a_separator_not_concatenation() -> None:
332
+ """Two clean edit fragments stay clean even when one ends where the next begins.
333
+
334
+ The first edit's new_string ends in "# describes the previous", and the
335
+ second's begins with "ly implemented step.". Concatenated with no
336
+ separator the two would spell "previously" and trip a false deny; joined
337
+ on a real separator they stay two distinct fragments.
338
+ """
339
+ result = _run_hook(
340
+ "MultiEdit",
341
+ {
342
+ "file_path": "src/main.py",
343
+ "edits": [
344
+ {"old_string": "a = 1", "new_string": "# describes the previous"},
345
+ {"old_string": "b = 2", "new_string": "ly implemented step."},
346
+ ],
347
+ },
348
+ )
349
+ assert result.returncode == 0
350
+ assert result.stdout == ""
351
+
352
+
353
+ def test_multi_edit_with_a_single_edit_still_denies() -> None:
354
+ result = _run_hook(
355
+ "MultiEdit",
356
+ {
357
+ "file_path": "src/main.py",
358
+ "edits": [{"old_string": "old_comment", "new_string": VIOLATION_PREVIOUSLY_COMMENT}],
359
+ },
360
+ )
361
+ assert result.returncode == 0
362
+ output = json.loads(result.stdout)
363
+ assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
364
+
365
+
298
366
  def test_system_message_and_suppress_output():
299
367
  result = _run_hook(
300
368
  "Write",
@@ -586,3 +586,70 @@ def test_full_hook_denies_edit_that_adds_a_timeout_to_a_non_budget_helper(
586
586
  hook_output = json.loads(completed_hook.stdout)
587
587
  assert hook_output["hookSpecificOutput"]["permissionDecision"] == "deny"
588
588
  assert "45s" in hook_output["hookSpecificOutput"]["permissionDecisionReason"]
589
+
590
+
591
+ _HARMLESS_COMMENT_EDIT = {
592
+ "old_string": "return git_check.returncode != 0\n",
593
+ "new_string": "return git_check.returncode != 0 # checked\n",
594
+ }
595
+ _UNCOUNTED_TIMEOUT_EDIT = {
596
+ "old_string": (
597
+ ' git_check = subprocess.run(["git", "ls-files", file_path],'
598
+ " timeout=GIT_CHECK_TIMEOUT_SECONDS)\n"
599
+ ),
600
+ "new_string": ' git_check = subprocess.run(["git", "ls-files", file_path], timeout=45)\n',
601
+ }
602
+
603
+
604
+ def _run_multi_edit_hook(edited_module_path: pathlib.Path, all_edits: list[dict[str, str]]):
605
+ hook_input = json.dumps(
606
+ {
607
+ "tool_name": "MultiEdit",
608
+ "tool_input": {"file_path": str(edited_module_path), "edits": all_edits},
609
+ }
610
+ )
611
+ return subprocess.run(
612
+ [sys.executable, str(_HOOK_DIR / "subprocess_budget_completeness.py")],
613
+ input=hook_input,
614
+ capture_output=True,
615
+ text=True,
616
+ timeout=15,
617
+ check=False,
618
+ )
619
+
620
+
621
+ def test_resolved_content_reconstructs_the_full_file_for_a_multi_edit(
622
+ tmp_path: pathlib.Path,
623
+ ) -> None:
624
+ """The whole reconstructed file reflects every edit, not only the first."""
625
+ edited_module_path = tmp_path / "timing_module.py"
626
+ edited_module_path.write_text(_BUDGET_COUNTS_EVERY_TIMEOUT, encoding="utf-8")
627
+ reconstructed_content = resolved_content(
628
+ {
629
+ "file_path": str(edited_module_path),
630
+ "edits": [_HARMLESS_COMMENT_EDIT, _UNCOUNTED_TIMEOUT_EDIT],
631
+ }
632
+ )
633
+ assert "timeout=45" in reconstructed_content
634
+ assert "# checked" in reconstructed_content
635
+
636
+
637
+ def test_full_hook_denies_multi_edit_that_adds_a_timeout_in_the_second_edit(
638
+ production_module_path: pathlib.Path,
639
+ ) -> None:
640
+ """A new timeout introduced by the second edit of a MultiEdit is caught."""
641
+ production_module_path.write_text(_BUDGET_COUNTS_EVERY_TIMEOUT, encoding="utf-8")
642
+ completed_hook = _run_multi_edit_hook(
643
+ production_module_path, [_HARMLESS_COMMENT_EDIT, _UNCOUNTED_TIMEOUT_EDIT]
644
+ )
645
+ assert completed_hook.returncode == 0
646
+ hook_output = json.loads(completed_hook.stdout)
647
+ assert hook_output["hookSpecificOutput"]["permissionDecision"] == "deny"
648
+ assert "45s" in hook_output["hookSpecificOutput"]["permissionDecisionReason"]
649
+
650
+
651
+ def test_full_hook_allows_a_clean_multi_edit(production_module_path: pathlib.Path) -> None:
652
+ production_module_path.write_text(_BUDGET_COUNTS_EVERY_TIMEOUT, encoding="utf-8")
653
+ completed_hook = _run_multi_edit_hook(production_module_path, [_HARMLESS_COMMENT_EDIT])
654
+ assert completed_hook.returncode == 0
655
+ assert completed_hook.stdout == ""
@@ -10,6 +10,9 @@ import time
10
10
  from pathlib import Path
11
11
 
12
12
  import pytest
13
+ from tdd_enforcer_parts import content_hash_store
14
+
15
+ pytestmark = pytest.mark.usefixtures("ephemeral_exempt_off")
13
16
 
14
17
  SCRIPT_PATH = Path(__file__).parent / "tdd_enforcer.py"
15
18
 
@@ -49,6 +52,14 @@ def _make_write_payload(file_path: Path, content: str = "") -> dict:
49
52
  }
50
53
 
51
54
 
55
+ def _make_apply_patch_payload(working_directory: Path, patch_command: str) -> dict:
56
+ return {
57
+ "tool_name": "apply_patch",
58
+ "cwd": str(working_directory),
59
+ "tool_input": {"command": patch_command},
60
+ }
61
+
62
+
52
63
  def _decision_from(completed: subprocess.CompletedProcess[str]) -> str | None:
53
64
  if not completed.stdout:
54
65
  return None
@@ -76,6 +87,49 @@ def test_should_allow_when_sibling_test_file_exists_and_recent(tmp_path: Path) -
76
87
  assert _decision_from(completed) == "allow"
77
88
 
78
89
 
90
+ def test_should_deny_apply_patch_add_with_no_fresh_test(tmp_path: Path) -> None:
91
+ sandbox = _sandbox(tmp_path)
92
+ patch_command = (
93
+ "*** Begin Patch\n"
94
+ "*** Add File: orders.py\n"
95
+ "+def fulfill(): pass\n"
96
+ "*** End Patch"
97
+ )
98
+
99
+ completed = _run_hook_with_payload(
100
+ _make_apply_patch_payload(sandbox, patch_command),
101
+ extra_env={"CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT": "1"},
102
+ )
103
+
104
+ assert _decision_from(completed) == "deny"
105
+ parsed = json.loads(completed.stdout)
106
+ reason = parsed["hookSpecificOutput"]["permissionDecisionReason"]
107
+ assert "test_orders.py" in reason
108
+
109
+
110
+ def test_should_allow_apply_patch_update_with_fresh_sibling_test(tmp_path: Path) -> None:
111
+ sandbox = _sandbox(tmp_path)
112
+ production_file = sandbox / "orders.py"
113
+ production_file.write_text("def fulfill(): pass\n")
114
+ sibling_test = sandbox / "test_orders.py"
115
+ sibling_test.write_text("def test_fulfill(): pass\n")
116
+ patch_command = (
117
+ "*** Begin Patch\n"
118
+ "*** Update File: orders.py\n"
119
+ "@@\n"
120
+ "-def fulfill(): pass\n"
121
+ "+def fulfill(): return True\n"
122
+ "*** End Patch"
123
+ )
124
+
125
+ completed = _run_hook_with_payload(
126
+ _make_apply_patch_payload(sandbox, patch_command),
127
+ extra_env={"CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT": "1"},
128
+ )
129
+
130
+ assert _decision_from(completed) == "allow"
131
+
132
+
79
133
  def test_should_deny_when_no_test_file_exists(tmp_path: Path) -> None:
80
134
  sandbox = _sandbox(tmp_path)
81
135
  production_file = sandbox / "orders.py"
@@ -834,8 +888,9 @@ def _run_hook_with_payload_and_env(
834
888
  _BEHAVIOR_BEARING_CONTENT = "def fulfill_order(order: str) -> str:\n return order\n"
835
889
 
836
890
 
837
- def test_should_exit_zero_for_ephemeral_scratch_python() -> None:
891
+ def test_should_exit_zero_for_ephemeral_scratch_python(monkeypatch: pytest.MonkeyPatch) -> None:
838
892
  """B16: behavior-bearing scratch .py under root-anchored /tmp exits 0 with no deny."""
893
+ monkeypatch.delenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", raising=False)
839
894
  ephemeral_path = "/tmp/scratch_work.py"
840
895
  payload = _make_write_payload(Path(ephemeral_path), _BEHAVIOR_BEARING_CONTENT)
841
896
  completed = _run_hook_with_payload(payload)
@@ -844,8 +899,9 @@ def test_should_exit_zero_for_ephemeral_scratch_python() -> None:
844
899
  )
845
900
 
846
901
 
847
- def test_should_exit_zero_for_ephemeral_scratch_typescript() -> None:
902
+ def test_should_exit_zero_for_ephemeral_scratch_typescript(monkeypatch: pytest.MonkeyPatch) -> None:
848
903
  """B17: ephemeral .ts scratch file under root-anchored /tmp exits 0 with no deny."""
904
+ monkeypatch.delenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", raising=False)
849
905
  ephemeral_path = "/tmp/scratch_work.ts"
850
906
  payload = {
851
907
  "tool_name": "Write",
@@ -1194,3 +1250,127 @@ def test_should_deny_changing_a_future_import_on_a_constants_only_file(tmp_path:
1194
1250
  )
1195
1251
 
1196
1252
  assert _decision_from(completed) == "deny"
1253
+
1254
+
1255
+ def _make_write_payload_for_session(
1256
+ file_path: Path, content: str, session_id: str, repository_root: Path
1257
+ ) -> dict:
1258
+ return {
1259
+ "session_id": session_id,
1260
+ "cwd": str(repository_root),
1261
+ "tool_name": "Write",
1262
+ "tool_input": {"file_path": str(file_path), "content": content},
1263
+ }
1264
+
1265
+
1266
+ def _seed_stale_recorded_hash(
1267
+ candidate: Path, session_id: str, repository_root: Path, seconds_past_window: int
1268
+ ) -> None:
1269
+ """Plant a session record as if the gate had genuinely seen *candidate* long ago."""
1270
+ state_file = content_hash_store._state_file_path(session_id, str(repository_root))
1271
+ candidate_key = content_hash_store._state_key_for(candidate)
1272
+ recorded_hash = content_hash_store._content_hash(candidate.read_text())
1273
+ stale_changed_at = time.time() - FRESHNESS_SECONDS - seconds_past_window
1274
+ state = {
1275
+ candidate_key: {
1276
+ content_hash_store.STORED_CONTENT_HASH_KEY: recorded_hash,
1277
+ content_hash_store.STORED_CHANGED_AT_KEY: stale_changed_at,
1278
+ }
1279
+ }
1280
+ content_hash_store._atomic_write_state(state_file, state)
1281
+
1282
+
1283
+ def test_should_deny_when_test_file_was_recorded_then_only_touched_after_window(
1284
+ tmp_path: Path,
1285
+ ) -> None:
1286
+ """The exact loophole: a session-recorded test, later only touched, stays denied."""
1287
+ sandbox = _sandbox(tmp_path)
1288
+ production_file = sandbox / "orders.py"
1289
+ production_file.write_text("def fulfill(): pass\n")
1290
+ sibling_test = sandbox / "test_orders.py"
1291
+ sibling_test.write_text("def test_fulfill(): pass\n")
1292
+ session_id = "touch-loophole-session"
1293
+ _seed_stale_recorded_hash(sibling_test, session_id, sandbox, seconds_past_window=60)
1294
+
1295
+ sibling_test.touch()
1296
+
1297
+ completed = _run_hook_with_payload(
1298
+ _make_write_payload_for_session(
1299
+ production_file, "def fulfill(): return 1\n", session_id, sandbox
1300
+ )
1301
+ )
1302
+
1303
+ assert _decision_from(completed) == "deny"
1304
+
1305
+
1306
+ def test_should_allow_when_test_file_content_actually_changed_after_window(
1307
+ tmp_path: Path,
1308
+ ) -> None:
1309
+ sandbox = _sandbox(tmp_path)
1310
+ production_file = sandbox / "orders.py"
1311
+ production_file.write_text("def fulfill(): pass\n")
1312
+ sibling_test = sandbox / "test_orders.py"
1313
+ sibling_test.write_text("def test_fulfill(): pass\n")
1314
+ session_id = "touch-loophole-real-edit-session"
1315
+ _seed_stale_recorded_hash(sibling_test, session_id, sandbox, seconds_past_window=60)
1316
+
1317
+ sibling_test.write_text("def test_fulfill(): assert True\n")
1318
+
1319
+ completed = _run_hook_with_payload(
1320
+ _make_write_payload_for_session(
1321
+ production_file, "def fulfill(): return 1\n", session_id, sandbox
1322
+ )
1323
+ )
1324
+
1325
+ assert _decision_from(completed) == "allow"
1326
+
1327
+
1328
+ def test_should_allow_repeat_production_write_within_window_without_further_test_change(
1329
+ tmp_path: Path,
1330
+ ) -> None:
1331
+ """GREEN/REFACTOR needs several production writes after one real test edit."""
1332
+ sandbox = _sandbox(tmp_path)
1333
+ production_file = sandbox / "orders.py"
1334
+ production_file.write_text("def fulfill(): pass\n")
1335
+ sibling_test = sandbox / "test_orders.py"
1336
+ sibling_test.write_text("def test_fulfill(): pass\n")
1337
+ session_id = "green-refactor-session"
1338
+
1339
+ first_write = _run_hook_with_payload(
1340
+ _make_write_payload_for_session(
1341
+ production_file, "def fulfill(): return 1\n", session_id, sandbox
1342
+ )
1343
+ )
1344
+ assert _decision_from(first_write) == "allow"
1345
+
1346
+ second_write = _run_hook_with_payload(
1347
+ _make_write_payload_for_session(
1348
+ production_file, "def fulfill(): return 2\n", session_id, sandbox
1349
+ )
1350
+ )
1351
+
1352
+ assert _decision_from(second_write) == "allow"
1353
+
1354
+
1355
+ def test_should_deny_apply_patch_that_adds_test_and_production_file_in_one_transaction(
1356
+ tmp_path: Path,
1357
+ ) -> None:
1358
+ """One patch adding both files denies: the test is not yet on disk when read.
1359
+
1360
+ ``apply_patch`` is a PreToolUse hook; it sees the patch text before the
1361
+ patch runs, so a same-transaction test file is not readable yet even
1362
+ though it names a real, matching test.
1363
+ """
1364
+ sandbox = _sandbox(tmp_path)
1365
+ patch_command = (
1366
+ "*** Begin Patch\n"
1367
+ "*** Add File: orders.py\n"
1368
+ "+def fulfill(): pass\n"
1369
+ "*** Add File: test_orders.py\n"
1370
+ "+def test_fulfill(): pass\n"
1371
+ "*** End Patch"
1372
+ )
1373
+
1374
+ completed = _run_hook_with_payload(_make_apply_patch_payload(sandbox, patch_command))
1375
+
1376
+ assert _decision_from(completed) == "deny"
@@ -165,6 +165,31 @@ def test_extract_payload_returns_content_for_write_without_file_path() -> None:
165
165
  assert extracted == "some python code"
166
166
 
167
167
 
168
+ def test_extract_payload_reads_every_multi_edit_new_string() -> None:
169
+ extracted = extract_payload_text(
170
+ "MultiEdit",
171
+ {
172
+ "file_path": "foo.py",
173
+ "edits": [
174
+ {"old_string": "a = 1", "new_string": "a = 11"},
175
+ {"old_string": "b = 2", "new_string": DANGEROUS_RMTREE_SNIPPET},
176
+ ],
177
+ },
178
+ )
179
+ assert DANGEROUS_RMTREE_SNIPPET in extracted
180
+
181
+
182
+ def test_extract_payload_reads_a_single_item_multi_edit() -> None:
183
+ extracted = extract_payload_text(
184
+ "MultiEdit",
185
+ {
186
+ "file_path": "foo.py",
187
+ "edits": [{"old_string": "a = 1", "new_string": DANGEROUS_RMTREE_SNIPPET}],
188
+ },
189
+ )
190
+ assert extracted == DANGEROUS_RMTREE_SNIPPET
191
+
192
+
168
193
  def _run_hook_with_stdin_text(stdin_text: str) -> tuple[str, str, int]:
169
194
  captured_stdout = io.StringIO()
170
195
  captured_stderr = io.StringIO()
@@ -207,6 +232,44 @@ def test_main_blocks_unsafe_bash_command() -> None:
207
232
  assert "windows-rmtree" in decision_block["permissionDecisionReason"]
208
233
 
209
234
 
235
+ def test_main_blocks_unsafe_rmtree_introduced_by_second_multi_edit() -> None:
236
+ """An unsafe rmtree call in the second edit of a MultiEdit is caught, not only the first."""
237
+ stdout_text, exit_code = _run_hook(
238
+ {
239
+ "tool_name": "MultiEdit",
240
+ "tool_input": {
241
+ "file_path": "cleanup.py",
242
+ "edits": [
243
+ {"old_string": "a = 1", "new_string": "a = 11"},
244
+ {"old_string": "b = 2", "new_string": DANGEROUS_RMTREE_SNIPPET},
245
+ ],
246
+ },
247
+ }
248
+ )
249
+ assert exit_code == 0
250
+ response_payload = json.loads(stdout_text)
251
+ decision_block = response_payload["hookSpecificOutput"]
252
+ assert decision_block["permissionDecision"] == "deny"
253
+ assert "windows-rmtree" in decision_block["permissionDecisionReason"]
254
+
255
+
256
+ def test_main_allows_clean_multi_edit() -> None:
257
+ stdout_text, exit_code = _run_hook(
258
+ {
259
+ "tool_name": "MultiEdit",
260
+ "tool_input": {
261
+ "file_path": "cleanup.py",
262
+ "edits": [
263
+ {"old_string": "a = 1", "new_string": "a = 11"},
264
+ {"old_string": "b = 2", "new_string": "shutil.rmtree(path, onexc=handler)"},
265
+ ],
266
+ },
267
+ }
268
+ )
269
+ assert exit_code == 0
270
+ assert stdout_text == ""
271
+
272
+
210
273
  def test_main_passes_through_safe_write() -> None:
211
274
  stdout_text, exit_code = _run_hook(
212
275
  {
@@ -0,0 +1,122 @@
1
+ """Tests for the write_existing_file_blocker hook.
2
+
3
+ Each case drives the real hook's ``main()`` through its production stdin path,
4
+ feeding it the PreToolUse JSON payload Claude Code sends and reading the
5
+ permission decision back off stdout.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import contextlib
11
+ import io
12
+ import json
13
+ from collections.abc import Mapping
14
+ from pathlib import Path
15
+
16
+ import pytest
17
+
18
+ import write_existing_file_blocker
19
+
20
+
21
+ def _run_payload(
22
+ payload: Mapping[str, object],
23
+ monkeypatch: pytest.MonkeyPatch,
24
+ capsys: pytest.CaptureFixture[str],
25
+ ) -> str:
26
+ """Run the real hook entry point and return its stdout."""
27
+ monkeypatch.setattr(write_existing_file_blocker.sys, "stdin", io.StringIO(json.dumps(payload)))
28
+ with contextlib.suppress(SystemExit):
29
+ write_existing_file_blocker.main()
30
+ return capsys.readouterr().out
31
+
32
+
33
+ def _is_deny(stdout_text: str) -> bool:
34
+ if not stdout_text.strip():
35
+ return False
36
+ decision = json.loads(stdout_text)
37
+ return decision["hookSpecificOutput"]["permissionDecision"] == "deny"
38
+
39
+
40
+ def test_write_denies_an_existing_target(
41
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
42
+ ) -> None:
43
+ """A Write onto a path that already exists is denied."""
44
+ existing_path = tmp_path / "already_here.py"
45
+ existing_path.write_text("x = 1\n", encoding="utf-8")
46
+ payload = {
47
+ "tool_name": "Write",
48
+ "tool_input": {"file_path": str(existing_path), "content": "x = 2\n"},
49
+ }
50
+
51
+ stdout = _run_payload(payload, monkeypatch, capsys)
52
+
53
+ assert _is_deny(stdout), f"expected a deny, got {stdout!r}"
54
+
55
+
56
+ def test_apply_patch_add_denies_an_existing_target(
57
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
58
+ ) -> None:
59
+ """An apply_patch 'Add File' section targeting an existing path is denied."""
60
+ existing_path = tmp_path / "already_here.py"
61
+ existing_path.write_text("x = 1\n", encoding="utf-8")
62
+ payload = {
63
+ "tool_name": "apply_patch",
64
+ "cwd": str(tmp_path),
65
+ "tool_input": {
66
+ "command": ("*** Begin Patch\n*** Add File: already_here.py\n+x = 2\n*** End Patch")
67
+ },
68
+ }
69
+
70
+ stdout = _run_payload(payload, monkeypatch, capsys)
71
+
72
+ assert _is_deny(stdout), f"expected a deny, got {stdout!r}"
73
+
74
+
75
+ def test_apply_patch_update_on_an_existing_target_is_allowed(
76
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
77
+ ) -> None:
78
+ """An 'Update File' section on an existing path is not an unread overwrite."""
79
+ existing_path = tmp_path / "already_here.py"
80
+ existing_path.write_text("x = 1\n", encoding="utf-8")
81
+ payload = {
82
+ "tool_name": "apply_patch",
83
+ "cwd": str(tmp_path),
84
+ "tool_input": {
85
+ "command": (
86
+ "*** Begin Patch\n"
87
+ "*** Update File: already_here.py\n"
88
+ "@@\n"
89
+ "-x = 1\n"
90
+ "+x = 2\n"
91
+ "*** End Patch"
92
+ )
93
+ },
94
+ }
95
+
96
+ stdout = _run_payload(payload, monkeypatch, capsys)
97
+
98
+ assert not _is_deny(stdout), f"expected an allow, got {stdout!r}"
99
+
100
+
101
+ def test_multi_edit_on_an_existing_target_is_allowed(
102
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
103
+ ) -> None:
104
+ """A MultiEdit always targets a file already on disk, so it is never denied here.
105
+
106
+ Every MultiEdit edit must match an ``old_string`` already present in the
107
+ file, so MultiEdit carries no blind create-or-clobber path the way a Write
108
+ or an apply_patch 'Add File' does — this hook allows it unconditionally.
109
+ """
110
+ existing_path = tmp_path / "already_here.py"
111
+ existing_path.write_text("x = 1\n", encoding="utf-8")
112
+ payload = {
113
+ "tool_name": "MultiEdit",
114
+ "tool_input": {
115
+ "file_path": str(existing_path),
116
+ "edits": [{"old_string": "x = 1", "new_string": "x = 2"}],
117
+ },
118
+ }
119
+
120
+ stdout = _run_payload(payload, monkeypatch, capsys)
121
+
122
+ assert not _is_deny(stdout), f"expected an allow, got {stdout!r}"
@@ -17,13 +17,22 @@ import re
17
17
  import sys
18
18
  from pathlib import Path
19
19
 
20
- _hooks_dir = str(Path(__file__).resolve().parent.parent)
21
- if _hooks_dir not in sys.path:
22
- sys.path.insert(0, _hooks_dir)
23
-
24
- from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
25
- from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin # noqa: E402
26
- from hooks_constants.windows_rmtree_blocker_constants import PYTHON_FILE_EXTENSION # noqa: E402
20
+ try:
21
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
22
+ if _hooks_dir not in sys.path:
23
+ sys.path.insert(0, _hooks_dir)
24
+
25
+ from hooks_constants.hook_block_logger import log_hook_block
26
+ from hooks_constants.multi_edit_reconstruction import joined_new_strings
27
+ from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
28
+ from hooks_constants.windows_rmtree_blocker_constants import (
29
+ PYTHON_FILE_EXTENSION,
30
+ )
31
+ except ImportError as import_error:
32
+ raise ImportError(
33
+ "windows_rmtree_blocker: cannot import its sibling modules; "
34
+ "ensure the hooks directory is importable."
35
+ ) from import_error
27
36
 
28
37
 
29
38
  def payload_contains_unsafe_rmtree(payload_text: str) -> bool:
@@ -37,6 +46,11 @@ def payload_contains_unsafe_rmtree(payload_text: str) -> bool:
37
46
 
38
47
 
39
48
  def extract_payload_text(tool_name: str, tool_input: dict) -> str:
49
+ if tool_name == "MultiEdit":
50
+ file_path = tool_input.get("file_path", "")
51
+ if file_path and not file_path.endswith(PYTHON_FILE_EXTENSION):
52
+ return ""
53
+ return joined_new_strings(tool_input)
40
54
  if tool_name in {"Write", "Edit"}:
41
55
  file_path = tool_input.get("file_path", "")
42
56
  if file_path and not file_path.endswith(PYTHON_FILE_EXTENSION):