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.
- package/hooks/blocking/CLAUDE.md +1 -0
- package/hooks/blocking/code_verifier_spawn_preflight_gate.py +2 -1
- package/hooks/blocking/duplicate_rmtree_helper_blocker.py +155 -0
- package/hooks/blocking/hedging_language_blocker.py +1 -13
- package/hooks/blocking/intent_only_ending_blocker.py +1 -15
- package/hooks/blocking/pre_tool_use_dispatcher.py +4 -5
- package/hooks/blocking/question_to_user_enforcer.py +1 -11
- package/hooks/blocking/session_handoff_blocker.py +1 -15
- package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +16 -0
- package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +328 -0
- package/hooks/blocking/test_hedging_language_blocker.py +6 -0
- package/hooks/blocking/test_intent_only_ending_blocker.py +5 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +52 -5
- package/hooks/blocking/test_question_to_user_enforcer.py +6 -0
- package/hooks/blocking/test_session_handoff_blocker.py +6 -0
- package/hooks/hooks_constants/CLAUDE.md +4 -1
- package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +2 -1
- package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +27 -0
- package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +2 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +8 -2
- package/hooks/hooks_constants/test_post_tool_use_dispatcher_constants.py +43 -0
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +99 -0
- package/hooks/hooks_constants/test_text_stripping.py +39 -0
- package/hooks/hooks_constants/text_stripping.py +36 -0
- package/hooks/validation/CLAUDE.md +1 -0
- package/hooks/validation/post_tool_use_dispatcher.py +2 -2
- package/hooks/validation/test_mypy_validator.py +1 -1
- package/hooks/validation/test_post_tool_use_dispatcher.py +6 -0
- package/hooks/workflow/auto_formatter.py +8 -5
- package/hooks/workflow/test_auto_formatter.py +33 -0
- package/package.json +1 -1
- package/rules/windows-filesystem-safe.md +2 -0
- package/skills/autoconverge/SKILL.md +6 -3
- package/skills/autoconverge/reference/stop-conditions.md +7 -0
- package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +5 -4
- package/skills/autoconverge/workflow/converge.contract.test.mjs +308 -132
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +16 -16
- package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +36 -44
- package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +16 -24
- package/skills/autoconverge/workflow/converge.mjs +598 -606
- package/skills/autoconverge/workflow/convergence_summary.py +1 -1
- package/skills/autoconverge/workflow/render_report.py +2 -6
- package/skills/autoconverge/workflow/test_convergence_summary.py +17 -0
- package/skills/autoconverge/workflow/test_render_report.py +1 -0
package/hooks/blocking/CLAUDE.md
CHANGED
|
@@ -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
|
|
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 (
|
|
12
|
-
- Edit -> Group A (
|
|
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:"
|