claude-dev-env 2.30.1 → 2.31.1

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 (143) 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 +12 -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_duplicate_body.py +272 -100
  13. package/hooks/blocking/code_rules_enforcer.py +89 -45
  14. package/hooks/blocking/code_rules_shared.py +64 -14
  15. package/hooks/blocking/codex_apply_patch.py +136 -21
  16. package/hooks/blocking/config/codex_apply_patch_constants.py +13 -0
  17. package/hooks/blocking/conftest.py +70 -0
  18. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +26 -16
  19. package/hooks/blocking/hook_prose_detector_consistency.py +30 -20
  20. package/hooks/blocking/pii_payload_scan.py +61 -0
  21. package/hooks/blocking/pii_prevention_blocker.py +5 -0
  22. package/hooks/blocking/pr_description_writer_gate.py +256 -0
  23. package/hooks/blocking/pre_tool_use_dispatcher.py +1 -1
  24. package/hooks/blocking/sensitive_file_protector.py +18 -3
  25. package/hooks/blocking/shell_substitution_blocker.py +25 -47
  26. package/hooks/blocking/stale_comment_reference_blocker.py +94 -24
  27. package/hooks/blocking/state_description_blocker.py +39 -26
  28. package/hooks/blocking/subprocess_budget_completeness.py +17 -43
  29. package/hooks/blocking/tdd_enforcer.py +63 -16
  30. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +11 -1
  31. package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +180 -0
  32. package/hooks/blocking/tdd_enforcer_parts/decisions.py +6 -2
  33. package/hooks/blocking/tdd_enforcer_parts/freshness.py +18 -3
  34. package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +66 -1
  35. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +143 -0
  36. package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +12 -0
  37. package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +63 -0
  38. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +1 -0
  39. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +81 -2
  40. package/hooks/blocking/test_code_rules_enforcer_codex_apply_patch.py +35 -0
  41. package/hooks/blocking/test_code_rules_enforcer_config_path.py +29 -16
  42. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +2 -0
  43. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +12 -1
  44. package/hooks/blocking/test_code_rules_enforcer_ephemeral_repository_root.py +118 -0
  45. package/hooks/blocking/test_code_rules_enforcer_multi_edit.py +118 -0
  46. package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +2 -0
  47. package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +2 -0
  48. package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +244 -0
  49. package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +29 -28
  50. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +24 -0
  51. package/hooks/blocking/test_code_rules_enforcer_stage.py +2 -0
  52. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +2 -0
  53. package/hooks/blocking/test_codex_apply_patch.py +43 -0
  54. package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +68 -0
  55. package/hooks/blocking/test_hook_prose_detector_consistency.py +41 -0
  56. package/hooks/blocking/test_pii_payload_scan.py +65 -2
  57. package/hooks/blocking/test_pii_prevention_blocker.py +80 -10
  58. package/hooks/blocking/test_pr_description_writer_gate.py +234 -0
  59. package/hooks/blocking/test_pre_tool_use_dispatcher.py +215 -25
  60. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +18 -6
  61. package/hooks/blocking/test_sensitive_file_protector.py +42 -2
  62. package/hooks/blocking/test_stale_comment_reference_blocker.py +71 -0
  63. package/hooks/blocking/test_state_description_blocker.py +163 -49
  64. package/hooks/blocking/test_subprocess_budget_completeness.py +67 -0
  65. package/hooks/blocking/test_tdd_enforcer.py +246 -2
  66. package/hooks/blocking/test_windows_rmtree_blocker.py +63 -0
  67. package/hooks/blocking/test_write_existing_file_blocker.py +122 -0
  68. package/hooks/blocking/windows_rmtree_blocker.py +21 -7
  69. package/hooks/blocking/write_existing_file_blocker.py +75 -29
  70. package/hooks/conftest.py +47 -0
  71. package/hooks/hooks.json +7 -12
  72. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +3 -0
  73. package/hooks/hooks_constants/code_rules_enforcer_constants.py +4 -0
  74. package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +3 -0
  75. package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +7 -2
  76. package/hooks/hooks_constants/migration_safety_advisor_constants.py +7 -0
  77. package/hooks/hooks_constants/multi_edit_reconstruction.py +44 -3
  78. package/hooks/hooks_constants/pii_prevention_constants.py +9 -7
  79. package/hooks/hooks_constants/pr_description_writer_gate_constants.py +73 -0
  80. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +31 -14
  81. package/hooks/hooks_constants/session_edit_stage_gate_constants.py +15 -1
  82. package/hooks/hooks_constants/state_description_blocker_constants.py +1 -0
  83. package/hooks/hooks_constants/subprocess_budget_completeness_content.py +71 -0
  84. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +2 -0
  85. package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +2 -0
  86. package/hooks/hooks_constants/test_multi_edit_reconstruction.py +103 -0
  87. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +139 -25
  88. package/hooks/hooks_constants/test_subprocess_budget_completeness_content.py +103 -0
  89. package/hooks/hooks_constants/validation_phase_constants.py +2 -0
  90. package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +7 -3
  91. package/hooks/observability/pr_description_writer_spawn_tracker.py +113 -0
  92. package/hooks/observability/session_file_edit_tracker.py +25 -4
  93. package/hooks/observability/test_pr_description_writer_spawn_tracker.py +150 -0
  94. package/hooks/observability/test_session_file_edit_tracker.py +32 -0
  95. package/hooks/validation/hook_format_validator.py +23 -9
  96. package/hooks/validation/test_hook_format_validator.py +58 -0
  97. package/hooks/validators/README.md +1 -1
  98. package/hooks/validators/abbreviation_checks.py +6 -8
  99. package/hooks/validators/code_quality_checks.py +4 -4
  100. package/hooks/validators/config/abbreviation_checks_constants.py +3 -0
  101. package/hooks/validators/config/baseline_identity_constants.py +12 -0
  102. package/hooks/validators/config/directory_exemption_constants.py +44 -1
  103. package/hooks/validators/config/fast_save_validator_constants.py +32 -0
  104. package/hooks/validators/config/hook_timing_harness_constants.py +3 -1
  105. package/hooks/validators/config/test_abbreviation_checks_constants.py +11 -0
  106. package/hooks/validators/config/validator_base_constants.py +9 -0
  107. package/hooks/validators/fast_save_validators.py +275 -0
  108. package/hooks/validators/hook_timing_harness.py +8 -4
  109. package/hooks/validators/pr_reference_checks.py +2 -2
  110. package/hooks/validators/python_antipattern_checks.py +3 -3
  111. package/hooks/validators/python_style_checks.py +37 -62
  112. package/hooks/validators/python_style_helpers.py +16 -2
  113. package/hooks/validators/python_style_import_bootstrap.py +83 -0
  114. package/hooks/validators/react_checks.py +2 -9
  115. package/hooks/validators/run_all_validators.py +201 -155
  116. package/hooks/validators/security_checks.py +3 -3
  117. package/hooks/validators/test_abbreviation_checks.py +20 -0
  118. package/hooks/validators/test_directory_exemption_constants.py +30 -24
  119. package/hooks/validators/test_fast_save_validators.py +172 -0
  120. package/hooks/validators/test_hook_timing_harness.py +10 -1
  121. package/hooks/validators/test_python_style_checks.py +9 -0
  122. package/hooks/validators/test_python_style_checks_import_order.py +81 -0
  123. package/hooks/validators/test_python_style_helpers.py +35 -0
  124. package/hooks/validators/test_python_style_import_bootstrap.py +69 -0
  125. package/hooks/validators/test_run_all_validators.py +46 -0
  126. package/hooks/validators/test_run_all_validators_pretooluse.py +163 -0
  127. package/hooks/validators/test_run_all_validators_repository_root.py +202 -0
  128. package/hooks/validators/test_validator_base.py +51 -2
  129. package/hooks/validators/todo_checks.py +2 -2
  130. package/hooks/validators/type_safety_checks.py +3 -3
  131. package/hooks/validators/useless_test_checks.py +3 -3
  132. package/hooks/validators/validator_base.py +73 -1
  133. package/package.json +1 -1
  134. package/rules/docstring-prose-matches-implementation.md +1 -1
  135. package/rules/git-workflow.md +2 -0
  136. package/rules/testing.md +4 -0
  137. package/scripts/codex_compat_materializer.py +5 -0
  138. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +15 -0
  139. package/scripts/grok_headless_runner.py +15 -12
  140. package/scripts/shared_tree_paths.py +70 -0
  141. package/scripts/test_shared_tree_paths.py +143 -0
  142. package/scripts/tests/test_codex_compat_materializer.py +37 -11
  143. package/hooks/blocking/tests/conftest.py +0 -10
