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
@@ -0,0 +1,13 @@
1
+ """Shared operation-name constants for codex_apply_patch.py.
2
+
3
+ A Codex apply_patch command names three operations — add, update, and
4
+ delete — by a literal marker line. These three constants are the single
5
+ source of truth for those spellings, so every caller that branches on one
6
+ compares against one value rather than a second local copy that could drift.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ CODEX_ADD_OPERATION = "add"
12
+ CODEX_UPDATE_OPERATION = "update"
13
+ CODEX_DELETE_OPERATION = "delete"
@@ -0,0 +1,70 @@
1
+ """Add hooks/blocking and hooks/ to sys.path for every test collected under this directory."""
2
+
3
+ import json
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Callable
8
+
9
+ import pytest
10
+
11
+ _BLOCKING_DIRECTORY = str(Path(__file__).resolve().parent)
12
+ _HOOKS_DIRECTORY = str(Path(_BLOCKING_DIRECTORY).parent)
13
+ for each_directory in (_BLOCKING_DIRECTORY, _HOOKS_DIRECTORY):
14
+ if each_directory not in sys.path:
15
+ sys.path.insert(0, each_directory)
16
+
17
+
18
+ @pytest.fixture
19
+ def three_operation_patch() -> str:
20
+ """Build a patch naming one update, one add, and one delete section."""
21
+ return (
22
+ "*** Begin Patch\n"
23
+ "*** Update File: updated.py\n"
24
+ "@@\n"
25
+ "-before\n"
26
+ "+after\n"
27
+ " keep\n"
28
+ "*** Add File: added.py\n"
29
+ "+new\n"
30
+ "*** Delete File: deleted.py\n"
31
+ "*** End of File\n"
32
+ "*** End Patch"
33
+ )
34
+
35
+
36
+ @pytest.fixture
37
+ def init_bare_git_repo() -> Callable[[Path], None]:
38
+ """Return a helper that initializes an empty git repository at a given path.
39
+
40
+ The PII scanner reads an apply_patch payload's ``cwd`` as a git
41
+ repository root, so a dispatcher-level apply_patch test needs a real
42
+ repository there rather than a bare temp directory.
43
+ """
44
+
45
+ def _init_bare_git_repo(repository_root: Path) -> None:
46
+ repository_root.mkdir(parents=True, exist_ok=True)
47
+ subprocess.run(["git", "init"], cwd=repository_root, check=True, capture_output=True)
48
+
49
+ return _init_bare_git_repo
50
+
51
+
52
+ @pytest.fixture
53
+ def synthetic_github_token() -> str:
54
+ """Return a fixture-only GitHub token shape, safe to embed in test payloads."""
55
+ return "ghp_" + ("C" * 36)
56
+
57
+
58
+ @pytest.fixture
59
+ def multi_edit_payload() -> Callable[[str, list[dict[str, str]]], str]:
60
+ """Return a helper that builds a MultiEdit tool payload JSON string."""
61
+
62
+ def _multi_edit_payload(file_path: str, all_edits: list[dict[str, str]]) -> str:
63
+ return json.dumps(
64
+ {
65
+ "tool_name": "MultiEdit",
66
+ "tool_input": {"file_path": file_path, "edits": all_edits},
67
+ }
68
+ )
69
+
70
+ return _multi_edit_payload
@@ -27,20 +27,28 @@ import json
27
27
  import sys
28
28
  from pathlib import Path
29
29
 
30
- _hooks_dir = str(Path(__file__).resolve().parent.parent)
31
- if _hooks_dir not in sys.path:
32
- sys.path.insert(0, _hooks_dir)
33
-
34
- from hooks_constants.duplicate_rmtree_helper_blocker_constants import ( # noqa: E402
35
- ALL_EXEMPT_PATH_FRAGMENTS,
36
- ALL_EXEMPT_TEST_FILE_PREFIXES,
37
- ALL_EXEMPT_TEST_FILE_SUFFIXES,
38
- HELPER_DEFINITION_PATTERN,
39
- PYTHON_FILE_EXTENSION,
40
- TRIPLE_QUOTED_STRING_PATTERN,
41
- )
42
- from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
43
- from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin # noqa: E402
30
+ try:
31
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
32
+ if _hooks_dir not in sys.path:
33
+ sys.path.insert(0, _hooks_dir)
34
+
35
+ from hooks_constants.duplicate_rmtree_helper_blocker_constants import (
36
+ ALL_APPLICABLE_TOOL_NAMES,
37
+ ALL_EXEMPT_PATH_FRAGMENTS,
38
+ ALL_EXEMPT_TEST_FILE_PREFIXES,
39
+ ALL_EXEMPT_TEST_FILE_SUFFIXES,
40
+ HELPER_DEFINITION_PATTERN,
41
+ PYTHON_FILE_EXTENSION,
42
+ TRIPLE_QUOTED_STRING_PATTERN,
43
+ )
44
+ from hooks_constants.hook_block_logger import log_hook_block
45
+ from hooks_constants.multi_edit_reconstruction import joined_new_strings
46
+ from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
47
+ except ImportError as import_error:
48
+ raise ImportError(
49
+ "duplicate_rmtree_helper_blocker: cannot import its sibling modules; "
50
+ "ensure the hooks directory is importable."
51
+ ) from import_error
44
52
 
45
53
 
46
54
  def payload_defines_sanctioned_helper(payload_text: str) -> bool:
@@ -87,7 +95,7 @@ def path_is_exempt(file_path: str) -> bool:
87
95
 
88
96
 
89
97
  def extract_payload_text(tool_name: str, tool_input: dict) -> tuple[str, str]:
90
- """Return the (file_path, scanned_text) pair for a Write/Edit to a Python file.
98
+ """Return the (file_path, scanned_text) pair for a Write/Edit/MultiEdit to a Python file.
91
99
 
92
100
  Args:
93
101
  tool_name: The PreToolUse tool name.
@@ -97,11 +105,13 @@ def extract_payload_text(tool_name: str, tool_input: dict) -> tuple[str, str]:
97
105
  A pair of the target path and the text to scan. The text is empty for an
98
106
  unrelated tool or a non-Python target, so the caller exits without blocking.
99
107
  """
