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 = length_violation(reply_text) or unlinked_pull_request_violation(reply_text)
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
+ )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.39.0",
3
+ "version": "8.40.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {