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.
- package/CLAUDE.md +2 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +2 -2
- package/bin/install.mjs +73 -5
- package/bin/install.test.mjs +360 -4
- package/hooks/blocking/CLAUDE.md +6 -1
- 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 +19 -48
- package/hooks/blocking/code_rules_dead_config_field.py +69 -56
- package/hooks/blocking/code_rules_docstrings.py +839 -0
- package/hooks/blocking/code_rules_enforcer.py +38 -0
- package/hooks/blocking/code_rules_shared.py +19 -0
- package/hooks/blocking/code_verifier_spawn_preflight_gate.py +426 -0
- package/hooks/blocking/convergence_gate_blocker.py +17 -3
- package/hooks/blocking/destructive_command_blocker.py +7 -0
- package/hooks/blocking/docstring_rule_gate_count_blocker.py +321 -0
- package/hooks/blocking/gh_body_arg_blocker.py +8 -0
- package/hooks/blocking/gh_pr_author_enforcer.py +7 -0
- package/hooks/blocking/hedging_language_blocker.py +16 -10
- package/hooks/blocking/hook_prose_detector_consistency.py +7 -0
- package/hooks/blocking/intent_only_ending_blocker.py +17 -11
- package/hooks/blocking/md_to_html_blocker.py +17 -10
- package/hooks/blocking/open_questions_in_plans_blocker.py +15 -8
- package/hooks/blocking/package_inventory_stale_blocker.py +398 -0
- package/hooks/blocking/plain_language_blocker.py +57 -16
- package/hooks/blocking/pr_converge_bugteam_enforcer.py +11 -5
- package/hooks/blocking/pr_description_enforcer.py +6 -0
- package/hooks/blocking/pre_tool_use_dispatcher.py +545 -0
- package/hooks/blocking/precommit_code_rules_gate.py +10 -1
- package/hooks/blocking/pytest_testpaths_orphan_blocker.py +366 -0
- package/hooks/blocking/question_to_user_enforcer.py +18 -12
- package/hooks/blocking/send_user_file_open_locally_blocker.py +70 -0
- package/hooks/blocking/sensitive_file_protector.py +15 -1
- package/hooks/blocking/session_handoff_blocker.py +14 -8
- package/hooks/blocking/state_description_blocker.py +81 -36
- package/hooks/blocking/subprocess_budget_completeness.py +9 -3
- package/hooks/blocking/tdd_enforcer.py +6 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +81 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_inline_literal_claim.py +93 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_returns_plural_cardinality.py +207 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_step_dispatch.py +262 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_undefined_constant.py +253 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_unguarded_payload.py +188 -0
- package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +279 -0
- package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +501 -0
- package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +203 -0
- package/hooks/blocking/test_hook_block_logger_coverage.py +53 -0
- package/hooks/blocking/test_package_inventory_stale_blocker.py +329 -0
- package/hooks/blocking/test_plain_language_blocker.py +36 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +816 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +341 -0
- package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +247 -0
- package/hooks/blocking/test_send_user_file_open_locally_blocker.py +114 -0
- package/hooks/blocking/test_shared_stdin_adoption.py +208 -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 +21 -7
- 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 +19 -79
- package/hooks/hooks_constants/CLAUDE.md +7 -1
- package/hooks/hooks_constants/blocking_check_limits.py +74 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +9 -0
- package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +45 -0
- package/hooks/hooks_constants/dead_config_field_constants.py +5 -5
- package/hooks/hooks_constants/docstring_rule_gate_count_blocker_constants.py +90 -0
- package/hooks/hooks_constants/hook_block_logger.py +59 -0
- package/hooks/hooks_constants/multi_edit_reconstruction.py +56 -0
- package/hooks/hooks_constants/mypy_validator_cache_constants.py +36 -0
- package/hooks/hooks_constants/package_inventory_stale_blocker_constants.py +111 -0
- package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +68 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +143 -0
- package/hooks/hooks_constants/pytest_testpaths_orphan_blocker_constants.py +79 -0
- package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +18 -0
- package/hooks/hooks_constants/test_dispatcher_constants_docstrings.py +44 -0
- package/hooks/hooks_constants/test_hook_block_logger.py +159 -0
- package/hooks/lifecycle/config_change_guard.py +12 -0
- package/hooks/lifecycle/test_config_change_guard.py +23 -0
- package/hooks/validation/hook_format_validator.py +13 -0
- package/hooks/validation/mypy_validator.py +245 -18
- package/hooks/validation/post_tool_use_dispatcher.py +344 -0
- package/hooks/validation/test_hook_format_validator.py +64 -0
- package/hooks/validation/test_mypy_validator.py +206 -1
- package/hooks/validation/test_post_tool_use_dispatcher.py +610 -0
- package/hooks/workflow/test_auto_formatter.py +10 -9
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/docstring-prose-matches-implementation.md +4 -2
- package/rules/package-inventory-stale-entry.md +24 -0
- package/skills/autoconverge/SKILL.md +111 -1
- package/skills/autoconverge/workflow/converge.contract.test.mjs +106 -0
- package/skills/autoconverge/workflow/converge.mjs +29 -3
- package/skills/autoconverge/workflow/converge.path-aware.test.mjs +47 -0
- package/skills/autoconverge/workflow/converge_multi.mjs +161 -0
- 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":
|
|
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
|
|