claude-dev-env 2.2.1 → 2.4.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 (170) hide show
  1. package/CLAUDE.md +3 -2
  2. package/_shared/advisor/advisor-protocol.md +2 -2
  3. package/_shared/pr-loop/scripts/CLAUDE.md +1 -0
  4. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +306 -284
  5. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
  6. package/_shared/pr-loop/scripts/pr_loop_shared_constants/claude_permissions_constants.py +3 -3
  7. package/_shared/pr-loop/scripts/pr_loop_shared_constants/stale_worktree_rule_sweep_constants.py +107 -0
  8. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +5 -0
  9. package/_shared/pr-loop/scripts/stale_worktree_rule_sweep.py +107 -0
  10. package/_shared/pr-loop/scripts/tests/CLAUDE.md +2 -0
  11. package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +55 -73
  12. package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +9 -15
  13. package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +91 -1
  14. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +88 -1
  15. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep.py +301 -0
  16. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep_constants.py +85 -0
  17. package/_shared/pr-loop/worker-spawn.md +1 -1
  18. package/agents/CLAUDE.md +1 -0
  19. package/agents/code-verifier.md +4 -4
  20. package/agents/skill-writer-agent.md +84 -0
  21. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +141 -41
  22. package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +29 -13
  23. package/docs/CLAUDE.md +1 -0
  24. package/docs/references/CLAUDE.md +1 -0
  25. package/docs/references/code-review-enforcement.md +97 -0
  26. package/docs/wsl-docker-cowork-starter-matrix.md +89 -0
  27. package/hooks/blocking/CLAUDE.md +8 -1
  28. package/hooks/blocking/code_review_enforcement_config_bootstrap.py +53 -0
  29. package/hooks/blocking/code_review_gate_deny.py +74 -0
  30. package/hooks/blocking/code_review_pr_create_gate.py +194 -0
  31. package/hooks/blocking/code_review_push_gate.py +140 -0
  32. package/hooks/blocking/code_review_stamp_directory_write_blocker.py +340 -0
  33. package/hooks/blocking/code_review_stamp_store.py +233 -0
  34. package/hooks/blocking/code_review_stamp_write_blocker_parts/__init__.py +7 -0
  35. package/hooks/blocking/code_review_stamp_write_blocker_parts/conftest.py +15 -0
  36. package/hooks/blocking/code_review_stamp_write_blocker_parts/obfuscated_stamp_path_reference.py +212 -0
  37. package/hooks/blocking/code_review_stamp_write_blocker_parts/split_directory_change_into_stamp.py +138 -0
  38. package/hooks/blocking/code_review_stamp_write_blocker_parts/test_obfuscated_stamp_path_reference.py +49 -0
  39. package/hooks/blocking/code_review_stamp_write_blocker_parts/test_split_directory_change_into_stamp.py +38 -0
  40. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +39 -27
  41. package/hooks/blocking/config/code_review_enforcement_constants.py +110 -0
  42. package/hooks/blocking/config/test_code_review_enforcement_constants.py +108 -0
  43. package/hooks/blocking/config/verified_commit_constants.py +24 -9
  44. package/hooks/blocking/conftest.py +2 -0
  45. package/hooks/blocking/convergence_gate_blocker.py +112 -23
  46. package/hooks/blocking/destructive_command_blocker.py +19 -6
  47. package/hooks/blocking/pr_description_proof_of_work.py +52 -34
  48. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +4 -1
  49. package/hooks/blocking/test_code_review_enforcement_config_bootstrap.py +62 -0
  50. package/hooks/blocking/test_code_review_gate_deny.py +54 -0
  51. package/hooks/blocking/test_code_review_pr_create_gate.py +185 -0
  52. package/hooks/blocking/test_code_review_push_gate.py +189 -0
  53. package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +180 -0
  54. package/hooks/blocking/test_code_review_stamp_store.py +205 -0
  55. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +124 -2
  56. package/hooks/blocking/test_code_verifier_tools_contract.py +28 -0
  57. package/hooks/blocking/test_convergence_gate_blocker.py +153 -5
  58. package/hooks/blocking/test_destructive_command_blocker.py +1 -1
  59. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +45 -0
  60. package/hooks/blocking/test_pr_description_proof_of_work.py +151 -0
  61. package/hooks/blocking/test_pre_tool_use_dispatcher.py +8 -8
  62. package/hooks/blocking/test_verification_verdict_store.py +920 -810
  63. package/hooks/blocking/test_volatile_path_in_post_blocker.py +114 -2
  64. package/hooks/blocking/verification_verdict_store.py +118 -5
  65. package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +29 -17
  66. package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +35 -0
  67. package/hooks/blocking/volatile_path_in_post_blocker.py +69 -8
  68. package/hooks/git-hooks/git_hooks_constants/__init__.py +6 -0
  69. package/hooks/git-hooks/pre_push.py +89 -2
  70. package/hooks/git-hooks/test_pre_push.py +103 -0
  71. package/hooks/hooks.json +16 -1
  72. package/hooks/hooks_constants/CLAUDE.md +1 -0
  73. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +8 -0
  74. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
  75. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +26 -11
  76. package/hooks/hooks_constants/convergence_gate_blocker_constants.py +20 -3
  77. package/hooks/hooks_constants/destructive_command_segment_constants.py +3 -1
  78. package/hooks/hooks_constants/pr_description_proof_of_work_constants.py +0 -4
  79. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  80. package/hooks/hooks_constants/pyproject_config_discovery_constants.py +16 -0
  81. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +24 -0
  82. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +6 -0
  83. package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +8 -1
  84. package/hooks/validators/CLAUDE.md +1 -0
  85. package/hooks/validators/mypy_integration.py +63 -50
  86. package/hooks/validators/pyproject_config_discovery.py +101 -0
  87. package/hooks/validators/ruff_integration.py +211 -23
  88. package/hooks/validators/run_all_validators.py +21 -14
  89. package/hooks/validators/test_mypy_integration.py +32 -0
  90. package/hooks/validators/test_pyproject_config_discovery.py +94 -0
  91. package/hooks/validators/test_ruff_integration.py +68 -1
  92. package/hooks/validators/test_run_all_validators.py +25 -0
  93. package/hooks/validators/test_run_all_validators_config_discovery.py +123 -0
  94. package/package.json +1 -1
  95. package/rules/docstring-prose-matches-implementation.md +20 -43
  96. package/rules/durable-post-artifacts.md +7 -0
  97. package/scripts/CLAUDE.md +2 -1
  98. package/scripts/claude-chain.example.json +15 -3
  99. package/scripts/claude_chain_runner.py +130 -27
  100. package/scripts/claude_chain_usage.py +346 -0
  101. package/scripts/codec_forwarding_test_support.py +83 -0
  102. package/scripts/conftest.py +8 -0
  103. package/scripts/dev_env_scripts_constants/CLAUDE.md +4 -3
  104. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +57 -2
  105. package/scripts/dev_env_scripts_constants/claude_chain_usage_constants.py +58 -0
  106. package/scripts/dev_env_scripts_constants/code_review_constants.py +129 -12
  107. package/scripts/dev_env_scripts_constants/test_code_review_constants.py +55 -0
  108. package/scripts/dev_env_scripts_constants/timing.py +1 -1
  109. package/scripts/invoke_code_review.py +550 -38
  110. package/scripts/resolve_worker_spawn.py +8 -1
  111. package/scripts/test_claude_chain_runner.py +412 -6
  112. package/scripts/test_claude_chain_usage.py +534 -0
  113. package/scripts/test_invoke_code_review.py +298 -4
  114. package/scripts/test_invoke_code_review_codec.py +77 -0
  115. package/scripts/test_resolve_worker_spawn_codec.py +101 -0
  116. package/skills/autoconverge/SKILL.md +9 -3
  117. package/skills/autoconverge/reference/convergence.md +2 -1
  118. package/skills/autoconverge/reference/multi-pr.md +6 -1
  119. package/skills/autoconverge/reference/stop-conditions.md +16 -10
  120. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +1 -1
  121. package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +175 -3
  122. package/skills/autoconverge/workflow/converge.contract.test.mjs +19 -0
  123. package/skills/autoconverge/workflow/converge.mjs +24 -8
  124. package/skills/autoconverge/workflow/converge_multi.mjs +7 -3
  125. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +5 -0
  126. package/skills/fresh-branch/CLAUDE.md +2 -0
  127. package/skills/fresh-branch/SKILL.md +2 -0
  128. package/skills/fresh-branch/scripts/create_fresh_branch.py +78 -180
  129. package/skills/fresh-branch/scripts/fresh_branch_git_commands.py +285 -0
  130. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -0
  131. package/skills/fresh-branch/scripts/pytest.ini +4 -0
  132. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +98 -0
  133. package/skills/fresh-branch/scripts/test_fresh_branch_git_commands.py +310 -0
  134. package/skills/orchestrator/SKILL.md +1 -20
  135. package/skills/orchestrator-refresh/SKILL.md +1 -1
  136. package/skills/pr-converge/SKILL.md +3 -3
  137. package/skills/pr-converge/reference/examples.md +3 -3
  138. package/skills/pr-converge/reference/fix-protocol.md +1 -1
  139. package/skills/pr-converge/reference/ground-rules.md +3 -3
  140. package/skills/pr-converge/reference/per-tick.md +5 -5
  141. package/skills/pr-converge/reference/progress-checklist.md +1 -1
  142. package/skills/pr-converge/test_step5_host_branch.py +1 -1
  143. package/skills/prototype/SKILL.md +86 -0
  144. package/skills/prototype/reference/honest-limitations.md +23 -0
  145. package/skills/prototype/reference/promotion-tasks.md +23 -0
  146. package/skills/prototype/scripts/build_sandbox_settings.py +249 -0
  147. package/skills/prototype/scripts/conftest.py +15 -0
  148. package/skills/prototype/scripts/launch_sandbox.py +205 -0
  149. package/skills/prototype/scripts/probe_sandbox_safety.py +311 -0
  150. package/skills/prototype/scripts/prototype_scripts_constants/__init__.py +1 -0
  151. package/skills/prototype/scripts/prototype_scripts_constants/config/__init__.py +0 -0
  152. package/skills/prototype/scripts/prototype_scripts_constants/config/build_sandbox_settings_constants.py +41 -0
  153. package/skills/prototype/scripts/prototype_scripts_constants/config/launch_sandbox_constants.py +23 -0
  154. package/skills/prototype/scripts/prototype_scripts_constants/config/probe_sandbox_safety_constants.py +45 -0
  155. package/skills/prototype/scripts/prototype_scripts_constants/config/prototype_common_constants.py +10 -0
  156. package/skills/prototype/scripts/test_build_sandbox_settings.py +275 -0
  157. package/skills/prototype/scripts/test_launch_sandbox.py +303 -0
  158. package/skills/prototype/scripts/test_probe_sandbox_safety.py +284 -0
  159. package/skills/prototype/workflows/promotion.md +27 -0
  160. package/skills/prototype/workflows/sandbox.md +35 -0
  161. package/skills/skill-builder/CLAUDE.md +3 -3
  162. package/skills/skill-builder/SKILL.md +5 -5
  163. package/skills/skill-builder/references/CLAUDE.md +1 -1
  164. package/skills/skill-builder/references/delegation-map.md +3 -3
  165. package/skills/skill-builder/references/description-field.md +1 -1
  166. package/skills/skill-builder/references/skill-modularity.md +2 -3
  167. package/skills/skill-builder/workflows/CLAUDE.md +1 -1
  168. package/skills/skill-builder/workflows/improve-skill.md +1 -1
  169. package/skills/skill-builder/workflows/new-skill.md +2 -2
  170. package/skills/team-advisor/SKILL.md +5 -4
