claude-dev-env 1.72.0 → 1.74.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 +2 -2
- package/bin/install.mjs +73 -5
- package/bin/install.test.mjs +360 -4
- package/hooks/blocking/CLAUDE.md +6 -1
- 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 +19 -48
- package/hooks/blocking/code_rules_dead_config_field.py +69 -56
- package/hooks/blocking/code_rules_docstrings.py +839 -0
- package/hooks/blocking/code_rules_enforcer.py +38 -0
- package/hooks/blocking/code_rules_shared.py +19 -0
- package/hooks/blocking/code_verifier_spawn_preflight_gate.py +426 -0
- 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/gh_body_arg_blocker.py +8 -0
- package/hooks/blocking/gh_pr_author_enforcer.py +7 -0
- package/hooks/blocking/hedging_language_blocker.py +16 -10
- package/hooks/blocking/hook_prose_detector_consistency.py +7 -0
- package/hooks/blocking/intent_only_ending_blocker.py +17 -11
- package/hooks/blocking/md_to_html_blocker.py +17 -10
- package/hooks/blocking/open_questions_in_plans_blocker.py +15 -8
- package/hooks/blocking/package_inventory_stale_blocker.py +398 -0
- package/hooks/blocking/plain_language_blocker.py +57 -16
- package/hooks/blocking/pr_converge_bugteam_enforcer.py +11 -5
- package/hooks/blocking/pr_description_enforcer.py +6 -0
- package/hooks/blocking/pre_tool_use_dispatcher.py +545 -0
- package/hooks/blocking/precommit_code_rules_gate.py +10 -1
- package/hooks/blocking/pytest_testpaths_orphan_blocker.py +366 -0
- package/hooks/blocking/question_to_user_enforcer.py +18 -12
- 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 +14 -8
- package/hooks/blocking/state_description_blocker.py +81 -36
- package/hooks/blocking/subprocess_budget_completeness.py +9 -3
- package/hooks/blocking/tdd_enforcer.py +6 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +81 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_inline_literal_claim.py +93 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_returns_plural_cardinality.py +207 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_step_dispatch.py +262 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_undefined_constant.py +253 -0
- package/hooks/blocking/test_code_rules_enforcer_docstring_unguarded_payload.py +188 -0
- package/hooks/blocking/test_code_rules_enforcer_module_docstring_roster.py +279 -0
- package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +501 -0
- package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +203 -0
- package/hooks/blocking/test_hook_block_logger_coverage.py +53 -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 +816 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +341 -0
- package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +247 -0
- package/hooks/blocking/test_send_user_file_open_locally_blocker.py +114 -0
- package/hooks/blocking/test_shared_stdin_adoption.py +208 -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 +21 -7
- 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 +19 -79
- package/hooks/hooks_constants/CLAUDE.md +7 -1
- package/hooks/hooks_constants/blocking_check_limits.py +74 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +9 -0
- package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +45 -0
- package/hooks/hooks_constants/dead_config_field_constants.py +5 -5
- package/hooks/hooks_constants/docstring_rule_gate_count_blocker_constants.py +90 -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/mypy_validator_cache_constants.py +36 -0
- package/hooks/hooks_constants/package_inventory_stale_blocker_constants.py +111 -0
- package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +68 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +143 -0
- package/hooks/hooks_constants/pytest_testpaths_orphan_blocker_constants.py +79 -0
- 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/lifecycle/config_change_guard.py +12 -0
- package/hooks/lifecycle/test_config_change_guard.py +23 -0
- package/hooks/validation/hook_format_validator.py +13 -0
- package/hooks/validation/mypy_validator.py +245 -18
- package/hooks/validation/post_tool_use_dispatcher.py +344 -0
- package/hooks/validation/test_hook_format_validator.py +64 -0
- package/hooks/validation/test_mypy_validator.py +206 -1
- package/hooks/validation/test_post_tool_use_dispatcher.py +610 -0
- package/hooks/workflow/test_auto_formatter.py +10 -9
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/docstring-prose-matches-implementation.md +4 -2
- package/rules/package-inventory-stale-entry.md +24 -0
- package/skills/autoconverge/SKILL.md +111 -1
- package/skills/autoconverge/workflow/converge.contract.test.mjs +106 -0
- package/skills/autoconverge/workflow/converge.mjs +29 -3
- package/skills/autoconverge/workflow/converge.path-aware.test.mjs +47 -0
- package/skills/autoconverge/workflow/converge_multi.mjs +161 -0
- package/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +100 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Unit tests for the send-user-file-open-locally PreToolUse hook."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
import pathlib
|
|
7
|
+
import sys
|
|
8
|
+
from unittest import mock
|
|
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
|
+
|
|
14
|
+
hook_spec = importlib.util.spec_from_file_location(
|
|
15
|
+
"send_user_file_open_locally_blocker",
|
|
16
|
+
_HOOK_DIR / "send_user_file_open_locally_blocker.py",
|
|
17
|
+
)
|
|
18
|
+
assert hook_spec is not None
|
|
19
|
+
assert hook_spec.loader is not None
|
|
20
|
+
hook_module = importlib.util.module_from_spec(hook_spec)
|
|
21
|
+
hook_spec.loader.exec_module(hook_module)
|
|
22
|
+
|
|
23
|
+
_should_block = hook_module._should_block
|
|
24
|
+
|
|
25
|
+
from hooks_constants.send_user_file_open_locally_blocker_constants import (
|
|
26
|
+
CORRECTIVE_MESSAGE,
|
|
27
|
+
PROACTIVE_STATUS,
|
|
28
|
+
TOOL_NAME,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_blocks_normal_status() -> None:
|
|
33
|
+
assert _should_block("normal") is True
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_blocks_empty_status() -> None:
|
|
37
|
+
assert _should_block("") is True
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_blocks_unknown_status() -> None:
|
|
41
|
+
assert _should_block("whatever") is True
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_allows_proactive_status() -> None:
|
|
45
|
+
assert _should_block(PROACTIVE_STATUS) is False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_corrective_message_points_to_show_asset() -> None:
|
|
49
|
+
assert "Show-Asset.ps1" in CORRECTIVE_MESSAGE
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_corrective_message_names_proactive_escape_hatch() -> None:
|
|
53
|
+
assert PROACTIVE_STATUS in CORRECTIVE_MESSAGE
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _run_main_with_io(input_text: str) -> str:
|
|
57
|
+
with mock.patch("sys.stdin", io.StringIO(input_text)):
|
|
58
|
+
with mock.patch("sys.stdout", new_callable=io.StringIO) as mock_stdout:
|
|
59
|
+
try:
|
|
60
|
+
hook_module.main()
|
|
61
|
+
except SystemExit:
|
|
62
|
+
pass
|
|
63
|
+
return mock_stdout.getvalue()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_main_blocks_normal_attach() -> None:
|
|
67
|
+
hook_input = {
|
|
68
|
+
"tool_name": TOOL_NAME,
|
|
69
|
+
"tool_input": {"files": ["report.html"], "status": "normal"},
|
|
70
|
+
}
|
|
71
|
+
output_text = _run_main_with_io(json.dumps(hook_input))
|
|
72
|
+
output = json.loads(output_text)
|
|
73
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
74
|
+
assert "Show-Asset.ps1" in output["hookSpecificOutput"]["permissionDecisionReason"]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_main_allows_proactive_attach() -> None:
|
|
78
|
+
hook_input = {
|
|
79
|
+
"tool_name": TOOL_NAME,
|
|
80
|
+
"tool_input": {"files": ["report.html"], "status": "proactive"},
|
|
81
|
+
}
|
|
82
|
+
assert _run_main_with_io(json.dumps(hook_input)) == ""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_main_blocks_when_status_missing() -> None:
|
|
86
|
+
hook_input = {
|
|
87
|
+
"tool_name": TOOL_NAME,
|
|
88
|
+
"tool_input": {"files": ["report.html"]},
|
|
89
|
+
}
|
|
90
|
+
output_text = _run_main_with_io(json.dumps(hook_input))
|
|
91
|
+
output = json.loads(output_text)
|
|
92
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_main_blocks_when_tool_input_is_null() -> None:
|
|
96
|
+
hook_input = {
|
|
97
|
+
"tool_name": TOOL_NAME,
|
|
98
|
+
"tool_input": None,
|
|
99
|
+
}
|
|
100
|
+
output_text = _run_main_with_io(json.dumps(hook_input))
|
|
101
|
+
output = json.loads(output_text)
|
|
102
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_main_passes_wrong_tool_name() -> None:
|
|
106
|
+
hook_input = {
|
|
107
|
+
"tool_name": "Write",
|
|
108
|
+
"tool_input": {"files": ["report.html"], "status": "normal"},
|
|
109
|
+
}
|
|
110
|
+
assert _run_main_with_io(json.dumps(hook_input)) == ""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_main_passes_malformed_json() -> None:
|
|
114
|
+
assert _run_main_with_io("not valid json {{{") == ""
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Same-decision tests for hooks converted to the shared stdin parser.
|
|
2
|
+
|
|
3
|
+
Each converted hook reads its PreToolUse payload through
|
|
4
|
+
``hooks_constants.pre_tool_use_stdin.read_hook_input_dictionary_from_stdin``
|
|
5
|
+
rather than a hand-rolled ``json.load(sys.stdin)`` plus ``isinstance(dict)``
|
|
6
|
+
guard. The shared parser fails open on empty stdin, malformed JSON, and a
|
|
7
|
+
non-object JSON root by returning ``None``; the hand-rolled form these hooks
|
|
8
|
+
carried failed open on the same three cases by exiting zero. These tests drive
|
|
9
|
+
each real hook script through its production ``__main__`` stdin path over a
|
|
10
|
+
corpus that pins those fail-soft edges plus a representative allow payload, so a
|
|
11
|
+
conversion that changes any decision is caught.
|
|
12
|
+
|
|
13
|
+
The deterministic deny payloads for the two Write/Edit blockers whose triggers
|
|
14
|
+
need no filesystem or state setup (``md_to_html_blocker``,
|
|
15
|
+
``open_questions_in_plans_blocker``) are exercised here too; each remaining
|
|
16
|
+
hook's full deny coverage stays in its own suite, which also drives the real
|
|
17
|
+
``main()`` and so re-proves the decision after the parser swap.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
import pytest
|
|
27
|
+
|
|
28
|
+
_BLOCKING_DIRECTORY = Path(__file__).resolve().parent
|
|
29
|
+
|
|
30
|
+
ALL_CONVERTED_HOOK_FILENAMES = (
|
|
31
|
+
"md_to_html_blocker.py",
|
|
32
|
+
"open_questions_in_plans_blocker.py",
|
|
33
|
+
"claude_md_orphan_file_blocker.py",
|
|
34
|
+
"pr_converge_bugteam_enforcer.py",
|
|
35
|
+
"verdict_directory_write_blocker.py",
|
|
36
|
+
"package_inventory_stale_blocker.py",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
EMPTY_STDIN_PAYLOAD = ""
|
|
40
|
+
WHITESPACE_STDIN_PAYLOAD = " \n\t "
|
|
41
|
+
MALFORMED_JSON_PAYLOAD = "{not valid json"
|
|
42
|
+
NON_OBJECT_JSON_ARRAY_PAYLOAD = "[1, 2, 3]"
|
|
43
|
+
NON_OBJECT_JSON_SCALAR_PAYLOAD = "42"
|
|
44
|
+
|
|
45
|
+
ALL_FAIL_SOFT_PAYLOADS = (
|
|
46
|
+
EMPTY_STDIN_PAYLOAD,
|
|
47
|
+
WHITESPACE_STDIN_PAYLOAD,
|
|
48
|
+
MALFORMED_JSON_PAYLOAD,
|
|
49
|
+
NON_OBJECT_JSON_ARRAY_PAYLOAD,
|
|
50
|
+
NON_OBJECT_JSON_SCALAR_PAYLOAD,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _run_hook_script(hook_filename: str, stdin_text: str) -> subprocess.CompletedProcess:
|
|
55
|
+
hook_script_path = _BLOCKING_DIRECTORY / hook_filename
|
|
56
|
+
return subprocess.run(
|
|
57
|
+
[sys.executable, str(hook_script_path)],
|
|
58
|
+
input=stdin_text,
|
|
59
|
+
capture_output=True,
|
|
60
|
+
text=True,
|
|
61
|
+
check=False,
|
|
62
|
+
cwd=str(Path.home()),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _decision_from_stdout(completed: subprocess.CompletedProcess) -> str | None:
|
|
67
|
+
if not completed.stdout.strip():
|
|
68
|
+
return None
|
|
69
|
+
parsed_output = json.loads(completed.stdout)
|
|
70
|
+
return parsed_output["hookSpecificOutput"]["permissionDecision"]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@pytest.mark.parametrize("hook_filename", ALL_CONVERTED_HOOK_FILENAMES)
|
|
74
|
+
@pytest.mark.parametrize("stdin_text", ALL_FAIL_SOFT_PAYLOADS)
|
|
75
|
+
def test_fail_soft_payload_exits_zero_with_no_decision(hook_filename: str, stdin_text: str) -> None:
|
|
76
|
+
completed = _run_hook_script(hook_filename, stdin_text)
|
|
77
|
+
assert completed.returncode == 0, (
|
|
78
|
+
f"{hook_filename} must exit zero on fail-soft stdin; "
|
|
79
|
+
f"got code {completed.returncode}, stderr {completed.stderr!r}"
|
|
80
|
+
)
|
|
81
|
+
assert _decision_from_stdout(completed) is None, (
|
|
82
|
+
f"{hook_filename} must emit no decision on fail-soft stdin; got stdout {completed.stdout!r}"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_md_to_html_blocker_still_denies_relative_markdown_write() -> None:
|
|
87
|
+
payload = json.dumps(
|
|
88
|
+
{
|
|
89
|
+
"tool_name": "Write",
|
|
90
|
+
"tool_input": {"file_path": "notes/topic.md", "content": "# Topic"},
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
completed = _run_hook_script("md_to_html_blocker.py", payload)
|
|
94
|
+
assert completed.returncode == 0
|
|
95
|
+
assert _decision_from_stdout(completed) == "deny"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_md_to_html_blocker_still_allows_non_markdown_write() -> None:
|
|
99
|
+
payload = json.dumps(
|
|
100
|
+
{
|
|
101
|
+
"tool_name": "Write",
|
|
102
|
+
"tool_input": {"file_path": "notes/topic.txt", "content": "plain"},
|
|
103
|
+
}
|
|
104
|
+
)
|
|
105
|
+
completed = _run_hook_script("md_to_html_blocker.py", payload)
|
|
106
|
+
assert completed.returncode == 0
|
|
107
|
+
assert _decision_from_stdout(completed) is None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_open_questions_blocker_still_denies_plan_with_open_questions(
|
|
111
|
+
tmp_path: Path,
|
|
112
|
+
) -> None:
|
|
113
|
+
plan_directory = tmp_path / "docs" / "plans"
|
|
114
|
+
plan_directory.mkdir(parents=True)
|
|
115
|
+
plan_path = plan_directory / "feature.md"
|
|
116
|
+
plan_body = "# Feature Plan\n\n## Open Questions\n\n- What endpoint do we call?\n"
|
|
117
|
+
payload = json.dumps(
|
|
118
|
+
{
|
|
119
|
+
"tool_name": "Write",
|
|
120
|
+
"tool_input": {"file_path": str(plan_path), "content": plan_body},
|
|
121
|
+
}
|
|
122
|
+
)
|
|
123
|
+
completed = _run_hook_script("open_questions_in_plans_blocker.py", payload)
|
|
124
|
+
assert completed.returncode == 0
|
|
125
|
+
assert _decision_from_stdout(completed) == "deny"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_open_questions_blocker_still_allows_plan_without_open_questions(
|
|
129
|
+
tmp_path: Path,
|
|
130
|
+
) -> None:
|
|
131
|
+
plan_directory = tmp_path / "docs" / "plans"
|
|
132
|
+
plan_directory.mkdir(parents=True)
|
|
133
|
+
plan_path = plan_directory / "feature.md"
|
|
134
|
+
plan_body = "# Feature Plan\n\n## Approach\n\nBuild the thing.\n"
|
|
135
|
+
payload = json.dumps(
|
|
136
|
+
{
|
|
137
|
+
"tool_name": "Write",
|
|
138
|
+
"tool_input": {"file_path": str(plan_path), "content": plan_body},
|
|
139
|
+
}
|
|
140
|
+
)
|
|
141
|
+
completed = _run_hook_script("open_questions_in_plans_blocker.py", payload)
|
|
142
|
+
assert completed.returncode == 0
|
|
143
|
+
assert _decision_from_stdout(completed) is None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_package_inventory_blocker_still_denies_uninventoried_new_file(
|
|
147
|
+
tmp_path: Path,
|
|
148
|
+
) -> None:
|
|
149
|
+
inventory_body = "# package\n\n| File | Role |\n|---|---|\n| `alpha.py` | A |\n| `beta.py` | B |\n"
|
|
150
|
+
(tmp_path / "README.md").write_text(inventory_body, encoding="utf-8")
|
|
151
|
+
(tmp_path / "alpha.py").write_text("x = 1\n", encoding="utf-8")
|
|
152
|
+
(tmp_path / "beta.py").write_text("x = 1\n", encoding="utf-8")
|
|
153
|
+
new_file_path = tmp_path / "gamma.py"
|
|
154
|
+
payload = json.dumps(
|
|
155
|
+
{
|
|
156
|
+
"tool_name": "Write",
|
|
157
|
+
"tool_input": {"file_path": str(new_file_path), "content": "x = 1\n"},
|
|
158
|
+
}
|
|
159
|
+
)
|
|
160
|
+
completed = _run_hook_script("package_inventory_stale_blocker.py", payload)
|
|
161
|
+
assert completed.returncode == 0
|
|
162
|
+
assert _decision_from_stdout(completed) == "deny"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_package_inventory_blocker_still_allows_inventoried_new_file(
|
|
166
|
+
tmp_path: Path,
|
|
167
|
+
) -> None:
|
|
168
|
+
inventory_body = (
|
|
169
|
+
"# package\n\n| File | Role |\n|---|---|\n"
|
|
170
|
+
"| `alpha.py` | A |\n| `beta.py` | B |\n| `gamma.py` | G |\n"
|
|
171
|
+
)
|
|
172
|
+
(tmp_path / "README.md").write_text(inventory_body, encoding="utf-8")
|
|
173
|
+
(tmp_path / "alpha.py").write_text("x = 1\n", encoding="utf-8")
|
|
174
|
+
(tmp_path / "beta.py").write_text("x = 1\n", encoding="utf-8")
|
|
175
|
+
new_file_path = tmp_path / "gamma.py"
|
|
176
|
+
payload = json.dumps(
|
|
177
|
+
{
|
|
178
|
+
"tool_name": "Write",
|
|
179
|
+
"tool_input": {"file_path": str(new_file_path), "content": "x = 1\n"},
|
|
180
|
+
}
|
|
181
|
+
)
|
|
182
|
+
completed = _run_hook_script("package_inventory_stale_blocker.py", payload)
|
|
183
|
+
assert completed.returncode == 0
|
|
184
|
+
assert _decision_from_stdout(completed) is None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_converted_hooks_allow_unrelated_tool_name() -> None:
|
|
188
|
+
payload = json.dumps({"tool_name": "Bash", "tool_input": {"command": "ls"}})
|
|
189
|
+
for each_hook_filename in ALL_CONVERTED_HOOK_FILENAMES:
|
|
190
|
+
completed = _run_hook_script(each_hook_filename, payload)
|
|
191
|
+
assert completed.returncode == 0, (
|
|
192
|
+
f"{each_hook_filename} must exit zero on an unrelated tool; stderr {completed.stderr!r}"
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_every_converted_hook_imports_shared_parser() -> None:
|
|
197
|
+
for each_hook_filename in ALL_CONVERTED_HOOK_FILENAMES:
|
|
198
|
+
hook_source = (_BLOCKING_DIRECTORY / each_hook_filename).read_text(encoding="utf-8")
|
|
199
|
+
assert "read_hook_input_dictionary_from_stdin" in hook_source, (
|
|
200
|
+
f"{each_hook_filename} must read stdin through the shared parser"
|
|
201
|
+
)
|
|
202
|
+
assert "json.load(sys.stdin)" not in hook_source, (
|
|
203
|
+
f"{each_hook_filename} must not hand-roll json.load(sys.stdin)"
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def test_blocking_directory_is_resolvable() -> None:
|
|
208
|
+
assert os.path.isdir(_BLOCKING_DIRECTORY)
|
|
@@ -4,6 +4,21 @@ import json
|
|
|
4
4
|
import os
|
|
5
5
|
import subprocess
|
|
6
6
|
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from unittest.mock import patch
|
|
9
|
+
|
|
10
|
+
_BLOCKING_DIR = str(Path(__file__).resolve().parent)
|
|
11
|
+
_HOOKS_ROOT = str(Path(__file__).resolve().parent.parent)
|
|
12
|
+
if _BLOCKING_DIR not in sys.path:
|
|
13
|
+
sys.path.insert(0, _BLOCKING_DIR)
|
|
14
|
+
if _HOOKS_ROOT not in sys.path:
|
|
15
|
+
sys.path.insert(0, _HOOKS_ROOT)
|
|
16
|
+
|
|
17
|
+
from pre_tool_use_dispatcher import NativeHook, run_native_hook # noqa: E402
|
|
18
|
+
from state_description_blocker import ( # noqa: E402
|
|
19
|
+
build_deny_payload,
|
|
20
|
+
evaluate,
|
|
21
|
+
)
|
|
7
22
|
|
|
8
23
|
HOOK_SCRIPT_PATH = os.path.join(
|
|
9
24
|
os.path.dirname(__file__), "state_description_blocker.py"
|
|
@@ -616,3 +631,29 @@ def test_handles_non_string_tool_name():
|
|
|
616
631
|
)
|
|
617
632
|
assert result.returncode == 0
|
|
618
633
|
assert result.stdout == ""
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def test_native_dispatch_path_logs_the_block(tmp_path: Path) -> None:
|
|
637
|
+
"""A deny routed through the dispatcher's native path logs one record.
|
|
638
|
+
|
|
639
|
+
hooks.json wires this hook only through pre_tool_use_dispatcher, whose
|
|
640
|
+
native path calls evaluate() and build_deny_payload() — never main(). The
|
|
641
|
+
block must still land in the hook-blocks log, so the log call lives on
|
|
642
|
+
build_deny_payload, the function the native path executes.
|
|
643
|
+
"""
|
|
644
|
+
deny_payload = {
|
|
645
|
+
"tool_name": "Write",
|
|
646
|
+
"tool_input": {"file_path": "src/main.py", "content": VIOLATION_INSTEAD_OF_COMMENT},
|
|
647
|
+
}
|
|
648
|
+
native_hook = NativeHook(evaluate=evaluate, build_deny_payload=build_deny_payload)
|
|
649
|
+
|
|
650
|
+
with patch.object(Path, "home", return_value=tmp_path):
|
|
651
|
+
hosted_result = run_native_hook(native_hook, deny_payload, is_blocking=True)
|
|
652
|
+
|
|
653
|
+
assert hosted_result.captured_stdout
|
|
654
|
+
log_path = tmp_path / ".claude" / "logs" / "hook-blocks.log"
|
|
655
|
+
all_records = log_path.read_text(encoding="utf-8").strip().splitlines()
|
|
656
|
+
assert len(all_records) == 1
|
|
657
|
+
logged_record = json.loads(all_records[0])
|
|
658
|
+
assert logged_record["hook"] == "state_description_blocker.py"
|
|
659
|
+
assert logged_record["event"] == "PreToolUse"
|
|
@@ -11,6 +11,8 @@ is denied, and commands that touch unrelated paths pass.
|
|
|
11
11
|
import importlib.util
|
|
12
12
|
import json
|
|
13
13
|
import pathlib
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
14
16
|
import sys
|
|
15
17
|
|
|
16
18
|
_HOOK_DIR = pathlib.Path(__file__).parent
|
|
@@ -718,3 +720,50 @@ def test_guard_is_registered_on_powershell() -> None:
|
|
|
718
720
|
"verdict_directory_write_blocker.py" in each_command
|
|
719
721
|
for each_command in _pretooluse_commands_for_matcher("PowerShell")
|
|
720
722
|
)
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def test_hook_subprocess_imports_real_config_when_parent_holds_shadowing_config(
|
|
726
|
+
tmp_path: pathlib.Path,
|
|
727
|
+
) -> None:
|
|
728
|
+
real_blocking_directory = pathlib.Path(__file__).resolve().parent
|
|
729
|
+
real_hooks_directory = real_blocking_directory.parent
|
|
730
|
+
|
|
731
|
+
staged_hooks_directory = tmp_path / "hooks"
|
|
732
|
+
staged_blocking_directory = staged_hooks_directory / "blocking"
|
|
733
|
+
staged_blocking_directory.mkdir(parents=True)
|
|
734
|
+
|
|
735
|
+
shutil.copy(
|
|
736
|
+
real_blocking_directory / "verdict_directory_write_blocker.py",
|
|
737
|
+
staged_blocking_directory / "verdict_directory_write_blocker.py",
|
|
738
|
+
)
|
|
739
|
+
shutil.copytree(
|
|
740
|
+
real_blocking_directory / "config",
|
|
741
|
+
staged_blocking_directory / "config",
|
|
742
|
+
)
|
|
743
|
+
shutil.copytree(
|
|
744
|
+
real_hooks_directory / "hooks_constants",
|
|
745
|
+
staged_hooks_directory / "hooks_constants",
|
|
746
|
+
)
|
|
747
|
+
|
|
748
|
+
shadowing_config_directory = staged_hooks_directory / "config"
|
|
749
|
+
shadowing_config_directory.mkdir(parents=True, exist_ok=True)
|
|
750
|
+
(shadowing_config_directory / "__init__.py").write_text("", encoding="utf-8")
|
|
751
|
+
(shadowing_config_directory / "unrelated_constants.py").write_text(
|
|
752
|
+
"UNRELATED_VALUE = 1\n", encoding="utf-8"
|
|
753
|
+
)
|
|
754
|
+
|
|
755
|
+
benign_payload = json.dumps(
|
|
756
|
+
{"tool_name": "Bash", "tool_input": {"command": "echo hello"}}
|
|
757
|
+
)
|
|
758
|
+
completed = subprocess.run(
|
|
759
|
+
[
|
|
760
|
+
sys.executable,
|
|
761
|
+
str(staged_blocking_directory / "verdict_directory_write_blocker.py"),
|
|
762
|
+
],
|
|
763
|
+
input=benign_payload,
|
|
764
|
+
capture_output=True,
|
|
765
|
+
text=True,
|
|
766
|
+
)
|
|
767
|
+
|
|
768
|
+
assert "ModuleNotFoundError" not in completed.stderr
|
|
769
|
+
assert completed.returncode == 0
|
|
@@ -23,7 +23,6 @@ hook_module = importlib.util.module_from_spec(hook_spec)
|
|
|
23
23
|
hook_spec.loader.exec_module(hook_module)
|
|
24
24
|
|
|
25
25
|
content_has_violation = hook_module.content_has_violation
|
|
26
|
-
find_bare_index_segments = hook_module.find_bare_index_segments
|
|
27
26
|
find_bare_path_segments = hook_module.find_bare_path_segments
|
|
28
27
|
has_iteration_loop = hook_module.has_iteration_loop
|
|
29
28
|
written_content = hook_module.written_content
|
|
@@ -47,37 +46,23 @@ _FIXED_TEMPLATE = (
|
|
|
47
46
|
|
|
48
47
|
|
|
49
48
|
def test_detects_bare_index_in_path_segment() -> None:
|
|
50
|
-
assert
|
|
49
|
+
assert find_bare_path_segments(
|
|
51
50
|
"render Path(r'${args.work_dir}\\\\cand_i\\\\plate.svg')"
|
|
52
51
|
) == {"cand_i"}
|
|
53
52
|
|
|
54
53
|
|
|
55
54
|
def test_detects_quoted_key_when_token_also_appears_as_path_segment() -> None:
|
|
56
55
|
looped_path_and_key = "write ${work}\\\\cand_i\\\\plate.svg\n{key: \"cand_i\", name}"
|
|
57
|
-
assert "cand_i" in
|
|
56
|
+
assert "cand_i" in find_bare_path_segments(looped_path_and_key)
|
|
58
57
|
|
|
59
58
|
|
|
60
59
|
def test_quoted_key_alone_without_path_segment_is_not_detected() -> None:
|
|
61
|
-
assert
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def test_index_segments_equal_path_segments_for_looped_path_and_key() -> None:
|
|
65
|
-
looped_path_and_key = "write ${work}\\\\cand_i\\\\plate.svg\n{key: \"cand_i\", name}"
|
|
66
|
-
assert find_bare_index_segments(looped_path_and_key) == find_bare_path_segments(
|
|
67
|
-
looped_path_and_key
|
|
68
|
-
)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def test_index_segments_equal_path_segments_for_quoted_only_key() -> None:
|
|
72
|
-
quoted_only_key = '{key: "metric_i", name}'
|
|
73
|
-
assert find_bare_index_segments(quoted_only_key) == find_bare_path_segments(
|
|
74
|
-
quoted_only_key
|
|
75
|
-
)
|
|
60
|
+
assert find_bare_path_segments('{key: "metric_i", name}') == set()
|
|
76
61
|
|
|
77
62
|
|
|
78
63
|
def test_marked_substitution_slot_is_not_a_bare_segment() -> None:
|
|
79
64
|
assert (
|
|
80
|
-
|
|
65
|
+
find_bare_path_segments(
|
|
81
66
|
"render Path(r'${args.work_dir}\\\\cand_<i>\\\\plate.svg')"
|
|
82
67
|
)
|
|
83
68
|
== set()
|
|
@@ -37,7 +37,11 @@ blocking_directory = str(Path(__file__).resolve().parent)
|
|
|
37
37
|
if blocking_directory not in sys.path:
|
|
38
38
|
sys.path.insert(0, blocking_directory)
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
41
|
+
if hooks_directory not in sys.path:
|
|
42
|
+
sys.path.append(hooks_directory)
|
|
43
|
+
|
|
44
|
+
from config.verified_commit_constants import ( # noqa: E402
|
|
41
45
|
ALL_GATED_TOOL_NAMES,
|
|
42
46
|
ALL_VERDICT_PATH_SEGMENT_BODIES,
|
|
43
47
|
ALL_VERDICT_PATH_SEGMENT_NAMES,
|
|
@@ -61,11 +65,11 @@ from config.verified_commit_constants import (
|
|
|
61
65
|
NON_REDIRECT_FILE_WRITE_PRIMITIVE_PATTERN,
|
|
62
66
|
PATH_OBFUSCATION_PRIMITIVE_PATTERN,
|
|
63
67
|
RELATIVE_VERDICT_DIRECTORY_PATTERN,
|
|
68
|
+
VERDICT_DIRECTORY_CHANGE_TARGET_PATTERN,
|
|
64
69
|
VERDICT_DIRECTORY_GUARD_MESSAGE,
|
|
65
70
|
VERDICT_DIRECTORY_NAME,
|
|
66
71
|
VERDICT_DIRECTORY_NAME_SEPARATOR_PATTERN,
|
|
67
72
|
VERDICT_DIRECTORY_PATH_BOUNDARY_PATTERN,
|
|
68
|
-
VERDICT_DIRECTORY_CHANGE_TARGET_PATTERN,
|
|
69
73
|
VERDICT_DIRECTORY_TARGET_BOUNDARY_PATTERN,
|
|
70
74
|
VERDICT_FILE_RELATIVE_REFERENCE_PATTERN,
|
|
71
75
|
VERDICT_PATH_GLUE_PATTERN,
|
|
@@ -74,6 +78,11 @@ from config.verified_commit_constants import (
|
|
|
74
78
|
WRITE_CALL_REGION_PATTERN,
|
|
75
79
|
)
|
|
76
80
|
|
|
81
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
82
|
+
from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
|
|
83
|
+
read_hook_input_dictionary_from_stdin,
|
|
84
|
+
)
|
|
85
|
+
|
|
77
86
|
|
|
78
87
|
def _directory_change_verbs_pattern() -> str:
|
|
79
88
|
"""Build the alternation of directory-change verbs for a change matcher.
|
|
@@ -650,15 +659,20 @@ def decision_for_payload(pretooluse_payload: dict) -> dict | None:
|
|
|
650
659
|
|
|
651
660
|
def main() -> None:
|
|
652
661
|
"""Read the PreToolUse payload and deny verdict-directory shell access."""
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
except json.JSONDecodeError:
|
|
656
|
-
return
|
|
657
|
-
if not isinstance(pretooluse_payload, dict):
|
|
662
|
+
pretooluse_payload = read_hook_input_dictionary_from_stdin()
|
|
663
|
+
if pretooluse_payload is None:
|
|
658
664
|
return
|
|
659
665
|
deny_decision = decision_for_payload(pretooluse_payload)
|
|
660
666
|
if deny_decision is None:
|
|
661
667
|
return
|
|
668
|
+
raw_tool_name = pretooluse_payload.get("tool_name", "")
|
|
669
|
+
tool_name_for_log = raw_tool_name if isinstance(raw_tool_name, str) else ""
|
|
670
|
+
log_hook_block(
|
|
671
|
+
calling_hook_name="verdict_directory_write_blocker.py",
|
|
672
|
+
hook_event="PreToolUse",
|
|
673
|
+
block_reason=VERDICT_DIRECTORY_GUARD_MESSAGE,
|
|
674
|
+
tool_name=tool_name_for_log,
|
|
675
|
+
)
|
|
662
676
|
print(json.dumps(deny_decision))
|
|
663
677
|
sys.stdout.flush()
|
|
664
678
|
|
|
@@ -38,6 +38,10 @@ blocking_directory = str(Path(__file__).resolve().parent)
|
|
|
38
38
|
if blocking_directory not in sys.path:
|
|
39
39
|
sys.path.insert(0, blocking_directory)
|
|
40
40
|
|
|
41
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
42
|
+
if _hooks_dir not in sys.path:
|
|
43
|
+
sys.path.insert(0, _hooks_dir)
|
|
44
|
+
|
|
41
45
|
from config.verified_commit_constants import (
|
|
42
46
|
ALL_GIT_BINARY_NAMES,
|
|
43
47
|
CORRECTIVE_MESSAGE,
|
|
@@ -55,6 +59,7 @@ from config.verified_commit_constants import (
|
|
|
55
59
|
VERIFICATION_BYPASS_MARKER,
|
|
56
60
|
WORK_TREE_OPTION,
|
|
57
61
|
)
|
|
62
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
58
63
|
from verification_verdict_store import (
|
|
59
64
|
branch_surface_manifest,
|
|
60
65
|
is_verification_exempt_diff,
|
|
@@ -543,6 +548,12 @@ def main() -> None:
|
|
|
543
548
|
"permissionDecisionReason": deny_reason,
|
|
544
549
|
}
|
|
545
550
|
}
|
|
551
|
+
log_hook_block(
|
|
552
|
+
calling_hook_name="verified_commit_gate.py",
|
|
553
|
+
hook_event="PreToolUse",
|
|
554
|
+
block_reason=deny_reason,
|
|
555
|
+
tool_name=pretooluse_payload.get("tool_name", "") if isinstance(pretooluse_payload.get("tool_name"), str) else None,
|
|
556
|
+
)
|
|
546
557
|
print(json.dumps(deny_payload))
|
|
547
558
|
return
|
|
548
559
|
|
|
@@ -24,6 +24,13 @@ import json
|
|
|
24
24
|
import os
|
|
25
25
|
import re
|
|
26
26
|
import sys
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
30
|
+
if _hooks_dir not in sys.path:
|
|
31
|
+
sys.path.insert(0, _hooks_dir)
|
|
32
|
+
|
|
33
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
27
34
|
|
|
28
35
|
|
|
29
36
|
def is_guarded_file(file_path: str) -> bool:
|
|
@@ -136,13 +143,21 @@ def main() -> None:
|
|
|
136
143
|
if not claims_blanket_comment_exemption(written_text):
|
|
137
144
|
sys.exit(0)
|
|
138
145
|
|
|
146
|
+
corrective_message = build_corrective_message()
|
|
139
147
|
deny_response = {
|
|
140
148
|
"hookSpecificOutput": {
|
|
141
149
|
"hookEventName": "PreToolUse",
|
|
142
150
|
"permissionDecision": "deny",
|
|
143
|
-
"permissionDecisionReason":
|
|
151
|
+
"permissionDecisionReason": corrective_message,
|
|
144
152
|
}
|
|
145
153
|
}
|
|
154
|
+
log_hook_block(
|
|
155
|
+
calling_hook_name="verified_commit_message_accuracy_blocker.py",
|
|
156
|
+
hook_event="PreToolUse",
|
|
157
|
+
block_reason=corrective_message,
|
|
158
|
+
tool_name=tool_name,
|
|
159
|
+
offending_input_preview=file_path,
|
|
160
|
+
)
|
|
146
161
|
print(json.dumps(deny_response))
|
|
147
162
|
sys.stdout.flush()
|
|
148
163
|
sys.exit(0)
|
|
@@ -21,6 +21,7 @@ _hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
|
21
21
|
if _hooks_dir not in sys.path:
|
|
22
22
|
sys.path.insert(0, _hooks_dir)
|
|
23
23
|
|
|
24
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
24
25
|
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin # noqa: E402
|
|
25
26
|
from hooks_constants.windows_rmtree_blocker_constants import PYTHON_FILE_EXTENSION # noqa: E402
|
|
26
27
|
|
|
@@ -104,6 +105,12 @@ def main() -> None:
|
|
|
104
105
|
"permissionDecisionReason": corrective_message,
|
|
105
106
|
}
|
|
106
107
|
}
|
|
108
|
+
log_hook_block(
|
|
109
|
+
calling_hook_name="windows_rmtree_blocker.py",
|
|
110
|
+
hook_event="PreToolUse",
|
|
111
|
+
block_reason=corrective_message,
|
|
112
|
+
tool_name=tool_name,
|
|
113
|
+
)
|
|
107
114
|
print(json.dumps(deny_response))
|
|
108
115
|
sys.stdout.flush()
|
|
109
116
|
sys.exit(0)
|
|
@@ -40,6 +40,7 @@ _hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
|
40
40
|
if _hooks_dir not in sys.path:
|
|
41
41
|
sys.path.insert(0, _hooks_dir)
|
|
42
42
|
|
|
43
|
+
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
43
44
|
from hooks_constants.workflow_substitution_slot_blocker_constants import ( # noqa: E402
|
|
44
45
|
CORRECTIVE_MESSAGE,
|
|
45
46
|
EDIT_TOOL_NAME,
|
|
@@ -111,16 +112,12 @@ def find_bare_path_segments(content: str) -> set[str]:
|
|
|
111
112
|
return all_path_segments
|
|
112
113
|
|
|
113
114
|
|
|
114
|
-
def find_bare_index_segments(content: str) -> set[str]:
|
|
115
|
-
return find_bare_path_segments(content)
|
|
116
|
-
|
|
117
|
-
|
|
118
115
|
def content_has_violation(content: str) -> bool:
|
|
119
116
|
if not uses_angle_slot_convention(content):
|
|
120
117
|
return False
|
|
121
118
|
if not has_iteration_loop(content):
|
|
122
119
|
return False
|
|
123
|
-
return bool(
|
|
120
|
+
return bool(find_bare_path_segments(content))
|
|
124
121
|
|
|
125
122
|
|
|
126
123
|
def main() -> None:
|
|
@@ -150,6 +147,14 @@ def main() -> None:
|
|
|
150
147
|
"permissionDecisionReason": CORRECTIVE_MESSAGE,
|
|
151
148
|
}
|
|
152
149
|
}
|
|
150
|
+
raw_tool_name_for_log = hook_input.get("tool_name", "")
|
|
151
|
+
tool_name_for_log = raw_tool_name_for_log if isinstance(raw_tool_name_for_log, str) else ""
|
|
152
|
+
log_hook_block(
|
|
153
|
+
calling_hook_name="workflow_substitution_slot_blocker.py",
|
|
154
|
+
hook_event="PreToolUse",
|
|
155
|
+
block_reason=CORRECTIVE_MESSAGE,
|
|
156
|
+
tool_name=tool_name_for_log,
|
|
157
|
+
)
|
|
153
158
|
print(json.dumps(deny_payload))
|
|
154
159
|
sys.stdout.flush()
|
|
155
160
|
sys.exit(0)
|