100
- if tool_name not in {"Write", "Edit"}:
108
+ if tool_name not in ALL_APPLICABLE_TOOL_NAMES:
101
109
  return "", ""
102
110
  file_path = tool_input.get("file_path", "") or ""
103
111
  if file_path and not file_path.endswith(PYTHON_FILE_EXTENSION):
104
112
  return file_path, ""
113
+ if tool_name == "MultiEdit":
114
+ return file_path, joined_new_strings(tool_input)
105
115
  scanned_text = tool_input.get("content", "") or tool_input.get("new_string", "") or ""
106
116
  return file_path, scanned_text
107
117
 
@@ -35,25 +35,33 @@ import re
35
35
  import sys
36
36
  from pathlib import Path
37
37
 
38
- _hooks_dir = str(Path(__file__).resolve().parent.parent)
39
- if _hooks_dir not in sys.path:
40
- sys.path.insert(0, _hooks_dir)
41
-
42
- from blocking.config.prose_style_enforcement_constants import ( # noqa: E402
43
- prose_style_enforcement_enabled_in_environment,
44
- )
45
- from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
46
- from hooks_constants.hook_prose_detector_consistency_constants import ( # noqa: E402
47
- CONSTANTS_MODULE_SUFFIX,
48
- CORRECTIVE_MESSAGE,
49
- EDIT_TOOL_NAME,
50
- HOOK_MODULE_PATH_SEGMENT,
51
- OVERSTATED_OUTPUT_KEY_PHRASE_PATTERN,
52
- PATH_SEPARATOR_CLASS_PATTERN,
53
- PYTHON_FILE_SUFFIX,
54
- TEST_MODULE_PREFIX,
55
- WRITE_TOOL_NAME,
56
- )
38
+ try:
39
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
40
+ if _hooks_dir not in sys.path:
41
+ sys.path.insert(0, _hooks_dir)
42
+
43
+ from blocking.config.prose_style_enforcement_constants import (
44
+ prose_style_enforcement_enabled_in_environment,
45
+ )
46
+ from hooks_constants.hook_block_logger import log_hook_block
47
+ from hooks_constants.hook_prose_detector_consistency_constants import (
48
+ CONSTANTS_MODULE_SUFFIX,
49
+ CORRECTIVE_MESSAGE,
50
+ EDIT_TOOL_NAME,
51
+ HOOK_MODULE_PATH_SEGMENT,
52
+ MULTI_EDIT_TOOL_NAME,
53
+ OVERSTATED_OUTPUT_KEY_PHRASE_PATTERN,
54
+ PATH_SEPARATOR_CLASS_PATTERN,
55
+ PYTHON_FILE_SUFFIX,
56
+ TEST_MODULE_PREFIX,
57
+ WRITE_TOOL_NAME,
58
+ )
59
+ from hooks_constants.multi_edit_reconstruction import joined_new_strings
60
+ except ImportError as import_error:
61
+ raise ImportError(
62
+ "hook_prose_detector_consistency: cannot import its sibling modules; "
63
+ "ensure the hooks directory is importable."
64
+ ) from import_error
57
65
 
