claude-dev-env 2.0.2 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/CLAUDE.md +1 -1
  2. package/hooks/blocking/CLAUDE.md +1 -0
  3. package/hooks/blocking/code_rules_shared.py +126 -47
  4. package/hooks/blocking/config/CLAUDE.md +2 -0
  5. package/hooks/blocking/config/verified_commit_context_constants.py +21 -0
  6. package/hooks/blocking/config/verified_commit_gate_output_constants.py +14 -0
  7. package/hooks/blocking/conftest.py +36 -30
  8. package/hooks/blocking/convergence_gate_blocker.py +76 -8
  9. package/hooks/blocking/plain_language_blocker.py +8 -0
  10. package/hooks/blocking/state_description_blocker.py +4 -1
  11. package/hooks/blocking/test_code_rules_shared.py +120 -20
  12. package/hooks/blocking/test_convergence_gate_blocker.py +146 -0
  13. package/hooks/blocking/test_plain_language_blocker.py +15 -0
  14. package/hooks/blocking/test_state_description_blocker.py +15 -0
  15. package/hooks/blocking/test_verified_commit_config_bootstrap.py +18 -0
  16. package/hooks/blocking/test_verified_commit_gate_additional_context.py +134 -0
  17. package/hooks/blocking/tests/test_verified_commit_gate.py +41 -0
  18. package/hooks/blocking/verified_commit_config_bootstrap.py +22 -10
  19. package/hooks/blocking/verified_commit_gate.py +113 -568
  20. package/hooks/blocking/verified_commit_gate_parts/CLAUDE.md +28 -0
  21. package/hooks/blocking/verified_commit_gate_parts/__init__.py +1 -0
  22. package/hooks/blocking/verified_commit_gate_parts/command_tokenization.py +174 -0
  23. package/hooks/blocking/verified_commit_gate_parts/deny_payload.py +53 -0
  24. package/hooks/blocking/verified_commit_gate_parts/deny_reason.py +80 -0
  25. package/hooks/blocking/verified_commit_gate_parts/directory_resolution.py +170 -0
  26. package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +205 -0
  27. package/hooks/blocking/verified_commit_gate_parts/tests/conftest.py +10 -0
  28. package/hooks/blocking/verified_commit_gate_parts/tests/test_command_tokenization.py +94 -0
  29. package/hooks/blocking/verified_commit_gate_parts/tests/test_deny_payload.py +17 -0
  30. package/hooks/blocking/verified_commit_gate_parts/tests/test_deny_reason.py +38 -0
  31. package/hooks/blocking/verified_commit_gate_parts/tests/test_directory_resolution.py +71 -0
  32. package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +61 -0
  33. package/hooks/hooks_constants/CLAUDE.md +4 -1
  34. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
  35. package/hooks/hooks_constants/convergence_gate_blocker_constants.py +36 -0
  36. package/hooks/hooks_constants/harness_scratchpad_constants.py +10 -9
  37. package/hooks/hooks_constants/mypy_integration_constants.py +16 -0
  38. package/hooks/validators/mypy_integration.py +145 -24
  39. package/hooks/validators/python_antipattern_checks.py +16 -0
  40. package/hooks/validators/run_all_validators.py +9 -3
  41. package/hooks/validators/test_mypy_integration.py +154 -0
  42. package/hooks/validators/test_python_antipattern_checks.py +112 -1
  43. package/hooks/validators/test_run_all_validators_pretooluse.py +16 -0
  44. package/package.json +1 -1
  45. package/rules/CLAUDE.md +1 -0
  46. package/rules/verified-commit-gate-skip.md +28 -0
  47. package/skills/auditing-claude-config/CLAUDE.md +2 -1
  48. package/skills/auditing-claude-config/SKILL.md +114 -176
  49. package/skills/auditing-claude-config/reference/probe-hook.md +74 -0
  50. package/skills/autoconverge/CLAUDE.md +1 -0
  51. package/skills/autoconverge/SKILL.md +310 -346
  52. package/skills/autoconverge/reference/CLAUDE.md +1 -0
  53. package/skills/autoconverge/reference/copilot-findings.md +51 -0
  54. package/skills/closeout/SKILL.md +7 -9
  55. package/skills/copilot-finding-triage/SKILL.md +5 -7
  56. package/skills/copilot-review/CLAUDE.md +3 -2
  57. package/skills/copilot-review/SKILL.md +119 -155
  58. package/skills/copilot-review/templates/subagent-prompt.md +49 -0
  59. package/skills/fresh-branch/CLAUDE.md +6 -9
  60. package/skills/fresh-branch/SKILL.md +84 -33
  61. package/skills/fresh-branch/scripts/create_fresh_branch.py +445 -0
  62. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/__init__.py +1 -0
  63. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +74 -0
  64. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +670 -0
  65. package/skills/pr-converge/SKILL.md +277 -422
  66. package/skills/pr-converge/reference/CLAUDE.md +1 -0
  67. package/skills/pr-converge/reference/progress-checklist.md +168 -0
  68. package/skills/pr-converge/test_step5_host_branch.py +8 -6
  69. package/skills/pr-loop-cloud-transport/SKILL.md +1 -1
  70. package/skills/privacy-hygiene/SKILL.md +68 -115
  71. package/skills/privacy-hygiene/reference/sweep-procedure.md +62 -0
  72. package/skills/session-log/CLAUDE.md +2 -1
  73. package/skills/session-log/SKILL.md +4 -26
  74. package/skills/session-log/templates/frontmatter.md +40 -0
  75. package/skills/skill-builder/CLAUDE.md +8 -7
  76. package/skills/skill-builder/SKILL.md +26 -11
  77. package/skills/skill-builder/references/CLAUDE.md +3 -1
  78. package/skills/skill-builder/references/delegation-map.md +21 -12
  79. package/skills/skill-builder/references/description-field.md +9 -11
  80. package/skills/skill-builder/references/deterministic-elements.md +218 -0
  81. package/skills/skill-builder/references/self-audit-checklist.md +62 -45
  82. package/skills/skill-builder/references/skill-modularity.md +8 -9
  83. package/skills/skill-builder/templates/CLAUDE.md +2 -2
  84. package/skills/skill-builder/templates/gap-analysis.md +15 -0
  85. package/skills/skill-builder/workflows/CLAUDE.md +5 -5
  86. package/skills/skill-builder/workflows/improve-skill.md +18 -9
  87. package/skills/skill-builder/workflows/new-skill.md +23 -15
  88. package/skills/skill-builder/workflows/polish-skill.md +28 -21
