claude-dev-env 1.50.0 → 1.50.2
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/hooks/blocking/_gh_body_arg_utils.py +67 -11
- package/hooks/blocking/_md_to_html_blocker_test_support.py +65 -0
- package/hooks/blocking/code_rules_annotations_length.py +167 -0
- package/hooks/blocking/code_rules_banned_identifiers.py +385 -0
- package/hooks/blocking/code_rules_boolean_mustcheck.py +350 -0
- package/hooks/blocking/code_rules_comments.py +337 -0
- package/hooks/blocking/code_rules_constants_config.py +252 -0
- package/hooks/blocking/code_rules_docstrings.py +308 -0
- package/hooks/blocking/code_rules_enforcer.py +98 -5765
- package/hooks/blocking/code_rules_imports_logging.py +276 -0
- package/hooks/blocking/code_rules_magic_values.py +180 -0
- package/hooks/blocking/code_rules_mock_completeness.py +295 -0
- package/hooks/blocking/code_rules_naming_collection.py +264 -0
- package/hooks/blocking/code_rules_optional_params.py +288 -0
- package/hooks/blocking/code_rules_paths_syspath.py +186 -0
- package/hooks/blocking/code_rules_probe_chains.py +305 -0
- package/hooks/blocking/code_rules_probe_detection.py +257 -0
- package/hooks/blocking/code_rules_probe_recording.py +225 -0
- package/hooks/blocking/code_rules_scope_binding.py +151 -0
- package/hooks/blocking/code_rules_shared.py +301 -0
- package/hooks/blocking/code_rules_string_magic.py +207 -0
- package/hooks/blocking/code_rules_test_assertions.py +226 -0
- package/hooks/blocking/code_rules_test_branching_except.py +181 -0
- package/hooks/blocking/code_rules_test_isolation.py +341 -0
- package/hooks/blocking/code_rules_type_escape.py +341 -0
- package/hooks/blocking/code_rules_typeddict_stub.py +305 -0
- package/hooks/blocking/code_rules_unused_imports.py +256 -0
- package/hooks/blocking/conftest.py +30 -0
- package/hooks/blocking/pr_description_body_audit.py +148 -0
- package/hooks/blocking/pr_description_command_parser.py +233 -0
- package/hooks/blocking/pr_description_enforcer.py +36 -825
- package/hooks/blocking/pr_description_pr_number.py +153 -0
- package/hooks/blocking/pr_description_readability.py +366 -0
- package/hooks/blocking/tdd_enforcer.py +31 -0
- package/hooks/blocking/test_code_rules_constants_config.py +26 -0
- package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +5 -2
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +0 -5
- package/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py +21 -15
- package/hooks/blocking/test_code_rules_enforcer_config_path.py +20 -16
- package/hooks/blocking/test_code_rules_enforcer_exempt_marker_chained.py +4 -2
- package/hooks/blocking/test_code_rules_enforcer_function_length.py +154 -18
- package/hooks/blocking/test_code_rules_enforcer_hardcoded_user_path.py +1 -2
- package/hooks/blocking/test_code_rules_enforcer_ignored_must_check_return.py +22 -12
- package/hooks/blocking/test_code_rules_enforcer_split_annotations_length.py +55 -0
- package/hooks/blocking/test_code_rules_enforcer_split_banned.py +170 -0
- package/hooks/blocking/test_code_rules_enforcer_split_comments.py +60 -0
- package/hooks/blocking/test_code_rules_enforcer_split_config_path.py +52 -0
- package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +236 -0
- package/hooks/blocking/test_code_rules_enforcer_split_entry_1.py +296 -0
- package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +238 -0
- package/hooks/blocking/test_code_rules_enforcer_split_isolation_1.py +271 -0
- package/hooks/blocking/test_code_rules_enforcer_split_isolation_2.py +283 -0
- package/hooks/blocking/test_code_rules_enforcer_split_isolation_3.py +268 -0
- package/hooks/blocking/test_code_rules_enforcer_split_isolation_4.py +85 -0
- package/hooks/blocking/test_code_rules_enforcer_split_mocks_1.py +303 -0
- package/hooks/blocking/test_code_rules_enforcer_split_mocks_2.py +111 -0
- package/hooks/blocking/test_code_rules_enforcer_split_mustcheck.py +87 -0
- package/hooks/blocking/test_code_rules_enforcer_split_naming.py +107 -0
- package/hooks/blocking/test_code_rules_enforcer_split_optional_params.py +325 -0
- package/hooks/blocking/test_code_rules_enforcer_split_paths_syspath.py +110 -0
- package/hooks/blocking/test_code_rules_enforcer_split_shared.py +44 -0
- package/hooks/blocking/test_code_rules_enforcer_split_string_magic.py +55 -0
- package/hooks/blocking/test_code_rules_enforcer_split_test_assertions.py +56 -0
- package/hooks/blocking/test_code_rules_enforcer_todo_markers.py +21 -15
- package/hooks/blocking/test_code_rules_paths_syspath.py +26 -0
- package/hooks/blocking/test_md_to_html_blocker_exemptions.py +368 -0
- package/hooks/blocking/test_md_to_html_blocker_extensions.py +157 -0
- package/hooks/blocking/test_md_to_html_blocker_path_resolution.py +336 -0
- package/hooks/blocking/test_pr_description_enforcer.py +13 -1499
- package/hooks/blocking/test_pr_description_enforcer_body_audit.py +247 -0
- package/hooks/blocking/test_pr_description_enforcer_body_rules.py +493 -0
- package/hooks/blocking/test_pr_description_enforcer_command_parser.py +366 -0
- package/hooks/blocking/test_pr_description_enforcer_pr_number.py +159 -0
- package/hooks/blocking/test_pr_description_enforcer_readability.py +443 -0
- package/hooks/blocking/test_tdd_enforcer.py +116 -0
- package/hooks/hooks_constants/blocking_check_limits.py +3 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +8 -0
- package/hooks/hooks_constants/pr_description_enforcer_constants.py +7 -0
- package/hooks/hooks_constants/sys_path_insert_constants.py +1 -0
- package/package.json +1 -1
- package/hooks/blocking/test_code_rules_enforcer.py +0 -2669
- package/hooks/blocking/test_md_to_html_blocker.py +0 -810
|
@@ -1,810 +0,0 @@
|
|
|
1
|
-
"""Tests for md_to_html_blocker hook.
|
|
2
|
-
|
|
3
|
-
Subprocess CWD is rooted in a per-session sandbox created lazily by a
|
|
4
|
-
session-scoped fixture so that relative-path test cases canonicalize outside
|
|
5
|
-
any `.claude-plugin/` ancestor, outside the OS temp directory, and outside the
|
|
6
|
-
exempt home-relative subdirectories. The sandbox is a real repo root (it
|
|
7
|
-
carries a `.git` marker) so relative `README.md` / `CHANGELOG.md` writes
|
|
8
|
-
exercise the repo-root exemption path. This keeps tests independent of where
|
|
9
|
-
pytest itself is run.
|
|
10
|
-
"""
|
|
11
|
-
|
|
12
|
-
import functools
|
|
13
|
-
import importlib
|
|
14
|
-
import json
|
|
15
|
-
import os
|
|
16
|
-
import shutil
|
|
17
|
-
import stat
|
|
18
|
-
import subprocess
|
|
19
|
-
import sys
|
|
20
|
-
import tempfile
|
|
21
|
-
from pathlib import Path
|
|
22
|
-
|
|
23
|
-
import pytest
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
HOOK_SCRIPT_PATH = os.path.join(os.path.dirname(__file__), "md_to_html_blocker.py")
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def _strip_read_only_and_retry(removal_function, target_path, *_exc_info):
|
|
30
|
-
try:
|
|
31
|
-
os.chmod(target_path, stat.S_IWRITE)
|
|
32
|
-
removal_function(target_path)
|
|
33
|
-
except OSError:
|
|
34
|
-
pass
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def _force_rmtree(target_path: str) -> None:
|
|
38
|
-
handler_kw = (
|
|
39
|
-
{"onexc": _strip_read_only_and_retry}
|
|
40
|
-
if sys.version_info >= (3, 12)
|
|
41
|
-
else {"onerror": _strip_read_only_and_retry}
|
|
42
|
-
)
|
|
43
|
-
try:
|
|
44
|
-
shutil.rmtree(target_path, **handler_kw)
|
|
45
|
-
except OSError:
|
|
46
|
-
pass
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
@functools.lru_cache(maxsize=1)
|
|
50
|
-
def _get_sandbox_parent_directory() -> str:
|
|
51
|
-
sandbox_parent = tempfile.mkdtemp(prefix="pytest_md_blocker_", dir=str(Path.home()))
|
|
52
|
-
git_marker_path = os.path.join(sandbox_parent, ".git")
|
|
53
|
-
Path(git_marker_path).touch()
|
|
54
|
-
return sandbox_parent
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
@pytest.fixture(scope="session", autouse=True)
|
|
58
|
-
def _cleanup_sandbox_parent_directory():
|
|
59
|
-
yield
|
|
60
|
-
if _get_sandbox_parent_directory.cache_info().currsize:
|
|
61
|
-
_force_rmtree(_get_sandbox_parent_directory())
|
|
62
|
-
_get_sandbox_parent_directory.cache_clear()
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
class _RunHook:
|
|
66
|
-
def __call__(self, tool_name: str, tool_input: dict) -> subprocess.CompletedProcess:
|
|
67
|
-
payload = json.dumps({"tool_name": tool_name, "tool_input": tool_input})
|
|
68
|
-
return subprocess.run(
|
|
69
|
-
[sys.executable, HOOK_SCRIPT_PATH],
|
|
70
|
-
input=payload,
|
|
71
|
-
capture_output=True,
|
|
72
|
-
text=True,
|
|
73
|
-
check=False,
|
|
74
|
-
cwd=_get_sandbox_parent_directory(),
|
|
75
|
-
)
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
_run_hook = _RunHook()
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def test_block_messages_mention_claude_dev_env_source_exemptions():
|
|
82
|
-
"""Block messages must surface the `packages/claude-dev-env/<dir>/` anchored
|
|
83
|
-
exemption so contributors aren't misled when a `.md` write is denied
|
|
84
|
-
elsewhere. Ensures docs/, rules/, and system-prompts/ source files
|
|
85
|
-
render as writable in the user-facing message."""
|
|
86
|
-
hook_dir = os.path.dirname(HOOK_SCRIPT_PATH)
|
|
87
|
-
if hook_dir not in sys.path:
|
|
88
|
-
sys.path.insert(0, hook_dir)
|
|
89
|
-
blocker_module = importlib.import_module("md_to_html_blocker")
|
|
90
|
-
importlib.reload(blocker_module)
|
|
91
|
-
|
|
92
|
-
context_message = blocker_module._block_context()
|
|
93
|
-
system_message = blocker_module._block_system_message()
|
|
94
|
-
combined_messages = context_message + " " + system_message
|
|
95
|
-
assert "claude-dev-env" in combined_messages, (
|
|
96
|
-
"Block messages must mention claude-dev-env source-directory exemption; "
|
|
97
|
-
f"got context={context_message!r} system={system_message!r}"
|
|
98
|
-
)
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
def test_blocks_write_md_file():
|
|
103
|
-
result = _run_hook(
|
|
104
|
-
"Write",
|
|
105
|
-
{"file_path": "docs/guide.md", "content": "# Hello"},
|
|
106
|
-
)
|
|
107
|
-
assert result.returncode == 0
|
|
108
|
-
output = json.loads(result.stdout)
|
|
109
|
-
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
def test_blocks_edit_md_file():
|
|
113
|
-
result = _run_hook(
|
|
114
|
-
"Edit",
|
|
115
|
-
{"file_path": "docs/guide.md", "old_string": "a", "new_string": "b"},
|
|
116
|
-
)
|
|
117
|
-
assert result.returncode == 0
|
|
118
|
-
output = json.loads(result.stdout)
|
|
119
|
-
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
def test_blocks_uppercase_md_extension():
|
|
123
|
-
result = _run_hook(
|
|
124
|
-
"Write",
|
|
125
|
-
{"file_path": "DOCS/GUIDE.MD", "content": "# Hello"},
|
|
126
|
-
)
|
|
127
|
-
assert result.returncode == 0
|
|
128
|
-
output = json.loads(result.stdout)
|
|
129
|
-
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
def test_module_imports_path_segments_from_hooks_constants():
|
|
133
|
-
"""The blocker pulls the two leading path segments (`packages` and
|
|
134
|
-
`claude-dev-env`) through the centralised hooks_constants module rather
|
|
135
|
-
than inlining them as raw string literals."""
|
|
136
|
-
hook_dir = os.path.dirname(HOOK_SCRIPT_PATH)
|
|
137
|
-
if hook_dir not in sys.path:
|
|
138
|
-
sys.path.insert(0, hook_dir)
|
|
139
|
-
blocker_module = importlib.import_module("md_to_html_blocker")
|
|
140
|
-
importlib.reload(blocker_module)
|
|
141
|
-
assert blocker_module.PACKAGES_TOP_LEVEL_SEGMENT == "packages"
|
|
142
|
-
assert blocker_module.CLAUDE_DEV_ENV_REPO_NAME_SEGMENT == "claude-dev-env"
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
def test_module_imports_top_directories_from_hooks_constants():
|
|
146
|
-
"""The exempt-top-directories set must live in `hooks_constants/` rather
|
|
147
|
-
than as a file-global single-use constant in the blocker module. The
|
|
148
|
-
blocker imports the centralized constant; a regression that reintroduces
|
|
149
|
-
a local module-scope copy would fail this assertion."""
|
|
150
|
-
hook_dir = os.path.dirname(HOOK_SCRIPT_PATH)
|
|
151
|
-
if hook_dir not in sys.path:
|
|
152
|
-
sys.path.insert(0, hook_dir)
|
|
153
|
-
blocker_module = importlib.import_module("md_to_html_blocker")
|
|
154
|
-
importlib.reload(blocker_module)
|
|
155
|
-
assert hasattr(blocker_module, "ALL_CLAUDE_CODE_SOURCE_TOP_DIRECTORIES"), (
|
|
156
|
-
"Blocker module must import ALL_CLAUDE_CODE_SOURCE_TOP_DIRECTORIES from "
|
|
157
|
-
"hooks_constants/ (file-global single-use rule)."
|
|
158
|
-
)
|
|
159
|
-
assert not hasattr(blocker_module, "_claude_code_source_top_directories"), (
|
|
160
|
-
"Local _claude_code_source_top_directories must not be re-introduced; "
|
|
161
|
-
"use the imported constant from hooks_constants/ instead."
|
|
162
|
-
)
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
def test_blocks_nested_packages_claude_dev_env_path():
|
|
166
|
-
"""`packages/claude-dev-env/` exemption is anchored to top-level use only;
|
|
167
|
-
a nested directory like `notes/packages/claude-dev-env/docs/...` is NOT a
|
|
168
|
-
Claude Code source path and must still be blocked. Substring matching let
|
|
169
|
-
this bypass through; segment-anchored matching prevents it."""
|
|
170
|
-
result = _run_hook(
|
|
171
|
-
"Write",
|
|
172
|
-
{"file_path": "notes/packages/claude-dev-env/docs/guide.md", "content": "# Hello"},
|
|
173
|
-
)
|
|
174
|
-
assert result.returncode == 0
|
|
175
|
-
output = json.loads(result.stdout)
|
|
176
|
-
assert output["hookSpecificOutput"]["permissionDecision"] == "deny", (
|
|
177
|
-
f"Nested fake claude-dev-env path must still be blocked; got {output!r}"
|
|
178
|
-
)
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
def test_passes_html_file():
|
|
182
|
-
result = _run_hook(
|
|
183
|
-
"Write",
|
|
184
|
-
{"file_path": "docs/guide.html", "content": "<h1>Hello</h1>"},
|
|
185
|
-
)
|
|
186
|
-
assert result.returncode == 0
|
|
187
|
-
assert result.stdout == ""
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
def test_passes_non_markdown_extension():
|
|
191
|
-
result = _run_hook(
|
|
192
|
-
"Write",
|
|
193
|
-
{"file_path": "src/main.py", "content": "x = 1"},
|
|
194
|
-
)
|
|
195
|
-
assert result.returncode == 0
|
|
196
|
-
assert result.stdout == ""
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
def test_passes_claude_dir():
|
|
200
|
-
result = _run_hook(
|
|
201
|
-
"Write",
|
|
202
|
-
{"file_path": ".claude/rules/foo.md", "content": "# Rule"},
|
|
203
|
-
)
|
|
204
|
-
assert result.returncode == 0
|
|
205
|
-
assert result.stdout == ""
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
def test_passes_nested_claude_dir():
|
|
209
|
-
result = _run_hook(
|
|
210
|
-
"Write",
|
|
211
|
-
{"file_path": "notes/.claude/plans/plan.md", "content": "# Plan"},
|
|
212
|
-
)
|
|
213
|
-
assert result.returncode == 0
|
|
214
|
-
assert result.stdout == ""
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
def test_passes_readme_at_root():
|
|
218
|
-
result = _run_hook(
|
|
219
|
-
"Write",
|
|
220
|
-
{"file_path": "README.md", "content": "# README"},
|
|
221
|
-
)
|
|
222
|
-
assert result.returncode == 0
|
|
223
|
-
assert result.stdout == ""
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
def test_passes_changelog_at_root():
|
|
227
|
-
result = _run_hook(
|
|
228
|
-
"Write",
|
|
229
|
-
{"file_path": "CHANGELOG.md", "content": "# Changelog"},
|
|
230
|
-
)
|
|
231
|
-
assert result.returncode == 0
|
|
232
|
-
assert result.stdout == ""
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
def test_blocks_readme_not_at_root():
|
|
236
|
-
result = _run_hook(
|
|
237
|
-
"Write",
|
|
238
|
-
{"file_path": "docs/README.md", "content": "# README"},
|
|
239
|
-
)
|
|
240
|
-
assert result.returncode == 0
|
|
241
|
-
output = json.loads(result.stdout)
|
|
242
|
-
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
def test_blocks_changelog_not_at_root():
|
|
246
|
-
result = _run_hook(
|
|
247
|
-
"Write",
|
|
248
|
-
{"file_path": "sub/CHANGELOG.md", "content": "# Log"},
|
|
249
|
-
)
|
|
250
|
-
assert result.returncode == 0
|
|
251
|
-
output = json.loads(result.stdout)
|
|
252
|
-
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
def test_passes_claude_md_at_root():
|
|
256
|
-
result = _run_hook(
|
|
257
|
-
"Write",
|
|
258
|
-
{"file_path": "CLAUDE.md", "content": "# CLAUDE"},
|
|
259
|
-
)
|
|
260
|
-
assert result.returncode == 0
|
|
261
|
-
assert result.stdout == ""
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
def test_passes_agents_md_at_root():
|
|
265
|
-
result = _run_hook(
|
|
266
|
-
"Write",
|
|
267
|
-
{"file_path": "AGENTS.md", "content": "# AGENTS"},
|
|
268
|
-
)
|
|
269
|
-
assert result.returncode == 0
|
|
270
|
-
assert result.stdout == ""
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
def test_blocks_claude_md_not_at_root():
|
|
274
|
-
result = _run_hook(
|
|
275
|
-
"Write",
|
|
276
|
-
{"file_path": "docs/CLAUDE.md", "content": "# CLAUDE"},
|
|
277
|
-
)
|
|
278
|
-
assert result.returncode == 0
|
|
279
|
-
output = json.loads(result.stdout)
|
|
280
|
-
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
def test_blocks_agents_md_not_at_root():
|
|
284
|
-
result = _run_hook(
|
|
285
|
-
"Write",
|
|
286
|
-
{"file_path": "sub/AGENTS.md", "content": "# AGENTS"},
|
|
287
|
-
)
|
|
288
|
-
assert result.returncode == 0
|
|
289
|
-
output = json.loads(result.stdout)
|
|
290
|
-
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
def test_blocks_relative_readme_when_cwd_is_not_repo_root():
|
|
294
|
-
sandbox_parent = _get_sandbox_parent_directory()
|
|
295
|
-
non_repo_cwd = os.path.join(sandbox_parent, "not-a-repo")
|
|
296
|
-
os.makedirs(non_repo_cwd, exist_ok=True)
|
|
297
|
-
payload = json.dumps(
|
|
298
|
-
{
|
|
299
|
-
"tool_name": "Write",
|
|
300
|
-
"tool_input": {"file_path": "README.md", "content": "# README"},
|
|
301
|
-
}
|
|
302
|
-
)
|
|
303
|
-
result = subprocess.run(
|
|
304
|
-
[sys.executable, HOOK_SCRIPT_PATH],
|
|
305
|
-
input=payload,
|
|
306
|
-
capture_output=True,
|
|
307
|
-
text=True,
|
|
308
|
-
check=False,
|
|
309
|
-
cwd=non_repo_cwd,
|
|
310
|
-
)
|
|
311
|
-
assert result.returncode == 0
|
|
312
|
-
output = json.loads(result.stdout)
|
|
313
|
-
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
def test_unknown_tool_passes():
|
|
317
|
-
result = _run_hook(
|
|
318
|
-
"Grep",
|
|
319
|
-
{"pattern": "foo", "path": "."},
|
|
320
|
-
)
|
|
321
|
-
assert result.returncode == 0
|
|
322
|
-
assert result.stdout == ""
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
def test_empty_file_path_passes():
|
|
326
|
-
result = _run_hook(
|
|
327
|
-
"Write",
|
|
328
|
-
{"file_path": "", "content": "# Hello"},
|
|
329
|
-
)
|
|
330
|
-
assert result.returncode == 0
|
|
331
|
-
assert result.stdout == ""
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
def test_non_dict_stdin_passes():
|
|
335
|
-
payload = json.dumps(["not", "a", "dict"])
|
|
336
|
-
result = subprocess.run(
|
|
337
|
-
[sys.executable, HOOK_SCRIPT_PATH],
|
|
338
|
-
input=payload,
|
|
339
|
-
capture_output=True,
|
|
340
|
-
text=True,
|
|
341
|
-
check=False,
|
|
342
|
-
)
|
|
343
|
-
assert result.returncode == 0
|
|
344
|
-
assert result.stdout == ""
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
def test_non_string_tool_name_passes():
|
|
348
|
-
payload = json.dumps(
|
|
349
|
-
{"tool_name": 123, "tool_input": {"file_path": "docs/guide.md"}}
|
|
350
|
-
)
|
|
351
|
-
result = subprocess.run(
|
|
352
|
-
[sys.executable, HOOK_SCRIPT_PATH],
|
|
353
|
-
input=payload,
|
|
354
|
-
capture_output=True,
|
|
355
|
-
text=True,
|
|
356
|
-
check=False,
|
|
357
|
-
)
|
|
358
|
-
assert result.returncode == 0
|
|
359
|
-
assert result.stdout == ""
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
def test_non_dict_tool_input_passes():
|
|
363
|
-
payload = json.dumps({"tool_name": "Write", "tool_input": "not_a_dict"})
|
|
364
|
-
result = subprocess.run(
|
|
365
|
-
[sys.executable, HOOK_SCRIPT_PATH],
|
|
366
|
-
input=payload,
|
|
367
|
-
capture_output=True,
|
|
368
|
-
text=True,
|
|
369
|
-
check=False,
|
|
370
|
-
)
|
|
371
|
-
assert result.returncode == 0
|
|
372
|
-
assert result.stdout == ""
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
def test_denial_has_system_message():
|
|
376
|
-
result = _run_hook(
|
|
377
|
-
"Write",
|
|
378
|
-
{"file_path": "docs/guide.md", "content": "# Hello"},
|
|
379
|
-
)
|
|
380
|
-
assert result.returncode == 0
|
|
381
|
-
output = json.loads(result.stdout)
|
|
382
|
-
assert output["suppressOutput"] is True
|
|
383
|
-
assert isinstance(output["systemMessage"], str)
|
|
384
|
-
assert len(output["systemMessage"]) > 0
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
def test_denial_has_additional_context():
|
|
388
|
-
result = _run_hook(
|
|
389
|
-
"Write",
|
|
390
|
-
{"file_path": "docs/guide.md", "content": "# Hello"},
|
|
391
|
-
)
|
|
392
|
-
assert result.returncode == 0
|
|
393
|
-
output = json.loads(result.stdout)
|
|
394
|
-
ctx = output["hookSpecificOutput"].get("additionalContext", "")
|
|
395
|
-
assert "HTML" in ctx
|
|
396
|
-
assert (
|
|
397
|
-
"thariqs.github.io" in output["hookSpecificOutput"]["permissionDecisionReason"]
|
|
398
|
-
)
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
def test_denial_reason_mentions_html_redirect():
|
|
402
|
-
result = _run_hook(
|
|
403
|
-
"Write",
|
|
404
|
-
{"file_path": "docs/guide.md", "content": "# Hello"},
|
|
405
|
-
)
|
|
406
|
-
assert result.returncode == 0
|
|
407
|
-
output = json.loads(result.stdout)
|
|
408
|
-
reason = output["hookSpecificOutput"]["permissionDecisionReason"]
|
|
409
|
-
assert ".html" in reason.lower()
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
def test_passes_claude_md_file():
|
|
413
|
-
result = _run_hook(
|
|
414
|
-
"Write",
|
|
415
|
-
{"file_path": ".claude/CLAUDE.md", "content": "# CLAUDE.md"},
|
|
416
|
-
)
|
|
417
|
-
assert result.returncode == 0
|
|
418
|
-
assert result.stdout == ""
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
def test_blocks_windows_path_with_backslash():
|
|
422
|
-
result = _run_hook(
|
|
423
|
-
"Write",
|
|
424
|
-
{"file_path": "docs\\guide.md", "content": "# Hello"},
|
|
425
|
-
)
|
|
426
|
-
assert result.returncode == 0
|
|
427
|
-
output = json.loads(result.stdout)
|
|
428
|
-
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
def test_passes_windows_path_claude_exempt():
|
|
432
|
-
result = _run_hook(
|
|
433
|
-
"Write",
|
|
434
|
-
{"file_path": "project\\.claude\\rules\\foo.md", "content": "# Rule"},
|
|
435
|
-
)
|
|
436
|
-
assert result.returncode == 0
|
|
437
|
-
assert result.stdout == ""
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
def test_passes_claude_dir_case_insensitive():
|
|
441
|
-
result = _run_hook(
|
|
442
|
-
"Write",
|
|
443
|
-
{"file_path": ".Claude/rules/foo.md", "content": "# Rule"},
|
|
444
|
-
)
|
|
445
|
-
assert result.returncode == 0
|
|
446
|
-
assert result.stdout == ""
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
def test_passes_readme_lowercase_at_root():
|
|
450
|
-
result = _run_hook(
|
|
451
|
-
"Write",
|
|
452
|
-
{"file_path": "readme.md", "content": "# readme"},
|
|
453
|
-
)
|
|
454
|
-
assert result.returncode == 0
|
|
455
|
-
assert result.stdout == ""
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
def test_json_decode_error_passes():
|
|
459
|
-
result = subprocess.run(
|
|
460
|
-
[sys.executable, HOOK_SCRIPT_PATH],
|
|
461
|
-
input="not json",
|
|
462
|
-
capture_output=True,
|
|
463
|
-
text=True,
|
|
464
|
-
check=False,
|
|
465
|
-
)
|
|
466
|
-
assert result.returncode == 0
|
|
467
|
-
assert result.stdout == ""
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
def test_blocks_claude_path_traversal_bypass():
|
|
471
|
-
result = _run_hook(
|
|
472
|
-
"Write",
|
|
473
|
-
{"file_path": ".claude/../docs/guide.md", "content": "# Bypass"},
|
|
474
|
-
)
|
|
475
|
-
assert result.returncode == 0
|
|
476
|
-
output = json.loads(result.stdout)
|
|
477
|
-
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
def test_blocks_md_with_curly_braces_in_path():
|
|
481
|
-
result = _run_hook(
|
|
482
|
-
"Write",
|
|
483
|
-
{"file_path": "docs/{template}.md", "content": "# Template"},
|
|
484
|
-
)
|
|
485
|
-
assert result.returncode == 0
|
|
486
|
-
output = json.loads(result.stdout)
|
|
487
|
-
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
def test_passes_home_session_log_directory():
|
|
491
|
-
home_directory = os.path.expanduser("~")
|
|
492
|
-
session_log_path = os.path.join(home_directory, "SessionLog", "decisions", "note.md")
|
|
493
|
-
result = _run_hook(
|
|
494
|
-
"Write",
|
|
495
|
-
{"file_path": session_log_path, "content": "# Note"},
|
|
496
|
-
)
|
|
497
|
-
assert result.returncode == 0
|
|
498
|
-
assert result.stdout == ""
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
def test_passes_home_claude_plans_directory():
|
|
502
|
-
home_directory = os.path.expanduser("~")
|
|
503
|
-
plans_path = os.path.join(home_directory, ".claude", "plans", "plan.md")
|
|
504
|
-
result = _run_hook(
|
|
505
|
-
"Write",
|
|
506
|
-
{"file_path": plans_path, "content": "# Plan"},
|
|
507
|
-
)
|
|
508
|
-
assert result.returncode == 0
|
|
509
|
-
assert result.stdout == ""
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
def test_blocks_home_directory_other_md_file():
|
|
513
|
-
home_directory = os.path.expanduser("~")
|
|
514
|
-
other_path = os.path.join(home_directory, "docs", "guide.md")
|
|
515
|
-
result = _run_hook(
|
|
516
|
-
"Write",
|
|
517
|
-
{"file_path": other_path, "content": "# Guide"},
|
|
518
|
-
)
|
|
519
|
-
assert result.returncode == 0
|
|
520
|
-
output = json.loads(result.stdout)
|
|
521
|
-
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
def test_passes_tilde_session_log_path():
|
|
525
|
-
result = _run_hook(
|
|
526
|
-
"Write",
|
|
527
|
-
{"file_path": "~/SessionLog/decisions/note.md", "content": "# Note"},
|
|
528
|
-
)
|
|
529
|
-
assert result.returncode == 0
|
|
530
|
-
assert result.stdout == ""
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
def test_passes_tilde_claude_plans_path():
|
|
534
|
-
result = _run_hook(
|
|
535
|
-
"Write",
|
|
536
|
-
{"file_path": "~/.claude/plans/plan.md", "content": "# Plan"},
|
|
537
|
-
)
|
|
538
|
-
assert result.returncode == 0
|
|
539
|
-
assert result.stdout == ""
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
def test_blocks_tilde_other_home_md_file():
|
|
543
|
-
result = _run_hook(
|
|
544
|
-
"Write",
|
|
545
|
-
{"file_path": "~/docs/guide.md", "content": "# Guide"},
|
|
546
|
-
)
|
|
547
|
-
assert result.returncode == 0
|
|
548
|
-
output = json.loads(result.stdout)
|
|
549
|
-
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
def test_passes_system_temp_directory():
|
|
553
|
-
temp_md_path = os.path.join(tempfile.gettempdir(), "bugteam-scratch", "pr-body.md")
|
|
554
|
-
result = _run_hook(
|
|
555
|
-
"Write",
|
|
556
|
-
{"file_path": temp_md_path, "content": "# Scratch"},
|
|
557
|
-
)
|
|
558
|
-
assert result.returncode == 0
|
|
559
|
-
assert result.stdout == ""
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
def test_passes_dot_claude_plugin_directory():
|
|
563
|
-
result = _run_hook(
|
|
564
|
-
"Write",
|
|
565
|
-
{"file_path": ".claude-plugin/manifest.md", "content": "# Manifest"},
|
|
566
|
-
)
|
|
567
|
-
assert result.returncode == 0
|
|
568
|
-
assert result.stdout == ""
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
def test_passes_nested_dot_claude_plugin_directory():
|
|
572
|
-
result = _run_hook(
|
|
573
|
-
"Write",
|
|
574
|
-
{
|
|
575
|
-
"file_path": "Y:/repo/.claude-plugin/skills/foo/SKILL.md",
|
|
576
|
-
"content": "# Skill",
|
|
577
|
-
},
|
|
578
|
-
)
|
|
579
|
-
assert result.returncode == 0
|
|
580
|
-
assert result.stdout == ""
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
def test_passes_skill_md_at_any_depth():
|
|
584
|
-
result = _run_hook(
|
|
585
|
-
"Write",
|
|
586
|
-
{
|
|
587
|
-
"file_path": "packages/dev-env/skills/pr-converge/SKILL.md",
|
|
588
|
-
"content": "# Skill",
|
|
589
|
-
},
|
|
590
|
-
)
|
|
591
|
-
assert result.returncode == 0
|
|
592
|
-
assert result.stdout == ""
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
def test_passes_skill_md_uppercase():
|
|
596
|
-
result = _run_hook(
|
|
597
|
-
"Write",
|
|
598
|
-
{"file_path": "any/path/SKILL.MD", "content": "# Skill"},
|
|
599
|
-
)
|
|
600
|
-
assert result.returncode == 0
|
|
601
|
-
assert result.stdout == ""
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
def test_passes_agents_directory_anywhere():
|
|
605
|
-
result = _run_hook(
|
|
606
|
-
"Write",
|
|
607
|
-
{
|
|
608
|
-
"file_path": "packages/dev-env/agents/pr-description-writer.md",
|
|
609
|
-
"content": "# Agent",
|
|
610
|
-
},
|
|
611
|
-
)
|
|
612
|
-
assert result.returncode == 0
|
|
613
|
-
assert result.stdout == ""
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
def test_passes_skills_reference_directory():
|
|
617
|
-
result = _run_hook(
|
|
618
|
-
"Write",
|
|
619
|
-
{
|
|
620
|
-
"file_path": "packages/dev-env/skills/pr-converge/reference/per-tick.md",
|
|
621
|
-
"content": "# Reference",
|
|
622
|
-
},
|
|
623
|
-
)
|
|
624
|
-
assert result.returncode == 0
|
|
625
|
-
assert result.stdout == ""
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
def test_passes_commands_directory_anywhere():
|
|
629
|
-
result = _run_hook(
|
|
630
|
-
"Write",
|
|
631
|
-
{"file_path": "commands/pyguide-health.md", "content": "# Command"},
|
|
632
|
-
)
|
|
633
|
-
assert result.returncode == 0
|
|
634
|
-
assert result.stdout == ""
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
def test_passes_claude_dev_env_docs_dir():
|
|
638
|
-
"""A .md file under ``packages/claude-dev-env/docs/`` is exempt. The
|
|
639
|
-
segment-anywhere rule does not list ``docs``; this exemption fires only
|
|
640
|
-
via the anchored helper."""
|
|
641
|
-
result = _run_hook(
|
|
642
|
-
"Write",
|
|
643
|
-
{
|
|
644
|
-
"file_path": "packages/claude-dev-env/docs/PR_DESCRIPTION_GUIDE.md",
|
|
645
|
-
"content": "# Guide",
|
|
646
|
-
},
|
|
647
|
-
)
|
|
648
|
-
assert result.returncode == 0
|
|
649
|
-
assert result.stdout == ""
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
def test_passes_claude_dev_env_rules_dir():
|
|
653
|
-
"""A .md file under ``packages/claude-dev-env/rules/`` is exempt. The
|
|
654
|
-
segment-anywhere rule does not list ``rules``; the anchored helper is
|
|
655
|
-
the only path to this exemption."""
|
|
656
|
-
result = _run_hook(
|
|
657
|
-
"Write",
|
|
658
|
-
{
|
|
659
|
-
"file_path": "packages/claude-dev-env/rules/my-rule.md",
|
|
660
|
-
"content": "# Rule",
|
|
661
|
-
},
|
|
662
|
-
)
|
|
663
|
-
assert result.returncode == 0
|
|
664
|
-
assert result.stdout == ""
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
def test_passes_claude_dev_env_system_prompts_dir():
|
|
668
|
-
"""A .md file under ``packages/claude-dev-env/system-prompts/`` is
|
|
669
|
-
exempt via the anchored helper."""
|
|
670
|
-
result = _run_hook(
|
|
671
|
-
"Write",
|
|
672
|
-
{
|
|
673
|
-
"file_path": "packages/claude-dev-env/system-prompts/new-prompt.md",
|
|
674
|
-
"content": "# Prompt",
|
|
675
|
-
},
|
|
676
|
-
)
|
|
677
|
-
assert result.returncode == 0
|
|
678
|
-
assert result.stdout == ""
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
def test_passes_claude_dev_env_windows_backslash_path():
|
|
682
|
-
"""A Windows-style backslash relative path under
|
|
683
|
-
``packages\\claude-dev-env\\<dir>\\`` is exempt."""
|
|
684
|
-
result = _run_hook(
|
|
685
|
-
"Write",
|
|
686
|
-
{
|
|
687
|
-
"file_path": "packages\\claude-dev-env\\docs\\windows-style.md",
|
|
688
|
-
"content": "# Guide",
|
|
689
|
-
},
|
|
690
|
-
)
|
|
691
|
-
assert result.returncode == 0
|
|
692
|
-
assert result.stdout == ""
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
def test_passes_claude_dev_env_absolute_drive_letter_path():
|
|
696
|
-
"""A Windows absolute drive-letter path containing the anchored
|
|
697
|
-
``packages\\claude-dev-env\\<dir>\\`` indicator at any depth is exempt."""
|
|
698
|
-
result = _run_hook(
|
|
699
|
-
"Write",
|
|
700
|
-
{
|
|
701
|
-
"file_path": "Y:\\repo\\packages\\claude-dev-env\\docs\\drive-letter.md",
|
|
702
|
-
"content": "# Guide",
|
|
703
|
-
},
|
|
704
|
-
)
|
|
705
|
-
assert result.returncode == 0
|
|
706
|
-
assert result.stdout == ""
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
def test_blocks_md_under_packages_but_not_in_anchored_source_subdir():
|
|
710
|
-
"""A .md file inside the package but under a non-source subtree (e.g.
|
|
711
|
-
``packages/claude-dev-env/hooks/blocking/``) is blocked. The anchored
|
|
712
|
-
helper accepts only the named source subdirectories (agents, docs,
|
|
713
|
-
skills, rules, system-prompts, commands)."""
|
|
714
|
-
result = _run_hook(
|
|
715
|
-
"Write",
|
|
716
|
-
{
|
|
717
|
-
"file_path": "packages/claude-dev-env/hooks/blocking/notes.md",
|
|
718
|
-
"content": "# Notes",
|
|
719
|
-
},
|
|
720
|
-
)
|
|
721
|
-
assert result.returncode == 0
|
|
722
|
-
output = json.loads(result.stdout)
|
|
723
|
-
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
def test_blocks_nested_claude_dev_env_substring_does_not_bypass():
|
|
727
|
-
"""A path that contains the anchored prefix as a non-leading substring
|
|
728
|
-
(e.g. ``notes/packages/claude-dev-env/docs/foo.md``) is blocked. The
|
|
729
|
-
anchored helper matches only at the start of the path (relative) or at
|
|
730
|
-
the root of an absolute path."""
|
|
731
|
-
result = _run_hook(
|
|
732
|
-
"Write",
|
|
733
|
-
{
|
|
734
|
-
"file_path": "notes/packages/claude-dev-env/docs/foo.md",
|
|
735
|
-
"content": "# Notes",
|
|
736
|
-
},
|
|
737
|
-
)
|
|
738
|
-
assert result.returncode == 0
|
|
739
|
-
output = json.loads(result.stdout)
|
|
740
|
-
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
def test_blocks_ordinary_docs_md_file():
|
|
744
|
-
result = _run_hook(
|
|
745
|
-
"Write",
|
|
746
|
-
{"file_path": "docs/intro.md", "content": "# Intro"},
|
|
747
|
-
)
|
|
748
|
-
assert result.returncode == 0
|
|
749
|
-
output = json.loads(result.stdout)
|
|
750
|
-
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
def test_passes_relative_path_from_home_cwd():
|
|
754
|
-
home_directory = os.path.expanduser("~")
|
|
755
|
-
payload = json.dumps(
|
|
756
|
-
{
|
|
757
|
-
"tool_name": "Write",
|
|
758
|
-
"tool_input": {
|
|
759
|
-
"file_path": "SessionLog/decisions/note.md",
|
|
760
|
-
"content": "# Note",
|
|
761
|
-
},
|
|
762
|
-
}
|
|
763
|
-
)
|
|
764
|
-
result = subprocess.run(
|
|
765
|
-
[sys.executable, HOOK_SCRIPT_PATH],
|
|
766
|
-
input=payload,
|
|
767
|
-
capture_output=True,
|
|
768
|
-
text=True,
|
|
769
|
-
check=False,
|
|
770
|
-
cwd=home_directory,
|
|
771
|
-
)
|
|
772
|
-
assert result.returncode == 0
|
|
773
|
-
assert result.stdout == ""
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
def test_passes_canonicalized_home_path():
|
|
777
|
-
canonical_home = os.path.realpath(os.path.expanduser("~"))
|
|
778
|
-
canonical_path = os.path.join(canonical_home, "SessionLog", "canonical-note.md")
|
|
779
|
-
result = _run_hook(
|
|
780
|
-
"Write",
|
|
781
|
-
{"file_path": canonical_path, "content": "# Canonical"},
|
|
782
|
-
)
|
|
783
|
-
assert result.returncode == 0
|
|
784
|
-
assert result.stdout == ""
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
def test_passes_relative_path_under_cwd_plugin_root_marker(tmp_path):
|
|
788
|
-
plugin_root = tmp_path / "plugin-cwd-repo"
|
|
789
|
-
(plugin_root / ".claude-plugin").mkdir(parents=True)
|
|
790
|
-
(plugin_root / "subdir").mkdir(parents=True)
|
|
791
|
-
|
|
792
|
-
payload = json.dumps(
|
|
793
|
-
{
|
|
794
|
-
"tool_name": "Write",
|
|
795
|
-
"tool_input": {
|
|
796
|
-
"file_path": "subdir/design.md",
|
|
797
|
-
"content": "# Design",
|
|
798
|
-
},
|
|
799
|
-
}
|
|
800
|
-
)
|
|
801
|
-
result = subprocess.run(
|
|
802
|
-
[sys.executable, HOOK_SCRIPT_PATH],
|
|
803
|
-
input=payload,
|
|
804
|
-
capture_output=True,
|
|
805
|
-
text=True,
|
|
806
|
-
check=False,
|
|
807
|
-
cwd=str(plugin_root),
|
|
808
|
-
)
|
|
809
|
-
assert result.returncode == 0
|
|
810
|
-
assert result.stdout == ""
|