claude-dev-env 1.73.0 → 1.74.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 +2 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
- package/hooks/blocking/CLAUDE.md +3 -0
- package/hooks/blocking/block_main_commit.py +14 -0
- package/hooks/blocking/bot_mention_comment_blocker.py +7 -0
- package/hooks/blocking/claude_md_orphan_file_blocker.py +14 -42
- package/hooks/blocking/code_rules_docstrings.py +223 -0
- package/hooks/blocking/code_rules_enforcer.py +16 -0
- package/hooks/blocking/code_verifier_spawn_preflight_gate.py +10 -4
- package/hooks/blocking/convergence_gate_blocker.py +17 -3
- package/hooks/blocking/destructive_command_blocker.py +7 -0
- package/hooks/blocking/docstring_rule_gate_count_blocker.py +321 -0
- package/hooks/blocking/gh_body_arg_blocker.py +8 -0
- package/hooks/blocking/gh_pr_author_enforcer.py +7 -0
- package/hooks/blocking/hedging_language_blocker.py +16 -10
- package/hooks/blocking/hook_prose_detector_consistency.py +7 -0
- package/hooks/blocking/intent_only_ending_blocker.py +17 -11
- package/hooks/blocking/md_to_html_blocker.py +10 -2
- package/hooks/blocking/open_questions_in_plans_blocker.py +10 -2
- package/hooks/blocking/package_inventory_stale_blocker.py +398 -0
- package/hooks/blocking/plain_language_blocker.py +6 -0
- package/hooks/blocking/pr_converge_bugteam_enforcer.py +6 -0
- package/hooks/blocking/pr_description_enforcer.py +6 -0
- package/hooks/blocking/pre_tool_use_dispatcher.py +3 -3
- package/hooks/blocking/precommit_code_rules_gate.py +10 -1
- package/hooks/blocking/pytest_testpaths_orphan_blocker.py +8 -0
- package/hooks/blocking/question_to_user_enforcer.py +18 -12
- package/hooks/blocking/send_user_file_open_locally_blocker.py +70 -0
- package/hooks/blocking/sensitive_file_protector.py +15 -1
- package/hooks/blocking/session_handoff_blocker.py +14 -8
- package/hooks/blocking/state_description_blocker.py +6 -0
- package/hooks/blocking/subprocess_budget_completeness.py +9 -3
- package/hooks/blocking/tdd_enforcer.py +6 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_returns_plural_cardinality.py +207 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_unguarded_payload.py +188 -0
- package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +45 -0
- package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +203 -0
- package/hooks/blocking/test_hook_block_logger_coverage.py +53 -0
- package/hooks/blocking/test_package_inventory_stale_blocker.py +329 -0
- package/hooks/blocking/test_plain_language_blocker.py +36 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +8 -8
- package/hooks/blocking/test_send_user_file_open_locally_blocker.py +114 -0
- package/hooks/blocking/test_shared_stdin_adoption.py +42 -0
- package/hooks/blocking/test_state_description_blocker.py +41 -0
- package/hooks/blocking/test_verdict_directory_write_blocker.py +49 -0
- package/hooks/blocking/test_workflow_substitution_slot_blocker.py +4 -19
- package/hooks/blocking/verdict_directory_write_blocker.py +10 -1
- package/hooks/blocking/verified_commit_gate.py +11 -0
- package/hooks/blocking/verified_commit_message_accuracy_blocker.py +16 -1
- package/hooks/blocking/windows_rmtree_blocker.py +7 -0
- package/hooks/blocking/workflow_substitution_slot_blocker.py +10 -5
- package/hooks/blocking/write_existing_file_blocker.py +16 -1
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/CLAUDE.md +4 -0
- package/hooks/hooks_constants/blocking_check_limits.py +13 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +3 -0
- package/hooks/hooks_constants/docstring_rule_gate_count_blocker_constants.py +90 -0
- package/hooks/hooks_constants/hook_block_logger.py +59 -0
- package/hooks/hooks_constants/multi_edit_reconstruction.py +56 -0
- package/hooks/hooks_constants/package_inventory_stale_blocker_constants.py +111 -0
- package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +1 -2
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +9 -1
- package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +18 -0
- package/hooks/hooks_constants/test_dispatcher_constants_docstrings.py +44 -0
- package/hooks/hooks_constants/test_hook_block_logger.py +159 -0
- package/hooks/lifecycle/config_change_guard.py +12 -0
- package/hooks/lifecycle/test_config_change_guard.py +23 -0
- package/hooks/validation/hook_format_validator.py +13 -0
- package/hooks/validation/mypy_validator.py +30 -1
- package/hooks/validation/test_hook_format_validator.py +64 -0
- package/hooks/validation/test_mypy_validator.py +22 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/docstring-prose-matches-implementation.md +2 -1
- package/rules/package-inventory-stale-entry.md +24 -0
- package/skills/autoconverge/SKILL.md +18 -1
- package/skills/autoconverge/workflow/converge.contract.test.mjs +106 -0
- package/skills/autoconverge/workflow/converge.mjs +2 -1
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Unit tests for the send-user-file-open-locally PreToolUse hook."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
import pathlib
|
|
7
|
+
import sys
|
|
8
|
+
from unittest import mock
|
|
9
|
+
|
|
10
|
+
_HOOK_DIR = pathlib.Path(__file__).parent
|
|
11
|
+
if str(_HOOK_DIR) not in sys.path:
|
|
12
|
+
sys.path.insert(0, str(_HOOK_DIR))
|
|
13
|
+
|
|
14
|
+
hook_spec = importlib.util.spec_from_file_location(
|
|
15
|
+
"send_user_file_open_locally_blocker",
|
|
16
|
+
_HOOK_DIR / "send_user_file_open_locally_blocker.py",
|
|
17
|
+
)
|
|
18
|
+
assert hook_spec is not None
|
|
19
|
+
assert hook_spec.loader is not None
|
|
20
|
+
hook_module = importlib.util.module_from_spec(hook_spec)
|
|
21
|
+
hook_spec.loader.exec_module(hook_module)
|
|
22
|
+
|
|
23
|
+
_should_block = hook_module._should_block
|
|
24
|
+
|
|
25
|
+
from hooks_constants.send_user_file_open_locally_blocker_constants import (
|
|
26
|
+
CORRECTIVE_MESSAGE,
|
|
27
|
+
PROACTIVE_STATUS,
|
|
28
|
+
TOOL_NAME,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_blocks_normal_status() -> None:
|
|
33
|
+
assert _should_block("normal") is True
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_blocks_empty_status() -> None:
|
|
37
|
+
assert _should_block("") is True
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_blocks_unknown_status() -> None:
|
|
41
|
+
assert _should_block("whatever") is True
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_allows_proactive_status() -> None:
|
|
45
|
+
assert _should_block(PROACTIVE_STATUS) is False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_corrective_message_points_to_show_asset() -> None:
|
|
49
|
+
assert "Show-Asset.ps1" in CORRECTIVE_MESSAGE
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_corrective_message_names_proactive_escape_hatch() -> None:
|
|
53
|
+
assert PROACTIVE_STATUS in CORRECTIVE_MESSAGE
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _run_main_with_io(input_text: str) -> str:
|
|
57
|
+
with mock.patch("sys.stdin", io.StringIO(input_text)):
|
|
58
|
+
with mock.patch("sys.stdout", new_callable=io.StringIO) as mock_stdout:
|
|
59
|
+
try:
|
|
60
|
+
hook_module.main()
|
|
61
|
+
except SystemExit:
|
|
62
|
+
pass
|
|
63
|
+
return mock_stdout.getvalue()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_main_blocks_normal_attach() -> None:
|
|
67
|
+
hook_input = {
|
|
68
|
+
"tool_name": TOOL_NAME,
|
|
69
|
+
"tool_input": {"files": ["report.html"], "status": "normal"},
|
|
70
|
+
}
|
|
71
|
+
output_text = _run_main_with_io(json.dumps(hook_input))
|
|
72
|
+
output = json.loads(output_text)
|
|
73
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
74
|
+
assert "Show-Asset.ps1" in output["hookSpecificOutput"]["permissionDecisionReason"]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_main_allows_proactive_attach() -> None:
|
|
78
|
+
hook_input = {
|
|
79
|
+
"tool_name": TOOL_NAME,
|
|
80
|
+
"tool_input": {"files": ["report.html"], "status": "proactive"},
|
|
81
|
+
}
|
|
82
|
+
assert _run_main_with_io(json.dumps(hook_input)) == ""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_main_blocks_when_status_missing() -> None:
|
|
86
|
+
hook_input = {
|
|
87
|
+
"tool_name": TOOL_NAME,
|
|
88
|
+
"tool_input": {"files": ["report.html"]},
|
|
89
|
+
}
|
|
90
|
+
output_text = _run_main_with_io(json.dumps(hook_input))
|
|
91
|
+
output = json.loads(output_text)
|
|
92
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_main_blocks_when_tool_input_is_null() -> None:
|
|
96
|
+
hook_input = {
|
|
97
|
+
"tool_name": TOOL_NAME,
|
|
98
|
+
"tool_input": None,
|
|
99
|
+
}
|
|
100
|
+
output_text = _run_main_with_io(json.dumps(hook_input))
|
|
101
|
+
output = json.loads(output_text)
|
|
102
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_main_passes_wrong_tool_name() -> None:
|
|
106
|
+
hook_input = {
|
|
107
|
+
"tool_name": "Write",
|
|
108
|
+
"tool_input": {"files": ["report.html"], "status": "normal"},
|
|
109
|
+
}
|
|
110
|
+
assert _run_main_with_io(json.dumps(hook_input)) == ""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_main_passes_malformed_json() -> None:
|
|
114
|
+
assert _run_main_with_io("not valid json {{{") == ""
|
|
@@ -33,6 +33,7 @@ ALL_CONVERTED_HOOK_FILENAMES = (
|
|
|
33
33
|
"claude_md_orphan_file_blocker.py",
|
|
34
34
|
"pr_converge_bugteam_enforcer.py",
|
|
35
35
|
"verdict_directory_write_blocker.py",
|
|
36
|
+
"package_inventory_stale_blocker.py",
|
|
36
37
|
)
|
|
37
38
|
|
|
38
39
|
EMPTY_STDIN_PAYLOAD = ""
|
|
@@ -142,6 +143,47 @@ def test_open_questions_blocker_still_allows_plan_without_open_questions(
|
|
|
142
143
|
assert _decision_from_stdout(completed) is None
|
|
143
144
|
|
|
144
145
|
|
|
146
|
+
def test_package_inventory_blocker_still_denies_uninventoried_new_file(
|
|
147
|
+
tmp_path: Path,
|
|
148
|
+
) -> None:
|
|
149
|
+
inventory_body = "# package\n\n| File | Role |\n|---|---|\n| `alpha.py` | A |\n| `beta.py` | B |\n"
|
|
150
|
+
(tmp_path / "README.md").write_text(inventory_body, encoding="utf-8")
|
|
151
|
+
(tmp_path / "alpha.py").write_text("x = 1\n", encoding="utf-8")
|
|
152
|
+
(tmp_path / "beta.py").write_text("x = 1\n", encoding="utf-8")
|
|
153
|
+
new_file_path = tmp_path / "gamma.py"
|
|
154
|
+
payload = json.dumps(
|
|
155
|
+
{
|
|
156
|
+
"tool_name": "Write",
|
|
157
|
+
"tool_input": {"file_path": str(new_file_path), "content": "x = 1\n"},
|
|
158
|
+
}
|
|
159
|
+
)
|
|
160
|
+
completed = _run_hook_script("package_inventory_stale_blocker.py", payload)
|
|
161
|
+
assert completed.returncode == 0
|
|
162
|
+
assert _decision_from_stdout(completed) == "deny"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_package_inventory_blocker_still_allows_inventoried_new_file(
|
|
166
|
+
tmp_path: Path,
|
|
167
|
+
) -> None:
|
|
168
|
+
inventory_body = (
|
|
169
|
+
"# package\n\n| File | Role |\n|---|---|\n"
|
|
170
|
+
"| `alpha.py` | A |\n| `beta.py` | B |\n| `gamma.py` | G |\n"
|
|
171
|
+
)
|
|
172
|
+
(tmp_path / "README.md").write_text(inventory_body, encoding="utf-8")
|
|
173
|
+
(tmp_path / "alpha.py").write_text("x = 1\n", encoding="utf-8")
|
|
174
|
+
(tmp_path / "beta.py").write_text("x = 1\n", encoding="utf-8")
|
|
175
|
+
new_file_path = tmp_path / "gamma.py"
|
|
176
|
+
payload = json.dumps(
|
|
177
|
+
{
|
|
178
|
+
"tool_name": "Write",
|
|
179
|
+
"tool_input": {"file_path": str(new_file_path), "content": "x = 1\n"},
|
|
180
|
+
}
|
|
181
|
+
)
|
|
182
|
+
completed = _run_hook_script("package_inventory_stale_blocker.py", payload)
|
|
183
|
+
assert completed.returncode == 0
|
|
184
|
+
assert _decision_from_stdout(completed) is None
|
|
185
|
+
|
|
186
|
+
|
|
145
187
|
def test_converted_hooks_allow_unrelated_tool_name() -> None:
|
|
146
188
|
payload = json.dumps({"tool_name": "Bash", "tool_input": {"command": "ls"}})
|
|
147
189
|
for each_hook_filename in ALL_CONVERTED_HOOK_FILENAMES:
|
|
@@ -4,6 +4,21 @@ import json
|
|
|
4
4
|
import os
|
|
5
5
|
import subprocess
|
|
6
6
|
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from unittest.mock import patch
|
|
9
|
+
|
|
10
|
+
_BLOCKING_DIR = str(Path(__file__).resolve().parent)
|
|
11
|
+
_HOOKS_ROOT = str(Path(__file__).resolve().parent.parent)
|
|
12
|
+
if _BLOCKING_DIR not in sys.path:
|
|
13
|
+
sys.path.insert(0, _BLOCKING_DIR)
|
|
14
|
+
if _HOOKS_ROOT not in sys.path:
|
|
15
|
+
sys.path.insert(0, _HOOKS_ROOT)
|
|
16
|
+
|
|
17
|
+
from pre_tool_use_dispatcher import NativeHook, run_native_hook # noqa: E402
|
|
18
|
+
from state_description_blocker import ( # noqa: E402
|
|
19
|
+
build_deny_payload,
|
|
20
|
+
evaluate,
|
|
21
|
+
)
|
|
7
22
|
|
|
8
23
|
HOOK_SCRIPT_PATH = os.path.join(
|
|
9
24
|
os.path.dirname(__file__), "state_description_blocker.py"
|
|
@@ -616,3 +631,29 @@ def test_handles_non_string_tool_name():
|
|
|
616
631
|
)
|
|
617
632
|
assert result.returncode == 0
|
|
618
633
|
assert result.stdout == ""
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def test_native_dispatch_path_logs_the_block(tmp_path: Path) -> None:
|
|
637
|
+
"""A deny routed through the dispatcher's native path logs one record.
|
|
638
|
+
|
|
639
|
+
hooks.json wires this hook only through pre_tool_use_dispatcher, whose
|
|
640
|
+
native path calls evaluate() and build_deny_payload() — never main(). The
|
|
641
|
+
block must still land in the hook-blocks log, so the log call lives on
|
|
642
|
+
build_deny_payload, the function the native path executes.
|
|
643
|
+
"""
|
|
644
|
+
deny_payload = {
|
|
645
|
+
"tool_name": "Write",
|
|
646
|
+
"tool_input": {"file_path": "src/main.py", "content": VIOLATION_INSTEAD_OF_COMMENT},
|
|
647
|
+
}
|
|
648
|
+
native_hook = NativeHook(evaluate=evaluate, build_deny_payload=build_deny_payload)
|
|
649
|
+
|
|
650
|
+
with patch.object(Path, "home", return_value=tmp_path):
|
|
651
|
+
hosted_result = run_native_hook(native_hook, deny_payload, is_blocking=True)
|
|
652
|
+
|
|
653
|
+
assert hosted_result.captured_stdout
|
|
654
|
+
log_path = tmp_path / ".claude" / "logs" / "hook-blocks.log"
|
|
655
|
+
all_records = log_path.read_text(encoding="utf-8").strip().splitlines()
|
|
656
|
+
assert len(all_records) == 1
|
|
657
|
+
logged_record = json.loads(all_records[0])
|
|
658
|
+
assert logged_record["hook"] == "state_description_blocker.py"
|
|
659
|
+
assert logged_record["event"] == "PreToolUse"
|
|
@@ -11,6 +11,8 @@ is denied, and commands that touch unrelated paths pass.
|
|
|
11
11
|
import importlib.util
|
|
12
12
|
import json
|
|
13
13
|
import pathlib
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
14
16
|
import sys
|
|
15
17
|
|
|
16
18
|
_HOOK_DIR = pathlib.Path(__file__).parent
|
|
@@ -718,3 +720,50 @@ def test_guard_is_registered_on_powershell() -> None:
|
|
|
718
720
|
"verdict_directory_write_blocker.py" in each_command
|
|
719
721
|
for each_command in _pretooluse_commands_for_matcher("PowerShell")
|
|
720
722
|
)
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def test_hook_subprocess_imports_real_config_when_parent_holds_shadowing_config(
|
|
726
|
+
tmp_path: pathlib.Path,
|
|
727
|
+
) -> None:
|
|
728
|
+
real_blocking_directory = pathlib.Path(__file__).resolve().parent
|
|
729
|
+
real_hooks_directory = real_blocking_directory.parent
|
|
730
|
+
|
|
731
|
+
staged_hooks_directory = tmp_path / "hooks"
|
|
732
|
+
staged_blocking_directory = staged_hooks_directory / "blocking"
|
|
733
|
+
staged_blocking_directory.mkdir(parents=True)
|
|
734
|
+
|
|
735
|
+
shutil.copy(
|
|
736
|
+
real_blocking_directory / "verdict_directory_write_blocker.py",
|
|
737
|
+
staged_blocking_directory / "verdict_directory_write_blocker.py",
|
|
738
|
+
)
|
|
739
|
+
shutil.copytree(
|
|
740
|
+
real_blocking_directory / "config",
|
|
741
|
+
staged_blocking_directory / "config",
|
|
742
|
+
)
|
|
743
|
+
shutil.copytree(
|
|
744
|
+
real_hooks_directory / "hooks_constants",
|
|
745
|
+
staged_hooks_directory / "hooks_constants",
|
|
746
|
+
)
|
|
747
|
+
|
|
748
|
+
shadowing_config_directory = staged_hooks_directory / "config"
|
|
749
|
+
shadowing_config_directory.mkdir(parents=True, exist_ok=True)
|
|
750
|
+
(shadowing_config_directory / "__init__.py").write_text("", encoding="utf-8")
|
|
751
|
+
(shadowing_config_directory / "unrelated_constants.py").write_text(
|
|
752
|
+
"UNRELATED_VALUE = 1\n", encoding="utf-8"
|
|
753
|
+
)
|
|
754
|
+
|
|
755
|
+
benign_payload = json.dumps(
|
|
756
|
+
{"tool_name": "Bash", "tool_input": {"command": "echo hello"}}
|
|
757
|
+
)
|
|
758
|
+
completed = subprocess.run(
|
|
759
|
+
[
|
|
760
|
+
sys.executable,
|
|
761
|
+
str(staged_blocking_directory / "verdict_directory_write_blocker.py"),
|
|
762
|
+
],
|
|
763
|
+
input=benign_payload,
|
|
764
|
+
capture_output=True,
|
|
765
|
+
text=True,
|
|
766
|
+
)
|
|
767
|
+
|
|
768
|
+
assert "ModuleNotFoundError" not in completed.stderr
|
|
769
|
+
assert completed.returncode == 0
|
|
@@ -23,7 +23,6 @@ hook_module = importlib.util.module_from_spec(hook_spec)
|
|
|
23
23
|
hook_spec.loader.exec_module(hook_module)
|
|
24
24
|
|
|
25
25
|
content_has_violation = hook_module.content_has_violation
|
|
26
|
-
find_bare_index_segments = hook_module.find_bare_index_segments
|
|
27
26
|
find_bare_path_segments = hook_module.find_bare_path_segments
|
|
28
27
|
has_iteration_loop = hook_module.has_iteration_loop
|
|
29
28
|
written_content = hook_module.written_content
|
|
@@ -47,37 +46,23 @@ _FIXED_TEMPLATE = (
|
|
|
47
46
|
|
|
48
47
|
|
|
49
48
|
def test_detects_bare_index_in_path_segment() -> None:
|
|
50
|
-
assert
|
|
49
|
+
assert find_bare_path_segments(
|
|
51
50
|
"render Path(r'${args.work_dir}\\\\cand_i\\\\plate.svg')"
|
|
52
51
|
) == {"cand_i"}
|
|
53
52
|
|
|
54
53
|
|
|
55
54
|
def test_detects_quoted_key_when_token_also_appears_as_path_segment() -> None:
|
|
56
55
|
looped_path_and_key = "write ${work}\\\\cand_i\\\\plate.svg\n{key: \"cand_i\", name}"
|
|
57
|
-
assert "cand_i" in
|
|
56
|
+
assert "cand_i" in find_bare_path_segments(looped_path_and_key)
|
|
58
57
|
|
|
59
58
|
|
|
60
59
|
def test_quoted_key_alone_without_path_segment_is_not_detected() -> None:
|
|
61
|
-
assert
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def test_index_segments_equal_path_segments_for_looped_path_and_key() -> None:
|
|
65
|
-
looped_path_and_key = "write ${work}\\\\cand_i\\\\plate.svg\n{key: \"cand_i\", name}"
|
|
66
|
-
assert find_bare_index_segments(looped_path_and_key) == find_bare_path_segments(
|
|
67
|
-
looped_path_and_key
|
|
68
|
-
)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def test_index_segments_equal_path_segments_for_quoted_only_key() -> None:
|
|
72
|
-
quoted_only_key = '{key: "metric_i", name}'
|
|
73
|
-
assert find_bare_index_segments(quoted_only_key) == find_bare_path_segments(
|
|
74
|
-
quoted_only_key
|
|
75
|
-
)
|
|
60
|
+
assert find_bare_path_segments('{key: "metric_i", name}') == set()
|
|
76
61
|
|
|
77
62
|
|
|
78
63
|
def test_marked_substitution_slot_is_not_a_bare_segment() -> None:
|
|
79
64
|
assert (
|
|
80
|
-
|
|
65
|
+
find_bare_path_segments(
|
|
81
66
|
"render Path(r'${args.work_dir}\\\\cand_<i>\\\\plate.svg')"
|
|
82
67
|
)
|
|
83
68
|
== set()
|
|
@@ -39,7 +39,7 @@ if blocking_directory not in sys.path:
|
|
|
39
39
|
|
|
40
40
|
hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
41
41
|
if hooks_directory not in sys.path:
|
|
42
|
-
sys.path.
|
|
42
|
+
sys.path.append(hooks_directory)
|
|
43
43
|
|
|
44
44
|
from config.verified_commit_constants import ( # noqa: E402
|
|
45
45
|
ALL_GATED_TOOL_NAMES,
|
|
@@ -78,6 +78,7 @@ from config.verified_commit_constants import ( # noqa: E402
|
|
|
78
78
|
WRITE_CALL_REGION_PATTERN,
|
|
79
79
|
)
|
|
80
80
|
|
|
81
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
81
82
|
from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
|
|
82
83
|
read_hook_input_dictionary_from_stdin,
|
|
83
84
|
)
|
|
@@ -664,6 +665,14 @@ def main() -> None:
|
|
|
664
665
|
deny_decision = decision_for_payload(pretooluse_payload)
|
|
665
666
|
if deny_decision is None:
|
|
666
667
|
return
|
|
668
|
+
raw_tool_name = pretooluse_payload.get("tool_name", "")
|
|
669
|
+
tool_name_for_log = raw_tool_name if isinstance(raw_tool_name, str) else ""
|
|
670
|
+
log_hook_block(
|
|
671
|
+
calling_hook_name="verdict_directory_write_blocker.py",
|
|
672
|
+
hook_event="PreToolUse",
|
|
673
|
+
block_reason=VERDICT_DIRECTORY_GUARD_MESSAGE,
|
|
674
|
+
tool_name=tool_name_for_log,
|
|
675
|
+
)
|
|
667
676
|
print(json.dumps(deny_decision))
|
|
668
677
|
sys.stdout.flush()
|
|
669
678
|
|
|
@@ -38,6 +38,10 @@ blocking_directory = str(Path(__file__).resolve().parent)
|
|
|
38
38
|
if blocking_directory not in sys.path:
|
|
39
39
|
sys.path.insert(0, blocking_directory)
|
|
40
40
|
|
|
41
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
42
|
+
if _hooks_dir not in sys.path:
|
|
43
|
+
sys.path.insert(0, _hooks_dir)
|
|
44
|
+
|
|
41
45
|
from config.verified_commit_constants import (
|
|
42
46
|
ALL_GIT_BINARY_NAMES,
|
|
43
47
|
CORRECTIVE_MESSAGE,
|
|
@@ -55,6 +59,7 @@ from config.verified_commit_constants import (
|
|
|
55
59
|
VERIFICATION_BYPASS_MARKER,
|
|
56
60
|
WORK_TREE_OPTION,
|
|
57
61
|
)
|
|
62
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
58
63
|
from verification_verdict_store import (
|
|
59
64
|
branch_surface_manifest,
|
|
60
65
|
is_verification_exempt_diff,
|
|
@@ -543,6 +548,12 @@ def main() -> None:
|
|
|
543
548
|
"permissionDecisionReason": deny_reason,
|
|
544
549
|
}
|
|
545
550
|
}
|
|
551
|
+
log_hook_block(
|
|
552
|
+
calling_hook_name="verified_commit_gate.py",
|
|
553
|
+
hook_event="PreToolUse",
|
|
554
|
+
block_reason=deny_reason,
|
|
555
|
+
tool_name=pretooluse_payload.get("tool_name", "") if isinstance(pretooluse_payload.get("tool_name"), str) else None,
|
|
556
|
+
)
|
|
546
557
|
print(json.dumps(deny_payload))
|
|
547
558
|
return
|
|
548
559
|
|
|
@@ -24,6 +24,13 @@ import json
|
|
|
24
24
|
import os
|
|
25
25
|
import re
|
|
26
26
|
import sys
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
30
|
+
if _hooks_dir not in sys.path:
|
|
31
|
+
sys.path.insert(0, _hooks_dir)
|
|
32
|
+
|
|
33
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
27
34
|
|
|
28
35
|
|
|
29
36
|
def is_guarded_file(file_path: str) -> bool:
|
|
@@ -136,13 +143,21 @@ def main() -> None:
|
|
|
136
143
|
if not claims_blanket_comment_exemption(written_text):
|
|
137
144
|
sys.exit(0)
|
|
138
145
|
|
|
146
|
+
corrective_message = build_corrective_message()
|
|
139
147
|
deny_response = {
|
|
140
148
|
"hookSpecificOutput": {
|
|
141
149
|
"hookEventName": "PreToolUse",
|
|
142
150
|
"permissionDecision": "deny",
|
|
143
|
-
"permissionDecisionReason":
|
|
151
|
+
"permissionDecisionReason": corrective_message,
|
|
144
152
|
}
|
|
145
153
|
}
|
|
154
|
+
log_hook_block(
|
|
155
|
+
calling_hook_name="verified_commit_message_accuracy_blocker.py",
|
|
156
|
+
hook_event="PreToolUse",
|
|
157
|
+
block_reason=corrective_message,
|
|
158
|
+
tool_name=tool_name,
|
|
159
|
+
offending_input_preview=file_path,
|
|
160
|
+
)
|
|
146
161
|
print(json.dumps(deny_response))
|
|
147
162
|
sys.stdout.flush()
|
|
148
163
|
sys.exit(0)
|
|
@@ -21,6 +21,7 @@ _hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
|
21
21
|
if _hooks_dir not in sys.path:
|
|
22
22
|
sys.path.insert(0, _hooks_dir)
|
|
23
23
|
|
|
24
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
24
25
|
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin # noqa: E402
|
|
25
26
|
from hooks_constants.windows_rmtree_blocker_constants import PYTHON_FILE_EXTENSION # noqa: E402
|
|
26
27
|
|
|
@@ -104,6 +105,12 @@ def main() -> None:
|
|
|
104
105
|
"permissionDecisionReason": corrective_message,
|
|
105
106
|
}
|
|
106
107
|
}
|
|
108
|
+
log_hook_block(
|
|
109
|
+
calling_hook_name="windows_rmtree_blocker.py",
|
|
110
|
+
hook_event="PreToolUse",
|
|
111
|
+
block_reason=corrective_message,
|
|
112
|
+
tool_name=tool_name,
|
|
113
|
+
)
|
|
107
114
|
print(json.dumps(deny_response))
|
|
108
115
|
sys.stdout.flush()
|
|
109
116
|
sys.exit(0)
|
|
@@ -40,6 +40,7 @@ _hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
|
40
40
|
if _hooks_dir not in sys.path:
|
|
41
41
|
sys.path.insert(0, _hooks_dir)
|
|
42
42
|
|
|
43
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
43
44
|
from hooks_constants.workflow_substitution_slot_blocker_constants import ( # noqa: E402
|
|
44
45
|
CORRECTIVE_MESSAGE,
|
|
45
46
|
EDIT_TOOL_NAME,
|
|
@@ -111,16 +112,12 @@ def find_bare_path_segments(content: str) -> set[str]:
|
|
|
111
112
|
return all_path_segments
|
|
112
113
|
|
|
113
114
|
|
|
114
|
-
def find_bare_index_segments(content: str) -> set[str]:
|
|
115
|
-
return find_bare_path_segments(content)
|
|
116
|
-
|
|
117
|
-
|
|
118
115
|
def content_has_violation(content: str) -> bool:
|
|
119
116
|
if not uses_angle_slot_convention(content):
|
|
120
117
|
return False
|
|
121
118
|
if not has_iteration_loop(content):
|
|
122
119
|
return False
|
|
123
|
-
return bool(
|
|
120
|
+
return bool(find_bare_path_segments(content))
|
|
124
121
|
|
|
125
122
|
|
|
126
123
|
def main() -> None:
|
|
@@ -150,6 +147,14 @@ def main() -> None:
|
|
|
150
147
|
"permissionDecisionReason": CORRECTIVE_MESSAGE,
|
|
151
148
|
}
|
|
152
149
|
}
|
|
150
|
+
raw_tool_name_for_log = hook_input.get("tool_name", "")
|
|
151
|
+
tool_name_for_log = raw_tool_name_for_log if isinstance(raw_tool_name_for_log, str) else ""
|
|
152
|
+
log_hook_block(
|
|
153
|
+
calling_hook_name="workflow_substitution_slot_blocker.py",
|
|
154
|
+
hook_event="PreToolUse",
|
|
155
|
+
block_reason=CORRECTIVE_MESSAGE,
|
|
156
|
+
tool_name=tool_name_for_log,
|
|
157
|
+
)
|
|
153
158
|
print(json.dumps(deny_payload))
|
|
154
159
|
sys.stdout.flush()
|
|
155
160
|
sys.exit(0)
|
|
@@ -8,6 +8,13 @@ Exemptions: Jupyter notebooks (.ipynb) and files in ~/.claude/hooks/ (standalone
|
|
|
8
8
|
import json
|
|
9
9
|
import os
|
|
10
10
|
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
14
|
+
if _hooks_dir not in sys.path:
|
|
15
|
+
sys.path.insert(0, _hooks_dir)
|
|
16
|
+
|
|
17
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
11
18
|
|
|
12
19
|
JUPYTER_EXTENSION = ".ipynb"
|
|
13
20
|
HOOKS_DIRECTORY = os.path.normpath(os.path.expanduser("~/.claude/hooks"))
|
|
@@ -48,13 +55,21 @@ def main() -> None:
|
|
|
48
55
|
if not os.path.exists(target_file_path):
|
|
49
56
|
sys.exit(0)
|
|
50
57
|
|
|
58
|
+
deny_reason = f"BLOCKED: Write on existing file {target_file_path}. Use Edit tool instead."
|
|
51
59
|
denial = {
|
|
52
60
|
"hookSpecificOutput": {
|
|
53
61
|
"hookEventName": "PreToolUse",
|
|
54
62
|
"permissionDecision": "deny",
|
|
55
|
-
"permissionDecisionReason":
|
|
63
|
+
"permissionDecisionReason": deny_reason,
|
|
56
64
|
}
|
|
57
65
|
}
|
|
66
|
+
log_hook_block(
|
|
67
|
+
calling_hook_name="write_existing_file_blocker.py",
|
|
68
|
+
hook_event="PreToolUse",
|
|
69
|
+
block_reason=deny_reason,
|
|
70
|
+
tool_name="Write",
|
|
71
|
+
offending_input_preview=target_file_path,
|
|
72
|
+
)
|
|
58
73
|
print(json.dumps(denial))
|
|
59
74
|
sys.exit(0)
|
|
60
75
|
|
package/hooks/hooks.json
CHANGED
|
@@ -161,6 +161,16 @@
|
|
|
161
161
|
"timeout": 10
|
|
162
162
|
}
|
|
163
163
|
]
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
"matcher": "SendUserFile",
|
|
167
|
+
"hooks": [
|
|
168
|
+
{
|
|
169
|
+
"type": "command",
|
|
170
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/send_user_file_open_locally_blocker.py",
|
|
171
|
+
"timeout": 10
|
|
172
|
+
}
|
|
173
|
+
]
|
|
164
174
|
}
|
|
165
175
|
],
|
|
166
176
|
"SessionStart": [
|
|
@@ -22,6 +22,7 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
|
|
|
22
22
|
| `dead_module_constant_constants.py` | Patterns for detecting unexported `UPPER_SNAKE` constants in `*_constants.py` modules |
|
|
23
23
|
| `destructive_command_segment_constants.py` | The list of destructive shell command patterns the blocker matches |
|
|
24
24
|
| `doc_gist_auto_publish_constants.py` | Sentinel marker and URL patterns for the doc-gist auto-publish hook |
|
|
25
|
+
| `docstring_rule_gate_count_blocker_constants.py` | Target rule basename, spelled-out-number lookup, count-clause and `check_*` validator patterns, and block-message text for the docstring-rule gate-count staleness blocker |
|
|
25
26
|
| `duplicate_function_body_constants.py` | Hashing and comparison config for the duplicate-body check |
|
|
26
27
|
| `dynamic_stderr_handler.py` | `DynamicStderrHandler` — a logging handler that resolves `sys.stderr` at emit time (for testability) |
|
|
27
28
|
| `gh_pr_author_swap_constants.py` | Constants for the PR-author swap enforcement hooks |
|
|
@@ -32,8 +33,10 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
|
|
|
32
33
|
| `inline_tuple_string_magic_constants.py` | Patterns for detecting magic strings in inline tuple literals |
|
|
33
34
|
| `md_to_html_blocker_constants.py` | Path exemptions and trigger patterns for the markdown-to-html blocker |
|
|
34
35
|
| `messages.py` | Short user-facing notice strings shown when a Stop hook redirects agent behavior |
|
|
36
|
+
| `multi_edit_reconstruction.py` | `apply_edits()` / `edits_for_tool()` — shared helpers that reconstruct the post-edit content of an Edit or MultiEdit, imported by the blockers that judge post-edit content |
|
|
35
37
|
| `open_questions_in_plans_blocker_constants.py` | Patterns for detecting unresolved open questions in plan documents |
|
|
36
38
|
| `orphan_css_class_constants.py` | Scan radius and selector patterns for the orphan-CSS-class check |
|
|
39
|
+
| `package_inventory_stale_blocker_constants.py` | Inventory document names, production code extensions, backtick token pattern, smallest inventory size, exempt names, scan budget, and block-message text for the package-inventory stale-entry blocker |
|
|
37
40
|
| `path_rewriter_constants.py` | Path rewriting patterns for the Everything-search path rewriter |
|
|
38
41
|
| `plain_language_blocker_constants.py` | The list of heavy words and their everyday replacements |
|
|
39
42
|
| `pr_converge_bugteam_enforcer_constants.py` | State keys and timing config for the bugteam-parallel enforcer |
|
|
@@ -43,6 +46,7 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
|
|
|
43
46
|
| `precommit_code_rules_gate_constants.py` | Scope argument and exit-code constants for the precommit gate |
|
|
44
47
|
| `project_paths_reader.py` | Loads `~/.claude/project-paths.json` — the per-user project-path registry |
|
|
45
48
|
| `pytest_testpaths_orphan_blocker_constants.py` | Marker filename, section and key names, test-file pattern, search budget, and block-message text for the pytest unregistered-test-directory blocker |
|
|
49
|
+
| `send_user_file_open_locally_blocker_constants.py` | Tool name, proactive status, and the block message for the open-locally attach blocker |
|
|
46
50
|
| `session_env_cleanup_constants.py` | Stale-age threshold and directory names for the session-env cleanup hook |
|
|
47
51
|
| `session_handoff_blocker_constants.py` | Trigger phrases for the session-handoff blocker |
|
|
48
52
|
| `setup_project_paths_constants.py` | Encoding policy, BOM marker, and registry meta-key used across multiple hooks |
|
|
@@ -30,6 +30,7 @@ DOCSTRING_TRIVIAL_FUNCTION_BODY_LINE_LIMIT: int = 3
|
|
|
30
30
|
MAX_DOCSTRING_FALLBACK_BRANCH_ISSUES: int = 3
|
|
31
31
|
DOCSTRING_FALLBACK_BRANCH_MINIMUM_ROUTE_COUNT: int = 2
|
|
32
32
|
MAX_DOCSTRING_NO_CONSUMER_CLAIM_ISSUES: int = 3
|
|
33
|
+
MAX_DOCSTRING_UNGUARDED_PAYLOAD_CLAIM_ISSUES: int = 3
|
|
33
34
|
MAX_STALE_TEST_NAME_TARGET_ISSUES: int = 3
|
|
34
35
|
STALE_TEST_NAME_MINIMUM_SHARED_TOKEN_COUNT: int = 2
|
|
35
36
|
MAX_MODULE_DOCSTRING_CHECK_ROSTER_ISSUES: int = 5
|
|
@@ -40,6 +41,8 @@ MAX_DOCSTRING_STEP_DISPATCH_ISSUES: int = 5
|
|
|
40
41
|
MINIMUM_NAMED_LINEAR_STEPS_FOR_DISPATCH_CHECK: int = 2
|
|
41
42
|
MINIMUM_TOKENS_FOR_DISPATCH_CALLEE: int = 2
|
|
42
43
|
MAX_DOCSTRING_UNDEFINED_CONSTANT_ISSUES: int = 3
|
|
44
|
+
MAX_DOCSTRING_RETURNS_PLURAL_CARDINALITY_ISSUES: int = 5
|
|
45
|
+
SINGLE_DICT_KEY_COUNT_FOR_PLURAL_CARDINALITY_DRIFT: int = 1
|
|
43
46
|
ALL_NAMING_CONVENTION_DESCRIPTOR_TOKENS: frozenset[str] = frozenset(
|
|
44
47
|
{
|
|
45
48
|
"UPPER_SNAKE_CASE",
|
|
@@ -94,6 +97,16 @@ ALL_DOCSTRING_NO_CONSUMER_CLAIM_PHRASES: tuple[str, ...] = (
|
|
|
94
97
|
"not yet read by any consumer",
|
|
95
98
|
)
|
|
96
99
|
|
|
100
|
+
ALL_DOCSTRING_GUARDED_FAILURE_CLAIM_PHRASES: tuple[str, ...] = (
|
|
101
|
+
"malformed payload resolves to none",
|
|
102
|
+
"malformed payload returns none",
|
|
103
|
+
"malformed response resolves to none",
|
|
104
|
+
"malformed response returns none",
|
|
105
|
+
"bad payload resolves to none",
|
|
106
|
+
"invalid payload resolves to none",
|
|
107
|
+
"malformed payload yields none",
|
|
108
|
+
)
|
|
109
|
+
|
|
97
110
|
MAX_DOCSTRING_INLINE_LITERAL_CLAIM_ISSUES: int = 3
|
|
98
111
|
ALL_DOCSTRING_NO_INLINE_LITERAL_CLAIM_PHRASES: tuple[str, ...] = (
|
|
99
112
|
"no literals appear inline",
|
|
@@ -42,6 +42,9 @@ UPPER_SNAKE_CONSTANT_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]*$")
|
|
|
42
42
|
ALL_MUST_CHECK_RETURN_FUNCTION_NAMES: frozenset[str] = frozenset({"find_and_click", "write_outcome"})
|
|
43
43
|
|
|
44
44
|
DOCSTRING_ARG_ENTRY_PATTERN: re.Pattern[str] = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\s*[:(]")
|
|
45
|
+
DOCSTRING_PLURAL_FAMILY_STOP_PATTERN: re.Pattern[str] = re.compile(
|
|
46
|
+
r"\bthe\s+([a-z][a-z]+)\s+stops\b"
|
|
47
|
+
)
|
|
45
48
|
INLINE_CODE_TOKEN_PATTERN: re.Pattern[str] = re.compile(r"``?(\.?[A-Za-z_][A-Za-z0-9_.]*)``?")
|
|
46
49
|
IDENTIFIER_SHAPED_TUPLE_MEMBER_PATTERN: re.Pattern[str] = re.compile(r"^\.?[A-Za-z_][A-Za-z0-9_]*$")
|
|
47
50
|
ALL_DOCSTRING_ARGS_SECTION_HEADERS: tuple[str, ...] = ("Args:", "Arguments:")
|