claude-dev-env 1.71.0 → 1.73.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 +8 -0
- package/_shared/pr-loop/scripts/code_rules_gate.py +5 -3
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +39 -0
- package/agents/clean-coder.md +1 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +2 -2
- package/bin/install.mjs +73 -5
- package/bin/install.test.mjs +360 -4
- package/docs/CODE_RULES.md +1 -1
- package/hooks/blocking/CLAUDE.md +3 -1
- package/hooks/blocking/claude_md_orphan_file_blocker.py +5 -6
- package/hooks/blocking/code_rules_dead_config_field.py +69 -56
- package/hooks/blocking/code_rules_docstrings.py +676 -0
- package/hooks/blocking/code_rules_enforcer.py +26 -0
- package/hooks/blocking/code_rules_shared.py +19 -0
- package/hooks/blocking/code_rules_test_assertions.py +152 -1
- package/hooks/blocking/code_rules_type_escape.py +447 -2
- package/hooks/blocking/code_verifier_spawn_preflight_gate.py +420 -0
- package/hooks/blocking/md_to_html_blocker.py +7 -8
- package/hooks/blocking/open_questions_in_plans_blocker.py +5 -6
- package/hooks/blocking/plain_language_blocker.py +51 -16
- package/hooks/blocking/pr_converge_bugteam_enforcer.py +5 -5
- package/hooks/blocking/pre_tool_use_dispatcher.py +545 -0
- package/hooks/blocking/pytest_testpaths_orphan_blocker.py +358 -0
- package/hooks/blocking/state_description_blocker.py +75 -36
- package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +81 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_inline_literal_claim.py +93 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_no_consumer.py +93 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_step_dispatch.py +262 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_undefined_constant.py +253 -0
- package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +279 -0
- package/hooks/blocking/test_code_rules_enforcer_object_parameter.py +499 -0
- package/hooks/blocking/test_code_rules_enforcer_stale_test_name.py +103 -0
- package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +456 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +816 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +341 -0
- package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +247 -0
- package/hooks/blocking/test_shared_stdin_adoption.py +166 -0
- package/hooks/blocking/verdict_directory_write_blocker.py +12 -7
- package/hooks/hooks.json +9 -79
- package/hooks/hooks_constants/CLAUDE.md +3 -1
- package/hooks/hooks_constants/blocking_check_limits.py +75 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +6 -0
- package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +45 -0
- package/hooks/hooks_constants/dead_config_field_constants.py +5 -5
- package/hooks/hooks_constants/mypy_validator_cache_constants.py +36 -0
- package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +69 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +135 -0
- package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
- package/hooks/hooks_constants/pytest_testpaths_orphan_blocker_constants.py +79 -0
- package/hooks/validation/mypy_validator.py +215 -17
- package/hooks/validation/post_tool_use_dispatcher.py +344 -0
- package/hooks/validation/test_mypy_validator.py +184 -1
- package/hooks/validation/test_post_tool_use_dispatcher.py +610 -0
- package/hooks/workflow/test_auto_formatter.py +10 -9
- package/package.json +1 -1
- package/rules/docstring-prose-matches-implementation.md +3 -2
- package/scripts/CLAUDE.md +1 -0
- package/scripts/Show-Asset.ps1 +106 -0
- package/skills/autoconverge/SKILL.md +123 -3
- package/skills/autoconverge/reference/convergence.md +41 -1
- package/skills/autoconverge/workflow/converge.contract.test.mjs +90 -0
- package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +98 -0
- package/skills/autoconverge/workflow/converge.mjs +203 -8
- package/skills/autoconverge/workflow/converge.path-aware.test.mjs +47 -0
- package/skills/autoconverge/workflow/converge_multi.mjs +161 -0
- package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +100 -0
- package/skills/bugteam/scripts/bugteam_code_rules_gate.py +47 -3
- package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +34 -0
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse hook: pre-flight gate for the code-verifier subagent spawn.
|
|
3
|
+
|
|
4
|
+
The hook fires only on an ``Agent`` tool call whose ``subagent_type`` is
|
|
5
|
+
``code-verifier``. Before that verification spawn runs, the hook checks the
|
|
6
|
+
branch for two committability problems against the resolved base ref: a real
|
|
7
|
+
merge conflict (a non-mutating trial-merge of HEAD against the base ref) and a
|
|
8
|
+
CODE_RULES violation on a line added in the uncommitted working tree. When
|
|
9
|
+
either fires, the hook denies the spawn with a reason addressed to the spawning
|
|
10
|
+
agent that names the conflicting files and the violating file:line, so that
|
|
11
|
+
agent fixes them and re-spawns. Both checks fail OPEN on any infrastructure
|
|
12
|
+
problem — a non-repo cwd, an absent base ref, a git or engine failure, or a
|
|
13
|
+
timeout — because the authoritative fail-closed CODE_RULES enforcement already
|
|
14
|
+
runs at Write time and at commit time. The hook never network-fetches and never
|
|
15
|
+
mutates the index or working tree.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import contextlib
|
|
21
|
+
import io
|
|
22
|
+
import json
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import TextIO
|
|
27
|
+
|
|
28
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
29
|
+
if _hooks_dir not in sys.path:
|
|
30
|
+
sys.path.insert(0, _hooks_dir)
|
|
31
|
+
|
|
32
|
+
_blocking_dir = str(Path(__file__).resolve().parent)
|
|
33
|
+
if _blocking_dir not in sys.path:
|
|
34
|
+
sys.path.insert(0, _blocking_dir)
|
|
35
|
+
|
|
36
|
+
from verification_verdict_store import ( # noqa: E402
|
|
37
|
+
candidate_base_references,
|
|
38
|
+
resolve_merge_base,
|
|
39
|
+
resolve_repo_root,
|
|
40
|
+
run_git,
|
|
41
|
+
untracked_file_paths,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
from hooks_constants.code_verifier_spawn_preflight_gate_constants import ( # noqa: E402
|
|
45
|
+
ALL_MERGE_TREE_COMMAND_FLAGS,
|
|
46
|
+
ALL_NAME_ONLY_WORKTREE_DIFF_FLAGS,
|
|
47
|
+
ALL_UNIFIED_ZERO_DIFF_FLAGS,
|
|
48
|
+
CODE_RULES_SECTION_HEADER,
|
|
49
|
+
CODE_VERIFIER_SUBAGENT_TYPE,
|
|
50
|
+
DENY_REASON_LEAD,
|
|
51
|
+
GATE_SCRIPTS_RELATIVE_PATH,
|
|
52
|
+
MERGE_CONFLICT_SECTION_HEADER,
|
|
53
|
+
MERGE_TREE_CLEAN_EXIT_CODE,
|
|
54
|
+
MERGE_TREE_CONFLICT_EXIT_CODE,
|
|
55
|
+
MERGE_TREE_TIMEOUT_SECONDS,
|
|
56
|
+
)
|
|
57
|
+
from hooks_constants.pr_converge_bugteam_enforcer_constants import ( # noqa: E402
|
|
58
|
+
AGENT_TOOL_NAME,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
_scripts_dir = str(Path(__file__).resolve().parents[2] / GATE_SCRIPTS_RELATIVE_PATH)
|
|
62
|
+
if _scripts_dir not in sys.path:
|
|
63
|
+
sys.path.insert(0, _scripts_dir)
|
|
64
|
+
|
|
65
|
+
from code_rules_gate import ( # noqa: E402
|
|
66
|
+
ValidateContentCallable,
|
|
67
|
+
_collect_partitioned_violations,
|
|
68
|
+
_report_partitioned_violations,
|
|
69
|
+
is_code_path,
|
|
70
|
+
load_validate_content,
|
|
71
|
+
parse_added_line_numbers,
|
|
72
|
+
whole_file_line_set,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _should_run(payload_by_field: dict[str, object]) -> bool:
|
|
77
|
+
"""Return True only for a code-verifier Agent spawn.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
payload_by_field: The full PreToolUse hook payload (already
|
|
81
|
+
JSON-parsed), keyed by top-level field name.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
True when the tool is Agent and ``tool_input.subagent_type`` is
|
|
85
|
+
``code-verifier``; False for every other shape.
|
|
86
|
+
"""
|
|
87
|
+
if payload_by_field.get("tool_name", "") != AGENT_TOOL_NAME:
|
|
88
|
+
return False
|
|
89
|
+
tool_input = payload_by_field.get("tool_input", {})
|
|
90
|
+
if not isinstance(tool_input, dict):
|
|
91
|
+
return False
|
|
92
|
+
return tool_input.get("subagent_type", "") == CODE_VERIFIER_SUBAGENT_TYPE
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _resolve_repo_root_and_base(working_directory: str | None) -> tuple[str, str, str] | None:
|
|
96
|
+
"""Resolve the repo root, merge-base sha, and chosen base ref.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
working_directory: The spawn's working directory from the payload, or
|
|
100
|
+
None when the payload carries no ``cwd``.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
A ``(repo_root, merge_base_sha, base_ref)`` triple, or None when the
|
|
104
|
+
directory is not a work tree or no base ref resolves on disk — the
|
|
105
|
+
caller fails OPEN on None.
|
|
106
|
+
"""
|
|
107
|
+
start_directory = working_directory if working_directory else str(Path.cwd())
|
|
108
|
+
repo_root = resolve_repo_root(start_directory)
|
|
109
|
+
if repo_root is None:
|
|
110
|
+
return None
|
|
111
|
+
merge_base_sha = resolve_merge_base(repo_root)
|
|
112
|
+
if merge_base_sha is None:
|
|
113
|
+
return None
|
|
114
|
+
for each_reference in candidate_base_references(repo_root):
|
|
115
|
+
if run_git(repo_root, "merge-base", "HEAD", each_reference) is not None:
|
|
116
|
+
return repo_root, merge_base_sha, each_reference
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _run_trial_merge(repo_root: str, base_ref: str) -> tuple[int, str] | None:
|
|
121
|
+
"""Run the non-mutating trial-merge and return its exit code and stdout.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
repo_root: The repository top-level directory.
|
|
125
|
+
base_ref: The base ref to trial-merge HEAD against.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
A ``(returncode, stdout)`` pair, or None when the command is missing,
|
|
129
|
+
times out, or raises an OS-level error — the caller fails OPEN on None.
|
|
130
|
+
"""
|
|
131
|
+
try:
|
|
132
|
+
completed_process = subprocess.run(
|
|
133
|
+
["git", "-C", repo_root, *ALL_MERGE_TREE_COMMAND_FLAGS, base_ref, "HEAD"],
|
|
134
|
+
capture_output=True,
|
|
135
|
+
text=True,
|
|
136
|
+
encoding="utf-8",
|
|
137
|
+
errors="replace",
|
|
138
|
+
timeout=MERGE_TREE_TIMEOUT_SECONDS,
|
|
139
|
+
check=False,
|
|
140
|
+
)
|
|
141
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
142
|
+
return None
|
|
143
|
+
return completed_process.returncode, completed_process.stdout
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _conflicting_files(repo_root: str, base_ref: str) -> list[str] | None:
|
|
147
|
+
"""Return the files that conflict when HEAD trial-merges against the base.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
repo_root: The repository top-level directory.
|
|
151
|
+
base_ref: The base ref to trial-merge HEAD against.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
The conflicting file paths on a conflict exit, an empty list on a
|
|
155
|
+
clean merge, or None on any infrastructure failure (command missing,
|
|
156
|
+
timeout, or an exit code that is neither clean nor conflict) — the
|
|
157
|
+
caller fails OPEN on None.
|
|
158
|
+
"""
|
|
159
|
+
merge_outcome = _run_trial_merge(repo_root, base_ref)
|
|
160
|
+
if merge_outcome is None:
|
|
161
|
+
return None
|
|
162
|
+
return_code, merge_stdout = merge_outcome
|
|
163
|
+
if return_code == MERGE_TREE_CLEAN_EXIT_CODE:
|
|
164
|
+
return []
|
|
165
|
+
if return_code != MERGE_TREE_CONFLICT_EXIT_CODE:
|
|
166
|
+
return None
|
|
167
|
+
return _parse_conflicting_paths(merge_stdout)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _parse_conflicting_paths(merge_stdout: str) -> list[str]:
|
|
171
|
+
"""Extract conflicting paths from trial-merge stdout.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
merge_stdout: The stdout of a conflict-exit trial-merge: the written
|
|
175
|
+
tree-OID line, then one conflicting path per line, then a blank
|
|
176
|
+
line and informational text.
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
The conflicting file paths — the lines after the tree-OID up to the
|
|
180
|
+
first blank line.
|
|
181
|
+
"""
|
|
182
|
+
all_lines = merge_stdout.splitlines()
|
|
183
|
+
conflicting_paths: list[str] = []
|
|
184
|
+
for each_line in all_lines[1:]:
|
|
185
|
+
if not each_line.strip():
|
|
186
|
+
break
|
|
187
|
+
conflicting_paths.append(each_line)
|
|
188
|
+
return conflicting_paths
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _working_tree_added_lines_by_path(
|
|
192
|
+
repo_root: str, merge_base_sha: str
|
|
193
|
+
) -> tuple[list[Path], dict[Path, set[int]]] | None:
|
|
194
|
+
"""Build the code-file surface and its working-tree-vs-merge-base added lines.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
repo_root: The repository top-level directory.
|
|
198
|
+
merge_base_sha: The merge-base sha the surface diffs against.
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
A ``(file_paths, added_lines_by_path)`` pair keyed by resolved
|
|
202
|
+
absolute path, or None when a surface git query fails — the caller
|
|
203
|
+
fails OPEN on None.
|
|
204
|
+
"""
|
|
205
|
+
tracked_changed_text = run_git(repo_root, *ALL_NAME_ONLY_WORKTREE_DIFF_FLAGS, merge_base_sha)
|
|
206
|
+
if tracked_changed_text is None:
|
|
207
|
+
return None
|
|
208
|
+
untracked_paths = untracked_file_paths(repo_root)
|
|
209
|
+
if untracked_paths is None:
|
|
210
|
+
return None
|
|
211
|
+
repo_root_path = Path(repo_root)
|
|
212
|
+
file_paths: list[Path] = []
|
|
213
|
+
added_lines_by_path: dict[Path, set[int]] = {}
|
|
214
|
+
for each_relative in tracked_changed_text.splitlines():
|
|
215
|
+
if not each_relative or not is_code_path(Path(each_relative)):
|
|
216
|
+
continue
|
|
217
|
+
resolved_path = (repo_root_path / each_relative).resolve()
|
|
218
|
+
file_paths.append(resolved_path)
|
|
219
|
+
added_lines_by_path[resolved_path] = _tracked_file_added_lines(
|
|
220
|
+
repo_root, merge_base_sha, each_relative
|
|
221
|
+
)
|
|
222
|
+
for each_relative in untracked_paths:
|
|
223
|
+
if not is_code_path(Path(each_relative)):
|
|
224
|
+
continue
|
|
225
|
+
resolved_path = (repo_root_path / each_relative).resolve()
|
|
226
|
+
file_paths.append(resolved_path)
|
|
227
|
+
added_lines_by_path[resolved_path] = whole_file_line_set(resolved_path)
|
|
228
|
+
return file_paths, added_lines_by_path
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _tracked_file_added_lines(repo_root: str, merge_base_sha: str, relative_path: str) -> set[int]:
|
|
232
|
+
"""Return the working-tree-added line numbers for one tracked file.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
repo_root: The repository top-level directory.
|
|
236
|
+
merge_base_sha: The merge-base sha the diff runs against.
|
|
237
|
+
relative_path: The repo-relative path of the tracked changed file.
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
The 1-indexed line numbers added vs the merge base in the working
|
|
241
|
+
tree, or an empty set when the per-file diff fails.
|
|
242
|
+
"""
|
|
243
|
+
unified_diff_text = run_git(
|
|
244
|
+
repo_root, *ALL_UNIFIED_ZERO_DIFF_FLAGS, merge_base_sha, "--", relative_path
|
|
245
|
+
)
|
|
246
|
+
if unified_diff_text is None:
|
|
247
|
+
return set()
|
|
248
|
+
return parse_added_line_numbers(unified_diff_text)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _code_rules_report(
|
|
252
|
+
repo_root: str, all_file_paths: list[Path], all_added_lines_by_path: dict[Path, set[int]]
|
|
253
|
+
) -> str | None:
|
|
254
|
+
"""Run the CODE_RULES engine and return its blocking report, or None.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
repo_root: The repository top-level directory.
|
|
258
|
+
all_file_paths: The resolved code-file paths to inspect.
|
|
259
|
+
all_added_lines_by_path: Per-file working-tree-added line numbers keyed
|
|
260
|
+
by resolved absolute path.
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
The engine's grouped file:line report when a blocking violation lands
|
|
264
|
+
on an added line, or None when the surface is clean, only an unreadable
|
|
265
|
+
changed file caused a non-zero gate exit, the engine fails to load, or
|
|
266
|
+
any engine error arises — every non-block outcome fails OPEN. The
|
|
267
|
+
harness hook timeout in hooks.json is the wall-clock bound on a runaway
|
|
268
|
+
engine.
|
|
269
|
+
"""
|
|
270
|
+
if not all_file_paths:
|
|
271
|
+
return None
|
|
272
|
+
try:
|
|
273
|
+
validate_content = load_validate_content()
|
|
274
|
+
except SystemExit:
|
|
275
|
+
return None
|
|
276
|
+
try:
|
|
277
|
+
blocking_present, captured_report = _run_gate_capturing_stderr(
|
|
278
|
+
validate_content, all_file_paths, Path(repo_root), all_added_lines_by_path
|
|
279
|
+
)
|
|
280
|
+
except OSError:
|
|
281
|
+
return None
|
|
282
|
+
if not blocking_present:
|
|
283
|
+
return None
|
|
284
|
+
return captured_report
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _run_gate_capturing_stderr(
|
|
288
|
+
validate_content: ValidateContentCallable,
|
|
289
|
+
all_file_paths: list[Path],
|
|
290
|
+
repository_root: Path,
|
|
291
|
+
all_added_lines_by_path: dict[Path, set[int]],
|
|
292
|
+
) -> tuple[bool, str]:
|
|
293
|
+
"""Run the gate, reporting whether a blocking violation was actually found.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
validate_content: The enforcer ``validate_content`` callable.
|
|
297
|
+
all_file_paths: The resolved code-file paths to inspect.
|
|
298
|
+
repository_root: The repository root path the gate resolves against.
|
|
299
|
+
all_added_lines_by_path: Per-file working-tree-added line numbers keyed
|
|
300
|
+
by resolved absolute path.
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
A ``(blocking_present, captured_report)`` pair. ``blocking_present`` is
|
|
304
|
+
True only when at least one blocking violation landed on an added line;
|
|
305
|
+
an unreadable changed file alone (which exits the gate non-zero) leaves
|
|
306
|
+
it False, so the caller fails OPEN. ``captured_report`` is the gate's
|
|
307
|
+
grouped stderr report.
|
|
308
|
+
"""
|
|
309
|
+
blocking_by_file, advisory_by_file, skipped_unreadable_count = (
|
|
310
|
+
_collect_partitioned_violations(
|
|
311
|
+
validate_content,
|
|
312
|
+
all_file_paths,
|
|
313
|
+
repository_root,
|
|
314
|
+
all_added_lines_by_path,
|
|
315
|
+
False,
|
|
316
|
+
)
|
|
317
|
+
)
|
|
318
|
+
captured_stderr = io.StringIO()
|
|
319
|
+
with contextlib.redirect_stderr(captured_stderr):
|
|
320
|
+
_report_partitioned_violations(
|
|
321
|
+
blocking_by_file,
|
|
322
|
+
advisory_by_file,
|
|
323
|
+
repository_root,
|
|
324
|
+
False,
|
|
325
|
+
skipped_unreadable_count,
|
|
326
|
+
)
|
|
327
|
+
return bool(blocking_by_file), captured_stderr.getvalue()
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _build_deny_reason(
|
|
331
|
+
all_conflicting_files: list[str] | None, base_ref: str, code_rules_report: str | None
|
|
332
|
+
) -> str | None:
|
|
333
|
+
"""Assemble the spawner-addressed deny reason from the two check results.
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
all_conflicting_files: The conflicting file paths from the conflict
|
|
337
|
+
check, an empty list when clean, or None when that check failed open.
|
|
338
|
+
base_ref: The base ref named in the conflict section header.
|
|
339
|
+
code_rules_report: The grouped report from the CODE_RULES check, or None
|
|
340
|
+
when that check found nothing or failed open.
|
|
341
|
+
|
|
342
|
+
Returns:
|
|
343
|
+
The full deny reason when either check fired, or None when neither
|
|
344
|
+
produced an issue.
|
|
345
|
+
"""
|
|
346
|
+
reason_sections: list[str] = []
|
|
347
|
+
if all_conflicting_files:
|
|
348
|
+
conflict_lines = "\n".join(f" {each_path}" for each_path in all_conflicting_files)
|
|
349
|
+
conflict_header = MERGE_CONFLICT_SECTION_HEADER.format(base_ref=base_ref)
|
|
350
|
+
reason_sections.append(f"{conflict_header}\n{conflict_lines}")
|
|
351
|
+
if code_rules_report:
|
|
352
|
+
reason_sections.append(f"{CODE_RULES_SECTION_HEADER}\n{code_rules_report.strip()}")
|
|
353
|
+
if not reason_sections:
|
|
354
|
+
return None
|
|
355
|
+
return DENY_REASON_LEAD + "\n\n" + "\n\n".join(reason_sections)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _emit_deny_payload(output_stream: TextIO, reason: str) -> None:
|
|
359
|
+
"""Write the PreToolUse deny payload to the provided stream.
|
|
360
|
+
|
|
361
|
+
Args:
|
|
362
|
+
output_stream: Writable text stream — production code passes
|
|
363
|
+
``sys.stdout``; tests pass a ``StringIO`` to capture the JSON.
|
|
364
|
+
reason: The ``permissionDecisionReason`` text for the deny payload.
|
|
365
|
+
"""
|
|
366
|
+
deny_payload = {
|
|
367
|
+
"hookSpecificOutput": {
|
|
368
|
+
"hookEventName": "PreToolUse",
|
|
369
|
+
"permissionDecision": "deny",
|
|
370
|
+
"permissionDecisionReason": reason,
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
output_stream.write(json.dumps(deny_payload) + "\n")
|
|
374
|
+
output_stream.flush()
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _preflight_deny_reason(payload_by_field: dict[str, object]) -> str | None:
|
|
378
|
+
"""Run both pre-flight checks and return a deny reason, or None to allow.
|
|
379
|
+
|
|
380
|
+
Args:
|
|
381
|
+
payload_by_field: The full PreToolUse hook payload, keyed by top-level
|
|
382
|
+
field name.
|
|
383
|
+
|
|
384
|
+
Returns:
|
|
385
|
+
The deny reason when a check fired, or None when both checks pass or
|
|
386
|
+
fail open.
|
|
387
|
+
"""
|
|
388
|
+
working_directory = payload_by_field.get("cwd")
|
|
389
|
+
resolution = _resolve_repo_root_and_base(
|
|
390
|
+
working_directory if isinstance(working_directory, str) else None
|
|
391
|
+
)
|
|
392
|
+
if resolution is None:
|
|
393
|
+
return None
|
|
394
|
+
repo_root, merge_base_sha, base_ref = resolution
|
|
395
|
+
conflicting_files = _conflicting_files(repo_root, base_ref)
|
|
396
|
+
surface = _working_tree_added_lines_by_path(repo_root, merge_base_sha)
|
|
397
|
+
code_rules_report = None
|
|
398
|
+
if surface is not None:
|
|
399
|
+
file_paths, added_lines_by_path = surface
|
|
400
|
+
code_rules_report = _code_rules_report(repo_root, file_paths, added_lines_by_path)
|
|
401
|
+
return _build_deny_reason(conflicting_files, base_ref, code_rules_report)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def main() -> None:
|
|
405
|
+
try:
|
|
406
|
+
hook_payload = json.load(sys.stdin)
|
|
407
|
+
except json.JSONDecodeError:
|
|
408
|
+
sys.exit(0)
|
|
409
|
+
if not isinstance(hook_payload, dict):
|
|
410
|
+
sys.exit(0)
|
|
411
|
+
if not _should_run(hook_payload):
|
|
412
|
+
sys.exit(0)
|
|
413
|
+
deny_reason = _preflight_deny_reason(hook_payload)
|
|
414
|
+
if deny_reason is not None:
|
|
415
|
+
_emit_deny_payload(sys.stdout, deny_reason)
|
|
416
|
+
sys.exit(0)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
if __name__ == "__main__":
|
|
420
|
+
main()
|
|
@@ -18,6 +18,8 @@ _blocking_directory = str(Path(__file__).resolve().parent)
|
|
|
18
18
|
if _blocking_directory not in sys.path:
|
|
19
19
|
sys.path.insert(0, _blocking_directory)
|
|
20
20
|
|
|
21
|
+
from md_path_exemptions import is_exempt_path # noqa: E402
|
|
22
|
+
|
|
21
23
|
from hooks_constants.md_to_html_blocker_constants import ( # noqa: E402
|
|
22
24
|
ALL_CLAUDE_CODE_SOURCE_TOP_DIRECTORIES,
|
|
23
25
|
ALL_EXEMPT_ANYWHERE_FILENAMES,
|
|
@@ -29,8 +31,9 @@ from hooks_constants.md_to_html_blocker_constants import ( # noqa: E402
|
|
|
29
31
|
PACKAGES_TOP_LEVEL_SEGMENT,
|
|
30
32
|
PLUGIN_ROOT_MARKER_DIRECTORY_NAME,
|
|
31
33
|
)
|
|
32
|
-
from
|
|
33
|
-
|
|
34
|
+
from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
|
|
35
|
+
read_hook_input_dictionary_from_stdin,
|
|
36
|
+
)
|
|
34
37
|
|
|
35
38
|
_markdown_extension = ".md"
|
|
36
39
|
_html_effectiveness_url = "https://thariqs.github.io/html-effectiveness/"
|
|
@@ -95,12 +98,8 @@ def main() -> None:
|
|
|
95
98
|
Returns:
|
|
96
99
|
None (exits process).
|
|
97
100
|
"""
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
except json.JSONDecodeError:
|
|
101
|
-
sys.exit(0)
|
|
102
|
-
|
|
103
|
-
if not isinstance(input_data, dict):
|
|
101
|
+
input_data = read_hook_input_dictionary_from_stdin()
|
|
102
|
+
if input_data is None:
|
|
104
103
|
sys.exit(0)
|
|
105
104
|
|
|
106
105
|
tool_name = input_data.get("tool_name", "")
|
|
@@ -31,6 +31,9 @@ from hooks_constants.open_questions_in_plans_blocker_constants import ( # noqa:
|
|
|
31
31
|
PLANS_PATH_SEGMENT,
|
|
32
32
|
UNREADABLE_FILE_SYNTHETIC_CONTENT,
|
|
33
33
|
)
|
|
34
|
+
from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
|
|
35
|
+
read_hook_input_dictionary_from_stdin,
|
|
36
|
+
)
|
|
34
37
|
|
|
35
38
|
|
|
36
39
|
def _is_markdown_file(file_path: str) -> bool:
|
|
@@ -204,12 +207,8 @@ def _emit_hook_result(payload: dict, output_stream: TextIO) -> None:
|
|
|
204
207
|
|
|
205
208
|
|
|
206
209
|
def main() -> None:
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
except json.JSONDecodeError:
|
|
210
|
-
sys.exit(0)
|
|
211
|
-
|
|
212
|
-
if not isinstance(input_data, dict):
|
|
210
|
+
input_data = read_hook_input_dictionary_from_stdin()
|
|
211
|
+
if input_data is None:
|
|
213
212
|
sys.exit(0)
|
|
214
213
|
|
|
215
214
|
tool_name = input_data.get("tool_name", "")
|
|
@@ -140,20 +140,64 @@ def _collect_prose_for_tool(tool_name: str, tool_input: dict) -> str:
|
|
|
140
140
|
return ""
|
|
141
141
|
|
|
142
142
|
|
|
143
|
-
def
|
|
144
|
-
|
|
143
|
+
def build_deny_payload(deny_reason: str) -> dict[str, object]:
|
|
144
|
+
"""Build the full deny payload the hook writes for a deny-reason string.
|
|
145
|
+
|
|
146
|
+
The payload carries the core permission decision plus the user-facing notice
|
|
147
|
+
and output suppression, so a caller routing this hook through a dispatcher
|
|
148
|
+
reproduces the same deny shape the standalone hook writes.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
deny_reason: The permissionDecisionReason text for the denial.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
The deny payload dictionary the hook serializes to stdout.
|
|
155
|
+
"""
|
|
156
|
+
return {
|
|
145
157
|
"hookSpecificOutput": {
|
|
146
158
|
"hookEventName": "PreToolUse",
|
|
147
159
|
"permissionDecision": "deny",
|
|
148
|
-
"permissionDecisionReason":
|
|
160
|
+
"permissionDecisionReason": deny_reason,
|
|
149
161
|
},
|
|
150
162
|
"systemMessage": USER_FACING_PLAIN_LANGUAGE_NOTICE,
|
|
151
163
|
"suppressOutput": True,
|
|
152
164
|
}
|
|
153
|
-
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _emit_deny(deny_reason: str, output_stream: TextIO) -> None:
|
|
168
|
+
output_stream.write(json.dumps(build_deny_payload(deny_reason)))
|
|
154
169
|
output_stream.flush()
|
|
155
170
|
|
|
156
171
|
|
|
172
|
+
def evaluate(payload_by_key: dict[str, object]) -> str | None:
|
|
173
|
+
"""Decide whether a payload's prose carries heavy words to block.
|
|
174
|
+
|
|
175
|
+
Collects the prose for the payload's tool, scans it for banned terms, and
|
|
176
|
+
returns the deny-reason text when any heavy word is found, or None to allow.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
payload_by_key: The PreToolUse payload with tool_name and tool_input.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
The permissionDecisionReason text when the prose is denied, or None when
|
|
183
|
+
the prose is allowed.
|
|
184
|
+
"""
|
|
185
|
+
raw_tool_name = payload_by_key.get("tool_name", "")
|
|
186
|
+
raw_tool_input = payload_by_key.get("tool_input", {})
|
|
187
|
+
if not isinstance(raw_tool_name, str) or not isinstance(raw_tool_input, dict):
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
prose_text = _collect_prose_for_tool(raw_tool_name, raw_tool_input)
|
|
191
|
+
if not prose_text:
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
all_matches = find_banned_terms(prose_text)
|
|
195
|
+
if not all_matches:
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
return build_block_reason(all_matches)
|
|
199
|
+
|
|
200
|
+
|
|
157
201
|
def main() -> None:
|
|
158
202
|
try:
|
|
159
203
|
input_data = json.load(sys.stdin)
|
|
@@ -163,20 +207,11 @@ def main() -> None:
|
|
|
163
207
|
if not isinstance(input_data, dict):
|
|
164
208
|
sys.exit(0)
|
|
165
209
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
if not isinstance(tool_name, str) or not isinstance(tool_input, dict):
|
|
169
|
-
sys.exit(0)
|
|
170
|
-
|
|
171
|
-
prose_text = _collect_prose_for_tool(tool_name, tool_input)
|
|
172
|
-
if not prose_text:
|
|
173
|
-
sys.exit(0)
|
|
174
|
-
|
|
175
|
-
all_matches = find_banned_terms(prose_text)
|
|
176
|
-
if not all_matches:
|
|
210
|
+
deny_reason = evaluate(input_data)
|
|
211
|
+
if deny_reason is None:
|
|
177
212
|
sys.exit(0)
|
|
178
213
|
|
|
179
|
-
_emit_deny(
|
|
214
|
+
_emit_deny(deny_reason, sys.stdout)
|
|
180
215
|
sys.exit(0)
|
|
181
216
|
|
|
182
217
|
|
|
@@ -45,6 +45,9 @@ from hooks_constants.pr_converge_bugteam_enforcer_state import ( # noqa: E402
|
|
|
45
45
|
load_state_dictionary,
|
|
46
46
|
resolve_state_path,
|
|
47
47
|
)
|
|
48
|
+
from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
|
|
49
|
+
read_hook_input_dictionary_from_stdin,
|
|
50
|
+
)
|
|
48
51
|
|
|
49
52
|
|
|
50
53
|
def _prompt_is_audit_shaped(agent_prompt: str) -> bool:
|
|
@@ -148,11 +151,8 @@ def _emit_deny_payload(output_stream: TextIO) -> None:
|
|
|
148
151
|
|
|
149
152
|
|
|
150
153
|
def main() -> None:
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
except json.JSONDecodeError:
|
|
154
|
-
sys.exit(0)
|
|
155
|
-
if not isinstance(hook_payload, dict):
|
|
154
|
+
hook_payload = read_hook_input_dictionary_from_stdin()
|
|
155
|
+
if hook_payload is None:
|
|
156
156
|
sys.exit(0)
|
|
157
157
|
if not _should_block(hook_payload):
|
|
158
158
|
sys.exit(0)
|