claude-dev-env 1.59.0 → 1.60.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/audit-rubrics/category_rubrics/category-b-selector-engine-compat.md +1 -1
- package/audit-rubrics/category_rubrics/category-e-dead-code.md +1 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
- package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
- package/hooks/blocking/code_rules_dead_module_constant.py +321 -0
- package/hooks/blocking/code_rules_duplicate_body.py +152 -0
- package/hooks/blocking/code_rules_enforcer.py +30 -15
- package/hooks/blocking/code_rules_typeddict_stub.py +172 -0
- package/hooks/blocking/config/__init__.py +5 -0
- package/hooks/blocking/config/verified_commit_constants.py +106 -0
- package/hooks/blocking/test_code_rules_enforcer_cross_skill_duplicate.py +146 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +188 -0
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias.py +415 -0
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +156 -0
- package/hooks/blocking/test_verdict_directory_write_blocker.py +720 -0
- package/hooks/blocking/test_verification_verdict_store.py +278 -0
- package/hooks/blocking/test_verified_commit_gate.py +368 -0
- package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -0
- package/hooks/blocking/test_verifier_verdict_minter.py +214 -0
- package/hooks/blocking/verdict_directory_write_blocker.py +667 -0
- package/hooks/blocking/verification_verdict_store.py +446 -0
- package/hooks/blocking/verified_commit_gate.py +523 -0
- package/hooks/blocking/verified_commit_message_accuracy_blocker.py +152 -0
- package/hooks/blocking/verifier_verdict_minter.py +299 -0
- package/hooks/diagnostic/test_hook_log_extractor.py +3 -3
- package/hooks/hooks.json +43 -1
- package/hooks/hooks_constants/blocking_check_limits.py +1 -0
- package/hooks/hooks_constants/dead_module_constant_constants.py +20 -0
- package/hooks/hooks_constants/duplicate_function_body_constants.py +22 -5
- package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
- package/package.json +1 -1
- package/rules/file-global-constants.md +7 -1
- package/rules/no-cross-skill-duplicate-helpers.md +29 -0
- package/skills/_shared/pr-loop/scripts/preflight_worktree.py +392 -0
- package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/preflight_constants.py +70 -0
- package/skills/_shared/pr-loop/scripts/test_preflight_worktree.py +263 -0
- package/skills/autoconverge/SKILL.md +54 -17
- package/skills/autoconverge/reference/closing-report.md +59 -17
- package/skills/autoconverge/workflow/aggregate_runs.py +371 -0
- package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +193 -76
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +76 -0
- package/skills/autoconverge/workflow/converge.contract.test.mjs +206 -206
- package/skills/autoconverge/workflow/converge.mjs +128 -6
- package/skills/autoconverge/workflow/convergence_summary.py +110 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ab1c2d3e4f5a6b7c8.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +7 -0
- package/skills/autoconverge/workflow/render_report.py +488 -397
- package/skills/autoconverge/workflow/test_aggregate_runs.py +134 -0
- package/skills/autoconverge/workflow/test_convergence_summary.py +132 -0
- package/skills/autoconverge/workflow/test_render_report.py +488 -259
- package/skills/pr-converge/reference/per-tick.md +28 -8
- package/skills/rebase/SKILL.md +2 -4
- package/system-prompts/software-engineer.xml +2 -6
- package/hooks/blocking/content_search_to_zoekt_redirector.py +0 -59
- package/hooks/blocking/content_search_zoekt_bash_block_reason.py +0 -25
- package/hooks/blocking/content_search_zoekt_block_payload.py +0 -21
- package/hooks/blocking/content_search_zoekt_indexed_paths.py +0 -24
- package/hooks/blocking/content_search_zoekt_indexed_roots_config.py +0 -131
- package/hooks/blocking/content_search_zoekt_redirect_guidance.py +0 -52
- package/hooks/blocking/test_content_search_to_zoekt_redirector_integration.py +0 -61
- package/hooks/blocking/test_content_search_to_zoekt_redirector_unit.py +0 -92
- package/hooks/blocking/test_content_search_zoekt_indexed_roots_config.py +0 -102
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
"""PreToolUse gate: git commit/push lands only behind a minted verifier verdict.
|
|
2
|
+
|
|
3
|
+
Fires on Bash and PowerShell tool calls. When the command carries a
|
|
4
|
+
``git commit`` or ``git push``, the gate resolves the repository the command
|
|
5
|
+
targets, computes the live change-surface manifest against the merge base,
|
|
6
|
+
and allows the command only when one of these holds:
|
|
7
|
+
|
|
8
|
+
- the repository has no resolvable upstream base — no ``origin/HEAD``, no
|
|
9
|
+
configured tracking ref, and neither ``origin/main`` nor ``origin/master``
|
|
10
|
+
(scratch repos with no remote branch are out of scope),
|
|
11
|
+
- the surface is mechanically exempt (docs/images by extension, pytest
|
|
12
|
+
test files by name convention, Python files whose docstring-stripped
|
|
13
|
+
AST is unchanged), or
|
|
14
|
+
- a verdict minted by ``verifier_verdict_minter.py`` reports ``all_pass``
|
|
15
|
+
and binds to the exact live manifest hash.
|
|
16
|
+
|
|
17
|
+
The surface binds every changed and untracked file's content, so slicing
|
|
18
|
+
work into small commits or staging files cannot move the hash, while any
|
|
19
|
+
content edit or new file after verification invalidates the verdict.
|
|
20
|
+
Verdict files live under ``~/.claude/verification/`` and are minted only by
|
|
21
|
+
the SubagentStop hook when a ``code-verifier`` agent finishes.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
import sys
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
blocking_directory = str(Path(__file__).resolve().parent)
|
|
33
|
+
if blocking_directory not in sys.path:
|
|
34
|
+
sys.path.insert(0, blocking_directory)
|
|
35
|
+
|
|
36
|
+
from config.verified_commit_constants import (
|
|
37
|
+
ALL_GIT_BINARY_NAMES,
|
|
38
|
+
CORRECTIVE_MESSAGE,
|
|
39
|
+
DIRECTORY_CHANGE_OPTION_TERMINATOR,
|
|
40
|
+
DIRECTORY_CHANGE_PATH_OPTIONS,
|
|
41
|
+
DIRECTORY_CHANGE_PATTERN_PREFIX,
|
|
42
|
+
DIRECTORY_CHANGE_PATTERN_SUFFIX,
|
|
43
|
+
DIRECTORY_CHANGE_VERBS,
|
|
44
|
+
GATED_GIT_SUBCOMMANDS,
|
|
45
|
+
ALL_GATED_TOOL_NAMES,
|
|
46
|
+
HASH_PREVIEW_LENGTH,
|
|
47
|
+
OPTION_WITH_VALUE_STEP,
|
|
48
|
+
REPO_DIRECTORY_OPTION,
|
|
49
|
+
VALUE_TAKING_GIT_OPTIONS,
|
|
50
|
+
WORK_TREE_OPTION,
|
|
51
|
+
)
|
|
52
|
+
from verification_verdict_store import (
|
|
53
|
+
branch_surface_manifest,
|
|
54
|
+
is_verification_exempt_diff,
|
|
55
|
+
load_valid_verdict,
|
|
56
|
+
manifest_sha256,
|
|
57
|
+
resolve_merge_base,
|
|
58
|
+
resolve_repo_root,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _collapse_line_continuations(command_text: str) -> str:
|
|
63
|
+
"""Remove backslash-newline line continuations the shell would erase.
|
|
64
|
+
|
|
65
|
+
Bash joins a line continuation — a backslash immediately followed by a
|
|
66
|
+
newline — by deleting both characters, so ``git \\<newline>commit``,
|
|
67
|
+
``git commit\\<newline> -m x``, and ``g\\<newline>it commit`` all run as a
|
|
68
|
+
plain ``git commit``. Stripping the pair before tokenizing makes the token
|
|
69
|
+
stream match what the shell executes, so a continuation abutting the
|
|
70
|
+
subcommand or splitting the ``git`` word cannot evade the gate.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
command_text: The raw command string from the tool payload.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
The command with every backslash-newline pair removed.
|
|
77
|
+
"""
|
|
78
|
+
return re.sub(r"\\\r?\n", "", command_text)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _quoted_spans(command_text: str) -> list[tuple[int, int]]:
|
|
82
|
+
"""Find the character spans of every quoted region in a command.
|
|
83
|
+
|
|
84
|
+
Scans single- and double-quoted runs left to right so a verb sitting
|
|
85
|
+
inside a quoted ``-m`` commit message is recognised as message text
|
|
86
|
+
rather than a real shell directory change.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
command_text: The raw command string from the tool payload.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
The ``(start, end)`` span of each quoted region, in order.
|
|
93
|
+
"""
|
|
94
|
+
quoted_region_pattern = re.compile(r"\"[^\"]*\"|'[^']*'")
|
|
95
|
+
return [
|
|
96
|
+
(each_match.start(), each_match.end())
|
|
97
|
+
for each_match in quoted_region_pattern.finditer(command_text)
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _is_inside_quoted_region(position: int, all_quoted_spans: list[tuple[int, int]]) -> bool:
|
|
102
|
+
"""Decide whether a position falls inside any quoted region.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
position: A character offset into the command string.
|
|
106
|
+
all_quoted_spans: The quoted-region spans from ``_quoted_spans``.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
True when the offset sits within a quoted region's bounds.
|
|
110
|
+
"""
|
|
111
|
+
for each_span_start, each_span_end in all_quoted_spans:
|
|
112
|
+
if each_span_start <= position < each_span_end:
|
|
113
|
+
return True
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _containing_quoted_span(
|
|
118
|
+
position: int, all_quoted_spans: list[tuple[int, int]]
|
|
119
|
+
) -> tuple[int, int] | None:
|
|
120
|
+
"""Return the quoted region a position falls inside, or None.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
position: A character offset into the command string.
|
|
124
|
+
all_quoted_spans: The quoted-region spans from ``_quoted_spans``.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
The ``(start, end)`` span containing the offset, or None when the
|
|
128
|
+
offset sits outside every quoted region.
|
|
129
|
+
"""
|
|
130
|
+
for each_span_start, each_span_end in all_quoted_spans:
|
|
131
|
+
if each_span_start <= position < each_span_end:
|
|
132
|
+
return (each_span_start, each_span_end)
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _git_word_match_gates(
|
|
137
|
+
git_word_match: re.Match[str],
|
|
138
|
+
command_text: str,
|
|
139
|
+
all_quoted_spans: list[tuple[int, int]],
|
|
140
|
+
) -> bool:
|
|
141
|
+
"""Decide whether a ``git`` word match counts as a real invocation.
|
|
142
|
+
|
|
143
|
+
A ``git`` word outside every quoted region always gates. Inside a quoted
|
|
144
|
+
region the word gates only when the region's content, with edge quotes
|
|
145
|
+
stripped, is a path whose final ``[\\/]``-split segment is ``git`` or
|
|
146
|
+
``git.exe`` — a wrapper-quoted binary (``"git" commit``), a quoted
|
|
147
|
+
call-operator path (``& 'C:/x/git.exe' commit``), or a quoted install
|
|
148
|
+
path whose directory components carry spaces
|
|
149
|
+
(``& "C:\\Program Files\\Git\\cmd\\git.exe" commit``). A ``git`` word that
|
|
150
|
+
is one word among prose inside a quoted string — an
|
|
151
|
+
``echo "Next: git commit"`` mention or a ``gh pr comment -b "please git
|
|
152
|
+
commit"`` body — does not gate, because the prose's final path segment is
|
|
153
|
+
the surrounding sentence rather than a bare ``git``/``git.exe`` binary
|
|
154
|
+
name, so the shell never runs that quoted text as a command.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
git_word_match: A ``git`` word match in the command.
|
|
158
|
+
command_text: The raw command string from the tool payload.
|
|
159
|
+
all_quoted_spans: The quoted-region spans from ``_quoted_spans``.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
True when the matched ``git`` word counts as a real git invocation.
|
|
163
|
+
"""
|
|
164
|
+
containing_span = _containing_quoted_span(git_word_match.start(), all_quoted_spans)
|
|
165
|
+
if containing_span is None:
|
|
166
|
+
return True
|
|
167
|
+
span_start, span_end = containing_span
|
|
168
|
+
quoted_content = _strip_token_quotes(command_text[span_start:span_end])
|
|
169
|
+
final_segment = re.split(r"[\\/]", quoted_content)[-1].lower()
|
|
170
|
+
return final_segment in ALL_GIT_BINARY_NAMES
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _strip_token_quotes(token_text: str) -> str:
|
|
174
|
+
"""Remove quote characters from a token's edges.
|
|
175
|
+
|
|
176
|
+
Tokens cut from inside a quoted shell-wrapper argument can carry an
|
|
177
|
+
unpaired edge quote (``push"``), so both edges are stripped rather
|
|
178
|
+
than only matched pairs.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
token_text: One quote-aware token from a command string.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
The token without leading or trailing quote characters.
|
|
185
|
+
"""
|
|
186
|
+
return token_text.strip("\"'")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _gated_invocation_directory(all_following_tokens: list[str]) -> tuple[bool, str | None]:
|
|
190
|
+
"""Walk the tokens after a ``git`` word to its first subcommand.
|
|
191
|
+
|
|
192
|
+
Skips git's global options (recording the targeted directory when one
|
|
193
|
+
appears) so a gated verb counts only in subcommand position — never as
|
|
194
|
+
an argument like ``git stash push`` or ``git log --grep commit``. The
|
|
195
|
+
``-C`` directory wins when both ``-C`` and ``--work-tree`` are present;
|
|
196
|
+
otherwise a ``--work-tree`` value supplies the targeted directory so a
|
|
197
|
+
commit aimed at another repo's work tree gates against that work tree
|
|
198
|
+
rather than the session directory.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
all_following_tokens: Quote-stripped tokens after the ``git`` word.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
Whether the first subcommand is gated, and the directory the
|
|
205
|
+
invocation targets via ``-C`` (or ``--work-tree``) when one appears.
|
|
206
|
+
"""
|
|
207
|
+
repo_directory: str | None = None
|
|
208
|
+
work_tree_directory: str | None = None
|
|
209
|
+
token_index = 0
|
|
210
|
+
while token_index < len(all_following_tokens):
|
|
211
|
+
each_token = all_following_tokens[token_index]
|
|
212
|
+
option_name, attached_value = _split_option_value(each_token)
|
|
213
|
+
if option_name in VALUE_TAKING_GIT_OPTIONS:
|
|
214
|
+
option_value = (
|
|
215
|
+
attached_value
|
|
216
|
+
if attached_value is not None
|
|
217
|
+
else _value_after_option(all_following_tokens, token_index)
|
|
218
|
+
)
|
|
219
|
+
if option_name == REPO_DIRECTORY_OPTION and option_value is not None:
|
|
220
|
+
repo_directory = _expand_home_prefix(option_value)
|
|
221
|
+
if option_name == WORK_TREE_OPTION and option_value is not None:
|
|
222
|
+
work_tree_directory = _expand_home_prefix(option_value)
|
|
223
|
+
token_index += 1 if attached_value is not None else OPTION_WITH_VALUE_STEP
|
|
224
|
+
continue
|
|
225
|
+
if each_token.startswith("-"):
|
|
226
|
+
token_index += 1
|
|
227
|
+
continue
|
|
228
|
+
return (
|
|
229
|
+
each_token.lower() in GATED_GIT_SUBCOMMANDS,
|
|
230
|
+
repo_directory or work_tree_directory,
|
|
231
|
+
)
|
|
232
|
+
return (False, repo_directory or work_tree_directory)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _split_option_value(option_token: str) -> tuple[str, str | None]:
|
|
236
|
+
"""Split a ``--name=value`` option token into its name and value.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
option_token: One quote-stripped token after the ``git`` word.
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
The option name and its attached value, or the whole token and None
|
|
243
|
+
when the token carries no ``=`` value.
|
|
244
|
+
"""
|
|
245
|
+
if option_token.startswith("--") and "=" in option_token:
|
|
246
|
+
option_name, _, attached_value = option_token.partition("=")
|
|
247
|
+
return (option_name, attached_value)
|
|
248
|
+
return (option_token, None)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _value_after_option(all_following_tokens: list[str], option_index: int) -> str | None:
|
|
252
|
+
"""Read the separate value token that follows a value-taking option.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
all_following_tokens: Quote-stripped tokens after the ``git`` word.
|
|
256
|
+
option_index: Index of the value-taking option token.
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
The next token when one exists, or None at the end of the tokens.
|
|
260
|
+
"""
|
|
261
|
+
if option_index + 1 < len(all_following_tokens):
|
|
262
|
+
return all_following_tokens[option_index + 1]
|
|
263
|
+
return None
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _expand_home_prefix(directory_token: str) -> str:
|
|
267
|
+
"""Expand a leading ``~`` to the home directory the shell would use.
|
|
268
|
+
|
|
269
|
+
Git does not expand ``~`` for ``-C`` or ``--work-tree`` and never sees a
|
|
270
|
+
shell's ``cd ~`` expansion, so the gate must expand the token itself;
|
|
271
|
+
otherwise it resolves a non-existent ``~/...`` path that git rejects while
|
|
272
|
+
the shell commits in the real home-anchored repo.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
directory_token: A directory token that may start with ``~``.
|
|
276
|
+
|
|
277
|
+
Returns:
|
|
278
|
+
The token with any leading home prefix expanded, unchanged otherwise.
|
|
279
|
+
"""
|
|
280
|
+
if directory_token.startswith("~"):
|
|
281
|
+
return os.path.expanduser(directory_token)
|
|
282
|
+
return directory_token
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _is_absolute_directory(directory_token: str) -> bool:
|
|
286
|
+
"""Decide whether a directory-change target is already absolute.
|
|
287
|
+
|
|
288
|
+
Treats a POSIX root, a Windows drive or UNC root, a leading slash or
|
|
289
|
+
backslash, and a home-relative ``~`` token as absolute so they are used
|
|
290
|
+
as given rather than joined onto the active directory.
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
directory_token: The destination of a directory-change verb.
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
True when the token names an absolute or home-anchored location.
|
|
297
|
+
"""
|
|
298
|
+
if directory_token.startswith("~"):
|
|
299
|
+
return True
|
|
300
|
+
if directory_token.startswith(("/", "\\")):
|
|
301
|
+
return True
|
|
302
|
+
return os.path.isabs(directory_token)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _resolve_against(active_directory: str, changed_directory: str) -> str:
|
|
306
|
+
"""Resolve a directory-change target against the active directory.
|
|
307
|
+
|
|
308
|
+
An absolute or home-anchored target replaces the active directory; a
|
|
309
|
+
relative target is joined onto it so a ``cd subdir`` gates against the
|
|
310
|
+
session directory's subdirectory rather than a token git would resolve
|
|
311
|
+
against the hook process's own working directory.
|
|
312
|
+
|
|
313
|
+
Args:
|
|
314
|
+
active_directory: The directory in effect before this change.
|
|
315
|
+
changed_directory: The destination of a directory-change verb.
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
The directory the shell runs in after the change.
|
|
319
|
+
"""
|
|
320
|
+
if _is_absolute_directory(changed_directory):
|
|
321
|
+
return _expand_home_prefix(changed_directory)
|
|
322
|
+
return os.path.join(active_directory, changed_directory)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _directory_change_target(command_text: str, match_end: int) -> str | None:
|
|
326
|
+
"""Read the destination of a directory-change verb.
|
|
327
|
+
|
|
328
|
+
Walks the arguments after the verb, skipping a leading ``--`` terminator
|
|
329
|
+
and consuming the value after a PowerShell path option
|
|
330
|
+
(``-Path``/``-LiteralPath``) so the destination is the path rather than
|
|
331
|
+
the flag. A leading shell operator (``cd && git ...``) means no argument
|
|
332
|
+
and the active directory stays unchanged. Applies to every spelling in
|
|
333
|
+
``DIRECTORY_CHANGE_VERBS`` (``cd``, ``pushd``, ``Set-Location``, ``sl``).
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
command_text: The raw command string from the tool payload.
|
|
337
|
+
match_end: The offset just past the directory-change verb word.
|
|
338
|
+
|
|
339
|
+
Returns:
|
|
340
|
+
The destination path when one follows the verb, or None for a bare
|
|
341
|
+
``cd`` (a return to the home directory, which the gate ignores).
|
|
342
|
+
"""
|
|
343
|
+
all_argument_tokens = _argument_tokens_after_verb(command_text, match_end)
|
|
344
|
+
token_index = 0
|
|
345
|
+
while token_index < len(all_argument_tokens):
|
|
346
|
+
each_token = _strip_token_quotes(all_argument_tokens[token_index])
|
|
347
|
+
if each_token == DIRECTORY_CHANGE_OPTION_TERMINATOR:
|
|
348
|
+
token_index += 1
|
|
349
|
+
continue
|
|
350
|
+
if each_token.lower() in DIRECTORY_CHANGE_PATH_OPTIONS:
|
|
351
|
+
token_index += 1
|
|
352
|
+
continue
|
|
353
|
+
return each_token
|
|
354
|
+
return None
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _argument_tokens_after_verb(command_text: str, match_end: int) -> list[str]:
|
|
358
|
+
"""Cut the run of argument tokens that follows a directory-change verb.
|
|
359
|
+
|
|
360
|
+
Reads tokens until the first shell command separator (``;``, ``&``,
|
|
361
|
+
``|``, or a newline), so only the verb's own arguments are returned and a
|
|
362
|
+
following command is left untouched.
|
|
363
|
+
|
|
364
|
+
Args:
|
|
365
|
+
command_text: The raw command string from the tool payload.
|
|
366
|
+
match_end: The offset just past the directory-change verb word.
|
|
367
|
+
|
|
368
|
+
Returns:
|
|
369
|
+
The quote-aware argument tokens following the verb, in order.
|
|
370
|
+
"""
|
|
371
|
+
argument_run_pattern = re.compile(r"[ \t]+((?:\"[^\"]*\"|'[^']*'|[^\s;&|])+)")
|
|
372
|
+
argument_token_pattern = re.compile(r"\"[^\"]*\"|'[^']*'|[^\s;&|]+")
|
|
373
|
+
all_argument_tokens: list[str] = []
|
|
374
|
+
scan_position = match_end
|
|
375
|
+
while True:
|
|
376
|
+
run_match = argument_run_pattern.match(command_text, scan_position)
|
|
377
|
+
if run_match is None:
|
|
378
|
+
return all_argument_tokens
|
|
379
|
+
all_argument_tokens.extend(argument_token_pattern.findall(run_match.group(1)))
|
|
380
|
+
scan_position = run_match.end()
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def gated_repo_directories(command_text: str, fallback_directory: str) -> list[str]:
|
|
384
|
+
"""Collect the directories of every git commit/push found in a command.
|
|
385
|
+
|
|
386
|
+
Backslash-newline line continuations are removed first so the token
|
|
387
|
+
stream matches what the shell runs (``git \\<newline>commit`` is a real
|
|
388
|
+
commit). Scans every ``git`` word in the command — the bare ``git`` and
|
|
389
|
+
the Windows ``git.exe`` spelling, a path-prefixed binary whose final
|
|
390
|
+
segment is ``git``/``git.exe`` (``/usr/bin/git``,
|
|
391
|
+
``C:\\...\\git.exe``), and a quoted git binary whose stripped content is
|
|
392
|
+
a single token ending in ``git``/``git.exe`` (``"git" commit``,
|
|
393
|
+
``& 'C:/x/git.exe' commit``) — and token-walks from each to its first
|
|
394
|
+
subcommand. A ``git`` word that is one word among prose inside a quoted
|
|
395
|
+
string (``echo "Next: git commit"``, a ``gh pr comment -b`` body) is left
|
|
396
|
+
alone, because the shell never runs that quoted text. A
|
|
397
|
+
directory-change verb (``cd``, ``pushd``, PowerShell ``Set-Location``,
|
|
398
|
+
or its ``sl`` alias) earlier in the command moves the active directory,
|
|
399
|
+
so a following un-``-C``'d commit/push gates against the directory the
|
|
400
|
+
shell actually runs it in rather than the session cwd. A relative
|
|
401
|
+
change target joins onto the active directory so it resolves the same
|
|
402
|
+
way the shell would, not against the hook process's own cwd.
|
|
403
|
+
|
|
404
|
+
Args:
|
|
405
|
+
command_text: The raw command string from the tool payload.
|
|
406
|
+
fallback_directory: The session working directory, used as the
|
|
407
|
+
active directory until a directory-change verb changes it and
|
|
408
|
+
when the git call carries no ``-C`` flag.
|
|
409
|
+
|
|
410
|
+
Returns:
|
|
411
|
+
One directory per detected commit/push invocation, in order; empty
|
|
412
|
+
when the command carries no gated git verb.
|
|
413
|
+
"""
|
|
414
|
+
command_text = _collapse_line_continuations(command_text)
|
|
415
|
+
git_word_pattern = re.compile(
|
|
416
|
+
r"(?:^|(?<=[\s;&|(\"'/\\]))git(?:\.exe)?(?:[\"'](?=\s|$)|(?=\s|$))",
|
|
417
|
+
re.IGNORECASE,
|
|
418
|
+
)
|
|
419
|
+
directory_change_verb_alternation = "|".join(
|
|
420
|
+
re.escape(each_verb) for each_verb in sorted(DIRECTORY_CHANGE_VERBS)
|
|
421
|
+
)
|
|
422
|
+
directory_change_pattern = re.compile(
|
|
423
|
+
DIRECTORY_CHANGE_PATTERN_PREFIX
|
|
424
|
+
+ directory_change_verb_alternation
|
|
425
|
+
+ DIRECTORY_CHANGE_PATTERN_SUFFIX,
|
|
426
|
+
re.IGNORECASE,
|
|
427
|
+
)
|
|
428
|
+
command_token_pattern = re.compile(r"\"[^\"]*\"|'[^']*'|\S+")
|
|
429
|
+
all_quoted_spans = _quoted_spans(command_text)
|
|
430
|
+
all_directory_change_matches = [
|
|
431
|
+
each_match
|
|
432
|
+
for each_match in directory_change_pattern.finditer(command_text)
|
|
433
|
+
if not _is_inside_quoted_region(each_match.start(), all_quoted_spans)
|
|
434
|
+
]
|
|
435
|
+
all_git_word_matches = [
|
|
436
|
+
each_match
|
|
437
|
+
for each_match in git_word_pattern.finditer(command_text)
|
|
438
|
+
if _git_word_match_gates(each_match, command_text, all_quoted_spans)
|
|
439
|
+
]
|
|
440
|
+
all_ordered_matches = sorted(
|
|
441
|
+
all_git_word_matches + all_directory_change_matches,
|
|
442
|
+
key=lambda each_match: each_match.start(),
|
|
443
|
+
)
|
|
444
|
+
active_directory = fallback_directory
|
|
445
|
+
target_directories: list[str] = []
|
|
446
|
+
for each_match in all_ordered_matches:
|
|
447
|
+
if each_match.group().lower().strip("\"'") in DIRECTORY_CHANGE_VERBS:
|
|
448
|
+
changed_directory = _directory_change_target(command_text, each_match.end())
|
|
449
|
+
if changed_directory is not None:
|
|
450
|
+
active_directory = _resolve_against(active_directory, changed_directory)
|
|
451
|
+
continue
|
|
452
|
+
following_text = command_text[each_match.end():]
|
|
453
|
+
all_following_tokens = [
|
|
454
|
+
_strip_token_quotes(each_token)
|
|
455
|
+
for each_token in command_token_pattern.findall(following_text)
|
|
456
|
+
]
|
|
457
|
+
is_gated, flagged_directory = _gated_invocation_directory(all_following_tokens)
|
|
458
|
+
if is_gated:
|
|
459
|
+
target_directories.append(
|
|
460
|
+
_resolve_against(active_directory, flagged_directory)
|
|
461
|
+
if flagged_directory is not None
|
|
462
|
+
else active_directory
|
|
463
|
+
)
|
|
464
|
+
return target_directories
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def deny_reason_for_directory(target_directory: str) -> str | None:
|
|
468
|
+
"""Decide whether a commit/push in a directory must be blocked.
|
|
469
|
+
|
|
470
|
+
Args:
|
|
471
|
+
target_directory: The directory the git command targets.
|
|
472
|
+
|
|
473
|
+
Returns:
|
|
474
|
+
The deny reason when the branch diff needs a verdict and none binds
|
|
475
|
+
to it; None when the command may proceed.
|
|
476
|
+
"""
|
|
477
|
+
repo_root = resolve_repo_root(target_directory)
|
|
478
|
+
if repo_root is None:
|
|
479
|
+
return None
|
|
480
|
+
merge_base_sha = resolve_merge_base(repo_root)
|
|
481
|
+
if merge_base_sha is None:
|
|
482
|
+
return None
|
|
483
|
+
if is_verification_exempt_diff(repo_root, merge_base_sha):
|
|
484
|
+
return None
|
|
485
|
+
surface_manifest_text = branch_surface_manifest(repo_root, merge_base_sha)
|
|
486
|
+
if surface_manifest_text is None:
|
|
487
|
+
return f"{CORRECTIVE_MESSAGE} (surface manifest failed in {repo_root})"
|
|
488
|
+
live_manifest_sha256 = manifest_sha256(surface_manifest_text)
|
|
489
|
+
if load_valid_verdict(repo_root, live_manifest_sha256) is None:
|
|
490
|
+
hash_preview = live_manifest_sha256[:HASH_PREVIEW_LENGTH]
|
|
491
|
+
return f"{CORRECTIVE_MESSAGE} (repo: {repo_root}, surface sha256 {hash_preview}...)"
|
|
492
|
+
return None
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def main() -> None:
|
|
496
|
+
"""Read the PreToolUse payload and deny unverified commit/push commands."""
|
|
497
|
+
try:
|
|
498
|
+
pretooluse_payload = json.load(sys.stdin)
|
|
499
|
+
except json.JSONDecodeError:
|
|
500
|
+
return
|
|
501
|
+
if pretooluse_payload.get("tool_name", "") not in ALL_GATED_TOOL_NAMES:
|
|
502
|
+
return
|
|
503
|
+
command_text = pretooluse_payload.get("tool_input", {}).get("command", "")
|
|
504
|
+
if not command_text:
|
|
505
|
+
return
|
|
506
|
+
session_directory = pretooluse_payload.get("cwd", ".")
|
|
507
|
+
for each_target_directory in gated_repo_directories(command_text, session_directory):
|
|
508
|
+
deny_reason = deny_reason_for_directory(each_target_directory)
|
|
509
|
+
if deny_reason is None:
|
|
510
|
+
continue
|
|
511
|
+
deny_payload = {
|
|
512
|
+
"hookSpecificOutput": {
|
|
513
|
+
"hookEventName": "PreToolUse",
|
|
514
|
+
"permissionDecision": "deny",
|
|
515
|
+
"permissionDecisionReason": deny_reason,
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
print(json.dumps(deny_payload))
|
|
519
|
+
return
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
if __name__ == "__main__":
|
|
523
|
+
main()
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse hook: keep verified-commit exemption messages accurate.
|
|
3
|
+
|
|
4
|
+
The verified-commit gate exempts a branch surface only when every changed path
|
|
5
|
+
matches one of two narrow rules, implemented in ``is_verification_exempt_diff``:
|
|
6
|
+
|
|
7
|
+
1. file extension in ``DOCS_ONLY_EXTENSIONS`` (docs and image extensions), or
|
|
8
|
+
2. a ``.py`` file whose docstring/comment-stripped AST is unchanged.
|
|
9
|
+
|
|
10
|
+
A comment-only change to a non-Python, non-doc file (for example ``.sh``,
|
|
11
|
+
``.json``, ``.yaml``) is therefore NOT exempt: comments are ignored for
|
|
12
|
+
exemption purposes only inside Python files via the AST path. A corrective or
|
|
13
|
+
guard message that claims comment-only or docs-only surfaces are blanket exempt
|
|
14
|
+
overstates the rule and misleads users into expecting such a change to skip
|
|
15
|
+
verification.
|
|
16
|
+
|
|
17
|
+
This hook fires on Write/Edit of any verified-commit constants module and denies
|
|
18
|
+
content whose exemption-claim wording asserts a blanket comment-only or docs-only
|
|
19
|
+
exemption. It guards the message constants at authoring time, before the change
|
|
20
|
+
reaches review.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import sys
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def is_guarded_file(file_path: str) -> bool:
|
|
30
|
+
"""Return True for any verified-commit constants module carrying messages."""
|
|
31
|
+
all_guarded_file_names = frozenset({"verified_commit_constants.py"})
|
|
32
|
+
return os.path.basename(file_path) in all_guarded_file_names
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def join_adjacent_string_literals(written_text: str) -> str:
|
|
36
|
+
"""Collapse a closing quote, inter-literal whitespace, and an opening quote.
|
|
37
|
+
|
|
38
|
+
Write/Edit content carries the message constant as it appears in source: a
|
|
39
|
+
long sentence split across adjacent Python string literals. Between two
|
|
40
|
+
literals sit a closing quote, a newline, indentation, and an opening quote.
|
|
41
|
+
Replacing that run with a single space rejoins the prose so a phrase wrapped
|
|
42
|
+
across a literal boundary — for example ``exempt "`` then ``"automatically``
|
|
43
|
+
— reads as one continuous clause for matching.
|
|
44
|
+
"""
|
|
45
|
+
inter_literal_noise_pattern = re.compile(r"[\"']\s*[\"']")
|
|
46
|
+
return inter_literal_noise_pattern.sub(" ", written_text)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def claims_blanket_comment_exemption(written_text: str) -> bool:
|
|
50
|
+
"""Return True when the text claims comment surfaces are blanket-exempt.
|
|
51
|
+
|
|
52
|
+
Rejoins source-wrapped string literals first, then matches only a genuine
|
|
53
|
+
blanket form in which a comment surface is the grammatical subject of
|
|
54
|
+
"exempt automatically". A comment surface is the bare noun ``comments`` or
|
|
55
|
+
the ``comment-only`` category — never the ``comment`` stem inside an
|
|
56
|
+
unrelated word such as ``commentary`` or the ``comment-stripped`` AST
|
|
57
|
+
qualifier.
|
|
58
|
+
|
|
59
|
+
Two blanket shapes match. The direct shape names a comment surface as the
|
|
60
|
+
immediate subject of "(are|is) exempt automatically" across a bridge that
|
|
61
|
+
crosses no period, semicolon, or comma, so an intervening predicate (for
|
|
62
|
+
example "Comments are handled, and docs are exempt automatically") leaves
|
|
63
|
+
docs, not comments, as the exemption subject and does not match. The
|
|
64
|
+
enumerated shape names a comment item inside an "...-only surfaces are
|
|
65
|
+
exempt automatically" list (for example "Docs-, comment-, and test-only
|
|
66
|
+
surfaces are exempt automatically").
|
|
67
|
+
|
|
68
|
+
An accurate sentence whose true exemption subject is docs, or that qualifies
|
|
69
|
+
comments as the stripped input to the AST comparison, does not match. This
|
|
70
|
+
isolates the overstated form the verified-commit gate does not honor for
|
|
71
|
+
non-Python files.
|
|
72
|
+
"""
|
|
73
|
+
comment_surface = r"comments?-only|comments?\b(?!-)"
|
|
74
|
+
blanket_direct_claim = (
|
|
75
|
+
"(?:" + comment_surface + r")(?:[^.;,]*?\b)?(?:are|is)\s+exempt\s+automatically"
|
|
76
|
+
)
|
|
77
|
+
blanket_enumerated_claim = (
|
|
78
|
+
"(?:" + comment_surface + "|comment-)"
|
|
79
|
+
r"[^.;]*?-only\s+surfaces\s+are\s+exempt\s+automatically"
|
|
80
|
+
)
|
|
81
|
+
blanket_exemption_claim_pattern = re.compile(
|
|
82
|
+
"(?:" + blanket_direct_claim + ")|(?:" + blanket_enumerated_claim + ")",
|
|
83
|
+
re.IGNORECASE,
|
|
84
|
+
)
|
|
85
|
+
joined_text = join_adjacent_string_literals(written_text)
|
|
86
|
+
return bool(blanket_exemption_claim_pattern.search(joined_text))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def build_corrective_message() -> str:
|
|
90
|
+
"""Return the deny reason explaining the real, narrower exemption rules."""
|
|
91
|
+
accurate_exemption_phrasing = (
|
|
92
|
+
"Docs and images are exempt by extension, and Python files whose "
|
|
93
|
+
"docstring- and comment-stripped AST is unchanged; a comment-only "
|
|
94
|
+
"change to a non-Python file still needs a verdict."
|
|
95
|
+
)
|
|
96
|
+
return (
|
|
97
|
+
"BLOCKED [verified-commit-message-accuracy]: this exemption message "
|
|
98
|
+
"claims comment-only surfaces are exempt automatically, but the "
|
|
99
|
+
"verified-commit gate exempts comments only inside Python files (via the "
|
|
100
|
+
"docstring/comment stripped AST path). A comment-only change to a "
|
|
101
|
+
"non-Python file is NOT exempt and still needs a verifier verdict, so "
|
|
102
|
+
"the blanket wording misleads users.\n\nDescribe the real exemption "
|
|
103
|
+
"rules instead, for example:\n " + accurate_exemption_phrasing
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def extract_written_text(all_written_fields: dict[str, str]) -> str:
|
|
108
|
+
"""Return the Write ``content`` and Edit ``new_string`` joined for scanning."""
|
|
109
|
+
return (
|
|
110
|
+
all_written_fields.get("content", "")
|
|
111
|
+
+ "\n"
|
|
112
|
+
+ all_written_fields.get("new_string", "")
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def main() -> None:
|
|
117
|
+
all_write_edit_tools = frozenset({"Write", "Edit"})
|
|
118
|
+
try:
|
|
119
|
+
hook_input = json.load(sys.stdin)
|
|
120
|
+
except json.JSONDecodeError:
|
|
121
|
+
sys.exit(0)
|
|
122
|
+
|
|
123
|
+
tool_name = hook_input.get("tool_name", "")
|
|
124
|
+
if tool_name not in all_write_edit_tools:
|
|
125
|
+
sys.exit(0)
|
|
126
|
+
|
|
127
|
+
tool_input = hook_input.get("tool_input", {})
|
|
128
|
+
file_path = tool_input.get("file_path", "")
|
|
129
|
+
if not file_path:
|
|
130
|
+
sys.exit(0)
|
|
131
|
+
|
|
132
|
+
if not is_guarded_file(file_path):
|
|
133
|
+
sys.exit(0)
|
|
134
|
+
|
|
135
|
+
written_text = extract_written_text(tool_input)
|
|
136
|
+
if not claims_blanket_comment_exemption(written_text):
|
|
137
|
+
sys.exit(0)
|
|
138
|
+
|
|
139
|
+
deny_response = {
|
|
140
|
+
"hookSpecificOutput": {
|
|
141
|
+
"hookEventName": "PreToolUse",
|
|
142
|
+
"permissionDecision": "deny",
|
|
143
|
+
"permissionDecisionReason": build_corrective_message(),
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
print(json.dumps(deny_response))
|
|
147
|
+
sys.stdout.flush()
|
|
148
|
+
sys.exit(0)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
if __name__ == "__main__":
|
|
152
|
+
main()
|