58
66
 
59
67
  def written_content(tool_name: str, all_tool_input: dict[str, object]) -> str:
@@ -63,6 +71,8 @@ def written_content(tool_name: str, all_tool_input: dict[str, object]) -> str:
63
71
  if tool_name == EDIT_TOOL_NAME:
64
72
  new_string = all_tool_input.get("new_string", "")
65
73
  return new_string if isinstance(new_string, str) else ""
74
+ if tool_name == MULTI_EDIT_TOOL_NAME:
75
+ return joined_new_strings(all_tool_input)
66
76
  return ""
67
77
 
68
78
 
@@ -125,7 +135,7 @@ def main() -> None:
125
135
  sys.exit(0)
126
136
 
127
137
  tool_name = hook_input.get("tool_name", "")
128
- if tool_name not in (WRITE_TOOL_NAME, EDIT_TOOL_NAME):
138
+ if tool_name not in (WRITE_TOOL_NAME, EDIT_TOOL_NAME, MULTI_EDIT_TOOL_NAME):
129
139
  sys.exit(0)
130
140
 
131
141
  all_tool_input = hook_input.get("tool_input", {})
@@ -19,6 +19,11 @@ try:
19
19
  if each_bootstrap_directory not in sys.path:
20
20
  sys.path.insert(0, each_bootstrap_directory)
21
21
  from code_rules_shared import is_ephemeral_path
22
+ from codex_apply_patch import (
23
+ CodexPatchError,
24
+ parse_codex_apply_patch,
25
+ payload_patch_command,
26
+ )
22
27
  from pii_prevention_blocker_parts.repository_exemption import (
23
28
  repository_allowlisted_values,
24
29
  )
@@ -48,6 +53,62 @@ except ImportError as import_error:
48
53
  ) from import_error
49
54
 
50
55
 
56
+ def _apply_patch_post_contents(
57
+ command: str, working_directory: str | None
58
+ ) -> list[tuple[str, str]]:
59
+ """Return (file_path, post_content) pairs for a Codex apply_patch command."""
60
+ if not command:
61
+ return []
62
+ try:
63
+ all_patch_files = parse_codex_apply_patch(command, working_directory)
64
+ except CodexPatchError:
65
+ return []
66
+ return [
67
+ (each_patch_file.file_path, each_patch_file.post_content)
68
+ for each_patch_file in all_patch_files
69
+ if each_patch_file.post_content
70
+ ]
71
+
72
+
73
+ def _apply_patch_target_deny_reason(
74
+ each_file_path: str,
75
+ each_post_content: str,
76
+ hook_payload: dict | None,
77
+ ) -> str | None:
78
+ """Return the deny reason for one apply_patch target, or None when it is clean or exempt."""
79
+ if is_path_exempt_from_pii_scan(each_file_path):
80
+ return None
81
+ if _target_is_ephemeral_outside_repository(each_file_path, hook_payload):
82
+ return None
83
+ return _write_deny_reason_for_texts([each_post_content], each_file_path, frozenset())
84
+
85
+
86
+ def evaluate_apply_patch_payload(
87
+ all_tool_input: dict[str, object],
88
+ hook_payload: dict | None = None,
89
+ ) -> str | None:
90
+ """Return a deny reason when a Codex apply_patch payload's post content carries PII.
91
+
92
+ Args:
93
+ all_tool_input: The apply_patch payload's ``command`` and other input fields.
94
+ hook_payload: The PreToolUse payload carrying ``cwd`` and the session id.
95
+
96
+ Returns:
97
+ Deny reason text for the first target carrying non-allowlisted PII, or
98
+ None when every target is clean or out of scope.
99
+ """
100
+ command, working_directory = payload_patch_command(hook_payload, all_tool_input)
101
+ for each_file_path, each_post_content in _apply_patch_post_contents(
102
+ command, working_directory
103
+ ):
104
+ deny_reason = _apply_patch_target_deny_reason(
105
+ each_file_path, each_post_content, hook_payload
106
+ )
107
+ if deny_reason is not None:
108
+ return deny_reason
109
+ return None
110
+
111
+
51
112
  def _target_is_ephemeral_outside_repository(
52
113
  file_path: str, hook_payload: dict | None
53
114
  ) -> bool:
