claude-dev-env 1.57.2 → 1.59.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 -2
- package/_shared/pr-loop/scripts/code_rules_gate.py +36 -3
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +6 -0
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
- package/_shared/pr-loop/scripts/reviews_disabled.py +12 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +265 -0
- package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +29 -0
- package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
- package/bin/install.mjs +317 -54
- package/bin/install.test.mjs +478 -3
- package/docs/CODE_RULES.md +3 -3
- package/hooks/blocking/code_rules_annotations_length.py +153 -0
- package/hooks/blocking/code_rules_dead_dataclass_field.py +319 -0
- package/hooks/blocking/code_rules_duplicate_body.py +287 -0
- package/hooks/blocking/code_rules_enforcer.py +175 -21
- package/hooks/blocking/code_rules_magic_values.py +98 -0
- package/hooks/blocking/code_rules_shared.py +41 -0
- package/hooks/blocking/destructive_command_blocker.py +1027 -12
- package/hooks/blocking/hook_prose_detector_consistency.py +150 -0
- package/hooks/blocking/intent_only_ending_blocker.py +155 -0
- package/hooks/blocking/session_handoff_blocker.py +190 -0
- package/hooks/blocking/subprocess_budget_completeness.py +380 -0
- package/hooks/blocking/test_code_rules_enforcer_annotations.py +225 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
- package/hooks/blocking/test_code_rules_enforcer_dead_dataclass_field.py +467 -0
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +330 -0
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +179 -0
- package/hooks/blocking/test_code_rules_enforcer_magic_slice_bounds.py +133 -0
- package/hooks/blocking/test_destructive_command_blocker.py +622 -3
- package/hooks/blocking/test_hook_prose_detector_consistency.py +265 -0
- package/hooks/blocking/test_intent_only_ending_blocker.py +175 -0
- package/hooks/blocking/test_session_handoff_blocker.py +312 -0
- package/hooks/blocking/test_subprocess_budget_completeness.py +588 -0
- package/hooks/blocking/test_workflow_substitution_slot_blocker.py +242 -0
- package/hooks/blocking/workflow_substitution_slot_blocker.py +159 -0
- package/hooks/hooks.json +25 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +16 -0
- package/hooks/hooks_constants/dead_dataclass_field_constants.py +25 -0
- package/hooks/hooks_constants/destructive_command_segment_constants.py +178 -0
- package/hooks/hooks_constants/duplicate_function_body_constants.py +17 -0
- package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +30 -0
- package/hooks/hooks_constants/messages.py +4 -0
- package/hooks/hooks_constants/session_handoff_blocker_constants.py +10 -0
- package/hooks/hooks_constants/subprocess_budget_completeness_constants.py +5 -0
- package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +22 -0
- package/hooks/workflow/auto_formatter.py +26 -1
- package/hooks/workflow/test_auto_formatter.py +134 -0
- package/package.json +1 -1
- package/rules/conservative-action.md +1 -0
- package/rules/docstring-prose-matches-implementation.md +43 -0
- package/rules/hook-prose-matches-detector.md +26 -0
- package/rules/long-horizon-autonomy.md +43 -0
- package/rules/no-inline-destructive-literals.md +11 -0
- package/rules/workflow-substitution-slots.md +7 -0
- package/skills/autoconverge/SKILL.md +68 -6
- package/skills/autoconverge/reference/closing-report.md +44 -0
- package/skills/autoconverge/reference/convergence.md +7 -3
- package/skills/autoconverge/reference/stop-conditions.md +7 -2
- package/skills/autoconverge/workflow/autoconverge_report_constants/__init__.py +0 -0
- package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +105 -0
- package/skills/autoconverge/workflow/converge.contract.test.mjs +30 -1
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +265 -0
- package/skills/autoconverge/workflow/converge.mjs +106 -38
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a11d903476b803493.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a26213978adeef6fb.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a3def0d15ed9d9110.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a41f41b1b708ee3b7.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a758b880abecc3ff7.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-a8897b89656b1bd16.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-abd463d744a1437bc.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ad19d027ae8ee1816.jsonl +2 -0
- package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +259 -0
- package/skills/autoconverge/workflow/render_report.py +903 -0
- package/skills/autoconverge/workflow/test_render_report.py +484 -0
- package/skills/pr-converge/scripts/check_convergence.py +195 -64
- package/skills/pr-converge/scripts/test_check_convergence.py +173 -2
- package/skills/update/SKILL.md +37 -5
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Unit tests for workflow_substitution_slot_blocker 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
|
+
_HOOKS_ROOT = _HOOK_DIR.parent
|
|
12
|
+
for _each_root in (str(_HOOK_DIR), str(_HOOKS_ROOT)):
|
|
13
|
+
if _each_root not in sys.path:
|
|
14
|
+
sys.path.insert(0, _each_root)
|
|
15
|
+
|
|
16
|
+
hook_spec = importlib.util.spec_from_file_location(
|
|
17
|
+
"workflow_substitution_slot_blocker",
|
|
18
|
+
_HOOK_DIR / "workflow_substitution_slot_blocker.py",
|
|
19
|
+
)
|
|
20
|
+
assert hook_spec is not None
|
|
21
|
+
assert hook_spec.loader is not None
|
|
22
|
+
hook_module = importlib.util.module_from_spec(hook_spec)
|
|
23
|
+
hook_spec.loader.exec_module(hook_module)
|
|
24
|
+
|
|
25
|
+
content_has_violation = hook_module.content_has_violation
|
|
26
|
+
find_bare_index_segments = hook_module.find_bare_index_segments
|
|
27
|
+
find_bare_path_segments = hook_module.find_bare_path_segments
|
|
28
|
+
has_iteration_loop = hook_module.has_iteration_loop
|
|
29
|
+
written_content = hook_module.written_content
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_VIOLATING_TEMPLATE = (
|
|
33
|
+
"For EACH candidate i, build a bible dir cand_i per the contract.\n"
|
|
34
|
+
" & ${PY} -c \"...Path(r'${args.work_dir}\\\\cand_i\\\\plate.svg')...\"\n"
|
|
35
|
+
" & ${PY} compose.py --out ${args.work_dir}\\\\cand_i\\\\sample.png "
|
|
36
|
+
"--glow <candidate glow_hex>\n"
|
|
37
|
+
'Return: {key: "cand_i", name, sample_png}\n'
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
_FIXED_TEMPLATE = (
|
|
41
|
+
"For EACH candidate i, build a bible dir cand_<i> per the contract.\n"
|
|
42
|
+
" & ${PY} -c \"...Path(r'${args.work_dir}\\\\cand_<i>\\\\plate.svg')...\"\n"
|
|
43
|
+
" & ${PY} compose.py --out ${args.work_dir}\\\\cand_<i>\\\\sample.png "
|
|
44
|
+
"--glow <candidate glow_hex>\n"
|
|
45
|
+
'Return: {key: "cand_<i>", name, sample_png}\n'
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_detects_bare_index_in_path_segment() -> None:
|
|
50
|
+
assert find_bare_index_segments(
|
|
51
|
+
"render Path(r'${args.work_dir}\\\\cand_i\\\\plate.svg')"
|
|
52
|
+
) == {"cand_i"}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_detects_quoted_key_when_token_also_appears_as_path_segment() -> None:
|
|
56
|
+
looped_path_and_key = "write ${work}\\\\cand_i\\\\plate.svg\n{key: \"cand_i\", name}"
|
|
57
|
+
assert "cand_i" in find_bare_index_segments(looped_path_and_key)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_quoted_key_alone_without_path_segment_is_not_detected() -> None:
|
|
61
|
+
assert find_bare_index_segments('{key: "metric_i", name}') == set()
|
|
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
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_marked_substitution_slot_is_not_a_bare_segment() -> None:
|
|
79
|
+
assert (
|
|
80
|
+
find_bare_index_segments(
|
|
81
|
+
"render Path(r'${args.work_dir}\\\\cand_<i>\\\\plate.svg')"
|
|
82
|
+
)
|
|
83
|
+
== set()
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_violating_template_is_flagged() -> None:
|
|
88
|
+
assert content_has_violation(_VIOLATING_TEMPLATE) is True
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_fixed_template_passes() -> None:
|
|
92
|
+
assert content_has_violation(_FIXED_TEMPLATE) is False
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_template_without_angle_convention_is_not_flagged() -> None:
|
|
96
|
+
no_convention = (
|
|
97
|
+
"For EACH candidate i, write to ${work}\\\\cand_i\\\\plate.svg and return.\n"
|
|
98
|
+
)
|
|
99
|
+
assert content_has_violation(no_convention) is False
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_template_without_loop_is_not_flagged() -> None:
|
|
103
|
+
no_loop = "Write the plate to ${work}\\\\cand_i\\\\plate.svg using <glow_hex>.\n"
|
|
104
|
+
assert content_has_violation(no_loop) is False
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_each_inside_an_ordinary_word_is_not_a_loop() -> None:
|
|
108
|
+
for each_word in ("reach", "teach", "breach", "bleach", "preach", "impeach"):
|
|
109
|
+
assert has_iteration_loop(each_word + " the end") is False
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_standalone_lowercase_each_in_prose_is_not_a_loop() -> None:
|
|
113
|
+
assert has_iteration_loop("use each color once") is False
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_standalone_each_keyword_is_a_loop() -> None:
|
|
117
|
+
assert has_iteration_loop("For EACH candidate i") is True
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_lowercase_for_each_phrase_is_still_a_loop() -> None:
|
|
121
|
+
assert has_iteration_loop("for each candidate") is True
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_benign_prose_each_with_fixed_literal_is_not_flagged() -> None:
|
|
125
|
+
benign_template = (
|
|
126
|
+
"Render each layer to <layer.svg>.\n"
|
|
127
|
+
"The protocol field is named 'tier_i' as a permanent identifier.\n"
|
|
128
|
+
)
|
|
129
|
+
assert content_has_violation(benign_template) is False
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_quoted_permanent_identifier_key_is_not_flagged() -> None:
|
|
133
|
+
permanent_identifier_template = (
|
|
134
|
+
'For EACH candidate, render <plate.svg>.\nReturn {key: "metric_i", value}'
|
|
135
|
+
)
|
|
136
|
+
assert content_has_violation(permanent_identifier_template) is False
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_quoted_key_flagged_only_when_token_also_appears_as_path_segment() -> None:
|
|
140
|
+
looping_path_and_key = (
|
|
141
|
+
"For EACH candidate, write <plate.svg> to ${work}\\\\cand_i\\\\plate.svg.\n"
|
|
142
|
+
'Return {key: "cand_i", name}\n'
|
|
143
|
+
)
|
|
144
|
+
assert content_has_violation(looping_path_and_key) is True
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_written_content_reads_multiedit_new_strings() -> None:
|
|
148
|
+
multi_edit_input = {
|
|
149
|
+
"edits": [
|
|
150
|
+
{"old_string": "x", "new_string": "first ${work}\\\\cand_i\\\\plate.svg"},
|
|
151
|
+
{"old_string": "y", "new_string": "second <glow_hex>"},
|
|
152
|
+
]
|
|
153
|
+
}
|
|
154
|
+
combined = written_content("MultiEdit", multi_edit_input)
|
|
155
|
+
assert "cand_i" in combined
|
|
156
|
+
assert "<glow_hex>" in combined
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _run_main_with_io(input_text: str) -> str:
|
|
160
|
+
with mock.patch("sys.stdin", io.StringIO(input_text)):
|
|
161
|
+
with mock.patch("sys.stdout", new_callable=io.StringIO) as mock_stdout:
|
|
162
|
+
try:
|
|
163
|
+
hook_module.main()
|
|
164
|
+
except SystemExit:
|
|
165
|
+
pass
|
|
166
|
+
return mock_stdout.getvalue()
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_main_blocks_violating_workflow_write() -> None:
|
|
170
|
+
hook_input = {
|
|
171
|
+
"tool_name": "Write",
|
|
172
|
+
"tool_input": {
|
|
173
|
+
"file_path": "/repo/scripts/shared_palette_gate.workflow.js",
|
|
174
|
+
"content": _VIOLATING_TEMPLATE,
|
|
175
|
+
},
|
|
176
|
+
}
|
|
177
|
+
output_text = _run_main_with_io(json.dumps(hook_input))
|
|
178
|
+
payload = json.loads(output_text)
|
|
179
|
+
assert payload["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_main_blocks_violating_workflow_edit() -> None:
|
|
183
|
+
hook_input = {
|
|
184
|
+
"tool_name": "Edit",
|
|
185
|
+
"tool_input": {
|
|
186
|
+
"file_path": "/repo/scripts/shared_palette_gate.workflow.js",
|
|
187
|
+
"new_string": _VIOLATING_TEMPLATE,
|
|
188
|
+
},
|
|
189
|
+
}
|
|
190
|
+
output_text = _run_main_with_io(json.dumps(hook_input))
|
|
191
|
+
payload = json.loads(output_text)
|
|
192
|
+
assert payload["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def test_main_blocks_violating_workflow_multiedit() -> None:
|
|
196
|
+
hook_input = {
|
|
197
|
+
"tool_name": "MultiEdit",
|
|
198
|
+
"tool_input": {
|
|
199
|
+
"file_path": "/repo/scripts/shared_palette_gate.workflow.js",
|
|
200
|
+
"edits": [{"old_string": "placeholder", "new_string": _VIOLATING_TEMPLATE}],
|
|
201
|
+
},
|
|
202
|
+
}
|
|
203
|
+
output_text = _run_main_with_io(json.dumps(hook_input))
|
|
204
|
+
payload = json.loads(output_text)
|
|
205
|
+
assert payload["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def test_main_passes_fixed_workflow_write() -> None:
|
|
209
|
+
hook_input = {
|
|
210
|
+
"tool_name": "Write",
|
|
211
|
+
"tool_input": {
|
|
212
|
+
"file_path": "/repo/scripts/shared_palette_gate.workflow.js",
|
|
213
|
+
"content": _FIXED_TEMPLATE,
|
|
214
|
+
},
|
|
215
|
+
}
|
|
216
|
+
assert _run_main_with_io(json.dumps(hook_input)) == ""
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def test_main_passes_non_workflow_path() -> None:
|
|
220
|
+
hook_input = {
|
|
221
|
+
"tool_name": "Write",
|
|
222
|
+
"tool_input": {
|
|
223
|
+
"file_path": "/repo/scripts/helper.js",
|
|
224
|
+
"content": _VIOLATING_TEMPLATE,
|
|
225
|
+
},
|
|
226
|
+
}
|
|
227
|
+
assert _run_main_with_io(json.dumps(hook_input)) == ""
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def test_main_passes_wrong_tool_name() -> None:
|
|
231
|
+
hook_input = {
|
|
232
|
+
"tool_name": "Bash",
|
|
233
|
+
"tool_input": {
|
|
234
|
+
"file_path": "/repo/scripts/x.workflow.js",
|
|
235
|
+
"command": "echo cand_i",
|
|
236
|
+
},
|
|
237
|
+
}
|
|
238
|
+
assert _run_main_with_io(json.dumps(hook_input)) == ""
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def test_main_passes_malformed_json() -> None:
|
|
242
|
+
assert _run_main_with_io("not valid json {{{") == ""
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse hook: block bare per-iteration index tokens in .workflow.js templates.
|
|
3
|
+
|
|
4
|
+
Root cause: a `.workflow.js` agent-prompt block that loops over an index (for
|
|
5
|
+
example "For EACH candidate i, build a dir cand_i ...") sometimes writes the
|
|
6
|
+
per-iteration directory or output key as a bare token like `cand_i`. A bare
|
|
7
|
+
`_i`-suffixed token reads as a fixed literal rather than a substitution slot, so
|
|
8
|
+
an agent can plausibly create one literal directory named `cand_i` and overwrite
|
|
9
|
+
it across every iteration -- collapsing an N-iteration gate into a single run.
|
|
10
|
+
|
|
11
|
+
The established convention in these templates marks every per-call substitution
|
|
12
|
+
slot with angle brackets (`<plate.svg>`, `<object.svg>`, `<glow_hex>`). The fix
|
|
13
|
+
is to mark the index the same way: `cand_<i>`.
|
|
14
|
+
|
|
15
|
+
Detection strategy: act only on Write/Edit to a path ending in `.workflow.js`.
|
|
16
|
+
Within the written content, fire only when ALL of the following hold, so the
|
|
17
|
+
hook catches exactly the bare-literal shape and never a template that does not
|
|
18
|
+
use the substitution convention at all:
|
|
19
|
+
|
|
20
|
+
1. the content uses the angle-bracket substitution convention somewhere
|
|
21
|
+
(a `<...>` slot), proving the author marks per-call values that way;
|
|
22
|
+
2. the content establishes a per-iteration loop (an "each"/"EACH"/"for i"
|
|
23
|
+
style phrase, or an explicit `cand_0` enumeration);
|
|
24
|
+
3. a bare `<word>_<i|j|k>` token appears as a per-iteration path segment
|
|
25
|
+
(adjacent to a path separator). A quoted structured-output key whose name
|
|
26
|
+
ends in `_i|_j|_k` (a permanent identifier with no per-iteration path) does
|
|
27
|
+
not fire on its own; only the per-iteration path shape triggers a block.
|
|
28
|
+
|
|
29
|
+
Fails OPEN (approves) on malformed input or a non-workflow path; the violation
|
|
30
|
+
shape is narrow enough that a false negative is preferable to blocking
|
|
31
|
+
unrelated edits.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
import json
|
|
35
|
+
import re
|
|
36
|
+
import sys
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
|
|
39
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
40
|
+
if _hooks_dir not in sys.path:
|
|
41
|
+
sys.path.insert(0, _hooks_dir)
|
|
42
|
+
|
|
43
|
+
from hooks_constants.workflow_substitution_slot_blocker_constants import ( # noqa: E402
|
|
44
|
+
CORRECTIVE_MESSAGE,
|
|
45
|
+
EDIT_TOOL_NAME,
|
|
46
|
+
MULTI_EDIT_TOOL_NAME,
|
|
47
|
+
WORKFLOW_FILE_SUFFIX,
|
|
48
|
+
WRITE_TOOL_NAME,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def multi_edit_new_strings(all_tool_input: dict[str, object]) -> str:
|
|
52
|
+
all_edits = all_tool_input.get("edits", [])
|
|
53
|
+
if not isinstance(all_edits, list):
|
|
54
|
+
return ""
|
|
55
|
+
all_new_strings = [
|
|
56
|
+
each_edit["new_string"]
|
|
57
|
+
for each_edit in all_edits
|
|
58
|
+
if isinstance(each_edit, dict) and isinstance(each_edit.get("new_string"), str)
|
|
59
|
+
]
|
|
60
|
+
return "\n".join(all_new_strings)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def written_content(tool_name: str, all_tool_input: dict[str, object]) -> str:
|
|
64
|
+
if tool_name == WRITE_TOOL_NAME:
|
|
65
|
+
content = all_tool_input.get("content", "")
|
|
66
|
+
return content if isinstance(content, str) else ""
|
|
67
|
+
if tool_name == EDIT_TOOL_NAME:
|
|
68
|
+
new_string = all_tool_input.get("new_string", "")
|
|
69
|
+
return new_string if isinstance(new_string, str) else ""
|
|
70
|
+
if tool_name == MULTI_EDIT_TOOL_NAME:
|
|
71
|
+
return multi_edit_new_strings(all_tool_input)
|
|
72
|
+
return ""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def target_path(all_tool_input: dict[str, object]) -> str:
|
|
76
|
+
file_path = all_tool_input.get("file_path", "")
|
|
77
|
+
return file_path if isinstance(file_path, str) else ""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def uses_angle_slot_convention(content: str) -> bool:
|
|
81
|
+
angle_slot_pattern = re.compile(r"<[^<>\n]+>")
|
|
82
|
+
return bool(angle_slot_pattern.search(content))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def has_iteration_loop(content: str) -> bool:
|
|
86
|
+
loop_phrase_pattern = re.compile(
|
|
87
|
+
r"\b(?:for\s+each|each\s+candidate|for\s+[ijk]\b|candidate\s+[ijk]\b|cand_0)\b",
|
|
88
|
+
re.IGNORECASE,
|
|
89
|
+
)
|
|
90
|
+
uppercase_each_keyword_pattern = re.compile(r"\bEACH\b")
|
|
91
|
+
return bool(
|
|
92
|
+
loop_phrase_pattern.search(content)
|
|
93
|
+
or uppercase_each_keyword_pattern.search(content)
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def find_bare_path_segments(content: str) -> set[str]:
|
|
98
|
+
loop_letters = "ijk"
|
|
99
|
+
path_context = re.compile(
|
|
100
|
+
r"(?:[\\/]\s*([A-Za-z][\w]*?_[" + loop_letters + r"])(?![\w>])"
|
|
101
|
+
r"|([A-Za-z][\w]*?_[" + loop_letters + r"])(?![\w>])\s*[\\/])"
|
|
102
|
+
)
|
|
103
|
+
all_path_segments: set[str] = set()
|
|
104
|
+
for each_match in path_context.finditer(content):
|
|
105
|
+
each_token = next(
|
|
106
|
+
(each_group for each_group in each_match.groups() if each_group),
|
|
107
|
+
"",
|
|
108
|
+
)
|
|
109
|
+
if each_token:
|
|
110
|
+
all_path_segments.add(each_token)
|
|
111
|
+
return all_path_segments
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def find_bare_index_segments(content: str) -> set[str]:
|
|
115
|
+
return find_bare_path_segments(content)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def content_has_violation(content: str) -> bool:
|
|
119
|
+
if not uses_angle_slot_convention(content):
|
|
120
|
+
return False
|
|
121
|
+
if not has_iteration_loop(content):
|
|
122
|
+
return False
|
|
123
|
+
return bool(find_bare_index_segments(content))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def main() -> None:
|
|
127
|
+
try:
|
|
128
|
+
hook_input = json.load(sys.stdin)
|
|
129
|
+
except json.JSONDecodeError:
|
|
130
|
+
sys.exit(0)
|
|
131
|
+
|
|
132
|
+
tool_name = hook_input.get("tool_name", "")
|
|
133
|
+
if tool_name not in (WRITE_TOOL_NAME, EDIT_TOOL_NAME, MULTI_EDIT_TOOL_NAME):
|
|
134
|
+
sys.exit(0)
|
|
135
|
+
|
|
136
|
+
all_tool_input = hook_input.get("tool_input", {})
|
|
137
|
+
if not isinstance(all_tool_input, dict):
|
|
138
|
+
sys.exit(0)
|
|
139
|
+
|
|
140
|
+
if not target_path(all_tool_input).endswith(WORKFLOW_FILE_SUFFIX):
|
|
141
|
+
sys.exit(0)
|
|
142
|
+
|
|
143
|
+
if not content_has_violation(written_content(tool_name, all_tool_input)):
|
|
144
|
+
sys.exit(0)
|
|
145
|
+
|
|
146
|
+
deny_payload = {
|
|
147
|
+
"hookSpecificOutput": {
|
|
148
|
+
"hookEventName": "PreToolUse",
|
|
149
|
+
"permissionDecision": "deny",
|
|
150
|
+
"permissionDecisionReason": CORRECTIVE_MESSAGE,
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
print(json.dumps(deny_payload))
|
|
154
|
+
sys.stdout.flush()
|
|
155
|
+
sys.exit(0)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
if __name__ == "__main__":
|
|
159
|
+
main()
|
package/hooks/hooks.json
CHANGED
|
@@ -44,12 +44,27 @@
|
|
|
44
44
|
"type": "command",
|
|
45
45
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/state_description_blocker.py",
|
|
46
46
|
"timeout": 10
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"type": "command",
|
|
50
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/subprocess_budget_completeness.py",
|
|
51
|
+
"timeout": 10
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"type": "command",
|
|
55
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/hook_prose_detector_consistency.py",
|
|
56
|
+
"timeout": 10
|
|
47
57
|
}
|
|
48
58
|
]
|
|
49
59
|
},
|
|
50
60
|
{
|
|
51
61
|
"matcher": "Write|Edit|MultiEdit",
|
|
52
62
|
"hooks": [
|
|
63
|
+
{
|
|
64
|
+
"type": "command",
|
|
65
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/workflow_substitution_slot_blocker.py",
|
|
66
|
+
"timeout": 10
|
|
67
|
+
},
|
|
53
68
|
{
|
|
54
69
|
"type": "command",
|
|
55
70
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/open_questions_in_plans_blocker.py",
|
|
@@ -214,6 +229,16 @@
|
|
|
214
229
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/question_to_user_enforcer.py",
|
|
215
230
|
"timeout": 10
|
|
216
231
|
},
|
|
232
|
+
{
|
|
233
|
+
"type": "command",
|
|
234
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/intent_only_ending_blocker.py",
|
|
235
|
+
"timeout": 10
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
"type": "command",
|
|
239
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/session_handoff_blocker.py",
|
|
240
|
+
"timeout": 10
|
|
241
|
+
},
|
|
217
242
|
{
|
|
218
243
|
"type": "command",
|
|
219
244
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/diagnostic/hook_log_stop_wrapper.py",
|
|
@@ -24,6 +24,8 @@ ALL_MIGRATION_PATH_PATTERNS = {"/migrations/", "\\migrations\\"}
|
|
|
24
24
|
ADVISORY_LINE_THRESHOLD_SOFT = 400
|
|
25
25
|
ADVISORY_LINE_THRESHOLD_HARD = 1000
|
|
26
26
|
|
|
27
|
+
DENY_REASON_ISSUE_PREVIEW_COUNT = 10
|
|
28
|
+
|
|
27
29
|
ALL_BOOLEAN_NAME_PREFIXES: tuple[str, ...] = ("is_", "has_", "should_", "can_", "was_", "did_")
|
|
28
30
|
UPPER_SNAKE_CONSTANT_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]*$")
|
|
29
31
|
|
|
@@ -108,6 +110,20 @@ ALL_BUILTIN_DICT_METHOD_NAMES: frozenset[str] = frozenset({
|
|
|
108
110
|
})
|
|
109
111
|
ALL_UNION_TYPING_NAMES: frozenset[str] = frozenset({"Optional", "Union"})
|
|
110
112
|
ALL_SELF_AND_CLS_PARAMETER_NAMES: frozenset[str] = frozenset({"self", "cls"})
|
|
113
|
+
ANNOTATION_BY_PYTEST_FIXTURE: dict[str, str] = {
|
|
114
|
+
"tmp_path": "Path",
|
|
115
|
+
"tmp_path_factory": "pytest.TempPathFactory",
|
|
116
|
+
"monkeypatch": "pytest.MonkeyPatch",
|
|
117
|
+
"capsys": "pytest.CaptureFixture[str]",
|
|
118
|
+
"capfd": "pytest.CaptureFixture[str]",
|
|
119
|
+
"caplog": "pytest.LogCaptureFixture",
|
|
120
|
+
"request": "pytest.FixtureRequest",
|
|
121
|
+
}
|
|
122
|
+
KNOWN_PYTEST_FIXTURE_ANNOTATION_MESSAGE_SUFFIX: str = (
|
|
123
|
+
"known pytest fixture parameter must carry its single documented type "
|
|
124
|
+
"(CODE_RULES §6; pytest builtin fixture reference "
|
|
125
|
+
"https://docs.pytest.org/en/stable/reference/fixtures.html)"
|
|
126
|
+
)
|
|
111
127
|
ALL_LOOP_INDEX_LETTER_EXEMPTIONS: frozenset[str] = frozenset({"i", "j", "k", "_"})
|
|
112
128
|
EACH_PREFIX = "each_"
|
|
113
129
|
BARE_EACH_TOKEN = "each"
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Constants for the dead dataclass-field detector in ``code_rules_enforcer``.
|
|
2
|
+
|
|
3
|
+
Lives under the hooks-tree ``hooks_constants`` package so module-level
|
|
4
|
+
UPPER_SNAKE constants satisfy the CODE_RULES "constants live in config"
|
|
5
|
+
requirement and share a home with the other hook-tree configuration.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
ALL_DATACLASS_DECORATOR_NAMES: frozenset[str] = frozenset({"dataclass", "dataclasses"})
|
|
9
|
+
ATTRGETTER_FUNCTION_NAME: str = "attrgetter"
|
|
10
|
+
CLASSVAR_ANNOTATION_NAME: str = "ClassVar"
|
|
11
|
+
GETATTR_FUNCTION_NAME: str = "getattr"
|
|
12
|
+
GETATTR_NAME_ARGUMENT_MINIMUM: int = 2
|
|
13
|
+
ALL_REFLECTIVE_FIELD_CONSUMER_NAMES: frozenset[str] = frozenset(
|
|
14
|
+
{"asdict", "astuple", "fields", "replace", "vars"}
|
|
15
|
+
)
|
|
16
|
+
WHOLE_INSTANCE_DICT_ATTRIBUTE_NAME: str = "__dict__"
|
|
17
|
+
ALL_WHOLE_INSTANCE_STRINGIFY_NAMES: frozenset[str] = frozenset(
|
|
18
|
+
{"str", "repr", "format"}
|
|
19
|
+
)
|
|
20
|
+
MAX_DEAD_DATACLASS_FIELD_ISSUES: int = 25
|
|
21
|
+
DEAD_DATACLASS_FIELD_GUIDANCE: str = (
|
|
22
|
+
"field is assigned but never read in this file - remove the field and the code"
|
|
23
|
+
" that only exists to populate it, or read it where the value is needed"
|
|
24
|
+
" (CODE_RULES §9.8)"
|
|
25
|
+
)
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Segment-splitting and command-name constants for the destructive command blocker compound rm guard."""
|
|
2
|
+
|
|
3
|
+
ALL_SHELL_CONTROL_OPERATOR_TOKENS: frozenset[str] = frozenset({"&&", "||", ";", "|&", "|", "&", "\n", "\r"})
|
|
4
|
+
ALL_COMMAND_LAUNCHER_WRAPPER_COMMANDS: frozenset[str] = frozenset(
|
|
5
|
+
{
|
|
6
|
+
"timeout",
|
|
7
|
+
"nohup",
|
|
8
|
+
"nice",
|
|
9
|
+
"ionice",
|
|
10
|
+
"stdbuf",
|
|
11
|
+
"time",
|
|
12
|
+
"setsid",
|
|
13
|
+
"chrt",
|
|
14
|
+
"taskset",
|
|
15
|
+
}
|
|
16
|
+
)
|
|
17
|
+
ALL_LAUNCHERS_REQUIRING_A_POSITIONAL_VALUE: frozenset[str] = frozenset(
|
|
18
|
+
{
|
|
19
|
+
"timeout",
|
|
20
|
+
"chrt",
|
|
21
|
+
"taskset",
|
|
22
|
+
}
|
|
23
|
+
)
|
|
24
|
+
ALL_INTERPRETER_AND_WRAPPER_COMMANDS: frozenset[str] = frozenset(
|
|
25
|
+
{
|
|
26
|
+
"sh",
|
|
27
|
+
"bash",
|
|
28
|
+
"zsh",
|
|
29
|
+
"dash",
|
|
30
|
+
"ksh",
|
|
31
|
+
"tcsh",
|
|
32
|
+
"csh",
|
|
33
|
+
"fish",
|
|
34
|
+
"pwsh",
|
|
35
|
+
"powershell",
|
|
36
|
+
"cmd",
|
|
37
|
+
"eval",
|
|
38
|
+
"exec",
|
|
39
|
+
"source",
|
|
40
|
+
"sudo",
|
|
41
|
+
"su",
|
|
42
|
+
"env",
|
|
43
|
+
"xargs",
|
|
44
|
+
"awk",
|
|
45
|
+
"gawk",
|
|
46
|
+
"mawk",
|
|
47
|
+
"nawk",
|
|
48
|
+
"make",
|
|
49
|
+
"tclsh",
|
|
50
|
+
"expect",
|
|
51
|
+
"lua",
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
ALL_REMOTE_AND_PROGRAM_STRING_EXECUTORS: frozenset[str] = frozenset(
|
|
55
|
+
{
|
|
56
|
+
"ssh",
|
|
57
|
+
"python",
|
|
58
|
+
"python2",
|
|
59
|
+
"python3",
|
|
60
|
+
"perl",
|
|
61
|
+
"ruby",
|
|
62
|
+
"node",
|
|
63
|
+
"deno",
|
|
64
|
+
"bun",
|
|
65
|
+
"php",
|
|
66
|
+
}
|
|
67
|
+
)
|
|
68
|
+
ALL_STRING_ARGUMENT_EXECUTION_FLAGS: frozenset[str] = frozenset({"-c", "-e"})
|
|
69
|
+
ALL_BENIGN_COMPOUND_SEGMENT_COMMANDS: frozenset[str] = frozenset(
|
|
70
|
+
{
|
|
71
|
+
"echo",
|
|
72
|
+
"printf",
|
|
73
|
+
"gh",
|
|
74
|
+
"head",
|
|
75
|
+
"tail",
|
|
76
|
+
"cat",
|
|
77
|
+
"ls",
|
|
78
|
+
"grep",
|
|
79
|
+
"wc",
|
|
80
|
+
"sort",
|
|
81
|
+
"uniq",
|
|
82
|
+
"true",
|
|
83
|
+
"git",
|
|
84
|
+
}
|
|
85
|
+
)
|
|
86
|
+
OUTPUT_REDIRECTION_OPERATOR_PATTERN: str = r"(?:\d+|&)?>>?\|?(?!&[\d-])"
|
|
87
|
+
ALL_FILE_WRITING_OUTPUT_FLAGS_BY_BENIGN_PROGRAM: dict[str, frozenset[str]] = {
|
|
88
|
+
"sort": frozenset({"-o", "--output"}),
|
|
89
|
+
}
|
|
90
|
+
ALL_GIT_CONFIG_READ_ONLY_FLAGS: frozenset[str] = frozenset(
|
|
91
|
+
{"--get", "--get-all", "--get-regexp", "--list", "-l", "--get-urlmatch"}
|
|
92
|
+
)
|
|
93
|
+
ALL_GIT_REMOTE_READ_ONLY_VERBS: frozenset[str] = frozenset({"show", "get-url"})
|
|
94
|
+
ALL_GIT_FETCH_FORCE_FLAGS: frozenset[str] = frozenset({"-f", "--force"})
|
|
95
|
+
ALL_GH_HTTP_WRITE_METHOD_FLAGS: frozenset[str] = frozenset({"-X", "--method"})
|
|
96
|
+
ALL_GH_HTTP_WRITE_METHODS: frozenset[str] = frozenset({"POST", "PUT", "PATCH", "DELETE"})
|
|
97
|
+
GH_HTTP_READ_ONLY_METHOD: str = "GET"
|
|
98
|
+
GH_SHORT_METHOD_FLAG_PREFIX: str = "-X"
|
|
99
|
+
GH_LONG_METHOD_FLAG_EQUALS_PREFIX: str = "--method="
|
|
100
|
+
ALL_GH_API_REQUEST_BODY_FIELD_FLAGS: frozenset[str] = frozenset(
|
|
101
|
+
{"-f", "--raw-field", "-F", "--field", "--input"}
|
|
102
|
+
)
|
|
103
|
+
ALL_GH_API_GLUED_REQUEST_BODY_FIELD_FLAG_PREFIXES: tuple[str, ...] = (
|
|
104
|
+
"-f",
|
|
105
|
+
"-F",
|
|
106
|
+
"--raw-field=",
|
|
107
|
+
"--field=",
|
|
108
|
+
"--input=",
|
|
109
|
+
)
|
|
110
|
+
ALL_READ_ONLY_GIT_SUBCOMMANDS: frozenset[str] = frozenset(
|
|
111
|
+
{
|
|
112
|
+
"status",
|
|
113
|
+
"log",
|
|
114
|
+
"show",
|
|
115
|
+
"diff",
|
|
116
|
+
"rev-parse",
|
|
117
|
+
"rev-list",
|
|
118
|
+
"describe",
|
|
119
|
+
"config",
|
|
120
|
+
"remote",
|
|
121
|
+
"fetch",
|
|
122
|
+
"ls-files",
|
|
123
|
+
"ls-remote",
|
|
124
|
+
"ls-tree",
|
|
125
|
+
"cat-file",
|
|
126
|
+
"blame",
|
|
127
|
+
"shortlog",
|
|
128
|
+
"name-rev",
|
|
129
|
+
"for-each-ref",
|
|
130
|
+
"symbolic-ref",
|
|
131
|
+
"merge-base",
|
|
132
|
+
"count-objects",
|
|
133
|
+
"version",
|
|
134
|
+
"help",
|
|
135
|
+
}
|
|
136
|
+
)
|
|
137
|
+
ALL_READ_ONLY_GH_SUBCOMMANDS: frozenset[str] = frozenset(
|
|
138
|
+
{
|
|
139
|
+
"view",
|
|
140
|
+
"list",
|
|
141
|
+
"status",
|
|
142
|
+
"checks",
|
|
143
|
+
"diff",
|
|
144
|
+
"search",
|
|
145
|
+
"browse",
|
|
146
|
+
"api",
|
|
147
|
+
}
|
|
148
|
+
)
|
|
149
|
+
ALL_READ_ONLY_SUBCOMMANDS_BY_DISPATCHING_PROGRAM: dict[str, frozenset[str]] = {
|
|
150
|
+
"git": ALL_READ_ONLY_GIT_SUBCOMMANDS,
|
|
151
|
+
"gh": ALL_READ_ONLY_GH_SUBCOMMANDS,
|
|
152
|
+
}
|
|
153
|
+
ALL_READ_ONLY_SUBCOMMAND_POSITION_DEPTHS_BY_DISPATCHING_PROGRAM: dict[str, int] = {
|
|
154
|
+
"git": 1,
|
|
155
|
+
"gh": 2,
|
|
156
|
+
}
|
|
157
|
+
LAUNCHER_POSITIONAL_VALUE_SHAPE_PATTERN: str = (
|
|
158
|
+
r"^(?:0x[0-9A-Fa-f]+"
|
|
159
|
+
r"|[0-9]+(?:[.,][0-9]+)?[smhd]?"
|
|
160
|
+
r"|[0-9]+(?:-[0-9]+)?(?:,[0-9]+(?:-[0-9]+)?)*)$"
|
|
161
|
+
)
|
|
162
|
+
ALL_LAUNCHER_OPTIONS_TAKING_SEPARATE_VALUE: frozenset[str] = frozenset(
|
|
163
|
+
{
|
|
164
|
+
"-s",
|
|
165
|
+
"--signal",
|
|
166
|
+
"-k",
|
|
167
|
+
"--kill-after",
|
|
168
|
+
"-n",
|
|
169
|
+
"-o",
|
|
170
|
+
"--output",
|
|
171
|
+
"-e",
|
|
172
|
+
"--error",
|
|
173
|
+
"-i",
|
|
174
|
+
"--input",
|
|
175
|
+
"--classdata",
|
|
176
|
+
}
|
|
177
|
+
)
|
|
178
|
+
ALL_SUBSHELL_GROUPING_CHARACTERS: str = "({"
|