@@ -1,568 +1,113 @@
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 command carries the verification bypass marker (``# verify-skip``),
9
- a manual on-the-fly override that skips the gate for that one command,
10
- - the repository has no resolvable upstream base — no ``origin/HEAD``, no
11
- configured tracking ref, and neither ``origin/main`` nor ``origin/master``
12
- (scratch repos with no remote branch are out of scope),
13
- - the surface is mechanically exempt (docs/images by extension, pytest
14
- test files by name convention, Python files whose docstring-stripped
15
- AST is unchanged), or
16
- - a passing verifier verdict binds to the exact live manifest hash —
17
- matched by content hash, not by work-tree location, so a verdict
18
- ``verifier_verdict_minter.py`` minted while verifying any work tree of
19
- the surface clears the commit, as does one a workflow ``code-verifier``
20
- emitted in its own transcript.
21
-
22
- The surface binds every changed and untracked file's content, so slicing
23
- work into small commits or staging files cannot move the hash, while any
24
- content edit or new file after verification invalidates the verdict.
25
- Verdict files live under ``~/.claude/verification/`` and are minted only by
26
- the SubagentStop hook when a ``code-verifier`` agent finishes.
27
- """
28
-
29
- from __future__ import annotations
30
-
31
- import json
32
- import os
33
- import re
34
- import sys
35
- from pathlib import Path
36
-
37
- blocking_directory = str(Path(__file__).resolve().parent)
38
- if blocking_directory not in sys.path:
39
- sys.path.insert(0, blocking_directory)
40
-
41
- _hooks_dir = str(Path(__file__).resolve().parent.parent)
42
- if _hooks_dir not in sys.path:
43
- sys.path.insert(0, _hooks_dir)
44
-
45
- from verified_commit_config_bootstrap import ( # noqa: E402
46
- register_verified_commit_constants,
47
- )
48
-
49
- register_verified_commit_constants()
50
-
51
- from config.verified_commit_constants import (
52
- ALL_GIT_BINARY_NAMES,
53
- CORRECTIVE_MESSAGE,
54
- DIRECTORY_CHANGE_OPTION_TERMINATOR,
55
- DIRECTORY_CHANGE_PATH_OPTIONS,
56
- DIRECTORY_CHANGE_PATTERN_PREFIX,
57
- DIRECTORY_CHANGE_PATTERN_SUFFIX,
58
- DIRECTORY_CHANGE_VERBS,
59
- GATED_GIT_SUBCOMMANDS,
60
- ALL_GATED_TOOL_NAMES,
61
- HASH_PREVIEW_LENGTH,
62
- OPTION_WITH_VALUE_STEP,
63
- REPO_DIRECTORY_OPTION,
64
- VALUE_TAKING_GIT_OPTIONS,
65
- VERIFICATION_BYPASS_MARKER,
66
- WORK_TREE_OPTION,
67
- )
68
- from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
69
- from verification_verdict_store import (
70
- branch_surface_manifest,
71
- is_verification_exempt_diff,
72
- load_valid_verdict,
73
- manifest_sha256,
74
- minted_verdict_covers_surface,
75
- resolve_merge_base,
76
- resolve_repo_root,
77
- workflow_verdict_covers_surface,
78
- )
79
-
80
-
81
- def _collapse_line_continuations(command_text: str) -> str:
82
- """Remove backslash-newline line continuations the shell would erase.
83
-
84
- Bash joins a line continuation — a backslash immediately followed by a
85
- newline by deleting both characters, so ``git \\<newline>commit``,
86
- ``git commit\\<newline> -m x``, and ``g\\<newline>it commit`` all run as a
87
- plain ``git commit``. Stripping the pair before tokenizing makes the token
88
- stream match what the shell executes, so a continuation abutting the
89
- subcommand or splitting the ``git`` word cannot evade the gate.
90
-
91
- Args:
92
- command_text: The raw command string from the tool payload.
93
-
94
- Returns:
95
- The command with every backslash-newline pair removed.
96
- """
97
- return re.sub(r"\\\r?\n", "", command_text)
98
-
99
-
100
- def _quoted_spans(command_text: str) -> list[tuple[int, int]]:
101
- """Find the character spans of every quoted region in a command.
102
-
103
- Scans single- and double-quoted runs left to right so a verb sitting
104
- inside a quoted ``-m`` commit message is recognised as message text
105
- rather than a real shell directory change.
106
-
107
- Args:
108
- command_text: The raw command string from the tool payload.
109
-
110
- Returns:
111
- The ``(start, end)`` span of each quoted region, in order.
112
- """
113
- quoted_region_pattern = re.compile(r"\"[^\"]*\"|'[^']*'")
114
- return [
115
- (each_match.start(), each_match.end())
116
- for each_match in quoted_region_pattern.finditer(command_text)
117
- ]
118
-
119
-
120
- def _is_inside_quoted_region(position: int, all_quoted_spans: list[tuple[int, int]]) -> bool:
121
- """Decide whether a position falls inside any quoted region.
122
-
123
- Args:
124
- position: A character offset into the command string.
125
- all_quoted_spans: The quoted-region spans from ``_quoted_spans``.
126
-
127
- Returns:
128
- True when the offset sits within a quoted region's bounds.
129
- """
130
- for each_span_start, each_span_end in all_quoted_spans:
131
- if each_span_start <= position < each_span_end:
132
- return True
133
- return False
134
-
135
-
136
- def _containing_quoted_span(
137
- position: int, all_quoted_spans: list[tuple[int, int]]
138
- ) -> tuple[int, int] | None:
139
- """Return the quoted region a position falls inside, or None.
140
-
141
- Args:
142
- position: A character offset into the command string.
143
- all_quoted_spans: The quoted-region spans from ``_quoted_spans``.
144
-
145
- Returns:
146
- The ``(start, end)`` span containing the offset, or None when the
147
- offset sits outside every quoted region.
148
- """
149
- for each_span_start, each_span_end in all_quoted_spans:
150
- if each_span_start <= position < each_span_end:
151
- return (each_span_start, each_span_end)
152
- return None
153
-
154
-
155
- def _git_word_match_gates(
156
- git_word_match: re.Match[str],
157
- command_text: str,
158
- all_quoted_spans: list[tuple[int, int]],
159
- ) -> bool:
160
- """Decide whether a ``git`` word match counts as a real invocation.
161
-
162
- A ``git`` word outside every quoted region always gates. Inside a quoted
163
- region the word gates only when the region's content, with edge quotes
164
- stripped, is a path whose final ``[\\/]``-split segment is ``git`` or
165
- ``git.exe`` — a wrapper-quoted binary (``"git" commit``), a quoted
166
- call-operator path (``& 'C:/x/git.exe' commit``), or a quoted install
167
- path whose directory components carry spaces
168
- (``& "C:\\Program Files\\Git\\cmd\\git.exe" commit``). A ``git`` word that
169
- is one word among prose inside a quoted string — an
170
- ``echo "Next: git commit"`` mention or a ``gh pr comment -b "please git
171
- commit"`` body — does not gate, because the prose's final path segment is
172
- the surrounding sentence rather than a bare ``git``/``git.exe`` binary
173
- name, so the shell never runs that quoted text as a command.
174
-
175
- Args:
176
- git_word_match: A ``git`` word match in the command.
177
- command_text: The raw command string from the tool payload.
178
- all_quoted_spans: The quoted-region spans from ``_quoted_spans``.
179
-
180
- Returns:
181
- True when the matched ``git`` word counts as a real git invocation.
182
- """
183
- containing_span = _containing_quoted_span(git_word_match.start(), all_quoted_spans)
184
- if containing_span is None:
185
- return True
186
- span_start, span_end = containing_span
187
- quoted_content = _strip_token_quotes(command_text[span_start:span_end])
188
- final_segment = re.split(r"[\\/]", quoted_content)[-1].lower()
189
- return final_segment in ALL_GIT_BINARY_NAMES
190
-
191
-
192
- def _strip_token_quotes(token_text: str) -> str:
193
- """Remove quote characters from a token's edges.
194
-
195
- Tokens cut from inside a quoted shell-wrapper argument can carry an
196
- unpaired edge quote (``push"``), so both edges are stripped rather
197
- than only matched pairs.
198
-
199
- Args:
200
- token_text: One quote-aware token from a command string.
201
-
202
- Returns:
203
- The token without leading or trailing quote characters.
204
- """
205
- return token_text.strip("\"'")
206
-
207
-
208
- def _gated_invocation_directory(all_following_tokens: list[str]) -> tuple[bool, str | None]:
209
- """Walk the tokens after a ``git`` word to its first subcommand.
210
-
211
- Skips git's global options (recording the targeted directory when one
212
- appears) so a gated verb counts only in subcommand position — never as
213
- an argument like ``git stash push`` or ``git log --grep commit``. The
214
- ``-C`` directory wins when both ``-C`` and ``--work-tree`` are present;
215
- otherwise a ``--work-tree`` value supplies the targeted directory so a
216
- commit aimed at another repo's work tree gates against that work tree
217
- rather than the session directory.
218
-
219
- Args:
220
- all_following_tokens: Quote-stripped tokens after the ``git`` word.
221
-
222
- Returns:
223
- Whether the first subcommand is gated, and the directory the
224
- invocation targets via ``-C`` (or ``--work-tree``) when one appears.
225
- """
226
- repo_directory: str | None = None
227
- work_tree_directory: str | None = None
228
- token_index = 0
229
- while token_index < len(all_following_tokens):
230
- each_token = all_following_tokens[token_index]
231
- option_name, attached_value = _split_option_value(each_token)
232
- if option_name in VALUE_TAKING_GIT_OPTIONS:
233
- option_value = (
234
- attached_value
235
- if attached_value is not None
236
- else _value_after_option(all_following_tokens, token_index)
237
- )
238
- if option_name == REPO_DIRECTORY_OPTION and option_value is not None:
239
- repo_directory = _expand_home_prefix(option_value)
240
- if option_name == WORK_TREE_OPTION and option_value is not None:
241
- work_tree_directory = _expand_home_prefix(option_value)
242
- token_index += 1 if attached_value is not None else OPTION_WITH_VALUE_STEP
243
- continue
244
- if each_token.startswith("-"):
245
- token_index += 1
246
- continue
247
- return (
248
- each_token.lower() in GATED_GIT_SUBCOMMANDS,
249
- repo_directory or work_tree_directory,
250
- )
251
- return (False, repo_directory or work_tree_directory)
252
-
253
-
254
- def _split_option_value(option_token: str) -> tuple[str, str | None]:
255
- """Split a ``--name=value`` option token into its name and value.
256
-
257
- Args:
258
- option_token: One quote-stripped token after the ``git`` word.
259
-
260
- Returns:
261
- The option name and its attached value, or the whole token and None
262
- when the token carries no ``=`` value.
263
- """
264
- if option_token.startswith("--") and "=" in option_token:
265
- option_name, _, attached_value = option_token.partition("=")
266
- return (option_name, attached_value)
267
- return (option_token, None)
268
-
269
-
270
- def _value_after_option(all_following_tokens: list[str], option_index: int) -> str | None:
271
- """Read the separate value token that follows a value-taking option.
272
-
273
- Args:
274
- all_following_tokens: Quote-stripped tokens after the ``git`` word.
275
- option_index: Index of the value-taking option token.
276
-
277
- Returns:
278
- The next token when one exists, or None at the end of the tokens.
279
- """
280
- if option_index + 1 < len(all_following_tokens):
281
- return all_following_tokens[option_index + 1]
282
- return None
283
-
284
-
285
- def _expand_home_prefix(directory_token: str) -> str:
286
- """Expand a leading ``~`` to the home directory the shell would use.
287
-
288
- Git does not expand ``~`` for ``-C`` or ``--work-tree`` and never sees a
289
- shell's ``cd ~`` expansion, so the gate must expand the token itself;
290
- otherwise it resolves a non-existent ``~/...`` path that git rejects while
291
- the shell commits in the real home-anchored repo.
292
-
293
- Args:
294
- directory_token: A directory token that may start with ``~``.
295
-
296
- Returns:
297
- The token with any leading home prefix expanded, unchanged otherwise.
298
- """
299
- if directory_token.startswith("~"):
300
- return os.path.expanduser(directory_token)
301
- return directory_token
302
-
303
-
304
- def _is_absolute_directory(directory_token: str) -> bool:
305
- """Decide whether a directory-change target is already absolute.
306
-
307
- Treats a POSIX root, a Windows drive or UNC root, a leading slash or
308
- backslash, and a home-relative ``~`` token as absolute so they are used
309
- as given rather than joined onto the active directory.
310
-
311
- Args:
312
- directory_token: The destination of a directory-change verb.
313
-
314
- Returns:
315
- True when the token names an absolute or home-anchored location.
316
- """
317
- if directory_token.startswith("~"):
318
- return True
319
- if directory_token.startswith(("/", "\\")):
320
- return True
321
- return os.path.isabs(directory_token)
322
-
323
-
324
- def _resolve_against(active_directory: str, changed_directory: str) -> str:
325
- """Resolve a directory-change target against the active directory.
326
-
327
- An absolute or home-anchored target replaces the active directory; a
328
- relative target is joined onto it so a ``cd subdir`` gates against the
329
- session directory's subdirectory rather than a token git would resolve
330
- against the hook process's own working directory.
331
-
332
- Args:
333
- active_directory: The directory in effect before this change.
334
- changed_directory: The destination of a directory-change verb.
335
-
336
- Returns:
337
- The directory the shell runs in after the change.
338
- """
339
- if _is_absolute_directory(changed_directory):
340
- return _expand_home_prefix(changed_directory)
341
- return os.path.join(active_directory, changed_directory)
342
-
343
-
344
- def _directory_change_target(command_text: str, match_end: int) -> str | None:
345
- """Read the destination of a directory-change verb.
346
-
347
- Walks the arguments after the verb, skipping a leading ``--`` terminator
348
- and consuming the value after a PowerShell path option
349
- (``-Path``/``-LiteralPath``) so the destination is the path rather than
350
- the flag. A leading shell operator (``cd && git ...``) means no argument
351
- and the active directory stays unchanged. Applies to every spelling in
352
- ``DIRECTORY_CHANGE_VERBS`` (``cd``, ``pushd``, ``Set-Location``, ``sl``).
353
-
354
- Args:
355
- command_text: The raw command string from the tool payload.
356
- match_end: The offset just past the directory-change verb word.
357
-
358
- Returns:
359
- The destination path when one follows the verb, or None for a bare
360
- ``cd`` (a return to the home directory, which the gate ignores).
361
- """
362
- all_argument_tokens = _argument_tokens_after_verb(command_text, match_end)
363
- token_index = 0
364
- while token_index < len(all_argument_tokens):
365
- each_token = _strip_token_quotes(all_argument_tokens[token_index])
366
- if each_token == DIRECTORY_CHANGE_OPTION_TERMINATOR:
367
- token_index += 1
368
- continue
369
- if each_token.lower() in DIRECTORY_CHANGE_PATH_OPTIONS:
370
- token_index += 1
371
- continue
372
- return each_token
373
- return None
374
-
375
-
376
- def _argument_tokens_after_verb(command_text: str, match_end: int) -> list[str]:
377
- """Cut the run of argument tokens that follows a directory-change verb.
378
-
379
- Reads tokens until the first shell command separator (``;``, ``&``,
380
- ``|``, or a newline), so only the verb's own arguments are returned and a
381
- following command is left untouched.
382
-
383
- Args:
384
- command_text: The raw command string from the tool payload.
385
- match_end: The offset just past the directory-change verb word.
386
-
387
- Returns:
388
- The quote-aware argument tokens following the verb, in order.
389
- """
390
- argument_run_pattern = re.compile(r"[ \t]+((?:\"[^\"]*\"|'[^']*'|[^\s;&|])+)")
391
- argument_token_pattern = re.compile(r"\"[^\"]*\"|'[^']*'|[^\s;&|]+")
392
- all_argument_tokens: list[str] = []
393
- scan_position = match_end
394
- while True:
395
- run_match = argument_run_pattern.match(command_text, scan_position)
396
- if run_match is None:
397
- return all_argument_tokens
398
- all_argument_tokens.extend(argument_token_pattern.findall(run_match.group(1)))
399
- scan_position = run_match.end()
400
-
401
-
402
- def gated_repo_directories(command_text: str, fallback_directory: str) -> list[str]:
403
- """Collect the directories of every git commit/push found in a command.
404
-
405
- Backslash-newline line continuations are removed first so the token
406
- stream matches what the shell runs (``git \\<newline>commit`` is a real
407
- commit). Scans every ``git`` word in the command — the bare ``git`` and
408
- the Windows ``git.exe`` spelling, a path-prefixed binary whose final
409
- segment is ``git``/``git.exe`` (``/usr/bin/git``,
410
- ``C:\\...\\git.exe``), and a quoted git binary whose stripped content is
411
- a single token ending in ``git``/``git.exe`` (``"git" commit``,
412
- ``& 'C:/x/git.exe' commit``) — and token-walks from each to its first
413
- subcommand. A ``git`` word that is one word among prose inside a quoted
414
- string (``echo "Next: git commit"``, a ``gh pr comment -b`` body) is left
415
- alone, because the shell never runs that quoted text. A
416
- directory-change verb (``cd``, ``pushd``, PowerShell ``Set-Location``,
417
- or its ``sl`` alias) earlier in the command moves the active directory,
418
- so a following un-``-C``'d commit/push gates against the directory the
419
- shell actually runs it in rather than the session cwd. A relative
420
- change target joins onto the active directory so it resolves the same
421
- way the shell would, not against the hook process's own cwd.
422
-
423
- Args:
424
- command_text: The raw command string from the tool payload.
425
- fallback_directory: The session working directory, used as the
426
- active directory until a directory-change verb changes it and
427
- when the git call carries no ``-C`` flag.
428
-
429
- Returns:
430
- One directory per detected commit/push invocation, in order; empty
431
- when the command carries no gated git verb.
432
- """
433
- command_text = _collapse_line_continuations(command_text)
434
- git_word_pattern = re.compile(
435
- r"(?:^|(?<=[\s;&|(\"'/\\]))git(?:\.exe)?(?:[\"'](?=\s|$)|(?=\s|$))",
436
- re.IGNORECASE,
437
- )
438
- directory_change_verb_alternation = "|".join(
439
- re.escape(each_verb) for each_verb in sorted(DIRECTORY_CHANGE_VERBS)
440
- )
441
- directory_change_pattern = re.compile(
442
- DIRECTORY_CHANGE_PATTERN_PREFIX
443
- + directory_change_verb_alternation
444
- + DIRECTORY_CHANGE_PATTERN_SUFFIX,
445
- re.IGNORECASE,
446
- )
447
- command_token_pattern = re.compile(r"\"[^\"]*\"|'[^']*'|\S+")
448
- all_quoted_spans = _quoted_spans(command_text)
449
- all_directory_change_matches = [
450
- each_match
451
- for each_match in directory_change_pattern.finditer(command_text)
452
- if not _is_inside_quoted_region(each_match.start(), all_quoted_spans)
453
- ]
454
- all_git_word_matches = [
455
- each_match
456
- for each_match in git_word_pattern.finditer(command_text)
457
- if _git_word_match_gates(each_match, command_text, all_quoted_spans)
458
- ]
459
- all_ordered_matches = sorted(
460
- all_git_word_matches + all_directory_change_matches,
461
- key=lambda each_match: each_match.start(),
462
- )
463
- active_directory = fallback_directory
464
- target_directories: list[str] = []
465
- for each_match in all_ordered_matches:
466
- if each_match.group().lower().strip("\"'") in DIRECTORY_CHANGE_VERBS:
467
- changed_directory = _directory_change_target(command_text, each_match.end())
468
- if changed_directory is not None:
469
- active_directory = _resolve_against(active_directory, changed_directory)
470
- continue
471
- following_text = command_text[each_match.end():]
472
- all_following_tokens = [
473
- _strip_token_quotes(each_token)
474
- for each_token in command_token_pattern.findall(following_text)
475
- ]
476
- is_gated, flagged_directory = _gated_invocation_directory(all_following_tokens)
477
- if is_gated:
478
- target_directories.append(
479
- _resolve_against(active_directory, flagged_directory)
480
- if flagged_directory is not None
481
- else active_directory
482
- )
483
- return target_directories
484
-
485
-
486
- def deny_reason_for_directory(target_directory: str, transcript_path: str) -> str | None:
487
- """Decide whether a commit/push in a directory must be blocked.
488
-
489
- Accepts the command when a minted verdict binds to the live surface, or
490
- when a workflow-spawned code-verifier emitted a passing verdict bound to
491
- the same surface in its own transcript — the latter covers workflow runs,
492
- where SubagentStop never fires to mint a verdict file.
493
-
494
- Args:
495
- target_directory: The directory the git command targets.
496
- transcript_path: The live session's transcript path from the payload,
497
- used to find a workflow code-verifier's verdict.
498
-
499
- Returns:
500
- The deny reason when the branch diff needs a verdict and neither a
501
- minted nor a workflow verdict binds to it; None when the command may
502
- proceed.
503
- """
504
- repo_root = resolve_repo_root(target_directory)
505
- if repo_root is None:
506
- return None
507
- merge_base_sha = resolve_merge_base(repo_root)
508
- if merge_base_sha is None:
509
- return None
510
- if is_verification_exempt_diff(repo_root, merge_base_sha):
511
- return None
512
- surface_manifest_text = branch_surface_manifest(repo_root, merge_base_sha)
513
- if surface_manifest_text is None:
514
- return f"{CORRECTIVE_MESSAGE} (surface manifest failed in {repo_root})"
515
- live_manifest_sha256 = manifest_sha256(surface_manifest_text)
516
- if load_valid_verdict(repo_root, live_manifest_sha256) is not None:
517
- return None
518
- if minted_verdict_covers_surface(live_manifest_sha256):
519
- return None
520
- if workflow_verdict_covers_surface(transcript_path, live_manifest_sha256):
521
- return None
522
- hash_preview = live_manifest_sha256[:HASH_PREVIEW_LENGTH]
523
- return f"{CORRECTIVE_MESSAGE} (repo: {repo_root}, surface sha256 {hash_preview}...)"
524
-
525
-
526
- def main() -> None:
527
- """Read the PreToolUse payload and decide whether to allow the command.
528
-
529
- Allows the command without a verdict when it carries the verification
530
- bypass marker (``VERIFICATION_BYPASS_MARKER``), a manual on-the-fly
531
- override; otherwise denies an unverified commit or push.
532
- """
533
- try:
534
- pretooluse_payload = json.load(sys.stdin)
535
- except json.JSONDecodeError:
536
- return
537
- if pretooluse_payload.get("tool_name", "") not in ALL_GATED_TOOL_NAMES:
538
- return
539
- command_text = pretooluse_payload.get("tool_input", {}).get("command", "")
540
- if not command_text:
541
- return
542
- if VERIFICATION_BYPASS_MARKER in command_text:
543
- return
544
- session_directory = pretooluse_payload.get("cwd", ".")
545
- transcript_path = pretooluse_payload.get("transcript_path", "")
546
- for each_target_directory in gated_repo_directories(command_text, session_directory):
547
- deny_reason = deny_reason_for_directory(each_target_directory, transcript_path)
548
- if deny_reason is None:
549
- continue
550
- deny_payload = {
551
- "hookSpecificOutput": {
552
- "hookEventName": "PreToolUse",
553
- "permissionDecision": "deny",
554
- "permissionDecisionReason": deny_reason,
555
- }
556
- }
557
- log_hook_block(
558
- calling_hook_name="verified_commit_gate.py",
559
- hook_event="PreToolUse",
560
- block_reason=deny_reason,
561
- tool_name=pretooluse_payload.get("tool_name", "") if isinstance(pretooluse_payload.get("tool_name"), str) else None,
562
- )
563
- print(json.dumps(deny_payload))
564
- return
565
-
566
-
567
- if __name__ == "__main__":
568
- main()
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 command carries the verification bypass marker (``# verify-skip``),
9
+ a manual on-the-fly override that skips the gate for that one command,
10
+ - the repository has no resolvable upstream base — no ``origin/HEAD``, no
11
+ configured tracking ref, and neither ``origin/main`` nor ``origin/master``
12
+ (scratch repos with no remote branch are out of scope),
13
+ - the surface is mechanically exempt (docs/images by extension, pytest
14
+ test files by name convention, Python files whose docstring-stripped
15
+ AST is unchanged), or
16
+ - a passing verifier verdict binds to the exact live manifest hash —
17
+ matched by content hash, not by work-tree location, so a verdict
18
+ ``verifier_verdict_minter.py`` minted while verifying any work tree of
19
+ the surface clears the commit, as does one a workflow ``code-verifier``
20
+ emitted in its own transcript.
21
+
22
+ The surface binds every changed and untracked file's content, so slicing
23
+ work into small commits or staging files cannot move the hash, while any
24
+ content edit or new file after verification invalidates the verdict.
25
+ Verdict files live under ``~/.claude/verification/`` and are minted only by
26
+ the SubagentStop hook when a ``code-verifier`` agent finishes. A blocked
27
+ command's deny payload carries ``VERIFY_SKIP_ADDITIONAL_CONTEXT``, so the
28
+ agent learns at the moment of the block when ``# verify-skip`` is legitimate.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import json
34
+ import sys
35
+ from pathlib import Path
36
+
37
+ try:
38
+ _blocking_directory = str(Path(__file__).resolve().parent)
39
+ _hooks_directory = str(Path(__file__).resolve().parent.parent)
40
+ for each_bootstrap_directory in (_blocking_directory, _hooks_directory):
41
+ if each_bootstrap_directory not in sys.path:
42
+ sys.path.insert(0, each_bootstrap_directory)
43
+
44
+ from verified_commit_config_bootstrap import register_verified_commit_constants
45
+
46
+ register_verified_commit_constants()
47
+
48
+ from config.verified_commit_constants import (
49
+ ALL_GATED_TOOL_NAMES,
50
+ VERIFICATION_BYPASS_MARKER,
51
+ )
52
+ from config.verified_commit_gate_output_constants import (
53
+ GATE_HOOK_MODULE_NAME,
54
+ PRE_TOOL_USE_HOOK_EVENT_NAME,
55
+ )
56
+ from verified_commit_gate_parts.command_tokenization import (
57
+ command_carries_trailing_marker,
58
+ )
59
+ from verified_commit_gate_parts.deny_payload import build_deny_payload
60
+ from verified_commit_gate_parts.deny_reason import deny_reason_for_directory
61
+ from verified_commit_gate_parts.gated_invocations import gated_repo_directories
62
+
63
+ from hooks_constants.hook_block_logger import log_hook_block
64
+ except ImportError as import_error:
65
+ raise ImportError(
66
+ "the verified_commit_gate_parts modules did not import; "
67
+ "ensure the hooks directory is importable."
68
+ ) from import_error
69
+
70
+
71
+ def _log_and_emit_deny(deny_reason: str, tool_name: str) -> None:
72
+ """Log the block and write the deny payload to stdout."""
73
+ log_hook_block(
74
+ calling_hook_name=GATE_HOOK_MODULE_NAME,
75
+ hook_event=PRE_TOOL_USE_HOOK_EVENT_NAME,
76
+ block_reason=deny_reason,
77
+ tool_name=tool_name if isinstance(tool_name, str) else None,
78
+ )
79
+ sys.stdout.write(json.dumps(build_deny_payload(deny_reason)) + "\n")
80
+
81
+
82
+ def main() -> None:
83
+ """Read the PreToolUse payload and decide whether to allow the command.
84
+
85
+ Allows the command without a verdict when it carries the verification
86
+ bypass marker (``VERIFICATION_BYPASS_MARKER``) as a genuine trailing shell
87
+ comment outside every quoted region, a manual on-the-fly override;
88
+ otherwise denies an unverified commit or push.
89
+ """
90
+ try:
91
+ pretooluse_payload = json.load(sys.stdin)
92
+ except json.JSONDecodeError:
93
+ return
94
+ tool_name = pretooluse_payload.get("tool_name", "")
95
+ if tool_name not in ALL_GATED_TOOL_NAMES:
96
+ return
97
+ command_text = pretooluse_payload.get("tool_input", {}).get("command", "")
98
+ if not command_text:
99
+ return
100
+ if command_carries_trailing_marker(command_text, VERIFICATION_BYPASS_MARKER):
101
+ return
102
+ session_directory = pretooluse_payload.get("cwd", ".")
103
+ transcript_path = pretooluse_payload.get("transcript_path", "")
104
+ for each_target_directory in gated_repo_directories(command_text, session_directory):
105
+ deny_reason = deny_reason_for_directory(each_target_directory, transcript_path)
106
+ if deny_reason is None:
107
+ continue
108
+ _log_and_emit_deny(deny_reason, tool_name)
109
+ return
110
+
111
+
112
+ if __name__ == "__main__":
113
+ main()