claude-dev-env 1.72.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.
Files changed (99) hide show
  1. package/CLAUDE.md +2 -0
  2. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +2 -2
  3. package/bin/install.mjs +73 -5
  4. package/bin/install.test.mjs +360 -4
  5. package/hooks/blocking/CLAUDE.md +6 -1
  6. package/hooks/blocking/block_main_commit.py +14 -0
  7. package/hooks/blocking/bot_mention_comment_blocker.py +7 -0
  8. package/hooks/blocking/claude_md_orphan_file_blocker.py +19 -48
  9. package/hooks/blocking/code_rules_dead_config_field.py +69 -56
  10. package/hooks/blocking/code_rules_docstrings.py +839 -0
  11. package/hooks/blocking/code_rules_enforcer.py +38 -0
  12. package/hooks/blocking/code_rules_shared.py +19 -0
  13. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +426 -0
  14. package/hooks/blocking/convergence_gate_blocker.py +17 -3
  15. package/hooks/blocking/destructive_command_blocker.py +7 -0
  16. package/hooks/blocking/docstring_rule_gate_count_blocker.py +321 -0
  17. package/hooks/blocking/gh_body_arg_blocker.py +8 -0
  18. package/hooks/blocking/gh_pr_author_enforcer.py +7 -0
  19. package/hooks/blocking/hedging_language_blocker.py +16 -10
  20. package/hooks/blocking/hook_prose_detector_consistency.py +7 -0
  21. package/hooks/blocking/intent_only_ending_blocker.py +17 -11
  22. package/hooks/blocking/md_to_html_blocker.py +17 -10
  23. package/hooks/blocking/open_questions_in_plans_blocker.py +15 -8
  24. package/hooks/blocking/package_inventory_stale_blocker.py +398 -0
  25. package/hooks/blocking/plain_language_blocker.py +57 -16
  26. package/hooks/blocking/pr_converge_bugteam_enforcer.py +11 -5
  27. package/hooks/blocking/pr_description_enforcer.py +6 -0
  28. package/hooks/blocking/pre_tool_use_dispatcher.py +545 -0
  29. package/hooks/blocking/precommit_code_rules_gate.py +10 -1
  30. package/hooks/blocking/pytest_testpaths_orphan_blocker.py +366 -0
  31. package/hooks/blocking/question_to_user_enforcer.py +18 -12
  32. package/hooks/blocking/send_user_file_open_locally_blocker.py +70 -0
  33. package/hooks/blocking/sensitive_file_protector.py +15 -1
  34. package/hooks/blocking/session_handoff_blocker.py +14 -8
  35. package/hooks/blocking/state_description_blocker.py +81 -36
  36. package/hooks/blocking/subprocess_budget_completeness.py +9 -3
  37. package/hooks/blocking/tdd_enforcer.py +6 -0
  38. package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +81 -0
  39. package/hooks/blocking/test_code_rules_enforcer_docstring_inline_literal_claim.py +93 -0
  40. package/hooks/blocking/test_code_rules_enforcer_docstring_returns_plural_cardinality.py +207 -0
  41. package/hooks/blocking/test_code_rules_enforcer_docstring_step_dispatch.py +262 -0
  42. package/hooks/blocking/test_code_rules_enforcer_docstring_undefined_constant.py +253 -0
  43. package/hooks/blocking/test_code_rules_enforcer_docstring_unguarded_payload.py +188 -0
  44. package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +279 -0
  45. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +501 -0
  46. package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +203 -0
  47. package/hooks/blocking/test_hook_block_logger_coverage.py +53 -0
  48. package/hooks/blocking/test_package_inventory_stale_blocker.py +329 -0
  49. package/hooks/blocking/test_plain_language_blocker.py +36 -0
  50. package/hooks/blocking/test_pre_tool_use_dispatcher.py +816 -0
  51. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +341 -0
  52. package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +247 -0
  53. package/hooks/blocking/test_send_user_file_open_locally_blocker.py +114 -0
  54. package/hooks/blocking/test_shared_stdin_adoption.py +208 -0
  55. package/hooks/blocking/test_state_description_blocker.py +41 -0
  56. package/hooks/blocking/test_verdict_directory_write_blocker.py +49 -0
  57. package/hooks/blocking/test_workflow_substitution_slot_blocker.py +4 -19
  58. package/hooks/blocking/verdict_directory_write_blocker.py +21 -7
  59. package/hooks/blocking/verified_commit_gate.py +11 -0
  60. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +16 -1
  61. package/hooks/blocking/windows_rmtree_blocker.py +7 -0
  62. package/hooks/blocking/workflow_substitution_slot_blocker.py +10 -5
  63. package/hooks/blocking/write_existing_file_blocker.py +16 -1
  64. package/hooks/hooks.json +19 -79
  65. package/hooks/hooks_constants/CLAUDE.md +7 -1
  66. package/hooks/hooks_constants/blocking_check_limits.py +74 -0
  67. package/hooks/hooks_constants/code_rules_enforcer_constants.py +9 -0
  68. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +45 -0
  69. package/hooks/hooks_constants/dead_config_field_constants.py +5 -5
  70. package/hooks/hooks_constants/docstring_rule_gate_count_blocker_constants.py +90 -0
  71. package/hooks/hooks_constants/hook_block_logger.py +59 -0
  72. package/hooks/hooks_constants/multi_edit_reconstruction.py +56 -0
  73. package/hooks/hooks_constants/mypy_validator_cache_constants.py +36 -0
  74. package/hooks/hooks_constants/package_inventory_stale_blocker_constants.py +111 -0
  75. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +68 -0
  76. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +143 -0
  77. package/hooks/hooks_constants/pytest_testpaths_orphan_blocker_constants.py +79 -0
  78. package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +18 -0
  79. package/hooks/hooks_constants/test_dispatcher_constants_docstrings.py +44 -0
  80. package/hooks/hooks_constants/test_hook_block_logger.py +159 -0
  81. package/hooks/lifecycle/config_change_guard.py +12 -0
  82. package/hooks/lifecycle/test_config_change_guard.py +23 -0
  83. package/hooks/validation/hook_format_validator.py +13 -0
  84. package/hooks/validation/mypy_validator.py +245 -18
  85. package/hooks/validation/post_tool_use_dispatcher.py +344 -0
  86. package/hooks/validation/test_hook_format_validator.py +64 -0
  87. package/hooks/validation/test_mypy_validator.py +206 -1
  88. package/hooks/validation/test_post_tool_use_dispatcher.py +610 -0
  89. package/hooks/workflow/test_auto_formatter.py +10 -9
  90. package/package.json +1 -1
  91. package/rules/CLAUDE.md +1 -0
  92. package/rules/docstring-prose-matches-implementation.md +4 -2
  93. package/rules/package-inventory-stale-entry.md +24 -0
  94. package/skills/autoconverge/SKILL.md +111 -1
  95. package/skills/autoconverge/workflow/converge.contract.test.mjs +106 -0
  96. package/skills/autoconverge/workflow/converge.mjs +29 -3
  97. package/skills/autoconverge/workflow/converge.path-aware.test.mjs +47 -0
  98. package/skills/autoconverge/workflow/converge_multi.mjs +161 -0
  99. package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +100 -0
