claude-dev-env 8.103.1 → 8.104.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hooks/blocking/test_wakeup_cap_gate.py +280 -0
- package/hooks/blocking/wakeup_cap_gate.py +400 -0
- package/hooks/features/conduct-gates.md +4 -0
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/wakeup_cap_constants.py +56 -0
- package/hooks/test_context_budget_hooks.py +1 -0
- package/package.json +1 -1
- package/scripts/policy_lint/adapter_configuration.py +5 -0
- package/scripts/policy_lint/config/constants.py +1 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import json
|
|
3
|
+
from datetime import datetime, timedelta, timezone
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
import wakeup_cap_gate
|
|
9
|
+
|
|
10
|
+
SEND_LATER_TOOL = "mcp__claude-code-remote__send_later"
|
|
11
|
+
CREATE_TRIGGER_TOOL = "mcp__claude-code-remote__create_trigger"
|
|
12
|
+
UPDATE_TRIGGER_TOOL = "mcp__claude-code-remote__update_trigger"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def run_gate(
|
|
16
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
17
|
+
capsys: pytest.CaptureFixture[str],
|
|
18
|
+
tool_name: str,
|
|
19
|
+
tool_input: dict[str, object],
|
|
20
|
+
) -> tuple[int, str, str]:
|
|
21
|
+
hook_input = {"hook_event_name": "PreToolUse", "tool_name": tool_name, "tool_input": tool_input}
|
|
22
|
+
monkeypatch.setattr(
|
|
23
|
+
"sys.stdin", io.TextIOWrapper(io.BytesIO(json.dumps(hook_input).encode("utf-8")))
|
|
24
|
+
)
|
|
25
|
+
exit_code = wakeup_cap_gate.main()
|
|
26
|
+
captured = capsys.readouterr()
|
|
27
|
+
return exit_code, captured.out, captured.err
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def updated_input_of(stdout_text: str) -> dict[str, object]:
|
|
31
|
+
payload = json.loads(stdout_text)
|
|
32
|
+
hook_output = payload["hookSpecificOutput"]
|
|
33
|
+
assert hook_output["permissionDecision"] == "allow"
|
|
34
|
+
return hook_output["updatedInput"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def utc_text(moment: datetime) -> str:
|
|
38
|
+
return moment.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def minutes_from_now(minutes: float) -> datetime:
|
|
42
|
+
return datetime.now(timezone.utc) + timedelta(minutes=minutes)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@pytest.fixture(autouse=True)
|
|
46
|
+
def isolated_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
47
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
48
|
+
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@pytest.mark.parametrize("requested_minutes", [58, 59, 60])
|
|
52
|
+
def test_send_later_at_the_cache_edge_is_rewritten_to_the_step_down(
|
|
53
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], requested_minutes: int
|
|
54
|
+
) -> None:
|
|
55
|
+
tool_input = {"delay_minutes": requested_minutes, "message": "check in", "name": "probe"}
|
|
56
|
+
exit_code, stdout_text, _ = run_gate(monkeypatch, capsys, SEND_LATER_TOOL, tool_input)
|
|
57
|
+
assert exit_code == 0
|
|
58
|
+
assert updated_input_of(stdout_text) == {
|
|
59
|
+
"delay_minutes": 57,
|
|
60
|
+
"message": "check in",
|
|
61
|
+
"name": "probe",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@pytest.mark.parametrize("requested_minutes", [1, 45, 57])
|
|
66
|
+
def test_send_later_under_the_step_down_passes_untouched(
|
|
67
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], requested_minutes: int
|
|
68
|
+
) -> None:
|
|
69
|
+
exit_code, stdout_text, stderr_text = run_gate(
|
|
70
|
+
monkeypatch, capsys, SEND_LATER_TOOL, {"delay_minutes": requested_minutes, "message": "x"}
|
|
71
|
+
)
|
|
72
|
+
assert (exit_code, stdout_text, stderr_text) == (0, "", "")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_send_later_over_an_hour_is_refused_with_the_stacking_instruction(
|
|
76
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
77
|
+
) -> None:
|
|
78
|
+
exit_code, stdout_text, stderr_text = run_gate(
|
|
79
|
+
monkeypatch, capsys, SEND_LATER_TOOL, {"delay_minutes": 90, "message": "x"}
|
|
80
|
+
)
|
|
81
|
+
assert exit_code == 2
|
|
82
|
+
assert stdout_text == ""
|
|
83
|
+
assert "delay_minutes=57" in stderr_text
|
|
84
|
+
assert "delaySeconds=3480" in stderr_text
|
|
85
|
+
assert "stack" in stderr_text
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_send_later_at_inside_the_cache_edge_is_rewritten(
|
|
89
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
90
|
+
) -> None:
|
|
91
|
+
exit_code, stdout_text, _ = run_gate(
|
|
92
|
+
monkeypatch,
|
|
93
|
+
capsys,
|
|
94
|
+
SEND_LATER_TOOL,
|
|
95
|
+
{"at": utc_text(minutes_from_now(59.5)), "message": "x"},
|
|
96
|
+
)
|
|
97
|
+
assert exit_code == 0
|
|
98
|
+
rewritten_at = datetime.fromisoformat(
|
|
99
|
+
str(updated_input_of(stdout_text)["at"]).replace("Z", "+00:00")
|
|
100
|
+
)
|
|
101
|
+
assert rewritten_at <= minutes_from_now(57)
|
|
102
|
+
assert rewritten_at >= minutes_from_now(56)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_send_later_at_two_hours_out_is_refused(
|
|
106
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
107
|
+
) -> None:
|
|
108
|
+
exit_code, _, stderr_text = run_gate(
|
|
109
|
+
monkeypatch,
|
|
110
|
+
capsys,
|
|
111
|
+
SEND_LATER_TOOL,
|
|
112
|
+
{"at": utc_text(minutes_from_now(120)), "message": "x"},
|
|
113
|
+
)
|
|
114
|
+
assert exit_code == 2
|
|
115
|
+
assert "stack" in stderr_text
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@pytest.mark.parametrize(("requested_seconds", "allowed_seconds"), [(3500, 3480), (3600, 3480)])
|
|
119
|
+
def test_schedule_wakeup_at_the_cache_edge_is_rewritten(
|
|
120
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
121
|
+
capsys: pytest.CaptureFixture[str],
|
|
122
|
+
requested_seconds: int,
|
|
123
|
+
allowed_seconds: int,
|
|
124
|
+
) -> None:
|
|
125
|
+
tool_input = {"delaySeconds": requested_seconds, "prompt": "/loop tick", "reason": "r"}
|
|
126
|
+
exit_code, stdout_text, _ = run_gate(monkeypatch, capsys, "ScheduleWakeup", tool_input)
|
|
127
|
+
assert exit_code == 0
|
|
128
|
+
assert updated_input_of(stdout_text)["delaySeconds"] == allowed_seconds
|
|
129
|
+
assert updated_input_of(stdout_text)["prompt"] == "/loop tick"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@pytest.mark.parametrize("requested_seconds", [60, 1800, 3480])
|
|
133
|
+
def test_schedule_wakeup_under_the_step_down_passes(
|
|
134
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], requested_seconds: int
|
|
135
|
+
) -> None:
|
|
136
|
+
exit_code, stdout_text, _ = run_gate(
|
|
137
|
+
monkeypatch, capsys, "ScheduleWakeup", {"delaySeconds": requested_seconds, "prompt": "p"}
|
|
138
|
+
)
|
|
139
|
+
assert (exit_code, stdout_text) == (0, "")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_schedule_wakeup_over_an_hour_is_refused(
|
|
143
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
144
|
+
) -> None:
|
|
145
|
+
exit_code, _, stderr_text = run_gate(
|
|
146
|
+
monkeypatch, capsys, "ScheduleWakeup", {"delaySeconds": 5400, "prompt": "p"}
|
|
147
|
+
)
|
|
148
|
+
assert exit_code == 2
|
|
149
|
+
assert "delaySeconds=3480" in stderr_text
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_create_trigger_run_once_at_the_hour_is_rewritten(
|
|
153
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
154
|
+
) -> None:
|
|
155
|
+
tool_input = {
|
|
156
|
+
"name": "n",
|
|
157
|
+
"prompt": "p",
|
|
158
|
+
"initiation": "own_followup",
|
|
159
|
+
"run_once_at": utc_text(minutes_from_now(60)),
|
|
160
|
+
}
|
|
161
|
+
exit_code, stdout_text, _ = run_gate(monkeypatch, capsys, CREATE_TRIGGER_TOOL, tool_input)
|
|
162
|
+
assert exit_code == 0
|
|
163
|
+
rewritten_at = datetime.fromisoformat(
|
|
164
|
+
str(updated_input_of(stdout_text)["run_once_at"]).replace("Z", "+00:00")
|
|
165
|
+
)
|
|
166
|
+
assert rewritten_at <= minutes_from_now(57)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@pytest.mark.parametrize(
|
|
170
|
+
"cron_expression", ["0 * * * *", "0 */4 * * *", "CRON_TZ=America/Los_Angeles 37 8 * * 1-5"]
|
|
171
|
+
)
|
|
172
|
+
def test_create_trigger_cron_into_a_session_is_refused(
|
|
173
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], cron_expression: str
|
|
174
|
+
) -> None:
|
|
175
|
+
tool_input = {
|
|
176
|
+
"name": "n",
|
|
177
|
+
"prompt": "p",
|
|
178
|
+
"initiation": "own_followup",
|
|
179
|
+
"cron_expression": cron_expression,
|
|
180
|
+
}
|
|
181
|
+
exit_code, _, stderr_text = run_gate(monkeypatch, capsys, CREATE_TRIGGER_TOOL, tool_input)
|
|
182
|
+
assert exit_code == 2
|
|
183
|
+
assert cron_expression in stderr_text
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_create_trigger_with_a_fresh_session_per_firing_passes(
|
|
187
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
188
|
+
) -> None:
|
|
189
|
+
tool_input = {
|
|
190
|
+
"name": "n",
|
|
191
|
+
"prompt": "p",
|
|
192
|
+
"initiation": "human_request",
|
|
193
|
+
"cron_expression": "0 9 * * *",
|
|
194
|
+
"create_new_session_on_fire": True,
|
|
195
|
+
}
|
|
196
|
+
exit_code, stdout_text, _ = run_gate(monkeypatch, capsys, CREATE_TRIGGER_TOOL, tool_input)
|
|
197
|
+
assert (exit_code, stdout_text) == (0, "")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_update_trigger_run_once_at_over_an_hour_is_refused(
|
|
201
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
202
|
+
) -> None:
|
|
203
|
+
tool_input = {"trigger_id": "trig_x", "run_once_at": utc_text(minutes_from_now(90))}
|
|
204
|
+
exit_code, _, _ = run_gate(monkeypatch, capsys, UPDATE_TRIGGER_TOOL, tool_input)
|
|
205
|
+
assert exit_code == 2
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def test_update_trigger_cron_change_passes(
|
|
209
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
210
|
+
) -> None:
|
|
211
|
+
tool_input = {"trigger_id": "trig_x", "cron_expression": "0 9 * * *"}
|
|
212
|
+
exit_code, stdout_text, _ = run_gate(monkeypatch, capsys, UPDATE_TRIGGER_TOOL, tool_input)
|
|
213
|
+
assert (exit_code, stdout_text) == (0, "")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@pytest.mark.parametrize(
|
|
217
|
+
"cron_expression",
|
|
218
|
+
["*/5 * * * *", "*/30 * * * *", "7,37 * * * *", "*/20 0-23 1-31 1-12 0-7", "*/20 * * * 0-6"],
|
|
219
|
+
)
|
|
220
|
+
def test_cron_create_firing_more_often_than_the_step_down_passes(
|
|
221
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], cron_expression: str
|
|
222
|
+
) -> None:
|
|
223
|
+
exit_code, stdout_text, _ = run_gate(
|
|
224
|
+
monkeypatch, capsys, "CronCreate", {"cron": cron_expression, "prompt": "p"}
|
|
225
|
+
)
|
|
226
|
+
assert (exit_code, stdout_text) == (0, "")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@pytest.mark.parametrize(
|
|
230
|
+
"cron_expression",
|
|
231
|
+
["7 * * * *", "57 8 * * *", "0,58 * * * *", "*/20 9-17 * * *", "*/5 * * * 1-5", "*/5 * * * MON"],
|
|
232
|
+
)
|
|
233
|
+
def test_cron_create_with_an_hour_or_more_between_wakes_is_refused(
|
|
234
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], cron_expression: str
|
|
235
|
+
) -> None:
|
|
236
|
+
exit_code, _, stderr_text = run_gate(
|
|
237
|
+
monkeypatch, capsys, "CronCreate", {"cron": cron_expression, "prompt": "p"}
|
|
238
|
+
)
|
|
239
|
+
assert exit_code == 2
|
|
240
|
+
assert "stack" in stderr_text
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def pinned_local_cron(minutes_ahead: float) -> str:
|
|
244
|
+
fire_time = datetime.now().astimezone() + timedelta(minutes=minutes_ahead)
|
|
245
|
+
return f"{fire_time.minute} {fire_time.hour} {fire_time.day} {fire_time.month} *"
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def test_cron_create_one_shot_within_the_step_down_passes(
|
|
249
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
250
|
+
) -> None:
|
|
251
|
+
tool_input = {"cron": pinned_local_cron(30), "prompt": "p", "recurring": False}
|
|
252
|
+
exit_code, stdout_text, _ = run_gate(monkeypatch, capsys, "CronCreate", tool_input)
|
|
253
|
+
assert (exit_code, stdout_text) == (0, "")
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def test_cron_create_one_shot_with_a_timezone_prefix_is_refused(
|
|
257
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
258
|
+
) -> None:
|
|
259
|
+
tool_input = {
|
|
260
|
+
"cron": "CRON_TZ=America/Los_Angeles " + pinned_local_cron(30),
|
|
261
|
+
"prompt": "p",
|
|
262
|
+
"recurring": False,
|
|
263
|
+
}
|
|
264
|
+
exit_code, _, _ = run_gate(monkeypatch, capsys, "CronCreate", tool_input)
|
|
265
|
+
assert exit_code == 2
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def test_cron_create_one_shot_two_hours_out_is_refused(
|
|
269
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
270
|
+
) -> None:
|
|
271
|
+
tool_input = {"cron": pinned_local_cron(120), "prompt": "p", "recurring": False}
|
|
272
|
+
exit_code, _, _ = run_gate(monkeypatch, capsys, "CronCreate", tool_input)
|
|
273
|
+
assert exit_code == 2
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def test_other_tools_pass(
|
|
277
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
278
|
+
) -> None:
|
|
279
|
+
exit_code, stdout_text, _ = run_gate(monkeypatch, capsys, "Bash", {"command": "sleep 4000"})
|
|
280
|
+
assert (exit_code, stdout_text) == (0, "")
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse hook that keeps every self-wakeup inside the 1-hour prompt cache.
|
|
3
|
+
|
|
4
|
+
A session that wakes 60 minutes or more after its last turn resumes with a cold
|
|
5
|
+
prompt cache and re-reads its whole transcript. The gate reads each tool call
|
|
6
|
+
that schedules a later wake of a session and acts on how far away the wake is:
|
|
7
|
+
|
|
8
|
+
- At most the step-down (57 minutes for send_later and Routines, 3480 seconds
|
|
9
|
+
for ScheduleWakeup): the call passes untouched.
|
|
10
|
+
- Over the step-down and at most 60 minutes: the call runs with the delay
|
|
11
|
+
rewritten to the step-down through ``updatedInput``.
|
|
12
|
+
- Over 60 minutes: the call is denied with a reason that names the step-down and
|
|
13
|
+
tells the session to stack wakes under it.
|
|
14
|
+
|
|
15
|
+
A cron schedule cannot be rewritten, so a cron that leaves more than the
|
|
16
|
+
step-down between wakes is denied. A Routine that starts a fresh session on each
|
|
17
|
+
firing has no cache to keep and passes. An ``update_trigger`` cron change passes,
|
|
18
|
+
because its input does not say whether the Routine wakes a session.
|
|
19
|
+
|
|
20
|
+
The step-down values come from fire-time probes: send_later rounds its fire time
|
|
21
|
+
up to the next minute and then waits up to one more minute for its poll, so it
|
|
22
|
+
lands up to two minutes after its requested delay. ScheduleWakeup rounds its
|
|
23
|
+
fire time up to the next minute and lands a few seconds after it, so it lands up
|
|
24
|
+
to about 64 seconds after its requested delay.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import math
|
|
31
|
+
import sys
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from datetime import datetime, timedelta, timezone
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
hooks_root_directory = str(Path(__file__).resolve().parent.parent)
|
|
37
|
+
if hooks_root_directory not in sys.path:
|
|
38
|
+
sys.path.insert(0, hooks_root_directory)
|
|
39
|
+
|
|
40
|
+
from hooks_constants.hook_block_logger import log_hook_block
|
|
41
|
+
from hooks_constants.hook_specific_output_keys import (
|
|
42
|
+
HOOK_EVENT_NAME_KEY,
|
|
43
|
+
HOOK_SPECIFIC_OUTPUT_KEY,
|
|
44
|
+
PERMISSION_DECISION_KEY,
|
|
45
|
+
UPDATED_INPUT_KEY,
|
|
46
|
+
)
|
|
47
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
48
|
+
from hooks_constants.wakeup_cap_constants import (
|
|
49
|
+
ADDITIONAL_CONTEXT_KEY,
|
|
50
|
+
ALL_CRON_FIELD_RANGES,
|
|
51
|
+
ALLOW_DECISION,
|
|
52
|
+
ALLOW_EXIT_CODE,
|
|
53
|
+
AT_KEY,
|
|
54
|
+
BLOCK_EXIT_CODE,
|
|
55
|
+
CACHE_LIFETIME_MINUTES,
|
|
56
|
+
CACHE_LIFETIME_SECONDS,
|
|
57
|
+
CREATE_TRIGGER_TOOL_PATTERN,
|
|
58
|
+
CRON_CREATE_TOOL_NAME,
|
|
59
|
+
CRON_EXPRESSION_KEY,
|
|
60
|
+
CRON_FIELD_COUNT,
|
|
61
|
+
CRON_KEY,
|
|
62
|
+
CRON_TIMEZONE_PREFIX_PATTERN,
|
|
63
|
+
DELAY_MINUTES_KEY,
|
|
64
|
+
DELAY_SECONDS_KEY,
|
|
65
|
+
DENY_CRON_DETAIL,
|
|
66
|
+
DENY_DELAY_DETAIL,
|
|
67
|
+
DENY_REASON_TEMPLATE,
|
|
68
|
+
GATE_EVENT_NAME,
|
|
69
|
+
MINUTES_PER_HOUR,
|
|
70
|
+
NEW_SESSION_ON_FIRE_KEY,
|
|
71
|
+
PINNED_FIELD_COUNT,
|
|
72
|
+
RECURRING_KEY,
|
|
73
|
+
REWRITE_CONTEXT_TEMPLATE,
|
|
74
|
+
RFC3339_OUTPUT_FORMAT,
|
|
75
|
+
RUN_ONCE_AT_KEY,
|
|
76
|
+
SCHEDULE_WAKEUP_STEP_DOWN_SECONDS,
|
|
77
|
+
SCHEDULE_WAKEUP_TOOL_NAME,
|
|
78
|
+
SECONDS_PER_MINUTE,
|
|
79
|
+
SEND_LATER_STEP_DOWN_MINUTES,
|
|
80
|
+
SEND_LATER_TOOL_PATTERN,
|
|
81
|
+
TOOL_INPUT_KEY,
|
|
82
|
+
TOOL_NAME_KEY,
|
|
83
|
+
UPDATE_TRIGGER_TOOL_PATTERN,
|
|
84
|
+
UTC_OFFSET_TEXT,
|
|
85
|
+
UTC_SUFFIX,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class Rewrite:
|
|
91
|
+
"""A tool input field to replace with the step-down value."""
|
|
92
|
+
|
|
93
|
+
field: str
|
|
94
|
+
requested: object
|
|
95
|
+
allowed: object
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class Denial:
|
|
100
|
+
"""The part of the deny reason that names what the call asked for."""
|
|
101
|
+
|
|
102
|
+
detail: str
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
Decision = Rewrite | Denial | None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _number_or_none(value: object) -> float | None:
|
|
109
|
+
if isinstance(value, bool) or not isinstance(value, int | float):
|
|
110
|
+
return None
|
|
111
|
+
return float(value)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _parse_utc_moment(text: object) -> datetime | None:
|
|
115
|
+
if not isinstance(text, str):
|
|
116
|
+
return None
|
|
117
|
+
try:
|
|
118
|
+
moment = datetime.fromisoformat(text.strip().replace(UTC_SUFFIX, UTC_OFFSET_TEXT))
|
|
119
|
+
except ValueError:
|
|
120
|
+
return None
|
|
121
|
+
if moment.tzinfo is None:
|
|
122
|
+
return None
|
|
123
|
+
return moment
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _decide_minutes(
|
|
127
|
+
field: str, requested: object, minutes_away: float, allowed: object
|
|
128
|
+
) -> Decision:
|
|
129
|
+
if minutes_away > CACHE_LIFETIME_MINUTES:
|
|
130
|
+
return Denial(DENY_DELAY_DETAIL.format(minutes=math.ceil(minutes_away)))
|
|
131
|
+
if minutes_away > SEND_LATER_STEP_DOWN_MINUTES:
|
|
132
|
+
return Rewrite(field, requested, allowed)
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _decide_delay_minutes(all_tool_input_fields: dict[str, object]) -> Decision:
|
|
137
|
+
requested = all_tool_input_fields.get(DELAY_MINUTES_KEY)
|
|
138
|
+
requested_minutes = _number_or_none(requested)
|
|
139
|
+
if requested_minutes is None:
|
|
140
|
+
return None
|
|
141
|
+
return _decide_minutes(
|
|
142
|
+
DELAY_MINUTES_KEY, requested, requested_minutes, SEND_LATER_STEP_DOWN_MINUTES
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _decide_fire_time(
|
|
147
|
+
field: str, all_tool_input_fields: dict[str, object], now: datetime
|
|
148
|
+
) -> Decision:
|
|
149
|
+
requested = all_tool_input_fields.get(field)
|
|
150
|
+
fire_time = _parse_utc_moment(requested)
|
|
151
|
+
if fire_time is None:
|
|
152
|
+
return None
|
|
153
|
+
minutes_away = (fire_time - now).total_seconds() / SECONDS_PER_MINUTE
|
|
154
|
+
latest_allowed = (now + timedelta(minutes=SEND_LATER_STEP_DOWN_MINUTES)).replace(
|
|
155
|
+
second=0, microsecond=0
|
|
156
|
+
)
|
|
157
|
+
allowed_text = latest_allowed.astimezone(timezone.utc).strftime(RFC3339_OUTPUT_FORMAT)
|
|
158
|
+
return _decide_minutes(field, requested, minutes_away, allowed_text)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _decide_delay_seconds(all_tool_input_fields: dict[str, object]) -> Decision:
|
|
162
|
+
requested = all_tool_input_fields.get(DELAY_SECONDS_KEY)
|
|
163
|
+
requested_seconds = _number_or_none(requested)
|
|
164
|
+
if requested_seconds is None:
|
|
165
|
+
return None
|
|
166
|
+
if requested_seconds > CACHE_LIFETIME_SECONDS:
|
|
167
|
+
return Denial(
|
|
168
|
+
DENY_DELAY_DETAIL.format(minutes=math.ceil(requested_seconds / SECONDS_PER_MINUTE))
|
|
169
|
+
)
|
|
170
|
+
if requested_seconds > SCHEDULE_WAKEUP_STEP_DOWN_SECONDS:
|
|
171
|
+
return Rewrite(DELAY_SECONDS_KEY, requested, SCHEDULE_WAKEUP_STEP_DOWN_SECONDS)
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _base_bounds(base: str, has_step: bool, lowest: int, highest: int) -> tuple[int, int] | None:
|
|
176
|
+
if base == "*":
|
|
177
|
+
return lowest, highest
|
|
178
|
+
first_text, separator, last_text = base.partition("-")
|
|
179
|
+
if separator and first_text.isdigit() and last_text.isdigit():
|
|
180
|
+
return int(first_text), int(last_text)
|
|
181
|
+
if separator or not base.isdigit():
|
|
182
|
+
return None
|
|
183
|
+
return int(base), highest if has_step else int(base)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _part_values(cron_part: str, lowest: int, highest: int) -> range | None:
|
|
187
|
+
base, _, step_text = cron_part.partition("/")
|
|
188
|
+
if step_text and not step_text.isdigit():
|
|
189
|
+
return None
|
|
190
|
+
step = int(step_text) if step_text else 1
|
|
191
|
+
bounds = _base_bounds(base, bool(step_text), lowest, highest)
|
|
192
|
+
if bounds is None or step < 1 or not lowest <= bounds[0] <= bounds[1] <= highest:
|
|
193
|
+
return None
|
|
194
|
+
return range(bounds[0], bounds[1] + 1, step)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _field_values(cron_field: str, lowest: int, highest: int) -> set[int] | None:
|
|
198
|
+
all_values: set[int] = set()
|
|
199
|
+
for each_part in cron_field.split(","):
|
|
200
|
+
part_values = _part_values(each_part, lowest, highest)
|
|
201
|
+
if part_values is None:
|
|
202
|
+
return None
|
|
203
|
+
all_values.update(part_values)
|
|
204
|
+
return all_values
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _covers_every_value(cron_field: str, lowest: int, highest: int, wrap: int) -> bool:
|
|
208
|
+
all_values = _field_values(cron_field, lowest, highest)
|
|
209
|
+
if all_values is None:
|
|
210
|
+
return False
|
|
211
|
+
return {each_value % wrap for each_value in all_values} == set(range(lowest, wrap))
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _cron_fields(cron_expression: str) -> list[str] | None:
|
|
215
|
+
all_fields = CRON_TIMEZONE_PREFIX_PATTERN.sub("", cron_expression.strip()).split()
|
|
216
|
+
if len(all_fields) != CRON_FIELD_COUNT:
|
|
217
|
+
return None
|
|
218
|
+
return all_fields
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def longest_cron_gap_minutes(cron_expression: str) -> int | None:
|
|
222
|
+
"""Return the longest wait between two firings of a cron schedule, in minutes.
|
|
223
|
+
|
|
224
|
+
A schedule that limits its hour, day, month or weekday leaves an hour or more
|
|
225
|
+
between some firings, so it reports the cache lifetime as a floor.
|
|
226
|
+
|
|
227
|
+
>>> longest_cron_gap_minutes("*/20 * * * *")
|
|
228
|
+
20
|
|
229
|
+
>>> longest_cron_gap_minutes("5 9 * * *")
|
|
230
|
+
60
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
cron_expression: A 5-field cron expression, with an optional CRON_TZ prefix.
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
The longest gap in minutes, or None when the expression cannot be read.
|
|
237
|
+
"""
|
|
238
|
+
all_fields = _cron_fields(cron_expression)
|
|
239
|
+
if all_fields is None:
|
|
240
|
+
return None
|
|
241
|
+
all_minutes = _field_values(all_fields[0], 0, MINUTES_PER_HOUR - 1)
|
|
242
|
+
if not all_minutes:
|
|
243
|
+
return None
|
|
244
|
+
if not _fires_every_hour(all_fields[1:]):
|
|
245
|
+
return CACHE_LIFETIME_MINUTES
|
|
246
|
+
return _longest_minute_gap(all_minutes)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _fires_every_hour(all_hour_and_day_fields: list[str]) -> bool:
|
|
250
|
+
return all(
|
|
251
|
+
_covers_every_value(each_field, lowest, highest, wrap)
|
|
252
|
+
for each_field, (lowest, highest, wrap) in zip(
|
|
253
|
+
all_hour_and_day_fields, ALL_CRON_FIELD_RANGES, strict=True
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _longest_minute_gap(all_minutes: set[int]) -> int:
|
|
259
|
+
ordered_minutes = sorted(all_minutes)
|
|
260
|
+
all_gaps = [
|
|
261
|
+
later - earlier
|
|
262
|
+
for earlier, later in zip(ordered_minutes, ordered_minutes[1:], strict=False)
|
|
263
|
+
]
|
|
264
|
+
all_gaps.append(ordered_minutes[0] + MINUTES_PER_HOUR - ordered_minutes[-1])
|
|
265
|
+
return max(all_gaps)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _decide_cron_gap(cron_expression: object) -> Decision:
|
|
269
|
+
if not isinstance(cron_expression, str):
|
|
270
|
+
return None
|
|
271
|
+
longest_gap = longest_cron_gap_minutes(cron_expression)
|
|
272
|
+
if longest_gap is None or longest_gap <= SEND_LATER_STEP_DOWN_MINUTES:
|
|
273
|
+
return None
|
|
274
|
+
return Denial(DENY_CRON_DETAIL.format(cron=cron_expression, minutes=longest_gap))
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _pinned_local_fire_time(cron_expression: str, now: datetime) -> datetime | None:
|
|
278
|
+
if CRON_TIMEZONE_PREFIX_PATTERN.match(cron_expression.strip()):
|
|
279
|
+
return None
|
|
280
|
+
all_fields = _cron_fields(cron_expression)
|
|
281
|
+
if all_fields is None:
|
|
282
|
+
return None
|
|
283
|
+
all_pinned_fields = all_fields[:PINNED_FIELD_COUNT]
|
|
284
|
+
if not all(each_field.isdigit() for each_field in all_pinned_fields):
|
|
285
|
+
return None
|
|
286
|
+
minute, hour, day, month = (int(each_field) for each_field in all_pinned_fields)
|
|
287
|
+
local_now = now.astimezone()
|
|
288
|
+
try:
|
|
289
|
+
fire_time = local_now.replace(
|
|
290
|
+
month=month, day=day, hour=hour, minute=minute, second=0, microsecond=0
|
|
291
|
+
)
|
|
292
|
+
if fire_time < local_now.replace(second=0, microsecond=0):
|
|
293
|
+
fire_time = fire_time.replace(year=fire_time.year + 1)
|
|
294
|
+
except ValueError:
|
|
295
|
+
return None
|
|
296
|
+
return fire_time
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _decide_pinned_one_shot(fire_time: datetime, now: datetime) -> Decision:
|
|
300
|
+
minutes_away = (fire_time - now).total_seconds() / SECONDS_PER_MINUTE
|
|
301
|
+
if minutes_away > SEND_LATER_STEP_DOWN_MINUTES:
|
|
302
|
+
return Denial(DENY_DELAY_DETAIL.format(minutes=math.ceil(minutes_away)))
|
|
303
|
+
return None
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _decide_cron_create(all_tool_input_fields: dict[str, object], now: datetime) -> Decision:
|
|
307
|
+
cron_expression = all_tool_input_fields.get(CRON_KEY)
|
|
308
|
+
if not isinstance(cron_expression, str):
|
|
309
|
+
return None
|
|
310
|
+
is_one_shot = all_tool_input_fields.get(RECURRING_KEY) is False
|
|
311
|
+
fire_time = _pinned_local_fire_time(cron_expression, now) if is_one_shot else None
|
|
312
|
+
if fire_time is not None:
|
|
313
|
+
return _decide_pinned_one_shot(fire_time, now)
|
|
314
|
+
return _decide_cron_gap(cron_expression)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _decide_create_trigger(all_tool_input_fields: dict[str, object], now: datetime) -> Decision:
|
|
318
|
+
if all_tool_input_fields.get(NEW_SESSION_ON_FIRE_KEY) is True:
|
|
319
|
+
return None
|
|
320
|
+
if RUN_ONCE_AT_KEY in all_tool_input_fields:
|
|
321
|
+
return _decide_fire_time(RUN_ONCE_AT_KEY, all_tool_input_fields, now)
|
|
322
|
+
return _decide_cron_gap(all_tool_input_fields.get(CRON_EXPRESSION_KEY))
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def decide(tool_name: str, all_tool_input_fields: dict[str, object], now: datetime) -> Decision:
|
|
326
|
+
"""Return how the gate treats one tool call that may schedule a wake.
|
|
327
|
+
|
|
328
|
+
Args:
|
|
329
|
+
tool_name: The name of the tool being called.
|
|
330
|
+
all_tool_input_fields: The tool call's input.
|
|
331
|
+
now: The current time, timezone aware.
|
|
332
|
+
|
|
333
|
+
Returns:
|
|
334
|
+
A Rewrite, a Denial, or None when the call passes untouched.
|
|
335
|
+
"""
|
|
336
|
+
if tool_name == SCHEDULE_WAKEUP_TOOL_NAME:
|
|
337
|
+
return _decide_delay_seconds(all_tool_input_fields)
|
|
338
|
+
if tool_name == CRON_CREATE_TOOL_NAME:
|
|
339
|
+
return _decide_cron_create(all_tool_input_fields, now)
|
|
340
|
+
if SEND_LATER_TOOL_PATTERN.match(tool_name):
|
|
341
|
+
if DELAY_MINUTES_KEY in all_tool_input_fields:
|
|
342
|
+
return _decide_delay_minutes(all_tool_input_fields)
|
|
343
|
+
return _decide_fire_time(AT_KEY, all_tool_input_fields, now)
|
|
344
|
+
if CREATE_TRIGGER_TOOL_PATTERN.match(tool_name):
|
|
345
|
+
return _decide_create_trigger(all_tool_input_fields, now)
|
|
346
|
+
if UPDATE_TRIGGER_TOOL_PATTERN.match(tool_name):
|
|
347
|
+
return _decide_fire_time(RUN_ONCE_AT_KEY, all_tool_input_fields, now)
|
|
348
|
+
return None
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _write_rewrite(all_tool_input_fields: dict[str, object], rewrite: Rewrite) -> None:
|
|
352
|
+
payload = {
|
|
353
|
+
HOOK_SPECIFIC_OUTPUT_KEY: {
|
|
354
|
+
HOOK_EVENT_NAME_KEY: GATE_EVENT_NAME,
|
|
355
|
+
PERMISSION_DECISION_KEY: ALLOW_DECISION,
|
|
356
|
+
UPDATED_INPUT_KEY: {**all_tool_input_fields, rewrite.field: rewrite.allowed},
|
|
357
|
+
ADDITIONAL_CONTEXT_KEY: REWRITE_CONTEXT_TEMPLATE.format(
|
|
358
|
+
field=rewrite.field,
|
|
359
|
+
requested=rewrite.requested,
|
|
360
|
+
allowed=rewrite.allowed,
|
|
361
|
+
cap=f"{CACHE_LIFETIME_MINUTES} minutes",
|
|
362
|
+
),
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
sys.stdout.write(json.dumps(payload) + "\n")
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def main() -> int:
|
|
369
|
+
hook_input = read_hook_input_dictionary_from_stdin()
|
|
370
|
+
if hook_input is None:
|
|
371
|
+
return ALLOW_EXIT_CODE
|
|
372
|
+
tool_name = hook_input.get(TOOL_NAME_KEY)
|
|
373
|
+
tool_input = hook_input.get(TOOL_INPUT_KEY)
|
|
374
|
+
if not isinstance(tool_name, str) or not isinstance(tool_input, dict):
|
|
375
|
+
return ALLOW_EXIT_CODE
|
|
376
|
+
decision = decide(tool_name, tool_input, datetime.now(timezone.utc))
|
|
377
|
+
if isinstance(decision, Rewrite):
|
|
378
|
+
_write_rewrite(tool_input, decision)
|
|
379
|
+
return ALLOW_EXIT_CODE
|
|
380
|
+
if decision is None:
|
|
381
|
+
return ALLOW_EXIT_CODE
|
|
382
|
+
block_reason = DENY_REASON_TEMPLATE.format(
|
|
383
|
+
tool_name=tool_name,
|
|
384
|
+
detail=decision.detail,
|
|
385
|
+
send_later_cap=SEND_LATER_STEP_DOWN_MINUTES,
|
|
386
|
+
schedule_wakeup_cap=SCHEDULE_WAKEUP_STEP_DOWN_SECONDS,
|
|
387
|
+
)
|
|
388
|
+
log_hook_block(
|
|
389
|
+
Path(__file__).name,
|
|
390
|
+
GATE_EVENT_NAME,
|
|
391
|
+
block_reason,
|
|
392
|
+
tool_name=tool_name,
|
|
393
|
+
offending_input_preview=json.dumps(tool_input),
|
|
394
|
+
)
|
|
395
|
+
sys.stderr.write(block_reason)
|
|
396
|
+
return BLOCK_EXIT_CODE
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
if __name__ == "__main__":
|
|
400
|
+
sys.exit(main())
|
|
@@ -15,6 +15,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
|
|
|
15
15
|
- `blocking/session_title_format_gate.py` denies a session title that breaks the `<emoji> <name>` shape or has a name longer than 25 characters.
|
|
16
16
|
- `blocking/session_title_turn_gate.py` holds the end of a turn once, in a session that has a `__set_session_title` tool, when the shown title does not fit the session's state: no title yet, a red flag after a prompt that asks for work, a check mark while the task list has open items, or an hourglass once every item is completed or deleted. A turn that set a title passes. A turn that called no tool other than the title tool passes, and so does a held turn.
|
|
17
17
|
- `blocking/step_note_gate.py` requires a status line before a tool call while its opt-in flag is on.
|
|
18
|
+
- `blocking/wakeup_cap_gate.py` keeps a self-wakeup inside the 1-hour prompt cache. A wake over the step-down (57 minutes for `send_later` and Routines, 3480 seconds for `ScheduleWakeup`) and at most 60 minutes away runs with its delay rewritten to the step-down. A wake over 60 minutes away, or a cron with more than 57 minutes between wakes, is denied with a reason that says to stack wakes under the cap. A Routine that starts a fresh session on each firing passes.
|
|
18
19
|
- `blocking/verify_before_acting.py` blocks a completed mutating call when the agent's reasoning contains an unchecked claim.
|
|
19
20
|
- `blocking/pr_lifecycle_skill_gate.py` denies a commit, push, pull request action, or merge until the `pr-lifecycle` skill appears in the transcript since the last compaction.
|
|
20
21
|
- `blocking/resolve_thread_reply_gate.py` denies a review thread resolve until the agent's own transcript shows a reply on that same thread that posted after the last user prompt and that no successful resolve of the thread has spent. A resolve is a `resolve_review_thread` call, which names the thread by `threadID`; a `pull_request_review_write` call with method `resolve_thread`, which names it by `threadId`; a shell command with a `gh api graphql` `resolveReviewThread` mutation, which names it by a literal `PRRT_` id; or a shell call to `pulls/<n>/ccr/comments/<id>/resolve`, which names it by a comment id. A graphql resolve that names no `PRRT_` id is denied. A reply is an `add_reply_to_pull_request_comment` call, or a `gh api` call to a comment's `replies` endpoint or to `addPullRequestReviewThreadReply`, which names its thread by `PRRT_` id. A reply and a resolve by comment id match when they name the same comment, or when a successful tool result in the transcript, such as `pull_request_read` `get_review_comments`, lists the comment under the thread by its `discussion_r` anchor or `databaseId`. A reply whose result is an error, such as a refusal from `blocking/reply_length_gate.py`, does not count. With no readable transcript, the resolve passes.
|
|
@@ -39,6 +40,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
|
|
|
39
40
|
- `blocking/session_title_format_gate.py` runs on `PreToolUse`, matcher `mcp__.*__set_session_title`, timeout `10` seconds in `hooks.json`.
|
|
40
41
|
- `blocking/session_title_turn_gate.py` runs on `Stop`, timeout `10` seconds in `hooks.json`.
|
|
41
42
|
- `blocking/step_note_gate.py` runs on `PreToolUse`, matcher `*`, timeout `15` seconds in `hooks.json`.
|
|
43
|
+
- `blocking/wakeup_cap_gate.py` runs on `PreToolUse`, matcher `ScheduleWakeup|CronCreate|mcp__.*__(send_later|create_trigger|update_trigger)`, timeout `10` seconds in `hooks.json`.
|
|
42
44
|
- `blocking/verify_before_acting.py` runs on `PostToolUse`, matcher `Write|Edit|MultiEdit|NotebookEdit|Agent|Task|apply_patch|Bash|PowerShell|mcp__.*`, timeout `10` seconds in `hooks.json`.
|
|
43
45
|
- `blocking/pr_lifecycle_skill_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge|update_pull_request|actions_run_trigger)`, timeout `10` seconds in `hooks.json`.
|
|
44
46
|
- `blocking/resolve_thread_reply_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell|mcp__.*__resolve_review_thread|mcp__.*__pull_request_review_write`, timeout `10` seconds in `hooks.json`.
|
|
@@ -68,6 +70,7 @@ Preconditions:
|
|
|
68
70
|
- **Session title.** Input is a set session title call with no leading emoji or a name over 25 characters. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_session_title_format_gate.py -q`. The adjacent test observes a denial that names the broken rule; a well-formed title passes.
|
|
69
71
|
- **Stale title.** Input is a transcript that lists the title tool, an earlier turn that set the red flag, and a work prompt whose turn ran a tool and set no title. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_session_title_turn_gate.py -q`. The adjacent test observes a `block` decision that names the shown title, the tool, and the four status emoji. A carried hourglass with an item in progress passes, and so does a turn whose title call came back without an error.
|
|
70
72
|
- **Step note.** Input is a tool call after a transcript message without a status line while the flag is on. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_step_note_gate.py -q`. The adjacent test observes a block until a status line appears.
|
|
73
|
+
- **Wakeup cap.** Input is a `send_later` call with `delay_minutes` of 60, then 90, then 45. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_wakeup_cap_gate.py -q`. The adjacent test observes 60 rewritten to 57 through `updatedInput`, 90 denied with the stacking instruction, and 45 passing untouched.
|
|
71
74
|
- **Checked claim.** Input is a Write after hedged reasoning in the transcript. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_verify_before_acting.py -q`. The adjacent test observes a block and a logged blocked outcome.
|
|
72
75
|
- **Lifecycle skill.** Input is a `git commit` or `gh pr create` command with no `pr-lifecycle` invocation in the transcript. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_pr_lifecycle_skill_gate.py -q`. The adjacent test observes a denial, then a silent allow once the skill or its slash command appears.
|
|
73
76
|
- **Verify before commit.** Input is `git add app.py; git commit -m change` in a temporary repository with `.agents/skills/verify`, and a transcript with no `verify` load after its last prompt. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_verify_before_commit_gate.py -q`. The adjacent test observes a denial, then a silent allow after a `verify` Skill call or a `/verify` prompt. A load before the last prompt still denies. A docs-only commit, a commit of data files under a `docs` folder, a tests-only commit, and a repository with no verify skill pass. A `.py` file under `docs` and a `.json` file outside `docs` still deny.
|
|
@@ -84,6 +87,7 @@ Preconditions:
|
|
|
84
87
|
- `blocking/turn_review_gate.py` starts `claude -p`, which inherits the parent session id unless the gate strips it. A review that kept `CLAUDE_CODE_SESSION_ID` would write into the parent's transcript.
|
|
85
88
|
- Each review run calls a model and costs about $0.03. Leave `CLAUDE_TURN_REVIEW_REPOSITORIES` unset and the gate makes no snapshot and no call.
|
|
86
89
|
- `blocking/step_note_gate.py` is off by default and polls for the transcript record. Subagent calls and unreadable records pass.
|
|
90
|
+
- `blocking/wakeup_cap_gate.py` lets an `update_trigger` cron change pass, because that input does not say whether the Routine wakes a session. Its step-down values come from fire-time probes: `send_later` rounds its fire time up to the next minute and polls once a minute, so a 1-minute request landed 123 and 158 seconds later. `ScheduleWakeup` rounds its fire time up to the next minute, so requests of 60 and 90 seconds landed 70 and 121 seconds later.
|
|
87
91
|
- `blocking/verify_before_acting.py` runs after the tool. A block asks the agent to check the claim and undo a contradicted change.
|
|
88
92
|
- The reply gate counts each nonempty list line as a sentence. Link targets and code spans add no words. Its banned words come from `reply-banned-words.json` in the Claude home, or the file `CLAUDE_REPLY_BANNED_WORDS_PATH` names, with built-in defaults.
|
|
89
93
|
- The final reply gate blocks the stop and returns its reason to the model, which rewrites the reply. Claude Code ends the turn anyway after eight blocks in a row. The cited number checks in both gates skip when the transcript cannot be read.
|
package/hooks/hooks.json
CHANGED
|
@@ -224,6 +224,16 @@
|
|
|
224
224
|
}
|
|
225
225
|
]
|
|
226
226
|
},
|
|
227
|
+
{
|
|
228
|
+
"matcher": "ScheduleWakeup|CronCreate|mcp__.*__(send_later|create_trigger|update_trigger)",
|
|
229
|
+
"hooks": [
|
|
230
|
+
{
|
|
231
|
+
"type": "command",
|
|
232
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/wakeup_cap_gate.py",
|
|
233
|
+
"timeout": 10
|
|
234
|
+
}
|
|
235
|
+
]
|
|
236
|
+
},
|
|
227
237
|
{
|
|
228
238
|
"matcher": "Artifact",
|
|
229
239
|
"hooks": [
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Constants for wakeup_cap_gate.py."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
TOOL_NAME_KEY = "tool_name"
|
|
6
|
+
TOOL_INPUT_KEY = "tool_input"
|
|
7
|
+
GATE_EVENT_NAME = "PreToolUse"
|
|
8
|
+
ALLOW_EXIT_CODE = 0
|
|
9
|
+
BLOCK_EXIT_CODE = 2
|
|
10
|
+
ALLOW_DECISION = "allow"
|
|
11
|
+
ADDITIONAL_CONTEXT_KEY = "additionalContext"
|
|
12
|
+
|
|
13
|
+
SCHEDULE_WAKEUP_TOOL_NAME = "ScheduleWakeup"
|
|
14
|
+
CRON_CREATE_TOOL_NAME = "CronCreate"
|
|
15
|
+
SEND_LATER_TOOL_PATTERN = re.compile(r"^mcp__.+__send_later$")
|
|
16
|
+
CREATE_TRIGGER_TOOL_PATTERN = re.compile(r"^mcp__.+__create_trigger$")
|
|
17
|
+
UPDATE_TRIGGER_TOOL_PATTERN = re.compile(r"^mcp__.+__update_trigger$")
|
|
18
|
+
|
|
19
|
+
DELAY_SECONDS_KEY = "delaySeconds"
|
|
20
|
+
DELAY_MINUTES_KEY = "delay_minutes"
|
|
21
|
+
AT_KEY = "at"
|
|
22
|
+
RUN_ONCE_AT_KEY = "run_once_at"
|
|
23
|
+
CRON_EXPRESSION_KEY = "cron_expression"
|
|
24
|
+
CRON_KEY = "cron"
|
|
25
|
+
RECURRING_KEY = "recurring"
|
|
26
|
+
NEW_SESSION_ON_FIRE_KEY = "create_new_session_on_fire"
|
|
27
|
+
|
|
28
|
+
SECONDS_PER_MINUTE = 60
|
|
29
|
+
MINUTES_PER_HOUR = 60
|
|
30
|
+
CACHE_LIFETIME_MINUTES = 60
|
|
31
|
+
CACHE_LIFETIME_SECONDS = CACHE_LIFETIME_MINUTES * SECONDS_PER_MINUTE
|
|
32
|
+
SEND_LATER_STEP_DOWN_MINUTES = 57
|
|
33
|
+
SCHEDULE_WAKEUP_STEP_DOWN_SECONDS = 3480
|
|
34
|
+
|
|
35
|
+
CRON_TIMEZONE_PREFIX_PATTERN = re.compile(r"^CRON_TZ=\S+\s+")
|
|
36
|
+
CRON_FIELD_COUNT = 5
|
|
37
|
+
PINNED_FIELD_COUNT = 4
|
|
38
|
+
ALL_CRON_FIELD_RANGES = ((0, 23, 24), (1, 31, 32), (1, 12, 13), (0, 7, 7))
|
|
39
|
+
UTC_SUFFIX = "Z"
|
|
40
|
+
UTC_OFFSET_TEXT = "+00:00"
|
|
41
|
+
RFC3339_OUTPUT_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
|
|
42
|
+
|
|
43
|
+
REWRITE_CONTEXT_TEMPLATE = (
|
|
44
|
+
"wakeup_cap_gate rewrote {field} from {requested} to {allowed}. A wake {cap} or later"
|
|
45
|
+
" lands after the 1-hour prompt cache expires, so this wake now fires sooner."
|
|
46
|
+
)
|
|
47
|
+
DENY_REASON_TEMPLATE = (
|
|
48
|
+
"wakeup_cap_gate refused {tool_name}: {detail} A wake 60 minutes or more after the last"
|
|
49
|
+
" turn lands after the 1-hour prompt cache expires and resumes cold. Arm a wake of at"
|
|
50
|
+
" most {send_later_cap} minutes (send_later delay_minutes={send_later_cap}, ScheduleWakeup"
|
|
51
|
+
" delaySeconds={schedule_wakeup_cap}). For a longer wait, stack wakes under that cap:"
|
|
52
|
+
" put the remaining wait in the wake prompt and re-arm on each wake until it is covered,"
|
|
53
|
+
" as the usage-pause skill's stages_seconds chain does."
|
|
54
|
+
)
|
|
55
|
+
DENY_DELAY_DETAIL = "the wake is {minutes} minutes away."
|
|
56
|
+
DENY_CRON_DETAIL = "the cron '{cron}' leaves {minutes} minutes or more between wakes."
|
|
@@ -31,6 +31,7 @@ UNMEASURED_REASON_BY_SCRIPT = {
|
|
|
31
31
|
"project registry names by absolute path",
|
|
32
32
|
"session/plugin_change_warning.py": "emits only after a plugin changes on disk mid-session, "
|
|
33
33
|
"once per plugin, naming plugin ids",
|
|
34
|
+
"blocking/wakeup_cap_gate.py": "emits one fixed-template line only when it rewrites a wake delay",
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
|
package/package.json
CHANGED
|
@@ -146,6 +146,7 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
|
|
|
146
146
|
hooks/blocking/session_title_turn_gate.py -> exempt
|
|
147
147
|
hooks/blocking/resolve_thread_reply_gate.py -> exempt
|
|
148
148
|
hooks/blocking/turn_review_gate.py -> exempt
|
|
149
|
+
hooks/blocking/wakeup_cap_gate.py -> exempt
|
|
149
150
|
hooks/blocking/some_new_blocker.py -> flagged
|
|
150
151
|
|
|
151
152
|
The Bash PreToolUse dispatcher sits under ``blocking/`` for layout reasons
|
|
@@ -213,6 +214,10 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
|
|
|
213
214
|
the agent fixes or answers each finding; the gate itself only decides when
|
|
214
215
|
a turn may end.
|
|
215
216
|
|
|
217
|
+
The wakeup cap gate shortens or denies a self-wakeup scheduled 60 minutes
|
|
218
|
+
or more away, so the wake lands inside the prompt cache. It decides when a
|
|
219
|
+
session wakes and no code or safety policy.
|
|
220
|
+
|
|
216
221
|
Args:
|
|
217
222
|
registered_string: One command, path, script, or entrypoint string.
|
|
218
223
|
"""
|
|
@@ -85,6 +85,7 @@ ALL_ACTION_BOUNDARY_EXEMPT_REGISTRATION_PATHS = (
|
|
|
85
85
|
"blocking/session_title_turn_gate.py",
|
|
86
86
|
"blocking/resolve_thread_reply_gate.py",
|
|
87
87
|
"blocking/turn_review_gate.py",
|
|
88
|
+
"blocking/wakeup_cap_gate.py",
|
|
88
89
|
)
|
|
89
90
|
ALL_TEST_DIRECTORY_NAMES = frozenset({"tests"})
|
|
90
91
|
ALL_TEST_FILE_PREFIXES = ("test_",)
|