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.
- package/CLAUDE.md +3 -2
- package/_shared/advisor/advisor-protocol.md +2 -2
- package/_shared/pr-loop/scripts/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +306 -284
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/claude_permissions_constants.py +3 -3
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/stale_worktree_rule_sweep_constants.py +107 -0
- package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +5 -0
- package/_shared/pr-loop/scripts/stale_worktree_rule_sweep.py +107 -0
- package/_shared/pr-loop/scripts/tests/CLAUDE.md +2 -0
- package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +55 -73
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +9 -15
- package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +91 -1
- package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +88 -1
- package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep.py +301 -0
- package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep_constants.py +85 -0
- package/_shared/pr-loop/worker-spawn.md +1 -1
- package/agents/CLAUDE.md +1 -0
- package/agents/code-verifier.md +4 -4
- package/agents/skill-writer-agent.md +84 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +141 -41
- package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +29 -13
- package/docs/CLAUDE.md +1 -0
- package/docs/references/CLAUDE.md +1 -0
- package/docs/references/code-review-enforcement.md +97 -0
- package/docs/wsl-docker-cowork-starter-matrix.md +89 -0
- package/hooks/blocking/CLAUDE.md +8 -1
- package/hooks/blocking/code_review_enforcement_config_bootstrap.py +53 -0
- package/hooks/blocking/code_review_gate_deny.py +74 -0
- package/hooks/blocking/code_review_pr_create_gate.py +194 -0
- package/hooks/blocking/code_review_push_gate.py +140 -0
- package/hooks/blocking/code_review_stamp_directory_write_blocker.py +340 -0
- package/hooks/blocking/code_review_stamp_store.py +233 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/__init__.py +7 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/conftest.py +15 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/obfuscated_stamp_path_reference.py +212 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/split_directory_change_into_stamp.py +138 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/test_obfuscated_stamp_path_reference.py +49 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/test_split_directory_change_into_stamp.py +38 -0
- package/hooks/blocking/code_verifier_spawn_preflight_gate.py +39 -27
- package/hooks/blocking/config/code_review_enforcement_constants.py +110 -0
- package/hooks/blocking/config/test_code_review_enforcement_constants.py +108 -0
- package/hooks/blocking/config/verified_commit_constants.py +24 -9
- package/hooks/blocking/conftest.py +2 -0
- package/hooks/blocking/convergence_gate_blocker.py +112 -23
- package/hooks/blocking/destructive_command_blocker.py +19 -6
- package/hooks/blocking/pr_description_proof_of_work.py +52 -34
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +4 -1
- package/hooks/blocking/test_code_review_enforcement_config_bootstrap.py +62 -0
- package/hooks/blocking/test_code_review_gate_deny.py +54 -0
- package/hooks/blocking/test_code_review_pr_create_gate.py +185 -0
- package/hooks/blocking/test_code_review_push_gate.py +189 -0
- package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +180 -0
- package/hooks/blocking/test_code_review_stamp_store.py +205 -0
- package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +124 -2
- package/hooks/blocking/test_code_verifier_tools_contract.py +28 -0
- package/hooks/blocking/test_convergence_gate_blocker.py +153 -5
- package/hooks/blocking/test_destructive_command_blocker.py +1 -1
- package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +45 -0
- package/hooks/blocking/test_pr_description_proof_of_work.py +151 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +8 -8
- package/hooks/blocking/test_verification_verdict_store.py +920 -810
- package/hooks/blocking/test_volatile_path_in_post_blocker.py +114 -2
- package/hooks/blocking/verification_verdict_store.py +118 -5
- package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +29 -17
- package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +35 -0
- package/hooks/blocking/volatile_path_in_post_blocker.py +69 -8
- package/hooks/git-hooks/git_hooks_constants/__init__.py +6 -0
- package/hooks/git-hooks/pre_push.py +89 -2
- package/hooks/git-hooks/test_pre_push.py +103 -0
- package/hooks/hooks.json +16 -1
- package/hooks/hooks_constants/CLAUDE.md +1 -0
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +8 -0
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
- package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +26 -11
- package/hooks/hooks_constants/convergence_gate_blocker_constants.py +20 -3
- package/hooks/hooks_constants/destructive_command_segment_constants.py +3 -1
- package/hooks/hooks_constants/pr_description_proof_of_work_constants.py +0 -4
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
- package/hooks/hooks_constants/pyproject_config_discovery_constants.py +16 -0
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +24 -0
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +6 -0
- package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +8 -1
- package/hooks/validators/CLAUDE.md +1 -0
- package/hooks/validators/mypy_integration.py +63 -50
- package/hooks/validators/pyproject_config_discovery.py +101 -0
- package/hooks/validators/ruff_integration.py +211 -23
- package/hooks/validators/run_all_validators.py +21 -14
- package/hooks/validators/test_mypy_integration.py +32 -0
- package/hooks/validators/test_pyproject_config_discovery.py +94 -0
- package/hooks/validators/test_ruff_integration.py +68 -1
- package/hooks/validators/test_run_all_validators.py +25 -0
- package/hooks/validators/test_run_all_validators_config_discovery.py +123 -0
- package/package.json +1 -1
- package/rules/docstring-prose-matches-implementation.md +20 -43
- package/rules/durable-post-artifacts.md +7 -0
- package/scripts/CLAUDE.md +2 -1
- package/scripts/claude-chain.example.json +15 -3
- package/scripts/claude_chain_runner.py +130 -27
- package/scripts/claude_chain_usage.py +346 -0
- package/scripts/codec_forwarding_test_support.py +83 -0
- package/scripts/conftest.py +8 -0
- package/scripts/dev_env_scripts_constants/CLAUDE.md +4 -3
- package/scripts/dev_env_scripts_constants/claude_chain_constants.py +57 -2
- package/scripts/dev_env_scripts_constants/claude_chain_usage_constants.py +58 -0
- package/scripts/dev_env_scripts_constants/code_review_constants.py +129 -12
- package/scripts/dev_env_scripts_constants/test_code_review_constants.py +55 -0
- package/scripts/dev_env_scripts_constants/timing.py +1 -1
- package/scripts/invoke_code_review.py +550 -38
- package/scripts/resolve_worker_spawn.py +8 -1
- package/scripts/test_claude_chain_runner.py +412 -6
- package/scripts/test_claude_chain_usage.py +534 -0
- package/scripts/test_invoke_code_review.py +298 -4
- package/scripts/test_invoke_code_review_codec.py +77 -0
- package/scripts/test_resolve_worker_spawn_codec.py +101 -0
- package/skills/autoconverge/SKILL.md +9 -3
- package/skills/autoconverge/reference/convergence.md +2 -1
- package/skills/autoconverge/reference/multi-pr.md +6 -1
- package/skills/autoconverge/reference/stop-conditions.md +16 -10
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +1 -1
- package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +175 -3
- package/skills/autoconverge/workflow/converge.contract.test.mjs +19 -0
- package/skills/autoconverge/workflow/converge.mjs +24 -8
- package/skills/autoconverge/workflow/converge_multi.mjs +7 -3
- package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +5 -0
- package/skills/fresh-branch/CLAUDE.md +2 -0
- package/skills/fresh-branch/SKILL.md +2 -0
- package/skills/fresh-branch/scripts/create_fresh_branch.py +78 -180
- package/skills/fresh-branch/scripts/fresh_branch_git_commands.py +285 -0
- package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -0
- package/skills/fresh-branch/scripts/pytest.ini +4 -0
- package/skills/fresh-branch/scripts/test_create_fresh_branch.py +98 -0
- package/skills/fresh-branch/scripts/test_fresh_branch_git_commands.py +310 -0
- package/skills/orchestrator/SKILL.md +1 -20
- package/skills/orchestrator-refresh/SKILL.md +1 -1
- package/skills/pr-converge/SKILL.md +3 -3
- package/skills/pr-converge/reference/examples.md +3 -3
- package/skills/pr-converge/reference/fix-protocol.md +1 -1
- package/skills/pr-converge/reference/ground-rules.md +3 -3
- package/skills/pr-converge/reference/per-tick.md +5 -5
- package/skills/pr-converge/reference/progress-checklist.md +1 -1
- package/skills/pr-converge/test_step5_host_branch.py +1 -1
- package/skills/prototype/SKILL.md +86 -0
- package/skills/prototype/reference/honest-limitations.md +23 -0
- package/skills/prototype/reference/promotion-tasks.md +23 -0
- package/skills/prototype/scripts/build_sandbox_settings.py +249 -0
- package/skills/prototype/scripts/conftest.py +15 -0
- package/skills/prototype/scripts/launch_sandbox.py +205 -0
- package/skills/prototype/scripts/probe_sandbox_safety.py +311 -0
- package/skills/prototype/scripts/prototype_scripts_constants/__init__.py +1 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/__init__.py +0 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/build_sandbox_settings_constants.py +41 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/launch_sandbox_constants.py +23 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/probe_sandbox_safety_constants.py +45 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/prototype_common_constants.py +10 -0
- package/skills/prototype/scripts/test_build_sandbox_settings.py +275 -0
- package/skills/prototype/scripts/test_launch_sandbox.py +303 -0
- package/skills/prototype/scripts/test_probe_sandbox_safety.py +284 -0
- package/skills/prototype/workflows/promotion.md +27 -0
- package/skills/prototype/workflows/sandbox.md +35 -0
- package/skills/skill-builder/CLAUDE.md +3 -3
- package/skills/skill-builder/SKILL.md +5 -5
- package/skills/skill-builder/references/CLAUDE.md +1 -1
- package/skills/skill-builder/references/delegation-map.md +3 -3
- package/skills/skill-builder/references/description-field.md +1 -1
- package/skills/skill-builder/references/skill-modularity.md +2 -3
- package/skills/skill-builder/workflows/CLAUDE.md +1 -1
- package/skills/skill-builder/workflows/improve-skill.md +1 -1
- package/skills/skill-builder/workflows/new-skill.md +2 -2
- package/skills/team-advisor/SKILL.md +5 -4
package/hooks/blocking/code_review_stamp_write_blocker_parts/obfuscated_stamp_path_reference.py
ADDED
|
@@ -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)
|
package/hooks/blocking/code_review_stamp_write_blocker_parts/split_directory_change_into_stamp.py
ADDED
|
@@ -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
|
+
)
|
package/hooks/blocking/code_review_stamp_write_blocker_parts/test_obfuscated_stamp_path_reference.py
ADDED
|
@@ -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
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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``
|
|
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", "")
|
|
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
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
changed file caused a non-zero gate
|
|
312
|
-
|
|
313
|
-
harness hook timeout in hooks.json is the wall-clock
|
|
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
|
|
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:
|
|
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(
|
|
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)
|