claude-dev-env 1.73.0 → 1.75.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 +4 -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 +12 -5
- 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/duplicate_rmtree_helper_blocker.py +155 -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 +17 -23
- package/hooks/blocking/hook_prose_detector_consistency.py +7 -0
- package/hooks/blocking/intent_only_ending_blocker.py +18 -26
- 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 +5 -6
- 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 +19 -23
- 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 +15 -23
- 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 +61 -0
- package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +203 -0
- package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +328 -0
- package/hooks/blocking/test_hedging_language_blocker.py +6 -0
- package/hooks/blocking/test_hook_block_logger_coverage.py +53 -0
- package/hooks/blocking/test_intent_only_ending_blocker.py +5 -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 +55 -8
- package/hooks/blocking/test_question_to_user_enforcer.py +6 -0
- package/hooks/blocking/test_send_user_file_open_locally_blocker.py +114 -0
- package/hooks/blocking/test_session_handoff_blocker.py +6 -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 +8 -1
- 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/code_verifier_spawn_preflight_gate_constants.py +2 -1
- package/hooks/hooks_constants/docstring_rule_gate_count_blocker_constants.py +90 -0
- package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +27 -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 +3 -2
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +17 -3
- 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/hooks_constants/test_post_tool_use_dispatcher_constants.py +43 -0
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +99 -0
- package/hooks/hooks_constants/test_text_stripping.py +39 -0
- package/hooks/hooks_constants/text_stripping.py +36 -0
- package/hooks/lifecycle/config_change_guard.py +12 -0
- package/hooks/lifecycle/test_config_change_guard.py +23 -0
- package/hooks/validation/CLAUDE.md +1 -0
- package/hooks/validation/hook_format_validator.py +13 -0
- package/hooks/validation/mypy_validator.py +30 -1
- package/hooks/validation/post_tool_use_dispatcher.py +2 -2
- package/hooks/validation/test_hook_format_validator.py +64 -0
- package/hooks/validation/test_mypy_validator.py +23 -1
- package/hooks/validation/test_post_tool_use_dispatcher.py +6 -0
- package/hooks/workflow/auto_formatter.py +8 -5
- package/hooks/workflow/test_auto_formatter.py +33 -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/rules/windows-filesystem-safe.md +2 -0
- package/skills/autoconverge/SKILL.md +21 -1
- package/skills/autoconverge/reference/stop-conditions.md +7 -0
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +5 -4
- package/skills/autoconverge/workflow/converge.contract.test.mjs +398 -116
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +16 -16
- package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +36 -44
- package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +16 -24
- package/skills/autoconverge/workflow/converge.mjs +599 -606
- package/skills/autoconverge/workflow/convergence_summary.py +1 -1
- package/skills/autoconverge/workflow/render_report.py +2 -6
- package/skills/autoconverge/workflow/test_convergence_summary.py +17 -0
- package/skills/autoconverge/workflow/test_render_report.py +1 -0
|
@@ -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,18 +22,23 @@ 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 |
|
|
27
|
+
| `duplicate_rmtree_helper_blocker_constants.py` | Sanctioned Windows-safe rmtree helper names, the definition pattern, and the exempt-path set for the duplicate-rmtree-helper blocker |
|
|
26
28
|
| `dynamic_stderr_handler.py` | `DynamicStderrHandler` — a logging handler that resolves `sys.stderr` at emit time (for testability) |
|
|
27
29
|
| `gh_pr_author_swap_constants.py` | Constants for the PR-author swap enforcement hooks |
|
|
28
30
|
| `hardcoded_user_path_constants.py` | Patterns for detecting hardcoded home-directory paths |
|
|
31
|
+
| `hook_block_logger.py` | `log_hook_block()` — shared fail-safe logger every blocking hook calls to append a JSON record of each block decision to `~/.claude/logs/hook-blocks.log` |
|
|
29
32
|
| `hook_log_extractor_constants.py` | Neon table name, offset state file path, timeouts, and outcome-type mapping for the hook-log extractor |
|
|
30
33
|
| `hook_prose_detector_consistency_constants.py` | Trigger patterns and corrective messages for the hook-prose consistency checker |
|
|
31
34
|
| `html_companion_constants.py` | Blocked URL schemes and other config for the `.md`-to-`.html` companion hook |
|
|
32
35
|
| `inline_tuple_string_magic_constants.py` | Patterns for detecting magic strings in inline tuple literals |
|
|
33
36
|
| `md_to_html_blocker_constants.py` | Path exemptions and trigger patterns for the markdown-to-html blocker |
|
|
34
37
|
| `messages.py` | Short user-facing notice strings shown when a Stop hook redirects agent behavior |
|
|
38
|
+
| `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
39
|
| `open_questions_in_plans_blocker_constants.py` | Patterns for detecting unresolved open questions in plan documents |
|
|
36
40
|
| `orphan_css_class_constants.py` | Scan radius and selector patterns for the orphan-CSS-class check |
|
|
41
|
+
| `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
42
|
| `path_rewriter_constants.py` | Path rewriting patterns for the Everything-search path rewriter |
|
|
38
43
|
| `plain_language_blocker_constants.py` | The list of heavy words and their everyday replacements |
|
|
39
44
|
| `pr_converge_bugteam_enforcer_constants.py` | State keys and timing config for the bugteam-parallel enforcer |
|
|
@@ -43,6 +48,7 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
|
|
|
43
48
|
| `precommit_code_rules_gate_constants.py` | Scope argument and exit-code constants for the precommit gate |
|
|
44
49
|
| `project_paths_reader.py` | Loads `~/.claude/project-paths.json` — the per-user project-path registry |
|
|
45
50
|
| `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 |
|
|
51
|
+
| `send_user_file_open_locally_blocker_constants.py` | Tool name, proactive status, and the block message for the open-locally attach blocker |
|
|
46
52
|
| `session_env_cleanup_constants.py` | Stale-age threshold and directory names for the session-env cleanup hook |
|
|
47
53
|
| `session_handoff_blocker_constants.py` | Trigger phrases for the session-handoff blocker |
|
|
48
54
|
| `setup_project_paths_constants.py` | Encoding policy, BOM marker, and registry meta-key used across multiple hooks |
|
|
@@ -51,6 +57,7 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
|
|
|
51
57
|
| `stuttering_import_binding_constants.py` | Import-binding patterns for the stuttering check |
|
|
52
58
|
| `subprocess_budget_completeness_constants.py` | Required argument names for the subprocess-budget completeness check |
|
|
53
59
|
| `sys_path_insert_constants.py` | Patterns for detecting unguarded `sys.path.insert` calls |
|
|
60
|
+
| `text_stripping.py` | `strip_code_and_quotes()` — shared helper that removes fenced code blocks, inline code, and blockquotes from prose, imported by the Stop-hook prose blockers |
|
|
54
61
|
| `unused_module_import_constants.py` | Patterns for detecting unused module-level imports |
|
|
55
62
|
| `windows_rmtree_blocker_constants.py` | The unsafe `shutil.rmtree` pattern and the safe replacement pattern |
|
|
56
63
|
| `workflow_substitution_slot_blocker_constants.py` | Per-iteration token patterns for the workflow-slot blocker |
|
|
@@ -60,4 +67,4 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
|
|
|
60
67
|
- Every file in this package is a pure constants module — no side effects, no I/O.
|
|
61
68
|
- Hooks import from this package with `from hooks_constants.<module> import <CONSTANT>`.
|
|
62
69
|
- Tests for these modules live beside them as `test_<module>.py`. Run with `python -m pytest hooks_constants/test_<name>.py`.
|
|
63
|
-
- `dynamic_stderr_handler.py` and `
|
|
70
|
+
- `dynamic_stderr_handler.py`, `pre_tool_use_stdin.py`, `multi_edit_reconstruction.py`, and `text_stripping.py` are utility modules (not pure constants) but live here because they are shared across many 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:")
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
The gate denies an ``Agent`` spawn whose ``subagent_type`` is ``code-verifier``
|
|
4
4
|
when the branch carries a merge conflict against its base ref or a CODE_RULES
|
|
5
|
-
violation on a line added in the
|
|
5
|
+
violation on a line added in the working tree since the merge base (committed on
|
|
6
|
+
the branch or uncommitted). It runs two
|
|
6
7
|
pre-flight checks before the expensive verification spawn and addresses its
|
|
7
8
|
deny reason to the spawning agent so that agent fixes the named issues and
|
|
8
9
|
re-spawns. Every literal the hook body reads lives here; the hook imports
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Constants for the docstring-rule gate-count staleness blocker.
|
|
2
|
+
|
|
3
|
+
The rule file ``docstring-prose-matches-implementation.md`` enumerates the
|
|
4
|
+
``check_docstring_*`` gate validators that cover deterministic slices of docstring
|
|
5
|
+
prose, both as a spelled-out count ("Three more gate validators", "four gated
|
|
6
|
+
slices") and as a backticked list of the validator names. When a new gate
|
|
7
|
+
validator is registered but the count word is left unchanged, the rule's stated
|
|
8
|
+
count drifts from the validators it actually names — the same companion-doc drift
|
|
9
|
+
the rule itself governs. This module holds the target rule basename, the
|
|
10
|
+
spelled-out-number lookup, the code-fence pattern that marks lines to skip, the
|
|
11
|
+
patterns that find the "<count> more gate validators" and "<count> gated slices"
|
|
12
|
+
count clauses and the backticked ``check_*`` validator names, the args-gate name,
|
|
13
|
+
the issue budget, and the block-message text the hook emits.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"TARGET_RULE_BASENAME",
|
|
20
|
+
"ALL_NUMBER_WORDS_BY_VALUE",
|
|
21
|
+
"CODE_FENCE_PATTERN",
|
|
22
|
+
"FREE_FORM_GATE_COUNT_PATTERN",
|
|
23
|
+
"TOTAL_GATED_SLICE_COUNT_PATTERN",
|
|
24
|
+
"GATE_VALIDATOR_NAME_PATTERN",
|
|
25
|
+
"ARGS_GATE_VALIDATOR_NAME",
|
|
26
|
+
"MAX_GATE_COUNT_ISSUES",
|
|
27
|
+
"GATE_COUNT_MESSAGE_TEMPLATE",
|
|
28
|
+
"GATE_COUNT_SYSTEM_MESSAGE",
|
|
29
|
+
"GATE_COUNT_ADDITIONAL_CONTEXT",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
TARGET_RULE_BASENAME: str = "docstring-prose-matches-implementation.md"
|
|
33
|
+
|
|
34
|
+
ALL_NUMBER_WORDS_BY_VALUE: dict[str, int] = {
|
|
35
|
+
"zero": 0,
|
|
36
|
+
"one": 1,
|
|
37
|
+
"two": 2,
|
|
38
|
+
"three": 3,
|
|
39
|
+
"four": 4,
|
|
40
|
+
"five": 5,
|
|
41
|
+
"six": 6,
|
|
42
|
+
"seven": 7,
|
|
43
|
+
"eight": 8,
|
|
44
|
+
"nine": 9,
|
|
45
|
+
"ten": 10,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
CODE_FENCE_PATTERN: re.Pattern[str] = re.compile(r"^\s*(?:```|~~~)")
|
|
49
|
+
|
|
50
|
+
FREE_FORM_GATE_COUNT_PATTERN: re.Pattern[str] = re.compile(
|
|
51
|
+
r"\b([A-Za-z]+)\s+more\s+gate\s+validators\b",
|
|
52
|
+
re.IGNORECASE,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
TOTAL_GATED_SLICE_COUNT_PATTERN: re.Pattern[str] = re.compile(
|
|
56
|
+
r"\b([A-Za-z]+)\s+gated\s+slices\b",
|
|
57
|
+
re.IGNORECASE,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
GATE_VALIDATOR_NAME_PATTERN: re.Pattern[str] = re.compile(r"`(check_[A-Za-z0-9_]+)`")
|
|
61
|
+
|
|
62
|
+
ARGS_GATE_VALIDATOR_NAME: str = "check_docstring_args_match_signature"
|
|
63
|
+
|
|
64
|
+
MAX_GATE_COUNT_ISSUES: int = 4
|
|
65
|
+
|
|
66
|
+
GATE_COUNT_MESSAGE_TEMPLATE: str = (
|
|
67
|
+
"{rule_basename} states '{stated_phrase}' ({stated_count}) but names "
|
|
68
|
+
"{named_count} distinct free-form gate validator(s) ({named_validators}). The "
|
|
69
|
+
"rule's spelled-out gate count drifts from the validators it enumerates — the "
|
|
70
|
+
"companion-doc-vs-implementation drift this rule governs. Update the count "
|
|
71
|
+
"word to {named_count} and the '... gated slices' total to {total_count} in "
|
|
72
|
+
"this same change, and name every gate validator the prose counts."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
GATE_COUNT_SYSTEM_MESSAGE: str = (
|
|
76
|
+
"Gate-validator count in docstring-prose-matches-implementation.md drifted "
|
|
77
|
+
"from the validators it names - update the count word in this same change"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
GATE_COUNT_ADDITIONAL_CONTEXT: str = (
|
|
81
|
+
"The rule docstring-prose-matches-implementation.md states a spelled-out "
|
|
82
|
+
"count of free-form docstring gate validators ('Four more gate validators') "
|
|
83
|
+
"and a total ('five gated slices'), then names each validator in backticks "
|
|
84
|
+
"(`check_docstring_fallback_branch_coverage`, ...). When a new "
|
|
85
|
+
"`check_docstring_*` gate is added, name it in the prose and bump both count "
|
|
86
|
+
"words: the 'N more gate validators' count equals the number of distinct "
|
|
87
|
+
"free-form validators named after it, and the 'M gated slices' total equals "
|
|
88
|
+
"that count plus one for check_docstring_args_match_signature. Keep the count "
|
|
89
|
+
"words and the named-validator list in step in the same change."
|
|
90
|
+
)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Configuration constants for the duplicate_rmtree_helper_blocker PreToolUse hook."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
PYTHON_FILE_EXTENSION: str = ".py"
|
|
6
|
+
|
|
7
|
+
HELPER_DEFINITION_PATTERN: re.Pattern[str] = re.compile(
|
|
8
|
+
r"^[ \t]*def[ \t]+(?:_strip_read_only_and_retry|_force_remove_tree|force_rmtree)[ \t]*\(",
|
|
9
|
+
re.MULTILINE,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
TRIPLE_QUOTED_STRING_PATTERN: re.Pattern[str] = re.compile(
|
|
13
|
+
r'"""[\s\S]*?"""|\'\'\'[\s\S]*?\'\'\'',
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
ALL_EXEMPT_PATH_FRAGMENTS: tuple[str, ...] = (
|
|
17
|
+
"windows_rmtree_blocker.py",
|
|
18
|
+
"duplicate_rmtree_helper_blocker.py",
|
|
19
|
+
"windows_safe_rmtree.py",
|
|
20
|
+
"windows_filesystem.py",
|
|
21
|
+
"session_env_cleanup.py",
|
|
22
|
+
"_md_to_html_blocker_test_support.py",
|
|
23
|
+
"teardown_worktrees.py",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
ALL_EXEMPT_TEST_FILE_PREFIXES: tuple[str, ...] = ("test_",)
|
|
27
|
+
ALL_EXEMPT_TEST_FILE_SUFFIXES: tuple[str, ...] = ("_test.py",)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Shared fail-safe logger for hook block events.
|
|
2
|
+
|
|
3
|
+
Every blocking hook calls log_hook_block at the moment it decides to block,
|
|
4
|
+
so the user has a single log showing what tripped and why.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import datetime
|
|
8
|
+
import json
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
_HOOK_BLOCKS_LOG_RELATIVE_PATH = ".claude/logs/hook-blocks.log"
|
|
12
|
+
_MAX_PREVIEW_LENGTH = 500
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def log_hook_block(
|
|
16
|
+
calling_hook_name: str,
|
|
17
|
+
hook_event: str,
|
|
18
|
+
block_reason: str,
|
|
19
|
+
tool_name: str | None = None,
|
|
20
|
+
offending_input_preview: str | None = None,
|
|
21
|
+
) -> None:
|
|
22
|
+
"""Append one JSON record to the hook-blocks log for a block decision.
|
|
23
|
+
|
|
24
|
+
Creates the logs directory if absent. Skips logging when the home directory
|
|
25
|
+
cannot be resolved, and silently swallows all IO errors otherwise, so a
|
|
26
|
+
logging failure never changes a hook's decision.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
calling_hook_name: The script basename of the hook that is blocking.
|
|
30
|
+
hook_event: The hook event type, e.g. ``PreToolUse`` or ``Stop``.
|
|
31
|
+
block_reason: The human-readable reason the hook is blocking.
|
|
32
|
+
tool_name: The Claude tool name when available, e.g. ``Bash``.
|
|
33
|
+
offending_input_preview: A short excerpt of the input that triggered
|
|
34
|
+
the block; truncated to 500 characters before writing.
|
|
35
|
+
"""
|
|
36
|
+
try:
|
|
37
|
+
home_directory = Path.home()
|
|
38
|
+
except RuntimeError:
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
log_path = home_directory / _HOOK_BLOCKS_LOG_RELATIVE_PATH
|
|
43
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
|
|
45
|
+
log_record: dict[str, str] = {
|
|
46
|
+
"timestamp": datetime.datetime.now().isoformat(),
|
|
47
|
+
"hook": calling_hook_name,
|
|
48
|
+
"event": hook_event,
|
|
49
|
+
"reason": block_reason,
|
|
50
|
+
}
|
|
51
|
+
if tool_name is not None:
|
|
52
|
+
log_record["tool"] = tool_name
|
|
53
|
+
if offending_input_preview is not None:
|
|
54
|
+
log_record["preview"] = offending_input_preview[:_MAX_PREVIEW_LENGTH]
|
|
55
|
+
|
|
56
|
+
with log_path.open("a", encoding="utf-8") as log_file:
|
|
57
|
+
log_file.write(json.dumps(log_record) + "\n")
|
|
58
|
+
except OSError:
|
|
59
|
+
pass
|