@@ -40,6 +40,7 @@ try:
40
40
  )
41
41
  from pii_payload_scan import (
42
42
  build_deny_reason,
43
+ evaluate_apply_patch_payload,
43
44
  evaluate_post_body_texts,
44
45
  evaluate_write_edit_payload,
45
46
  )
@@ -66,6 +67,7 @@ try:
66
67
  ALL_STAGED_BLOB_SHOW_COMMAND_PREFIX,
67
68
  ALL_STAGED_FILES_COMMAND,
68
69
  ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
70
+ APPLY_PATCH_TOOL_NAME,
69
71
  BODY_FILE_ENCODING,
70
72
  GIT_COMMAND_TIMEOUT_SECONDS,
71
73
  HOOK_SCRIPT_BASENAME,
@@ -92,6 +94,7 @@ except ImportError as import_error:
92
94
 
93
95
  __all__ = [
94
96
  "evaluate",
97
+ "evaluate_apply_patch_payload",
95
98
  "evaluate_bash_command",
96
99
  "evaluate_post_body_texts",
97
100
  "evaluate_staged_commit",
@@ -332,6 +335,8 @@ def evaluate(payload_by_key: dict[str, object]) -> str | None:
332
335
  return evaluate_write_edit_payload(
333
336
  tool_name, all_tool_input, hook_payload=payload_by_key
334
337
  )
338
+ if tool_name == APPLY_PATCH_TOOL_NAME:
339
+ return evaluate_apply_patch_payload(all_tool_input, hook_payload=payload_by_key)
335
340
  if tool_name in ALL_SHELL_TOOL_NAMES:
336
341
  return _evaluate_shell_tool(all_tool_input, payload_by_key)
337
342
  if tool_name.startswith(MCP_GITHUB_TOOL_PREFIX):
@@ -0,0 +1,256 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse hook: block gh pr create when no pr-description-writer spawn is recorded.
3
+
4
+ Picture a reviewer opening a fresh pull request and meeting a body that repeats
5
+ the branch name. The ``pr-description-writer`` agent exists to write that body
6
+ from the diff, and the spawn tracker records each time this session runs it.
7
+
8
+ This gate reads that record when the session runs ``gh pr create``::
9
+
10
+ gh pr create --body-file body.md flag: no spawn recorded
11
+ gh pr create --body-file body.md ok: a spawn recorded earlier
12
+ gh pr create ... # pr-description-skip ok: the author opted out
13
+
14
+ Detection strategy: tokenize the command, then check each position that opens a
15
+ command segment for a ``gh`` executable followed by ``pr`` and ``create``. The
16
+ bypass marker counts only as the command's final tokens, so the same text
17
+ inside a quoted title leaves the gate running.
18
+
19
+ The gate fails OPEN on anything it cannot read: a command that will not
20
+ tokenize, a payload that is not a shell tool, or a missing command. Only a
21
+ recognized ``gh pr create`` with no recorded spawn and no bypass marker denies.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import shlex
28
+ import sys
29
+ from pathlib import Path
30
+
31
+ _blocking_dir = str(Path(__file__).resolve().parent)
32
+ if _blocking_dir not in sys.path:
33
+ sys.path.insert(0, _blocking_dir)
34
+
35
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
36
+ if _hooks_dir not in sys.path:
37
+ sys.path.insert(0, _hooks_dir)
38
+
39
+ from hooks_constants.bash_pre_tool_use_dispatcher_constants import ( # noqa: E402
40
+ ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
41
+ )
42
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
43
+ from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
44
+ read_hook_input_dictionary_from_stdin,
45
+ )
46
+ from hooks_constants.pr_description_writer_gate_constants import ( # noqa: E402
47
+ ALL_GH_EXECUTABLE_BASENAMES,
48
+ COMMAND_FIELD_NAME,
49
+ CORRECTIVE_MESSAGE,
50
+ CREATE_SUBCOMMAND_TOKEN,
51
+ GH_PR_CREATE_MINIMUM_TOKEN_COUNT,
52
+ PR_SUBCOMMAND_TOKEN,
53
+ SPAWN_BYPASS_MARKER,
54
+ TOOL_INPUT_FIELD_NAME,
55
+ TOOL_NAME_FIELD_NAME,
56
+ spawn_marker_path,
57
+ )
58
+ from hooks_constants.session_edit_stage_gate_constants import ( # noqa: E402
59
+ ALL_COMMAND_SEPARATOR_TOKENS,
60
+ )
61
+ from sensitive_file_protector import build_deny_response # noqa: E402
62
+
63
+
64
+ def _all_command_tokens(shell_command: str) -> list[str] | None:
65
+ """Return the command's tokens, or None when it will not tokenize.
66
+
67
+ Args:
68
+ shell_command: The raw shell command string.
69
+
70
+ Returns:
71
+ The token list, or None when shlex rejects the command.
72
+ """
73
+ try:
74
+ return shlex.split(shell_command, posix=True)
75
+ except ValueError:
76
+ return None
77
+
78
+
79
+ def _carries_trailing_bypass_marker(all_tokens: list[str]) -> bool:
80
+ """Report whether the bypass marker sits at the very end of the command.
81
+
82
+ The marker opts out only as a trailing shell comment the author adds on
83
+ purpose, so its parts are the command's final tokens. The same text inside
84
+ a quoted title tokenizes into the middle of the list and does not match.
85
+
86
+ Args:
87
+ all_tokens: The tokens of the whole command.
88
+
89
+ Returns:
90
+ True when the marker's tokens close the command.
91
+ """
92
+ all_marker_tokens = SPAWN_BYPASS_MARKER.split()
93
+ marker_length = len(all_marker_tokens)
94
+ if marker_length > len(all_tokens):
95
+ return False
96
+ return all_tokens[-marker_length:] == all_marker_tokens
97
+
98
+
99
+ def _opens_pull_request_creation(all_leading_tokens: list[str]) -> bool:
100
+ """Report whether these leading tokens open a gh pr create invocation.
101
+
102
+ Args:
103
+ all_leading_tokens: The tokens that open one command segment.
104
+
105
+ Returns:
106
+ True when they name a gh executable, then ``pr``, then ``create``.
107
+ """
108
+ if len(all_leading_tokens) < GH_PR_CREATE_MINIMUM_TOKEN_COUNT:
109
+ return False
110
+ executable_basename = Path(all_leading_tokens[0]).name
111
+ if executable_basename not in ALL_GH_EXECUTABLE_BASENAMES:
112
+ return False
113
+ return all_leading_tokens[1:] == [PR_SUBCOMMAND_TOKEN, CREATE_SUBCOMMAND_TOKEN]
114
+
115
+
116
+ def _all_command_segments(all_tokens: list[str]) -> list[list[str]]:
117
+ """Return the command's token segments in their original order.
118
+
119
+ A shell separator ends the current segment and starts the next one.
120
+
121
+ Args:
122
+ all_tokens: The tokens of the whole command.
123
+
124
+ Returns:
125
+ One token list for each command segment.
126
+ """
127
+ all_segments: list[list[str]] = [[]]
128
+ for each_token in all_tokens:
129
+ if each_token in ALL_COMMAND_SEPARATOR_TOKENS:
130
+ all_segments.append([])
131
+ continue
132
+ all_segments[-1].append(each_token)
133
+ return all_segments
134
+
135
+
136
+ def _all_pull_request_creation_segment_indexes(all_segments: list[list[str]]) -> list[int]:
137
+ """Return indexes for segments that open a gh pr create command.
138
+
139
+ Args:
140
+ all_segments: The shell command's token segments.
141
+
142
+ Returns:
143
+ The indexes of segments that open a gh pr create command.
144
+ """
145
+ return [
146
+ each_index
147
+ for each_index, each_segment in enumerate(all_segments)
148
+ if _opens_pull_request_creation(each_segment[:GH_PR_CREATE_MINIMUM_TOKEN_COUNT])
149
+ ]
150
+
151
+
152
+ def _only_final_create_carries_bypass(
153
+ all_tokens: list[str],
154
+ all_segments: list[list[str]],
155
+ all_creation_segment_indexes: list[int],
156
+ ) -> bool:
157
+ """Report whether one final gh pr create carries the final bypass comment.
158
+
159
+ The marker belongs to the command segment that creates the pull request.
160
+ A marker after another command, or after a second create, cannot opt an
161
+ earlier create out.
162
+
163
+ Args:
164
+ all_tokens: The tokens of the whole command.
165
+ all_segments: The command's token segments, already split by the caller.
166
+ all_creation_segment_indexes: Indexes of segments that create pull requests.
167
+
168
+ Returns:
169
+ True when one final create segment carries the final bypass marker.
170
+ """
171
+ if not _carries_trailing_bypass_marker(all_tokens):
172
+ return False
173
+ final_segment_index = len(all_segments) - 1
174
+ return all_creation_segment_indexes == [final_segment_index]
175
+
176
+
177
+ def _spawn_is_recorded(session_id: str) -> bool:
178
+ """Report whether this session recorded a pr-description-writer spawn.
179
+
180
+ A payload naming no session reads as no record, so the gate denies rather
181
+ than falling back to a shared marker a different session could have left.
182
+
183
+ Args:
184
+ session_id: Raw ``session_id`` from the hook payload.
185
+
186
+ Returns:
187
+ True when this session's marker file is present.
188
+ """
189
+ marker_file = spawn_marker_path(session_id)
190
+ return marker_file is not None and marker_file.exists()
191
+
192
+
193
+ def _shell_command_or_none(all_hook_payload: dict[str, object]) -> str | None:
194
+ """Return the shell command this payload carries, or None.
195
+
196
+ Args:
197
+ all_hook_payload: The parsed PreToolUse payload.
198
+
199
+ Returns:
200
+ The command string, or None when the payload runs no shell command.
201
+ """
202
+ if all_hook_payload.get(TOOL_NAME_FIELD_NAME, "") not in ALL_BASH_AND_POWERSHELL_TOOL_NAMES:
203
+ return None
204
+ tool_input = all_hook_payload.get(TOOL_INPUT_FIELD_NAME, {})
205
+ if not isinstance(tool_input, dict):
206
+ return None
207
+ shell_command = tool_input.get(COMMAND_FIELD_NAME, "")
208
+ if not isinstance(shell_command, str) or not shell_command:
209
+ return None
210
+ return shell_command
211
+
212
+
213
+ def _emit_denial() -> None:
214
+ """Write the PreToolUse deny payload to stdout and log the block."""
215
+ log_hook_block(
216
+ calling_hook_name="pr_description_writer_gate.py",
217
+ hook_event="PreToolUse",
218
+ block_reason=CORRECTIVE_MESSAGE,
219
+ )
220
+ sys.stdout.write(json.dumps(build_deny_response(CORRECTIVE_MESSAGE)) + "\n")
221
+ sys.stdout.flush()
222
+
223
+
224
+ def main() -> None:
225
+ """Deny a gh pr create that no recorded agent spawn or bypass marker covers.
226
+
227
+ Reads the PreToolUse payload from stdin and exits zero on every branch. A
228
+ tool that runs no shell, a missing command, a command that will not
229
+ tokenize, a command opening no pull request, a trailing bypass marker, and
230
+ a recorded spawn each pass through untouched.
231
+ """
232
+ hook_payload = read_hook_input_dictionary_from_stdin()
233
+ if hook_payload is None:
234
+ sys.exit(0)
235
+ shell_command = _shell_command_or_none(hook_payload)
236
+ if shell_command is None:
237
+ sys.exit(0)
238
+ all_tokens = _all_command_tokens(shell_command)
239
+ if all_tokens is None:
240
+ sys.exit(0)
241
+ all_segments = _all_command_segments(all_tokens)
242
+ all_creation_segment_indexes = _all_pull_request_creation_segment_indexes(all_segments)
243
+ if not all_creation_segment_indexes:
244
+ sys.exit(0)
245
+ if _only_final_create_carries_bypass(
246
+ all_tokens, all_segments, all_creation_segment_indexes
247
+ ):
248
+ sys.exit(0)
249
+ if _spawn_is_recorded(str(hook_payload.get("session_id") or "")):
250
+ sys.exit(0)
251
+ _emit_denial()
252
+ sys.exit(0)
253
+
254
+
255
+ if __name__ == "__main__":
256
+ main()
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env python3
2
- """PreToolUse dispatcher that hosts Write, Edit, and MultiEdit blocking hooks.
2
+ """PreToolUse dispatcher that hosts Write, Edit, MultiEdit, and apply_patch blocking hooks.
3
3
 