@@ -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 {{{") == ""
@@ -0,0 +1,208 @@
1
+ """Same-decision tests for hooks converted to the shared stdin parser.
2
+
3
+ Each converted hook reads its PreToolUse payload through
4
+ ``hooks_constants.pre_tool_use_stdin.read_hook_input_dictionary_from_stdin``
5
+ rather than a hand-rolled ``json.load(sys.stdin)`` plus ``isinstance(dict)``
6
+ guard. The shared parser fails open on empty stdin, malformed JSON, and a
7
+ non-object JSON root by returning ``None``; the hand-rolled form these hooks
8
+ carried failed open on the same three cases by exiting zero. These tests drive
9
+ each real hook script through its production ``__main__`` stdin path over a
10
+ corpus that pins those fail-soft edges plus a representative allow payload, so a
11
+ conversion that changes any decision is caught.
12
+
13
+ The deterministic deny payloads for the two Write/Edit blockers whose triggers
14
+ need no filesystem or state setup (``md_to_html_blocker``,
15
+ ``open_questions_in_plans_blocker``) are exercised here too; each remaining
16
+ hook's full deny coverage stays in its own suite, which also drives the real
17
+ ``main()`` and so re-proves the decision after the parser swap.
18
+ """
19
+
20
+ import json
21
+ import os
22
+ import subprocess
23
+ import sys
24
+ from pathlib import Path
25
+
26
+ import pytest
27
+
28
+ _BLOCKING_DIRECTORY = Path(__file__).resolve().parent
29
+
30
+ ALL_CONVERTED_HOOK_FILENAMES = (
31
+ "md_to_html_blocker.py",
32
+ "open_questions_in_plans_blocker.py",
33
+ "claude_md_orphan_file_blocker.py",
34
+ "pr_converge_bugteam_enforcer.py",
35
+ "verdict_directory_write_blocker.py",
36
+ "package_inventory_stale_blocker.py",
37
+ )
38
+
39
+ EMPTY_STDIN_PAYLOAD = ""
40
+ WHITESPACE_STDIN_PAYLOAD = " \n\t "
41
+ MALFORMED_JSON_PAYLOAD = "{not valid json"
42
+ NON_OBJECT_JSON_ARRAY_PAYLOAD = "[1, 2, 3]"
43
+ NON_OBJECT_JSON_SCALAR_PAYLOAD = "42"
44
+
45
+ ALL_FAIL_SOFT_PAYLOADS = (
46
+ EMPTY_STDIN_PAYLOAD,
47
+ WHITESPACE_STDIN_PAYLOAD,
48
+ MALFORMED_JSON_PAYLOAD,
49
+ NON_OBJECT_JSON_ARRAY_PAYLOAD,
50
+ NON_OBJECT_JSON_SCALAR_PAYLOAD,
51
+ )
52
+
53
+
54
+ def _run_hook_script(hook_filename: str, stdin_text: str) -> subprocess.CompletedProcess:
55
+ hook_script_path = _BLOCKING_DIRECTORY / hook_filename
56
+ return subprocess.run(
57
+ [sys.executable, str(hook_script_path)],
58
+ input=stdin_text,
59
+ capture_output=True,
60
+ text=True,
61
+ check=False,
62
+ cwd=str(Path.home()),
63
+ )
64
+
65
+
66
+ def _decision_from_stdout(completed: subprocess.CompletedProcess) -> str | None:
67
+ if not completed.stdout.strip():
68
+ return None
69
+ parsed_output = json.loads(completed.stdout)
70
+ return parsed_output["hookSpecificOutput"]["permissionDecision"]
71
+
72
+
73
+ @pytest.mark.parametrize("hook_filename", ALL_CONVERTED_HOOK_FILENAMES)
74
+ @pytest.mark.parametrize("stdin_text", ALL_FAIL_SOFT_PAYLOADS)
75
+ def test_fail_soft_payload_exits_zero_with_no_decision(hook_filename: str, stdin_text: str) -> None:
76
+ completed = _run_hook_script(hook_filename, stdin_text)
77
+ assert completed.returncode == 0, (
78
+ f"{hook_filename} must exit zero on fail-soft stdin; "
79
+ f"got code {completed.returncode}, stderr {completed.stderr!r}"
80
+ )
81
+ assert _decision_from_stdout(completed) is None, (
82
+ f"{hook_filename} must emit no decision on fail-soft stdin; got stdout {completed.stdout!r}"
83
+ )
84
+
85
+
86
+ def test_md_to_html_blocker_still_denies_relative_markdown_write() -> None:
87
+ payload = json.dumps(
88
+ {
89
+ "tool_name": "Write",
90
+ "tool_input": {"file_path": "notes/topic.md", "content": "# Topic"},
91
+ }
92
+ )
93
+ completed = _run_hook_script("md_to_html_blocker.py", payload)
94
+ assert completed.returncode == 0
95
+ assert _decision_from_stdout(completed) == "deny"
96
+
97
+
98
+ def test_md_to_html_blocker_still_allows_non_markdown_write() -> None:
99
+ payload = json.dumps(
100
+ {
101
+ "tool_name": "Write",
102
+ "tool_input": {"file_path": "notes/topic.txt", "content": "plain"},
103
+ }
104
+ )
105
+ completed = _run_hook_script("md_to_html_blocker.py", payload)
106
+ assert completed.returncode == 0
107
+ assert _decision_from_stdout(completed) is None
108
+
109
+
110
+ def test_open_questions_blocker_still_denies_plan_with_open_questions(
111
+ tmp_path: Path,
112
+ ) -> None:
113
+ plan_directory = tmp_path / "docs" / "plans"
114
+ plan_directory.mkdir(parents=True)
115
+ plan_path = plan_directory / "feature.md"
116
+ plan_body = "# Feature Plan\n\n## Open Questions\n\n- What endpoint do we call?\n"
117
+ payload = json.dumps(
118
+ {
119
+ "tool_name": "Write",
120
+ "tool_input": {"file_path": str(plan_path), "content": plan_body},
121
+ }
122
+ )
123
+ completed = _run_hook_script("open_questions_in_plans_blocker.py", payload)
124
+ assert completed.returncode == 0
125
+ assert _decision_from_stdout(completed) == "deny"
126
+
127
+
128
+ def test_open_questions_blocker_still_allows_plan_without_open_questions(
129
+ tmp_path: Path,
130
+ ) -> None:
131
+ plan_directory = tmp_path / "docs" / "plans"
132
+ plan_directory.mkdir(parents=True)
133
+ plan_path = plan_directory / "feature.md"
134
+ plan_body = "# Feature Plan\n\n## Approach\n\nBuild the thing.\n"
135
+ payload = json.dumps(
136
+ {
137
+ "tool_name": "Write",
138
+ "tool_input": {"file_path": str(plan_path), "content": plan_body},
139
+ }
140
+ )
141
+ completed = _run_hook_script("open_questions_in_plans_blocker.py", payload)
142
+ assert completed.returncode == 0
143
+ assert _decision_from_stdout(completed) is None
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
+
187
+ def test_converted_hooks_allow_unrelated_tool_name() -> None:
188
+ payload = json.dumps({"tool_name": "Bash", "tool_input": {"command": "ls"}})
189
+ for each_hook_filename in ALL_CONVERTED_HOOK_FILENAMES:
190
+ completed = _run_hook_script(each_hook_filename, payload)
191
+ assert completed.returncode == 0, (
192
+ f"{each_hook_filename} must exit zero on an unrelated tool; stderr {completed.stderr!r}"
193
+ )
194
+
195
+
196
+ def test_every_converted_hook_imports_shared_parser() -> None:
197
+ for each_hook_filename in ALL_CONVERTED_HOOK_FILENAMES:
198
+ hook_source = (_BLOCKING_DIRECTORY / each_hook_filename).read_text(encoding="utf-8")
199
+ assert "read_hook_input_dictionary_from_stdin" in hook_source, (
200
+ f"{each_hook_filename} must read stdin through the shared parser"
201
+ )
202
+ assert "json.load(sys.stdin)" not in hook_source, (
203
+ f"{each_hook_filename} must not hand-roll json.load(sys.stdin)"
204
+ )
205
+
206
+
207
+ def test_blocking_directory_is_resolvable() -> None:
208
+ assert os.path.isdir(_BLOCKING_DIRECTORY)
@@ -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 find_bare_index_segments(
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 find_bare_index_segments(looped_path_and_key)
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 find_bare_index_segments('{key: "metric_i", name}') == set()
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
- find_bare_index_segments(
65
+ find_bare_path_segments(
81
66
  "render Path(r'${args.work_dir}\\\\cand_<i>\\\\plate.svg')"
82
67
  )
83
68
  == set()
@@ -37,7 +37,11 @@ blocking_directory = str(Path(__file__).resolve().parent)
37
37
  if blocking_directory not in sys.path:
38
38
  sys.path.insert(0, blocking_directory)
39
39
 
40
- from config.verified_commit_constants import (
40
+ hooks_directory = str(Path(__file__).resolve().parent.parent)
41
+ if hooks_directory not in sys.path:
42
+ sys.path.append(hooks_directory)
43
+
44
+ from config.verified_commit_constants import ( # noqa: E402
41
45
  ALL_GATED_TOOL_NAMES,
42
46
  ALL_VERDICT_PATH_SEGMENT_BODIES,
43
47
  ALL_VERDICT_PATH_SEGMENT_NAMES,
@@ -61,11 +65,11 @@ from config.verified_commit_constants import (
61
65
  NON_REDIRECT_FILE_WRITE_PRIMITIVE_PATTERN,
62
66
  PATH_OBFUSCATION_PRIMITIVE_PATTERN,
63
67
  RELATIVE_VERDICT_DIRECTORY_PATTERN,
68
+ VERDICT_DIRECTORY_CHANGE_TARGET_PATTERN,
64
69
  VERDICT_DIRECTORY_GUARD_MESSAGE,
65
70
  VERDICT_DIRECTORY_NAME,
66
71
  VERDICT_DIRECTORY_NAME_SEPARATOR_PATTERN,
67
72
  VERDICT_DIRECTORY_PATH_BOUNDARY_PATTERN,
68
- VERDICT_DIRECTORY_CHANGE_TARGET_PATTERN,
69
73
  VERDICT_DIRECTORY_TARGET_BOUNDARY_PATTERN,
70
74
  VERDICT_FILE_RELATIVE_REFERENCE_PATTERN,
71
75
  VERDICT_PATH_GLUE_PATTERN,
@@ -74,6 +78,11 @@ from config.verified_commit_constants import (
74
78
  WRITE_CALL_REGION_PATTERN,
75
79
  )
76
80
 
81
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
82
+ from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
83
+ read_hook_input_dictionary_from_stdin,
84
+ )
85
+
77
86
 
78
87
  def _directory_change_verbs_pattern() -> str:
79
88
  """Build the alternation of directory-change verbs for a change matcher.
@@ -650,15 +659,20 @@ def decision_for_payload(pretooluse_payload: dict) -> dict | None:
650
659
 
651
660
  def main() -> None:
652
661
  """Read the PreToolUse payload and deny verdict-directory shell access."""
653
- try:
654
- pretooluse_payload = json.load(sys.stdin)
655
- except json.JSONDecodeError:
656
- return
657
- if not isinstance(pretooluse_payload, dict):
662
+ pretooluse_payload = read_hook_input_dictionary_from_stdin()
663
+ if pretooluse_payload is None:
658
664
  return
659
665
  deny_decision = decision_for_payload(pretooluse_payload)
660
666
  if deny_decision is None:
661
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
+ )
662
676
  print(json.dumps(deny_decision))
663
677
  sys.stdout.flush()
664
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": build_corrective_message(),
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(find_bare_index_segments(content))
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)