@@ -16,7 +16,6 @@ concern focused. The separate ``tdd_enforcer.py`` hook accepts any
16
16
  ``code_rules_*`` module family, so the suffix files satisfy its gate.
17
17
  """
18
18
  import json
19
- import os
20
19
  import sys
21
20
  from collections import Counter
22
21
  from collections.abc import Callable
@@ -186,11 +185,42 @@ from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
186
185
  PRECHECK_USAGE_MESSAGE,
187
186
  UNKNOWN_VALIDATION_PHASE_MESSAGE_TEMPLATE,
188
187
  VIOLATION_SEPARATOR,
188
+ apply_edits,
189
+ edits_for_tool,
189
190
  )
190
191
  from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
191
192
  from hooks_constants.setup_project_paths_constants import ( # noqa: E402
192
193
  UTF8_BYTE_ORDER_MARK,
193
194
  )
195
+ from hooks_constants.subprocess_budget_completeness_content import ( # noqa: E402
196
+ existing_file_content,
197
+ )
198
+
199
+
200
+ def _multiedit_post_edit_view(
201
+ file_path: str, all_tool_input: dict[str, object]
202
+ ) -> tuple[str, str] | None:
203
+ """Return the prior and reconstructed post-edit content for a MultiEdit payload.
204
+
205
+ Reads ``file_path`` once and applies every edit in the payload's ``edits``
206
+ list in order, so a violation introduced by any edit — not only the
207
+ first — is judged against the file's real prior and post-edit state.
208
+
209
+ Args:
210
+ file_path: The destination path the MultiEdit targets.
211
+ all_tool_input: The MultiEdit payload's input mapping.
212
+
213
+ Returns:
214
+ A ``(prior_content, post_edit_content)`` pair, or None when the target
215
+ file cannot be read.
216
+ """
217
+ existing_content = existing_file_content(file_path)
218
+ if existing_content is None:
219
+ return None
220
+ post_edit_content = apply_edits(
221
+ existing_content, edits_for_tool("MultiEdit", all_tool_input)
222
+ )
223
+ return existing_content, post_edit_content
194
224
 
195
225
 
196
226
  class _ValidationContext(NamedTuple):
@@ -217,11 +247,11 @@ def _validated_phase(phase: str) -> str:
217
247
  return phase
218
248
 
219
249
 
220
- def _codex_patch_issues(each_patch_file: CodexPatchFile) -> list[str]:
250
+ def _codex_patch_issues(each_patch_file: CodexPatchFile, repository_root: str = "") -> list[str]:
221
251
  """Run the existing code-rules verdict over one Codex patch view."""
222
252
  if not each_patch_file.post_content and each_patch_file.operation == "delete":
223
253
  return []
224
- if _is_hook_infrastructure_python_target(each_patch_file.file_path):
254
+ if _is_hook_infrastructure_python_target(each_patch_file.file_path, repository_root):
225
255
  all_issues = _hook_infrastructure_blocking_issues(
226
256
  each_patch_file.post_content,
227
257
  each_patch_file.file_path,
@@ -229,7 +259,7 @@ def _codex_patch_issues(each_patch_file: CodexPatchFile) -> list[str]:
229
259
  each_patch_file.prior_content,
230
260
  phase=EDIT_LANE_PHASE,
231
261
  )
232
- elif _is_validated_target(each_patch_file.file_path):
262
+ elif _is_validated_target(each_patch_file.file_path, repository_root):
233
263
  all_issues = validate_content_for_edit_lane(
234
264
  each_patch_file.post_content,
235
265
  each_patch_file.file_path,
@@ -263,9 +293,8 @@ def _report_codex_patch_payload(
263
293
  deny_stream,
264
294
  )
265
295
  return
266
- working_directory = all_pretooluse_payload.get("cwd")
267
- if not isinstance(working_directory, str):
268
- working_directory = os.getcwd()
296
+ raw_working_directory = all_pretooluse_payload.get("cwd")
297
+ working_directory = raw_working_directory if isinstance(raw_working_directory, str) else None
269
298
  try:
270
299
  all_patch_files = parse_codex_apply_patch(patch_command, working_directory)
271
300
  except CodexPatchError as error:
@@ -277,7 +306,7 @@ def _report_codex_patch_payload(
277
306
  all_issues = [
278
307
  each_issue
279
308
  for each_patch_file in all_patch_files
280
- for each_issue in _codex_patch_issues(each_patch_file)
309
+ for each_issue in _codex_patch_issues(each_patch_file, working_directory or "")
281
310
  ]
282
311
  if all_issues:
283
312
  _write_deny_payload(
@@ -325,7 +354,7 @@ def _python_magic_value_and_constant_issues(context: _ValidationContext) -> list
325
354
  )
326
355
  all_issues.extend(check_fstring_structural_literals(content, file_path))
327
356
  all_issues.extend(check_constants_outside_config(content, file_path))
328
- all_issues.extend(check_constants_outside_config_advisory(content, file_path))
357
+ check_constants_outside_config_advisory(content, file_path)
329
358
  all_issues.extend(check_file_global_constants_use_count(content, file_path))
330
359
  return all_issues
331
360
 
@@ -678,24 +707,17 @@ def validate_content_for_full_gate(
678
707
  )
679
708
 
680
709
 
681
- def _read_existing_file_content(file_path: str) -> str | None:
682
- """Return the on-disk content of *file_path*, or None when it cannot be read."""
683
- try:
684
- with open(file_path, "r", encoding="utf-8") as existing_file:
685
- return existing_file.read()
686
- except (FileNotFoundError, OSError, UnicodeDecodeError):
687
- return None
688
-
689
-
690
710
  def prior_and_post_edit_content(
691
- file_path: str, old_string: str, new_string: str,
711
+ file_path: str, old_string: str, new_string: str, is_replace_all: bool = False,
692
712
  ) -> tuple[str | None, str | None]:
693
713
  """Return the pre-edit and post-edit file content from a single disk read.