4
4
  Reads the tool payload from stdin once, selects the hosted hooks applicable to
5
5
  the payload's tool name, runs each hook in-process via runpy in the fixed order
@@ -34,9 +34,11 @@ import sys
34
34
 
35
35
  import _path_setup # noqa: F401
36
36
 
37
+ from codex_apply_patch import payload_patch_target_paths
37
38
  from hooks_constants.hook_block_logger import log_hook_block
38
39
  from hooks_constants.pre_tool_use_dispatcher_constants import (
39
- ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
40
+ ALL_WRITE_EDIT_MULTI_EDIT_APPLY_PATCH_TOOL_NAMES,
41
+ APPLY_PATCH_TOOL_NAME,
40
42
  )
41
43
  from hooks_constants.sensitive_file_protector_constants import (
42
44
  ALL_SENSITIVE_PATTERNS,
@@ -108,7 +110,7 @@ def deny_write(file_path: str, matched_pattern: str) -> None:
108
110
  """Record the block in the hook-blocks log and emit the deny decision.
109
111
 
110
112
  Args:
111
- file_path: The path the Write, Edit, or MultiEdit targets.
113
+ file_path: The path the Write, Edit, MultiEdit, or apply_patch targets.
112
114
  matched_pattern: The sensitive pattern the basename matched.
113
115
  """
114
116
  deny_reason = DENY_REASON_TEMPLATE.format(
@@ -124,6 +126,15 @@ def deny_write(file_path: str, matched_pattern: str) -> None:
124
126
  sys.stdout.write(json.dumps(build_deny_response(deny_reason)))
125
127
 
126
128
 
129
+ def _deny_apply_patch_sensitive_target(hook_input: dict, tool_input: dict) -> None:
130
+ """Deny the first apply_patch target path whose basename names a sensitive file."""
131
+ for each_target_path in payload_patch_target_paths(hook_input, tool_input):
132
+ matched_pattern = is_sensitive_file(each_target_path)
133
+ if matched_pattern is not None:
134
+ deny_write(each_target_path, matched_pattern)
135
+ return
136
+
137
+
127
138
  def main() -> None:
128
139
  """Deny the write when the stdin payload targets a sensitive filename."""
129
140
  try:
@@ -134,7 +145,11 @@ def main() -> None:
134
145
  tool_name = hook_input.get("tool_name", "")
135
146
  tool_input = hook_input.get("tool_input", {})
136
147
 
137
- if tool_name not in ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES:
148
+ if tool_name not in ALL_WRITE_EDIT_MULTI_EDIT_APPLY_PATCH_TOOL_NAMES:
149
+ sys.exit(0)
150
+
151
+ if tool_name == APPLY_PATCH_TOOL_NAME:
152
+ _deny_apply_patch_sensitive_target(hook_input, tool_input)
138
153
  sys.exit(0)
139
154
 
140
155
  file_path = tool_input.get("file_path", "")