claude-dev-env 1.74.0 → 1.75.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.
Files changed (44) hide show
  1. package/hooks/blocking/CLAUDE.md +1 -0
  2. package/hooks/blocking/code_verifier_spawn_preflight_gate.py +2 -1
  3. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +155 -0
  4. package/hooks/blocking/hedging_language_blocker.py +1 -13
  5. package/hooks/blocking/intent_only_ending_blocker.py +1 -15
  6. package/hooks/blocking/pre_tool_use_dispatcher.py +4 -5
  7. package/hooks/blocking/question_to_user_enforcer.py +1 -11
  8. package/hooks/blocking/session_handoff_blocker.py +1 -15
  9. package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +16 -0
  10. package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +328 -0
  11. package/hooks/blocking/test_hedging_language_blocker.py +6 -0
  12. package/hooks/blocking/test_intent_only_ending_blocker.py +5 -0
  13. package/hooks/blocking/test_pre_tool_use_dispatcher.py +52 -5
  14. package/hooks/blocking/test_question_to_user_enforcer.py +6 -0
  15. package/hooks/blocking/test_session_handoff_blocker.py +6 -0
  16. package/hooks/hooks_constants/CLAUDE.md +4 -1
  17. package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +2 -1
  18. package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +27 -0
  19. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +2 -0
  20. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +8 -2
  21. package/hooks/hooks_constants/test_post_tool_use_dispatcher_constants.py +43 -0
  22. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +99 -0
  23. package/hooks/hooks_constants/test_text_stripping.py +39 -0
  24. package/hooks/hooks_constants/text_stripping.py +36 -0
  25. package/hooks/validation/CLAUDE.md +1 -0
  26. package/hooks/validation/post_tool_use_dispatcher.py +2 -2
  27. package/hooks/validation/test_mypy_validator.py +1 -1
  28. package/hooks/validation/test_post_tool_use_dispatcher.py +6 -0
  29. package/hooks/workflow/auto_formatter.py +8 -5
  30. package/hooks/workflow/test_auto_formatter.py +33 -0
  31. package/package.json +1 -1
  32. package/rules/windows-filesystem-safe.md +2 -0
  33. package/skills/autoconverge/SKILL.md +6 -3
  34. package/skills/autoconverge/reference/stop-conditions.md +7 -0
  35. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +5 -4
  36. package/skills/autoconverge/workflow/converge.contract.test.mjs +308 -132
  37. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +16 -16
  38. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +36 -44
  39. package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +16 -24
  40. package/skills/autoconverge/workflow/converge.mjs +598 -606
  41. package/skills/autoconverge/workflow/convergence_summary.py +1 -1
  42. package/skills/autoconverge/workflow/render_report.py +2 -6
  43. package/skills/autoconverge/workflow/test_convergence_summary.py +17 -0
  44. package/skills/autoconverge/workflow/test_render_report.py +1 -0
@@ -63,6 +63,7 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
63
63
  | `convergence_gate_blocker.py` | PreToolUse (Bash) | Convergence workflow actions on a conflicting PR |
64
64
  | `destructive_command_blocker.py` | PreToolUse (Bash/PowerShell) | Shell commands with destructive literals (`rm -rf`, `git reset --hard`, etc.) |
65
65
  | `docstring_rule_gate_count_blocker.py` | PreToolUse (Write/Edit/MultiEdit) | A stale spelled-out gate-validator count in `docstring-prose-matches-implementation.md` — the "N more gate validators" / "M gated slices" count drifting from the `check_docstring_*` validators the prose names |
66
+ | `duplicate_rmtree_helper_blocker.py` | PreToolUse (Write/Edit) | A local re-definition of the Windows-safe rmtree helper trio (`_strip_read_only_and_retry`, `_force_remove_tree` / `force_rmtree`) in place of importing a shared helper |
66
67
  | `es_exe_path_rewriter.py` | PreToolUse | Rewrites paths referencing `.exe` under the Everything search path |
67
68
  | `gh_body_arg_blocker.py` | PreToolUse (Bash) | `gh` commands passing `--body`/`-b` directly (requires `--body-file` instead) |
68
69
  | `gh_pr_author_enforcer.py` | PreToolUse | Enforces PR author identity rules |
@@ -5,7 +5,8 @@ The hook fires only on an ``Agent`` tool call whose ``subagent_type`` is
5
5
  ``code-verifier``. Before that verification spawn runs, the hook checks the
6
6
  branch for two committability problems against the resolved base ref: a real
7
7
  merge conflict (a non-mutating trial-merge of HEAD against the base ref) and a
