claude-dev-env 1.26.4 → 1.27.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.
@@ -11,6 +11,9 @@ import os
11
11
  import re
12
12
  import sys
13
13
 
14
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "config"))
15
+ from messages import USER_FACING_NOTICE
16
+
14
17
  PLUGIN_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
15
18
 
16
19
  RESEARCH_MODE_SKILL_SEARCH_PATHS = [
@@ -99,14 +102,19 @@ def main() -> None:
99
102
 
100
103
  formatted_term_list = ", ".join(f'"{term}"' for term in found_hedging_terms)
101
104
 
102
- research_mode_content = "(Could not load research-mode skill file)"
105
+ resolved_skill_path: str | None = None
103
106
  for each_skill_path in RESEARCH_MODE_SKILL_SEARCH_PATHS:
104
- try:
105
- with open(each_skill_path, encoding="utf-8") as skill_file:
106
- research_mode_content = skill_file.read()
107
- break
108
- except OSError:
109
- continue
107
+ if os.path.exists(each_skill_path):
108
+ resolved_skill_path = each_skill_path
109
+ break
110
+
111
+ if resolved_skill_path is not None:
112
+ skill_reference = f"under the research-mode constraints defined in:\n\n{resolved_skill_path}"
113
+ else:
114
+ skill_reference = (
115
+ "under research-mode constraints "
116
+ "(no research-mode skill installed; verify with sources or reply 'I don't know')"
117
+ )
110
118
 
111
119
  block_response = {
112
120
  "decision": "block",
@@ -114,12 +122,13 @@ def main() -> None:
114
122
  f"ANTI-HALLUCINATION GUARDRAIL: Your response contains hedging language: "
115
123
  f"{formatted_term_list}. "
116
124
  f"These words signal unverified claims. You MUST rewrite your response "
117
- f"with these constraints active:\n\n"
118
- f"{research_mode_content}\n\n"
125
+ f"{skill_reference}\n\n"
119
126
  f"Do NOT simply remove the hedging word and keep the unverified claim. "
120
127
  f"Either VERIFY it with a source or replace it with 'I don't know'.\n\n"
121
128
  f"You MUST re-output the complete, revised response with the corrections applied."
122
129
  ),
130
+ "systemMessage": USER_FACING_NOTICE,
131
+ "suppressOutput": True,
123
132
  }
124
133
 
125
134
  print(json.dumps(block_response))
@@ -6,12 +6,19 @@ Blocks writes to production source files when no matching test exists
6
6
  or the matching test has not been modified within the configured
7
7
  freshness window. Enforces "TDD IS NON-NEGOTIABLE" from CLAUDE.md.