@@ -0,0 +1,212 @@
1
+ """Decide whether a command builds a stamp path with obfuscation and writes.
2
+
3
+ A path-obfuscation forge hides ``.claude`` or ``code-review-stamps`` inside hex
4
+ digits, a base64 token, a comma-separated character-code sequence, or a run of
5
+ ``+``-joined ``chr(<int>)`` calls, then pairs the assembled path with a
6
+ non-redirect file write (``open(``, ``write_text``, ``Out-File``,
7
+ ``Set-Content``, ``Add-Content``, ``tee``). The literal path matchers miss such
8
+ a path because it carries no plain ``code-review-stamps`` substring, so this
9
+ module decodes each embedded token and looks for a stamp-path segment either in
10
+ the decoded text or inside the write call's argument region. A bare ``>``
11
+ redirect to the stamp path is caught by the literal-path matchers, so a command
12
+ whose only write is a redirect is left alone here.
13
+
14
+ The entry hook registers the constant packages before importing this module, so
15
+ the ``from config...`` imports below resolve against the sibling ``config/``
16
+ package without a bootstrap call here.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import base64
22
+ import binascii
23
+ import re
24
+
25
+ from config.code_review_enforcement_constants import (
26
+ ALL_STAMP_PATH_SEGMENT_BODIES,
27
+ ALL_STAMP_PATH_SEGMENT_NAMES,
28
+ )
29
+ from config.verified_commit_constants import (
30
+ BASE64_TOKEN_PATTERN,
31
+ CHARACTER_CODE_SEQUENCE_PATTERN,
32
+ CHR_CALL_CHAIN_PATTERN,
33
+ CHR_CALL_CODE_PATTERN,
34
+ HEX_DIGITS_PER_BYTE,
35
+ HEX_TOKEN_PATTERN,
36
+ NON_REDIRECT_FILE_WRITE_PRIMITIVE_PATTERN,
37
+ PATH_OBFUSCATION_PRIMITIVE_PATTERN,
38
+ WRITE_CALL_REGION_PATTERN,
39
+ )
40
+
41
+ _ASCII_ENCODING = "ascii"
42
+
43
+
44
+ def _decode_hex_token(hex_token: str) -> str:
45
+ """Decode a hex-digit token to text, returning empty text on failure.
46
+
47
+ Args:
48
+ hex_token: A run of hex digits taken from the command.
49
+
50
+ Returns:
51
+ The decoded ASCII text, or empty text when the token is odd-length,
52
+ not valid hex, or not decodable as ASCII.
53
+ """
54
+ odd_hex_token_remainder = 1
55
+ if len(hex_token) % HEX_DIGITS_PER_BYTE == odd_hex_token_remainder:
56
+ return ""
57
+ try:
58
+ return bytes.fromhex(hex_token).decode(_ASCII_ENCODING)
59
+ except (ValueError, binascii.Error):
60
+ return ""
61
+
62
+
63
+ def _decode_base64_token(base64_token: str) -> str:
64
+ """Decode a base64 token to text, returning empty text on failure.
65
+
66
+ Args:
67
+ base64_token: A base64-shaped token taken from the command.
68
+
69
+ Returns:
70
+ The decoded ASCII text, or empty text when the token is not valid
71
+ base64 or not decodable as ASCII.
72
+ """
73
+ try:
74
+ return base64.b64decode(base64_token, validate=True).decode(_ASCII_ENCODING)
75
+ except (ValueError, binascii.Error):
76
+ return ""
77
+
78
+
79
+ def _decode_character_code_sequence(code_sequence: str) -> str:
80
+ """Decode a comma-separated character-code sequence to text.
81
+
82
+ Args:
83
+ code_sequence: A comma-separated run of decimal character codes, each
84
+ within the three-digit range the source pattern admits.
85
+
86
+ Returns:
87
+ The decoded text.
88
+ """
89
+ character_code_separator = ","
90
+ decoded_characters: list[str] = []
91
+ for each_code in code_sequence.split(character_code_separator):
92
+ code_point = int(each_code.strip())
93
+ decoded_characters.append(chr(code_point))
94
+ return "".join(decoded_characters)
95
+
96
+
97
+ def _decode_chr_call_chain(chr_chain: str) -> str:
98
+ """Decode a run of ``+``-joined ``chr(<int>)`` calls to text.
99
+
100
+ Args:
101
+ chr_chain: A run of two or more ``+``-joined ``chr(<int>)`` calls taken
102
+ from the command, each code within the three-digit source range.
103
+
104
+ Returns:
105
+ The decoded text.
106
+ """
107
+ decoded_characters: list[str] = []
108
+ for each_code in re.findall(CHR_CALL_CODE_PATTERN, chr_chain, re.IGNORECASE):
109
+ decoded_characters.append(chr(int(each_code)))
110
+ return "".join(decoded_characters)
111
+
112
+
113
+ def _append_when_present(all_decoded_texts: list[str], decoded_text: str) -> None:
114
+ """Append the lowercase decoded text when it is non-empty.
115
+
116
+ Args:
117
+ all_decoded_texts: The running list of decoded texts to extend.
118
+ decoded_text: One token's decoded text, empty when the token failed.
119
+
120
+ Returns:
121
+ None. The list is extended in place when the text is non-empty.
122
+ """
123
+ if decoded_text:
124
+ all_decoded_texts.append(decoded_text.lower())
125
+
126
+
127
+ def _decoded_texts_from_command(command_text: str) -> list[str]:
128
+ """Decode every hex, base64, character-code, and chr-chain token a command embeds.
129
+
130
+ Args:
131
+ command_text: The raw command string from the tool payload.
132
+
133
+ Returns:
134
+ The lowercase decoded text for every token that decodes cleanly;
135
+ tokens that fail to decode or carry an out-of-range code are skipped.
136
+ """
137
+ all_decoded_texts: list[str] = []
138
+ for each_hex_token in re.findall(HEX_TOKEN_PATTERN, command_text, re.IGNORECASE):
139
+ _append_when_present(all_decoded_texts, _decode_hex_token(each_hex_token))
140
+ for each_base64_token in re.findall(BASE64_TOKEN_PATTERN, command_text):
141
+ _append_when_present(all_decoded_texts, _decode_base64_token(each_base64_token))
142
+ for each_code_sequence in re.findall(CHARACTER_CODE_SEQUENCE_PATTERN, command_text):
143
+ _append_when_present(all_decoded_texts, _decode_character_code_sequence(each_code_sequence))
144
+ for each_chr_chain in re.findall(CHR_CALL_CHAIN_PATTERN, command_text, re.IGNORECASE):
145
+ _append_when_present(all_decoded_texts, _decode_chr_call_chain(each_chr_chain))
146
+ return all_decoded_texts
147
+
148
+
149
+ def _decoded_token_references_stamp_segment(command_text: str) -> bool:
150
+ """Decide whether a decodable token in a command hides a stamp-path segment.
151
+
152
+ Args:
153
+ command_text: The raw command string from the tool payload.
154
+
155
+ Returns:
156
+ True when a decodable token decodes to a stamp-path segment body
157
+ (``claude`` or ``code-review-stamps``).
158
+ """
159
+ all_decoded_texts = _decoded_texts_from_command(command_text)
160
+ return any(
161
+ each_segment_body in each_decoded_text
162
+ for each_decoded_text in all_decoded_texts
163
+ for each_segment_body in ALL_STAMP_PATH_SEGMENT_BODIES
164
+ )
165
+
166
+
167
+ def _write_call_region_names_stamp_segment(command_text: str) -> bool:
168
+ """Decide whether a write call's argument region names a stamp-path segment.
169
+
170
+ Args:
171
+ command_text: The raw command string from the tool payload.
172
+
173
+ Returns:
174
+ True when a stamp-path segment name appears inside a non-redirect write
175
+ call's argument region.
176
+ """
177
+ write_call_region_pattern = re.compile(WRITE_CALL_REGION_PATTERN, re.IGNORECASE)
178
+ for each_region_match in write_call_region_pattern.finditer(command_text):
179
+ lowercased_region = each_region_match.group(0).lower()
180
+ if any(each_name in lowercased_region for each_name in ALL_STAMP_PATH_SEGMENT_NAMES):
181
+ return True
182
+ return False
183
+
184
+
185
+ def references_obfuscated_stamp_path(command_text: str) -> bool:
186
+ """Decide whether a command pairs path obfuscation with a stamp-path write.
187
+
188
+ ::
189
+
190
+ python -c "open(bytes.fromhex('...code-review-stamps...'),'w')" -> True
191
+ open(bytes.fromhex('2f746d702f78'),'w') -> False
192
+
193
+ Args:
194
+ command_text: The raw command string from the tool payload.
195
+
196
+ Returns:
197
+ True when the command holds a path-obfuscation primitive, a
198
+ non-redirect file write, and a stamp-path segment reachable by that
199
+ write — either a decoded token anywhere in the command or a plain-text
200
+ segment inside the write call's argument region.
201
+ """
202
+ has_obfuscation_primitive = (
203
+ re.search(PATH_OBFUSCATION_PRIMITIVE_PATTERN, command_text) is not None
204
+ )
205
+ has_non_redirect_write = (
206
+ re.search(NON_REDIRECT_FILE_WRITE_PRIMITIVE_PATTERN, command_text) is not None
207
+ )
208
+ if not (has_obfuscation_primitive and has_non_redirect_write):
209
+ return False
210
+ if _decoded_token_references_stamp_segment(command_text):
211
+ return True
212
+ return _write_call_region_names_stamp_segment(command_text)
@@ -0,0 +1,138 @@
1
+ """Decide whether a split directory change reaches the stamp directory then runs a command.
2
+
3
+ A change into the Claude home (``cd ~/.claude``) followed by a change into a
4
+ relative ``code-review-stamps`` directory (``cd code-review-stamps``) lands in
5
+ the stamp directory without ever naming the two segments adjacently, so the
6
+ absolute-path matcher and the single-step ``cd ~/.claude/code-review-stamps``
7
+ matcher both miss it. The trust contract denies every shell command that
8
+ reaches the stamp directory, so any command after the second change — a
9
+ redirect, ``cp``, ``mv``, ``tee``, ``install``, or a ``python -c`` write — is a
10
+ forge vector. An unrelated second change (``cd hooks``) lands elsewhere and
11
+ passes.
12
+
13
+ The entry hook registers the constant packages before importing this module, so
14
+ the ``from config...`` imports below resolve against the sibling ``config/``
15
+ package without a bootstrap call here.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+
22
+ from config.code_review_enforcement_constants import STAMP_DIRECTORY_CHANGE_TARGET_PATTERN
23
+ from config.verified_commit_constants import (
24
+ CLAUDE_HOME_DIRECTORY_NAME,
25
+ CLAUDE_HOME_TARGET_BOUNDARY_PATTERN,
26
+ COMMAND_AFTER_DIRECTORY_CHANGE_PATTERN,
27
+ DIRECTORY_CHANGE_OPTION_PREFIX_PATTERN,
28
+ DIRECTORY_CHANGE_OPTION_TERMINATOR,
29
+ DIRECTORY_CHANGE_PATH_OPTIONS,
30
+ DIRECTORY_CHANGE_PATTERN_PREFIX,
31
+ DIRECTORY_CHANGE_PATTERN_SUFFIX,
32
+ DIRECTORY_CHANGE_TARGET_PATTERN,
33
+ DIRECTORY_CHANGE_VERBS,
34
+ )
35
+ from config.verified_commit_gate_output_constants import REGEX_ALTERNATION_SEPARATOR
36
+
37
+
38
+ def _directory_change_verbs_pattern() -> str:
39
+ """Build the alternation of directory-change verbs for a change matcher.
40
+
41
+ Returns:
42
+ The regex alternation of escaped directory-change verbs in sorted order.
43
+ """
44
+ return REGEX_ALTERNATION_SEPARATOR.join(
45
+ re.escape(each_verb) for each_verb in sorted(DIRECTORY_CHANGE_VERBS)
46
+ )
47
+
48
+
49
+ def _directory_change_option_prefix_pattern() -> str:
50
+ """Build the optional path-option prefix that may precede a change target.
51
+
52
+ Returns:
53
+ The regex matching zero or more leading path-option or terminator
54
+ tokens, ready to sit between the change suffix and target patterns.
55
+ """
56
+ option_alternation = REGEX_ALTERNATION_SEPARATOR.join(
57
+ re.escape(each_option)
58
+ for each_option in sorted(
59
+ DIRECTORY_CHANGE_PATH_OPTIONS | {DIRECTORY_CHANGE_OPTION_TERMINATOR}
60
+ )
61
+ )
62
+ return DIRECTORY_CHANGE_OPTION_PREFIX_PATTERN % option_alternation
63
+
64
+
65
+ def directory_change_prefix() -> str:
66
+ """Build the change-verb prefix shared by the directory-change matchers.
67
+
68
+ The stamp-directory write-blocker reuses this same prefix, so a single
69
+ builder feeds both this module's split-change matchers and the blocker's
70
+ single-step change matchers, and the two never drift.
71
+
72
+ Returns:
73
+ The regex prefix matching a directory-change verb and any leading
74
+ path-option tokens, ready for a target pattern to follow.
75
+ """
76
+ return (
77
+ DIRECTORY_CHANGE_PATTERN_PREFIX
78
+ + _directory_change_verbs_pattern()
79
+ + DIRECTORY_CHANGE_PATTERN_SUFFIX
80
+ + _directory_change_option_prefix_pattern()
81
+ )
82
+
83
+
84
+ def _change_into_claude_pattern() -> re.Pattern[str]:
85
+ """Build the compiled matcher for a directory change into the Claude home.
86
+
87
+ Returns:
88
+ The compiled, case-insensitive pattern matching a change verb into any
89
+ path ending in ``.claude``.
90
+ """
91
+ return re.compile(
92
+ directory_change_prefix()
93
+ + DIRECTORY_CHANGE_TARGET_PATTERN
94
+ + re.escape(CLAUDE_HOME_DIRECTORY_NAME)
95
+ + CLAUDE_HOME_TARGET_BOUNDARY_PATTERN,
96
+ re.IGNORECASE,
97
+ )
98
+
99
+
100
+ def _change_into_stamp_pattern() -> re.Pattern[str]:
101
+ """Build the compiled matcher for a directory change into the stamp directory.
102
+
103
+ Returns:
104
+ The compiled, case-insensitive pattern matching a change verb into a
105
+ relative ``code-review-stamps`` directory.
106
+ """
107
+ return re.compile(
108
+ directory_change_prefix() + STAMP_DIRECTORY_CHANGE_TARGET_PATTERN, re.IGNORECASE
109
+ )
110
+
111
+
112
+ def changes_through_split_directory_into_stamp(command_text: str) -> bool:
113
+ """Decide whether a split change reaches the stamp directory then runs a command.
114
+
115
+ ::
116
+
117
+ cd ~/.claude && cd code-review-stamps && echo x > f.json -> True
118
+ cd ~/.claude && cd hooks && echo x > f.json -> False
119
+
120
+ Args:
121
+ command_text: The raw command string from the tool payload.
122
+
123
+ Returns:
124
+ True when a change into the Claude home precedes a change into a
125
+ relative stamp directory that is itself followed by any further command.
126
+ """
127
+ change_into_claude_match = _change_into_claude_pattern().search(command_text)
128
+ if change_into_claude_match is None:
129
+ return False
130
+ change_into_stamp_match = _change_into_stamp_pattern().search(
131
+ command_text, change_into_claude_match.end()
132
+ )
133
+ if change_into_stamp_match is None:
134
+ return False
135
+ command_after_change_pattern = re.compile(COMMAND_AFTER_DIRECTORY_CHANGE_PATTERN)
136
+ return (
137
+ command_after_change_pattern.search(command_text, change_into_stamp_match.end()) is not None
138
+ )
@@ -0,0 +1,49 @@
1
+ """Behavioral tests for the obfuscated-stamp-path matcher.
2
+
3
+ The matcher blocks a command that assembles a stamp path from hex, base64, or
4
+ ``chr(<int>)`` codes and pairs it with a non-redirect file write, while a
5
+ decode-to-other-path one-liner passes.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from code_review_stamp_write_blocker_parts.obfuscated_stamp_path_reference import (
11
+ references_obfuscated_stamp_path,
12
+ )
13
+
14
+ STAMP_PATH_TEXT = "/.claude/code-review-stamps/a.json"
15
+ BENIGN_PATH_TEXT = "/tmp/x"
16
+
17
+
18
+ def _hex_forge_command(path_text: str) -> str:
19
+ return f"python -c \"open(bytes.fromhex('{path_text.encode().hex()}'),'w').write('x')\""
20
+
21
+
22
+ def _chr_chain_forge_command(path_text: str) -> str:
23
+ chr_chain = "+".join(f"chr({ord(each_character)})" for each_character in path_text)
24
+ return f"python -c \"open({chr_chain},'w').write('x')\""
25
+
26
+
27
+ def _character_code_forge_command(path_text: str) -> str:
28
+ comma_separated_codes = ",".join(str(ord(each_character)) for each_character in path_text)
29
+ return f"python -c \"open(bytes([{comma_separated_codes}]),'w').write('x')\""
30
+
31
+
32
+ def test_hex_assembled_stamp_path_write_is_blocked() -> None:
33
+ assert references_obfuscated_stamp_path(_hex_forge_command(STAMP_PATH_TEXT))
34
+
35
+
36
+ def test_character_code_assembled_stamp_path_write_is_blocked() -> None:
37
+ assert references_obfuscated_stamp_path(_character_code_forge_command(STAMP_PATH_TEXT))
38
+
39
+
40
+ def test_chr_chain_assembled_stamp_path_write_is_blocked() -> None:
41
+ assert references_obfuscated_stamp_path(_chr_chain_forge_command(STAMP_PATH_TEXT))
42
+
43
+
44
+ def test_decode_to_other_path_write_is_not_blocked() -> None:
45
+ assert not references_obfuscated_stamp_path(_hex_forge_command(BENIGN_PATH_TEXT))
46
+
47
+
48
+ def test_redirect_only_stamp_reference_is_left_to_literal_matchers() -> None:
49
+ assert not references_obfuscated_stamp_path("echo x > ~/.claude/code-review-stamps/a.json")
@@ -0,0 +1,38 @@
1
+ """Behavioral tests for the split directory-change-into-stamp matcher.
2
+
3
+ The matcher blocks a command that changes into the Claude home, then into a
4
+ relative ``code-review-stamps`` directory, then runs any further command, while
5
+ an unrelated second change or a benign command passes.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from code_review_stamp_write_blocker_parts.split_directory_change_into_stamp import (
11
+ changes_through_split_directory_into_stamp,
12
+ )
13
+
14
+ SPLIT_CHANGE_FORGE = "cd ~/.claude && cd code-review-stamps && echo x > f.json"
15
+ UNRELATED_SECOND_CHANGE = "cd ~/.claude && cd hooks && echo x > f.json"
16
+ PYTEST_RUN = "python -m pytest test_code_review_stamp_store.py"
17
+
18
+
19
+ def test_split_change_into_stamp_then_command_is_blocked() -> None:
20
+ assert changes_through_split_directory_into_stamp(SPLIT_CHANGE_FORGE)
21
+
22
+
23
+ def test_unrelated_second_change_is_not_blocked() -> None:
24
+ assert not changes_through_split_directory_into_stamp(UNRELATED_SECOND_CHANGE)
25
+
26
+
27
+ def test_pytest_run_is_not_blocked() -> None:
28
+ assert not changes_through_split_directory_into_stamp(PYTEST_RUN)
29
+
30
+
31
+ def test_change_into_stamp_without_following_command_is_not_blocked() -> None:
32
+ assert not changes_through_split_directory_into_stamp("cd ~/.claude && cd code-review-stamps")
33
+
34
+
35
+ def test_pushd_split_change_into_stamp_then_command_is_blocked() -> None:
36
+ assert changes_through_split_directory_into_stamp(
37
+ "pushd ~/.claude && pushd code-review-stamps && cp a b"
38
+ )
@@ -1,18 +1,19 @@
1
1
  #!/usr/bin/env python3
