claude-dev-env 1.26.5 → 1.28.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/bin/git_hooks_installer.mjs +245 -0
- package/bin/git_hooks_installer.test.mjs +208 -0
- package/bin/install.mjs +68 -1
- package/hooks/blocking/destructive_command_blocker.py +63 -10
- package/hooks/blocking/tdd_enforcer.py +52 -6
- package/hooks/blocking/test_destructive_command_blocker.py +169 -0
- package/hooks/blocking/test_tdd_enforcer.py +126 -3
- package/hooks/config/messages.py +1 -1
- package/hooks/config/test_messages.py +13 -0
- package/hooks/git-hooks/config.py +48 -0
- package/hooks/git-hooks/gate_utils.py +86 -0
- package/hooks/git-hooks/pre_commit.py +61 -0
- package/hooks/git-hooks/pre_push.py +146 -0
- package/hooks/git-hooks/test_config.py +24 -0
- package/hooks/git-hooks/test_gate_utils.py +225 -0
- package/hooks/git-hooks/test_pre_commit.py +179 -0
- package/hooks/git-hooks/test_pre_push.py +316 -0
- package/hooks/hooks.json +0 -5
- package/package.json +4 -1
- package/skills/bugteam/scripts/bugteam_code_rules_gate.py +150 -0
- package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +271 -0
- package/skills/qbug/SKILL.md +318 -0
|
@@ -6,12 +6,19 @@ Blocks writes to production source files when no matching test exists
|
|
|
6
6
|
or the matching test has not been modified within the configured
|
|
7
7
|
freshness window. Enforces "TDD IS NON-NEGOTIABLE" from CLAUDE.md.
|
|
8
8
|
"""
|
|
9
|
+
import ast
|
|
9
10
|
import json
|
|
10
11
|
import re
|
|
11
12
|
import sys
|
|
12
13
|
import time
|
|
13
14
|
from pathlib import Path
|
|
14
15
|
|
|
16
|
+
_hooks_root_path_string = str(Path(__file__).resolve().parent.parent)
|
|
17
|
+
if _hooks_root_path_string not in sys.path:
|
|
18
|
+
sys.path.insert(0, _hooks_root_path_string)
|
|
19
|
+
|
|
20
|
+
from config.messages import USER_FACING_TDD_NOTICE
|
|
21
|
+
|
|
15
22
|
PRODUCTION_EXTENSIONS = {'.py', '.ts', '.tsx', '.js', '.jsx'}
|
|
16
23
|
SKIP_PATTERNS = {
|
|
17
24
|
'test_', '_test.', '.test.', 'tests/', '__tests__/',
|
|
@@ -33,8 +40,41 @@ def _freshness_seconds() -> int:
|
|
|
33
40
|
return 600
|
|
34
41
|
|
|
35
42
|
|
|
36
|
-
def
|
|
37
|
-
return
|
|
43
|
+
def _constants_only_allowed_node_types() -> tuple[type, ...]:
|
|
44
|
+
return (
|
|
45
|
+
ast.Import,
|
|
46
|
+
ast.ImportFrom,
|
|
47
|
+
ast.Assign,
|
|
48
|
+
ast.AnnAssign,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _is_module_docstring_expression(module_level_node: ast.stmt) -> bool:
|
|
53
|
+
if not isinstance(module_level_node, ast.Expr):
|
|
54
|
+
return False
|
|
55
|
+
expression_value = module_level_node.value
|
|
56
|
+
if not isinstance(expression_value, ast.Constant):
|
|
57
|
+
return False
|
|
58
|
+
return isinstance(expression_value.value, str)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _is_constants_only_python_content(content: str) -> bool:
|
|
62
|
+
if not content.strip():
|
|
63
|
+
return False
|
|
64
|
+
try:
|
|
65
|
+
parsed_tree = ast.parse(content)
|
|
66
|
+
except SyntaxError:
|
|
67
|
+
return False
|
|
68
|
+
if not parsed_tree.body:
|
|
69
|
+
return False
|
|
70
|
+
allowed_node_types = _constants_only_allowed_node_types()
|
|
71
|
+
for each_top_level_node in parsed_tree.body:
|
|
72
|
+
if isinstance(each_top_level_node, allowed_node_types):
|
|
73
|
+
continue
|
|
74
|
+
if _is_module_docstring_expression(each_top_level_node):
|
|
75
|
+
continue
|
|
76
|
+
return False
|
|
77
|
+
return True
|
|
38
78
|
|
|
39
79
|
|
|
40
80
|
def _tests_directory_name() -> str:
|
|
@@ -154,13 +194,17 @@ def has_fresh_test(
|
|
|
154
194
|
|
|
155
195
|
def build_deny_reason(production_path: Path, all_candidates: list[Path]) -> str:
|
|
156
196
|
candidate_lines = "\n".join(f" - {each_path}" for each_path in all_candidates)
|
|
197
|
+
hook_source_path = Path(__file__).resolve()
|
|
157
198
|
return (
|
|
158
199
|
f"[TDD] Blocking write to production file: {production_path}\n"
|
|
159
200
|
f"No matching test file exists, or it has not been modified within the last "
|
|
160
201
|
f"{_freshness_seconds()} seconds.\n"
|
|
161
202
|
f"Expected one of:\n{candidate_lines}\n"
|
|
162
|
-
f"Write a failing test first (RED), then the minimum code to pass it (GREEN).\n"
|
|
163
|
-
f"
|
|
203
|
+
f"Write a failing test first (RED), then the minimum code to pass it (GREEN).\n\n"
|
|
204
|
+
f"If this file legitimately does not need a test (for example, a module containing only "
|
|
205
|
+
f"module-level constants with no behavior), that is a hook enhancement, not a bypass. "
|
|
206
|
+
f"Propose an exemption rule in {hook_source_path} so every similar file benefits "
|
|
207
|
+
f"automatically. Do not add escape-hatch markers to production files."
|
|
164
208
|
)
|
|
165
209
|
|
|
166
210
|
|
|
@@ -180,7 +224,9 @@ def emit_deny(reason: str) -> None:
|
|
|
180
224
|
"hookEventName": "PreToolUse",
|
|
181
225
|
"permissionDecision": "deny",
|
|
182
226
|
"permissionDecisionReason": reason,
|
|
183
|
-
}
|
|
227
|
+
},
|
|
228
|
+
"suppressOutput": True,
|
|
229
|
+
"systemMessage": USER_FACING_TDD_NOTICE,
|
|
184
230
|
}
|
|
185
231
|
print(json.dumps(deny_payload))
|
|
186
232
|
|
|
@@ -252,7 +298,7 @@ def main() -> None:
|
|
|
252
298
|
|
|
253
299
|
# Block production code - require confirmation
|
|
254
300
|
written_content = _extract_written_content(tool_name, tool_input)
|
|
255
|
-
if
|
|
301
|
+
if tool_name == "Write" and ext == ".py" and _is_constants_only_python_content(written_content):
|
|
256
302
|
emit_allow()
|
|
257
303
|
sys.exit(0)
|
|
258
304
|
|
|
@@ -4,6 +4,7 @@ import json
|
|
|
4
4
|
import os
|
|
5
5
|
import subprocess
|
|
6
6
|
import sys
|
|
7
|
+
import tempfile
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
|
|
9
10
|
|
|
@@ -165,3 +166,171 @@ def test_rm_rf_still_asks_when_redirect_env_var_is_unset() -> None:
|
|
|
165
166
|
|
|
166
167
|
response = json.loads(result.stdout)
|
|
167
168
|
assert response["hookSpecificOutput"]["permissionDecision"] == "ask"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _run_hook_with_fake_home(
|
|
172
|
+
payload: dict,
|
|
173
|
+
fake_home: Path,
|
|
174
|
+
working_directory: Path,
|
|
175
|
+
disable_ephemeral_auto_allow: bool = True,
|
|
176
|
+
) -> subprocess.CompletedProcess[str]:
|
|
177
|
+
child_environment = os.environ.copy()
|
|
178
|
+
child_environment.pop(GH_REDIRECT_ACTIVE_ENV_VAR, None)
|
|
179
|
+
child_environment["HOME"] = str(fake_home)
|
|
180
|
+
child_environment["USERPROFILE"] = str(fake_home)
|
|
181
|
+
if disable_ephemeral_auto_allow:
|
|
182
|
+
child_environment["CLAUDE_DESTRUCTIVE_DISABLE_EPHEMERAL_AUTO_ALLOW"] = "1"
|
|
183
|
+
else:
|
|
184
|
+
child_environment.pop("CLAUDE_DESTRUCTIVE_DISABLE_EPHEMERAL_AUTO_ALLOW", None)
|
|
185
|
+
return subprocess.run(
|
|
186
|
+
[sys.executable, str(SCRIPT_PATH)],
|
|
187
|
+
input=json.dumps(payload),
|
|
188
|
+
text=True,
|
|
189
|
+
capture_output=True,
|
|
190
|
+
check=False,
|
|
191
|
+
env=child_environment,
|
|
192
|
+
cwd=str(working_directory),
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _write_settings_with_allow_list(fake_home: Path, allow_list: list[str]) -> None:
|
|
197
|
+
claude_directory = fake_home / ".claude"
|
|
198
|
+
claude_directory.mkdir(parents=True, exist_ok=True)
|
|
199
|
+
settings_payload = {"hooks": {"allowGitResetHardProjects": allow_list}}
|
|
200
|
+
(claude_directory / "settings.json").write_text(
|
|
201
|
+
json.dumps(settings_payload), encoding="utf-8"
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_git_reset_hard_allowed_when_cwd_matches_settings_allow_list(tmp_path: Path) -> None:
|
|
206
|
+
fake_home = tmp_path / "home"
|
|
207
|
+
fake_home.mkdir()
|
|
208
|
+
project_directory = tmp_path / "approved_project"
|
|
209
|
+
project_directory.mkdir()
|
|
210
|
+
project_path_forward = str(project_directory).replace("\\", "/")
|
|
211
|
+
_write_settings_with_allow_list(fake_home, [project_path_forward])
|
|
212
|
+
payload = _make_bash_payload("git reset --hard origin/main")
|
|
213
|
+
|
|
214
|
+
result = _run_hook_with_fake_home(payload, fake_home, project_directory)
|
|
215
|
+
|
|
216
|
+
assert result.stdout.strip() == ""
|
|
217
|
+
assert result.returncode == 0
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def test_git_reset_hard_asks_when_cwd_not_in_settings_allow_list(tmp_path: Path) -> None:
|
|
221
|
+
fake_home = tmp_path / "home"
|
|
222
|
+
fake_home.mkdir()
|
|
223
|
+
approved_directory = tmp_path / "approved_project"
|
|
224
|
+
approved_directory.mkdir()
|
|
225
|
+
unapproved_directory = tmp_path / "unapproved_project"
|
|
226
|
+
unapproved_directory.mkdir()
|
|
227
|
+
approved_path_forward = str(approved_directory).replace("\\", "/")
|
|
228
|
+
_write_settings_with_allow_list(fake_home, [approved_path_forward])
|
|
229
|
+
payload = _make_bash_payload("git reset --hard origin/main")
|
|
230
|
+
|
|
231
|
+
result = _run_hook_with_fake_home(payload, fake_home, unapproved_directory)
|
|
232
|
+
|
|
233
|
+
response = json.loads(result.stdout)
|
|
234
|
+
assert response["hookSpecificOutput"]["permissionDecision"] == "ask"
|
|
235
|
+
assert "git reset --hard" in response["hookSpecificOutput"]["permissionDecisionReason"]
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def test_git_reset_hard_asks_when_settings_missing(tmp_path: Path) -> None:
|
|
239
|
+
fake_home = tmp_path / "home"
|
|
240
|
+
fake_home.mkdir()
|
|
241
|
+
project_directory = tmp_path / "unapproved_project"
|
|
242
|
+
project_directory.mkdir()
|
|
243
|
+
payload = _make_bash_payload("git reset --hard origin/main")
|
|
244
|
+
|
|
245
|
+
result = _run_hook_with_fake_home(payload, fake_home, project_directory)
|
|
246
|
+
|
|
247
|
+
response = json.loads(result.stdout)
|
|
248
|
+
assert response["hookSpecificOutput"]["permissionDecision"] == "ask"
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def test_git_reset_hard_asks_when_allow_list_is_empty(tmp_path: Path) -> None:
|
|
252
|
+
fake_home = tmp_path / "home"
|
|
253
|
+
fake_home.mkdir()
|
|
254
|
+
project_directory = tmp_path / "some_project"
|
|
255
|
+
project_directory.mkdir()
|
|
256
|
+
_write_settings_with_allow_list(fake_home, [])
|
|
257
|
+
payload = _make_bash_payload("git reset --hard origin/main")
|
|
258
|
+
|
|
259
|
+
result = _run_hook_with_fake_home(payload, fake_home, project_directory)
|
|
260
|
+
|
|
261
|
+
response = json.loads(result.stdout)
|
|
262
|
+
assert response["hookSpecificOutput"]["permissionDecision"] == "ask"
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def test_git_reset_hard_allowed_in_linked_git_worktree(tmp_path: Path) -> None:
|
|
266
|
+
fake_home = tmp_path / "home"
|
|
267
|
+
fake_home.mkdir()
|
|
268
|
+
main_repository = tmp_path / "main_repository"
|
|
269
|
+
main_repository.mkdir()
|
|
270
|
+
subprocess.run(["git", "init", "-q"], cwd=main_repository, check=True)
|
|
271
|
+
subprocess.run(["git", "commit", "-q", "--allow-empty", "-m", "init"], cwd=main_repository, check=True)
|
|
272
|
+
worktree_directory = tmp_path / "worktree_copy"
|
|
273
|
+
subprocess.run(
|
|
274
|
+
["git", "worktree", "add", "-q", "-b", "feature", str(worktree_directory)],
|
|
275
|
+
cwd=main_repository,
|
|
276
|
+
check=True,
|
|
277
|
+
)
|
|
278
|
+
payload = _make_bash_payload("git reset --hard origin/main")
|
|
279
|
+
|
|
280
|
+
result = _run_hook_with_fake_home(payload, fake_home, worktree_directory, disable_ephemeral_auto_allow=False)
|
|
281
|
+
|
|
282
|
+
assert result.stdout.strip() == ""
|
|
283
|
+
assert result.returncode == 0
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def test_git_reset_hard_allowed_when_path_contains_worktrees_segment(tmp_path: Path) -> None:
|
|
287
|
+
fake_home = tmp_path / "home"
|
|
288
|
+
fake_home.mkdir()
|
|
289
|
+
worktree_directory = tmp_path / "worktrees" / "some_feature"
|
|
290
|
+
worktree_directory.mkdir(parents=True)
|
|
291
|
+
payload = _make_bash_payload("git reset --hard origin/main")
|
|
292
|
+
|
|
293
|
+
result = _run_hook_with_fake_home(payload, fake_home, worktree_directory, disable_ephemeral_auto_allow=False)
|
|
294
|
+
|
|
295
|
+
assert result.stdout.strip() == ""
|
|
296
|
+
assert result.returncode == 0
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def test_git_reset_hard_allowed_under_os_temp_directory() -> None:
|
|
300
|
+
fake_home = Path(tempfile.mkdtemp(prefix="home_"))
|
|
301
|
+
working_directory = Path(tempfile.mkdtemp(prefix="ephemeral_work_"))
|
|
302
|
+
payload = _make_bash_payload("git reset --hard origin/main")
|
|
303
|
+
|
|
304
|
+
result = _run_hook_with_fake_home(payload, fake_home, working_directory, disable_ephemeral_auto_allow=False)
|
|
305
|
+
|
|
306
|
+
assert result.stdout.strip() == ""
|
|
307
|
+
assert result.returncode == 0
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def test_git_reset_hard_asks_in_plain_directory_with_no_settings(tmp_path: Path) -> None:
|
|
311
|
+
fake_home = tmp_path / "home"
|
|
312
|
+
fake_home.mkdir()
|
|
313
|
+
plain_directory = tmp_path / "regular_project"
|
|
314
|
+
plain_directory.mkdir()
|
|
315
|
+
payload = _make_bash_payload("git reset --hard origin/main")
|
|
316
|
+
|
|
317
|
+
result = _run_hook_with_fake_home(payload, fake_home, plain_directory)
|
|
318
|
+
|
|
319
|
+
response = json.loads(result.stdout)
|
|
320
|
+
assert response["hookSpecificOutput"]["permissionDecision"] == "ask"
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def test_git_reset_hard_asks_when_settings_file_is_invalid_json(tmp_path: Path) -> None:
|
|
324
|
+
fake_home = tmp_path / "home"
|
|
325
|
+
(fake_home / ".claude").mkdir(parents=True)
|
|
326
|
+
(fake_home / ".claude" / "settings.json").write_text(
|
|
327
|
+
"{not valid json", encoding="utf-8"
|
|
328
|
+
)
|
|
329
|
+
project_directory = tmp_path / "unapproved_project"
|
|
330
|
+
project_directory.mkdir()
|
|
331
|
+
payload = _make_bash_payload("git reset --hard origin/main")
|
|
332
|
+
|
|
333
|
+
result = _run_hook_with_fake_home(payload, fake_home, project_directory)
|
|
334
|
+
|
|
335
|
+
response = json.loads(result.stdout)
|
|
336
|
+
assert response["hookSpecificOutput"]["permissionDecision"] == "ask"
|
|
@@ -96,7 +96,7 @@ def test_should_deny_when_test_file_exists_but_is_stale(tmp_path: Path) -> None:
|
|
|
96
96
|
assert _decision_from(completed) == "deny"
|
|
97
97
|
|
|
98
98
|
|
|
99
|
-
def
|
|
99
|
+
def test_should_deny_when_pragma_no_tdd_gate_sentinel_is_present_without_test(tmp_path: Path) -> None:
|
|
100
100
|
sandbox = _sandbox(tmp_path)
|
|
101
101
|
production_file = sandbox / "orders.py"
|
|
102
102
|
content_with_sentinel = "# pragma: no-tdd-gate\ndef fulfill(): pass\n"
|
|
@@ -105,7 +105,7 @@ def test_should_allow_when_bypass_sentinel_present_in_content(tmp_path: Path) ->
|
|
|
105
105
|
_make_write_payload(production_file, content_with_sentinel)
|
|
106
106
|
)
|
|
107
107
|
|
|
108
|
-
assert _decision_from(completed) == "
|
|
108
|
+
assert _decision_from(completed) == "deny"
|
|
109
109
|
|
|
110
110
|
|
|
111
111
|
def test_should_skip_markdown_files_entirely(tmp_path: Path) -> None:
|
|
@@ -170,11 +170,12 @@ def test_should_deny_when_test_file_has_no_test_evidence(tmp_path: Path) -> None
|
|
|
170
170
|
assert _decision_from(completed) == "deny"
|
|
171
171
|
|
|
172
172
|
|
|
173
|
-
def
|
|
173
|
+
def test_should_deny_edit_when_pragma_sentinel_present_in_new_string_without_test(
|
|
174
174
|
tmp_path: Path,
|
|
175
175
|
) -> None:
|
|
176
176
|
sandbox = _sandbox(tmp_path)
|
|
177
177
|
production_file = sandbox / "orders.py"
|
|
178
|
+
production_file.write_text("def fulfill(): pass\n")
|
|
178
179
|
payload = {
|
|
179
180
|
"tool_name": "Edit",
|
|
180
181
|
"tool_input": {
|
|
@@ -186,9 +187,131 @@ def test_should_allow_edit_when_bypass_sentinel_present_in_new_string(
|
|
|
186
187
|
|
|
187
188
|
completed = _run_hook_with_payload(payload)
|
|
188
189
|
|
|
190
|
+
assert _decision_from(completed) == "deny"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_should_allow_python_file_with_only_module_level_constants(tmp_path: Path) -> None:
|
|
194
|
+
sandbox = _sandbox(tmp_path)
|
|
195
|
+
constants_file = sandbox / "constants.py"
|
|
196
|
+
constants_only_content = (
|
|
197
|
+
'"""Module-level constants for the widget subsystem."""\n'
|
|
198
|
+
"import re\n"
|
|
199
|
+
"MAXIMUM_RETRIES: int = 3\n"
|
|
200
|
+
"DEFAULT_TIMEOUT_SECONDS: float = 30.0\n"
|
|
201
|
+
'BANNED_WORDS: tuple[str, ...] = ("foo", "bar")\n'
|
|
202
|
+
)
|
|
203
|
+
constants_file.write_text(constants_only_content)
|
|
204
|
+
|
|
205
|
+
completed = _run_hook_with_payload(
|
|
206
|
+
_make_write_payload(constants_file, constants_only_content)
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
assert _decision_from(completed) == "allow"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def test_should_deny_python_file_when_any_function_definition_is_present(tmp_path: Path) -> None:
|
|
213
|
+
sandbox = _sandbox(tmp_path)
|
|
214
|
+
mixed_file = sandbox / "mixed.py"
|
|
215
|
+
mixed_content = (
|
|
216
|
+
"MAXIMUM_RETRIES: int = 3\n"
|
|
217
|
+
"def do_something() -> None:\n"
|
|
218
|
+
" return None\n"
|
|
219
|
+
)
|
|
220
|
+
mixed_file.write_text(mixed_content)
|
|
221
|
+
|
|
222
|
+
completed = _run_hook_with_payload(
|
|
223
|
+
_make_write_payload(mixed_file, mixed_content)
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
assert _decision_from(completed) == "deny"
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def test_should_deny_python_file_when_any_class_definition_is_present(tmp_path: Path) -> None:
|
|
230
|
+
sandbox = _sandbox(tmp_path)
|
|
231
|
+
class_file = sandbox / "klass.py"
|
|
232
|
+
class_content = (
|
|
233
|
+
"class Widget:\n"
|
|
234
|
+
" size: int = 3\n"
|
|
235
|
+
)
|
|
236
|
+
class_file.write_text(class_content)
|
|
237
|
+
|
|
238
|
+
completed = _run_hook_with_payload(
|
|
239
|
+
_make_write_payload(class_file, class_content)
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
assert _decision_from(completed) == "deny"
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def test_deny_response_places_system_message_and_suppress_output_at_top_level(
|
|
246
|
+
tmp_path: Path,
|
|
247
|
+
) -> None:
|
|
248
|
+
sandbox = _sandbox(tmp_path)
|
|
249
|
+
production_file = sandbox / "orders.py"
|
|
250
|
+
production_file.write_text("def fulfill(): pass\n")
|
|
251
|
+
|
|
252
|
+
completed = _run_hook_with_payload(_make_write_payload(production_file))
|
|
253
|
+
|
|
254
|
+
parsed = json.loads(completed.stdout)
|
|
255
|
+
hook_output = parsed["hookSpecificOutput"]
|
|
256
|
+
assert hook_output["permissionDecision"] == "deny"
|
|
257
|
+
assert parsed.get("suppressOutput") is True
|
|
258
|
+
assert isinstance(parsed.get("systemMessage"), str)
|
|
259
|
+
assert parsed["systemMessage"]
|
|
260
|
+
assert "suppressOutput" not in hook_output
|
|
261
|
+
assert "systemMessage" not in hook_output
|
|
262
|
+
verbose_reason = hook_output["permissionDecisionReason"]
|
|
263
|
+
assert "propose" in verbose_reason.lower() or "enhancement" in verbose_reason.lower()
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def test_should_deny_python_file_that_calls_function_at_module_level(tmp_path: Path) -> None:
|
|
267
|
+
sandbox = _sandbox(tmp_path)
|
|
268
|
+
side_effect_file = sandbox / "side_effects.py"
|
|
269
|
+
side_effect_content = (
|
|
270
|
+
"import sys\n"
|
|
271
|
+
"sys.exit(1)\n"
|
|
272
|
+
)
|
|
273
|
+
side_effect_file.write_text(side_effect_content)
|
|
274
|
+
|
|
275
|
+
completed = _run_hook_with_payload(
|
|
276
|
+
_make_write_payload(side_effect_file, side_effect_content)
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
assert _decision_from(completed) == "deny"
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def test_should_allow_python_file_with_module_docstring_plus_constants(tmp_path: Path) -> None:
|
|
283
|
+
sandbox = _sandbox(tmp_path)
|
|
284
|
+
constants_file = sandbox / "constants.py"
|
|
285
|
+
constants_content = (
|
|
286
|
+
'"""Module-level constants for the widget subsystem."""\n'
|
|
287
|
+
"import re\n"
|
|
288
|
+
"MAXIMUM_RETRIES: int = 3\n"
|
|
289
|
+
)
|
|
290
|
+
constants_file.write_text(constants_content)
|
|
291
|
+
|
|
292
|
+
completed = _run_hook_with_payload(
|
|
293
|
+
_make_write_payload(constants_file, constants_content)
|
|
294
|
+
)
|
|
295
|
+
|
|
189
296
|
assert _decision_from(completed) == "allow"
|
|
190
297
|
|
|
191
298
|
|
|
299
|
+
def test_should_deny_python_file_that_mutates_module_state_via_aug_assign(tmp_path: Path) -> None:
|
|
300
|
+
sandbox = _sandbox(tmp_path)
|
|
301
|
+
mutation_file = sandbox / "mutation.py"
|
|
302
|
+
mutation_content = (
|
|
303
|
+
"COUNTER: int = 0\n"
|
|
304
|
+
"COUNTER += 1\n"
|
|
305
|
+
)
|
|
306
|
+
mutation_file.write_text(mutation_content)
|
|
307
|
+
|
|
308
|
+
completed = _run_hook_with_payload(
|
|
309
|
+
_make_write_payload(mutation_file, mutation_content)
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
assert _decision_from(completed) == "deny"
|
|
313
|
+
|
|
314
|
+
|
|
192
315
|
def test_should_deny_production_file_inside_directory_containing_skip_substring(
|
|
193
316
|
tmp_path: Path,
|
|
194
317
|
) -> None:
|
package/hooks/config/messages.py
CHANGED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Smoke tests for hooks.config.messages — verify user-facing notice constants exist."""
|
|
2
|
+
|
|
3
|
+
from config import messages
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_user_facing_notice_is_nonempty_string() -> None:
|
|
7
|
+
assert isinstance(messages.USER_FACING_NOTICE, str)
|
|
8
|
+
assert messages.USER_FACING_NOTICE
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_user_facing_tdd_notice_is_nonempty_string() -> None:
|
|
12
|
+
assert isinstance(messages.USER_FACING_TDD_NOTICE, str)
|
|
13
|
+
assert messages.USER_FACING_TDD_NOTICE
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Constants for the claude-dev-env git-hook entry points.
|
|
2
|
+
|
|
3
|
+
Co-located with ``pre_commit.py`` and ``pre_push.py`` so the installed shim
|
|
4
|
+
directory is self-contained at runtime: the shim prepends its own directory
|
|
5
|
+
to ``sys.path`` before importing the hook module, which makes ``from config
|
|
6
|
+
import ...`` resolve against this file both inside the repo and under
|
|
7
|
+
``~/.claude/hooks/git-hooks/`` after installation.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
STAGED_SCOPE_ARGUMENT: str = "--staged"
|
|
14
|
+
BASE_REFERENCE_ARGUMENT: str = "--base"
|
|
15
|
+
DEFAULT_REMOTE_BASE_REFERENCE: str = "origin/HEAD"
|
|
16
|
+
ALL_ZEROS_OBJECT_NAME_CHARACTER: str = "0"
|
|
17
|
+
STDIN_LINE_FIELD_COUNT: int = 4
|
|
18
|
+
STDIN_REMOTE_OBJECT_FIELD_INDEX: int = 3
|
|
19
|
+
GATE_PATH_OVERRIDE_ENV_VAR: str = "CODE_RULES_GATE_PATH"
|
|
20
|
+
CLAUDE_HOME_ENV_VAR: str = "CLAUDE_HOME"
|
|
21
|
+
CLAUDE_HOME_DEFAULT_SUBDIRECTORY: str = ".claude"
|
|
22
|
+
GATE_SCRIPT_RELATIVE_PATH: tuple[str, ...] = (
|
|
23
|
+
"skills",
|
|
24
|
+
"bugteam",
|
|
25
|
+
"scripts",
|
|
26
|
+
"bugteam_code_rules_gate.py",
|
|
27
|
+
)
|
|
28
|
+
GATE_INFRASTRUCTURE_FAILURE_EXIT_CODE: int = 2
|
|
29
|
+
GATE_SCRIPT_NOT_FOUND_MESSAGE: str = (
|
|
30
|
+
"claude-dev-env pre-commit: gate script not found at {path}, skipping enforcement"
|
|
31
|
+
)
|
|
32
|
+
PRE_PUSH_GATE_SCRIPT_NOT_FOUND_MESSAGE: str = (
|
|
33
|
+
"claude-dev-env pre-push: gate script not found at {path}, skipping enforcement"
|
|
34
|
+
)
|
|
35
|
+
STDIN_READ_FAILURE_MESSAGE: str = (
|
|
36
|
+
"claude-dev-env pre-push: could not read stdin ({error}), aborting"
|
|
37
|
+
)
|
|
38
|
+
INVOKE_GATE_FAILURE_MESSAGE: str = (
|
|
39
|
+
"claude-dev-env: could not launch gate script ({error}), aborting"
|
|
40
|
+
)
|
|
41
|
+
MALFORMED_STDIN_LINE_MESSAGE: str = (
|
|
42
|
+
"claude-dev-env pre-push: ignoring malformed stdin line: {line!r}"
|
|
43
|
+
)
|
|
44
|
+
LOCAL_SHA_FIELD_INDEX: int = 1
|
|
45
|
+
NO_PARSEABLE_STDIN_LINES_MESSAGE: str = (
|
|
46
|
+
"claude-dev-env pre-push: no parseable stdin lines; aborting"
|
|
47
|
+
)
|
|
48
|
+
NO_PARSEABLE_STDIN_LINES_SENTINEL: str = "__no_parseable_stdin_lines__"
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Shared utilities for the claude-dev-env git-hook entry points."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from config import (
|
|
10
|
+
CLAUDE_HOME_DEFAULT_SUBDIRECTORY,
|
|
11
|
+
CLAUDE_HOME_ENV_VAR,
|
|
12
|
+
GATE_PATH_OVERRIDE_ENV_VAR,
|
|
13
|
+
GATE_SCRIPT_RELATIVE_PATH,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def resolve_gate_script_path() -> tuple[Path, Path | None]:
|
|
18
|
+
"""Return (gate_path, exact_allowed_override_or_none).
|
|
19
|
+
|
|
20
|
+
When CODE_RULES_GATE_PATH is set the second element is the resolved
|
|
21
|
+
override path — the only path is_safe_regular_file will accept.
|
|
22
|
+
When falling back to CLAUDE_HOME / default the second element is None,
|
|
23
|
+
signalling that the trusted prefix (Path.home() / '.claude') applies.
|
|
24
|
+
|
|
25
|
+
Capturing both values here eliminates the TOCTOU window that would arise
|
|
26
|
+
when the same env vars are read again inside is_safe_regular_file.
|
|
27
|
+
"""
|
|
28
|
+
override_path_raw = os.environ.get(GATE_PATH_OVERRIDE_ENV_VAR, "").strip()
|
|
29
|
+
if override_path_raw:
|
|
30
|
+
exact_override = Path(override_path_raw).resolve()
|
|
31
|
+
return exact_override, exact_override
|
|
32
|
+
claude_home_override = os.environ.get(CLAUDE_HOME_ENV_VAR, "").strip()
|
|
33
|
+
if claude_home_override:
|
|
34
|
+
claude_home_directory = Path(claude_home_override).resolve()
|
|
35
|
+
else:
|
|
36
|
+
claude_home_directory = Path.home() / CLAUDE_HOME_DEFAULT_SUBDIRECTORY
|
|
37
|
+
gate_path = claude_home_directory.joinpath(*GATE_SCRIPT_RELATIVE_PATH)
|
|
38
|
+
return gate_path, None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def is_safe_regular_file(candidate_path: Path, exact_allowed_path: Path | None) -> bool:
|
|
42
|
+
"""Return True only when candidate_path is a regular file at a trusted location.
|
|
43
|
+
|
|
44
|
+
When exact_allowed_path is not None candidate_path must resolve to that
|
|
45
|
+
exact path (CODE_RULES_GATE_PATH override case). When it is None the
|
|
46
|
+
resolved candidate must fall within Path.home() / '.claude' — CLAUDE_HOME
|
|
47
|
+
is intentionally excluded from the trust boundary because it is
|
|
48
|
+
attacker-settable via the process environment.
|
|
49
|
+
|
|
50
|
+
The candidate is resolved to its real path before any containment check,
|
|
51
|
+
preventing symlinks inside the trusted tree from redirecting execution
|
|
52
|
+
outside it.
|
|
53
|
+
"""
|
|
54
|
+
resolved_candidate = candidate_path.resolve()
|
|
55
|
+
if not _is_resolved_candidate_allowed(resolved_candidate, exact_allowed_path):
|
|
56
|
+
return False
|
|
57
|
+
try:
|
|
58
|
+
path_stat = os.stat(resolved_candidate)
|
|
59
|
+
except OSError:
|
|
60
|
+
return False
|
|
61
|
+
return stat.S_ISREG(path_stat.st_mode)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _resolve_trust_root() -> Path:
|
|
65
|
+
claude_home_override = os.environ.get(CLAUDE_HOME_ENV_VAR, "").strip()
|
|
66
|
+
if claude_home_override:
|
|
67
|
+
return Path(claude_home_override).resolve()
|
|
68
|
+
return (Path.home() / CLAUDE_HOME_DEFAULT_SUBDIRECTORY).resolve()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _is_resolved_candidate_allowed(
|
|
72
|
+
resolved_candidate: Path,
|
|
73
|
+
exact_allowed_path: Path | None,
|
|
74
|
+
) -> bool:
|
|
75
|
+
if exact_allowed_path is not None:
|
|
76
|
+
return resolved_candidate == exact_allowed_path
|
|
77
|
+
trusted_prefix = _resolve_trust_root()
|
|
78
|
+
return _is_within_directory(resolved_candidate, trusted_prefix)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _is_within_directory(candidate_path: Path, directory: Path) -> bool:
|
|
82
|
+
try:
|
|
83
|
+
candidate_path.relative_to(directory)
|
|
84
|
+
return True
|
|
85
|
+
except ValueError:
|
|
86
|
+
return False
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Git pre-commit hook: run the CODE_RULES gate over staged changes.
|
|
3
|
+
|
|
4
|
+
Installed to the user's shared git-hooks directory via the claude-dev-env
|
|
5
|
+
installer; git invokes this file as `pre-commit` (the installer strips the
|
|
6
|
+
`_` and `.py` suffix when copying into the live hooks path).
|
|
7
|
+
|
|
8
|
+
Exit codes:
|
|
9
|
+
0 - staged changes pass the gate (or the gate is not installed locally).
|
|
10
|
+
1 - staged changes introduce one or more blocking violations.
|
|
11
|
+
2 - unexpected invocation failure (e.g., subprocess could not launch).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from config import (
|
|
21
|
+
GATE_INFRASTRUCTURE_FAILURE_EXIT_CODE,
|
|
22
|
+
GATE_SCRIPT_NOT_FOUND_MESSAGE,
|
|
23
|
+
INVOKE_GATE_FAILURE_MESSAGE,
|
|
24
|
+
STAGED_SCOPE_ARGUMENT,
|
|
25
|
+
)
|
|
26
|
+
from gate_utils import is_safe_regular_file, resolve_gate_script_path
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def invoke_gate(gate_script_path: Path) -> int:
|
|
30
|
+
staged_scope_argument = STAGED_SCOPE_ARGUMENT
|
|
31
|
+
invoke_gate_failure_message = INVOKE_GATE_FAILURE_MESSAGE
|
|
32
|
+
gate_infrastructure_failure_exit_code = GATE_INFRASTRUCTURE_FAILURE_EXIT_CODE
|
|
33
|
+
try:
|
|
34
|
+
resolved_gate_path = gate_script_path.resolve(strict=True)
|
|
35
|
+
completion = subprocess.run(
|
|
36
|
+
[sys.executable, str(resolved_gate_path), staged_scope_argument],
|
|
37
|
+
check=False,
|
|
38
|
+
)
|
|
39
|
+
except OSError as launch_error:
|
|
40
|
+
print(
|
|
41
|
+
invoke_gate_failure_message.format(error=launch_error),
|
|
42
|
+
file=sys.stderr,
|
|
43
|
+
)
|
|
44
|
+
return gate_infrastructure_failure_exit_code
|
|
45
|
+
return completion.returncode
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def main() -> int:
|
|
49
|
+
gate_script_not_found_message = GATE_SCRIPT_NOT_FOUND_MESSAGE
|
|
50
|
+
gate_script_path, exact_allowed_path = resolve_gate_script_path()
|
|
51
|
+
if not is_safe_regular_file(gate_script_path, exact_allowed_path):
|
|
52
|
+
print(
|
|
53
|
+
gate_script_not_found_message.format(path=gate_script_path),
|
|
54
|
+
file=sys.stderr,
|
|
55
|
+
)
|
|
56
|
+
return 0
|
|
57
|
+
return invoke_gate(gate_script_path)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
if __name__ == "__main__":
|
|
61
|
+
sys.exit(main())
|