claude-dev-env 2.3.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +3 -2
- package/agents/CLAUDE.md +1 -0
- package/agents/code-verifier.md +3 -3
- package/agents/skill-writer-agent.md +84 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +141 -41
- package/audit-rubrics/prompts/category-o-docstring-vs-impl-drift.md +29 -13
- package/docs/CLAUDE.md +1 -0
- package/docs/references/CLAUDE.md +1 -0
- package/docs/references/code-review-enforcement.md +97 -0
- package/docs/wsl-docker-cowork-starter-matrix.md +89 -0
- package/hooks/blocking/CLAUDE.md +8 -1
- package/hooks/blocking/code_review_enforcement_config_bootstrap.py +53 -0
- package/hooks/blocking/code_review_gate_deny.py +74 -0
- package/hooks/blocking/code_review_pr_create_gate.py +194 -0
- package/hooks/blocking/code_review_push_gate.py +140 -0
- package/hooks/blocking/code_review_stamp_directory_write_blocker.py +340 -0
- package/hooks/blocking/code_review_stamp_store.py +233 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/__init__.py +7 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/conftest.py +15 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/obfuscated_stamp_path_reference.py +212 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/split_directory_change_into_stamp.py +138 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/test_obfuscated_stamp_path_reference.py +49 -0
- package/hooks/blocking/code_review_stamp_write_blocker_parts/test_split_directory_change_into_stamp.py +38 -0
- package/hooks/blocking/code_verifier_spawn_preflight_gate.py +39 -27
- package/hooks/blocking/config/code_review_enforcement_constants.py +110 -0
- package/hooks/blocking/config/test_code_review_enforcement_constants.py +108 -0
- package/hooks/blocking/config/verified_commit_constants.py +13 -9
- package/hooks/blocking/conftest.py +2 -0
- package/hooks/blocking/convergence_gate_blocker.py +112 -23
- package/hooks/blocking/destructive_command_blocker.py +19 -6
- package/hooks/blocking/pr_description_proof_of_work.py +52 -34
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +4 -1
- package/hooks/blocking/test_code_review_enforcement_config_bootstrap.py +62 -0
- package/hooks/blocking/test_code_review_gate_deny.py +54 -0
- package/hooks/blocking/test_code_review_pr_create_gate.py +185 -0
- package/hooks/blocking/test_code_review_push_gate.py +189 -0
- package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +180 -0
- package/hooks/blocking/test_code_review_stamp_store.py +205 -0
- package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +124 -2
- package/hooks/blocking/test_convergence_gate_blocker.py +153 -5
- package/hooks/blocking/test_destructive_command_blocker.py +1 -1
- package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +45 -0
- package/hooks/blocking/test_pr_description_proof_of_work.py +151 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +8 -8
- package/hooks/blocking/test_verification_verdict_store.py +920 -903
- package/hooks/blocking/test_volatile_path_in_post_blocker.py +114 -2
- package/hooks/blocking/verification_verdict_store.py +27 -5
- package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +29 -17
- package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +35 -0
- package/hooks/blocking/volatile_path_in_post_blocker.py +69 -8
- package/hooks/git-hooks/git_hooks_constants/__init__.py +6 -0
- package/hooks/git-hooks/pre_push.py +89 -2
- package/hooks/git-hooks/test_pre_push.py +103 -0
- package/hooks/hooks.json +16 -1
- package/hooks/hooks_constants/CLAUDE.md +1 -0
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +8 -0
- package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -0
- package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +26 -11
- package/hooks/hooks_constants/convergence_gate_blocker_constants.py +20 -3
- package/hooks/hooks_constants/destructive_command_segment_constants.py +3 -1
- package/hooks/hooks_constants/pr_description_proof_of_work_constants.py +0 -4
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
- package/hooks/hooks_constants/pyproject_config_discovery_constants.py +16 -0
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +24 -0
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +6 -0
- package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +8 -1
- package/hooks/validators/CLAUDE.md +1 -0
- package/hooks/validators/mypy_integration.py +63 -50
- package/hooks/validators/pyproject_config_discovery.py +101 -0
- package/hooks/validators/ruff_integration.py +211 -23
- package/hooks/validators/run_all_validators.py +21 -14
- package/hooks/validators/test_mypy_integration.py +32 -0
- package/hooks/validators/test_pyproject_config_discovery.py +94 -0
- package/hooks/validators/test_ruff_integration.py +68 -1
- package/hooks/validators/test_run_all_validators.py +25 -0
- package/hooks/validators/test_run_all_validators_config_discovery.py +123 -0
- package/package.json +1 -1
- package/rules/docstring-prose-matches-implementation.md +20 -43
- package/rules/durable-post-artifacts.md +7 -0
- package/scripts/codec_forwarding_test_support.py +83 -0
- package/scripts/conftest.py +8 -0
- package/scripts/dev_env_scripts_constants/CLAUDE.md +1 -1
- package/scripts/dev_env_scripts_constants/claude_chain_constants.py +44 -1
- package/scripts/dev_env_scripts_constants/code_review_constants.py +129 -12
- package/scripts/dev_env_scripts_constants/test_code_review_constants.py +55 -0
- package/scripts/invoke_code_review.py +550 -38
- package/scripts/resolve_worker_spawn.py +8 -1
- package/scripts/test_invoke_code_review.py +298 -4
- package/scripts/test_invoke_code_review_codec.py +77 -0
- package/scripts/test_resolve_worker_spawn_codec.py +101 -0
- package/skills/autoconverge/SKILL.md +9 -3
- package/skills/autoconverge/reference/convergence.md +2 -1
- package/skills/autoconverge/reference/multi-pr.md +6 -1
- package/skills/autoconverge/reference/stop-conditions.md +16 -10
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +1 -1
- package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +175 -3
- package/skills/autoconverge/workflow/converge.contract.test.mjs +19 -0
- package/skills/autoconverge/workflow/converge.mjs +24 -8
- package/skills/autoconverge/workflow/converge_multi.mjs +7 -3
- package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +5 -0
- package/skills/fresh-branch/CLAUDE.md +2 -0
- package/skills/fresh-branch/SKILL.md +2 -0
- package/skills/fresh-branch/scripts/create_fresh_branch.py +78 -180
- package/skills/fresh-branch/scripts/fresh_branch_git_commands.py +285 -0
- package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +1 -0
- package/skills/fresh-branch/scripts/pytest.ini +4 -0
- package/skills/fresh-branch/scripts/test_create_fresh_branch.py +98 -0
- package/skills/fresh-branch/scripts/test_fresh_branch_git_commands.py +310 -0
- package/skills/prototype/SKILL.md +86 -0
- package/skills/prototype/reference/honest-limitations.md +23 -0
- package/skills/prototype/reference/promotion-tasks.md +23 -0
- package/skills/prototype/scripts/build_sandbox_settings.py +249 -0
- package/skills/prototype/scripts/conftest.py +15 -0
- package/skills/prototype/scripts/launch_sandbox.py +205 -0
- package/skills/prototype/scripts/probe_sandbox_safety.py +311 -0
- package/skills/prototype/scripts/prototype_scripts_constants/__init__.py +1 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/__init__.py +0 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/build_sandbox_settings_constants.py +41 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/launch_sandbox_constants.py +23 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/probe_sandbox_safety_constants.py +45 -0
- package/skills/prototype/scripts/prototype_scripts_constants/config/prototype_common_constants.py +10 -0
- package/skills/prototype/scripts/test_build_sandbox_settings.py +275 -0
- package/skills/prototype/scripts/test_launch_sandbox.py +303 -0
- package/skills/prototype/scripts/test_probe_sandbox_safety.py +284 -0
- package/skills/prototype/workflows/promotion.md +27 -0
- package/skills/prototype/workflows/sandbox.md +35 -0
- package/skills/skill-builder/CLAUDE.md +3 -3
- package/skills/skill-builder/SKILL.md +5 -5
- package/skills/skill-builder/references/CLAUDE.md +1 -1
- package/skills/skill-builder/references/delegation-map.md +3 -3
- package/skills/skill-builder/references/description-field.md +1 -1
- package/skills/skill-builder/references/skill-modularity.md +2 -3
- package/skills/skill-builder/workflows/CLAUDE.md +1 -1
- package/skills/skill-builder/workflows/improve-skill.md +1 -1
- package/skills/skill-builder/workflows/new-skill.md +2 -2
- package/skills/team-advisor/SKILL.md +2 -2
|
@@ -41,7 +41,7 @@ def test_scan_detects_job_scratch_path_forward_slash() -> None:
|
|
|
41
41
|
|
|
42
42
|
|
|
43
43
|
def test_scan_detects_worktree_path() -> None:
|
|
44
|
-
text = r"edited
|
|
44
|
+
text = r"edited C:\Users\me\.claude\worktrees\feature\file.py"
|
|
45
45
|
assert scan_text_for_volatile_marker(text) == ".claude/worktrees/"
|
|
46
46
|
|
|
47
47
|
|
|
@@ -71,6 +71,103 @@ def test_scan_clean_body_returns_none() -> None:
|
|
|
71
71
|
assert scan_text_for_volatile_marker(text) is None
|
|
72
72
|
|
|
73
73
|
|
|
74
|
+
def test_scan_bare_backticked_worktree_mention_is_allowed() -> None:
|
|
75
|
+
text = "the `.claude/worktrees/` prefix sits in ALL_TOOLING_STATE_PREFIXES"
|
|
76
|
+
assert scan_text_for_volatile_marker(text) is None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_scan_bare_start_of_text_worktree_mention_is_allowed() -> None:
|
|
80
|
+
text = ".claude/worktrees/ entries are skipped by the manifest"
|
|
81
|
+
assert scan_text_for_volatile_marker(text) is None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_scan_windows_absolute_worktree_path_is_detected() -> None:
|
|
85
|
+
text = r"C:\Users\me\.claude\worktrees\wt-1\notes.md"
|
|
86
|
+
assert scan_text_for_volatile_marker(text) == ".claude/worktrees/"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_scan_home_anchored_worktree_path_is_detected() -> None:
|
|
90
|
+
text = "~/.claude/worktrees/wt-2/file.py"
|
|
91
|
+
assert scan_text_for_volatile_marker(text) == ".claude/worktrees/"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_scan_posix_absolute_job_scratch_path_is_detected() -> None:
|
|
95
|
+
text = "/home/me/.claude-editor/jobs/j1/log.txt"
|
|
96
|
+
assert scan_text_for_volatile_marker(text) == ".claude-editor/jobs/"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_scan_bare_parenthesized_job_scratch_mention_is_allowed() -> None:
|
|
100
|
+
text = "(.claude-editor/jobs/) as a directory name in prose"
|
|
101
|
+
assert scan_text_for_volatile_marker(text) is None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_scan_bare_mention_before_anchored_path_is_detected() -> None:
|
|
105
|
+
text = (
|
|
106
|
+
".claude/worktrees/ is the manifest key; the live path is "
|
|
107
|
+
r"C:\Users\me\.claude\worktrees\wt-1\notes.md"
|
|
108
|
+
)
|
|
109
|
+
assert scan_text_for_volatile_marker(text) == ".claude/worktrees/"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_scan_unchanged_bare_markers_still_detected() -> None:
|
|
113
|
+
assert scan_text_for_volatile_marker(r"saved to %TEMP%\x") == "%temp%"
|
|
114
|
+
assert scan_text_for_volatile_marker("log at /tmp/x") == "/tmp/"
|
|
115
|
+
assert (
|
|
116
|
+
scan_text_for_volatile_marker(r"C:\Users\x\AppData\Local\Temp\y")
|
|
117
|
+
== "appdata/local/temp"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_scan_space_prefixed_relative_worktree_path_is_detected() -> None:
|
|
122
|
+
text = "see .claude/worktrees/wt-1/notes.md"
|
|
123
|
+
assert scan_text_for_volatile_marker(text) == ".claude/worktrees/"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_scan_cd_relative_worktree_path_is_detected() -> None:
|
|
127
|
+
text = "cd .claude/worktrees/wt-199 && pytest"
|
|
128
|
+
assert scan_text_for_volatile_marker(text) == ".claude/worktrees/"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_scan_start_of_text_relative_worktree_path_is_detected() -> None:
|
|
132
|
+
text = ".claude/worktrees/wt-1/notes.md"
|
|
133
|
+
assert scan_text_for_volatile_marker(text) == ".claude/worktrees/"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_scan_markdown_link_relative_worktree_target_is_detected() -> None:
|
|
137
|
+
text = "[notes](.claude/worktrees/wt-1/notes.md)"
|
|
138
|
+
assert scan_text_for_volatile_marker(text) == ".claude/worktrees/"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_scan_space_prefixed_relative_job_scratch_path_is_detected() -> None:
|
|
142
|
+
text = "see .claude-editor/jobs/j1/log.txt"
|
|
143
|
+
assert scan_text_for_volatile_marker(text) == ".claude-editor/jobs/"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_scan_backslash_relative_worktree_path_is_detected() -> None:
|
|
147
|
+
text = r"see .claude\worktrees\wt-1\notes.md"
|
|
148
|
+
assert scan_text_for_volatile_marker(text) == ".claude/worktrees/"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_scan_prose_child_token_after_marker_is_detected() -> None:
|
|
152
|
+
text = "the .claude/worktrees/wt-name pattern"
|
|
153
|
+
assert scan_text_for_volatile_marker(text) == ".claude/worktrees/"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_scan_relative_worktree_path_with_hyphen_child_is_detected() -> None:
|
|
157
|
+
text = "see .claude/worktrees/-scratch/notes.md"
|
|
158
|
+
assert scan_text_for_volatile_marker(text) == ".claude/worktrees/"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_scan_placeholder_child_segment_is_allowed() -> None:
|
|
162
|
+
text = "worktrees live under `.claude/worktrees/<name>`"
|
|
163
|
+
assert scan_text_for_volatile_marker(text) is None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_scan_sentence_final_marker_period_is_allowed() -> None:
|
|
167
|
+
text = "the constant is `.claude/worktrees/`. Next."
|
|
168
|
+
assert scan_text_for_volatile_marker(text) is None
|
|
169
|
+
|
|
170
|
+
|
|
74
171
|
def test_gh_comment_with_job_scratch_path_is_blocked() -> None:
|
|
75
172
|
command = (
|
|
76
173
|
'gh pr comment 669 --body "Contact sheet at '
|
|
@@ -147,7 +244,7 @@ def test_mcp_issue_comment_body_blocked() -> None:
|
|
|
147
244
|
|
|
148
245
|
|
|
149
246
|
def test_mcp_review_comment_param_blocked() -> None:
|
|
150
|
-
tool_input: dict[str, object] = {"comment": r"see
|
|
247
|
+
tool_input: dict[str, object] = {"comment": r"see C:\Users\me\.claude\worktrees\x\file"}
|
|
151
248
|
assert _body_names_volatile_path(
|
|
152
249
|
"mcp__plugin_github_github__add_reply_to_pull_request_comment", tool_input
|
|
153
250
|
)
|
|
@@ -177,6 +274,21 @@ def test_extract_gh_post_body_texts_returns_inline_and_file(tmp_path: pathlib.Pa
|
|
|
177
274
|
assert "file body text" in all_texts
|
|
178
275
|
|
|
179
276
|
|
|
277
|
+
def test_gh_issue_edit_quoting_bare_constant_is_allowed(tmp_path: pathlib.Path) -> None:
|
|
278
|
+
body_file = tmp_path / "issue_body.md"
|
|
279
|
+
body_file.write_text(
|
|
280
|
+
"The manifest skips these tooling-state prefixes:\n"
|
|
281
|
+
' ".claude/worktrees/",\n'
|
|
282
|
+
' ".claude-editor/jobs/",\n'
|
|
283
|
+
"Neither is a machine-local path.",
|
|
284
|
+
encoding="utf-8",
|
|
285
|
+
)
|
|
286
|
+
command = f"gh issue edit 134 --body-file {body_file}"
|
|
287
|
+
all_body_texts = extract_gh_post_body_texts(command)
|
|
288
|
+
assert all_body_texts
|
|
289
|
+
assert hook_module._first_volatile_marker(all_body_texts) is None
|
|
290
|
+
|
|
291
|
+
|
|
180
292
|
def test_hook_subprocess_denies_volatile_gh_comment() -> None:
|
|
181
293
|
payload = {
|
|
182
294
|
"tool_name": "Bash",
|
|
@@ -390,12 +390,36 @@ def verdict_directory() -> Path:
|
|
|
390
390
|
return Path.home() / CLAUDE_HOME_DIRECTORY_NAME / VERDICT_DIRECTORY_NAME
|
|
391
391
|
|
|
392
392
|
|
|
393
|
+
def root_key_for_repo(repo_root: str) -> str:
|
|
394
|
+
"""Derive the shared path-key that names a work tree's store file.
|
|
395
|
+
|
|
396
|
+
::
|
|
397
|
+
|
|
398
|
+
root_key_for_repo("/repo") == root_key_for_repo("/repo/sub") (same tree)
|
|
399
|
+
root_key_for_repo("/Repo") == root_key_for_repo("/repo") (case-folded)
|
|
400
|
+
|
|
401
|
+
The path is resolved, forward-slashed, and lowercased before hashing, so a
|
|
402
|
+
subdirectory of a work tree keys to the same file as its root and case
|
|
403
|
+
differences fold together. Both the verdict store and the code-review stamp
|
|
404
|
+
store key their per-work-tree files by this one derivation, so the two never
|
|
405
|
+
drift and an existing verdict file still resolves.
|
|
406
|
+
|
|
407
|
+
Args:
|
|
408
|
+
repo_root: The repository top-level directory.
|
|
409
|
+
|
|
410
|
+
Returns:
|
|
411
|
+
The lowercased hex path-key, truncated to ``ROOT_KEY_HEX_LENGTH`` chars.
|
|
412
|
+
"""
|
|
413
|
+
normalized_root = str(Path(repo_root).resolve()).replace("\\", "/").lower()
|
|
414
|
+
return hashlib.sha256(normalized_root.encode("utf-8")).hexdigest()[:ROOT_KEY_HEX_LENGTH]
|
|
415
|
+
|
|
416
|
+
|
|
393
417
|
def verdict_path_for_repo(repo_root: str) -> Path:
|
|
394
418
|
"""Derive the verdict file path for a repository work tree.
|
|
395
419
|
|
|
396
420
|
Verdicts live outside the repository (under the user's Claude home) so
|
|
397
|
-
no repo accumulates untracked files, keyed by
|
|
398
|
-
|
|
421
|
+
no repo accumulates untracked files, keyed by the shared
|
|
422
|
+
``root_key_for_repo`` derivation so every worktree gets its own verdict.
|
|
399
423
|
|
|
400
424
|
Args:
|
|
401
425
|
repo_root: The repository top-level directory.
|
|
@@ -403,9 +427,7 @@ def verdict_path_for_repo(repo_root: str) -> Path:
|
|
|
403
427
|
Returns:
|
|
404
428
|
The verdict file path for this work tree.
|
|
405
429
|
"""
|
|
406
|
-
|
|
407
|
-
root_key = hashlib.sha256(normalized_root.encode("utf-8")).hexdigest()[:ROOT_KEY_HEX_LENGTH]
|
|
408
|
-
return verdict_directory() / f"{root_key}.json"
|
|
430
|
+
return verdict_directory() / f"{root_key_for_repo(repo_root)}.json"
|
|
409
431
|
|
|
410
432
|
|
|
411
433
|
def load_valid_verdict(repo_root: str, expected_manifest_sha256: str) -> dict | None:
|
|
@@ -81,14 +81,15 @@ def _resolved_option_value(
|
|
|
81
81
|
return value_after_option(all_following_tokens, option_index)
|
|
82
82
|
|
|
83
83
|
|
|
84
|
-
def gated_invocation_directory(
|
|
84
|
+
def gated_invocation_directory(
|
|
85
|
+
all_following_tokens: list[str],
|
|
86
|
+
all_gated_subcommands: frozenset[str] = GATED_GIT_SUBCOMMANDS,
|
|
87
|
+
) -> tuple[bool, str | None]:
|
|
85
88
|
"""Walk the quote-stripped tokens after a ``git`` word to its subcommand.
|
|
86
89
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
Returns:
|
|
91
|
-
Whether the subcommand is gated, and its ``-C``/``--work-tree`` directory.
|
|
90
|
+
Args:
|
|
91
|
+
all_following_tokens: Quote-stripped tokens after the ``git`` word.
|
|
92
|
+
all_gated_subcommands: Subcommand names that gate; defaults to commit+push.
|
|
92
93
|
"""
|
|
93
94
|
repo_directory: str | None = None
|
|
94
95
|
work_tree_directory: str | None = None
|
|
@@ -108,7 +109,7 @@ def gated_invocation_directory(all_following_tokens: list[str]) -> tuple[bool, s
|
|
|
108
109
|
if each_token.startswith("-"):
|
|
109
110
|
token_index += 1
|
|
110
111
|
continue
|
|
111
|
-
return each_token.lower() in
|
|
112
|
+
return each_token.lower() in all_gated_subcommands, repo_directory or work_tree_directory
|
|
112
113
|
return False, repo_directory or work_tree_directory
|
|
113
114
|
|
|
114
115
|
|
|
@@ -167,11 +168,16 @@ def _apply_directory_change(
|
|
|
167
168
|
|
|
168
169
|
|
|
169
170
|
def _target_directory_for_match(
|
|
170
|
-
command_text: str,
|
|
171
|
+
command_text: str,
|
|
172
|
+
git_word_match: re.Match[str],
|
|
173
|
+
active_directory: str,
|
|
174
|
+
all_gated_subcommands: frozenset[str],
|
|
171
175
|
) -> str | None:
|
|
172
176
|
"""Resolve the gated directory for one ``git`` word match, or None."""
|
|
173
177
|
all_following_tokens = _following_tokens(command_text, git_word_match.end())
|
|
174
|
-
is_gated, flagged_directory = gated_invocation_directory(
|
|
178
|
+
is_gated, flagged_directory = gated_invocation_directory(
|
|
179
|
+
all_following_tokens, all_gated_subcommands
|
|
180
|
+
)
|
|
175
181
|
if not is_gated:
|
|
176
182
|
return None
|
|
177
183
|
if flagged_directory is None:
|
|
@@ -179,18 +185,22 @@ def _target_directory_for_match(
|
|
|
179
185
|
return resolve_against(active_directory, flagged_directory)
|
|
180
186
|
|
|
181
187
|
|
|
182
|
-
def gated_repo_directories(
|
|
183
|
-
|
|
188
|
+
def gated_repo_directories(
|
|
189
|
+
command_text: str,
|
|
190
|
+
fallback_directory: str,
|
|
191
|
+
all_gated_subcommands: frozenset[str] = GATED_GIT_SUBCOMMANDS,
|
|
192
|
+
) -> list[str]:
|
|
193
|
+
"""Collect the directories of every gated git call found in a command.
|
|
184
194
|
|
|
185
195
|
Args:
|
|
186
196
|
command_text: The raw command string from the tool payload.
|
|
187
|
-
fallback_directory: The session working directory,
|
|
188
|
-
|
|
189
|
-
|
|
197
|
+
fallback_directory: The session working directory, the active directory
|
|
198
|
+
until a directory-change verb or a ``-C`` flag overrides it.
|
|
199
|
+
all_gated_subcommands: Subcommand names that gate; defaults to commit+push.
|
|
190
200
|
|
|
191
201
|
Returns:
|
|
192
|
-
One directory per detected
|
|
193
|
-
|
|
202
|
+
One directory per detected gated invocation, in order; empty when the
|
|
203
|
+
command carries no gated git verb.
|
|
194
204
|
"""
|
|
195
205
|
command_text = collapse_line_continuations(command_text)
|
|
196
206
|
active_directory = fallback_directory
|
|
@@ -199,7 +209,9 @@ def gated_repo_directories(command_text: str, fallback_directory: str) -> list[s
|
|
|
199
209
|
if each_match.group().lower().strip("\"'") in DIRECTORY_CHANGE_VERBS:
|
|
200
210
|
active_directory = _apply_directory_change(command_text, each_match, active_directory)
|
|
201
211
|
continue
|
|
202
|
-
target_directory = _target_directory_for_match(
|
|
212
|
+
target_directory = _target_directory_for_match(
|
|
213
|
+
command_text, each_match, active_directory, all_gated_subcommands
|
|
214
|
+
)
|
|
203
215
|
if target_directory is not None:
|
|
204
216
|
target_directories.append(target_directory)
|
|
205
217
|
return target_directories
|
|
@@ -59,3 +59,38 @@ def test_gated_repo_directories_gates_an_empty_work_tree_against_the_session() -
|
|
|
59
59
|
assert gated_repo_directories("git --work-tree= commit -m x", "/session") == [
|
|
60
60
|
os.path.join("/session", "")
|
|
61
61
|
]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_default_gated_subcommands_gates_both_commit_and_push() -> None:
|
|
65
|
+
assert gated_repo_directories("git commit -m x", "/session") == ["/session"]
|
|
66
|
+
assert gated_repo_directories("git push", "/session") == ["/session"]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_push_only_subcommands_gate_push_but_not_commit() -> None:
|
|
70
|
+
push_only = frozenset({"push"})
|
|
71
|
+
assert gated_invocation_directory(["push"], push_only)[0] is True
|
|
72
|
+
assert gated_invocation_directory(["commit", "-m", "x"], push_only)[0] is False
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_commit_only_subcommands_gate_commit_but_not_push() -> None:
|
|
76
|
+
commit_only = frozenset({"commit"})
|
|
77
|
+
assert gated_invocation_directory(["commit", "-m", "x"], commit_only)[0] is True
|
|
78
|
+
assert gated_invocation_directory(["push"], commit_only)[0] is False
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_gated_repo_directories_threads_push_only_subcommands() -> None:
|
|
82
|
+
push_only = frozenset({"push"})
|
|
83
|
+
assert gated_repo_directories("git push", "/session", push_only) == ["/session"]
|
|
84
|
+
assert gated_repo_directories("git commit -m x", "/session", push_only) == []
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_gated_repo_directories_accepts_the_all_gated_subcommands_keyword() -> None:
|
|
88
|
+
push_only = frozenset({"push"})
|
|
89
|
+
assert gated_repo_directories(
|
|
90
|
+
"git push", "/session", all_gated_subcommands=push_only
|
|
91
|
+
) == ["/session"]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_gated_invocation_directory_accepts_the_all_gated_subcommands_keyword() -> None:
|
|
95
|
+
push_only = frozenset({"push"})
|
|
96
|
+
assert gated_invocation_directory(["push"], all_gated_subcommands=push_only)[0] is True
|
|
@@ -18,6 +18,15 @@ and scanned, since that content is what gets embedded in the post). The ``gh``
|
|
|
18
18
|
command is tokenized with ``shlex.split`` and the ``gh`` word must be the first
|
|
19
19
|
command token, so a ``gh pr comment`` that only appears as quoted data inside
|
|
20
20
|
another argument never classifies.
|
|
21
|
+
|
|
22
|
+
The marker scan runs in two tiers over the normalized body. Five bare markers
|
|
23
|
+
— the system temp locations and env tokens — match as a plain substring
|
|
24
|
+
anywhere. Two dot-relative directory markers, ``.claude-editor/jobs/`` and
|
|
25
|
+
``.claude/worktrees/``, match when either a slash sits immediately before the
|
|
26
|
+
marker or a path segment follows it. A machine-local absolute path
|
|
27
|
+
(``~/.claude/worktrees/wt/f.py``) and a relative path that names a child
|
|
28
|
+
(``see .claude/worktrees/wt-1/notes.md``) both count; a bare directory-name
|
|
29
|
+
mention with neither anchor posts cleanly.
|
|
21
30
|
"""
|
|
22
31
|
|
|
23
32
|
import json
|
|
@@ -42,30 +51,79 @@ from blocking._gh_body_arg_utils import ( # noqa: E402
|
|
|
42
51
|
)
|
|
43
52
|
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
44
53
|
from hooks_constants.volatile_path_in_post_blocker_constants import ( # noqa: E402
|
|
54
|
+
ALL_BARE_VOLATILE_PATH_MARKERS,
|
|
45
55
|
ALL_GH_POST_SUBCOMMANDS,
|
|
46
56
|
ALL_MCP_BODY_PARAM_NAMES,
|
|
47
|
-
|
|
57
|
+
ALL_PATH_ANCHORED_VOLATILE_PATH_MARKERS,
|
|
48
58
|
BASH_TOOL_NAME,
|
|
49
59
|
BODY_FILE_ENCODING,
|
|
50
60
|
CORRECTIVE_MESSAGE,
|
|
51
61
|
GH_COMMAND_NAME,
|
|
52
62
|
MCP_GITHUB_TOOL_PREFIX,
|
|
53
63
|
MINIMUM_POST_SUBCOMMAND_TOKEN_COUNT,
|
|
64
|
+
PATH_ANCHOR_CHARACTER,
|
|
65
|
+
PATH_SEGMENT_START_CHARACTERS,
|
|
54
66
|
TOKEN_JOIN_SEPARATOR,
|
|
55
67
|
)
|
|
56
68
|
|
|
57
69
|
|
|
70
|
+
def _character_starts_path_segment(character: str) -> bool:
|
|
71
|
+
"""Return whether a character can open a path segment after a marker."""
|
|
72
|
+
return bool(character) and (
|
|
73
|
+
character.isalnum() or character in PATH_SEGMENT_START_CHARACTERS
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _text_has_anchored_marker(normalized_text: str, marker: str) -> bool:
|
|
78
|
+
"""Return whether the marker appears as part of a path.
|
|
79
|
+
|
|
80
|
+
A match counts when either a slash sits immediately before the marker or a
|
|
81
|
+
path segment follows it. Every occurrence is scanned, so a bare directory
|
|
82
|
+
mention earlier in the text never masks a path occurrence later::
|
|
83
|
+
|
|
84
|
+
ok: ".claude/worktrees/ is the manifest key" -> False
|
|
85
|
+
flag: "path c:/users/me/.claude/worktrees/wt/f.py" -> True
|
|
86
|
+
flag: "see .claude/worktrees/wt-1/notes.md" -> True
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
normalized_text: The body text with backslashes folded to forward
|
|
90
|
+
slashes and lowercased.
|
|
91
|
+
marker: A dot-relative directory marker to search for.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
True when at least one occurrence is path-anchored.
|
|
95
|
+
"""
|
|
96
|
+
search_start = 0
|
|
97
|
+
while True:
|
|
98
|
+
found_index = normalized_text.find(marker, search_start)
|
|
99
|
+
if found_index < 0:
|
|
100
|
+
return False
|
|
101
|
+
preceding_character = normalized_text[found_index - 1 : found_index]
|
|
102
|
+
following_index = found_index + len(marker)
|
|
103
|
+
following_character = normalized_text[following_index : following_index + 1]
|
|
104
|
+
is_slash_before = preceding_character == PATH_ANCHOR_CHARACTER
|
|
105
|
+
is_segment_after = _character_starts_path_segment(following_character)
|
|
106
|
+
if is_slash_before or is_segment_after:
|
|
107
|
+
return True
|
|
108
|
+
search_start = found_index + 1
|
|
109
|
+
|
|
110
|
+
|
|
58
111
|
def scan_text_for_volatile_marker(text: str) -> str | None:
|
|
59
112
|
"""Return the first volatile-path marker found in text, or None.
|
|
60
113
|
|
|
61
114
|
Backslashes normalize to forward slashes and the text lowercases before the
|
|
62
|
-
scan
|
|
115
|
+
scan. The two dot-relative directory markers count when either a slash sits
|
|
116
|
+
immediately before them or a path segment follows them; the five bare
|
|
117
|
+
markers match anywhere::
|
|
63
118
|
|
|
64
|
-
ok: "
|
|
65
|
-
flag: r"C:\\Users\\me\\.claude-editor\\jobs\\x"
|
|
119
|
+
ok: "the `.claude/worktrees/` manifest key" -> None
|
|
120
|
+
flag: r"C:\\Users\\me\\.claude-editor\\jobs\\x" -> ".claude-editor/jobs/"
|
|
121
|
+
flag: "see .claude/worktrees/wt-1/notes.md" -> ".claude/worktrees/"
|
|
122
|
+
flag: "saved to %TEMP%\\out.txt" -> "%temp%"
|
|
66
123
|
|
|
67
|
-
|
|
68
|
-
|
|
124
|
+
A dot-relative marker with neither a slash before it nor a path segment
|
|
125
|
+
after it — start of text naming the directory, a quoted config constant,
|
|
126
|
+
or a placeholder form such as ``.claude/worktrees/<name>`` — posts cleanly.
|
|
69
127
|
|
|
70
128
|
Args:
|
|
71
129
|
text: The post body text to scan.
|
|
@@ -73,8 +131,11 @@ def scan_text_for_volatile_marker(text: str) -> str | None:
|
|
|
73
131
|
Returns:
|
|
74
132
|
The matched marker string, or None when the text names no volatile path.
|
|
75
133
|
"""
|
|
76
|
-
normalized_text = text.replace("\\",
|
|
77
|
-
for each_marker in
|
|
134
|
+
normalized_text = text.replace("\\", PATH_ANCHOR_CHARACTER).lower()
|
|
135
|
+
for each_marker in ALL_PATH_ANCHORED_VOLATILE_PATH_MARKERS:
|
|
136
|
+
if _text_has_anchored_marker(normalized_text, each_marker):
|
|
137
|
+
return each_marker
|
|
138
|
+
for each_marker in ALL_BARE_VOLATILE_PATH_MARKERS:
|
|
78
139
|
if each_marker in normalized_text:
|
|
79
140
|
return each_marker
|
|
80
141
|
return None
|
|
@@ -49,6 +49,12 @@ NO_PARSEABLE_STDIN_LINES_MESSAGE: str = (
|
|
|
49
49
|
)
|
|
50
50
|
NO_PARSEABLE_STDIN_LINES_SENTINEL: str = "__no_parseable_stdin_lines__"
|
|
51
51
|
LOCAL_BRANCH_REFERENCE_PREFIX: str = "refs/heads/"
|
|
52
|
+
CODE_REVIEW_PUSH_GATE_PATH_OVERRIDE_ENV_VAR: str = "CODE_REVIEW_PUSH_GATE_PATH"
|
|
53
|
+
CODE_REVIEW_PUSH_GATE_MODULE_NAME: str = "code_review_push_gate"
|
|
54
|
+
CODE_REVIEW_PUSH_GATE_SCRIPT_FILENAME: str = "code_review_push_gate.py"
|
|
55
|
+
CODE_REVIEW_DENY_REASON_FUNCTION_NAME: str = "deny_reason_for_directory"
|
|
56
|
+
BLOCKING_DIRECTORY_NAME: str = "blocking"
|
|
57
|
+
CODE_REVIEW_STAMP_BLOCK_EXIT_CODE: int = 1
|
|
52
58
|
ALL_PROTECTED_BRANCH_PUSH_NAMES: tuple[str, ...] = ("main", "master")
|
|
53
59
|
PROTECTED_BRANCH_PUSH_BLOCK_EXIT_CODE: int = 1
|
|
54
60
|
PROTECTED_BRANCH_PUSH_BLOCK_MESSAGE: str = (
|
|
@@ -30,6 +30,8 @@ Exit codes:
|
|
|
30
30
|
|
|
31
31
|
from __future__ import annotations
|
|
32
32
|
|
|
33
|
+
import importlib.util
|
|
34
|
+
import os
|
|
33
35
|
import subprocess
|
|
34
36
|
import sys
|
|
35
37
|
from pathlib import Path
|
|
@@ -38,6 +40,12 @@ from git_hooks_constants import (
|
|
|
38
40
|
ALL_PROTECTED_BRANCH_PUSH_NAMES,
|
|
39
41
|
ALL_ZEROS_OBJECT_NAME_CHARACTER,
|
|
40
42
|
BASE_REFERENCE_ARGUMENT,
|
|
43
|
+
BLOCKING_DIRECTORY_NAME,
|
|
44
|
+
CODE_REVIEW_DENY_REASON_FUNCTION_NAME,
|
|
45
|
+
CODE_REVIEW_PUSH_GATE_MODULE_NAME,
|
|
46
|
+
CODE_REVIEW_PUSH_GATE_PATH_OVERRIDE_ENV_VAR,
|
|
47
|
+
CODE_REVIEW_PUSH_GATE_SCRIPT_FILENAME,
|
|
48
|
+
CODE_REVIEW_STAMP_BLOCK_EXIT_CODE,
|
|
41
49
|
DEFAULT_REMOTE_BASE_REFERENCE,
|
|
42
50
|
GATE_INFRASTRUCTURE_FAILURE_EXIT_CODE,
|
|
43
51
|
INVOKE_GATE_FAILURE_MESSAGE,
|
|
@@ -158,6 +166,82 @@ def invoke_gate(gate_script_path: Path, base_reference: str) -> int:
|
|
|
158
166
|
return completion.returncode
|
|
159
167
|
|
|
160
168
|
|
|
169
|
+
def resolve_code_review_gate_path() -> tuple[Path, Path | None]:
|
|
170
|
+
"""Return the code-review push gate script path and its exact-allow override.
|
|
171
|
+
|
|
172
|
+
The override environment variable, when set, names the only path the trust
|
|
173
|
+
check accepts; otherwise the gate script resolves to the ``blocking``
|
|
174
|
+
directory beside this hook's ``git-hooks`` directory, which holds in both
|
|
175
|
+
the repository layout and the installed ``~/.claude/hooks`` layout.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
A pair of the resolved gate script path and either the exact override
|
|
179
|
+
path (override set) or None (trust-root case).
|
|
180
|
+
"""
|
|
181
|
+
override_path_raw = os.environ.get(
|
|
182
|
+
CODE_REVIEW_PUSH_GATE_PATH_OVERRIDE_ENV_VAR, ""
|
|
183
|
+
).strip()
|
|
184
|
+
if override_path_raw:
|
|
185
|
+
exact_override = Path(override_path_raw).resolve()
|
|
186
|
+
return exact_override, exact_override
|
|
187
|
+
blocking_directory = Path(__file__).resolve().parent.parent / BLOCKING_DIRECTORY_NAME
|
|
188
|
+
return blocking_directory / CODE_REVIEW_PUSH_GATE_SCRIPT_FILENAME, None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def load_code_review_deny_reason(gate_script_path: Path, work_tree_directory: str) -> str | None:
|
|
192
|
+
"""Import the gate script and read its push deny reason for a work tree.
|
|
193
|
+
|
|
194
|
+
Any import or evaluation failure reads as no deny reason.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
gate_script_path: The resolved code-review push gate script path.
|
|
198
|
+
work_tree_directory: The work tree the push targets.
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
The gate's deny reason when the surface lacks a covering low stamp;
|
|
202
|
+
None when the gate allows the push or could not be evaluated.
|
|
203
|
+
"""
|
|
204
|
+
try:
|
|
205
|
+
gate_specification = importlib.util.spec_from_file_location(
|
|
206
|
+
CODE_REVIEW_PUSH_GATE_MODULE_NAME, str(gate_script_path)
|
|
207
|
+
)
|
|
208
|
+
if gate_specification is None or gate_specification.loader is None:
|
|
209
|
+
return None
|
|
210
|
+
gate_module = importlib.util.module_from_spec(gate_specification)
|
|
211
|
+
gate_specification.loader.exec_module(gate_module)
|
|
212
|
+
deny_reason_function = getattr(gate_module, CODE_REVIEW_DENY_REASON_FUNCTION_NAME, None)
|
|
213
|
+
if deny_reason_function is None:
|
|
214
|
+
return None
|
|
215
|
+
deny_reason = deny_reason_function(work_tree_directory)
|
|
216
|
+
except Exception:
|
|
217
|
+
return None
|
|
218
|
+
if isinstance(deny_reason, str) and deny_reason:
|
|
219
|
+
return deny_reason
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def code_review_stamp_block_exit_code() -> int:
|
|
224
|
+
"""Block the push when no clean low code-review stamp covers the surface.
|
|
225
|
+
|
|
226
|
+
Runs the gate decision only when the gate script sits at a trusted
|
|
227
|
+
installed location (or the exact override path). When the gate is absent
|
|
228
|
+
from a trusted location, the check is skipped and the push is allowed,
|
|
229
|
+
matching the CODE_RULES gate's fail-open posture.
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
The block exit code when a covering low stamp is missing; 0 when the
|
|
233
|
+
surface is covered or the gate is not installed at a trusted location.
|
|
234
|
+
"""
|
|
235
|
+
gate_script_path, exact_allowed_path = resolve_code_review_gate_path()
|
|
236
|
+
if not is_safe_regular_file(gate_script_path, exact_allowed_path):
|
|
237
|
+
return 0
|
|
238
|
+
deny_reason = load_code_review_deny_reason(gate_script_path, os.getcwd())
|
|
239
|
+
if deny_reason is None:
|
|
240
|
+
return 0
|
|
241
|
+
sys.stderr.write(deny_reason + "\n")
|
|
242
|
+
return CODE_REVIEW_STAMP_BLOCK_EXIT_CODE
|
|
243
|
+
|
|
244
|
+
|
|
161
245
|
def main() -> int:
|
|
162
246
|
stdin_read_failure_message = STDIN_READ_FAILURE_MESSAGE
|
|
163
247
|
gate_infrastructure_failure_exit_code = GATE_INFRASTRUCTURE_FAILURE_EXIT_CODE
|
|
@@ -191,14 +275,17 @@ def main() -> int:
|
|
|
191
275
|
pre_push_gate_script_not_found_message.format(path=gate_script_path),
|
|
192
276
|
file=sys.stderr,
|
|
193
277
|
)
|
|
194
|
-
return
|
|
278
|
+
return code_review_stamp_block_exit_code()
|
|
195
279
|
base_reference = resolve_base_reference_from_stdin(stdin_text)
|
|
196
280
|
if base_reference is None:
|
|
197
281
|
return 0
|
|
198
282
|
if base_reference == no_parseable_stdin_lines_sentinel:
|
|
199
283
|
print(no_parseable_stdin_lines_message, file=sys.stderr)
|
|
200
284
|
return gate_infrastructure_failure_exit_code
|
|
201
|
-
|
|
285
|
+
code_rules_exit_code = invoke_gate(gate_script_path, base_reference)
|
|
286
|
+
if code_rules_exit_code != 0:
|
|
287
|
+
return code_rules_exit_code
|
|
288
|
+
return code_review_stamp_block_exit_code()
|
|
202
289
|
|
|
203
290
|
|
|
204
291
|
if __name__ == "__main__":
|