2
2
  """PreToolUse hook: pre-flight gate for the code-verifier subagent spawn.
3
3
 
4
- The hook fires only on an ``Agent`` tool call whose ``subagent_type`` is
4
+ The hook fires on an ``Agent`` or ``Task`` tool call whose ``subagent_type`` is
5
5
  ``code-verifier``. Before that verification spawn runs, the hook checks the
6
6
  branch for two committability problems against the resolved base ref: a real
7
7
  merge conflict (a non-mutating trial-merge of HEAD against the base ref) and a
8
8
  CODE_RULES violation on a line added in the working tree since the merge base
9
- (committed on the branch or uncommitted). When
10
- either fires, the hook denies the spawn with a reason addressed to the spawning
11
- agent that names the conflicting files and the violating file:line, so that
12
- agent fixes them and re-spawns. Both checks fail OPEN on any infrastructure
13
- problem — a non-repo cwd, an absent base ref, a git or engine failure, or a
14
- timeout because the authoritative fail-closed CODE_RULES enforcement already
15
- runs at Write time and at commit time. The hook never network-fetches and never
9
+ (committed on the branch or uncommitted). When either fires, the hook denies
10
+ the spawn with a reason addressed to the spawning agent that names the
11
+ conflicting files and the violating file:line, so that agent fixes them and
12
+ re-spawns. A CODE_RULES engine import/load failure is fail-closed (deny with a
13
+ named load-failure section). Environmental problems — a non-repo cwd, an
14
+ absent base ref, a git failure, an unreadable file alone, or a timeout — fail
15
+ OPEN because the authoritative fail-closed CODE_RULES enforcement already runs
16
+ at Write time and at commit time. The hook never network-fetches and never
16
17
  mutates the index or working tree.
17
18
  """
