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,366 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse hook: blocks a test file written outside a package's explicit pytest testpaths allowlist.
3
+
4
+ A package whose ``pyproject.toml`` declares ``[tool.pytest.ini_options]`` with an
5
+ explicit ``testpaths`` list runs only the directories that list names. A new
6
+ ``test_*.py`` written into a directory that no ``testpaths`` entry covers is
7
+ collected by no default ``pytest`` run, so the test silently never executes and a
8
+ regression in the code it guards passes the standard suite undetected. This hook
9
+ fires on Write, Edit, and MultiEdit that create a ``test_*.py`` file, walks up
10
+ from the file to the nearest ``pyproject.toml`` that declares an explicit
11
+ ``testpaths`` allowlist, and blocks the write when the file's directory (relative
12
+ to that package root) is covered by no entry. A package whose pyproject declares
13
+ no pytest section, or a pytest section with no explicit ``testpaths`` list, is out
14
+ of scope, since pytest then discovers tests by recursive default and the file is
15
+ collected wherever it lands.
16
+ """
17
+
18
+ import fnmatch
19
+ import json
20
+ import sys
21
+ import tomllib
22
+ from pathlib import Path
23
+ from typing import TextIO
24
+
25
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
26
+ if _hooks_dir not in sys.path:
27
+ sys.path.insert(0, _hooks_dir)
28
+
29
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
30
+ from hooks_constants.pytest_testpaths_orphan_blocker_constants import ( # noqa: E402
31
+ ALL_PRUNED_PARENT_DIRECTORY_NAMES,
32
+ GLOB_METACHARACTERS,
33
+ MAX_PARENT_DIRECTORIES_SEARCHED,
34
+ PACKAGE_ROOT_ENTRY,
35
+ PACKAGE_ROOT_ENTRY_PREFIX,
36
+ PYPROJECT_FILENAME,
37
+ TEST_FILE_BASENAME_PATTERN,
38
+ TESTPATHS_KEY,
39
+ UNREGISTERED_TEST_DIRECTORY_ADDITIONAL_CONTEXT,
40
+ UNREGISTERED_TEST_DIRECTORY_MESSAGE_TEMPLATE,
41
+ UNREGISTERED_TEST_DIRECTORY_SYSTEM_MESSAGE,
42
+ )
43
+
44
+
45
+ def is_test_file(file_path: str) -> bool:
46
+ """Return whether *file_path* names a pytest-collectable ``test_*.py`` file.
47
+
48
+ Args:
49
+ file_path: The destination path of the write or edit.
50
+
51
+ Returns:
52
+ True when the path's basename matches the ``test_*.py`` pattern.
53
+ """
54
+ return TEST_FILE_BASENAME_PATTERN.match(Path(file_path).name) is not None
55
+
56
+
57
+ class _PytestPackage:
58
+ """A pyproject.toml that declares an explicit pytest testpaths allowlist.
59
+
60
+ Attributes:
61
+ package_root: The directory holding the pyproject.toml, against which
62
+ every testpaths entry and the test file's location are resolved.
63
+ all_testpaths: Each directory the testpaths list names, as written.
64
+ """
65
+
66
+ def __init__(self, package_root: Path, all_testpaths: list[str]) -> None:
67
+ self.package_root = package_root
68
+ self.all_testpaths = all_testpaths
69
+
70
+
71
+ def _nested_dict_table(parent_table: dict, table_key: str) -> dict | None:
72
+ """Return the child table at *table_key*, or None when it is absent or a scalar.
73
+
74
+ Args:
75
+ parent_table: The enclosing TOML table to look the key up in.
76
+ table_key: The key whose value is expected to be a nested table.
77
+
78
+ Returns:
79
+ The nested table, or None when the key is missing or maps to a non-table.
80
+ """
81
+ child_table = parent_table.get(table_key, {})
82
+ if not isinstance(child_table, dict):
83
+ return None
84
+ return child_table
85
+
86
+
87
+ def _explicit_testpaths(pyproject_path: Path) -> list[str] | None:
88
+ """Return the explicit testpaths entries a pyproject declares, when it has them.
89
+
90
+ The pyproject declares an explicit allowlist only when its
91
+ ``[tool.pytest.ini_options]`` table holds a ``testpaths`` key whose value is a
92
+ non-empty list of strings. A pyproject with no pytest table, no ``testpaths``
93
+ key, or a malformed value yields None, so the caller treats that package as
94
+ out of scope (pytest then discovers tests by recursive default). A scalar
95
+ ``tool``, ``pytest``, or ``ini_options`` value also yields None, since the
96
+ descent through those nested tables stops at the first non-table.
97
+
98
+ Args:
99
+ pyproject_path: The path of the pyproject.toml to read.
100
+
101
+ Returns:
102
+ The list of testpaths entries, or None when the pyproject declares no
103
+ explicit list or cannot be parsed.
104
+ """
105
+ try:
106
+ parsed_pyproject = tomllib.loads(pyproject_path.read_text(encoding="utf-8"))
107
+ except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError):
108
+ return None
109
+ tool_table = _nested_dict_table(parsed_pyproject, "tool")
110
+ pytest_table = _nested_dict_table(tool_table, "pytest") if tool_table is not None else None
111
+ pytest_section = (
112
+ _nested_dict_table(pytest_table, "ini_options") if pytest_table is not None else None
113
+ )
114
+ if pytest_section is None:
115
+ return None
116
+ declared_testpaths = pytest_section.get(TESTPATHS_KEY)
117
+ if not isinstance(declared_testpaths, list):
118
+ return None
119
+ string_entries = [each for each in declared_testpaths if isinstance(each, str) and each]
120
+ if not string_entries:
121
+ return None
122
+ return string_entries
123
+
124
+
125
+ def _find_governing_package(test_file: Path) -> _PytestPackage | None:
126
+ """Return the nearest ancestor package that governs *test_file* with an allowlist.
127
+
128
+ Walks upward from the test file's directory, pruning noise directories, until
129
+ it reaches a ``pyproject.toml`` that declares an explicit ``testpaths`` list.
130
+ The first such pyproject governs the file. The walk stops at the budget, so a
131
+ deeply nested file never searches indefinitely.
132
+
133
+ Args:
134
+ test_file: The resolved path of the test file being written.
135
+
136
+ Returns:
137
+ The governing package paired with its testpaths entries, or None when no
138
+ ancestor declares an explicit allowlist within the budget.
139
+ """
140
+ searched_count = 0
141
+ for each_directory in test_file.parents:
142
+ if each_directory.name in ALL_PRUNED_PARENT_DIRECTORY_NAMES:
143
+ continue
144
+ searched_count += 1
145
+ if searched_count > MAX_PARENT_DIRECTORIES_SEARCHED:
146
+ return None
147
+ candidate_pyproject = each_directory / PYPROJECT_FILENAME
148
+ if not candidate_pyproject.is_file():
149
+ continue
150
+ all_testpaths = _explicit_testpaths(candidate_pyproject)
151
+ if all_testpaths is None:
152
+ continue
153
+ return _PytestPackage(each_directory, all_testpaths)
154
+ return None
155
+
156
+
157
+ def _is_collected_by_entry(relative_test_path: Path, testpaths_entry: str) -> bool:
158
+ """Return whether one testpaths entry collects the test at *relative_test_path*.
159
+
160
+ The entry and the relative path are normalized to forward-slash posix form
161
+ (a leading ``./`` is stripped) so a Windows backslash path matches a
162
+ posix-written testpaths entry. An entry that reduces to ``.`` or empty names
163
+ the package root, which collects every test recursively, so it collects the
164
+ file. An entry holding a glob metacharacter is matched as an fnmatch pattern
165
+ against the file's relative path. Otherwise the entry collects the file when
166
+ it names the file itself or names a directory the file sits inside (the entry
167
+ is a path prefix of the file's relative path).
168
+
169
+ Args:
170
+ relative_test_path: The test file's path relative to the package root.
171
+ testpaths_entry: One entry from the package's testpaths list.
172
+
173
+ Returns:
174
+ True when the entry collects the test file.
175
+ """
176
+ normalized_test_path = relative_test_path.as_posix()
177
+ normalized_entry = testpaths_entry.strip().replace("\\", "/")
178
+ if normalized_entry.startswith(PACKAGE_ROOT_ENTRY_PREFIX):
179
+ normalized_entry = normalized_entry[len(PACKAGE_ROOT_ENTRY_PREFIX) :]
180
+ normalized_entry = normalized_entry.strip("/")
181
+ if normalized_entry in (PACKAGE_ROOT_ENTRY, ""):
182
+ return True
183
+ if any(metacharacter in normalized_entry for metacharacter in GLOB_METACHARACTERS):
184
+ return _matches_glob_entry(normalized_test_path, normalized_entry)
185
+ if normalized_test_path == normalized_entry:
186
+ return True
187
+ return normalized_test_path.startswith(normalized_entry + "/")
188
+
189
+
190
+ def _matches_glob_entry(normalized_test_path: str, normalized_entry: str) -> bool:
191
+ """Return whether a glob testpaths entry collects the file at *normalized_test_path*.
192
+
193
+ A glob entry collects the file when the entry matches the file's relative
194
+ path, or when the entry matches an ancestor directory the file sits inside —
195
+ so ``tests/*`` (which fnmatch-matches the directory ``tests/data``) collects
196
+ ``tests/data/test_x.py``.
197
+
198
+ Args:
199
+ normalized_test_path: The test file's posix relative path.
200
+ normalized_entry: The glob entry, normalized to posix form.
201
+
202
+ Returns:
203
+ True when the entry matches the file or a directory containing it.
204
+ """
205
+ if fnmatch.fnmatch(normalized_test_path, normalized_entry):
206
+ return True
207
+ ancestor_path = Path(normalized_test_path).parent
208
+ while ancestor_path != ancestor_path.parent:
209
+ if fnmatch.fnmatch(ancestor_path.as_posix(), normalized_entry):
210
+ return True
211
+ ancestor_path = ancestor_path.parent
212
+ return False
213
+
214
+
215
+ def _suggested_testpaths_entry(relative_test_path: Path) -> str:
216
+ """Return the testpaths entry that would collect the test file's directory.
217
+
218
+ Args:
219
+ relative_test_path: The test file's path relative to the package root.
220
+
221
+ Returns:
222
+ The posix-form parent directory of the test file, the entry a maintainer
223
+ would add to the testpaths list to collect it.
224
+ """
225
+ return relative_test_path.parent.as_posix()
226
+
227
+
228
+ def find_unregistered_test_directory(file_path: str) -> dict[str, str] | None:
229
+ """Return the block details when a test file lands outside its package's allowlist.
230
+
231
+ Resolves the test file, finds the nearest ancestor pyproject that declares an
232
+ explicit ``testpaths`` allowlist, and checks whether any entry collects the
233
+ file. When a governing allowlist exists and no entry covers the file's
234
+ directory, the details name the file, the pyproject, the uncollected
235
+ directory, and the entry a maintainer would add. A file under no explicit
236
+ allowlist, or one already covered by an entry, yields None. A filesystem error
237
+ yields None (fail open), so an unreadable tree never blocks a write.
238
+
239
+ Args:
240
+ file_path: The destination path of the test file being written.
241
+
242
+ Returns:
243
+ A mapping of message fields when the file is unregistered, or None.
244
+ """
245
+ test_file = Path(file_path).resolve()
246
+ governing_package = _find_governing_package(test_file)
247
+ if governing_package is None:
248
+ return None
249
+ try:
250
+ relative_test_path = test_file.relative_to(governing_package.package_root)
251
+ except ValueError:
252
+ return None
253
+ for each_entry in governing_package.all_testpaths:
254
+ if _is_collected_by_entry(relative_test_path, each_entry):
255
+ return None
256
+ return {
257
+ "test_file": relative_test_path.as_posix(),
258
+ "pyproject": (governing_package.package_root / PYPROJECT_FILENAME).as_posix(),
259
+ "test_directory": relative_test_path.parent.as_posix(),
260
+ "suggested_entry": _suggested_testpaths_entry(relative_test_path),
261
+ }
262
+
263
+
264
+ def _creates_file(tool_name: str, tool_input: dict, file_path: str) -> bool:
265
+ """Return whether the tool call creates the test file rather than editing it.
266
+
267
+ The check targets a newly created test file: a Write whose target does not yet
268
+ exist, or any Edit/MultiEdit whose target file is absent (the harness models a
269
+ create-via-edit as an edit against a missing path). An edit to an existing test
270
+ file is out of scope, since the file's collection status was settled when it
271
+ was first created.
272
+
273
+ Args:
274
+ tool_name: The intercepted tool — ``Write``, ``Edit``, or ``MultiEdit``.
275
+ tool_input: The tool's input payload.
276
+ file_path: The destination path of the write or edit.
277
+
278
+ Returns:
279
+ True when the call creates a test file that does not yet exist on disk.
280
+ """
281
+ if tool_name not in ("Write", "Edit", "MultiEdit"):
282
+ return False
283
+ if not isinstance(tool_input, dict):
284
+ return False
285
+ return not Path(file_path).exists()
286
+
287
+
288
+ def _build_block_payload(block_details: dict[str, str]) -> dict:
289
+ """Build the PreToolUse deny payload naming the uncollected test directory.
290
+
291
+ Args:
292
+ block_details: The message fields the find step produced.
293
+
294
+ Returns:
295
+ The hook-result dictionary the harness reads to deny the write.
296
+ """
297
+ reason = UNREGISTERED_TEST_DIRECTORY_MESSAGE_TEMPLATE.format(**block_details)
298
+ return {
299
+ "hookSpecificOutput": {
300
+ "hookEventName": "PreToolUse",
301
+ "permissionDecision": "deny",
302
+ "permissionDecisionReason": reason,
303
+ "additionalContext": UNREGISTERED_TEST_DIRECTORY_ADDITIONAL_CONTEXT,
304
+ },
305
+ "systemMessage": UNREGISTERED_TEST_DIRECTORY_SYSTEM_MESSAGE,
306
+ "suppressOutput": True,
307
+ }
308
+
309
+
310
+ def _emit_hook_result(all_hook_data: dict, output_stream: TextIO) -> None:
311
+ """Write the hook result JSON to the given output stream.
312
+
313
+ Args:
314
+ all_hook_data: The hook-result dictionary to serialize.
315
+ output_stream: The stream the harness reads the decision from.
316
+ """
317
+ output_stream.write(json.dumps(all_hook_data) + "\n")
318
+ output_stream.flush()
319
+
320
+
321
+ def main() -> None:
322
+ """Read the PreToolUse payload from stdin and block an unregistered test file."""
323
+ try:
324
+ input_data = json.load(sys.stdin)
325
+ except json.JSONDecodeError:
326
+ sys.exit(0)
327
+
328
+ if not isinstance(input_data, dict):
329
+ sys.exit(0)
330
+
331
+ tool_name = input_data.get("tool_name", "")
332
+ if not isinstance(tool_name, str):
333
+ sys.exit(0)
334
+
335
+ tool_input = input_data.get("tool_input", {})
336
+ if not isinstance(tool_input, dict):
337
+ sys.exit(0)
338
+
339
+ file_path = tool_input.get("file_path", "")
340
+ if not isinstance(file_path, str) or not is_test_file(file_path):
341
+ sys.exit(0)
342
+
343
+ if not _creates_file(tool_name, tool_input, file_path):
344
+ sys.exit(0)
345
+
346
+ try:
347
+ block_details = find_unregistered_test_directory(file_path)
348
+ except OSError:
349
+ sys.exit(0)
350
+ if block_details is None:
351
+ sys.exit(0)
352
+
353
+ block_payload = _build_block_payload(block_details)
354
+ log_hook_block(
355
+ calling_hook_name="pytest_testpaths_orphan_blocker.py",
356
+ hook_event="PreToolUse",
357
+ block_reason=block_payload["hookSpecificOutput"]["permissionDecisionReason"],
358
+ tool_name=tool_name,
359
+ offending_input_preview=file_path,
360
+ )
361
+ _emit_hook_result(block_payload, sys.stdout)
362
+ sys.exit(0)
363
+
364
+
365
+ if __name__ == "__main__":
366
+ main()
@@ -19,6 +19,7 @@ _hooks_dir = str(Path(__file__).resolve().parent.parent)
19
19
  if _hooks_dir not in sys.path:
20
20
  sys.path.insert(0, _hooks_dir)
21
21
 
22
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
22
23
  from hooks_constants.messages import USER_FACING_ASKUSERQUESTION_NOTICE # noqa: E402
23
24
 
24
25
 
@@ -109,23 +110,28 @@ def main() -> None:
109
110
  f'"{each_indicator}"' for each_indicator in matched_indicators
110
111
  )
111
112
 
113
+ block_reason = (
114
+ f"ASKUSERQUESTION GUARDRAIL: Your response asks the user a question in prose "
115
+ f"(indicators: {formatted_indicator_list}). "
116
+ f"User-directed questions must route through the AskUserQuestion tool so the user "
117
+ f"sees structured options with labels.\n\n"
118
+ f"Re-output your response with the trailing question removed from prose and moved "
119
+ f"into an AskUserQuestion tool call. Rhetorical questions answered in the same "
120
+ f"paragraph are allowed; questions inside code fences, inline code, and blockquotes "
121
+ f"are ignored.\n\n"
122
+ f"You MUST re-output the complete, revised response with the correction applied."
123
+ )
112
124
  block_response = {
113
125
  "decision": "block",
114
- "reason": (
115
- f"ASKUSERQUESTION GUARDRAIL: Your response asks the user a question in prose "
116
- f"(indicators: {formatted_indicator_list}). "
117
- f"User-directed questions must route through the AskUserQuestion tool so the user "
118
- f"sees structured options with labels.\n\n"
119
- f"Re-output your response with the trailing question removed from prose and moved "
120
- f"into an AskUserQuestion tool call. Rhetorical questions answered in the same "
121
- f"paragraph are allowed; questions inside code fences, inline code, and blockquotes "
122
- f"are ignored.\n\n"
123
- f"You MUST re-output the complete, revised response with the correction applied."
124
- ),
126
+ "reason": block_reason,
125
127
  "systemMessage": USER_FACING_ASKUSERQUESTION_NOTICE,
126
128
  "suppressOutput": True,
127
129
  }
128
-
130
+ log_hook_block(
131
+ calling_hook_name="question_to_user_enforcer.py",
132
+ hook_event="Stop",
133
+ block_reason=block_reason,
134
+ )
129
135
  print(json.dumps(block_response))
130
136
  sys.exit(0)
131
137
 
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse hook: block SendUserFile attaches that should open locally.
3
+
4
+ SendUserFile attaches a file to the session. While the user is at the terminal
5
+ (status "normal" or unset) an attach does not let them see the file — it must
6
+ open on screen in its own viewer via Show-Asset.ps1. The one attach allowed
7
+ through is an away-from-desk phone push (status "proactive").
8
+ """
9
+
10
+ import json
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
15
+ if _hooks_dir not in sys.path:
16
+ sys.path.insert(0, _hooks_dir)
17
+
18
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
19
+ from hooks_constants.send_user_file_open_locally_blocker_constants import ( # noqa: E402
20
+ CORRECTIVE_MESSAGE,
21
+ PROACTIVE_STATUS,
22
+ TOOL_NAME,
23
+ )
24
+
25
+
26
+ def _should_block(status: str) -> bool:
27
+ """Return whether a SendUserFile call with this status should be denied.
28
+
29
+ Args:
30
+ status: The ``status`` field from the SendUserFile input. A proactive
31
+ phone push is allowed; every other value, including an empty one,
32
+ is a desk-side attach the user cannot see and is denied.
33
+ """
34
+ return status != PROACTIVE_STATUS
35
+
36
+
37
+ def main() -> None:
38
+ try:
39
+ hook_input = json.load(sys.stdin)
40
+ except json.JSONDecodeError:
41
+ sys.exit(0)
42
+
43
+ if hook_input.get("tool_name", "") != TOOL_NAME:
44
+ sys.exit(0)
45
+
46
+ tool_input = hook_input.get("tool_input") or {}
47
+ status = tool_input.get("status", "")
48
+ if not _should_block(status):
49
+ sys.exit(0)
50
+
51
+ deny_payload = {
52
+ "hookSpecificOutput": {
53
+ "hookEventName": "PreToolUse",
54
+ "permissionDecision": "deny",
55
+ "permissionDecisionReason": CORRECTIVE_MESSAGE,
56
+ }
57
+ }
58
+ log_hook_block(
59
+ calling_hook_name="send_user_file_open_locally_blocker.py",
60
+ hook_event="PreToolUse",
61
+ block_reason=CORRECTIVE_MESSAGE,
62
+ tool_name=TOOL_NAME,
63
+ )
64
+ print(json.dumps(deny_payload))
65
+ sys.stdout.flush()
66
+ sys.exit(0)
67
+
68
+
69
+ if __name__ == "__main__":
70
+ main()
@@ -3,6 +3,13 @@ import fnmatch
3
3
  import json
4
4
  import os
5
5
  import sys
6
+ from pathlib import Path
7
+
8
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
9
+ if _hooks_dir not in sys.path:
10
+ sys.path.insert(0, _hooks_dir)
11
+
12
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
6
13
 
7
14
  SENSITIVE_PATTERNS = [
8
15
  ".env",
@@ -54,13 +61,20 @@ def main() -> None:
54
61
  matched_pattern = is_sensitive_file(file_path)
55
62
 
56
63
  if matched_pattern is not None:
64
+ deny_reason = f"BLOCKED: Sensitive file '{os.path.basename(file_path)}' (pattern: '{matched_pattern}'). Edit manually outside Claude Code."
57
65
  deny_response = {
58
66
  "hookSpecificOutput": {
59
67
  "hookEventName": "PreToolUse",
60
68
  "permissionDecision": "deny",
61
- "permissionDecisionReason": f"BLOCKED: Sensitive file '{os.path.basename(file_path)}' (pattern: '{matched_pattern}'). Edit manually outside Claude Code."
69
+ "permissionDecisionReason": deny_reason,
62
70
  }
63
71
  }
72
+ log_hook_block(
73
+ calling_hook_name="sensitive_file_protector.py",
74
+ hook_event="PreToolUse",
75
+ block_reason=deny_reason,
76
+ offending_input_preview=file_path,
77
+ )
64
78
  print(json.dumps(deny_response))
65
79
 
66
80
  sys.exit(0)
@@ -18,6 +18,7 @@ _hooks_dir = str(Path(__file__).resolve().parent.parent)
18
18
  if _hooks_dir not in sys.path:
19
19
  sys.path.insert(0, _hooks_dir)
20
20
 
21
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
21
22
  from hooks_constants.messages import USER_FACING_CONTEXT_REASSURANCE_NOTICE # noqa: E402
22
23
  from hooks_constants.session_handoff_blocker_constants import ( # noqa: E402
23
24
  FIRST_PERSON_SUBJECT_PATTERN,
@@ -169,19 +170,24 @@ def main() -> None:
169
170
  if not find_session_handoff_proposal(assistant_message):
170
171
  sys.exit(0)
171
172
 
173
+ block_reason = (
174
+ "LONG-HORIZON-AUTONOMY GUARDRAIL: You have ample context remaining. Do not "
175
+ "stop, summarize, or suggest a new session on account of context limits. "
176
+ "Continue the work.\n\n"
177
+ "Re-output your response continuing the task without the handoff suggestion, "
178
+ "per the long-horizon-autonomy rule."
179
+ )
172
180
  block_response = {
173
181
  "decision": "block",
174
- "reason": (
175
- "LONG-HORIZON-AUTONOMY GUARDRAIL: You have ample context remaining. Do not "
176
- "stop, summarize, or suggest a new session on account of context limits. "
177
- "Continue the work.\n\n"
178
- "Re-output your response continuing the task without the handoff suggestion, "
179
- "per the long-horizon-autonomy rule."
180
- ),
182
+ "reason": block_reason,
181
183
  "systemMessage": USER_FACING_CONTEXT_REASSURANCE_NOTICE,
182
184
  "suppressOutput": True,
183
185
  }
184
-
186
+ log_hook_block(
187
+ calling_hook_name="session_handoff_blocker.py",
188
+ hook_event="Stop",
189
+ block_reason=block_reason,
190
+ )
185
191
  print(json.dumps(block_response))
186
192
  sys.exit(0)
187
193