claude-dev-env 1.73.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/CLAUDE.md +2 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
- package/hooks/blocking/CLAUDE.md +4 -0
- package/hooks/blocking/block_main_commit.py +14 -0
- package/hooks/blocking/bot_mention_comment_blocker.py +7 -0
- package/hooks/blocking/claude_md_orphan_file_blocker.py +14 -42
- package/hooks/blocking/code_rules_docstrings.py +223 -0
- package/hooks/blocking/code_rules_enforcer.py +16 -0
- package/hooks/blocking/code_verifier_spawn_preflight_gate.py +12 -5
- package/hooks/blocking/convergence_gate_blocker.py +17 -3
- package/hooks/blocking/destructive_command_blocker.py +7 -0
- package/hooks/blocking/docstring_rule_gate_count_blocker.py +321 -0
- package/hooks/blocking/duplicate_rmtree_helper_blocker.py +155 -0
- package/hooks/blocking/gh_body_arg_blocker.py +8 -0
- package/hooks/blocking/gh_pr_author_enforcer.py +7 -0
- package/hooks/blocking/hedging_language_blocker.py +17 -23
- package/hooks/blocking/hook_prose_detector_consistency.py +7 -0
- package/hooks/blocking/intent_only_ending_blocker.py +18 -26
- package/hooks/blocking/md_to_html_blocker.py +10 -2
- package/hooks/blocking/open_questions_in_plans_blocker.py +10 -2
- package/hooks/blocking/package_inventory_stale_blocker.py +398 -0
- package/hooks/blocking/plain_language_blocker.py +6 -0
- package/hooks/blocking/pr_converge_bugteam_enforcer.py +6 -0
- package/hooks/blocking/pr_description_enforcer.py +6 -0
- package/hooks/blocking/pre_tool_use_dispatcher.py +5 -6
- package/hooks/blocking/precommit_code_rules_gate.py +10 -1
- package/hooks/blocking/pytest_testpaths_orphan_blocker.py +8 -0
- package/hooks/blocking/question_to_user_enforcer.py +19 -23
- package/hooks/blocking/send_user_file_open_locally_blocker.py +70 -0
- package/hooks/blocking/sensitive_file_protector.py +15 -1
- package/hooks/blocking/session_handoff_blocker.py +15 -23
- package/hooks/blocking/state_description_blocker.py +6 -0
- package/hooks/blocking/subprocess_budget_completeness.py +9 -3
- package/hooks/blocking/tdd_enforcer.py +6 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_returns_plural_cardinality.py +207 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_unguarded_payload.py +188 -0
- package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +61 -0
- package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +203 -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_hook_block_logger_coverage.py +53 -0
- package/hooks/blocking/test_intent_only_ending_blocker.py +5 -0
- package/hooks/blocking/test_package_inventory_stale_blocker.py +329 -0
- package/hooks/blocking/test_plain_language_blocker.py +36 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +55 -8
- package/hooks/blocking/test_question_to_user_enforcer.py +6 -0
- package/hooks/blocking/test_send_user_file_open_locally_blocker.py +114 -0
- package/hooks/blocking/test_session_handoff_blocker.py +6 -0
- package/hooks/blocking/test_shared_stdin_adoption.py +42 -0
- package/hooks/blocking/test_state_description_blocker.py +41 -0
- package/hooks/blocking/test_verdict_directory_write_blocker.py +49 -0
- package/hooks/blocking/test_workflow_substitution_slot_blocker.py +4 -19
- package/hooks/blocking/verdict_directory_write_blocker.py +10 -1
- package/hooks/blocking/verified_commit_gate.py +11 -0
- package/hooks/blocking/verified_commit_message_accuracy_blocker.py +16 -1
- package/hooks/blocking/windows_rmtree_blocker.py +7 -0
- package/hooks/blocking/workflow_substitution_slot_blocker.py +10 -5
- package/hooks/blocking/write_existing_file_blocker.py +16 -1
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/CLAUDE.md +8 -1
- package/hooks/hooks_constants/blocking_check_limits.py +13 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +3 -0
- package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +2 -1
- package/hooks/hooks_constants/docstring_rule_gate_count_blocker_constants.py +90 -0
- package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +27 -0
- package/hooks/hooks_constants/hook_block_logger.py +59 -0
- package/hooks/hooks_constants/multi_edit_reconstruction.py +56 -0
- package/hooks/hooks_constants/package_inventory_stale_blocker_constants.py +111 -0
- package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +3 -2
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +17 -3
- package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +18 -0
- package/hooks/hooks_constants/test_dispatcher_constants_docstrings.py +44 -0
- package/hooks/hooks_constants/test_hook_block_logger.py +159 -0
- 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/lifecycle/config_change_guard.py +12 -0
- package/hooks/lifecycle/test_config_change_guard.py +23 -0
- package/hooks/validation/CLAUDE.md +1 -0
- package/hooks/validation/hook_format_validator.py +13 -0
- package/hooks/validation/mypy_validator.py +30 -1
- package/hooks/validation/post_tool_use_dispatcher.py +2 -2
- package/hooks/validation/test_hook_format_validator.py +64 -0
- package/hooks/validation/test_mypy_validator.py +23 -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/CLAUDE.md +1 -0
- package/rules/docstring-prose-matches-implementation.md +2 -1
- package/rules/package-inventory-stale-entry.md +24 -0
- package/rules/windows-filesystem-safe.md +2 -0
- package/skills/autoconverge/SKILL.md +21 -1
- 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 +398 -116
- 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 +599 -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
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Shared helpers that reconstruct the post-edit content of an Edit or MultiEdit.
|
|
2
|
+
|
|
3
|
+
Several PreToolUse blockers judge the content a write would leave on disk rather
|
|
4
|
+
than the raw payload fragment, so an edit on a line the blocker watches still
|
|
5
|
+
participates even when an untouched line elsewhere supplies the context. Both the
|
|
6
|
+
edit-replacement applier and the edit-list extractor are identical across those
|
|
7
|
+
blockers, so they live here once and are imported from each.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"apply_edits",
|
|
14
|
+
"edits_for_tool",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def apply_edits(existing_content: str, all_edits: list[dict]) -> str:
|
|
19
|
+
"""Return *existing_content* with each Edit/MultiEdit replacement applied in order.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
existing_content: The current on-disk file content.
|
|
23
|
+
all_edits: The Edit payload (as a single-element list) or MultiEdit
|
|
24
|
+
``edits`` list, each a mapping with an ``old_string`` and a
|
|
25
|
+
``new_string``.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
The content after replacing the first occurrence of each edit's
|
|
29
|
+
``old_string`` with its ``new_string``, in list order.
|
|
30
|
+
"""
|
|
31
|
+
edited_content = existing_content
|
|
32
|
+
for each_edit in all_edits:
|
|
33
|
+
if not isinstance(each_edit, dict):
|
|
34
|
+
continue
|
|
35
|
+
old_string = each_edit.get("old_string", "")
|
|
36
|
+
new_string = each_edit.get("new_string", "")
|
|
37
|
+
if isinstance(old_string, str) and isinstance(new_string, str) and old_string:
|
|
38
|
+
edited_content = edited_content.replace(old_string, new_string, 1)
|
|
39
|
+
return edited_content
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def edits_for_tool(tool_name: str, tool_input: dict) -> list[dict]:
|
|
43
|
+
"""Return the edit mappings an Edit or MultiEdit payload carries.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
tool_name: The intercepted tool — ``Edit`` or ``MultiEdit``.
|
|
47
|
+
tool_input: The tool's input payload.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
A single-element list holding the Edit payload, or the MultiEdit
|
|
51
|
+
``edits`` list when it is present as a list; an empty list otherwise.
|
|
52
|
+
"""
|
|
53
|
+
if tool_name == "Edit":
|
|
54
|
+
return [tool_input]
|
|
55
|
+
all_edits = tool_input.get("edits", [])
|
|
56
|
+
return all_edits if isinstance(all_edits, list) else []
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Constants for the package-inventory stale-entry blocker.
|
|
2
|
+
|
|
3
|
+
A package directory documents its own files in a sibling inventory document —
|
|
4
|
+
a ``README.md`` Layout table or a ``CLAUDE.md`` "Key files" list — whose entries
|
|
5
|
+
name each file in backticks. When a new production code file lands in that
|
|
6
|
+
directory and the inventory carries no entry naming it, the inventory disagrees
|
|
7
|
+
with the directory on the package's file set, and a reader trusting the
|
|
8
|
+
inventory to map the directory misses the new file. This module holds the
|
|
9
|
+
inventory document names, the production code extensions that earn an inventory
|
|
10
|
+
entry, the backtick pattern that finds an inventory's named files, the code-fence
|
|
11
|
+
pattern that marks lines to skip, the glob-metacharacter pattern that rejects
|
|
12
|
+
pattern tokens, the non-filename pattern that rejects command-example and
|
|
13
|
+
path-bearing prose spans, the minimum inventory size that marks a document as a
|
|
14
|
+
maintained inventory, the filenames exempt from an entry, the scan budget, and
|
|
15
|
+
the block-message text the hook emits.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"ALL_INVENTORY_DOCUMENT_NAMES",
|
|
22
|
+
"ALL_PRODUCTION_CODE_EXTENSIONS",
|
|
23
|
+
"PYTHON_FILE_EXTENSION",
|
|
24
|
+
"ALL_TEST_FILE_MARKERS",
|
|
25
|
+
"BACKTICK_TOKEN_PATTERN",
|
|
26
|
+
"CODE_FENCE_PATTERN",
|
|
27
|
+
"GLOB_METACHARACTER_PATTERN",
|
|
28
|
+
"NON_FILENAME_TOKEN_PATTERN",
|
|
29
|
+
"MINIMUM_INVENTORY_ENTRY_COUNT",
|
|
30
|
+
"ALL_EXEMPT_BASENAMES",
|
|
31
|
+
"ALL_EXEMPT_DIRECTORY_NAMES",
|
|
32
|
+
"MAX_INVENTORY_FILE_BYTES",
|
|
33
|
+
"STALE_INVENTORY_MESSAGE_TEMPLATE",
|
|
34
|
+
"STALE_INVENTORY_SYSTEM_MESSAGE",
|
|
35
|
+
"STALE_INVENTORY_ADDITIONAL_CONTEXT",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
ALL_INVENTORY_DOCUMENT_NAMES: frozenset[str] = frozenset({"README.md", "CLAUDE.md"})
|
|
39
|
+
|
|
40
|
+
PYTHON_FILE_EXTENSION: str = ".py"
|
|
41
|
+
|
|
42
|
+
ALL_TEST_FILE_MARKERS: tuple[str, ...] = (".spec.", ".test.")
|
|
43
|
+
|
|
44
|
+
ALL_PRODUCTION_CODE_EXTENSIONS: frozenset[str] = frozenset(
|
|
45
|
+
{
|
|
46
|
+
".py",
|
|
47
|
+
".mjs",
|
|
48
|
+
".js",
|
|
49
|
+
".ts",
|
|
50
|
+
".ps1",
|
|
51
|
+
".sh",
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
BACKTICK_TOKEN_PATTERN: re.Pattern[str] = re.compile(r"`([^`]+)`")
|
|
56
|
+
|
|
57
|
+
CODE_FENCE_PATTERN: re.Pattern[str] = re.compile(r"^\s*(?:```|~~~)")
|
|
58
|
+
|
|
59
|
+
GLOB_METACHARACTER_PATTERN: re.Pattern[str] = re.compile(r"[*?{}\[\]]")
|
|
60
|
+
|
|
61
|
+
NON_FILENAME_TOKEN_PATTERN: re.Pattern[str] = re.compile(r"[\s:$<>]")
|
|
62
|
+
|
|
63
|
+
MINIMUM_INVENTORY_ENTRY_COUNT: int = 2
|
|
64
|
+
|
|
65
|
+
ALL_EXEMPT_BASENAMES: frozenset[str] = frozenset(
|
|
66
|
+
{
|
|
67
|
+
"__init__.py",
|
|
68
|
+
"conftest.py",
|
|
69
|
+
"setup.py",
|
|
70
|
+
"_path_setup.py",
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
ALL_EXEMPT_DIRECTORY_NAMES: frozenset[str] = frozenset(
|
|
75
|
+
{
|
|
76
|
+
"config",
|
|
77
|
+
"tests",
|
|
78
|
+
"__pycache__",
|
|
79
|
+
".git",
|
|
80
|
+
"node_modules",
|
|
81
|
+
".pytest_cache",
|
|
82
|
+
".ruff_cache",
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
MAX_INVENTORY_FILE_BYTES: int = 200_000
|
|
87
|
+
|
|
88
|
+
STALE_INVENTORY_MESSAGE_TEMPLATE: str = (
|
|
89
|
+
"New production file `{filename}` lands in {directory}, whose inventory "
|
|
90
|
+
"document(s) ({inventories}) name {entry_count} sibling files but no entry "
|
|
91
|
+
"for `{filename}`. A package inventory names every production file in its "
|
|
92
|
+
"directory; a new file the inventory omits leaves the inventory and the "
|
|
93
|
+
"directory disagreeing on the package's file set. Add an entry naming "
|
|
94
|
+
"`{filename}` to the inventory in this same change."
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
STALE_INVENTORY_SYSTEM_MESSAGE: str = (
|
|
98
|
+
"New production file is absent from its package inventory (README.md / "
|
|
99
|
+
"CLAUDE.md) - add the inventory entry in this same change"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
STALE_INVENTORY_ADDITIONAL_CONTEXT: str = (
|
|
103
|
+
"A package directory whose README.md or CLAUDE.md lists its files in "
|
|
104
|
+
"backticks is a maintained inventory of the package's file set. A new "
|
|
105
|
+
"production code file (.py, .mjs, .js, .ts, .ps1, .sh) in that directory "
|
|
106
|
+
"carries one inventory entry naming it. Add a row to the README.md table or "
|
|
107
|
+
"a bullet to the CLAUDE.md list naming this file, describing what it does, "
|
|
108
|
+
"in the same change that creates the file. Exempt files (no entry needed): "
|
|
109
|
+
"__init__.py, conftest.py, setup.py, _path_setup.py, files under config/ or "
|
|
110
|
+
"tests/, and test files (test_*.py, *_test.py, *.spec.*, *.test.*)."
|
|
111
|
+
)
|
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Holds the ordered hosted-hook list with each hook's extra command-line
|
|
4
4
|
arguments and blocking flag, the PostToolUse block-decision string and key,
|
|
5
|
-
and the hook-event name. The dispatcher imports these
|
|
6
|
-
inline in the dispatcher script.
|
|
5
|
+
and the hook-event name. The dispatcher imports each of these by name.
|
|
7
6
|
"""
|
|
8
7
|
|
|
9
8
|
from __future__ import annotations
|
|
@@ -16,6 +15,7 @@ __all__ = [
|
|
|
16
15
|
"REASON_KEY",
|
|
17
16
|
"HOOK_EVENT_NAME",
|
|
18
17
|
"EMPTY_REASON_BLOCK_FALLBACK",
|
|
18
|
+
"BLOCKING_CRASH_DENY_REASON",
|
|
19
19
|
"PLUGIN_ROOT_PLACEHOLDER",
|
|
20
20
|
"PostHostedHookEntry",
|
|
21
21
|
"ALL_POST_HOSTED_HOOK_ENTRIES",
|
|
@@ -26,6 +26,7 @@ DECISION_KEY = "decision"
|
|
|
26
26
|
REASON_KEY = "reason"
|
|
27
27
|
HOOK_EVENT_NAME = "PostToolUse"
|
|
28
28
|
EMPTY_REASON_BLOCK_FALLBACK = "[dispatcher] hook blocked with no reason — write blocked"
|
|
29
|
+
BLOCKING_CRASH_DENY_REASON = "[dispatcher] hook crash in blocking hook — write blocked for safety"
|
|
29
30
|
|
|
30
31
|
PLUGIN_ROOT_PLACEHOLDER = "${CLAUDE_PLUGIN_ROOT}"
|
|
31
32
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Holds the ordered hosted-hook list with per-hook applicable-tool sets, the
|
|
4
4
|
special exit codes, the deny decision string, and the hook-event name. The
|
|
5
|
-
dispatcher imports
|
|
5
|
+
dispatcher imports each of these by name.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
@@ -15,6 +15,7 @@ __all__ = [
|
|
|
15
15
|
"HOOK_EVENT_NAME",
|
|
16
16
|
"BLOCKING_CRASH_EXIT_CODE",
|
|
17
17
|
"EXIT_CODE_TWO_DENY_REASON",
|
|
18
|
+
"BLOCKING_CRASH_DENY_REASON",
|
|
18
19
|
"WRITE_TOOL_NAME",
|
|
19
20
|
"EDIT_TOOL_NAME",
|
|
20
21
|
"MULTI_EDIT_TOOL_NAME",
|
|
@@ -31,6 +32,7 @@ ALLOW_DECISION = "allow"
|
|
|
31
32
|
HOOK_EVENT_NAME = "PreToolUse"
|
|
32
33
|
BLOCKING_CRASH_EXIT_CODE = 2
|
|
33
34
|
EXIT_CODE_TWO_DENY_REASON = "[dispatcher] hook denied via exit code 2 — write blocked"
|
|
35
|
+
BLOCKING_CRASH_DENY_REASON = "[dispatcher] hook crash in blocking hook — write blocked for safety"
|
|
34
36
|
|
|
35
37
|
WRITE_TOOL_NAME = "Write"
|
|
36
38
|
EDIT_TOOL_NAME = "Edit"
|
|
@@ -59,8 +61,8 @@ class HostedHookEntry:
|
|
|
59
61
|
native_module_name: The importable module name whose evaluate function
|
|
60
62
|
the dispatcher calls in-process for this hook, or None when the hook
|
|
61
63
|
runs via runpy under __main__. The named module exposes a function
|
|
62
|
-
named
|
|
63
|
-
|
|
64
|
+
named `evaluate` taking the payload dict and returning a deny-reason
|
|
65
|
+
string or None.
|
|
64
66
|
"""
|
|
65
67
|
|
|
66
68
|
script_relative_path: str
|
|
@@ -94,6 +96,10 @@ ALL_HOSTED_HOOK_ENTRIES: tuple[HostedHookEntry, ...] = (
|
|
|
94
96
|
script_relative_path="blocking/windows_rmtree_blocker.py",
|
|
95
97
|
applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
|
|
96
98
|
),
|
|
99
|
+
HostedHookEntry(
|
|
100
|
+
script_relative_path="blocking/duplicate_rmtree_helper_blocker.py",
|
|
101
|
+
applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
|
|
102
|
+
),
|
|
97
103
|
HostedHookEntry(
|
|
98
104
|
script_relative_path="blocking/state_description_blocker.py",
|
|
99
105
|
applicable_tool_names=ALL_WRITE_AND_EDIT_TOOL_NAMES,
|
|
@@ -119,6 +125,10 @@ ALL_HOSTED_HOOK_ENTRIES: tuple[HostedHookEntry, ...] = (
|
|
|
119
125
|
script_relative_path="blocking/claude_md_orphan_file_blocker.py",
|
|
120
126
|
applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
|
|
121
127
|
),
|
|
128
|
+
HostedHookEntry(
|
|
129
|
+
script_relative_path="blocking/package_inventory_stale_blocker.py",
|
|
130
|
+
applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
|
|
131
|
+
),
|
|
122
132
|
HostedHookEntry(
|
|
123
133
|
script_relative_path="blocking/pytest_testpaths_orphan_blocker.py",
|
|
124
134
|
applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
|
|
@@ -127,6 +137,10 @@ ALL_HOSTED_HOOK_ENTRIES: tuple[HostedHookEntry, ...] = (
|
|
|
127
137
|
script_relative_path="blocking/open_questions_in_plans_blocker.py",
|
|
128
138
|
applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
|
|
129
139
|
),
|
|
140
|
+
HostedHookEntry(
|
|
141
|
+
script_relative_path="blocking/docstring_rule_gate_count_blocker.py",
|
|
142
|
+
applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
|
|
143
|
+
),
|
|
130
144
|
HostedHookEntry(
|
|
131
145
|
script_relative_path="blocking/plain_language_blocker.py",
|
|
132
146
|
applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_TOOL_NAMES,
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Configuration constants for the send_user_file_open_locally_blocker PreToolUse hook."""
|
|
2
|
+
|
|
3
|
+
TOOL_NAME: str = "SendUserFile"
|
|
4
|
+
|
|
5
|
+
PROACTIVE_STATUS: str = "proactive"
|
|
6
|
+
|
|
7
|
+
CORRECTIVE_MESSAGE: str = (
|
|
8
|
+
"BLOCKED [open-locally]: SendUserFile attaches a file to the session, which "
|
|
9
|
+
"does not let the user see it while they are at the terminal. Open the file on "
|
|
10
|
+
"screen in its own viewer:\n"
|
|
11
|
+
" Start-Process pwsh -WindowStyle Hidden -ArgumentList "
|
|
12
|
+
"'-NoProfile','-File',\"$HOME\\.claude\\scripts\\Show-Asset.ps1\","
|
|
13
|
+
"'<path 1>','<path 2>'\n"
|
|
14
|
+
"Show-Asset.ps1 sizes each image window to the image and opens every other file "
|
|
15
|
+
"type in its default app. Pass every path the user named.\n"
|
|
16
|
+
"The one allowed attach is a phone push: when the user has stepped away and you "
|
|
17
|
+
'want the file to reach their phone, call SendUserFile with status "proactive".'
|
|
18
|
+
)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Regression tests proving the dispatcher constants docstrings clear the O6 gate.
|
|
2
|
+
|
|
3
|
+
The PreToolUse and PostToolUse dispatcher constants modules each carry a module
|
|
4
|
+
docstring stating what the module centralizes. Neither docstring may assert that
|
|
5
|
+
no literals appear inline in its companion dispatcher script — that completeness
|
|
6
|
+
claim about a sibling file is the deterministic Category O6 drift the enforcer's
|
|
7
|
+
check_docstring_no_inline_literal_claim blocks. These tests load the real
|
|
8
|
+
modules' source and assert the check returns no issues for either.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import importlib.util
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from types import ModuleType
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _load_enforcer_module() -> ModuleType:
|
|
19
|
+
enforcer_path = Path(__file__).resolve().parent.parent / "blocking" / "code_rules_enforcer.py"
|
|
20
|
+
enforcer_spec = importlib.util.spec_from_file_location("code_rules_enforcer", enforcer_path)
|
|
21
|
+
assert enforcer_spec is not None
|
|
22
|
+
assert enforcer_spec.loader is not None
|
|
23
|
+
enforcer_module = importlib.util.module_from_spec(enforcer_spec)
|
|
24
|
+
enforcer_spec.loader.exec_module(enforcer_module)
|
|
25
|
+
return enforcer_module
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
code_rules_enforcer = _load_enforcer_module()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _check_issues_for(module_filename: str) -> list[str]:
|
|
32
|
+
module_path = Path(__file__).resolve().parent / module_filename
|
|
33
|
+
module_source = module_path.read_text(encoding="utf-8")
|
|
34
|
+
return code_rules_enforcer.check_docstring_no_inline_literal_claim(
|
|
35
|
+
module_source, str(module_path)
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_pre_tool_use_dispatcher_constants_docstring_clears_o6_gate() -> None:
|
|
40
|
+
assert _check_issues_for("pre_tool_use_dispatcher_constants.py") == []
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_post_tool_use_dispatcher_constants_docstring_clears_o6_gate() -> None:
|
|
44
|
+
assert _check_issues_for("post_tool_use_dispatcher_constants.py") == []
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Tests for the shared hook block logger."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
import json
|
|
7
|
+
import stat
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from unittest.mock import patch
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
15
|
+
|
|
16
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_log_hook_block_writes_parseable_json_line(tmp_path: Path) -> None:
|
|
20
|
+
with patch.object(Path, "home", return_value=tmp_path):
|
|
21
|
+
log_hook_block(
|
|
22
|
+
calling_hook_name="test_hook.py",
|
|
23
|
+
hook_event="PreToolUse",
|
|
24
|
+
block_reason="test block reason",
|
|
25
|
+
tool_name="Bash",
|
|
26
|
+
offending_input_preview="echo hello",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
log_path = tmp_path / ".claude" / "logs" / "hook-blocks.log"
|
|
30
|
+
assert log_path.exists()
|
|
31
|
+
line = log_path.read_text(encoding="utf-8").strip()
|
|
32
|
+
parsed = json.loads(line)
|
|
33
|
+
|
|
34
|
+
assert "timestamp" in parsed
|
|
35
|
+
assert parsed["hook"] == "test_hook.py"
|
|
36
|
+
assert parsed["event"] == "PreToolUse"
|
|
37
|
+
assert parsed["tool"] == "Bash"
|
|
38
|
+
assert parsed["reason"] == "test block reason"
|
|
39
|
+
assert parsed["preview"] == "echo hello"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_log_hook_block_creates_logs_directory(tmp_path: Path) -> None:
|
|
43
|
+
with patch.object(Path, "home", return_value=tmp_path):
|
|
44
|
+
log_hook_block(
|
|
45
|
+
calling_hook_name="some_hook.py",
|
|
46
|
+
hook_event="Stop",
|
|
47
|
+
block_reason="stop reason",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
log_path = tmp_path / ".claude" / "logs" / "hook-blocks.log"
|
|
51
|
+
assert log_path.exists()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_log_hook_block_omits_none_fields(tmp_path: Path) -> None:
|
|
55
|
+
with patch.object(Path, "home", return_value=tmp_path):
|
|
56
|
+
log_hook_block(
|
|
57
|
+
calling_hook_name="minimal_hook.py",
|
|
58
|
+
hook_event="PreToolUse",
|
|
59
|
+
block_reason="some reason",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
log_path = tmp_path / ".claude" / "logs" / "hook-blocks.log"
|
|
63
|
+
line = log_path.read_text(encoding="utf-8").strip()
|
|
64
|
+
parsed = json.loads(line)
|
|
65
|
+
assert "tool" not in parsed
|
|
66
|
+
assert "preview" not in parsed
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_log_hook_block_appends_multiple_records(tmp_path: Path) -> None:
|
|
70
|
+
with patch.object(Path, "home", return_value=tmp_path):
|
|
71
|
+
log_hook_block(
|
|
72
|
+
calling_hook_name="hook_a.py",
|
|
73
|
+
hook_event="PreToolUse",
|
|
74
|
+
block_reason="first",
|
|
75
|
+
)
|
|
76
|
+
log_hook_block(
|
|
77
|
+
calling_hook_name="hook_b.py",
|
|
78
|
+
hook_event="Stop",
|
|
79
|
+
block_reason="second",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
log_path = tmp_path / ".claude" / "logs" / "hook-blocks.log"
|
|
83
|
+
all_lines = log_path.read_text(encoding="utf-8").strip().splitlines()
|
|
84
|
+
assert len(all_lines) == 2
|
|
85
|
+
first_parsed = json.loads(all_lines[0])
|
|
86
|
+
second_parsed = json.loads(all_lines[1])
|
|
87
|
+
assert first_parsed["hook"] == "hook_a.py"
|
|
88
|
+
assert second_parsed["hook"] == "hook_b.py"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_log_hook_block_swallows_io_error_on_unwritable_log(tmp_path: Path) -> None:
|
|
92
|
+
logs_dir = tmp_path / ".claude" / "logs"
|
|
93
|
+
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
log_path = logs_dir / "hook-blocks.log"
|
|
95
|
+
log_path.write_text("", encoding="utf-8")
|
|
96
|
+
log_path.chmod(stat.S_IREAD)
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
with patch.object(Path, "home", return_value=tmp_path):
|
|
100
|
+
log_hook_block(
|
|
101
|
+
calling_hook_name="any_hook.py",
|
|
102
|
+
hook_event="PreToolUse",
|
|
103
|
+
block_reason="reason",
|
|
104
|
+
)
|
|
105
|
+
except OSError:
|
|
106
|
+
pytest.fail("log_hook_block raised OSError on unwritable log file")
|
|
107
|
+
finally:
|
|
108
|
+
log_path.chmod(stat.S_IREAD | stat.S_IWRITE)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_log_hook_block_swallows_runtime_error_when_home_unresolvable() -> None:
|
|
112
|
+
def raise_home_resolution_failure() -> Path:
|
|
113
|
+
raise RuntimeError("Could not determine home directory.")
|
|
114
|
+
|
|
115
|
+
with patch.object(Path, "home", side_effect=raise_home_resolution_failure):
|
|
116
|
+
try:
|
|
117
|
+
returned_nothing = log_hook_block(
|
|
118
|
+
calling_hook_name="any_hook.py",
|
|
119
|
+
hook_event="PreToolUse",
|
|
120
|
+
block_reason="reason",
|
|
121
|
+
)
|
|
122
|
+
except RuntimeError:
|
|
123
|
+
pytest.fail("log_hook_block raised RuntimeError when home was unresolvable")
|
|
124
|
+
|
|
125
|
+
assert returned_nothing is None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_log_hook_block_truncates_long_preview(tmp_path: Path) -> None:
|
|
129
|
+
long_input = "x" * 600
|
|
130
|
+
|
|
131
|
+
with patch.object(Path, "home", return_value=tmp_path):
|
|
132
|
+
log_hook_block(
|
|
133
|
+
calling_hook_name="hook.py",
|
|
134
|
+
hook_event="PreToolUse",
|
|
135
|
+
block_reason="reason",
|
|
136
|
+
offending_input_preview=long_input,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
log_path = tmp_path / ".claude" / "logs" / "hook-blocks.log"
|
|
140
|
+
line = log_path.read_text(encoding="utf-8").strip()
|
|
141
|
+
parsed = json.loads(line)
|
|
142
|
+
assert len(parsed["preview"]) <= 500
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_log_hook_block_timestamp_is_iso8601(tmp_path: Path) -> None:
|
|
146
|
+
before = datetime.datetime.now()
|
|
147
|
+
with patch.object(Path, "home", return_value=tmp_path):
|
|
148
|
+
log_hook_block(
|
|
149
|
+
calling_hook_name="ts_hook.py",
|
|
150
|
+
hook_event="PreToolUse",
|
|
151
|
+
block_reason="ts test",
|
|
152
|
+
)
|
|
153
|
+
after = datetime.datetime.now()
|
|
154
|
+
|
|
155
|
+
log_path = tmp_path / ".claude" / "logs" / "hook-blocks.log"
|
|
156
|
+
line = log_path.read_text(encoding="utf-8").strip()
|
|
157
|
+
parsed = json.loads(line)
|
|
158
|
+
parsed_timestamp = datetime.datetime.fromisoformat(parsed["timestamp"])
|
|
159
|
+
assert before <= parsed_timestamp <= after
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Tests for the PostToolUse dispatcher constants module."""
|
|
2
|
+
|
|
3
|
+
import pathlib
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
_HOOKS_ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
7
|
+
if str(_HOOKS_ROOT) not in sys.path:
|
|
8
|
+
sys.path.insert(0, str(_HOOKS_ROOT))
|
|
9
|
+
|
|
10
|
+
_VALIDATION_DIR = _HOOKS_ROOT / "validation"
|
|
11
|
+
if str(_VALIDATION_DIR) not in sys.path:
|
|
12
|
+
sys.path.insert(0, str(_VALIDATION_DIR))
|
|
13
|
+
|
|
14
|
+
from post_tool_use_dispatcher import (
|
|
15
|
+
PostHostedHookResult,
|
|
16
|
+
aggregate_post_hosted_hook_results,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from hooks_constants.post_tool_use_dispatcher_constants import (
|
|
20
|
+
BLOCKING_CRASH_DENY_REASON,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_blocking_hook_crash_block_reason_surfaces_the_constant() -> None:
|
|
25
|
+
crash_result = PostHostedHookResult(
|
|
26
|
+
captured_stdout="",
|
|
27
|
+
did_crash=True,
|
|
28
|
+
is_blocking=True,
|
|
29
|
+
)
|
|
30
|
+
decision = aggregate_post_hosted_hook_results([crash_result])
|
|
31
|
+
assert decision.should_block
|
|
32
|
+
assert BLOCKING_CRASH_DENY_REASON in decision.all_block_reasons
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_non_blocking_hook_crash_does_not_surface_the_constant() -> None:
|
|
36
|
+
crash_result = PostHostedHookResult(
|
|
37
|
+
captured_stdout="",
|
|
38
|
+
did_crash=True,
|
|
39
|
+
is_blocking=False,
|
|
40
|
+
)
|
|
41
|
+
decision = aggregate_post_hosted_hook_results([crash_result])
|
|
42
|
+
assert not decision.should_block
|
|
43
|
+
assert BLOCKING_CRASH_DENY_REASON not in decision.all_block_reasons
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Tests for the PreToolUse dispatcher hosted-hook roster."""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import pathlib
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
_HOOKS_ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
8
|
+
if str(_HOOKS_ROOT) not in sys.path:
|
|
9
|
+
sys.path.insert(0, str(_HOOKS_ROOT))
|
|
10
|
+
|
|
11
|
+
_BLOCKING_DIR = _HOOKS_ROOT / "blocking"
|
|
12
|
+
if str(_BLOCKING_DIR) not in sys.path:
|
|
13
|
+
sys.path.insert(0, str(_BLOCKING_DIR))
|
|
14
|
+
|
|
15
|
+
from hooks_constants.pre_tool_use_dispatcher_constants import (
|
|
16
|
+
ALL_HOSTED_HOOK_ENTRIES,
|
|
17
|
+
ALL_WRITE_AND_EDIT_TOOL_NAMES,
|
|
18
|
+
BLOCKING_CRASH_DENY_REASON,
|
|
19
|
+
EDIT_TOOL_NAME,
|
|
20
|
+
WRITE_TOOL_NAME,
|
|
21
|
+
)
|
|
22
|
+
from pre_tool_use_dispatcher import (
|
|
23
|
+
HostedHookResult,
|
|
24
|
+
aggregate_hosted_hook_results,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _entry_for(script_relative_path: str):
|
|
29
|
+
matching_entries = [
|
|
30
|
+
each_entry
|
|
31
|
+
for each_entry in ALL_HOSTED_HOOK_ENTRIES
|
|
32
|
+
if each_entry.script_relative_path == script_relative_path
|
|
33
|
+
]
|
|
34
|
+
return matching_entries[0] if matching_entries else None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_roster_includes_duplicate_rmtree_helper_blocker_script_path() -> None:
|
|
38
|
+
all_registered_script_paths = [
|
|
39
|
+
each_entry.script_relative_path for each_entry in ALL_HOSTED_HOOK_ENTRIES
|
|
40
|
+
]
|
|
41
|
+
assert "blocking/duplicate_rmtree_helper_blocker.py" in all_registered_script_paths, (
|
|
42
|
+
"duplicate_rmtree_helper_blocker must be hosted by the dispatcher so a local "
|
|
43
|
+
"re-definition of the Windows-safe rmtree helper trio is blocked at Write time"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_duplicate_rmtree_helper_blocker_applies_to_write_and_edit() -> None:
|
|
48
|
+
entry = _entry_for("blocking/duplicate_rmtree_helper_blocker.py")
|
|
49
|
+
assert entry is not None
|
|
50
|
+
assert WRITE_TOOL_NAME in entry.applicable_tool_names
|
|
51
|
+
assert EDIT_TOOL_NAME in entry.applicable_tool_names
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_duplicate_rmtree_helper_blocker_is_blocking() -> None:
|
|
55
|
+
entry = _entry_for("blocking/duplicate_rmtree_helper_blocker.py")
|
|
56
|
+
assert entry is not None
|
|
57
|
+
assert entry.is_blocking is True
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_duplicate_rmtree_helper_blocker_runs_via_runpy() -> None:
|
|
61
|
+
entry = _entry_for("blocking/duplicate_rmtree_helper_blocker.py")
|
|
62
|
+
assert entry is not None
|
|
63
|
+
assert entry.native_module_name is None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_windows_rmtree_blocker_still_registered() -> None:
|
|
67
|
+
entry = _entry_for("blocking/windows_rmtree_blocker.py")
|
|
68
|
+
assert entry is not None
|
|
69
|
+
assert entry.applicable_tool_names == ALL_WRITE_AND_EDIT_TOOL_NAMES
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_blocking_hook_crash_deny_reason_surfaces_the_constant() -> None:
|
|
73
|
+
crash_result = HostedHookResult(
|
|
74
|
+
exit_code=0,
|
|
75
|
+
captured_stdout="",
|
|
76
|
+
did_crash=True,
|
|
77
|
+
is_blocking=True,
|
|
78
|
+
)
|
|
79
|
+
decision = aggregate_hosted_hook_results([crash_result])
|
|
80
|
+
assert decision.should_deny
|
|
81
|
+
assert BLOCKING_CRASH_DENY_REASON in decision.all_deny_reasons
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_every_native_module_exposes_a_callable_evaluate() -> None:
|
|
85
|
+
nativized_entries = [
|
|
86
|
+
each_entry
|
|
87
|
+
for each_entry in ALL_HOSTED_HOOK_ENTRIES
|
|
88
|
+
if each_entry.native_module_name is not None
|
|
89
|
+
]
|
|
90
|
+
assert nativized_entries, (
|
|
91
|
+
"the roster must carry at least one nativized hook for this test to lock the contract"
|
|
92
|
+
)
|
|
93
|
+
for each_entry in nativized_entries:
|
|
94
|
+
native_module = importlib.import_module(each_entry.native_module_name)
|
|
95
|
+
evaluate_function = getattr(native_module, "evaluate", None)
|
|
96
|
+
assert callable(evaluate_function), (
|
|
97
|
+
f"{each_entry.native_module_name} must expose a callable named evaluate, "
|
|
98
|
+
"matching the native_module_name docstring contract"
|
|
99
|
+
)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Tests for the shared strip_code_and_quotes helper."""
|
|
2
|
+
|
|
3
|
+
import pathlib
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
_HOOKS_ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
7
|
+
if str(_HOOKS_ROOT) not in sys.path:
|
|
8
|
+
sys.path.insert(0, str(_HOOKS_ROOT))
|
|
9
|
+
|
|
10
|
+
from hooks_constants.text_stripping import strip_code_and_quotes
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_removes_fenced_code_block() -> None:
|
|
14
|
+
text = "before\n```python\nshould I run this?\n```\nafter"
|
|
15
|
+
stripped = strip_code_and_quotes(text)
|
|
16
|
+
assert "should I run this?" not in stripped
|
|
17
|
+
assert "before" in stripped
|
|
18
|
+
assert "after" in stripped
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_removes_inline_code_span() -> None:
|
|
22
|
+
text = "the function `would you like` is named oddly"
|
|
23
|
+
stripped = strip_code_and_quotes(text)
|
|
24
|
+
assert "would you like" not in stripped
|
|
25
|
+
assert "the function" in stripped
|
|
26
|
+
assert "is named oddly" in stripped
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_removes_leading_blockquote_lines() -> None:
|
|
30
|
+
text = "real line\n> should I proceed?\nfinal line"
|
|
31
|
+
stripped = strip_code_and_quotes(text)
|
|
32
|
+
assert "should I proceed?" not in stripped
|
|
33
|
+
assert "real line" in stripped
|
|
34
|
+
assert "final line" in stripped
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_leaves_plain_prose_unchanged() -> None:
|
|
38
|
+
text = "This sentence carries no code or quotes."
|
|
39
|
+
assert strip_code_and_quotes(text) == text
|