claude-dev-env 1.25.1 → 1.25.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/destructive-command-blocker.py +12 -4
- package/hooks/blocking/tdd-enforcer.py +12 -0
- package/hooks/blocking/test_destructive_command_blocker.py +62 -3
- package/hooks/blocking/test_tdd_enforcer.py +52 -0
- package/hooks/notification/notification_utils.py +4 -2
- package/hooks/notification/test_notification_utils.py +46 -0
- package/package.json +1 -1
|
@@ -7,6 +7,13 @@ import sys
|
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
|
|
9
9
|
CLAUDE_DIRECTORY_PATH = os.path.normpath(os.path.expanduser("~/.claude"))
|
|
10
|
+
GH_REDIRECT_ACTIVE_ENV_VAR = "CLAUDE_GH_REDIRECT_ACTIVE"
|
|
11
|
+
GH_REDIRECT_ACTIVE_TRUTHY_VALUES = frozenset({"1", "true", "yes", "on"})
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def gh_redirect_is_active() -> bool:
|
|
15
|
+
env_var_value = os.environ.get(GH_REDIRECT_ACTIVE_ENV_VAR, "").strip().lower()
|
|
16
|
+
return env_var_value in GH_REDIRECT_ACTIVE_TRUTHY_VALUES
|
|
10
17
|
|
|
11
18
|
# Projects where git reset --hard is explicitly allowed by the user.
|
|
12
19
|
# Add your own project paths here, e.g.:
|
|
@@ -116,10 +123,11 @@ def main() -> None:
|
|
|
116
123
|
|
|
117
124
|
command = tool_input.get("command", "")
|
|
118
125
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
126
|
+
if gh_redirect_is_active():
|
|
127
|
+
redirected_gh_description = find_redirected_gh_pattern(command)
|
|
128
|
+
if redirected_gh_description is not None:
|
|
129
|
+
print(json.dumps(_build_silent_gh_deny_response(redirected_gh_description)))
|
|
130
|
+
sys.exit(0)
|
|
123
131
|
|
|
124
132
|
matched_description = find_destructive_pattern(command)
|
|
125
133
|
|
|
@@ -18,6 +18,15 @@ SKIP_PATTERNS = {
|
|
|
18
18
|
'conftest', 'fixture', 'mock', 'stub'
|
|
19
19
|
}
|
|
20
20
|
SKIP_EXTENSIONS = {'.md', '.json', '.yaml', '.yml', '.toml', '.ini', '.cfg', '.txt'}
|
|
21
|
+
DOTCLAUDE_PATH_SEGMENTS = frozenset({".claude"})
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _is_inside_dotclaude_segment(file_path_string: str) -> bool:
|
|
25
|
+
normalized_path = file_path_string.replace("\\", "/")
|
|
26
|
+
for each_segment in normalized_path.split("/"):
|
|
27
|
+
if each_segment and each_segment in DOTCLAUDE_PATH_SEGMENTS:
|
|
28
|
+
return True
|
|
29
|
+
return False
|
|
21
30
|
|
|
22
31
|
|
|
23
32
|
def _freshness_seconds() -> int:
|
|
@@ -221,6 +230,9 @@ def main() -> None:
|
|
|
221
230
|
if not file_path:
|
|
222
231
|
sys.exit(0)
|
|
223
232
|
|
|
233
|
+
if _is_inside_dotclaude_segment(file_path):
|
|
234
|
+
sys.exit(0)
|
|
235
|
+
|
|
224
236
|
path = Path(file_path)
|
|
225
237
|
ext = path.suffix.lower()
|
|
226
238
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Tests for destructive-command-blocker hook."""
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import os
|
|
4
5
|
import subprocess
|
|
5
6
|
import sys
|
|
6
7
|
from pathlib import Path
|
|
@@ -8,15 +9,26 @@ from pathlib import Path
|
|
|
8
9
|
|
|
9
10
|
SCRIPT_PATH = Path(__file__).parent / "destructive-command-blocker.py"
|
|
10
11
|
GH_GATE_USER_FACING_PREFIX = "[gh-gate]"
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
GH_REDIRECT_ACTIVE_ENV_VAR = "CLAUDE_GH_REDIRECT_ACTIVE"
|
|
13
|
+
GH_REDIRECT_ACTIVE_VALUE = "1"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _run_hook(
|
|
17
|
+
payload: dict,
|
|
18
|
+
gh_redirect_active: bool = True,
|
|
19
|
+
) -> subprocess.CompletedProcess[str]:
|
|
20
|
+
child_environment = os.environ.copy()
|
|
21
|
+
if gh_redirect_active:
|
|
22
|
+
child_environment[GH_REDIRECT_ACTIVE_ENV_VAR] = GH_REDIRECT_ACTIVE_VALUE
|
|
23
|
+
else:
|
|
24
|
+
child_environment.pop(GH_REDIRECT_ACTIVE_ENV_VAR, None)
|
|
14
25
|
return subprocess.run(
|
|
15
26
|
[sys.executable, str(SCRIPT_PATH)],
|
|
16
27
|
input=json.dumps(payload),
|
|
17
28
|
text=True,
|
|
18
29
|
capture_output=True,
|
|
19
30
|
check=False,
|
|
31
|
+
env=child_environment,
|
|
20
32
|
)
|
|
21
33
|
|
|
22
34
|
|
|
@@ -106,3 +118,50 @@ def test_ignores_non_bash_tool() -> None:
|
|
|
106
118
|
result = _run_hook(payload)
|
|
107
119
|
assert result.stdout.strip() == ""
|
|
108
120
|
assert result.returncode == 0
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_gh_issue_comment_is_allowed_when_redirect_env_var_is_unset() -> None:
|
|
124
|
+
payload = _make_bash_payload('gh issue comment 83 --body "hello"')
|
|
125
|
+
|
|
126
|
+
result = _run_hook(payload, gh_redirect_active=False)
|
|
127
|
+
|
|
128
|
+
assert result.stdout.strip() == ""
|
|
129
|
+
assert result.returncode == 0
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_gh_pr_comment_is_allowed_when_redirect_env_var_is_unset() -> None:
|
|
133
|
+
payload = _make_bash_payload('gh pr comment 42 --body "ok"')
|
|
134
|
+
|
|
135
|
+
result = _run_hook(payload, gh_redirect_active=False)
|
|
136
|
+
|
|
137
|
+
assert result.stdout.strip() == ""
|
|
138
|
+
assert result.returncode == 0
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_gh_pr_review_is_allowed_when_redirect_env_var_is_unset() -> None:
|
|
142
|
+
payload = _make_bash_payload("gh pr review 42 --approve")
|
|
143
|
+
|
|
144
|
+
result = _run_hook(payload, gh_redirect_active=False)
|
|
145
|
+
|
|
146
|
+
assert result.stdout.strip() == ""
|
|
147
|
+
assert result.returncode == 0
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_gh_api_post_comment_is_allowed_when_redirect_env_var_is_unset() -> None:
|
|
151
|
+
payload = _make_bash_payload(
|
|
152
|
+
"gh api /repos/owner/name/issues/1/comments -X POST -f body=hello"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
result = _run_hook(payload, gh_redirect_active=False)
|
|
156
|
+
|
|
157
|
+
assert result.stdout.strip() == ""
|
|
158
|
+
assert result.returncode == 0
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_rm_rf_still_asks_when_redirect_env_var_is_unset() -> None:
|
|
162
|
+
payload = _make_bash_payload("rm -rf /tmp/somewhere")
|
|
163
|
+
|
|
164
|
+
result = _run_hook(payload, gh_redirect_active=False)
|
|
165
|
+
|
|
166
|
+
response = json.loads(result.stdout)
|
|
167
|
+
assert response["hookSpecificOutput"]["permissionDecision"] == "ask"
|
|
@@ -247,3 +247,55 @@ def test_directory_skip_components_excludes_pluralized_conftest() -> None:
|
|
|
247
247
|
actual_directory_skip_components = _PRODUCTION_MODULE._directory_skip_components()
|
|
248
248
|
|
|
249
249
|
assert "conftests" not in actual_directory_skip_components
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def test_should_skip_silently_when_posix_path_has_dotclaude_segment(tmp_path: Path) -> None:
|
|
253
|
+
dotclaude_production_file = tmp_path / ".claude" / "agents" / "reviewer.py"
|
|
254
|
+
|
|
255
|
+
completed = _run_hook_with_payload(
|
|
256
|
+
_make_write_payload(dotclaude_production_file, "def review(): pass\n")
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
assert completed.returncode == 0
|
|
260
|
+
assert completed.stdout.strip() == ""
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def test_should_skip_silently_when_windows_backslash_path_has_dotclaude_segment() -> None:
|
|
264
|
+
windows_style_path = "C:\\Users\\dev\\.claude\\agents\\reviewer.py"
|
|
265
|
+
|
|
266
|
+
completed = _run_hook_with_payload({
|
|
267
|
+
"tool_name": "Write",
|
|
268
|
+
"tool_input": {"file_path": windows_style_path, "content": "def review(): pass\n"},
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
assert completed.returncode == 0
|
|
272
|
+
assert completed.stdout.strip() == ""
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def test_should_skip_silently_when_mixed_separator_path_has_dotclaude_segment() -> None:
|
|
276
|
+
mixed_separator_path = "C:/Users/dev\\.claude/agents\\reviewer.py"
|
|
277
|
+
|
|
278
|
+
completed = _run_hook_with_payload({
|
|
279
|
+
"tool_name": "Write",
|
|
280
|
+
"tool_input": {"file_path": mixed_separator_path, "content": "def review(): pass\n"},
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
assert completed.returncode == 0
|
|
284
|
+
assert completed.stdout.strip() == ""
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def test_should_not_skip_when_dotclaude_is_only_a_filename_substring(tmp_path: Path) -> None:
|
|
288
|
+
sandbox = _sandbox(tmp_path)
|
|
289
|
+
substring_production_file = sandbox / "my.claude.helpers.py"
|
|
290
|
+
substring_production_file.write_text("def help(): pass\n")
|
|
291
|
+
|
|
292
|
+
completed = _run_hook_with_payload(_make_write_payload(substring_production_file))
|
|
293
|
+
|
|
294
|
+
assert _decision_from(completed) == "deny"
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def test_is_inside_dotclaude_segment_helper_matches_only_exact_segments() -> None:
|
|
298
|
+
assert _PRODUCTION_MODULE._is_inside_dotclaude_segment("/home/user/.claude/agent.py") is True
|
|
299
|
+
assert _PRODUCTION_MODULE._is_inside_dotclaude_segment("C:\\Users\\dev\\.claude\\agent.py") is True
|
|
300
|
+
assert _PRODUCTION_MODULE._is_inside_dotclaude_segment("/src/my.claude.helpers.py") is False
|
|
301
|
+
assert _PRODUCTION_MODULE._is_inside_dotclaude_segment("/src/app/service.py") is False
|
|
@@ -4,8 +4,8 @@ import os
|
|
|
4
4
|
import platform
|
|
5
5
|
import subprocess
|
|
6
6
|
|
|
7
|
-
NTFY_TOPIC = os.environ.get("NTFY_TOPIC", "
|
|
8
|
-
NTFY_BASE_URL = f"https://ntfy.sh/{NTFY_TOPIC}"
|
|
7
|
+
NTFY_TOPIC = os.environ.get("NTFY_TOPIC", "")
|
|
8
|
+
NTFY_BASE_URL = f"https://ntfy.sh/{NTFY_TOPIC}" if NTFY_TOPIC else ""
|
|
9
9
|
WINDOWS_CHIMES_PATH = os.path.join(os.environ.get("SYSTEMROOT", r"C:\Windows"), "Media", "Windows Battery Critical.wav")
|
|
10
10
|
LINUX_NOTIFICATION_SOUND = os.environ.get("NOTIFICATION_SOUND", "/usr/share/sounds/freedesktop/stereo/message.oga")
|
|
11
11
|
LINUX_NOTIFICATION_TIMEOUT_MS = "3000"
|
|
@@ -138,6 +138,8 @@ def notify_windows(title: str, message: str) -> None:
|
|
|
138
138
|
|
|
139
139
|
|
|
140
140
|
def notify_ntfy(title: str, message: str, priority: str = "high") -> None:
|
|
141
|
+
if not NTFY_TOPIC:
|
|
142
|
+
return
|
|
141
143
|
try:
|
|
142
144
|
subprocess.Popen(
|
|
143
145
|
[
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Unit tests for notification_utils ntfy guard behavior."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import pathlib
|
|
5
|
+
import types
|
|
6
|
+
from unittest.mock import patch
|
|
7
|
+
|
|
8
|
+
HOOK_DIRECTORY = pathlib.Path(__file__).parent
|
|
9
|
+
MODULE_PATH = HOOK_DIRECTORY / "notification_utils.py"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def load_notification_utils_with_environment(
|
|
13
|
+
environment_overrides: dict[str, str],
|
|
14
|
+
) -> types.ModuleType:
|
|
15
|
+
module_specification = importlib.util.spec_from_file_location(
|
|
16
|
+
"notification_utils_under_test",
|
|
17
|
+
MODULE_PATH,
|
|
18
|
+
)
|
|
19
|
+
assert module_specification is not None
|
|
20
|
+
assert module_specification.loader is not None
|
|
21
|
+
module_under_test = importlib.util.module_from_spec(module_specification)
|
|
22
|
+
with patch.dict("os.environ", environment_overrides, clear=False):
|
|
23
|
+
module_specification.loader.exec_module(module_under_test)
|
|
24
|
+
return module_under_test
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_should_skip_curl_when_topic_environment_variable_is_unset() -> None:
|
|
28
|
+
environment_with_topic_removed = {"NTFY_TOPIC": ""}
|
|
29
|
+
module_under_test = load_notification_utils_with_environment(
|
|
30
|
+
environment_with_topic_removed
|
|
31
|
+
)
|
|
32
|
+
with patch("subprocess.Popen") as popen_spy:
|
|
33
|
+
module_under_test.notify_ntfy(title="Test", message="payload")
|
|
34
|
+
assert popen_spy.call_count == 0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_should_invoke_curl_when_topic_environment_variable_is_set() -> None:
|
|
38
|
+
environment_with_topic_set = {"NTFY_TOPIC": "private-topic-for-test"}
|
|
39
|
+
module_under_test = load_notification_utils_with_environment(
|
|
40
|
+
environment_with_topic_set
|
|
41
|
+
)
|
|
42
|
+
with patch("subprocess.Popen") as popen_spy:
|
|
43
|
+
module_under_test.notify_ntfy(title="Test", message="payload")
|
|
44
|
+
assert popen_spy.call_count == 1
|
|
45
|
+
curl_arguments = popen_spy.call_args.args[0]
|
|
46
|
+
assert "https://ntfy.sh/private-topic-for-test" in curl_arguments
|