claude-dev-env 2.0.1 → 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.
- package/CLAUDE.md +1 -1
- package/agents/clean-coder.md +31 -1
- package/audit-rubrics/prompts/category-e-dead-code.md +2 -2
- package/bin/install.test.mjs +2 -2
- package/docs/CLAUDE.md +6 -3
- package/docs/CODE_RULES.md +5 -1
- package/docs/agent-spawn-protocol.md +39 -0
- package/docs/nas-ssh-invocation.md +23 -0
- package/docs/worker-completion-gate.md +33 -0
- package/hooks/blocking/CLAUDE.md +1 -0
- package/hooks/blocking/code_rules_dead_module_constant.py +11 -5
- package/hooks/blocking/code_rules_shared.py +126 -47
- package/hooks/blocking/config/CLAUDE.md +2 -0
- package/hooks/blocking/config/verified_commit_constants.py +8 -1
- package/hooks/blocking/config/verified_commit_context_constants.py +21 -0
- package/hooks/blocking/config/verified_commit_gate_output_constants.py +14 -0
- package/hooks/blocking/conftest.py +36 -30
- package/hooks/blocking/convergence_gate_blocker.py +76 -8
- package/hooks/blocking/plain_language_blocker.py +8 -0
- package/hooks/blocking/state_description_blocker.py +4 -1
- package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +58 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_module_constant_alias.py +133 -0
- package/hooks/blocking/test_code_rules_shared.py +120 -20
- package/hooks/blocking/test_convergence_gate_blocker.py +146 -0
- package/hooks/blocking/test_plain_language_blocker.py +15 -0
- package/hooks/blocking/test_state_description_blocker.py +15 -0
- package/hooks/blocking/test_verification_verdict_store.py +12 -0
- package/hooks/blocking/test_verified_commit_config_bootstrap.py +18 -0
- package/hooks/blocking/test_verified_commit_docs_delta.py +176 -0
- package/hooks/blocking/test_verified_commit_gate_additional_context.py +134 -0
- package/hooks/blocking/tests/test_pii_prevention_blocker.py +0 -1
- package/hooks/blocking/tests/test_verified_commit_gate.py +41 -0
- package/hooks/blocking/verified_commit_config_bootstrap.py +22 -10
- package/hooks/blocking/verified_commit_gate.py +113 -568
- package/hooks/blocking/verified_commit_gate_parts/CLAUDE.md +28 -0
- package/hooks/blocking/verified_commit_gate_parts/__init__.py +1 -0
- package/hooks/blocking/verified_commit_gate_parts/command_tokenization.py +174 -0
- package/hooks/blocking/verified_commit_gate_parts/deny_payload.py +53 -0
- package/hooks/blocking/verified_commit_gate_parts/deny_reason.py +80 -0
- package/hooks/blocking/verified_commit_gate_parts/directory_resolution.py +170 -0
- package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +205 -0
- package/hooks/blocking/verified_commit_gate_parts/tests/conftest.py +10 -0
- package/hooks/blocking/verified_commit_gate_parts/tests/test_command_tokenization.py +94 -0
- package/hooks/blocking/verified_commit_gate_parts/tests/test_deny_payload.py +17 -0
- package/hooks/blocking/verified_commit_gate_parts/tests/test_deny_reason.py +38 -0
- package/hooks/blocking/verified_commit_gate_parts/tests/test_directory_resolution.py +71 -0
- package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +61 -0
- package/hooks/hooks_constants/CLAUDE.md +4 -1
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
- package/hooks/hooks_constants/convergence_gate_blocker_constants.py +36 -0
- package/hooks/hooks_constants/dead_module_constant_constants.py +8 -0
- package/hooks/hooks_constants/harness_scratchpad_constants.py +10 -9
- package/hooks/hooks_constants/mypy_integration_constants.py +16 -0
- package/hooks/hooks_constants/pii_prevention_constants.py +1 -0
- package/hooks/validators/mypy_integration.py +145 -24
- package/hooks/validators/python_antipattern_checks.py +16 -0
- package/hooks/validators/run_all_validators.py +9 -3
- package/hooks/validators/test_mypy_integration.py +154 -0
- package/hooks/validators/test_python_antipattern_checks.py +112 -1
- package/hooks/validators/test_run_all_validators_pretooluse.py +16 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +3 -3
- package/rules/agent-spawn-protocol.md +5 -43
- package/rules/code-standards.md +1 -36
- package/rules/env-var-table-code-drift.md +2 -21
- package/rules/hook-prose-matches-detector.md +5 -16
- package/rules/nas-ssh-invocation.md +3 -15
- package/rules/no-historical-clutter.md +7 -49
- package/rules/no-inline-destructive-literals.md +3 -5
- package/rules/package-inventory-stale-entry.md +7 -32
- package/rules/re-stage-before-commit.md +6 -23
- package/rules/shell-invocation-policy.md +1 -1
- package/rules/vault-context.md +3 -3
- package/rules/verified-commit-gate-skip.md +28 -0
- package/rules/workers-done-before-complete.md +2 -30
- package/scripts/claude_chain_runner.py +39 -3
- package/scripts/dev_env_scripts_constants/claude_chain_constants.py +3 -0
- package/scripts/test_claude_chain_runner.py +112 -0
- package/scripts/test_grok_headless_runner.py +0 -1
- package/skills/auditing-claude-config/CLAUDE.md +2 -1
- package/skills/auditing-claude-config/SKILL.md +114 -176
- package/skills/auditing-claude-config/reference/probe-hook.md +74 -0
- package/skills/autoconverge/CLAUDE.md +1 -0
- package/skills/autoconverge/SKILL.md +310 -346
- package/skills/autoconverge/reference/CLAUDE.md +1 -0
- package/skills/autoconverge/reference/copilot-findings.md +51 -0
- package/skills/closeout/SKILL.md +7 -9
- package/skills/copilot-finding-triage/SKILL.md +5 -7
- package/skills/copilot-review/CLAUDE.md +3 -2
- package/skills/copilot-review/SKILL.md +119 -155
- package/skills/copilot-review/templates/subagent-prompt.md +49 -0
- package/skills/everything-search/SKILL.md +7 -1
- package/skills/fresh-branch/CLAUDE.md +6 -9
- package/skills/fresh-branch/SKILL.md +84 -33
- package/skills/fresh-branch/scripts/create_fresh_branch.py +445 -0
- package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/__init__.py +1 -0
- package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +74 -0
- package/skills/fresh-branch/scripts/test_create_fresh_branch.py +670 -0
- package/skills/orchestrator/SKILL.md +161 -147
- package/skills/pr-converge/SKILL.md +277 -422
- package/skills/pr-converge/reference/CLAUDE.md +1 -0
- package/skills/pr-converge/reference/progress-checklist.md +168 -0
- package/skills/pr-converge/test_step5_host_branch.py +8 -6
- package/skills/pr-loop-cloud-transport/SKILL.md +1 -1
- package/skills/privacy-hygiene/SKILL.md +68 -115
- package/skills/privacy-hygiene/reference/sweep-procedure.md +62 -0
- package/skills/session-log/CLAUDE.md +2 -1
- package/skills/session-log/SKILL.md +4 -26
- package/skills/session-log/templates/frontmatter.md +40 -0
- package/skills/skill-builder/CLAUDE.md +8 -7
- package/skills/skill-builder/SKILL.md +26 -11
- package/skills/skill-builder/references/CLAUDE.md +3 -1
- package/skills/skill-builder/references/delegation-map.md +21 -12
- package/skills/skill-builder/references/description-field.md +9 -11
- package/skills/skill-builder/references/deterministic-elements.md +218 -0
- package/skills/skill-builder/references/self-audit-checklist.md +62 -45
- package/skills/skill-builder/references/skill-modularity.md +8 -9
- package/skills/skill-builder/templates/CLAUDE.md +2 -2
- package/skills/skill-builder/templates/gap-analysis.md +15 -0
- package/skills/skill-builder/workflows/CLAUDE.md +5 -5
- package/skills/skill-builder/workflows/improve-skill.md +18 -9
- package/skills/skill-builder/workflows/new-skill.md +23 -15
- package/skills/skill-builder/workflows/polish-skill.md +28 -21
- package/system-prompts/CLAUDE.md +3 -3
- package/docs/agents-md-alignment-plan.md +0 -123
- package/docs/emotion-informed-prompt-design.md +0 -362
- package/rules/es-exe-file-search.md +0 -17
|
@@ -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
|
-
|
|
30
|
-
|
|
31
|
-
import
|
|
32
|
-
|
|
33
|
-
import
|
|
34
|
-
import sys
|
|
35
|
-
from pathlib import Path
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
register_verified_commit_constants
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
from
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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()
|