claude-dev-env 2.30.0 → 2.30.1
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/.agents/skills/e-simplify/SKILL.md +20 -8
- package/hooks/blocking/code_rules_enforcer.py +2 -5
- package/hooks/blocking/shell_substitution_blocker.py +106 -30
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +14 -0
- package/hooks/blocking/test_code_rules_validation_phase.py +51 -0
- package/hooks/blocking/test_shell_substitution_blocker.py +69 -0
- package/hooks/hooks.json +1 -1
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +0 -1
- package/hooks/hooks_constants/shell_substitution_blocker_constants.py +4 -1
- package/hooks/hooks_constants/task_tool_prompt_constants.py +13 -0
- package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +0 -9
- package/hooks/session/task_tool_prompt.py +37 -0
- package/hooks/session/test_task_tool_prompt.py +65 -0
- package/package.json +1 -1
- package/hooks/hooks_constants/task_list_loop_starter_constants.py +0 -19
- package/hooks/session/task_list_loop_starter.py +0 -45
- package/hooks/session/test_task_list_loop_starter.py +0 -63
|
@@ -25,7 +25,7 @@ Triggers: `/e-simplify` on the current diff (or a PR/branch/path passed as an ar
|
|
|
25
25
|
|
|
26
26
|
## The process
|
|
27
27
|
|
|
28
|
-
`/simplify → 4 cleanup agents in parallel → apply the fixes`
|
|
28
|
+
`/simplify → up to 4 cleanup agents in parallel → apply the fixes`
|
|
29
29
|
|
|
30
30
|
You are improving the quality of the changed code, not hunting for bugs. Review
|
|
31
31
|
it for reuse, simplification, efficiency, and altitude issues, then fix what you
|
|
@@ -40,13 +40,25 @@ include the working-tree changes in scope — the review often runs before the
|
|
|
40
40
|
commit. If a PR number, branch name, or file path was passed as an argument,
|
|
41
41
|
review that target instead. Treat this diff as the review scope.
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
**Diff-size cap.** Run `git diff --shortstat` on the same range Phase 0 used
|
|
44
|
+
to get the diff, and add its insertions and deletions for the changed-line
|
|
45
|
+
count. Default ceiling: 400 changed lines for one run — a tunable default the
|
|
46
|
+
user may adjust. Above the ceiling, narrow the target first — a subdirectory,
|
|
47
|
+
a package, or one file path — and review that narrowed target. Do not launch
|
|
48
|
+
Phase 1 over a diff above the ceiling.
|
|
44
49
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
+
### Phase 1 — Review (up to 4 cleanup agents in parallel)
|
|
51
|
+
|
|
52
|
+
**Lens cap.** Four lenses is the ceiling, not the floor. Default split — also
|
|
53
|
+
tunable — by the `git diff --shortstat` changed-line count from Phase 0:
|
|
54
|
+
under 100 changed lines, run 2 lenses (reuse and simplification); at 100
|
|
55
|
+
changed lines or above, and under the diff-size cap, run all 4 lenses.
|
|
56
|
+
|
|
57
|
+
Launch the review agents for the lenses in scope for this run — 2 or 4 by the
|
|
58
|
+
cap above — via the Agent tool, all in a single message so they run
|
|
59
|
+
concurrently. Pass each agent the diff and one of the angles below. Each
|
|
60
|
+
returns its findings with `file`, `line`, a one-line `summary`, and the
|
|
61
|
+
concrete cost (what is duplicated, wasted, or harder to maintain).
|
|
50
62
|
|
|
51
63
|
#### Reuse
|
|
52
64
|
|
|
@@ -79,7 +91,7 @@ special cases.
|
|
|
79
91
|
|
|
80
92
|
### Phase 2 — Apply the fixes
|
|
81
93
|
|
|
82
|
-
Wait for
|
|
94
|
+
Wait for every launched agent to complete, dedup findings that point at the same
|
|
83
95
|
line or mechanism, and fix each remaining one directly. Skip any finding whose
|
|
84
96
|
fix would change intended behavior, require changes well outside the reviewed
|
|
85
97
|
diff, or that you judge to be a false positive — note the skip rather than
|
|
@@ -799,11 +799,8 @@ def _hook_infrastructure_blocking_issues(
|
|
|
799
799
|
Returns:
|
|
800
800
|
The in-scope violations for the target at the given phase.
|
|
801
801
|
"""
|
|
802
|
-
effective_content =
|
|
803
|
-
|
|
804
|
-
changed_line_numbers(prior_full_file_content, full_file_content)
|
|
805
|
-
if full_file_content is not None
|
|
806
|
-
else None
|
|
802
|
+
effective_content, all_changed_lines = _effective_content_and_changed_lines(
|
|
803
|
+
content, full_file_content, prior_full_file_content
|
|
807
804
|
)
|
|
808
805
|
all_issues: list[str] = []
|
|
809
806
|
if phase == FULL_GATE_PHASE:
|
|
@@ -24,13 +24,28 @@ are treated as literal only when the count of preceding backslashes is odd;
|
|
|
24
24
|
an even count (including zero) means the backtick is a live substitution.
|
|
25
25
|
|
|
26
26
|
Bash arithmetic expansion ``$((...))`` is explicitly NOT blocked: it does not
|
|
27
|
-
spawn a subshell and does not defeat the allowlist matcher
|
|
28
|
-
|
|
27
|
+
spawn a subshell and does not defeat the allowlist matcher. Telling it apart
|
|
28
|
+
from a disguised command substitution takes more than counting two open
|
|
29
|
+
parens, because bash itself falls back from arithmetic to command
|
|
30
|
+
substitution when the expansion body does not close as arithmetic. Observed
|
|
31
|
+
against real bash:
|
|
32
|
+
|
|
33
|
+
echo $((1 + 2)) prints 3 (arithmetic; not blocked)
|
|
34
|
+
echo $((cd /tmp) && pwd) prints the new cwd (command substitution; blocked)
|
|
35
|
+
echo $((1+2) ) runs "1+2" as a command, rc=0, no output
|
|
36
|
+
(command substitution; blocked)
|
|
37
|
+
|
|
38
|
+
Bash's own rule is the terminator: walk forward from ``$((`` tracking paren
|
|
39
|
+
depth; when depth returns to zero on a closer immediately preceded by
|
|
40
|
+
another ``)`` (an ``))`` pair), the expansion is arithmetic and passes. When
|
|
41
|
+
the closer that brings depth to zero is a lone ``)``, bash has fallen back to
|
|
42
|
+
running a parenthesized command inside the substitution, so it is blocked.
|
|
43
|
+
This hook applies that walk, tracking paren depth from the opening ``$((``.
|
|
29
44
|
|
|
30
45
|
False positives are still possible when the user is intentionally writing
|
|
31
46
|
shell-script content (for example, authoring a .sh file via heredoc) and the
|
|
32
47
|
substitution is literal payload rather than something to execute. In that
|
|
33
|
-
case,
|
|
48
|
+
case, author the file with the Write tool, not a Bash heredoc.
|
|
34
49
|
"""
|
|
35
50
|
|
|
36
51
|
from __future__ import annotations
|
|
@@ -39,34 +54,95 @@ import json
|
|
|
39
54
|
import sys
|
|
40
55
|
from pathlib import Path
|
|
41
56
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
)
|
|
57
|
+
try:
|
|
58
|
+
from hooks_constants.shell_substitution_blocker_constants import (
|
|
59
|
+
BASH_TOOL_NAME,
|
|
60
|
+
CLOSE_PAREN_CHARACTER,
|
|
61
|
+
COMMAND_KEY,
|
|
62
|
+
CORRECTIVE_MESSAGE,
|
|
63
|
+
DENY_DECISION,
|
|
64
|
+
DOLLAR_PAREN_PATTERN,
|
|
65
|
+
EVEN_BACKSLASH_BACKTICK_PATTERN,
|
|
66
|
+
HOOK_EVENT_NAME,
|
|
67
|
+
HOOK_EVENT_NAME_KEY,
|
|
68
|
+
HOOK_SPECIFIC_OUTPUT_KEY,
|
|
69
|
+
OPEN_PAREN_CHARACTER,
|
|
70
|
+
PAREN_DEPTH_AFTER_DOUBLE_OPEN,
|
|
71
|
+
PERMISSION_DECISION_KEY,
|
|
72
|
+
PERMISSION_DECISION_REASON_KEY,
|
|
73
|
+
PROCESS_SUBSTITUTION_PATTERN,
|
|
74
|
+
SINGLE_QUOTED_RUN_PATTERN,
|
|
75
|
+
STRIPPED_RUN_REPLACEMENT,
|
|
76
|
+
TOOL_INPUT_KEY,
|
|
77
|
+
TOOL_NAME_KEY,
|
|
78
|
+
)
|
|
79
|
+
except ModuleNotFoundError:
|
|
80
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
81
|
+
if _hooks_dir not in sys.path:
|
|
82
|
+
sys.path.insert(0, _hooks_dir)
|
|
83
|
+
from hooks_constants.shell_substitution_blocker_constants import (
|
|
84
|
+
BASH_TOOL_NAME,
|
|
85
|
+
CLOSE_PAREN_CHARACTER,
|
|
86
|
+
COMMAND_KEY,
|
|
87
|
+
CORRECTIVE_MESSAGE,
|
|
88
|
+
DENY_DECISION,
|
|
89
|
+
DOLLAR_PAREN_PATTERN,
|
|
90
|
+
EVEN_BACKSLASH_BACKTICK_PATTERN,
|
|
91
|
+
HOOK_EVENT_NAME,
|
|
92
|
+
HOOK_EVENT_NAME_KEY,
|
|
93
|
+
HOOK_SPECIFIC_OUTPUT_KEY,
|
|
94
|
+
OPEN_PAREN_CHARACTER,
|
|
95
|
+
PAREN_DEPTH_AFTER_DOUBLE_OPEN,
|
|
96
|
+
PERMISSION_DECISION_KEY,
|
|
97
|
+
PERMISSION_DECISION_REASON_KEY,
|
|
98
|
+
PROCESS_SUBSTITUTION_PATTERN,
|
|
99
|
+
SINGLE_QUOTED_RUN_PATTERN,
|
|
100
|
+
STRIPPED_RUN_REPLACEMENT,
|
|
101
|
+
TOOL_INPUT_KEY,
|
|
102
|
+
TOOL_NAME_KEY,
|
|
103
|
+
)
|
|
64
104
|
|
|
65
105
|
|
|
66
106
|
def _strip_single_quoted_runs(command: str) -> str:
|
|
67
107
|
return SINGLE_QUOTED_RUN_PATTERN.sub(STRIPPED_RUN_REPLACEMENT, command)
|
|
68
108
|
|
|
69
109
|
|
|
110
|
+
def _closes_as_arithmetic_expansion(command: str, scan_start_index: int) -> bool:
|
|
111
|
+
"""Return True when a ``$((`` expansion closes on an ``))`` pair.
|
|
112
|
+
|
|
113
|
+
See the module docstring for the real-bash evidence behind this rule.
|
|
114
|
+
"""
|
|
115
|
+
paren_depth = PAREN_DEPTH_AFTER_DOUBLE_OPEN
|
|
116
|
+
was_previous_character_close_paren = False
|
|
117
|
+
for each_index in range(scan_start_index, len(command)):
|
|
118
|
+
current_character = command[each_index]
|
|
119
|
+
if current_character == OPEN_PAREN_CHARACTER:
|
|
120
|
+
paren_depth += 1
|
|
121
|
+
was_previous_character_close_paren = False
|
|
122
|
+
continue
|
|
123
|
+
if current_character != CLOSE_PAREN_CHARACTER:
|
|
124
|
+
was_previous_character_close_paren = False
|
|
125
|
+
continue
|
|
126
|
+
paren_depth -= 1
|
|
127
|
+
if paren_depth == 0:
|
|
128
|
+
return was_previous_character_close_paren
|
|
129
|
+
was_previous_character_close_paren = True
|
|
130
|
+
return False
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _has_unsafe_dollar_paren_expansion(command: str) -> bool:
|
|
134
|
+
"""Return True for a `$(` that is not a safe `$((...))` arithmetic pair."""
|
|
135
|
+
for each_match in DOLLAR_PAREN_PATTERN.finditer(command):
|
|
136
|
+
is_arithmetic_style_open = (
|
|
137
|
+
command[each_match.end() : each_match.end() + 1] == OPEN_PAREN_CHARACTER
|
|
138
|
+
)
|
|
139
|
+
if not is_arithmetic_style_open:
|
|
140
|
+
return True
|
|
141
|
+
if not _closes_as_arithmetic_expansion(command, each_match.end() + 1):
|
|
142
|
+
return True
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
|
|
70
146
|
def has_shell_substitution(command: str) -> bool:
|
|
71
147
|
"""Return True when a Bash command carries a live shell substitution.
|
|
72
148
|
|
|
@@ -79,18 +155,18 @@ def has_shell_substitution(command: str) -> bool:
|
|
|
79
155
|
echo $((2 + 2)) ok: arithmetic expansion
|
|
80
156
|
|
|
81
157
|
Single-quoted runs are stripped first because bash substitutes nothing
|
|
82
|
-
inside them. A backtick preceded by an odd
|
|
83
|
-
|
|
158
|
+
inside them. A backtick preceded by an odd backslash count is escaped.
|
|
159
|
+
A `$((` pair is walked to its terminator (module docstring) to tell
|
|
160
|
+
real arithmetic from a disguised subshell.
|
|
84
161
|
|
|
85
162
|
Args:
|
|
86
163
|
command: The raw Bash command string from the tool input.
|
|
87
164
|
|
|
88
165
|
Returns:
|
|
89
|
-
True when a substitution the
|
|
90
|
-
present outside single quotes.
|
|
166
|
+
True when a substitution the matcher cannot descend into is present.
|
|
91
167
|
"""
|
|
92
168
|
scannable_command = _strip_single_quoted_runs(command)
|
|
93
|
-
if
|
|
169
|
+
if _has_unsafe_dollar_paren_expansion(scannable_command):
|
|
94
170
|
return True
|
|
95
171
|
if EVEN_BACKSLASH_BACKTICK_PATTERN.search(scannable_command):
|
|
96
172
|
return True
|
|
@@ -261,6 +261,20 @@ def test_emit_decision_re_emits_silent_deny_fields(
|
|
|
261
261
|
assert emitted_payload["suppressOutput"] is True
|
|
262
262
|
|
|
263
263
|
|
|
264
|
+
def test_dispatcher_denies_subshell_disguised_as_arithmetic_expansion() -> None:
|
|
265
|
+
"""The dispatcher denies a `$((cd X) && Y)` subshell, not only the bare hook.
|
|
266
|
+
|
|
267
|
+
`echo $((cd /tmp) && pwd)` opens with a `$((` two-character lookahead
|
|
268
|
+
match, so a function-level test alone does not prove the block reaches
|
|
269
|
+
the live Bash tool path.
|
|
270
|
+
"""
|
|
271
|
+
payload_text = _bash_payload("echo $((cd /tmp) && pwd)")
|
|
272
|
+
decision, _reason = _decision_from_stdout(
|
|
273
|
+
_run_process(_DISPATCHER_SCRIPT, payload_text).stdout
|
|
274
|
+
)
|
|
275
|
+
assert decision == "deny"
|
|
276
|
+
|
|
277
|
+
|
|
264
278
|
def test_dispatch_emits_deny_immediately_and_skips_later_hooks(
|
|
265
279
|
monkeypatch: pytest.MonkeyPatch,
|
|
266
280
|
capsys: pytest.CaptureFixture[str],
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import ast
|
|
5
6
|
import contextlib
|
|
6
7
|
import importlib
|
|
7
8
|
import importlib.util
|
|
@@ -10,6 +11,7 @@ import re
|
|
|
10
11
|
import shutil
|
|
11
12
|
import sys
|
|
12
13
|
import tempfile
|
|
14
|
+
import textwrap
|
|
13
15
|
from pathlib import Path
|
|
14
16
|
from types import ModuleType
|
|
15
17
|
from unittest.mock import DEFAULT, MagicMock, patch
|
|
@@ -279,3 +281,52 @@ def test_gate_running_selects_the_full_gate(tmp_path: Path) -> None:
|
|
|
279
281
|
assert scoped_violations is not None
|
|
280
282
|
blocking_violations, _advisory_violations = scoped_violations
|
|
281
283
|
assert any("9999" in each_issue for each_issue in blocking_violations)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
ALL_CHECK_FUNCTION_NAME_PREFIXES = ("check_", "advise_")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _check_names_called_in(enforcer_function_name: str) -> set[str]:
|
|
290
|
+
"""Return the check-function names called directly inside a phase router.
|
|
291
|
+
|
|
292
|
+
Parses the router's own source and collects every direct call to a
|
|
293
|
+
module-level name beginning with a check prefix, so a roster constant can
|
|
294
|
+
assert against the calls production really makes rather than against a
|
|
295
|
+
second hand-written literal.
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
enforcer_function_name: The attribute name of the router on the
|
|
299
|
+
enforcer module, such as ``_python_full_gate_only_issues``.
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
The set of check-function names the router calls directly.
|
|
303
|
+
"""
|
|
304
|
+
router_source = textwrap.dedent(
|
|
305
|
+
inspect.getsource(getattr(code_rules_enforcer, enforcer_function_name))
|
|
306
|
+
)
|
|
307
|
+
return {
|
|
308
|
+
each_node.func.id
|
|
309
|
+
for each_node in ast.walk(ast.parse(router_source))
|
|
310
|
+
if isinstance(each_node, ast.Call)
|
|
311
|
+
and isinstance(each_node.func, ast.Name)
|
|
312
|
+
and each_node.func.id.startswith(ALL_CHECK_FUNCTION_NAME_PREFIXES)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def test_full_gate_only_roster_matches_the_checks_the_router_calls() -> None:
|
|
317
|
+
"""The roster constant tracks the calls in _python_full_gate_only_issues."""
|
|
318
|
+
assert (
|
|
319
|
+
_check_names_called_in("_python_full_gate_only_issues")
|
|
320
|
+
== validation_phase_constants.ALL_FULL_GATE_ONLY_CHECK_NAMES
|
|
321
|
+
), "ALL_FULL_GATE_ONLY_CHECK_NAMES must name exactly the router's own calls"
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def test_hook_infrastructure_edit_lane_roster_matches_the_checks_the_router_calls() -> None:
|
|
325
|
+
"""The roster constant tracks the always-run calls in the hook-infrastructure router."""
|
|
326
|
+
all_always_run_check_names = _check_names_called_in(
|
|
327
|
+
"_hook_infrastructure_blocking_issues"
|
|
328
|
+
) - validation_phase_constants.ALL_FULL_GATE_ONLY_CHECK_NAMES
|
|
329
|
+
assert (
|
|
330
|
+
all_always_run_check_names
|
|
331
|
+
== validation_phase_constants.ALL_HOOK_INFRASTRUCTURE_EDIT_LANE_CHECK_NAMES
|
|
332
|
+
), "ALL_HOOK_INFRASTRUCTURE_EDIT_LANE_CHECK_NAMES must name exactly the router's own calls"
|
|
@@ -122,3 +122,72 @@ def test_allows_arithmetic_expansion() -> None:
|
|
|
122
122
|
"tool_input": {"command": "echo $((2+2))"},
|
|
123
123
|
}
|
|
124
124
|
assert _run_hook(payload).stdout == ""
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_allows_arithmetic_expansion_with_nested_parens() -> None:
|
|
128
|
+
payload = {
|
|
129
|
+
"tool_name": "Bash",
|
|
130
|
+
"tool_input": {"command": "echo $(( (1+2) * 3 ))"},
|
|
131
|
+
}
|
|
132
|
+
assert _run_hook(payload).stdout == ""
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_allows_arithmetic_expansion_whose_body_bash_itself_rejects() -> None:
|
|
136
|
+
payload = {
|
|
137
|
+
"tool_name": "Bash",
|
|
138
|
+
"tool_input": {"command": "echo $((echo hi))"},
|
|
139
|
+
}
|
|
140
|
+
assert _run_hook(payload).stdout == ""
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_allows_arithmetic_lookalike_inside_single_quotes() -> None:
|
|
144
|
+
payload = {
|
|
145
|
+
"tool_name": "Bash",
|
|
146
|
+
"tool_input": {"command": "echo '$((cd /tmp) && pwd)'"},
|
|
147
|
+
}
|
|
148
|
+
assert _run_hook(payload).stdout == ""
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_denies_subshell_disguised_as_arithmetic_expansion() -> None:
|
|
152
|
+
payload = {
|
|
153
|
+
"tool_name": "Bash",
|
|
154
|
+
"tool_input": {"command": "echo $((cd /tmp) && pwd)"},
|
|
155
|
+
}
|
|
156
|
+
response = json.loads(_run_hook(payload).stdout)
|
|
157
|
+
assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_denies_parenthesized_command_word_disguised_as_arithmetic() -> None:
|
|
161
|
+
payload = {
|
|
162
|
+
"tool_name": "Bash",
|
|
163
|
+
"tool_input": {"command": "echo $((1+2) )"},
|
|
164
|
+
}
|
|
165
|
+
response = json.loads(_run_hook(payload).stdout)
|
|
166
|
+
assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_denies_command_substitution_nested_inside_arithmetic_expansion() -> None:
|
|
170
|
+
payload = {
|
|
171
|
+
"tool_name": "Bash",
|
|
172
|
+
"tool_input": {"command": "echo $(( $(id -u) + 1 ))"},
|
|
173
|
+
}
|
|
174
|
+
response = json.loads(_run_hook(payload).stdout)
|
|
175
|
+
assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_denies_backtick_substitution_nested_inside_arithmetic_expansion() -> None:
|
|
179
|
+
payload = {
|
|
180
|
+
"tool_name": "Bash",
|
|
181
|
+
"tool_input": {"command": "echo $(( `id -u` + 1 ))"},
|
|
182
|
+
}
|
|
183
|
+
response = json.loads(_run_hook(payload).stdout)
|
|
184
|
+
assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_denies_nested_command_substitution_without_arithmetic() -> None:
|
|
188
|
+
payload = {
|
|
189
|
+
"tool_name": "Bash",
|
|
190
|
+
"tool_input": {"command": "echo $(echo $(whoami))"},
|
|
191
|
+
}
|
|
192
|
+
response = json.loads(_run_hook(payload).stdout)
|
|
193
|
+
assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
|
package/hooks/hooks.json
CHANGED
|
@@ -41,7 +41,10 @@ PERMISSION_DECISION_KEY = "permissionDecision"
|
|
|
41
41
|
DENY_DECISION = "deny"
|
|
42
42
|
PERMISSION_DECISION_REASON_KEY = "permissionDecisionReason"
|
|
43
43
|
|
|
44
|
-
DOLLAR_PAREN_PATTERN = re.compile(r"\$\(
|
|
44
|
+
DOLLAR_PAREN_PATTERN = re.compile(r"\$\(")
|
|
45
|
+
OPEN_PAREN_CHARACTER = "("
|
|
46
|
+
CLOSE_PAREN_CHARACTER = ")"
|
|
47
|
+
PAREN_DEPTH_AFTER_DOUBLE_OPEN = 2
|
|
45
48
|
EVEN_BACKSLASH_BACKTICK_PATTERN = re.compile(r"(?<!\\)(?:\\\\)*`")
|
|
46
49
|
PROCESS_SUBSTITUTION_PATTERN = re.compile(r"[<>]\(")
|
|
47
50
|
SINGLE_QUOTED_RUN_PATTERN = re.compile(r"'[^']*'")
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Directive text for the task_tool_prompt SessionStart hook."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"TASK_TOOL_DIRECTIVE",
|
|
7
|
+
]
|
|
8
|
+
|
|
9
|
+
TASK_TOOL_DIRECTIVE = (
|
|
10
|
+
"TASK TRACKING: Track this session's work with the session's task or todo "
|
|
11
|
+
"tracking tool, whichever this host provides. Create tasks at the start "
|
|
12
|
+
"of the session, then update them as work proceeds."
|
|
13
|
+
)
|
|
@@ -17,7 +17,6 @@ if str(_HOOKS_ROOT) not in sys.path:
|
|
|
17
17
|
sys.path.insert(0, str(_HOOKS_ROOT))
|
|
18
18
|
|
|
19
19
|
from hooks_constants.code_rules_enforcer_constants import (
|
|
20
|
-
ALL_HOOK_INFRASTRUCTURE_EDIT_LANE_CHECK_NAMES,
|
|
21
20
|
ALL_VALIDATION_PHASES,
|
|
22
21
|
CONFIG_DIRECTORY_SEGMENT,
|
|
23
22
|
CONSTANTS_MODULE_SUFFIX,
|
|
@@ -111,11 +110,3 @@ def test_validation_phase_constants_are_reexported_and_render() -> None:
|
|
|
111
110
|
phase="bogus_phase", all_phases=sorted(ALL_VALIDATION_PHASES)
|
|
112
111
|
)
|
|
113
112
|
assert "bogus_phase" in rendered_message
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
def test_hook_infrastructure_edit_lane_check_names_are_reexported() -> None:
|
|
117
|
-
assert ALL_HOOK_INFRASTRUCTURE_EDIT_LANE_CHECK_NAMES == {
|
|
118
|
-
"check_same_file_inline_duplicate_body",
|
|
119
|
-
"check_zero_payload_function_alias",
|
|
120
|
-
"check_unanchored_command_dispatch",
|
|
121
|
-
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""SessionStart hook — direct the session to track work with its task tool.
|
|
3
|
+
|
|
4
|
+
At session start this hook emits an ``additionalContext`` directive asking
|
|
5
|
+
the session to track its work with the task or todo tracking tool its host
|
|
6
|
+
provides: create tasks at the start of the session, then update them as
|
|
7
|
+
work proceeds.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
import _path_setup # noqa: F401
|
|
16
|
+
|
|
17
|
+
from hooks_constants.task_tool_prompt_constants import TASK_TOOL_DIRECTIVE
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_session_directive() -> str:
|
|
21
|
+
"""Return the task-tool directive emitted at session start."""
|
|
22
|
+
return TASK_TOOL_DIRECTIVE
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main() -> None:
|
|
26
|
+
"""Emit the task-tool directive as SessionStart additionalContext."""
|
|
27
|
+
payload = {
|
|
28
|
+
"hookSpecificOutput": {
|
|
29
|
+
"hookEventName": "SessionStart",
|
|
30
|
+
"additionalContext": build_session_directive(),
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
sys.stdout.write(json.dumps(payload) + "\n")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
if __name__ == "__main__":
|
|
37
|
+
main()
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Tests for task_tool_prompt — SessionStart hook that directs task-tool tracking."""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from io import StringIO
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from unittest.mock import patch
|
|
9
|
+
|
|
10
|
+
_SESSION_DIR = Path(__file__).resolve().parent
|
|
11
|
+
_HOOKS_ROOT = _SESSION_DIR.parent
|
|
12
|
+
for each_sys_path_entry in (str(_SESSION_DIR), str(_HOOKS_ROOT)):
|
|
13
|
+
if each_sys_path_entry not in sys.path:
|
|
14
|
+
sys.path.insert(0, each_sys_path_entry)
|
|
15
|
+
|
|
16
|
+
prompt = importlib.import_module("task_tool_prompt")
|
|
17
|
+
task_tool_prompt_constants = importlib.import_module("hooks_constants.task_tool_prompt_constants")
|
|
18
|
+
TASK_TOOL_DIRECTIVE = task_tool_prompt_constants.TASK_TOOL_DIRECTIVE
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _run_main() -> str:
|
|
22
|
+
"""Return stdout produced by running the hook's main()."""
|
|
23
|
+
captured_stdout = StringIO()
|
|
24
|
+
with patch("sys.stdout", captured_stdout):
|
|
25
|
+
prompt.main()
|
|
26
|
+
return captured_stdout.getvalue()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TestSessionDirective:
|
|
30
|
+
def test_main_emits_additional_context(self) -> None:
|
|
31
|
+
emitted = json.loads(_run_main())
|
|
32
|
+
hook_output = emitted["hookSpecificOutput"]
|
|
33
|
+
assert hook_output["hookEventName"] == "SessionStart"
|
|
34
|
+
assert "additionalContext" in hook_output
|
|
35
|
+
|
|
36
|
+
def test_directive_names_the_tracking_tool(self) -> None:
|
|
37
|
+
emitted = json.loads(_run_main())
|
|
38
|
+
assert "task or todo tracking tool" in emitted["hookSpecificOutput"]["additionalContext"]
|
|
39
|
+
|
|
40
|
+
def test_directive_names_no_vendor_or_host_product(self) -> None:
|
|
41
|
+
emitted = json.loads(_run_main())
|
|
42
|
+
additional_context = emitted["hookSpecificOutput"]["additionalContext"]
|
|
43
|
+
for each_forbidden_term in ("Claude", "Cursor", "Codex", "TaskCreate", "TodoWrite"):
|
|
44
|
+
assert each_forbidden_term not in additional_context
|
|
45
|
+
|
|
46
|
+
def test_directive_says_create_tasks_at_session_start(self) -> None:
|
|
47
|
+
emitted = json.loads(_run_main())
|
|
48
|
+
additional_context = emitted["hookSpecificOutput"]["additionalContext"]
|
|
49
|
+
assert "start of the session" in additional_context
|
|
50
|
+
|
|
51
|
+
def test_directive_says_update_as_work_proceeds(self) -> None:
|
|
52
|
+
emitted = json.loads(_run_main())
|
|
53
|
+
additional_context = emitted["hookSpecificOutput"]["additionalContext"]
|
|
54
|
+
assert "update" in additional_context
|
|
55
|
+
assert "as work proceeds" in additional_context
|
|
56
|
+
|
|
57
|
+
def test_directive_names_no_loop_cadence(self) -> None:
|
|
58
|
+
emitted = json.loads(_run_main())
|
|
59
|
+
additional_context = emitted["hookSpecificOutput"]["additionalContext"]
|
|
60
|
+
assert "/loop" not in additional_context
|
|
61
|
+
assert "cadence" not in additional_context
|
|
62
|
+
assert "minute" not in additional_context
|
|
63
|
+
|
|
64
|
+
def test_build_session_directive_returns_the_shared_constant(self) -> None:
|
|
65
|
+
assert prompt.build_session_directive() == TASK_TOOL_DIRECTIVE
|
package/package.json
CHANGED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
"""Directive text for the task_list_loop_starter SessionStart hook."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
__all__ = [
|
|
6
|
-
"TASK_LIST_MAINTENANCE_INSTRUCTION",
|
|
7
|
-
"TASK_LIST_LOOP_DIRECTIVE",
|
|
8
|
-
]
|
|
9
|
-
|
|
10
|
-
TASK_LIST_MAINTENANCE_INSTRUCTION = "build or update the task list using the task tools"
|
|
11
|
-
|
|
12
|
-
TASK_LIST_LOOP_DIRECTIVE = (
|
|
13
|
-
"TASK LIST MAINTENANCE: Keep this session's task list current on a 10-minute "
|
|
14
|
-
"cadence. Reuse the active task-list maintenance loop when available. Start "
|
|
15
|
-
"Claude Code's built-in /loop skill with a 10-minute interval when this session "
|
|
16
|
-
"needs its first loop: "
|
|
17
|
-
f'/loop 10m "{TASK_LIST_MAINTENANCE_INSTRUCTION}". Run that instruction once '
|
|
18
|
-
"immediately when starting the loop, then let the loop refresh the list every 10 minutes."
|
|
19
|
-
)
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""SessionStart hook — start a task-list maintenance loop for the session.
|
|
3
|
-
|
|
4
|
-
At session start this hook emits an ``additionalContext`` directive asking Claude
|
|
5
|
-
to keep the session's task list current on a 10-minute cadence. Claude reuses the
|
|
6
|
-
active ``/loop`` skill when available and starts the skill for the session's first
|
|
7
|
-
task-list maintenance loop.
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
from __future__ import annotations
|
|
11
|
-
|
|
12
|
-
import json
|
|
13
|
-
import sys
|
|
14
|
-
from pathlib import Path
|
|
15
|
-
|
|
16
|
-
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
17
|
-
if _hooks_dir not in sys.path:
|
|
18
|
-
sys.path.insert(0, _hooks_dir)
|
|
19
|
-
|
|
20
|
-
from hooks_constants.task_list_loop_starter_constants import ( # noqa: E402
|
|
21
|
-
TASK_LIST_LOOP_DIRECTIVE,
|
|
22
|
-
)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def build_session_directive() -> str:
|
|
26
|
-
"""Return the task-list loop directive emitted at session start."""
|
|
27
|
-
return TASK_LIST_LOOP_DIRECTIVE
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def main() -> None:
|
|
31
|
-
"""Emit the task-list loop directive as SessionStart additionalContext."""
|
|
32
|
-
print(
|
|
33
|
-
json.dumps(
|
|
34
|
-
{
|
|
35
|
-
"hookSpecificOutput": {
|
|
36
|
-
"hookEventName": "SessionStart",
|
|
37
|
-
"additionalContext": build_session_directive(),
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
)
|
|
41
|
-
)
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if __name__ == "__main__":
|
|
45
|
-
main()
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
"""Tests for task_list_loop_starter — SessionStart hook that starts a task-list loop."""
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
import sys
|
|
5
|
-
from io import StringIO
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
from unittest.mock import patch
|
|
8
|
-
|
|
9
|
-
_SESSION_DIR = Path(__file__).resolve().parent
|
|
10
|
-
_HOOKS_ROOT = _SESSION_DIR.parent
|
|
11
|
-
for each_sys_path_entry in (str(_SESSION_DIR), str(_HOOKS_ROOT)):
|
|
12
|
-
if each_sys_path_entry not in sys.path:
|
|
13
|
-
sys.path.insert(0, each_sys_path_entry)
|
|
14
|
-
|
|
15
|
-
import task_list_loop_starter as starter
|
|
16
|
-
|
|
17
|
-
from hooks_constants.task_list_loop_starter_constants import (
|
|
18
|
-
TASK_LIST_LOOP_DIRECTIVE,
|
|
19
|
-
TASK_LIST_MAINTENANCE_INSTRUCTION,
|
|
20
|
-
)
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def _run_main() -> str:
|
|
24
|
-
"""Return stdout produced by running the hook's main()."""
|
|
25
|
-
captured_stdout = StringIO()
|
|
26
|
-
with patch("sys.stdout", captured_stdout):
|
|
27
|
-
starter.main()
|
|
28
|
-
return captured_stdout.getvalue()
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
class TestSessionDirective:
|
|
32
|
-
def test_main_emits_additional_context(self) -> None:
|
|
33
|
-
emitted = json.loads(_run_main())
|
|
34
|
-
hook_output = emitted["hookSpecificOutput"]
|
|
35
|
-
assert hook_output["hookEventName"] == "SessionStart"
|
|
36
|
-
assert "additionalContext" in hook_output
|
|
37
|
-
|
|
38
|
-
def test_directive_carries_the_one_line_instruction(self) -> None:
|
|
39
|
-
emitted = json.loads(_run_main())
|
|
40
|
-
assert TASK_LIST_MAINTENANCE_INSTRUCTION in emitted["hookSpecificOutput"]["additionalContext"]
|
|
41
|
-
|
|
42
|
-
def test_directive_names_the_ten_minute_cadence(self) -> None:
|
|
43
|
-
emitted = json.loads(_run_main())
|
|
44
|
-
assert "10-minute" in emitted["hookSpecificOutput"]["additionalContext"]
|
|
45
|
-
|
|
46
|
-
def test_directive_names_the_loop_command(self) -> None:
|
|
47
|
-
emitted = json.loads(_run_main())
|
|
48
|
-
assert "/loop 10m" in emitted["hookSpecificOutput"]["additionalContext"]
|
|
49
|
-
|
|
50
|
-
def test_directive_runs_the_instruction_immediately(self) -> None:
|
|
51
|
-
emitted = json.loads(_run_main())
|
|
52
|
-
assert "Run that instruction once immediately when starting the loop, then" in emitted[
|
|
53
|
-
"hookSpecificOutput"
|
|
54
|
-
]["additionalContext"]
|
|
55
|
-
|
|
56
|
-
def test_directive_reuses_an_existing_loop(self) -> None:
|
|
57
|
-
emitted = json.loads(_run_main())
|
|
58
|
-
assert "Reuse the active task-list maintenance loop when available." in emitted[
|
|
59
|
-
"hookSpecificOutput"
|
|
60
|
-
]["additionalContext"]
|
|
61
|
-
|
|
62
|
-
def test_build_session_directive_returns_the_shared_constant(self) -> None:
|
|
63
|
-
assert starter.build_session_directive() == TASK_LIST_LOOP_DIRECTIVE
|