694
714
 
695
715
  Reads ``file_path`` once and derives both views from that single read so the
696
716
  prior and the reconstruction never diverge across two independent reads.
697
- The post-edit view replaces the first occurrence of ``old_string`` with
698
- ``new_string``, mirroring how the Edit tool itself applies a single
717
+ The post-edit view comes from apply_edits, the same reconstruction the
718
+ MultiEdit lane uses, so one payload never reconstructs two ways. It replaces
719
+ the first occurrence of ``old_string`` with ``new_string``, or every occurrence
720
+ when the Edit carries ``replace_all``, mirroring how the Edit tool applies a
699
721
  replacement.
700
722
 
701
723
  Returns ``(None, None)`` when the file cannot be read, ``old_string`` is
@@ -709,6 +731,7 @@ def prior_and_post_edit_content(
709
731
  file_path: The path of the file the Edit targets.
710
732
  old_string: The Edit's ``old_string`` fragment.
711
733
  new_string: The Edit's ``new_string`` fragment.
734
+ is_replace_all: Whether the Edit carries ``replace_all``.
712
735
 
713
736
  Returns:
714
737
  A ``(prior_content, post_edit_content)`` pair, or ``(None, None)`` when
@@ -716,19 +739,27 @@ def prior_and_post_edit_content(
716
739
  """
717
740
  if not old_string:
718
741
  return None, None
719
- existing_content = _read_existing_file_content(file_path)
742
+ existing_content = existing_file_content(file_path)
720
743
  if existing_content is None:
721
744
  return None, None
722
745
  if old_string not in existing_content:
723
746
  return None, None
724
- return existing_content, existing_content.replace(old_string, new_string, 1)
747
+ one_edit = {
748
+ "old_string": old_string,
749
+ "new_string": new_string,
750
+ "replace_all": is_replace_all,
751
+ }
752
+ return existing_content, apply_edits(existing_content, [one_edit])
725
753
 
726
754
 
727
- def _is_validated_target(file_path: str) -> bool:
755
+ def _is_validated_target(file_path: str, repository_root: str = "") -> bool:
728
756
  """Return whether the path is subject to code-rules validation.
729
757
 
730
758
  Args:
731
759
  file_path: The destination path of the write, edit, or pre-check target.
760
+ repository_root: The session's working directory (the PreToolUse
761
+ payload's ``cwd``), so a path inside it is never exempted as
762
+ scratch merely because the repository sits under ``/tmp``.
732
763
 
733
764
  Returns:
734
765
  True when the path is non-empty, outside hook infrastructure, and
@@ -736,14 +767,14 @@ def _is_validated_target(file_path: str) -> bool:
736
767
  """
737
768
  if not file_path:
738
769
  return False
739
- if is_ephemeral_script_path(file_path):
770
+ if is_ephemeral_script_path(file_path, repository_root):
740
771
  return False
741
772
  if is_hook_infrastructure(file_path):
742
773
  return False
743
774
  return get_file_extension(file_path) in ALL_CODE_EXTENSIONS
744
775
 
745
776
 
746
- def _is_hook_infrastructure_python_target(file_path: str) -> bool:
777
+ def _is_hook_infrastructure_python_target(file_path: str, repository_root: str = "") -> bool:
747
778
  """Return whether the path is a hook-infrastructure Python file.
748
779
 
749
780
  The full code-rules suite exempts hook-infrastructure files, but the
@@ -753,13 +784,16 @@ def _is_hook_infrastructure_python_target(file_path: str) -> bool:
753
784
 
754
785
  Args:
755
786
  file_path: The destination path of the write, edit, or pre-check target.
787
+ repository_root: The session's working directory (the PreToolUse
788
+ payload's ``cwd``), so a path inside it is never exempted as
789
+ scratch merely because the repository sits under ``/tmp``.
756
790
 
757
791
  Returns:
758
792
  True when the path names a Python file inside hook infrastructure.
759
793
  """
760
794
  if not file_path:
761
795
  return False
762
- if is_ephemeral_script_path(file_path):
796
+ if is_ephemeral_script_path(file_path, repository_root):
763
797
  return False
764
798
  if not is_hook_infrastructure(file_path):
765
799
  return False
@@ -1048,13 +1082,13 @@ def _run_precheck(
1048
1082
  runs_hook_duplicate_body = _is_hook_infrastructure_python_target(target_path)
1049
1083
  if not runs_full_verdict and not runs_hook_duplicate_body:
1050
1084
  return 0
1051
- candidate_content = _read_existing_file_content(candidate_path)
1085
+ candidate_content = existing_file_content(candidate_path)
1052
1086
  if candidate_content is None:
1053
1087
  error_stream.write(f"error: cannot read candidate file: {candidate_path}\n")
1054
1088
  return 1
1055
1089
  candidate_content = candidate_content.lstrip(UTF8_BYTE_ORDER_MARK)
1056
1090
  if runs_full_verdict:
1057
- old_content = _read_existing_file_content(target_path) or ""
1091
+ old_content = existing_file_content(target_path) or ""
1058
1092
  all_issues = validate_content_for_full_gate(candidate_content, target_path, old_content)
1059
1093
  else:
1060
1094
  all_issues = _hook_infrastructure_blocking_issues(
@@ -1127,18 +1161,16 @@ def _run_precheck_command(
1127
1161
 
1128
1162
  def _contents_for_validation(
1129
1163
  tool_name: str,
1130
- new_string: str,
1131
- old_string: str,
1132
- written_content: str,
1164
+ all_tool_input: dict[str, object],
1133
1165
  file_path: str,
1134
1166
  ) -> tuple[str, str, str | None, str] | None:
1135
1167
  """Resolve the content views the verdict needs for the given tool payload.
1136
1168
 
1137
1169
  Args:
1138
1170
  tool_name: The tool named in the PreToolUse payload.
1139
- new_string: The Edit payload's replacement fragment.
1140
- old_string: The Edit payload's fragment to replace.
1141
- written_content: The Write payload's whole file body.
1171
+ all_tool_input: The PreToolUse payload's tool_input mapping, carrying
1172
+ ``new_string``/``old_string`` for Edit, ``content`` for Write, and
1173
+ the ``edits`` list for MultiEdit.
1142
1174
  file_path: The destination path of the write or edit.
1143
1175
 
1144
1176
  Returns:
@@ -1146,17 +1178,27 @@ def _contents_for_validation(
1146
1178
  prior_full_file_content)`` tuple, or None when no validatable view
1147
1179
  exists — an unreadable edit target, or a write over an existing file.
1148
1180
  """
1181
+ new_string = str(all_tool_input.get("new_string", "") or "")
1182
+ old_string = str(all_tool_input.get("old_string", "") or "")
1183
+ written_content = str(all_tool_input.get("content", "") or "")
1149
1184
  if tool_name == "Edit":
1150
1185
  prior_content, full_file_content_after_edit = prior_and_post_edit_content(
1151
1186
  file_path, old_string, new_string,
1187
+ is_replace_all=all_tool_input.get("replace_all") is True,
1152
1188
  )
1153
1189
  if full_file_content_after_edit is None:
1154
- full_file_content_after_edit = _read_existing_file_content(file_path)
1190
+ full_file_content_after_edit = existing_file_content(file_path)
1155
1191
  if full_file_content_after_edit is None:
1156
1192
  return None
1157
1193
  return new_string, old_string, full_file_content_after_edit, prior_content or ""
1194
+ if tool_name == "MultiEdit":
1195
+ multiedit_view = _multiedit_post_edit_view(file_path, all_tool_input or {})
1196
+ if multiedit_view is None:
1197
+ return None
1198
+ prior_content, post_edit_content = multiedit_view
1199
+ return post_edit_content, prior_content, post_edit_content, prior_content
1158
1200
  content = written_content or new_string
1159
- old_content = _read_existing_file_content(file_path) or ""
1201
+ old_content = existing_file_content(file_path) or ""
1160
1202
  if old_content:
1161
1203
  return None
1162
1204
  return content, old_content, None, ""
@@ -1176,7 +1218,7 @@ def _deny_reason_for_issues(
1176
1218
  tool_name: The tool named in the PreToolUse payload.
1177
1219
  file_path: The destination path used for forecast classification.
1178
1220
  full_file_content_after_edit: The whole post-edit file content when the
1179
- edit reconstructs one, used to run the full-file forecast.
1221
+ edit reconstructs one, for the full-file forecast.
1180
1222
  prior_full_file_content: The whole file content before the edit applied.
1181
1223
  Empty when the edit's old_string is absent and no reliable prior
1182
1224
  exists; the forecast is skipped in that case so a comment diff
@@ -1192,7 +1234,7 @@ def _deny_reason_for_issues(
1192
1234
  )
1193
1235
  has_reconstructed_prior = bool(prior_full_file_content)
1194
1236
  if (
1195
- tool_name == "Edit"
1237
+ tool_name in ("Edit", "MultiEdit")
1196
1238
  and full_file_content_after_edit is not None
1197
1239
  and has_reconstructed_prior
1198
1240
  ):
@@ -1250,7 +1292,8 @@ def _report_blocking_violations(
1250
1292
  content: The fragment or whole-file body under validation.
1251
1293
  tool_name: The tool named in the PreToolUse payload.
1252
1294
  file_path: The destination path of the write or edit.
1253
- old_content: The fragment the edit replaces, or empty for a write.
1295
+ old_content: The fragment named by the edit's old_string, or empty for
1296
+ a write.
1254
1297
  full_file_content_after_edit: The reconstructed post-edit file body,
1255
1298
  or None when the payload is not an Edit.
1256
1299
  prior_full_file_content: The on-disk content before the edit.
@@ -1344,15 +1387,16 @@ def main(all_arguments: list[str]) -> None:
1344
1387
  if is_under_session_scratchpad(file_path, pretooluse_payload):
1345
1388
  sys.exit(0)
1346
1389
 
1347
- runs_full_verdict = _is_validated_target(file_path)
1348
- if not runs_full_verdict and not _is_hook_infrastructure_python_target(file_path):
1390
+ repository_root = str(pretooluse_payload.get("cwd") or "")
1391
+ runs_full_verdict = _is_validated_target(file_path, repository_root)
1392
+ if not runs_full_verdict and not _is_hook_infrastructure_python_target(
1393
+ file_path, repository_root
1394
+ ):
1349
1395
  sys.exit(0)
1350
1396
 
1351
1397
  validation_contents = _contents_for_validation(
1352
1398
  tool_name,
1353
- tool_input.get("new_string", ""),
1354
- tool_input.get("old_string", ""),
1355
- tool_input.get("content", ""),
1399
+ tool_input,
1356
1400
  file_path,
1357
1401
  )
1358
1402
  if validation_contents is None:
@@ -251,10 +251,50 @@ def _extract_fstring_literal_parts(
251
251
  return "".join(display_segments), "".join(shape_segments)
252
252
 
253
253
 
254
- def is_ephemeral_script_path(file_path: str) -> bool:
254
+ def _normalized_absolute_comparison_path(raw_path: str) -> str:
255
+ """Return raw_path as an absolute, lowercase, forward-slash path for root comparison.
256
+
257
+ Strips a leading Windows drive letter after normalizing, so a POSIX root
258
+ check and a Windows root check compare on the same shape.
259
+
260
+ Args:
261
+ raw_path: The path to normalize.
262
+
263
+ Returns:
264
+ The absolute, lowercase, forward-slash path with any leading drive
265
+ letter removed.
266
+ """
267
+ return LEADING_DRIVE_LETTER_PATTERN.sub("", os.path.abspath(raw_path).replace("\\", "/").lower())
268
+
269
+
270
+ def _is_at_or_under_root(normalized_candidate: str, normalized_root: str) -> bool:
271
+ """Return whether normalized_candidate sits at or under normalized_root.
272
+
273
+ Requires a path-segment boundary (an exact match or a ``/`` before the
274
+ remainder), so ``/tmp/repo-sibling`` never counts as under ``/tmp/repo``.
275
+
276
+ Args:
277
+ normalized_candidate: A path already run through
278
+ ``_normalized_absolute_comparison_path``.
279
+ normalized_root: A root path already run through
280
+ ``_normalized_absolute_comparison_path``.
281
+
282
+ Returns:
283
+ True when normalized_candidate equals normalized_root or names a
284
+ descendant of it.
285
+ """
286
+ return normalized_candidate == normalized_root or normalized_candidate.startswith(normalized_root + "/")
287
+
288
+
289
+ def is_ephemeral_script_path(file_path: str, repository_root: str = "") -> bool:
255
290
  """Return True when the path is rooted at a throwaway scratch directory.
256
291
 
257
292
  Checks these sources in order:
293
+ - ``repository_root`` — when given, a path inside it is never ephemeral,
294
+ whatever directory the repository itself is checked out under. This
295
+ keeps a repository placed under ``/tmp`` fully gated: the exemption
296
+ below is for scratch outside every repository, not for the repository's
297
+ own files.
258
298
  - ``$CLAUDE_JOB_DIR/tmp`` — only when ``CLAUDE_JOB_DIR`` is set.
259
299
  - Root-anchored ``/tmp`` and ``/temp`` (drive-letter tolerant).
260
300
 
@@ -262,20 +302,28 @@ def is_ephemeral_script_path(file_path: str) -> bool:
262
302
  its sandbox fixtures there, so matching it would exempt the suite's own
263
303
  targets. Returns False when ``CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT``
264
304
  is truthy, when ``file_path`` is empty, and when no root matches. Path
265
- classification is string-only; the file need not exist.
305
+ classification is string-only; neither path need exist.
266
306
 
267
307
  Args:
268
308
  file_path: The candidate path to classify.
309
+ repository_root: The session's working directory — typically the
310
+ PreToolUse payload's ``cwd`` — or empty when no such directory is
311
+ known. A path outside this directory is unaffected by it.
269
312
 
270
313
  Returns:
271
- True when the path is rooted at a recognized ephemeral scratch directory.
314
+ True when the path is rooted at a recognized ephemeral scratch
315
+ directory and does not sit inside ``repository_root``.
272
316
  """
273
317
  if not file_path:
274
318
  return False
275
319
  disable_value = os.environ.get(EPHEMERAL_EXEMPT_DISABLE_ENVIRONMENT_VARIABLE_NAME, "").strip().lower()
276
320
  if disable_value in ALL_EPHEMERAL_EXEMPT_DISABLE_TRUTHY_VALUES:
277
321
  return False
278
- normalized = LEADING_DRIVE_LETTER_PATTERN.sub("", os.path.abspath(file_path).replace("\\", "/").lower())
322
+ normalized = _normalized_absolute_comparison_path(file_path)
323
+ if repository_root and _is_at_or_under_root(
324
+ normalized, _normalized_absolute_comparison_path(repository_root)
325
+ ):
326
+ return False
279
327
  all_temp_roots: list[str] = []
280
328
  job_dir = os.environ.get(CLAUDE_JOB_DIR_ENVIRONMENT_VARIABLE_NAME)
281
329
  if job_dir:
@@ -286,7 +334,7 @@ def is_ephemeral_script_path(file_path: str) -> bool:
286
334
  for each_root in ALL_ROOT_ANCHORED_EPHEMERAL_DIRECTORIES:
287
335
  all_temp_roots.append(each_root)
288
336
  for each_temp_root in all_temp_roots:
289
- if normalized == each_temp_root or normalized.startswith(each_temp_root + "/"):
337
+ if _is_at_or_under_root(normalized, each_temp_root):
290
338
  return True
291
339
  return False
292
340
 
@@ -426,23 +474,25 @@ def is_ephemeral_path(file_path: str, hook_payload: dict | None = None) -> bool:
426
474
  Combines the path families a repo gate skips: the root-anchored ephemeral
427
475
  scratch directories (``/tmp`` and ``$CLAUDE_JOB_DIR/tmp``), the harness
428
476
  session scratchpad, and a coding agent's own home-directory tooling such as
429
- ``~/.grok/runs/``. The session scratchpad match reads the session id from the
430
- payload when one is supplied, and from the harness environment variable
431
- otherwise, so a caller that holds no payload still gets the match. The
432
- run_all_validators PreToolUse gate calls this predicate to skip an exempt
433
- target before it validates. The code-rules and TDD gates call the underlying
434
- predicates ``is_ephemeral_script_path``, ``is_agent_home_tooling``, and
477
+ ``~/.grok/runs/``. A path inside the payload's own working directory
478
+ (``cwd``) is never treated as scratch, so a repository checked out under
479
+ ``/tmp`` still receives full enforcement. The scratchpad match reads the
480
+ session id from the payload when supplied, else the harness environment
481
+ variable. The code-rules and TDD gates call the underlying predicates
482
+ ``is_ephemeral_script_path``, ``is_agent_home_tooling``, and
435
483
  ``is_under_session_scratchpad`` directly.
436
484
 
437
485
  Args:
438
486
  file_path: The candidate path to classify.
439
- hook_payload: The PreToolUse payload carrying the session id, or None to
440
- read the session id from the environment alone.
487
+ hook_payload: The PreToolUse payload carrying the session id and the
488
+ working directory (``cwd``), or None to read the session id from
489
+ the environment alone.
441
490
 
442
491
  Returns:
443
492
  True when the path is scratch, agent tooling, or the session scratchpad.
444
493
  """
445
- if is_ephemeral_script_path(file_path):
494
+ repository_root = str((hook_payload or {}).get("cwd") or "")
495
+ if is_ephemeral_script_path(file_path, repository_root):
446
496
  return True
447
497
  if is_agent_home_tooling(file_path):
448
498
  return True
@@ -2,9 +2,21 @@
2
2
 
3
3
  import os
4
4
  import re
5
+ import sys
5
6
  from dataclasses import dataclass
7
+ from functools import cache
6
8
  from pathlib import Path
7
9
 
10
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
11
+ if _hooks_dir not in sys.path:
12
+ sys.path.insert(0, _hooks_dir)
13
+
14
+ from blocking.config.codex_apply_patch_constants import ( # noqa: E402
15
+ CODEX_ADD_OPERATION,
16
+ CODEX_DELETE_OPERATION,
17
+ CODEX_UPDATE_OPERATION,
18
+ )
19
+
8
20
  _codex_patch_begin_marker = "*** Begin Patch"
9
21
  _codex_patch_end_marker = "*** End Patch"
10
22
  _codex_update_marker = "*** Update File:"
@@ -14,9 +26,6 @@ _codex_hunk_marker = "@@"
14
26
  _codex_end_of_file_marker = "*** End of File"
15
27
  _codex_no_newline_marker = "\"
16
28
  _codex_minimum_patch_line_count = 2
17
- _codex_update_operation = "update"
18
- _codex_add_operation = "add"
19
- _codex_delete_operation = "delete"
20
29
 
21
30
 
22
31
  class CodexPatchError(ValueError):
@@ -70,6 +79,11 @@ def _codex_patch_sections(command: str) -> list[tuple[str, str, list[str]]]:
70
79
  raise CodexPatchError("patch requires a begin marker")
71
80
  if _codex_marker_text(all_lines[-1]) != _codex_patch_end_marker:
72
81
  raise CodexPatchError("patch requires an end marker")
82
+ all_marker_by_operation = {
83
+ CODEX_UPDATE_OPERATION: _codex_update_marker,
84
+ CODEX_ADD_OPERATION: _codex_add_marker,
85
+ CODEX_DELETE_OPERATION: _codex_delete_marker,
86
+ }
73
87
  all_sections: list[tuple[str, str, list[str]]] = []
74
88
  current_section: tuple[str, str, list[str]] | None = None
75
89
  for each_line in all_lines[1:-1]:
@@ -77,11 +91,7 @@ def _codex_patch_sections(command: str) -> list[tuple[str, str, list[str]]]:
77
91
  operation = next(
78
92
  (
79
93
  each_operation
80
- for each_operation, each_marker in (
81
- (_codex_update_operation, _codex_update_marker),
82
- (_codex_add_operation, _codex_add_marker),
83
- (_codex_delete_operation, _codex_delete_marker),
84
- )
94
+ for each_operation, each_marker in all_marker_by_operation.items()
85
95
  if marker_text.startswith(each_marker)
86
96
  ),
87
97
  None,
@@ -89,12 +99,7 @@ def _codex_patch_sections(command: str) -> list[tuple[str, str, list[str]]]:
89
99
  if operation is not None:
90
100
  if current_section is not None:
91
101
  all_sections.append(current_section)
92
- marker_by_operation = {
93
- _codex_update_operation: _codex_update_marker,
94
- _codex_add_operation: _codex_add_marker,
95
- _codex_delete_operation: _codex_delete_marker,
96
- }
97
- path_text = marker_text[len(marker_by_operation[operation]) :].strip()
102
+ path_text = marker_text[len(all_marker_by_operation[operation]) :].strip()
98
103
  if not path_text:
99
104
  raise CodexPatchError("patch operation requires a path")
100
105
  current_section = (operation, path_text, [])
@@ -190,15 +195,15 @@ def _codex_read_patch_file(
190
195
  try:
191
196
  prior_content = target_path.read_text(encoding="utf-8")
192
197
  except (FileNotFoundError, IsADirectoryError, OSError, UnicodeDecodeError, ValueError) as error:
193
- if operation == _codex_add_operation and isinstance(error, FileNotFoundError):
198
+ if operation == CODEX_ADD_OPERATION and isinstance(error, FileNotFoundError):
194
199
  prior_content = ""
195
200
  else:
196
201
  raise CodexPatchError("patch target requires readable UTF-8 content") from error
197
- if operation == _codex_add_operation:
202
+ if operation == CODEX_ADD_OPERATION:
198
203
  if target_path.exists():
199
204
  raise CodexPatchError("add target requires a new path")
200
205
  post_content = _codex_add_content(all_section_lines)
201
- elif operation == _codex_update_operation:
206
+ elif operation == CODEX_UPDATE_OPERATION:
202
207
  post_content = _codex_apply_update(prior_content, all_section_lines)
203
208
  else:
204
209
  if any(
@@ -210,15 +215,125 @@ def _codex_read_patch_file(
210
215
  return CodexPatchFile(str(target_path), prior_content, post_content, operation)
211
216
 
212
217
 
213
- def parse_codex_apply_patch(
214
- command: str, working_directory: str | None = None
215
- ) -> tuple[CodexPatchFile, ...]:
216
- """Return pre-edit and post-edit views for every Codex patch path."""
218
+ def payload_patch_command(hook_payload: dict, tool_input: dict) -> tuple[str, str | None]:
219
+ """Pull the patch text and its working directory out of an apply_patch payload.
220
+
221
+ ::
222
+
223
+ hook_payload = {"cwd": "/repo"}
224
+ tool_input = {"command": "*** Begin Patch ..."}
225
+ payload_patch_command(...) -> ("*** Begin Patch ...", "/repo")
226
+
227
+ A payload whose command or cwd is missing, or carries a non-string value,
228
+ yields an empty command and a None directory. An empty command tells the
229
+ caller there is no patch to parse.
230
+
231
+ Args:
232
+ hook_payload: The whole PreToolUse payload, carrying the cwd.
233
+ tool_input: The tool input mapping, carrying the command text.
234
+
235
+ Returns:
236
+ The command text and the working directory to resolve paths against.
237
+ """
238
+ raw_command = (tool_input or {}).get("command", "")
239
+ command = raw_command if isinstance(raw_command, str) else ""
240
+ raw_working_directory = (hook_payload or {}).get("cwd")
241
+ working_directory = raw_working_directory if isinstance(raw_working_directory, str) else None
242
+ return command, working_directory
243
+
244
+
245
+ def _codex_working_directory(command: str, working_directory: str | None) -> Path:
246
+ """Validate the patch command text and working directory, then resolve it.
247
+
248
+ Args:
249
+ command: The raw Codex apply_patch command text.
250
+ working_directory: The directory patch paths resolve against, or None
251
+ to use the current working directory.
252
+
253
+ Returns:
254
+ The resolved, existing working directory.
255
+ """
217
256
  if not isinstance(command, str) or not command.strip():
218
257
  raise CodexPatchError("patch command requires text")
219
258
  resolved_working_directory = Path(working_directory or os.getcwd()).expanduser().resolve()
220
259
  if not resolved_working_directory.is_dir():
221
260
  raise CodexPatchError("patch working directory requires an existing directory")
261
+ return resolved_working_directory
262
+
263
+
264
+ @cache
265
+ def codex_patch_operation_targets(
266
+ command: str, working_directory: str | None = None
267
+ ) -> tuple[tuple[str, str], ...]:
268
+ """Return each (operation, resolved path) pair a Codex patch command names.
269
+
270
+ Resolves every section's target path the same way ``parse_codex_apply_patch``
271
+ does, without reading any file's prior content, so a caller that only needs
272
+ each target path (a sensitive-path check, an already-exists check) is not
273
+ tripped up by a prior file it never needs to read.
274
+
275
+ Args:
276
+ command: The raw Codex apply_patch command text.
277
+ working_directory: The directory patch paths resolve against, or None
278
+ to use the current working directory.
279
+
280
+ Returns:
281
+ The ordered ``(operation, resolved_path)`` pairs the patch names.
282
+ """
283
+ resolved_working_directory = _codex_working_directory(command, working_directory)
284
+ return tuple(
285
+ (each_operation, _codex_resolve_patch_path(each_relative_path, resolved_working_directory))
286
+ for each_operation, each_relative_path, _each_section_lines in _codex_patch_sections(command)
287
+ )
288
+
289
+
290
+ def _target_paths_for_command(command: str, working_directory: str | None) -> tuple[str, ...]:
291
+ """Return each resolved target path one patch command names.
292
+
293
+ An empty command, and a command this parser rejects, both yield an empty
294
+ tuple, so the caller reads one result shape either way.
295
+
296
+ Args:
297
+ command: The raw Codex apply_patch command text, or an empty string.
298
+ working_directory: The directory patch paths resolve against, or None.
299
+
300
+ Returns:
301
+ The ordered resolved target paths the patch names.
302
+ """
303
+ if not command:
304
+ return ()
305
+ try:
306
+ all_operation_targets = codex_patch_operation_targets(command, working_directory)
307
+ except CodexPatchError:
308
+ return ()
309
+ return tuple(each_path for _each_operation, each_path in all_operation_targets)
310
+
311
+
312
+ def payload_patch_target_paths(hook_payload: dict, tool_input: dict) -> tuple[str, ...]:
313
+ """Return every resolved target path an apply_patch payload names.
314
+
315
+ The wrapper a hook wants when it needs target paths and nothing else. It
316
+ reads the command out of the payload, then resolves paths without reading
317
+ any target file's prior content.
318
+
319
+ Args:
320
+ hook_payload: The whole hook payload, carrying the cwd.
321
+ tool_input: The tool input mapping, carrying the command text.
322
+
323
+ Returns:
324
+ The ordered resolved target paths the patch names, empty when the
325
+ payload names no patch or the patch will not parse.
326
+ """
327
+ command, working_directory = payload_patch_command(hook_payload, tool_input)
328
+ return _target_paths_for_command(command, working_directory)
329
+
330
+
331
+ @cache
332
+ def parse_codex_apply_patch(
333
+ command: str, working_directory: str | None = None
334
+ ) -> tuple[CodexPatchFile, ...]:
335
+ """Return pre-edit and post-edit views for every Codex patch path."""
336
+ resolved_working_directory = _codex_working_directory(command, working_directory)
222
337
  all_patch_files: list[CodexPatchFile] = []
223
338
  seen_paths: set[str] = set()
224
339
  for each_operation, each_relative_path, each_section_lines in _codex_patch_sections(command):