claude-dev-env 8.39.0 → 8.40.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.
|
@@ -9,6 +9,15 @@ It also denies a pull request number the reader cannot open::
|
|
|
9
9
|
flag: Both land in PR 5256.
|
|
10
10
|
flag: It merged in #4347.
|
|
11
11
|
ok: Both land in [PR 5256](https://github.com/owner/repo/pull/5256).
|
|
12
|
+
|
|
13
|
+
It denies a banned word or phrase, matched whole and case-insensitive::
|
|
14
|
+
|
|
15
|
+
flag: The cause is likely the cache.
|
|
16
|
+
ok: The cache log shows the miss at 12:04.
|
|
17
|
+
|
|
18
|
+
The gate uses the ``banned_words`` list in ``~/.claude/reply-banned-words.json``,
|
|
19
|
+
or in the file that CLAUDE_REPLY_BANNED_WORDS_PATH names. Without a valid
|
|
20
|
+
list there, it uses ALL_DEFAULT_BANNED_WORDS.
|
|
12
21
|
Each non-empty line counts as its own sentence, so a list counts one
|
|
13
22
|
sentence per item. URLs, markdown link targets, inline code spans, and
|
|
14
23
|
fenced blocks carry no words.
|
|
@@ -20,6 +29,8 @@ later pass, so this gate denies it and the model resends a shorter one.
|
|
|
20
29
|
|
|
21
30
|
from __future__ import annotations
|
|
22
31
|
|
|
32
|
+
import os
|
|
33
|
+
import re
|
|
23
34
|
import sys
|
|
24
35
|
from pathlib import Path
|
|
25
36
|
|
|
@@ -27,12 +38,22 @@ hooks_root_directory = str(Path(__file__).resolve().parent.parent)
|
|
|
27
38
|
if hooks_root_directory not in sys.path:
|
|
28
39
|
sys.path.insert(0, hooks_root_directory)
|
|
29
40
|
|
|
41
|
+
from json_file_reader import read_json_object
|
|
30
42
|
from hooks_constants.hook_block_logger import log_hook_block
|
|
31
43
|
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
32
44
|
from hooks_constants.reply_length_gate_constants import (
|
|
33
45
|
ALL_CHECKED_TOOL_NAMES,
|
|
46
|
+
ALL_DEFAULT_BANNED_WORDS,
|
|
34
47
|
ALLOW_EXIT_CODE,
|
|
48
|
+
BANNED_WORD_MESSAGE,
|
|
49
|
+
BANNED_WORD_PART_SEPARATOR,
|
|
50
|
+
BANNED_WORD_PATTERN_TEMPLATE,
|
|
51
|
+
BANNED_WORDS_FILE_NAME,
|
|
52
|
+
BANNED_WORDS_JSON_KEY,
|
|
53
|
+
BANNED_WORDS_PATH_ENV_VAR,
|
|
35
54
|
BLOCK_EXIT_CODE,
|
|
55
|
+
CLAUDE_HOME_DIRECTORY_NAME,
|
|
56
|
+
CONFIG_FILE_ENCODING,
|
|
36
57
|
FENCED_BLOCK_PATTERN,
|
|
37
58
|
HOOK_EVENT_NAME,
|
|
38
59
|
INLINE_CODE_PATTERN,
|
|
@@ -106,6 +127,42 @@ def unlinked_pull_request_violation(reply_text: str) -> str | None:
|
|
|
106
127
|
return UNLINKED_PULL_REQUEST_MESSAGE.format(reference=unlinked_match.group(0))
|
|
107
128
|
|
|
108
129
|
|
|
130
|
+
def banned_words_config_path() -> Path:
|
|
131
|
+
"""Return the banned-words file named by the environment, or the one in the Claude home."""
|
|
132
|
+
path_override = os.environ.get(BANNED_WORDS_PATH_ENV_VAR)
|
|
133
|
+
if path_override:
|
|
134
|
+
return Path(path_override)
|
|
135
|
+
return Path.home() / CLAUDE_HOME_DIRECTORY_NAME / BANNED_WORDS_FILE_NAME
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def configured_banned_words() -> tuple[str, ...]:
|
|
139
|
+
"""Return the configured banned words, or the defaults when no valid list is configured."""
|
|
140
|
+
config_document = read_json_object(banned_words_config_path(), CONFIG_FILE_ENCODING)
|
|
141
|
+
if config_document is None:
|
|
142
|
+
return ALL_DEFAULT_BANNED_WORDS
|
|
143
|
+
all_configured_words = config_document.get(BANNED_WORDS_JSON_KEY)
|
|
144
|
+
if not isinstance(all_configured_words, list):
|
|
145
|
+
return ALL_DEFAULT_BANNED_WORDS
|
|
146
|
+
return tuple(
|
|
147
|
+
each_word.strip()
|
|
148
|
+
for each_word in all_configured_words
|
|
149
|
+
if isinstance(each_word, str) and each_word.strip()
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def banned_word_violation(reply_text: str, all_banned_words: tuple[str, ...]) -> str | None:
|
|
154
|
+
"""Return the deny reason for the first banned word in the prose, or None."""
|
|
155
|
+
prose_text = countable_text(reply_text)
|
|
156
|
+
for each_banned_word in all_banned_words:
|
|
157
|
+
word_pattern = BANNED_WORD_PART_SEPARATOR.join(
|
|
158
|
+
re.escape(each_part) for each_part in each_banned_word.split()
|
|
159
|
+
)
|
|
160
|
+
whole_word_pattern = BANNED_WORD_PATTERN_TEMPLATE.format(word_pattern=word_pattern)
|
|
161
|
+
if re.search(whole_word_pattern, prose_text, re.IGNORECASE):
|
|
162
|
+
return BANNED_WORD_MESSAGE.format(banned_word=each_banned_word)
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
|
|
109
166
|
def main() -> int:
|
|
110
167
|
hook_input = read_hook_input_dictionary_from_stdin()
|
|
111
168
|
if hook_input is None:
|
|
@@ -119,7 +176,11 @@ def main() -> int:
|
|
|
119
176
|
reply_text = tool_input.get(TEXT_KEY)
|
|
120
177
|
if not isinstance(reply_text, str):
|
|
121
178
|
return ALLOW_EXIT_CODE
|
|
122
|
-
violation =
|
|
179
|
+
violation = (
|
|
180
|
+
length_violation(reply_text)
|
|
181
|
+
or unlinked_pull_request_violation(reply_text)
|
|
182
|
+
or banned_word_violation(reply_text, configured_banned_words())
|
|
183
|
+
)
|
|
123
184
|
if violation is None:
|
|
124
185
|
return ALLOW_EXIT_CODE
|
|
125
186
|
block_reason = violation + RETRY_INSTRUCTION
|
|
@@ -235,3 +235,90 @@ def test_should_allow_a_bare_pull_request_url(
|
|
|
235
235
|
{"text": "Pull request: https://github.com/owner/repo/pull/5256"},
|
|
236
236
|
)
|
|
237
237
|
assert exit_code == 0
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def test_should_deny_a_reply_that_says_likely_and_name_the_word(
|
|
241
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
242
|
+
) -> None:
|
|
243
|
+
exit_code, stderr_text = run_gate(
|
|
244
|
+
monkeypatch,
|
|
245
|
+
capsys,
|
|
246
|
+
REPLY_TOOL_NAME,
|
|
247
|
+
{"text": "The back-test flagged the garland star. It likely flags the call button."},
|
|
248
|
+
)
|
|
249
|
+
assert exit_code == 2
|
|
250
|
+
assert 'Banned word "likely"' in stderr_text
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def test_should_allow_the_same_reply_without_the_banned_word(
|
|
254
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
255
|
+
) -> None:
|
|
256
|
+
exit_code, stderr_text = run_gate(
|
|
257
|
+
monkeypatch,
|
|
258
|
+
capsys,
|
|
259
|
+
REPLY_TOOL_NAME,
|
|
260
|
+
{"text": "The back-test flagged the garland star. It flags the call button in 7 themes."},
|
|
261
|
+
)
|
|
262
|
+
assert (exit_code, stderr_text) == (0, "")
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@pytest.mark.parametrize(
|
|
266
|
+
"reply_text",
|
|
267
|
+
["Really fast.", "A real-world case.", "In reality it passed.", "ACTUALLY done."],
|
|
268
|
+
)
|
|
269
|
+
def test_should_deny_default_banned_words_in_any_case_and_spacing(
|
|
270
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], reply_text: str
|
|
271
|
+
) -> None:
|
|
272
|
+
exit_code, _ = run_gate(monkeypatch, capsys, POST_TOOL_NAME, {"text": reply_text})
|
|
273
|
+
assert exit_code == 2
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@pytest.mark.parametrize(
|
|
277
|
+
"reply_text",
|
|
278
|
+
[
|
|
279
|
+
"I realized the cache missed.",
|
|
280
|
+
"Run `actual_count` again.",
|
|
281
|
+
"See https://example.com/likely.",
|
|
282
|
+
],
|
|
283
|
+
)
|
|
284
|
+
def test_should_allow_banned_words_inside_longer_words_code_and_urls(
|
|
285
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], reply_text: str
|
|
286
|
+
) -> None:
|
|
287
|
+
exit_code, _ = run_gate(monkeypatch, capsys, REPLY_TOOL_NAME, {"text": reply_text})
|
|
288
|
+
assert exit_code == 0
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def test_should_replace_the_defaults_with_the_configured_list(
|
|
292
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
293
|
+
) -> None:
|
|
294
|
+
config_path = tmp_path / ".claude" / "reply-banned-words.json"
|
|
295
|
+
config_path.parent.mkdir()
|
|
296
|
+
config_path.write_text(json.dumps({"banned_words": ["synergy"]}), encoding="utf-8")
|
|
297
|
+
likely_exit_code, _ = run_gate(
|
|
298
|
+
monkeypatch, capsys, REPLY_TOOL_NAME, {"text": "It likely passed."}
|
|
299
|
+
)
|
|
300
|
+
synergy_exit_code, stderr_text = run_gate(
|
|
301
|
+
monkeypatch, capsys, REPLY_TOOL_NAME, {"text": "Synergy shipped."}
|
|
302
|
+
)
|
|
303
|
+
assert (likely_exit_code, synergy_exit_code) == (0, 2)
|
|
304
|
+
assert 'Banned word "synergy"' in stderr_text
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def test_should_read_the_list_from_the_path_the_environment_names(
|
|
308
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
309
|
+
) -> None:
|
|
310
|
+
config_path = tmp_path / "words.json"
|
|
311
|
+
config_path.write_text(json.dumps({"banned_words": ["basically"]}), encoding="utf-8")
|
|
312
|
+
monkeypatch.setenv("CLAUDE_REPLY_BANNED_WORDS_PATH", str(config_path))
|
|
313
|
+
exit_code, _ = run_gate(monkeypatch, capsys, REPLY_TOOL_NAME, {"text": "Basically done."})
|
|
314
|
+
assert exit_code == 2
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def test_should_keep_the_defaults_when_the_config_file_is_malformed(
|
|
318
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|
|
319
|
+
) -> None:
|
|
320
|
+
config_path = tmp_path / ".claude" / "reply-banned-words.json"
|
|
321
|
+
config_path.parent.mkdir()
|
|
322
|
+
config_path.write_text("{not json", encoding="utf-8")
|
|
323
|
+
exit_code, _ = run_gate(monkeypatch, capsys, REPLY_TOOL_NAME, {"text": "It likely passed."})
|
|
324
|
+
assert exit_code == 2
|
|
@@ -28,6 +28,32 @@ SENTENCE_PREVIEW_SUFFIX = "..."
|
|
|
28
28
|
RETRY_INSTRUCTION = " Cut the text and resend the call."
|
|
29
29
|
TOO_MANY_SENTENCES_MESSAGE = "Reply too long: {sentence_count} sentences, limit {sentence_limit}."
|
|
30
30
|
UNLINKED_PULL_REQUEST_MESSAGE = 'Pull request "{reference}" has no link. Write it as [PR N](https://github.com/<owner>/<repo>/pull/N).'
|
|
31
|
+
ALL_DEFAULT_BANNED_WORDS = (
|
|
32
|
+
"real",
|
|
33
|
+
"really",
|
|
34
|
+
"in reality",
|
|
35
|
+
"genuine",
|
|
36
|
+
"genuinely",
|
|
37
|
+
"actual",
|
|
38
|
+
"actually",
|
|
39
|
+
"likely",
|
|
40
|
+
"unlikely",
|
|
41
|
+
"probably",
|
|
42
|
+
"seems",
|
|
43
|
+
"seem",
|
|
44
|
+
"seemingly",
|
|
45
|
+
"suspect",
|
|
46
|
+
"guess",
|
|
47
|
+
"my theory",
|
|
48
|
+
)
|
|
49
|
+
BANNED_WORD_PART_SEPARATOR = r"\s+"
|
|
50
|
+
BANNED_WORD_PATTERN_TEMPLATE = r"(?<![A-Za-z0-9]){word_pattern}(?![A-Za-z0-9])"
|
|
51
|
+
BANNED_WORDS_JSON_KEY = "banned_words"
|
|
52
|
+
BANNED_WORDS_FILE_NAME = "reply-banned-words.json"
|
|
53
|
+
BANNED_WORDS_PATH_ENV_VAR = "CLAUDE_REPLY_BANNED_WORDS_PATH"
|
|
54
|
+
CLAUDE_HOME_DIRECTORY_NAME = ".claude"
|
|
55
|
+
CONFIG_FILE_ENCODING = "utf-8"
|
|
56
|
+
BANNED_WORD_MESSAGE = 'Banned word "{banned_word}". Delete it and name the evidence: the log line, the check, the file and line.'
|
|
31
57
|
LONG_SENTENCE_MESSAGE = (
|
|
32
58
|
'Sentence too long: {word_count} words, limit {word_limit}: "{sentence_preview}".'
|
|
33
59
|
)
|
|
@@ -13,3 +13,9 @@ def test_gate_checks_the_two_chat_tools_that_post_to_the_user() -> None:
|
|
|
13
13
|
|
|
14
14
|
def test_gate_exit_codes_match_the_hook_contract() -> None:
|
|
15
15
|
assert (constants.ALLOW_EXIT_CODE, constants.BLOCK_EXIT_CODE) == (0, 2)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_default_banned_words_hold_the_hedges_and_intensifiers() -> None:
|
|
19
|
+
assert {"likely", "probably", "seems", "real", "actually", "genuine"} <= set(
|
|
20
|
+
constants.ALL_DEFAULT_BANNED_WORDS
|
|
21
|
+
)
|