8
8
  """
9
+ import ast
9
10
  import json
10
11
  import re
11
12
  import sys
12
13
  import time
13
14
  from pathlib import Path
14
15
 
16
+ _hooks_root_path_string = str(Path(__file__).resolve().parent.parent)
17
+ if _hooks_root_path_string not in sys.path:
18
+ sys.path.insert(0, _hooks_root_path_string)
19
+
20
+ from config.messages import USER_FACING_TDD_NOTICE
21
+
15
22
  PRODUCTION_EXTENSIONS = {'.py', '.ts', '.tsx', '.js', '.jsx'}
16
23
  SKIP_PATTERNS = {
17
24
  'test_', '_test.', '.test.', 'tests/', '__tests__/',
@@ -33,8 +40,41 @@ def _freshness_seconds() -> int:
33
40
  return 600
34
41
 
35
42
 
36
- def _bypass_sentinel() -> str:
37
- return "# pragma: no-tdd-gate"
43
+ def _constants_only_allowed_node_types() -> tuple[type, ...]:
44
+ return (
45
+ ast.Import,
46
+ ast.ImportFrom,
47
+ ast.Assign,
48
+ ast.AnnAssign,
49
+ )
50
+
51
+
52
+ def _is_module_docstring_expression(module_level_node: ast.stmt) -> bool:
53
+ if not isinstance(module_level_node, ast.Expr):
54
+ return False
55
+ expression_value = module_level_node.value
56
+ if not isinstance(expression_value, ast.Constant):
57
+ return False
58
+ return isinstance(expression_value.value, str)
59
+
60
+
61
+ def _is_constants_only_python_content(content: str) -> bool:
62
+ if not content.strip():
63
+ return False
64
+ try:
65
+ parsed_tree = ast.parse(content)
66
+ except SyntaxError:
67
+ return False
68
+ if not parsed_tree.body:
69
+ return False
70
+ allowed_node_types = _constants_only_allowed_node_types()
71
+ for each_top_level_node in parsed_tree.body:
72
+ if isinstance(each_top_level_node, allowed_node_types):
73
+ continue
74
+ if _is_module_docstring_expression(each_top_level_node):
75
+ continue
76
+ return False
77
+ return True
38
78
 
39
79
 
40
80
  def _tests_directory_name() -> str:
@@ -154,13 +194,17 @@ def has_fresh_test(
154
194
 
155
195
  def build_deny_reason(production_path: Path, all_candidates: list[Path]) -> str:
156
196
  candidate_lines = "\n".join(f" - {each_path}" for each_path in all_candidates)
197
+ hook_source_path = Path(__file__).resolve()
157
198
  return (
158
199
  f"[TDD] Blocking write to production file: {production_path}\n"
159
200
  f"No matching test file exists, or it has not been modified within the last "
160
201
  f"{_freshness_seconds()} seconds.\n"
161
202
  f"Expected one of:\n{candidate_lines}\n"
162
- f"Write a failing test first (RED), then the minimum code to pass it (GREEN).\n"
163
- f"Bypass (discouraged): include the sentinel '{_bypass_sentinel()}' in the file content."
203
+ f"Write a failing test first (RED), then the minimum code to pass it (GREEN).\n\n"
204
+ f"If this file legitimately does not need a test (for example, a module containing only "
205
+ f"module-level constants with no behavior), that is a hook enhancement, not a bypass. "
206
+ f"Propose an exemption rule in {hook_source_path} so every similar file benefits "
207
+ f"automatically. Do not add escape-hatch markers to production files."
164
208
  )
165
209
 
166
210
 
@@ -180,7 +224,9 @@ def emit_deny(reason: str) -> None:
180
224
  "hookEventName": "PreToolUse",
181
225
  "permissionDecision": "deny",
182
226
  "permissionDecisionReason": reason,
183
- }
227
+ },
228
+ "suppressOutput": True,
229
+ "systemMessage": USER_FACING_TDD_NOTICE,
184
230
  }
185
231
  print(json.dumps(deny_payload))
186
232
 
@@ -252,7 +298,7 @@ def main() -> None:
252
298
 
253
299
  # Block production code - require confirmation
254
300
  written_content = _extract_written_content(tool_name, tool_input)
255
- if _bypass_sentinel() in written_content:
301
+ if tool_name == "Write" and ext == ".py" and _is_constants_only_python_content(written_content):
256
302
  emit_allow()
257
303
  sys.exit(0)
258
304
 
@@ -4,6 +4,7 @@ import json
4
4
  import os
5
5
  import subprocess
6
6
  import sys
7
+ import tempfile
7
8
  from pathlib import Path
8
9
 
9
10
 
@@ -165,3 +166,171 @@ def test_rm_rf_still_asks_when_redirect_env_var_is_unset() -> None:
165
166
 
166
167
  response = json.loads(result.stdout)
167
168
  assert response["hookSpecificOutput"]["permissionDecision"] == "ask"
169
+
170
+
171
+ def _run_hook_with_fake_home(
172
+ payload: dict,
173
+ fake_home: Path,
174
+ working_directory: Path,
175
+ disable_ephemeral_auto_allow: bool = True,
176
+ ) -> subprocess.CompletedProcess[str]:
177
+ child_environment = os.environ.copy()
178
+ child_environment.pop(GH_REDIRECT_ACTIVE_ENV_VAR, None)
179
+ child_environment["HOME"] = str(fake_home)
180
+ child_environment["USERPROFILE"] = str(fake_home)
181
+ if disable_ephemeral_auto_allow:
182
+ child_environment["CLAUDE_DESTRUCTIVE_DISABLE_EPHEMERAL_AUTO_ALLOW"] = "1"
183
+ else:
184
+ child_environment.pop("CLAUDE_DESTRUCTIVE_DISABLE_EPHEMERAL_AUTO_ALLOW", None)
185
+ return subprocess.run(
186
+ [sys.executable, str(SCRIPT_PATH)],
187
+ input=json.dumps(payload),
188
+ text=True,
189
+ capture_output=True,
190
+ check=False,
191
+ env=child_environment,
192
+ cwd=str(working_directory),
193
+ )
194
+
195
+
196
+ def _write_settings_with_allow_list(fake_home: Path, allow_list: list[str]) -> None:
197
+ claude_directory = fake_home / ".claude"
198
+ claude_directory.mkdir(parents=True, exist_ok=True)
199
+ settings_payload = {"hooks": {"allowGitResetHardProjects": allow_list}}
200
+ (claude_directory / "settings.json").write_text(
201
+ json.dumps(settings_payload), encoding="utf-8"
202
+ )
203
+
204
+
205
+ def test_git_reset_hard_allowed_when_cwd_matches_settings_allow_list(tmp_path: Path) -> None:
206
+ fake_home = tmp_path / "home"
207
+ fake_home.mkdir()
208
+ project_directory = tmp_path / "approved_project"
209
+ project_directory.mkdir()
210
+ project_path_forward = str(project_directory).replace("\\", "/")
211
+ _write_settings_with_allow_list(fake_home, [project_path_forward])
212
+ payload = _make_bash_payload("git reset --hard origin/main")
213
+
214
+ result = _run_hook_with_fake_home(payload, fake_home, project_directory)
215
+
216
+ assert result.stdout.strip() == ""
217
+ assert result.returncode == 0
218
+
219
+
220
+ def test_git_reset_hard_asks_when_cwd_not_in_settings_allow_list(tmp_path: Path) -> None:
221
+ fake_home = tmp_path / "home"
222
+ fake_home.mkdir()
223
+ approved_directory = tmp_path / "approved_project"
224
+ approved_directory.mkdir()
225
+ unapproved_directory = tmp_path / "unapproved_project"
226
+ unapproved_directory.mkdir()
227
+ approved_path_forward = str(approved_directory).replace("\\", "/")
228
+ _write_settings_with_allow_list(fake_home, [approved_path_forward])
229
+ payload = _make_bash_payload("git reset --hard origin/main")
230
+
231
+ result = _run_hook_with_fake_home(payload, fake_home, unapproved_directory)
232
+
233
+ response = json.loads(result.stdout)
234
+ assert response["hookSpecificOutput"]["permissionDecision"] == "ask"
235
+ assert "git reset --hard" in response["hookSpecificOutput"]["permissionDecisionReason"]
236
+
237
+
238
+ def test_git_reset_hard_asks_when_settings_missing(tmp_path: Path) -> None:
239
+ fake_home = tmp_path / "home"
240
+ fake_home.mkdir()
241
+ project_directory = tmp_path / "unapproved_project"
242
+ project_directory.mkdir()
243
+ payload = _make_bash_payload("git reset --hard origin/main")
244
+
245
+ result = _run_hook_with_fake_home(payload, fake_home, project_directory)
246
+
247
+ response = json.loads(result.stdout)
248
+ assert response["hookSpecificOutput"]["permissionDecision"] == "ask"
249
+
250
+
251
+ def test_git_reset_hard_asks_when_allow_list_is_empty(tmp_path: Path) -> None:
252
+ fake_home = tmp_path / "home"
253
+ fake_home.mkdir()
254
+ project_directory = tmp_path / "some_project"
255
+ project_directory.mkdir()
256
+ _write_settings_with_allow_list(fake_home, [])
257
+ payload = _make_bash_payload("git reset --hard origin/main")
258
+
259
+ result = _run_hook_with_fake_home(payload, fake_home, project_directory)
260
+
261
+ response = json.loads(result.stdout)
262
+ assert response["hookSpecificOutput"]["permissionDecision"] == "ask"
263
+
264
+
265
+ def test_git_reset_hard_allowed_in_linked_git_worktree(tmp_path: Path) -> None:
266
+ fake_home = tmp_path / "home"
267
+ fake_home.mkdir()
268
+ main_repository = tmp_path / "main_repository"
269
+ main_repository.mkdir()
270
+ subprocess.run(["git", "init", "-q"], cwd=main_repository, check=True)
271
+ subprocess.run(["git", "commit", "-q", "--allow-empty", "-m", "init"], cwd=main_repository, check=True)
272
+ worktree_directory = tmp_path / "worktree_copy"
273
+ subprocess.run(
274
+ ["git", "worktree", "add", "-q", "-b", "feature", str(worktree_directory)],
275
+ cwd=main_repository,
276
+ check=True,
277
+ )
278
+ payload = _make_bash_payload("git reset --hard origin/main")
279
+
280
+ result = _run_hook_with_fake_home(payload, fake_home, worktree_directory, disable_ephemeral_auto_allow=False)
281
+
282
+ assert result.stdout.strip() == ""
283
+ assert result.returncode == 0
284
+
285
+
286
+ def test_git_reset_hard_allowed_when_path_contains_worktrees_segment(tmp_path: Path) -> None:
287
+ fake_home = tmp_path / "home"
288
+ fake_home.mkdir()
289
+ worktree_directory = tmp_path / "worktrees" / "some_feature"
290
+ worktree_directory.mkdir(parents=True)
291
+ payload = _make_bash_payload("git reset --hard origin/main")
292
+
293
+ result = _run_hook_with_fake_home(payload, fake_home, worktree_directory, disable_ephemeral_auto_allow=False)
294
+
295
+ assert result.stdout.strip() == ""
296
+ assert result.returncode == 0
297
+
298
+
299
+ def test_git_reset_hard_allowed_under_os_temp_directory() -> None:
300
+ fake_home = Path(tempfile.mkdtemp(prefix="home_"))
301
+ working_directory = Path(tempfile.mkdtemp(prefix="ephemeral_work_"))
302
+ payload = _make_bash_payload("git reset --hard origin/main")
303
+
304
+ result = _run_hook_with_fake_home(payload, fake_home, working_directory, disable_ephemeral_auto_allow=False)
305
+
306
+ assert result.stdout.strip() == ""
307
+ assert result.returncode == 0
308
+
309
+
310
+ def test_git_reset_hard_asks_in_plain_directory_with_no_settings(tmp_path: Path) -> None:
311
+ fake_home = tmp_path / "home"
312
+ fake_home.mkdir()
313
+ plain_directory = tmp_path / "regular_project"
314
+ plain_directory.mkdir()
315
+ payload = _make_bash_payload("git reset --hard origin/main")
316
+
317
+ result = _run_hook_with_fake_home(payload, fake_home, plain_directory)
318
+
319
+ response = json.loads(result.stdout)
320
+ assert response["hookSpecificOutput"]["permissionDecision"] == "ask"
321
+
322
+
323
+ def test_git_reset_hard_asks_when_settings_file_is_invalid_json(tmp_path: Path) -> None:
324
+ fake_home = tmp_path / "home"
325
+ (fake_home / ".claude").mkdir(parents=True)
326
+ (fake_home / ".claude" / "settings.json").write_text(
327
+ "{not valid json", encoding="utf-8"
328
+ )
329
+ project_directory = tmp_path / "unapproved_project"
330
+ project_directory.mkdir()
331
+ payload = _make_bash_payload("git reset --hard origin/main")
332
+
333
+ result = _run_hook_with_fake_home(payload, fake_home, project_directory)
334
+
335
+ response = json.loads(result.stdout)
336
+ assert response["hookSpecificOutput"]["permissionDecision"] == "ask"
@@ -0,0 +1,135 @@
1
+ """Tests for hedging_language_blocker hook response shape."""
2
+
3
+ import importlib.util
4
+ import json
5
+ import os
6
+ import subprocess
7
+ import sys
8
+ import tempfile
9
+
10
+ HOOK_SCRIPT_PATH = os.path.join(os.path.dirname(__file__), "hedging_language_blocker.py")
11
+ _HOOKS_DIR = os.path.dirname(HOOK_SCRIPT_PATH)
12
+ _CONFIG_DIR = os.path.join(_HOOKS_DIR, "..", "config")
13
+ if _HOOKS_DIR not in sys.path:
14
+ sys.path.insert(0, _HOOKS_DIR)
15
+ if _CONFIG_DIR not in sys.path:
16
+ sys.path.insert(0, _CONFIG_DIR)
17
+ import hedging_language_blocker
18
+ from messages import USER_FACING_NOTICE
19
+
20
+ RESEARCH_MODE_SKILL_BODY_MARKER = "Three anti-hallucination constraints are ALWAYS active."
21
+ HEDGING_MESSAGE = "This is likely correct."
22
+ CLEAN_MESSAGE = "This is verified by the source document."
23
+ EMPTY_MESSAGE = ""
24
+
25
+
26
+ def run_hook_with_message(assistant_message: str) -> subprocess.CompletedProcess:
27
+ hook_input_payload = json.dumps({"last_assistant_message": assistant_message})
28
+ return subprocess.run(
29
+ [sys.executable, HOOK_SCRIPT_PATH],
30
+ input=hook_input_payload,
31
+ capture_output=True,
32
+ text=True,
33
+ check=False,
34
+ )
35
+
36
+
37
+ def run_hook_with_patched_search_paths(
38
+ assistant_message: str,
39
+ search_paths: list[str],
40
+ ) -> subprocess.CompletedProcess:
41
+ """Run the hook with RESEARCH_MODE_SKILL_SEARCH_PATHS overridden via a wrapper script."""
42
+ wrapper_script = (
43
+ "import sys, json, os\n"
44
+ f"sys.path.insert(0, {repr(os.path.dirname(HOOK_SCRIPT_PATH))})\n"
45
+ "import hedging_language_blocker as blocker\n"
46
+ f"blocker.RESEARCH_MODE_SKILL_SEARCH_PATHS = {repr(search_paths)}\n"
47
+ "blocker.main()\n"
48
+ )
49
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as wrapper_file:
50
+ wrapper_file.write(wrapper_script)
51
+ wrapper_file_path = wrapper_file.name
52
+
53
+ hook_input_payload = json.dumps({"last_assistant_message": assistant_message})
54
+ try:
55
+ completed_process = subprocess.run(
56
+ [sys.executable, wrapper_file_path],
57
+ input=hook_input_payload,
58
+ capture_output=True,
59
+ text=True,
60
+ check=False,
61
+ )
62
+ finally:
63
+ os.unlink(wrapper_file_path)
64
+ return completed_process
65
+
66
+
67
+ def test_user_facing_notice_importable_from_config_messages():
68
+ config_messages_path = os.path.join(_CONFIG_DIR, "messages.py")
69
+ specification = importlib.util.spec_from_file_location("messages", config_messages_path)
70
+ module = importlib.util.module_from_spec(specification)
71
+ specification.loader.exec_module(module)
72
+
73
+ assert module.USER_FACING_NOTICE == USER_FACING_NOTICE
74
+
75
+
76
+ def test_hedging_message_emits_block_with_short_user_notice():
77
+ completed_process = run_hook_with_message(HEDGING_MESSAGE)
78
+
79
+ assert completed_process.returncode == 0
80
+ parsed_response = json.loads(completed_process.stdout)
81
+
82
+ assert parsed_response["decision"] == "block"
83
+ assert parsed_response["systemMessage"] == USER_FACING_NOTICE
84
+ assert parsed_response["suppressOutput"] is True
85
+ assert "likely" in parsed_response["reason"]
86
+
87
+
88
+ def test_hedging_reason_contains_not_installed_notice_when_skill_absent():
89
+ completed_process = run_hook_with_patched_search_paths(
90
+ HEDGING_MESSAGE,
91
+ ["/nonexistent/path/one/SKILL.md", "/nonexistent/path/two/SKILL.md"],
92
+ )
93
+
94
+ assert completed_process.returncode == 0
95
+ parsed_response = json.loads(completed_process.stdout)
96
+
97
+ assert parsed_response["decision"] == "block"
98
+ assert "no research-mode skill installed" in parsed_response["reason"]
99
+ assert "verify with sources or reply" in parsed_response["reason"]
100
+ assert "SKILL.md" not in parsed_response["reason"]
101
+ assert RESEARCH_MODE_SKILL_BODY_MARKER not in parsed_response["reason"]
102
+
103
+
104
+ def test_hedging_reason_contains_skill_path_when_skill_present():
105
+ with tempfile.TemporaryDirectory() as skill_dir:
106
+ skill_file_path = os.path.join(skill_dir, "SKILL.md")
107
+ with open(skill_file_path, "w") as skill_file:
108
+ skill_file.write("# Research Mode Skill\n")
109
+
110
+ completed_process = run_hook_with_patched_search_paths(
111
+ HEDGING_MESSAGE,
112
+ ["/nonexistent/path/SKILL.md", skill_file_path],
113
+ )
114
+
115
+ assert completed_process.returncode == 0
116
+ parsed_response = json.loads(completed_process.stdout)
117
+
118
+ assert parsed_response["decision"] == "block"
119
+ assert "SKILL.md" in parsed_response["reason"]
120
+ assert "no research-mode skill installed" not in parsed_response["reason"]
121
+ assert RESEARCH_MODE_SKILL_BODY_MARKER not in parsed_response["reason"]
122
+
123
+
124
+ def test_clean_message_passes_through_with_no_output():
125
+ completed_process = run_hook_with_message(CLEAN_MESSAGE)
126
+
127
+ assert completed_process.returncode == 0
128
+ assert completed_process.stdout == ""
129
+
130
+
131
+ def test_empty_message_passes_through_with_no_output():
132
+ completed_process = run_hook_with_message(EMPTY_MESSAGE)
133
+
134
+ assert completed_process.returncode == 0
135
+ assert completed_process.stdout == ""
@@ -96,7 +96,7 @@ def test_should_deny_when_test_file_exists_but_is_stale(tmp_path: Path) -> None:
96
96
  assert _decision_from(completed) == "deny"
97
97
 
98
98
 
99
- def test_should_allow_when_bypass_sentinel_present_in_content(tmp_path: Path) -> None:
99
+ def test_should_deny_when_pragma_no_tdd_gate_sentinel_is_present_without_test(tmp_path: Path) -> None:
100
100
  sandbox = _sandbox(tmp_path)
101
101
  production_file = sandbox / "orders.py"
102
102
  content_with_sentinel = "# pragma: no-tdd-gate\ndef fulfill(): pass\n"
@@ -105,7 +105,7 @@ def test_should_allow_when_bypass_sentinel_present_in_content(tmp_path: Path) ->
105
105
  _make_write_payload(production_file, content_with_sentinel)
106
106
  )
107
107
 
108
- assert _decision_from(completed) == "allow"
108
+ assert _decision_from(completed) == "deny"
109
109
 
110
110
 
111
111
  def test_should_skip_markdown_files_entirely(tmp_path: Path) -> None:
@@ -170,11 +170,12 @@ def test_should_deny_when_test_file_has_no_test_evidence(tmp_path: Path) -> None
170
170
  assert _decision_from(completed) == "deny"
171
171
 
172
172
 
173
- def test_should_allow_edit_when_bypass_sentinel_present_in_new_string(
173
+ def test_should_deny_edit_when_pragma_sentinel_present_in_new_string_without_test(
174
174
  tmp_path: Path,
175
175
  ) -> None:
176
176
  sandbox = _sandbox(tmp_path)
177
177
  production_file = sandbox / "orders.py"
178
+ production_file.write_text("def fulfill(): pass\n")
178
179
  payload = {
179
180
  "tool_name": "Edit",
180
181
  "tool_input": {
@@ -186,9 +187,131 @@ def test_should_allow_edit_when_bypass_sentinel_present_in_new_string(
186
187
 
187
188
  completed = _run_hook_with_payload(payload)
188
189
 
190
+ assert _decision_from(completed) == "deny"
191
+
192
+
193
+ def test_should_allow_python_file_with_only_module_level_constants(tmp_path: Path) -> None:
194
+ sandbox = _sandbox(tmp_path)
195
+ constants_file = sandbox / "constants.py"
196
+ constants_only_content = (
197
+ '"""Module-level constants for the widget subsystem."""\n'
198
+ "import re\n"
199
+ "MAXIMUM_RETRIES: int = 3\n"
200
+ "DEFAULT_TIMEOUT_SECONDS: float = 30.0\n"
201
+ 'BANNED_WORDS: tuple[str, ...] = ("foo", "bar")\n'
202
+ )
203
+ constants_file.write_text(constants_only_content)
204
+
205
+ completed = _run_hook_with_payload(
206
+ _make_write_payload(constants_file, constants_only_content)
207
+ )
208
+
209
+ assert _decision_from(completed) == "allow"
210
+
211
+
212
+ def test_should_deny_python_file_when_any_function_definition_is_present(tmp_path: Path) -> None:
213
+ sandbox = _sandbox(tmp_path)
214
+ mixed_file = sandbox / "mixed.py"
215
+ mixed_content = (
216
+ "MAXIMUM_RETRIES: int = 3\n"
217
+ "def do_something() -> None:\n"
218
+ " return None\n"
219
+ )
220
+ mixed_file.write_text(mixed_content)
221
+
222
+ completed = _run_hook_with_payload(
223
+ _make_write_payload(mixed_file, mixed_content)
224
+ )
225
+
226
+ assert _decision_from(completed) == "deny"
227
+
228
+
229
+ def test_should_deny_python_file_when_any_class_definition_is_present(tmp_path: Path) -> None:
230
+ sandbox = _sandbox(tmp_path)
231
+ class_file = sandbox / "klass.py"
232
+ class_content = (
233
+ "class Widget:\n"
234
+ " size: int = 3\n"
235
+ )
236
+ class_file.write_text(class_content)
237
+
238
+ completed = _run_hook_with_payload(
239
+ _make_write_payload(class_file, class_content)
240
+ )
241
+
242
+ assert _decision_from(completed) == "deny"
243
+
244
+
245
+ def test_deny_response_places_system_message_and_suppress_output_at_top_level(
246
+ tmp_path: Path,
247
+ ) -> None:
248
+ sandbox = _sandbox(tmp_path)
249
+ production_file = sandbox / "orders.py"
250
+ production_file.write_text("def fulfill(): pass\n")
251
+
252
+ completed = _run_hook_with_payload(_make_write_payload(production_file))
253
+
254
+ parsed = json.loads(completed.stdout)
255
+ hook_output = parsed["hookSpecificOutput"]
256
+ assert hook_output["permissionDecision"] == "deny"
257
+ assert parsed.get("suppressOutput") is True
258
+ assert isinstance(parsed.get("systemMessage"), str)
259
+ assert parsed["systemMessage"]
260
+ assert "suppressOutput" not in hook_output
261
+ assert "systemMessage" not in hook_output
262
+ verbose_reason = hook_output["permissionDecisionReason"]
263
+ assert "propose" in verbose_reason.lower() or "enhancement" in verbose_reason.lower()
264
+
265
+
266
+ def test_should_deny_python_file_that_calls_function_at_module_level(tmp_path: Path) -> None:
267
+ sandbox = _sandbox(tmp_path)
268
+ side_effect_file = sandbox / "side_effects.py"
269
+ side_effect_content = (
270
+ "import sys\n"
271
+ "sys.exit(1)\n"
272
+ )
273
+ side_effect_file.write_text(side_effect_content)
274
+
275
+ completed = _run_hook_with_payload(
276
+ _make_write_payload(side_effect_file, side_effect_content)
277
+ )
278
+
279
+ assert _decision_from(completed) == "deny"
280
+
281
+
282
+ def test_should_allow_python_file_with_module_docstring_plus_constants(tmp_path: Path) -> None:
283
+ sandbox = _sandbox(tmp_path)
284
+ constants_file = sandbox / "constants.py"
285
+ constants_content = (
286
+ '"""Module-level constants for the widget subsystem."""\n'
287
+ "import re\n"
288
+ "MAXIMUM_RETRIES: int = 3\n"
289
+ )
290
+ constants_file.write_text(constants_content)
291
+
292
+ completed = _run_hook_with_payload(
293
+ _make_write_payload(constants_file, constants_content)
294
+ )
295
+
189
296
  assert _decision_from(completed) == "allow"
190
297
 
191
298
 
299
+ def test_should_deny_python_file_that_mutates_module_state_via_aug_assign(tmp_path: Path) -> None:
300
+ sandbox = _sandbox(tmp_path)
301
+ mutation_file = sandbox / "mutation.py"
302
+ mutation_content = (
303
+ "COUNTER: int = 0\n"
304
+ "COUNTER += 1\n"
305
+ )
306
+ mutation_file.write_text(mutation_content)
307
+
308
+ completed = _run_hook_with_payload(
309
+ _make_write_payload(mutation_file, mutation_content)
310
+ )
311
+
312
+ assert _decision_from(completed) == "deny"
313
+
314
+
192
315
  def test_should_deny_production_file_inside_directory_containing_skip_substring(
193
316
  tmp_path: Path,
194
317
  ) -> None:
@@ -0,0 +1 @@
1
+ # pragma: no-tdd-gate
@@ -0,0 +1,4 @@
1
+ """User-facing notice messages for blocking hooks."""
2
+
3
+ USER_FACING_NOTICE = "Agent was found guessing - sourcing opinions..."
4
+ USER_FACING_TDD_NOTICE = "TDD gate held - writing the failing test first..."
@@ -0,0 +1,13 @@
1
+ """Smoke tests for hooks.config.messages — verify user-facing notice constants exist."""
2
+
3
+ from config import messages
4
+
5
+
6
+ def test_user_facing_notice_is_nonempty_string() -> None:
7
+ assert isinstance(messages.USER_FACING_NOTICE, str)
8
+ assert messages.USER_FACING_NOTICE
9
+
10
+
11
+ def test_user_facing_tdd_notice_is_nonempty_string() -> None:
12
+ assert isinstance(messages.USER_FACING_TDD_NOTICE, str)
13
+ assert messages.USER_FACING_TDD_NOTICE