18
19
 
@@ -21,6 +22,7 @@ from __future__ import annotations
21
22
  import contextlib
22
23
  import io
23
24
  import json
25
+ import re
24
26
  import subprocess
25
27
  import sys
26
28
  from pathlib import Path
@@ -43,6 +45,7 @@ from verification_verdict_store import ( # noqa: E402
43
45
  )
44
46
 
45
47
  from hooks_constants.code_verifier_spawn_preflight_gate_constants import ( # noqa: E402
48
+ ALL_CODE_VERIFIER_SPAWN_TOOL_NAMES,
46
49
  ALL_MERGE_HEAD_PROBE_FLAGS,
47
50
  ALL_MERGE_TREE_COMMAND_FLAGS,
48
51
  ALL_NAME_ONLY_WORKTREE_DIFF_FLAGS,
@@ -51,6 +54,7 @@ from hooks_constants.code_verifier_spawn_preflight_gate_constants import ( # no
51
54
  CODE_RULES_SECTION_HEADER,
52
55
  CODE_VERIFIER_SUBAGENT_TYPE,
53
56
  DENY_REASON_LEAD,
57
+ ENGINE_LOAD_FAILURE_SECTION,
54
58
  GATE_SCRIPTS_RELATIVE_PATH,
55
59
  MERGE_CONFLICT_SECTION_HEADER,
56
60
  MERGE_TREE_CLEAN_EXIT_CODE,
@@ -58,9 +62,6 @@ from hooks_constants.code_verifier_spawn_preflight_gate_constants import ( # no
58
62
  MERGE_TREE_TIMEOUT_SECONDS,
59
63
  )
60
64
  from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
61
- from hooks_constants.pr_converge_bugteam_enforcer_constants import ( # noqa: E402
62
- AGENT_TOOL_NAME,
63
- )
64
65
 
65
66
  _scripts_dir = str(Path(__file__).resolve().parents[2] / GATE_SCRIPTS_RELATIVE_PATH)
66
67
  if _scripts_dir not in sys.path:
@@ -78,17 +79,17 @@ from code_rules_gate import ( # noqa: E402
78
79
 
79
80
 
80
81
  def _should_run(payload_by_field: dict[str, object]) -> bool:
81
- """Return True only for a code-verifier Agent spawn.
82
+ """Return True only for a code-verifier Agent or Task spawn.
82
83
 
83
84
  Args:
84
85
  payload_by_field: The full PreToolUse hook payload (already
85
86
  JSON-parsed), keyed by top-level field name.
86
87
 
87
88
  Returns:
88
- True when the tool is Agent and ``tool_input.subagent_type`` is
89
- ``code-verifier``; False for every other shape.
89
+ True when the tool is Agent or Task and ``tool_input.subagent_type``
90
+ is ``code-verifier``; False for every other shape.
90
91
  """
91
- if payload_by_field.get("tool_name", "") != AGENT_TOOL_NAME:
92
+ if payload_by_field.get("tool_name", "") not in ALL_CODE_VERIFIER_SPAWN_TOOL_NAMES:
92
93
  return False
93
94
  tool_input = payload_by_field.get("tool_input", {})
94
95
  if not isinstance(tool_input, dict):
@@ -297,7 +298,7 @@ def _tracked_file_added_lines(repo_root: str, merge_base_sha: str, relative_path
297
298
  def _code_rules_report(
298
299
  repo_root: str, all_file_paths: list[Path], all_added_lines_by_path: dict[Path, set[int]]
299
300
  ) -> str | None:
300
- """Run the CODE_RULES engine and return its blocking report, or None.
301
+ """Run the CODE_RULES engine and return a full deny section, or None.
301
302
 
302
303
  Args:
303
304
  repo_root: The repository top-level directory.
@@ -306,19 +307,30 @@ def _code_rules_report(
306
307
  by resolved absolute path.
307
308
 
308
309
  Returns:
309
- The engine's grouped file:line report when a blocking violation lands
310
- on an added line, or None when the surface is clean, only an unreadable
311
- changed file caused a non-zero gate exit, the engine fails to load, or
312
- any engine error arises — every non-block outcome fails OPEN. The
313
- harness hook timeout in hooks.json is the wall-clock bound on a runaway
314
- engine.
310
+ A full deny section when a blocking violation lands on an added line or
311
+ the CODE_RULES engine fails to import/load (fail-closed). None when the
312
+ surface is clean, only an unreadable changed file caused a non-zero gate
313
+ exit, or an environmental engine error arises — those non-block outcomes
314
+ fail OPEN. The harness hook timeout in hooks.json is the wall-clock
315
+ bound on a runaway engine.
315
316
  """
316
317
  if not all_file_paths:
317
318
  return None
318
319
  try:
319
320
  validate_content = load_validate_content()
320
321
  except SystemExit:
321
- return None
322
+ return ENGINE_LOAD_FAILURE_SECTION
323
+ except (
324
+ ImportError,
325
+ SyntaxError,
326
+ AttributeError,
327
+ RuntimeError,
328
+ TypeError,
329
+ NameError,
330
+ OSError,
331
+ re.error,
332
+ ):
333
+ return ENGINE_LOAD_FAILURE_SECTION
322
334
  try:
323
335
  blocking_present, captured_report = _run_gate_capturing_stderr(
324
336
  validate_content, all_file_paths, Path(repo_root), all_added_lines_by_path
@@ -327,7 +339,7 @@ def _code_rules_report(
327
339
  return None
328
340
  if not blocking_present:
329
341
  return None
330
- return captured_report
342
+ return f"{CODE_RULES_SECTION_HEADER}\n{captured_report.strip()}"
331
343
 
332
344
 
333
345
  def _run_gate_capturing_stderr(
@@ -382,8 +394,8 @@ def _build_deny_reason(
382
394
  all_conflicting_files: The conflicting file paths from the conflict
383
395
  check, an empty list when clean, or None when that check failed open.
384
396
  base_ref: The base ref named in the conflict section header.
385
- code_rules_report: The grouped report from the CODE_RULES check, or None
386
- when that check found nothing or failed open.
397
+ code_rules_report: A full CODE_RULES deny section (violations or engine
398
+ load failure), or None when that check found nothing or failed open.
387
399
 
388
400
  Returns:
389
401
  The full deny reason when either check fired, or None when neither
@@ -395,7 +407,7 @@ def _build_deny_reason(
395
407
  conflict_header = MERGE_CONFLICT_SECTION_HEADER.format(base_ref=base_ref)
396
408
  reason_sections.append(f"{conflict_header}\n{conflict_lines}")
397
409
  if code_rules_report:
398
- reason_sections.append(f"{CODE_RULES_SECTION_HEADER}\n{code_rules_report.strip()}")
410
+ reason_sections.append(code_rules_report.strip())
399
411
  if not reason_sections:
400
412
  return None
401
413
  return DENY_REASON_LEAD + "\n\n" + "\n\n".join(reason_sections)