8
- CODE_RULES violation on a line added in the uncommitted working tree. When
8
+ CODE_RULES violation on a line added in the working tree since the merge base
9
+ (committed on the branch or uncommitted). When
9
10
  either fires, the hook denies the spawn with a reason addressed to the spawning
10
11
  agent that names the conflicting files and the violating file:line, so that
11
12
  agent fixes them and re-spawns. Both checks fail OPEN on any infrastructure
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse hook: block a local re-definition of the Windows-safe rmtree helper trio.
3
+
4
+ The Windows-safe deletion helper trio — `_strip_read_only_and_retry`,
5
+ `_force_remove_tree`/`force_rmtree`, and the `inspect.signature` onexc/onerror guard —
6
+ is the sanctioned pattern for removing a directory tree that may hold ReadOnly files.
7
+ Because the windows_rmtree_blocker corrective message ships the trio as a paste-ready
8
+ snippet, agents paste a fresh local copy into each module that needs cleanup. Three
9
+ near-matching copies already span one codebase (a parser service, a categorizer, and a
10
+ test isolation helper), so a fix to one copy never reaches the others — the exact
11
+ "duplicated logic drifts" failure CODE_RULES.md section 3 (Reuse before create) names.
12
+
13
+ This hook scans Write/Edit content to a Python file for a `def` of any sanctioned
14
+ helper name and blocks it with a corrective message pointing to a single shared
15
+ force_rmtree utility. The canonical shared-helper home, the rmtree-blocker hook
16
+ sources (whose corrective strings embed the snippet), and test files are exempt.
17
+
18
+ This complements the same-directory `check_duplicate_function_body_across_files`
19
+ gate, which compares a written function only against `.py` siblings in its own
20
+ directory. That scope leaves a copy of this trio between two distant packages
21
+ unguarded, which is how the copies above spread. Keying on the sanctioned helper
22
+ names blocks the cross-directory copy the structural same-directory check cannot
23
+ reach.
24
+ """
25
+
26
+ import json
27
+ import sys
28
+ from pathlib import Path
29
+
30
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
31
+ if _hooks_dir not in sys.path:
32
+ sys.path.insert(0, _hooks_dir)
33
+
34
+ from hooks_constants.duplicate_rmtree_helper_blocker_constants import ( # noqa: E402
35
+ ALL_EXEMPT_PATH_FRAGMENTS,
36
+ ALL_EXEMPT_TEST_FILE_PREFIXES,
37
+ ALL_EXEMPT_TEST_FILE_SUFFIXES,
38
+ HELPER_DEFINITION_PATTERN,
39
+ PYTHON_FILE_EXTENSION,
40
+ TRIPLE_QUOTED_STRING_PATTERN,
41
+ )
42
+ from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin # noqa: E402
43
+
44
+
45
+ def payload_defines_sanctioned_helper(payload_text: str) -> bool:
46
+ """Return True when the text defines a sanctioned Windows-safe rmtree helper.
47
+
48
+ Args:
49
+ payload_text: The file content or new_string fragment under inspection.
50
+
51
+ Returns:
52
+ True when a line defines `_strip_read_only_and_retry`, `_force_remove_tree`,
53
+ or `force_rmtree`. Triple-quoted string bodies are masked before the
54
+ line-anchored pattern runs, so a `def` that begins its own line inside a
55
+ documentation snippet or multi-line string literal is left untouched. A
56
+ helper name inside a single-line quoted string carries a quote before `def`,
57
+ so the line-anchored pattern leaves it untouched as well.
58
+ """
59
+ if not payload_text:
60
+ return False
61
+ masked_text = TRIPLE_QUOTED_STRING_PATTERN.sub("", payload_text)
62
+ return bool(HELPER_DEFINITION_PATTERN.search(masked_text))
63
+
64
+
65
+ def path_is_exempt(file_path: str) -> bool:
66
+ """Return True when a Python path may carry the helper definition.
67
+
68
+ Args:
69
+ file_path: The target path the Write/Edit writes to.
70
+
71
+ Returns:
72
+ True when the path's basename is the canonical shared-helper home, an
73
+ rmtree-blocker hook source, one of the existing in-repo definition sites
74
+ (session_env_cleanup.py, _md_to_html_blocker_test_support.py,
75
+ teardown_worktrees.py), or a test file. A definition there is intentional.
76
+ Basename equality (not substring containment) prevents a sibling whose name
77
+ merely contains an exempt fragment from bypassing the block.
78
+ """
79
+ normalized_path = file_path.replace("\\", "/")
80
+ file_name = normalized_path.rsplit("/", 1)[-1]
81
+ if any(file_name.startswith(each_prefix) for each_prefix in ALL_EXEMPT_TEST_FILE_PREFIXES):
82
+ return True
83
+ if any(file_name.endswith(each_suffix) for each_suffix in ALL_EXEMPT_TEST_FILE_SUFFIXES):
84
+ return True
85
+ return file_name in ALL_EXEMPT_PATH_FRAGMENTS
86
+
87
+
88
+ def extract_payload_text(tool_name: str, tool_input: dict) -> tuple[str, str]:
89
+ """Return the (file_path, scanned_text) pair for a Write/Edit to a Python file.
90
+
91
+ Args:
92
+ tool_name: The PreToolUse tool name.
93
+ tool_input: The tool input dictionary.
94
+
95
+ Returns:
96
+ A pair of the target path and the text to scan. The text is empty for an
97
+ unrelated tool or a non-Python target, so the caller exits without blocking.
98
+ """
99
+ if tool_name not in {"Write", "Edit"}:
100
+ return "", ""
101
+ file_path = tool_input.get("file_path", "") or ""
102
+ if file_path and not file_path.endswith(PYTHON_FILE_EXTENSION):
103
+ return file_path, ""
104
+ scanned_text = tool_input.get("content", "") or tool_input.get("new_string", "") or ""
105
+ return file_path, scanned_text
106
+
107
+
108
+ def main() -> None:
109
+ corrective_message = (
110
+ "BLOCKED [duplicate-rmtree-helper]: this Write/Edit defines a local copy of "
111
+ "the Windows-safe rmtree helper trio (_strip_read_only_and_retry, "
112
+ "_force_remove_tree / force_rmtree). The trio is already implemented once; a "
113
+ "second copy drifts from the original — a fix lands in one copy and the other "
114
+ "keeps the bug (CODE_RULES.md section 3, Reuse before create).\n\n"
115
+ "Import the shared force_rmtree helper rather than pasting the trio:\n\n"
116
+ " from <shared_package>.windows_filesystem import force_rmtree\n"
117
+ " force_rmtree(staging_directory)\n\n"
118
+ "When no shared helper module exists yet, create ONE — a windows-filesystem "
119
+ "utility module the consuming packages can import — define the trio there once, "
120
+ "and import it from every call site. Do not paste the trio from the "
121
+ "windows_rmtree_blocker corrective message into each module.\n\n"
122
+ "See ~/.claude/rules/windows-filesystem-safe.md for the sanctioned pattern."
123
+ )
124
+ hook_input = read_hook_input_dictionary_from_stdin()
125
+ if hook_input is None:
126
+ sys.exit(0)
127
+
128
+ raw_tool_name = hook_input.get("tool_name", "")
129
+ raw_tool_input = hook_input.get("tool_input", {})
130
+ tool_name = raw_tool_name if isinstance(raw_tool_name, str) else ""
131
+ tool_input = raw_tool_input if isinstance(raw_tool_input, dict) else {}
132
+
133
+ file_path, scanned_text = extract_payload_text(tool_name, tool_input)
134
+
135
+ if not scanned_text:
136
+ sys.exit(0)
137
+ if path_is_exempt(file_path):
138
+ sys.exit(0)
139
+ if not payload_defines_sanctioned_helper(scanned_text):
140
+ sys.exit(0)
141
+
142
+ deny_response = {
143
+ "hookSpecificOutput": {
144
+ "hookEventName": "PreToolUse",
145
+ "permissionDecision": "deny",
146
+ "permissionDecisionReason": corrective_message,
147
+ }
148
+ }
149
+ print(json.dumps(deny_response))
150
+ sys.stdout.flush()
151
+ sys.exit(0)
152
+
153
+
154
+ if __name__ == "__main__":
155
+ main()
@@ -18,6 +18,7 @@ if _hooks_dir not in sys.path:
18
18
 
19
19
  from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
20
20
  from hooks_constants.messages import USER_FACING_NOTICE # noqa: E402
21
+ from hooks_constants.text_stripping import strip_code_and_quotes # noqa: E402
21
22
 
22
23
  PLUGIN_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
23
24
 
@@ -58,19 +59,6 @@ ALL_HEDGING_PATTERNS = [
58
59
  re.compile(pattern, re.IGNORECASE) for pattern in HEDGING_WORDS + HEDGING_PHRASES
59
60
  ]
60
61
 
61
- CODE_BLOCK_PATTERN = re.compile(r"```[\s\S]*?```", re.MULTILINE)
62
- INLINE_CODE_PATTERN = re.compile(r"`[^`]+`")
63
- QUOTED_BLOCK_PATTERN = re.compile(r"^>.*$", re.MULTILINE)
64
-
65
-
66
- def strip_code_and_quotes(text: str) -> str:
67
- """Remove code blocks, inline code, and blockquotes to avoid false positives."""
68
- text = CODE_BLOCK_PATTERN.sub("", text)
69
- text = INLINE_CODE_PATTERN.sub("", text)
70
- text = QUOTED_BLOCK_PATTERN.sub("", text)
71
- return text
72
-
73
-
74
62
  def find_hedging_words(text: str) -> list[str]:
75
63
  """Return all hedging words/phrases found in the text."""
76
64
  prose_text = strip_code_and_quotes(text)
@@ -20,21 +20,7 @@ if _hooks_dir not in sys.path:
20
20
 
21
21
  from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
22
22
  from hooks_constants.messages import USER_FACING_INTENT_ENDING_NOTICE # noqa: E402
23
-
24
-
25
- def strip_code_and_quotes(text: str) -> str:
26
- """Remove code blocks, inline code, and blockquotes to avoid false positives.
27
-
28
- Args:
29
- text: The raw assistant message to clean.
30
- """
31
- code_block_pattern = re.compile(r"```[\s\S]*?```", re.MULTILINE)
32
- inline_code_pattern = re.compile(r"`[^`]+`")
33
- quoted_block_pattern = re.compile(r"^>.*$", re.MULTILINE)
34
- text = code_block_pattern.sub("", text)
35
- text = inline_code_pattern.sub("", text)
36
- text = quoted_block_pattern.sub("", text)
37
- return text
23
+ from hooks_constants.text_stripping import strip_code_and_quotes # noqa: E402
38
24
 
39
25
 
40
26
  def extract_final_paragraph(text: str) -> str:
@@ -8,8 +8,8 @@ decision when any hook denied (carrying every denying reason) or exits zero to
8
8
  allow.
9
9
 
10
10
  The per-hook coverage matrix:
11
- - Write -> Group A (10 hooks) + Group B (7 hooks) = 17 hooks
12
- - Edit -> Group A (10 hooks) + Group B (7 hooks) = 17 hooks
11
+ - Write -> Group A (11 hooks) + Group B (7 hooks) = 18 hooks
12
+ - Edit -> Group A (11 hooks) + Group B (7 hooks) = 18 hooks
13
13
  - MultiEdit -> Group B only (7 hooks)
14
14
  """
