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
|
@@ -26,6 +26,7 @@ if _HOOKS_DIR not in sys.path:
|
|
|
26
26
|
|
|
27
27
|
from hooks_constants.pre_tool_use_dispatcher_constants import ( # noqa: E402, I001
|
|
28
28
|
ALL_HOSTED_HOOK_ENTRIES,
|
|
29
|
+
BLOCKING_CRASH_DENY_REASON,
|
|
29
30
|
BLOCKING_CRASH_EXIT_CODE,
|
|
30
31
|
DENY_DECISION,
|
|
31
32
|
EDIT_TOOL_NAME,
|
|
@@ -485,6 +486,52 @@ def test_aggregate_exit_code_two_signals_deny() -> None:
|
|
|
485
486
|
)
|
|
486
487
|
|
|
487
488
|
|
|
489
|
+
def test_aggregate_blocking_hook_crash_surfaces_a_deny() -> None:
|
|
490
|
+
"""A crash in a blocking hook surfaces a deny with the crash reason.
|
|
491
|
+
|
|
492
|
+
When a blocking hook raises a non-SystemExit exception before emitting any
|
|
493
|
+
output, the aggregator must still deny so a bad write does not silently
|
|
494
|
+
pass. The deny reason must be the BLOCKING_CRASH_DENY_REASON constant.
|
|
495
|
+
"""
|
|
496
|
+
all_results = [
|
|
497
|
+
HostedHookResult(
|
|
498
|
+
exit_code=0,
|
|
499
|
+
captured_stdout="",
|
|
500
|
+
did_crash=True,
|
|
501
|
+
is_blocking=True,
|
|
502
|
+
)
|
|
503
|
+
]
|
|
504
|
+
decision = aggregate_hosted_hook_results(all_results)
|
|
505
|
+
assert decision.should_deny, "a blocking hook crash must surface a deny"
|
|
506
|
+
assert decision.all_deny_reasons, (
|
|
507
|
+
"the deny reasons list must be non-empty after a blocking hook crash"
|
|
508
|
+
)
|
|
509
|
+
assert BLOCKING_CRASH_DENY_REASON in decision.all_deny_reasons, (
|
|
510
|
+
"the deny reason from a blocking hook crash must be BLOCKING_CRASH_DENY_REASON.\n"
|
|
511
|
+
f"Got: {decision.all_deny_reasons!r}"
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def test_aggregate_non_blocking_hook_crash_does_not_deny() -> None:
|
|
516
|
+
"""A crash in a non-blocking hook does not change an allow to a deny.
|
|
517
|
+
|
|
518
|
+
A hosted hook carrying is_blocking=False must not surface a deny when it
|
|
519
|
+
crashes — the aggregated decision stays allow.
|
|
520
|
+
"""
|
|
521
|
+
all_results = [
|
|
522
|
+
HostedHookResult(
|
|
523
|
+
exit_code=0,
|
|
524
|
+
captured_stdout="",
|
|
525
|
+
did_crash=True,
|
|
526
|
+
is_blocking=False,
|
|
527
|
+
)
|
|
528
|
+
]
|
|
529
|
+
decision = aggregate_hosted_hook_results(all_results)
|
|
530
|
+
assert not decision.should_deny, (
|
|
531
|
+
"a non-blocking hook crash must not change an allow to a deny"
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
|
|
488
535
|
def test_aggregate_exit_code_zero_with_no_output_allows() -> None:
|
|
489
536
|
"""A HostedHookResult with exit_code 0 and empty stdout signals allow.
|
|
490
537
|
|
|
@@ -585,16 +632,16 @@ def test_dispatcher_write_applies_both_groups() -> None:
|
|
|
585
632
|
assert "blocking/plain_language_blocker.py" in all_write_script_paths, (
|
|
586
633
|
"plain_language_blocker (Group B) must be in Write applicable set"
|
|
587
634
|
)
|
|
588
|
-
assert len(all_write_entries) ==
|
|
589
|
-
f"Write tool must apply to all
|
|
635
|
+
assert len(all_write_entries) == 18, (
|
|
636
|
+
f"Write tool must apply to all 18 hosted hooks, got {len(all_write_entries)}"
|
|
590
637
|
)
|
|
591
638
|
|
|
592
639
|
|
|
593
640
|
def test_dispatcher_edit_applies_both_groups() -> None:
|
|
594
641
|
"""Edit tool triggers both Group A and Group B hooks through the dispatcher."""
|
|
595
642
|
all_edit_entries = _applicable_entries_for_tool(EDIT_TOOL_NAME)
|
|
596
|
-
assert len(all_edit_entries) ==
|
|
597
|
-
f"Edit tool must apply to all
|
|
643
|
+
assert len(all_edit_entries) == 18, (
|
|
644
|
+
f"Edit tool must apply to all 18 hosted hooks, got {len(all_edit_entries)}"
|
|
598
645
|
)
|
|
599
646
|
|
|
600
647
|
|
|
@@ -613,7 +660,7 @@ def test_proceed_after_run_all_validators_removal_allows() -> None:
|
|
|
613
660
|
it was never a PreToolUse hook and never hosted by the PreToolUse dispatcher.
|
|
614
661
|
A Python Write payload that run_all_validators would have flagged (mypy errors, for
|
|
615
662
|
instance) still produces ALLOW from the PreToolUse dispatcher because the PreToolUse
|
|
616
|
-
dispatcher covers only its
|
|
663
|
+
dispatcher covers only its 18 hosted blocking hooks — none of which includes the
|
|
617
664
|
validators runner.
|
|
618
665
|
"""
|
|
619
666
|
python_content_with_type_error = (
|
|
@@ -14,7 +14,9 @@ if _HOOKS_DIR not in sys.path:
|
|
|
14
14
|
sys.path.insert(0, _HOOKS_DIR)
|
|
15
15
|
if _HOOKS_ROOT not in sys.path:
|
|
16
16
|
sys.path.insert(0, _HOOKS_ROOT)
|
|
17
|
+
import question_to_user_enforcer
|
|
17
18
|
from hooks_constants.messages import USER_FACING_ASKUSERQUESTION_NOTICE
|
|
19
|
+
from hooks_constants.text_stripping import strip_code_and_quotes
|
|
18
20
|
|
|
19
21
|
CLEAN_DECLARATIVE_MESSAGE = "I applied the rename across both files. The tests pass."
|
|
20
22
|
TRAILING_QUESTION_MESSAGE = (
|
|
@@ -68,6 +70,10 @@ def run_hook_with_message(assistant_message: str) -> subprocess.CompletedProcess
|
|
|
68
70
|
return run_hook_with_payload({"last_assistant_message": assistant_message})
|
|
69
71
|
|
|
70
72
|
|
|
73
|
+
def test_blocker_uses_shared_strip_code_and_quotes() -> None:
|
|
74
|
+
assert question_to_user_enforcer.strip_code_and_quotes is strip_code_and_quotes
|
|
75
|
+
|
|
76
|
+
|
|
71
77
|
def test_clean_declarative_message_passes_through():
|
|
72
78
|
completed_process = run_hook_with_message(CLEAN_DECLARATIVE_MESSAGE)
|
|
73
79
|
assert completed_process.returncode == 0
|
|
@@ -16,6 +16,12 @@ if _HOOKS_ROOT not in sys.path:
|
|
|
16
16
|
sys.path.insert(0, _HOOKS_ROOT)
|
|
17
17
|
import session_handoff_blocker
|
|
18
18
|
from hooks_constants.messages import USER_FACING_CONTEXT_REASSURANCE_NOTICE
|
|
19
|
+
from hooks_constants.text_stripping import strip_code_and_quotes
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_blocker_uses_shared_strip_code_and_quotes() -> None:
|
|
23
|
+
assert session_handoff_blocker.strip_code_and_quotes is strip_code_and_quotes
|
|
24
|
+
|
|
19
25
|
|
|
20
26
|
NEW_SESSION_PROPOSAL_MESSAGE = (
|
|
21
27
|
"I recommend we continue this in a fresh session to keep things manageable."
|
|
@@ -24,9 +24,11 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
|
|
|
24
24
|
| `doc_gist_auto_publish_constants.py` | Sentinel marker and URL patterns for the doc-gist auto-publish hook |
|
|
25
25
|
| `docstring_rule_gate_count_blocker_constants.py` | Target rule basename, spelled-out-number lookup, count-clause and `check_*` validator patterns, and block-message text for the docstring-rule gate-count staleness blocker |
|
|
26
26
|
| `duplicate_function_body_constants.py` | Hashing and comparison config for the duplicate-body check |
|
|
27
|
+
| `duplicate_rmtree_helper_blocker_constants.py` | Sanctioned Windows-safe rmtree helper names, the definition pattern, and the exempt-path set for the duplicate-rmtree-helper blocker |
|
|
27
28
|
| `dynamic_stderr_handler.py` | `DynamicStderrHandler` — a logging handler that resolves `sys.stderr` at emit time (for testability) |
|
|
28
29
|
| `gh_pr_author_swap_constants.py` | Constants for the PR-author swap enforcement hooks |
|
|
29
30
|
| `hardcoded_user_path_constants.py` | Patterns for detecting hardcoded home-directory paths |
|
|
31
|
+
| `hook_block_logger.py` | `log_hook_block()` — shared fail-safe logger every blocking hook calls to append a JSON record of each block decision to `~/.claude/logs/hook-blocks.log` |
|
|
30
32
|
| `hook_log_extractor_constants.py` | Neon table name, offset state file path, timeouts, and outcome-type mapping for the hook-log extractor |
|
|
31
33
|
| `hook_prose_detector_consistency_constants.py` | Trigger patterns and corrective messages for the hook-prose consistency checker |
|
|
32
34
|
| `html_companion_constants.py` | Blocked URL schemes and other config for the `.md`-to-`.html` companion hook |
|
|
@@ -55,6 +57,7 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
|
|
|
55
57
|
| `stuttering_import_binding_constants.py` | Import-binding patterns for the stuttering check |
|
|
56
58
|
| `subprocess_budget_completeness_constants.py` | Required argument names for the subprocess-budget completeness check |
|
|
57
59
|
| `sys_path_insert_constants.py` | Patterns for detecting unguarded `sys.path.insert` calls |
|
|
60
|
+
| `text_stripping.py` | `strip_code_and_quotes()` — shared helper that removes fenced code blocks, inline code, and blockquotes from prose, imported by the Stop-hook prose blockers |
|
|
58
61
|
| `unused_module_import_constants.py` | Patterns for detecting unused module-level imports |
|
|
59
62
|
| `windows_rmtree_blocker_constants.py` | The unsafe `shutil.rmtree` pattern and the safe replacement pattern |
|
|
60
63
|
| `workflow_substitution_slot_blocker_constants.py` | Per-iteration token patterns for the workflow-slot blocker |
|
|
@@ -64,4 +67,4 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
|
|
|
64
67
|
- Every file in this package is a pure constants module — no side effects, no I/O.
|
|
65
68
|
- Hooks import from this package with `from hooks_constants.<module> import <CONSTANT>`.
|
|
66
69
|
- Tests for these modules live beside them as `test_<module>.py`. Run with `python -m pytest hooks_constants/test_<name>.py`.
|
|
67
|
-
- `dynamic_stderr_handler.py` and `
|
|
70
|
+
- `dynamic_stderr_handler.py`, `pre_tool_use_stdin.py`, `multi_edit_reconstruction.py`, and `text_stripping.py` are utility modules (not pure constants) but live here because they are shared across many hooks.
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
The gate denies an ``Agent`` spawn whose ``subagent_type`` is ``code-verifier``
|
|
4
4
|
when the branch carries a merge conflict against its base ref or a CODE_RULES
|
|
5
|
-
violation on a line added in the
|
|
5
|
+
violation on a line added in the working tree since the merge base (committed on
|
|
6
|
+
the branch or uncommitted). It runs two
|
|
6
7
|
pre-flight checks before the expensive verification spawn and addresses its
|
|
7
8
|
deny reason to the spawning agent so that agent fixes the named issues and
|
|
8
9
|
re-spawns. Every literal the hook body reads lives here; the hook imports
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Configuration constants for the duplicate_rmtree_helper_blocker PreToolUse hook."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
PYTHON_FILE_EXTENSION: str = ".py"
|
|
6
|
+
|
|
7
|
+
HELPER_DEFINITION_PATTERN: re.Pattern[str] = re.compile(
|
|
8
|
+
r"^[ \t]*def[ \t]+(?:_strip_read_only_and_retry|_force_remove_tree|force_rmtree)[ \t]*\(",
|
|
9
|
+
re.MULTILINE,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
TRIPLE_QUOTED_STRING_PATTERN: re.Pattern[str] = re.compile(
|
|
13
|
+
r'"""[\s\S]*?"""|\'\'\'[\s\S]*?\'\'\'',
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
ALL_EXEMPT_PATH_FRAGMENTS: tuple[str, ...] = (
|
|
17
|
+
"windows_rmtree_blocker.py",
|
|
18
|
+
"duplicate_rmtree_helper_blocker.py",
|
|
19
|
+
"windows_safe_rmtree.py",
|
|
20
|
+
"windows_filesystem.py",
|
|
21
|
+
"session_env_cleanup.py",
|
|
22
|
+
"_md_to_html_blocker_test_support.py",
|
|
23
|
+
"teardown_worktrees.py",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
ALL_EXEMPT_TEST_FILE_PREFIXES: tuple[str, ...] = ("test_",)
|
|
27
|
+
ALL_EXEMPT_TEST_FILE_SUFFIXES: tuple[str, ...] = ("_test.py",)
|
|
@@ -15,6 +15,7 @@ __all__ = [
|
|
|
15
15
|
"REASON_KEY",
|
|
16
16
|
"HOOK_EVENT_NAME",
|
|
17
17
|
"EMPTY_REASON_BLOCK_FALLBACK",
|
|
18
|
+
"BLOCKING_CRASH_DENY_REASON",
|
|
18
19
|
"PLUGIN_ROOT_PLACEHOLDER",
|
|
19
20
|
"PostHostedHookEntry",
|
|
20
21
|
"ALL_POST_HOSTED_HOOK_ENTRIES",
|
|
@@ -25,6 +26,7 @@ DECISION_KEY = "decision"
|
|
|
25
26
|
REASON_KEY = "reason"
|
|
26
27
|
HOOK_EVENT_NAME = "PostToolUse"
|
|
27
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"
|
|
28
30
|
|
|
29
31
|
PLUGIN_ROOT_PLACEHOLDER = "${CLAUDE_PLUGIN_ROOT}"
|
|
30
32
|
|
|
@@ -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,
|
|
@@ -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
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Shared text-stripping helper for the Stop-hook prose blockers.
|
|
2
|
+
|
|
3
|
+
Several Stop hooks judge the prose of an assistant message and must ignore
|
|
4
|
+
fenced code blocks, inline code spans, and leading blockquotes so a phrase that
|
|
5
|
+
appears only inside code or a quote never trips the detector. The stripping
|
|
6
|
+
logic is identical across those blockers, so it lives here once and is imported
|
|
7
|
+
from each.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"strip_code_and_quotes",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
CODE_BLOCK_PATTERN = re.compile(r"```[\s\S]*?```", re.MULTILINE)
|
|
19
|
+
INLINE_CODE_PATTERN = re.compile(r"`[^`]+`")
|
|
20
|
+
QUOTED_BLOCK_PATTERN = re.compile(r"^>.*$", re.MULTILINE)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def strip_code_and_quotes(text: str) -> str:
|
|
24
|
+
"""Remove fenced code blocks, inline code, and blockquotes from prose.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
text: The raw assistant message to clean of code and quoted lines.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
The text with every fenced code block, inline code span, and leading
|
|
31
|
+
blockquote line removed, so only the prose a reader sees remains.
|
|
32
|
+
"""
|
|
33
|
+
text = CODE_BLOCK_PATTERN.sub("", text)
|
|
34
|
+
text = INLINE_CODE_PATTERN.sub("", text)
|
|
35
|
+
text = QUOTED_BLOCK_PATTERN.sub("", text)
|
|
36
|
+
return text
|
|
@@ -9,6 +9,7 @@ PostToolUse hooks that validate code quality after Claude writes or edits a file
|
|
|
9
9
|
| `mypy_validator.py` | PostToolUse (Write/Edit on `.py` files) | Runs mypy on the written file and blocks (via PostToolUse block decision) when type errors are found — catches missing attributes, wrong signatures, type mismatches, and import errors |
|
|
10
10
|
| `hook_format_validator.py` | PostToolUse | Validates that a hook script's output JSON matches the expected Claude Code hook-output schema |
|
|
11
11
|
| `test_mypy_validator.py` | — | Tests for `mypy_validator.py` |
|
|
12
|
+
| `test_hook_format_validator.py` | — | Tests for `hook_format_validator.py` |
|
|
12
13
|
|
|
13
14
|
## Conventions
|
|
14
15
|
|
|
@@ -33,6 +33,7 @@ if _hooks_directory not in sys.path:
|
|
|
33
33
|
from hooks_constants.post_tool_use_dispatcher_constants import ( # noqa: E402
|
|
34
34
|
ALL_POST_HOSTED_HOOK_ENTRIES,
|
|
35
35
|
BLOCK_DECISION,
|
|
36
|
+
BLOCKING_CRASH_DENY_REASON,
|
|
36
37
|
DECISION_KEY,
|
|
37
38
|
EMPTY_REASON_BLOCK_FALLBACK,
|
|
38
39
|
HOOK_EVENT_NAME,
|
|
@@ -197,7 +198,6 @@ def aggregate_post_hosted_hook_results(
|
|
|
197
198
|
A PostDispatcherDecision with the aggregated allow-or-block signal,
|
|
198
199
|
all block reasons, and all non-block stdout.
|
|
199
200
|
"""
|
|
200
|
-
blocking_crash_reason = "[dispatcher] hook crash in blocking hook — write blocked for safety"
|
|
201
201
|
all_block_reasons: list[str] = []
|
|
202
202
|
all_non_block_stdout: list[str] = []
|
|
203
203
|
|
|
@@ -206,7 +206,7 @@ def aggregate_post_hosted_hook_results(
|
|
|
206
206
|
if is_block:
|
|
207
207
|
all_block_reasons.append(block_reason if block_reason else EMPTY_REASON_BLOCK_FALLBACK)
|
|
208
208
|
elif each_result.did_crash and each_result.is_blocking:
|
|
209
|
-
all_block_reasons.append(
|
|
209
|
+
all_block_reasons.append(BLOCKING_CRASH_DENY_REASON)
|
|
210
210
|
else:
|
|
211
211
|
non_block_text = each_result.captured_stdout.strip()
|
|
212
212
|
if non_block_text:
|
|
@@ -94,7 +94,7 @@ def test_build_mypy_command_includes_config_file_when_present(tmp_path: Path) ->
|
|
|
94
94
|
assert command[-1] == "package/module.py"
|
|
95
95
|
|
|
96
96
|
|
|
97
|
-
def test_build_mypy_command_omits_config_file_when_absent(
|
|
97
|
+
def test_build_mypy_command_omits_config_file_when_absent() -> None:
|
|
98
98
|
validator = _load_validator()
|
|
99
99
|
|
|
100
100
|
command = validator.build_mypy_command("package/module.py", None)
|
|
@@ -39,6 +39,7 @@ from hooks_constants.doc_gist_auto_publish_constants import ( # noqa: E402, I00
|
|
|
39
39
|
from hooks_constants.post_tool_use_dispatcher_constants import ( # noqa: E402, I001
|
|
40
40
|
ALL_POST_HOSTED_HOOK_ENTRIES,
|
|
41
41
|
BLOCK_DECISION,
|
|
42
|
+
BLOCKING_CRASH_DENY_REASON,
|
|
42
43
|
EMPTY_REASON_BLOCK_FALLBACK,
|
|
43
44
|
PLUGIN_ROOT_PLACEHOLDER,
|
|
44
45
|
PostHostedHookEntry,
|
|
@@ -608,3 +609,8 @@ def test_blocking_hook_crash_surfaces_a_block() -> None:
|
|
|
608
609
|
"The block reason from a blocking hook crash must reference the dispatcher.\n"
|
|
609
610
|
f"Got: {aggregated_decision.all_block_reasons[0]!r}"
|
|
610
611
|
)
|
|
612
|
+
assert BLOCKING_CRASH_DENY_REASON in aggregated_decision.all_block_reasons, (
|
|
613
|
+
"The block reason from a blocking hook crash must be the "
|
|
614
|
+
"BLOCKING_CRASH_DENY_REASON constant.\n"
|
|
615
|
+
f"Got: {aggregated_decision.all_block_reasons!r}"
|
|
616
|
+
)
|
|
@@ -79,11 +79,14 @@ def has_prettier_config(file_path: str) -> bool:
|
|
|
79
79
|
def budgeted_python_format_seconds() -> int:
|
|
80
80
|
"""Return the wall-clock budget for the two-subprocess happy path.
|
|
81
81
|
|
|
82
|
-
The
|
|
83
|
-
|
|
84
|
-
is
|
|
85
|
-
|
|
86
|
-
|
|
82
|
+
The fix loop breaks on the first command that runs — whether it returns zero
|
|
83
|
+
or non-zero — or on a timeout, and continues to the next command only when a
|
|
84
|
+
command is missing (FileNotFoundError). The format loop breaks only on a
|
|
85
|
+
returncode of zero or on a timeout, and continues on a non-zero return or a
|
|
86
|
+
missing command. The common case spends one fix subprocess plus one format
|
|
87
|
+
subprocess. This is a budget for that assumed path, not a guaranteed upper
|
|
88
|
+
bound: when commands are missing or time out the loops can spend more than
|
|
89
|
+
this budget.
|
|
87
90
|
"""
|
|
88
91
|
fix_phase_seconds = PYTHON_FORMAT_TIMEOUT_SECONDS
|
|
89
92
|
format_phase_seconds = PYTHON_FORMAT_TIMEOUT_SECONDS
|
|
@@ -105,6 +105,39 @@ class TestRuffFixOnNewFiles:
|
|
|
105
105
|
assert completed_hook.returncode == 0
|
|
106
106
|
assert "import os" in edited_file.read_text(encoding="utf-8")
|
|
107
107
|
|
|
108
|
+
def should_leave_tracked_python_file_arriving_through_write_untouched(
|
|
109
|
+
self, git_repository: Path
|
|
110
|
+
) -> None:
|
|
111
|
+
tracked_file = git_repository / "tracked.py"
|
|
112
|
+
tracked_file.write_text(UNUSED_IMPORT_SOURCE, encoding="utf-8")
|
|
113
|
+
subprocess.run(
|
|
114
|
+
["git", "add", "tracked.py"],
|
|
115
|
+
cwd=git_repository,
|
|
116
|
+
capture_output=True,
|
|
117
|
+
check=True,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
completed_hook = _run_hook("Write", tracked_file)
|
|
121
|
+
|
|
122
|
+
assert completed_hook.returncode == 0
|
|
123
|
+
assert "import os" in tracked_file.read_text(encoding="utf-8")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_tracked_write_leaves_unused_import_in_place(git_repository: Path) -> None:
|
|
127
|
+
tracked_file = git_repository / "tracked_module.py"
|
|
128
|
+
tracked_file.write_text(UNUSED_IMPORT_SOURCE, encoding="utf-8")
|
|
129
|
+
subprocess.run(
|
|
130
|
+
["git", "add", "tracked_module.py"],
|
|
131
|
+
cwd=git_repository,
|
|
132
|
+
capture_output=True,
|
|
133
|
+
check=True,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
completed_hook = _run_hook("Write", tracked_file)
|
|
137
|
+
|
|
138
|
+
assert completed_hook.returncode == 0
|
|
139
|
+
assert "import os" in tracked_file.read_text(encoding="utf-8")
|
|
140
|
+
|
|
108
141
|
|
|
109
142
|
def _load_auto_formatter_module() -> object:
|
|
110
143
|
module_spec = importlib.util.spec_from_file_location("auto_formatter", HOOK_SCRIPT_PATH)
|
package/package.json
CHANGED
|
@@ -5,3 +5,5 @@ Never call `shutil.rmtree` with `ignore_errors=True` — Windows `ReadOnly` file
|
|
|
5
5
|
In Node, call `mkdirSync(targetPath, { recursive: true })` on possibly-existing paths — `ReadOnly` directories break the non-recursive form. When the call must be non-recursive, strip the attribute first (`(Get-Item $path -Force).Attributes = "Directory"` / `os.chmod(path, stat.S_IWRITE)`).
|
|
6
6
|
|
|
7
7
|
The `windows_rmtree_blocker.py` PreToolUse hook (Write/Edit/Bash) blocks the unsafe rmtree pattern and returns the full `force_rmtree` safe-pattern code.
|
|
8
|
+
|
|
9
|
+
Define the safe handler trio (`_strip_read_only_and_retry`, `_force_remove_tree` / `force_rmtree`, and the `inspect.signature` onexc/onerror guard) once in a shared Windows-filesystem utility module, and import it from every call site. A second local copy drifts from the first — a fix lands in one and the other keeps the bug (CODE_RULES.md section 3, Reuse before create). The `duplicate_rmtree_helper_blocker.py` PreToolUse hook (Write/Edit) blocks a local re-definition of any trio member outside the shared home and points the writer at the import. This complements the same-directory `check_duplicate_function_body_across_files` gate, which a copy between two distant packages slips past.
|
|
@@ -266,9 +266,12 @@ describes the narrowest rm auto-allow path — a standalone Bash call whose targ
|
|
|
266
266
|
resolves inside the ephemeral namespace (`/tmp`, `/temp`, the OS temp root, or the
|
|
267
267
|
run worktree) — and a compound path that accepts an rm joined with benign
|
|
268
268
|
reporting segments when every rm target is an absolute ephemeral path. Both of
|
|
269
|
-
those paths fail closed on `$(...)` substitution
|
|
270
|
-
|
|
271
|
-
|
|
269
|
+
those paths fail closed on `$(...)` substitution and backtick subshells. The
|
|
270
|
+
compound path also fails closed on any `$` in the target — including
|
|
271
|
+
`$CLAUDE_JOB_DIR`. The standalone path declines a `$`-bearing target only when
|
|
272
|
+
the literal path is not already under an ephemeral root, so it does not by
|
|
273
|
+
itself stop a `$VAR` that expands inside an ephemeral root. A third, broad path
|
|
274
|
+
matches only when the command itself declares an
|
|
272
275
|
ephemeral working directory (it `cd`s into one, or runs under one): that
|
|
273
276
|
cwd-scoped path resolves the target against the declared cwd, fails closed on
|
|
274
277
|
`$(...)`, backticks, and unknown variables, and resolves the known temporary
|
|
@@ -31,6 +31,13 @@ skill still runs teardown (revoke permissions, final report).
|
|
|
31
31
|
cannot confirm the PR left draft state (`gh pr ready` errored, or the draft
|
|
32
32
|
re-query still reports true). The workflow does not report `converged: true`;
|
|
33
33
|
the run ends with a `blocker` naming the failed ready transition.
|
|
34
|
+
- **Clean-audit post blocked** — every review lens is clean on HEAD, but the
|
|
35
|
+
CLEAN bugteam review cannot be posted (the `post_audit_thread.py` post is
|
|
36
|
+
denied, errors, or its agent dies). The convergence gate's bugteam-review
|
|
37
|
+
check can never pass without that CLEAN review, so the run stops rather than
|
|
38
|
+
re-converge to the iteration cap. The `blocker` names the post failure and the
|
|
39
|
+
HEAD. Unblock by allowing `post_audit_thread.py` with a Bash permission rule,
|
|
40
|
+
or post the CLEAN review by hand, then re-run.
|
|
34
41
|
|
|
35
42
|
## Not a blocker (the run continues)
|
|
36
43
|
|