15
15
 
@@ -40,6 +40,7 @@ from state_description_blocker import evaluate as evaluate_state_description #
40
40
  from hooks_constants.pre_tool_use_dispatcher_constants import ( # noqa: E402
41
41
  ALL_HOSTED_HOOK_ENTRIES,
42
42
  ALLOW_DECISION,
43
+ BLOCKING_CRASH_DENY_REASON,
43
44
  BLOCKING_CRASH_EXIT_CODE,
44
45
  DENY_DECISION,
45
46
  EXIT_CODE_TWO_DENY_REASON,
@@ -360,9 +361,7 @@ def aggregate_hosted_hook_results(
360
361
  parsed_output.deny_reason if parsed_output.deny_reason else EXIT_CODE_TWO_DENY_REASON
361
362
  )
362
363
  elif each_result.did_crash and each_result.is_blocking:
363
- all_deny_reasons.append(
364
- "[dispatcher] hook crash in blocking hook — write blocked for safety"
365
- )
364
+ all_deny_reasons.append(BLOCKING_CRASH_DENY_REASON)
366
365
  elif each_result.exit_code == BLOCKING_CRASH_EXIT_CODE and each_result.is_blocking:
367
366
  all_deny_reasons.append(EXIT_CODE_TWO_DENY_REASON)
368
367
  if parsed_output.is_allow:
@@ -21,17 +21,7 @@ if _hooks_dir not in sys.path:
21
21
 
22
22
  from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
23
23
  from hooks_constants.messages import USER_FACING_ASKUSERQUESTION_NOTICE # noqa: E402
24
-
25
-
26
- def strip_code_and_quotes(text: str) -> str:
27
- """Remove code blocks, inline code, and blockquotes to avoid false positives."""
28
- code_block_pattern = re.compile(r"```[\s\S]*?```", re.MULTILINE)
29
- inline_code_pattern = re.compile(r"`[^`]+`")
30
- quoted_block_pattern = re.compile(r"^>.*$", re.MULTILINE)
31
- text = code_block_pattern.sub("", text)
32
- text = inline_code_pattern.sub("", text)
33
- text = quoted_block_pattern.sub("", text)
34
- return text
24
+ from hooks_constants.text_stripping import strip_code_and_quotes # noqa: E402
35
25
 
36
26
 
37
27
  def extract_final_paragraph(text: str) -> str:
@@ -23,21 +23,7 @@ from hooks_constants.messages import USER_FACING_CONTEXT_REASSURANCE_NOTICE # n
23
23
  from hooks_constants.session_handoff_blocker_constants import ( # noqa: E402
24
24
  FIRST_PERSON_SUBJECT_PATTERN,
25
25
  )
26
-
27
-
28
- def strip_code_and_quotes(text: str) -> str:
29
- """Remove code blocks, inline code, and blockquotes to avoid false positives.
30
-
31
- Args:
32
- text: The raw assistant message to clean.
33
- """
34
- code_block_pattern = re.compile(r"```[\s\S]*?```", re.MULTILINE)
35
- inline_code_pattern = re.compile(r"`[^`]+`")
36
- quoted_block_pattern = re.compile(r"^>.*$", re.MULTILINE)
37
- text = code_block_pattern.sub("", text)
38
- text = inline_code_pattern.sub("", text)
39
- text = quoted_block_pattern.sub("", text)
40
- return text
26
+ from hooks_constants.text_stripping import strip_code_and_quotes # noqa: E402
41
27
 
42
28
 
43
29
  def split_into_sentences(text: str) -> list[str]:
@@ -342,6 +342,22 @@ def test_real_code_rules_violation_on_added_line_denies(tmp_path: Path) -> None:
342
342
  assert "Line " in reason
343
343
 
344
344
 
345
+ def test_committed_on_branch_violation_with_clean_working_tree_denies(tmp_path: Path) -> None:
346
+ repository_root = tmp_path / "repo"
347
+ repository_root.mkdir()
348
+ initialize_repository(repository_root)
349
+ run_git(repository_root, "checkout", "-b", "feature")
350
+ commit_file(repository_root, "committed.py", VIOLATING_MODULE_SOURCE, "commit violation")
351
+ status_output = run_git(repository_root, "status", "--porcelain")
352
+ assert status_output == "", "working tree must be clean so the deny comes from the committed line"
353
+ payload = write_agent_payload("code-verifier", "verify the change", repository_root)
354
+ result = run_hook(payload, repository_root)
355
+ assert not is_allow(result)
356
+ reason = deny_reason(result)
357
+ assert "committed.py" in reason
358
+ assert "Line " in reason
359
+
360
+
345
361
  def test_preexisting_violation_on_untouched_line_allows(tmp_path: Path) -> None:
346
362
  repository_root = tmp_path / "repo"
347
363
  repository_root.mkdir()
@@ -0,0 +1,328 @@
1
+ """Unit tests for duplicate_rmtree_helper_blocker PreToolUse hook."""
2
+
3
+ import importlib.util
4
+ import io
5
+ import json
6
+ import pathlib
7
+ import sys
8
+ from contextlib import redirect_stderr, redirect_stdout
9
+
10
+ _HOOK_DIR = pathlib.Path(__file__).parent
11
+ if str(_HOOK_DIR) not in sys.path:
12
+ sys.path.insert(0, str(_HOOK_DIR))
13
+ _HOOKS_ROOT = _HOOK_DIR.parent
14
+ if str(_HOOKS_ROOT) not in sys.path:
15
+ sys.path.insert(0, str(_HOOKS_ROOT))
16
+
17
+ hook_spec = importlib.util.spec_from_file_location(
18
+ "duplicate_rmtree_helper_blocker",
19
+ _HOOK_DIR / "duplicate_rmtree_helper_blocker.py",
20
+ )
21
+ assert hook_spec is not None
22
+ assert hook_spec.loader is not None
23
+ hook_module = importlib.util.module_from_spec(hook_spec)
24
+ hook_spec.loader.exec_module(hook_module)
25
+
26
+ payload_defines_sanctioned_helper = hook_module.payload_defines_sanctioned_helper
27
+ path_is_exempt = hook_module.path_is_exempt
28
+ extract_payload_text = hook_module.extract_payload_text
29
+
30
+ COPIED_TRIO = (
31
+ "def _strip_read_only_and_retry(removal_function, target_path, *_exc_info):\n"
32
+ " try:\n"
33
+ " os.chmod(target_path, stat.S_IWRITE)\n"
34
+ " removal_function(target_path)\n"
35
+ " except OSError:\n"
36
+ " pass\n\n\n"
37
+ "_rmtree_supports_onexc = 'onexc' in inspect.signature(shutil.rmtree).parameters\n\n\n"
38
+ "def _force_remove_tree(target_path):\n"
39
+ " if _rmtree_supports_onexc:\n"
40
+ " shutil.rmtree(target_path, onexc=_strip_read_only_and_retry)\n"
41
+ )
42
+
43
+
44
+ def test_detects_strip_read_only_definition() -> None:
45
+ assert payload_defines_sanctioned_helper(
46
+ "def _strip_read_only_and_retry(removal_function, target_path, *_exc_info):\n pass"
47
+ )
48
+
49
+
50
+ def test_detects_force_remove_tree_definition() -> None:
51
+ assert payload_defines_sanctioned_helper(
52
+ "def _force_remove_tree(target_path: Path) -> None:\n pass"
53
+ )
54
+
55
+
56
+ def test_detects_force_rmtree_definition() -> None:
57
+ assert payload_defines_sanctioned_helper(
58
+ "def force_rmtree(target_path: str) -> None:\n pass"
59
+ )
60
+
61
+
62
+ def test_detects_indented_method_definition() -> None:
63
+ assert payload_defines_sanctioned_helper(
64
+ "class FileTools:\n def _strip_read_only_and_retry(self, fn, path):\n pass"
65
+ )
66
+
67
+
68
+ def test_detects_copied_trio_block() -> None:
69
+ assert payload_defines_sanctioned_helper(COPIED_TRIO)
70
+
71
+
72
+ def test_allows_import_of_shared_helper() -> None:
73
+ assert not payload_defines_sanctioned_helper(
74
+ "from shared_utils.web_automation.utils.windows_filesystem import force_rmtree"
75
+ )
76
+
77
+
78
+ def test_allows_call_site_without_definition() -> None:
79
+ assert not payload_defines_sanctioned_helper("force_rmtree(staging_directory)")
80
+
81
+
82
+ def test_allows_helper_name_inside_string_literal() -> None:
83
+ corrective_message = (
84
+ ' " def _strip_read_only_and_retry(removal_function, target_path):\\n"'
85
+ )
86
+ assert not payload_defines_sanctioned_helper(corrective_message)
87
+
88
+
89
+ def test_allows_helper_definition_inside_triple_quoted_string() -> None:
90
+ documentation_snippet = (
91
+ 'EXAMPLE = """\\\n'
92
+ 'def _strip_read_only_and_retry(removal_function, target_path, *_exc_info):\n'
93
+ ' pass\n'
94
+ '"""\n'
95
+ )
96
+ assert not payload_defines_sanctioned_helper(documentation_snippet)
97
+
98
+
99
+ def test_allows_force_rmtree_definition_inside_triple_quoted_string() -> None:
100
+ documentation_snippet = (
101
+ "snippet = '''\n"
102
+ "def force_rmtree(target_path: str) -> None:\n"
103
+ " pass\n"
104
+ "'''\n"
105
+ )
106
+ assert not payload_defines_sanctioned_helper(documentation_snippet)
107
+
108
+
109
+ def test_detects_real_definition_following_triple_quoted_docstring() -> None:
110
+ module_text = (
111
+ '"""Module docstring."""\n'
112
+ 'def force_rmtree(target_path: str) -> None:\n'
113
+ ' pass\n'
114
+ )
115
+ assert payload_defines_sanctioned_helper(module_text)
116
+
117
+
118
+ def test_detects_real_definition_between_two_triple_quoted_strings() -> None:
119
+ module_text = (
120
+ '"""Leading docstring."""\n'
121
+ 'def _force_remove_tree(target_path: str) -> None:\n'
122
+ ' pass\n'
123
+ '"""Trailing docstring."""\n'
124
+ )
125
+ assert payload_defines_sanctioned_helper(module_text)
126
+
127
+
128
+ def test_allows_unrelated_definition() -> None:
129
+ assert not payload_defines_sanctioned_helper("def categorize_and_move(theme_folder):\n pass")
130
+
131
+
132
+ def test_path_exempts_blocker_source() -> None:
133
+ assert path_is_exempt("packages/x/hooks/blocking/windows_rmtree_blocker.py")
134
+ assert path_is_exempt("packages/x/hooks/blocking/duplicate_rmtree_helper_blocker.py")
135
+
136
+
137
+ def test_path_exempts_shared_helper_module() -> None:
138
+ assert path_is_exempt("shared_utils/web_automation/utils/windows_filesystem.py")
139
+
140
+
141
+ def test_path_exempts_existing_session_env_cleanup_definition_site() -> None:
142
+ assert path_is_exempt("packages/claude-dev-env/hooks/session/session_env_cleanup.py")
143
+
144
+
145
+ def test_path_exempts_existing_md_to_html_test_support_definition_site() -> None:
146
+ assert path_is_exempt(
147
+ "packages/claude-dev-env/hooks/blocking/_md_to_html_blocker_test_support.py"
148
+ )
149
+
150
+
151
+ def test_path_exempts_existing_teardown_worktrees_definition_site() -> None:
152
+ assert path_is_exempt(
153
+ "packages/claude-dev-env/skills/_shared/pr-loop/scripts/teardown_worktrees.py"
154
+ )
155
+
156
+
157
+ def test_main_allows_full_file_write_of_existing_definition_site() -> None:
158
+ stdout_text, exit_code = _run_hook(
159
+ {
160
+ "tool_name": "Write",
161
+ "tool_input": {
162
+ "file_path": "packages/claude-dev-env/hooks/session/session_env_cleanup.py",
163
+ "content": COPIED_TRIO,
164
+ },
165
+ }
166
+ )
167
+ assert exit_code == 0
168
+ assert stdout_text == ""
169
+
170
+
171
+ def test_path_exempts_test_file_prefix() -> None:
172
+ assert path_is_exempt("hooks/blocking/test_duplicate_rmtree_helper_blocker.py")
173
+
174
+
175
+ def test_path_exempts_test_file_suffix() -> None:
176
+ assert path_is_exempt("shared_utils/something_test.py")
177
+
178
+
179
+ def test_path_does_not_exempt_production_module() -> None:
180
+ assert not path_is_exempt(
181
+ "shared_utils/samsung_utils/cert_failure_processor/failure_categorizer.py"
182
+ )
183
+
184
+
185
+ def test_path_does_not_exempt_filename_containing_exempt_fragment() -> None:
186
+ assert not path_is_exempt("packages/x/not_windows_filesystem.py")
187
+ assert not path_is_exempt("packages/x/my_windows_filesystem.py")
188
+
189
+
190
+ def test_path_does_not_exempt_backslash_path_with_containing_fragment() -> None:
191
+ assert not path_is_exempt("packages\\x\\not_windows_filesystem.py")
192
+
193
+
194
+ def test_extract_payload_text_reads_write_content() -> None:
195
+ extracted = extract_payload_text("Write", {"file_path": "foo.py", "content": "abc"})
196
+ assert extracted == ("foo.py", "abc")
197
+
198
+
199
+ def test_extract_payload_text_reads_edit_new_string() -> None:
200
+ extracted = extract_payload_text("Edit", {"file_path": "foo.py", "new_string": "abc"})
201
+ assert extracted == ("foo.py", "abc")
202
+
203
+
204
+ def test_extract_payload_text_returns_empty_for_non_python_file() -> None:
205
+ extracted = extract_payload_text("Write", {"file_path": "notes.md", "content": COPIED_TRIO})
206
+ assert extracted == ("notes.md", "")
207
+
208
+
209
+ def test_extract_payload_text_returns_empty_for_unknown_tool() -> None:
210
+ extracted = extract_payload_text("Read", {"file_path": "foo.py"})
211
+ assert extracted == ("", "")
212
+
213
+
214
+ def _run_hook_with_stdin_text(stdin_text: str) -> tuple[str, str, int]:
215
+ captured_stdout = io.StringIO()
216
+ captured_stderr = io.StringIO()
217
+ exit_code = 0
218
+ sys.stdin = io.StringIO(stdin_text)
219
+ try:
220
+ with redirect_stdout(captured_stdout), redirect_stderr(captured_stderr):
221
+ try:
222
+ hook_module.main()
223
+ except SystemExit as exit_signal:
224
+ raw_exit_code = exit_signal.code
225
+ exit_code = raw_exit_code if isinstance(raw_exit_code, int) else 0
226
+ finally:
227
+ sys.stdin = sys.__stdin__
228
+ return captured_stdout.getvalue(), captured_stderr.getvalue(), exit_code
229
+
230
+
231
+ def _run_hook(hook_input: dict) -> tuple[str, int]:
232
+ stdout_text, _stderr_text, exit_code = _run_hook_with_stdin_text(json.dumps(hook_input))
233
+ return stdout_text, exit_code
234
+
235
+
236
+ def test_main_blocks_local_trio_copy_in_production_module() -> None:
237
+ stdout_text, exit_code = _run_hook(
238
+ {
239
+ "tool_name": "Write",
240
+ "tool_input": {
241
+ "file_path": (
242
+ "shared_utils/samsung_utils/cert_failure_processor/failure_categorizer.py"
243
+ ),
244
+ "content": COPIED_TRIO,
245
+ },
246
+ }
247
+ )
248
+ assert exit_code == 0
249
+ response_payload = json.loads(stdout_text)
250
+ decision_block = response_payload["hookSpecificOutput"]
251
+ assert decision_block["permissionDecision"] == "deny"
252
+ assert "duplicate-rmtree-helper" in decision_block["permissionDecisionReason"]
253
+
254
+
255
+ def test_main_allows_import_of_shared_helper() -> None:
256
+ stdout_text, exit_code = _run_hook(
257
+ {
258
+ "tool_name": "Write",
259
+ "tool_input": {
260
+ "file_path": "shared_utils/samsung_utils/cleanup.py",
261
+ "content": (
262
+ "from shared_utils.web_automation.utils.windows_filesystem import "
263
+ "force_rmtree\n\nforce_rmtree(path)\n"
264
+ ),
265
+ },
266
+ }
267
+ )
268
+ assert exit_code == 0
269
+ assert stdout_text == ""
270
+
271
+
272
+ def test_main_allows_definition_in_shared_helper_module() -> None:
273
+ stdout_text, exit_code = _run_hook(
274
+ {
275
+ "tool_name": "Write",
276
+ "tool_input": {
277
+ "file_path": "shared_utils/web_automation/utils/windows_filesystem.py",
278
+ "content": COPIED_TRIO,
279
+ },
280
+ }
281
+ )
282
+ assert exit_code == 0
283
+ assert stdout_text == ""
284
+
285
+
286
+ def test_main_allows_definition_in_test_file() -> None:
287
+ stdout_text, exit_code = _run_hook(
288
+ {
289
+ "tool_name": "Write",
290
+ "tool_input": {
291
+ "file_path": "shared_utils/test_windows_filesystem.py",
292
+ "content": COPIED_TRIO,
293
+ },
294
+ }
295
+ )
296
+ assert exit_code == 0
297
+ assert stdout_text == ""
298
+
299
+
300
+ def test_main_passes_through_non_python_file() -> None:
301
+ stdout_text, exit_code = _run_hook(
302
+ {
303
+ "tool_name": "Write",
304
+ "tool_input": {"file_path": "docs/cleanup.md", "content": COPIED_TRIO},
305
+ }
306
+ )
307
+ assert exit_code == 0
308
+ assert stdout_text == ""
309
+
310
+
311
+ def test_main_passes_through_unrelated_tool() -> None:
312
+ stdout_text, exit_code = _run_hook({"tool_name": "Read", "tool_input": {"file_path": "foo.py"}})
313
+ assert exit_code == 0
314
+ assert stdout_text == ""
315
+
316
+
317
+ def test_main_with_empty_stdin_exits_silently() -> None:
318
+ stdout_text, stderr_text, exit_code = _run_hook_with_stdin_text("")
319
+ assert exit_code == 0
320
+ assert stdout_text == ""
321
+ assert stderr_text == ""
322
+
323
+
324
+ def test_main_with_invalid_json_stdin_exits_silently() -> None:
325
+ stdout_text, stderr_text, exit_code = _run_hook_with_stdin_text("{broken")
326
+ assert exit_code == 0
327
+ assert stdout_text == ""
328
+ assert stderr_text == ""
@@ -17,6 +17,12 @@ if _HOOKS_ROOT not in sys.path:
17
17
  sys.path.insert(0, _HOOKS_ROOT)
18
18
  import hedging_language_blocker
19
19
  from hooks_constants.messages import USER_FACING_NOTICE
20
+ from hooks_constants.text_stripping import strip_code_and_quotes
21
+
22
+
23
+ def test_blocker_uses_shared_strip_code_and_quotes() -> None:
24
+ assert hedging_language_blocker.strip_code_and_quotes is strip_code_and_quotes
25
+
20
26
 
21
27
  RESEARCH_MODE_SKILL_BODY_MARKER = "Three anti-hallucination constraints are ALWAYS active."
22
28
  HEDGING_MESSAGE = "This is likely correct."
@@ -16,6 +16,11 @@ if _HOOKS_ROOT not in sys.path:
16
16
  sys.path.insert(0, _HOOKS_ROOT)
17
17
  import intent_only_ending_blocker
18
18
  from hooks_constants.messages import USER_FACING_INTENT_ENDING_NOTICE
19
+ from hooks_constants.text_stripping import strip_code_and_quotes
20
+
21
+ def test_blocker_uses_shared_strip_code_and_quotes() -> None:
22
+ assert intent_only_ending_blocker.strip_code_and_quotes is strip_code_and_quotes
23
+
19
24
 
20
25
  INTENT_ENDING_MESSAGE = "I'll now run the test suite and fix any failures that come up."
21
26
  NEXT_STEPS_MESSAGE = "Next steps:"