claude-dev-env 8.80.0 → 8.81.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/pr_lifecycle_skill_gate.py +13 -3
- package/hooks/blocking/pull_request_proof.py +42 -13
- package/hooks/blocking/pull_request_proof_edit_gate.py +180 -0
- package/hooks/blocking/test_pr_lifecycle_skill_gate.py +8 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +1 -0
- package/hooks/blocking/test_pull_request_proof.py +7 -0
- package/hooks/blocking/test_pull_request_proof_edit_gate.py +199 -0
- package/hooks/features/write-edit-blocking.md +4 -1
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
- package/hooks/hooks_constants/pull_request_proof_constants.py +22 -3
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +9 -6
- package/package.json +1 -1
|
@@ -261,14 +261,24 @@ def decision_for(all_payload_fields: dict[str, object]) -> dict[str, object] | N
|
|
|
261
261
|
return None if duplicate_reason is None else _deny(duplicate_reason)
|
|
262
262
|
|
|
263
263
|
|
|
264
|
-
def
|
|
264
|
+
def transcript_paths(all_payload_fields: dict[str, object]) -> list[str]:
|
|
265
|
+
"""Return each transcript path a hook payload names, with the subagent transcript last.
|
|
266
|
+
|
|
267
|
+
::
|
|
268
|
+
|
|
269
|
+
transcript_path only -> [transcript_path]
|
|
270
|
+
transcript_path and a known agent_id -> [transcript_path, <session>/subagents/agent-<id>.jsonl]
|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
all_payload_fields: The parsed PreToolUse input.
|
|
274
|
+
"""
|
|
265
275
|
paths = [all_payload_fields.get(field) for field in ALL_TRANSCRIPT_PATH_FIELDS]
|
|
266
276
|
paths.append(_subagent_transcript_path(all_payload_fields))
|
|
267
277
|
return list(dict.fromkeys(path for path in paths if isinstance(path, str) and path))
|
|
268
278
|
|
|
269
279
|
|
|
270
280
|
def _is_skill_unloaded(all_payload_fields: dict[str, object]) -> bool:
|
|
271
|
-
readable_paths =
|
|
281
|
+
readable_paths = transcript_paths(all_payload_fields)
|
|
272
282
|
if not readable_paths:
|
|
273
283
|
return False
|
|
274
284
|
statuses = [_scanned_transcript(path, _shows_skill_load) for path in readable_paths]
|
|
@@ -278,7 +288,7 @@ def _is_skill_unloaded(all_payload_fields: dict[str, object]) -> bool:
|
|
|
278
288
|
def _is_build_eval_missing(all_payload_fields: dict[str, object]) -> bool:
|
|
279
289
|
all_statuses = [
|
|
280
290
|
_scanned_transcript(each_path, _shows_build_eval)
|
|
281
|
-
for each_path in
|
|
291
|
+
for each_path in transcript_paths(all_payload_fields)
|
|
282
292
|
]
|
|
283
293
|
all_read_statuses = [each_status for each_status in all_statuses if each_status is not None]
|
|
284
294
|
return bool(all_read_statuses) and not any(all_read_statuses)
|
|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import re
|
|
6
6
|
import subprocess
|
|
7
7
|
import sys
|
|
8
|
-
from collections.abc import Mapping
|
|
8
|
+
from collections.abc import Iterator, Mapping
|
|
9
9
|
from typing import NamedTuple
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
|
|
@@ -130,15 +130,30 @@ def _create_arguments(all_segment_tokens: list[str]) -> list[str] | None:
|
|
|
130
130
|
return _script_create_arguments(program, all_arguments)
|
|
131
131
|
|
|
132
132
|
|
|
133
|
+
def _shell_command(all_payload_fields: Mapping[str, object]) -> str | None:
|
|
134
|
+
tool_input = all_payload_fields.get("tool_input")
|
|
135
|
+
command = tool_input.get("command") if isinstance(tool_input, dict) else None
|
|
136
|
+
if all_payload_fields.get("tool_name") not in SHELL_TOOL_NAMES or not isinstance(command, str):
|
|
137
|
+
return None
|
|
138
|
+
return command
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _all_create_argument_lists(command: str) -> Iterator[list[str]]:
|
|
142
|
+
return (
|
|
143
|
+
all_create_arguments
|
|
144
|
+
for each_text in all_wrapped_command_texts(command)
|
|
145
|
+
for each_segment, _each_following_operator in pipeline_segments_for_command(each_text)
|
|
146
|
+
if (all_create_arguments := _create_arguments(each_segment)) is not None
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
133
150
|
def _shell_create_calls(command: str, working_directory: str) -> list[CreateCall]:
|
|
134
151
|
return [
|
|
135
152
|
CreateCall(
|
|
136
153
|
title_from_arguments(all_create_arguments),
|
|
137
154
|
body_from_arguments(all_create_arguments, working_directory),
|
|
138
155
|
)
|
|
139
|
-
for
|
|
140
|
-
for each_segment, _each_following_operator in pipeline_segments_for_command(each_text)
|
|
141
|
-
if (all_create_arguments := _create_arguments(each_segment)) is not None
|
|
156
|
+
for all_create_arguments in _all_create_argument_lists(command)
|
|
142
157
|
]
|
|
143
158
|
|
|
144
159
|
|
|
@@ -156,15 +171,12 @@ def _named_head_branch(all_payload_fields: Mapping[str, object]) -> str | None:
|
|
|
156
171
|
head_branch = None if create_fields is None else create_fields.get("head")
|
|
157
172
|
if isinstance(head_branch, str):
|
|
158
173
|
return head_branch
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
if all_payload_fields.get("tool_name") not in SHELL_TOOL_NAMES or not isinstance(command, str):
|
|
174
|
+
command = _shell_command(all_payload_fields)
|
|
175
|
+
if command is None:
|
|
162
176
|
return None
|
|
163
177
|
all_head_flag_values = [
|
|
164
178
|
_head_flag_value(all_create_arguments)
|
|
165
|
-
for
|
|
166
|
-
for each_segment, _each_following_operator in pipeline_segments_for_command(each_text)
|
|
167
|
-
if (all_create_arguments := _create_arguments(each_segment)) is not None
|
|
179
|
+
for all_create_arguments in _all_create_argument_lists(command)
|
|
168
180
|
]
|
|
169
181
|
return next((each_value for each_value in all_head_flag_values if each_value), None)
|
|
170
182
|
|
|
@@ -180,9 +192,8 @@ def _create_calls(all_payload_fields: Mapping[str, object]) -> list[CreateCall]:
|
|
|
180
192
|
body if isinstance(body, str) else "",
|
|
181
193
|
)
|
|
182
194
|
]
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
if all_payload_fields.get("tool_name") not in SHELL_TOOL_NAMES or not isinstance(command, str):
|
|
195
|
+
command = _shell_command(all_payload_fields)
|
|
196
|
+
if command is None:
|
|
186
197
|
return []
|
|
187
198
|
working_directory = all_payload_fields.get("cwd")
|
|
188
199
|
return _shell_create_calls(
|
|
@@ -190,6 +201,24 @@ def _create_calls(all_payload_fields: Mapping[str, object]) -> list[CreateCall]:
|
|
|
190
201
|
)
|
|
191
202
|
|
|
192
203
|
|
|
204
|
+
def is_create_call(all_payload_fields: Mapping[str, object]) -> bool:
|
|
205
|
+
"""Return whether a tool call opens a pull request.
|
|
206
|
+
|
|
207
|
+
::
|
|
208
|
+
|
|
209
|
+
gh pr create --title t --body-file b.md -> True
|
|
210
|
+
mcp__github__create_pull_request -> True
|
|
211
|
+
gh pr edit 7, git push, Read -> False
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
all_payload_fields: A tool name and input, shaped like a PreToolUse payload.
|
|
215
|
+
"""
|
|
216
|
+
command = _shell_command(all_payload_fields)
|
|
217
|
+
return create_tool_fields(all_payload_fields) is not None or (
|
|
218
|
+
command is not None and next(_all_create_argument_lists(command), None) is not None
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
193
222
|
def _create_bodies(all_payload_fields: Mapping[str, object]) -> list[str | None]:
|
|
194
223
|
return [each_call.body for each_call in _create_calls(all_payload_fields)]
|
|
195
224
|
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Deny an edit to the pull request proof gate while its last deny in the session has no passing create call after it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Iterable, Mapping
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
11
|
+
if hooks_directory not in sys.path:
|
|
12
|
+
sys.path.insert(0, hooks_directory)
|
|
13
|
+
|
|
14
|
+
from blocking.codex_apply_patch import payload_patch_target_paths
|
|
15
|
+
from blocking.pr_lifecycle_skill_gate import transcript_paths
|
|
16
|
+
from blocking.pull_request_proof import is_create_call
|
|
17
|
+
from blocking.step_note_gate import entry_content_blocks
|
|
18
|
+
from hooks_constants.hook_specific_output_keys import (
|
|
19
|
+
HOOK_EVENT_NAME_KEY,
|
|
20
|
+
HOOK_SPECIFIC_OUTPUT_KEY,
|
|
21
|
+
PERMISSION_DECISION_KEY,
|
|
22
|
+
)
|
|
23
|
+
from hooks_constants.pr_lifecycle_skill_gate_constants import (
|
|
24
|
+
DENY_DECISION,
|
|
25
|
+
HOOK_EVENT_NAME,
|
|
26
|
+
PERMISSION_DECISION_REASON_KEY,
|
|
27
|
+
)
|
|
28
|
+
from hooks_constants.pre_tool_use_dispatcher_constants import APPLY_PATCH_TOOL_NAME
|
|
29
|
+
from hooks_constants.pre_tool_use_stdin import print_decision_from_stdin
|
|
30
|
+
from hooks_constants.pull_request_proof_constants import (
|
|
31
|
+
ALL_PROOF_GATE_DENY_MARKERS,
|
|
32
|
+
ALL_PROOF_GATE_FILE_NAMES,
|
|
33
|
+
GATE_EDIT_BEFORE_ROUTE_REASON,
|
|
34
|
+
LINE_SEPARATOR,
|
|
35
|
+
POSIX_PATH_SEPARATOR,
|
|
36
|
+
WINDOWS_PATH_SEPARATOR,
|
|
37
|
+
)
|
|
38
|
+
from hooks_constants.setup_project_paths_constants import DECODE_ERRORS_POLICY, UTF8_ENCODING
|
|
39
|
+
from hooks_constants.spawn_readiness_hook_constants import (
|
|
40
|
+
TOOL_RESULT_BLOCK_TYPE,
|
|
41
|
+
TOOL_USE_BLOCK_TYPE,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _content_blocks(transcript_line: str) -> list[dict]:
|
|
46
|
+
try:
|
|
47
|
+
entry = json.loads(transcript_line)
|
|
48
|
+
except json.JSONDecodeError:
|
|
49
|
+
return []
|
|
50
|
+
if not isinstance(entry, dict) or not isinstance(entry.get("message"), dict):
|
|
51
|
+
return []
|
|
52
|
+
return entry_content_blocks(entry)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _result_text(content: object) -> str:
|
|
56
|
+
if isinstance(content, str):
|
|
57
|
+
return content
|
|
58
|
+
if not isinstance(content, list):
|
|
59
|
+
return ""
|
|
60
|
+
return LINE_SEPARATOR.join(
|
|
61
|
+
each_block["text"]
|
|
62
|
+
for each_block in content
|
|
63
|
+
if isinstance(each_block, dict) and isinstance(each_block.get("text"), str)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _is_proof_gate_deny(all_block_fields: dict[str, object]) -> bool:
|
|
68
|
+
result_text = _result_text(all_block_fields.get("content"))
|
|
69
|
+
return any(each_marker in result_text for each_marker in ALL_PROOF_GATE_DENY_MARKERS)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _deny_state_after(
|
|
73
|
+
all_block_fields: dict[str, object], all_create_tool_use_ids: set[str], is_deny_open: bool
|
|
74
|
+
) -> bool:
|
|
75
|
+
if all_block_fields.get("type") == TOOL_USE_BLOCK_TYPE:
|
|
76
|
+
tool_use_id = all_block_fields.get("id")
|
|
77
|
+
payload = {"tool_name": all_block_fields.get("name"), "tool_input": all_block_fields.get("input")}
|
|
78
|
+
if isinstance(tool_use_id, str) and is_create_call(payload):
|
|
79
|
+
all_create_tool_use_ids.add(tool_use_id)
|
|
80
|
+
return is_deny_open
|
|
81
|
+
if all_block_fields.get("type") != TOOL_RESULT_BLOCK_TYPE:
|
|
82
|
+
return is_deny_open
|
|
83
|
+
result_tool_use_id = all_block_fields.get("tool_use_id")
|
|
84
|
+
if not isinstance(result_tool_use_id, str) or result_tool_use_id not in all_create_tool_use_ids:
|
|
85
|
+
return is_deny_open
|
|
86
|
+
if all_block_fields.get("is_error") is True:
|
|
87
|
+
return is_deny_open or _is_proof_gate_deny(all_block_fields)
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def has_open_deny(all_transcript_lines: Iterable[str]) -> bool:
|
|
92
|
+
"""Return whether the proof gate's last deny in a transcript has no passing create call after it.
|
|
93
|
+
|
|
94
|
+
::
|
|
95
|
+
|
|
96
|
+
create denied with the proof gate's reason -> True
|
|
97
|
+
create denied, later create returned without an error -> False
|
|
98
|
+
no deny from the proof gate -> False
|
|
99
|
+
|
|
100
|
+
A deny is an errored result of a create call that carries one of the proof gate's guide paths.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
all_transcript_lines: The JSON lines of a session transcript, oldest first.
|
|
104
|
+
"""
|
|
105
|
+
all_create_tool_use_ids: set[str] = set()
|
|
106
|
+
is_deny_open = False
|
|
107
|
+
for each_line in all_transcript_lines:
|
|
108
|
+
for each_block in _content_blocks(each_line):
|
|
109
|
+
is_deny_open = _deny_state_after(each_block, all_create_tool_use_ids, is_deny_open)
|
|
110
|
+
return is_deny_open
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _transcript_has_open_deny(transcript_path: str) -> bool:
|
|
114
|
+
try:
|
|
115
|
+
with open(transcript_path, encoding=UTF8_ENCODING, errors=DECODE_ERRORS_POLICY) as transcript:
|
|
116
|
+
return has_open_deny(transcript)
|
|
117
|
+
except OSError:
|
|
118
|
+
return False
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _target_paths(all_payload_fields: Mapping[str, object]) -> tuple[str, ...]:
|
|
122
|
+
tool_input = all_payload_fields.get("tool_input")
|
|
123
|
+
if not isinstance(tool_input, dict):
|
|
124
|
+
return ()
|
|
125
|
+
if all_payload_fields.get("tool_name") == APPLY_PATCH_TOOL_NAME:
|
|
126
|
+
try:
|
|
127
|
+
return payload_patch_target_paths(dict(all_payload_fields), tool_input)
|
|
128
|
+
except ValueError:
|
|
129
|
+
return ()
|
|
130
|
+
file_path = tool_input.get("file_path")
|
|
131
|
+
return (file_path,) if isinstance(file_path, str) else ()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _edits_proof_gate(all_payload_fields: Mapping[str, object]) -> bool:
|
|
135
|
+
return any(
|
|
136
|
+
Path(each_path.replace(WINDOWS_PATH_SEPARATOR, POSIX_PATH_SEPARATOR)).name.lower()
|
|
137
|
+
in ALL_PROOF_GATE_FILE_NAMES
|
|
138
|
+
for each_path in _target_paths(all_payload_fields)
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def gate_edit_reason(all_payload_fields: Mapping[str, object]) -> str | None:
|
|
143
|
+
"""Return a deny reason for an edit to the proof gate while its last deny is still open, else None.
|
|
144
|
+
|
|
145
|
+
::
|
|
146
|
+
|
|
147
|
+
Edit or apply_patch of a gate file after an open deny -> GATE_EDIT_BEFORE_ROUTE_REASON
|
|
148
|
+
Edit of pull_request_proof.py after a later passing create -> None
|
|
149
|
+
Edit of any other file, or no readable transcript -> None
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
all_payload_fields: The parsed PreToolUse input of a Write, Edit, MultiEdit or apply_patch call.
|
|
153
|
+
"""
|
|
154
|
+
if not _edits_proof_gate(all_payload_fields):
|
|
155
|
+
return None
|
|
156
|
+
if any(_transcript_has_open_deny(each_path) for each_path in transcript_paths(dict(all_payload_fields))):
|
|
157
|
+
return GATE_EDIT_BEFORE_ROUTE_REASON
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _gate_edit_decision(all_payload_fields: dict[str, object]) -> dict[str, object] | None:
|
|
162
|
+
reason = gate_edit_reason(all_payload_fields)
|
|
163
|
+
if reason is None:
|
|
164
|
+
return None
|
|
165
|
+
return {
|
|
166
|
+
HOOK_SPECIFIC_OUTPUT_KEY: {
|
|
167
|
+
HOOK_EVENT_NAME_KEY: HOOK_EVENT_NAME,
|
|
168
|
+
PERMISSION_DECISION_KEY: DENY_DECISION,
|
|
169
|
+
PERMISSION_DECISION_REASON_KEY: reason,
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def main() -> int:
|
|
175
|
+
"""Read one Write, Edit, MultiEdit or apply_patch payload and print a deny when it edits the proof gate too soon."""
|
|
176
|
+
return print_decision_from_stdin(_gate_edit_decision)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
if __name__ == "__main__":
|
|
180
|
+
sys.exit(main())
|
|
@@ -117,6 +117,14 @@ def test_subagent_without_invocation_is_denied(tmp_path: Path) -> None:
|
|
|
117
117
|
_assert_denied(payload)
|
|
118
118
|
|
|
119
119
|
|
|
120
|
+
def test_transcript_paths_should_add_the_subagent_transcript_after_the_session(tmp_path: Path) -> None:
|
|
121
|
+
session_path = _transcript(tmp_path)
|
|
122
|
+
subagent_path = _subagent_transcript(tmp_path, "a8865a3f3c71595ce", None)
|
|
123
|
+
payload = {"transcript_path": str(session_path), "agent_id": "a8865a3f3c71595ce"}
|
|
124
|
+
assert gate.transcript_paths(payload) == [str(session_path), str(subagent_path)]
|
|
125
|
+
assert gate.transcript_paths({"transcript_path": str(session_path)}) == [str(session_path)]
|
|
126
|
+
|
|
127
|
+
|
|
120
128
|
def test_missing_subagent_transcript_keeps_session_deny(tmp_path: Path) -> None:
|
|
121
129
|
payload = _payload("git push", _transcript(tmp_path))
|
|
122
130
|
payload["agent_id"] = "a8865a3f3c71595ce"
|
|
@@ -806,6 +806,7 @@ def test_dispatcher_allows_clean_apply_patch_add(tmp_path: Path) -> None:
|
|
|
806
806
|
|
|
807
807
|
ALL_RETAINED_PATHS = {
|
|
808
808
|
"blocking/context_budget_blocker.py",
|
|
809
|
+
"blocking/pull_request_proof_edit_gate.py",
|
|
809
810
|
}
|
|
810
811
|
ALL_RETIRED_PATHS = {
|
|
811
812
|
"validation/hook_format_validator.py",
|
|
@@ -333,3 +333,10 @@ def test_dispatch_that_names_no_head_or_runs_no_workflow_should_not_count_as_a_c
|
|
|
333
333
|
for each_payload in all_payloads:
|
|
334
334
|
assert gate.missing_proof_reason(each_payload) is None
|
|
335
335
|
assert not gate.is_feature_pull_request(each_payload)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def test_is_create_call_should_name_shell_and_mcp_creates() -> None:
|
|
339
|
+
assert gate.is_create_call(_shell("gh pr create --title t --body b"))
|
|
340
|
+
assert gate.is_create_call(_mcp_create("b"))
|
|
341
|
+
assert not gate.is_create_call(_shell("gh pr edit 7 --body b"))
|
|
342
|
+
assert not gate.is_create_call({"tool_name": "Read", "tool_input": {"file_path": "a.py"}})
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Behavior tests for the gate that holds an edit to the proof gate until its named route is taken."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
HOOKS_DIRECTORY = Path(__file__).resolve().parent.parent
|
|
8
|
+
if str(HOOKS_DIRECTORY) not in sys.path:
|
|
9
|
+
sys.path.insert(0, str(HOOKS_DIRECTORY))
|
|
10
|
+
|
|
11
|
+
from blocking import pull_request_proof_edit_gate as gate
|
|
12
|
+
from hooks_constants.pull_request_proof_constants import (
|
|
13
|
+
GATE_EDIT_BEFORE_ROUTE_REASON,
|
|
14
|
+
UNREADABLE_CHANGES_REASON,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _transcript(tmp_path: Path, all_entries: list[dict[str, object]]) -> str:
|
|
19
|
+
transcript_path = tmp_path / "session.jsonl"
|
|
20
|
+
transcript_path.write_text(
|
|
21
|
+
"\n".join(json.dumps(each_entry) for each_entry in all_entries) + "\n", encoding="utf-8"
|
|
22
|
+
)
|
|
23
|
+
return str(transcript_path)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _tool_use(tool_use_id: object, name: str, tool_input: dict[str, object]) -> dict[str, object]:
|
|
27
|
+
return {
|
|
28
|
+
"type": "assistant",
|
|
29
|
+
"message": {"content": [{"type": "tool_use", "id": tool_use_id, "name": name, "input": tool_input}]},
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _tool_result(tool_use_id: object, content: object, is_error: bool) -> dict[str, object]:
|
|
34
|
+
return {
|
|
35
|
+
"type": "user",
|
|
36
|
+
"message": {
|
|
37
|
+
"content": [
|
|
38
|
+
{"type": "tool_result", "tool_use_id": tool_use_id, "content": content, "is_error": is_error}
|
|
39
|
+
]
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
SHELL_CREATE_INPUT = {"command": "gh pr create --title t --body-file body.md"}
|
|
45
|
+
DENIED_CREATE_ENTRIES = [
|
|
46
|
+
_tool_use("create-1", "Bash", SHELL_CREATE_INPUT),
|
|
47
|
+
_tool_result("create-1", UNREADABLE_CHANGES_REASON, True),
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _edit_payload(transcript_path: str, file_path: str) -> dict[str, object]:
|
|
52
|
+
return {
|
|
53
|
+
"tool_name": "Edit",
|
|
54
|
+
"tool_input": {"file_path": file_path, "old_string": "a", "new_string": "b"},
|
|
55
|
+
"transcript_path": transcript_path,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
GATE_FILE_PATH = "C:/workspace/x/packages/claude-dev-env/hooks/blocking/pull_request_proof.py"
|
|
60
|
+
GATE_CONSTANTS_PATH = "/w/packages/claude-dev-env/hooks/hooks_constants/pull_request_proof_constants.py"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_should_deny_an_edit_to_the_gate_after_its_deny_with_no_passing_create(tmp_path: Path) -> None:
|
|
64
|
+
transcript_path = _transcript(tmp_path, DENIED_CREATE_ENTRIES)
|
|
65
|
+
assert gate.gate_edit_reason(_edit_payload(transcript_path, GATE_FILE_PATH)) == GATE_EDIT_BEFORE_ROUTE_REASON
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_should_deny_an_edit_to_the_gate_constants_after_its_deny(tmp_path: Path) -> None:
|
|
69
|
+
transcript_path = _transcript(tmp_path, DENIED_CREATE_ENTRIES)
|
|
70
|
+
payload = _edit_payload(transcript_path, GATE_CONSTANTS_PATH)
|
|
71
|
+
payload["tool_name"] = "Write"
|
|
72
|
+
assert gate.gate_edit_reason(payload) == GATE_EDIT_BEFORE_ROUTE_REASON
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_should_read_a_deny_carried_as_text_blocks(tmp_path: Path) -> None:
|
|
76
|
+
all_text_blocks = [{"type": "text", "text": "Error: " + UNREADABLE_CHANGES_REASON}]
|
|
77
|
+
transcript_path = _transcript(
|
|
78
|
+
tmp_path,
|
|
79
|
+
[_tool_use("create-1", "Bash", SHELL_CREATE_INPUT), _tool_result("create-1", all_text_blocks, True)],
|
|
80
|
+
)
|
|
81
|
+
assert gate.gate_edit_reason(_edit_payload(transcript_path, GATE_FILE_PATH)) == GATE_EDIT_BEFORE_ROUTE_REASON
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_should_allow_a_gate_edit_once_a_later_create_passed(tmp_path: Path) -> None:
|
|
85
|
+
transcript_path = _transcript(
|
|
86
|
+
tmp_path,
|
|
87
|
+
[
|
|
88
|
+
*DENIED_CREATE_ENTRIES,
|
|
89
|
+
_tool_use("create-2", "mcp__github__create_pull_request", {"owner": "o", "repo": "r", "body": "b"}),
|
|
90
|
+
_tool_result("create-2", "https://github.com/o/r/pull/7", False),
|
|
91
|
+
],
|
|
92
|
+
)
|
|
93
|
+
assert gate.gate_edit_reason(_edit_payload(transcript_path, GATE_FILE_PATH)) is None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_should_deny_again_when_a_deny_follows_the_passing_create(tmp_path: Path) -> None:
|
|
97
|
+
transcript_path = _transcript(
|
|
98
|
+
tmp_path,
|
|
99
|
+
[
|
|
100
|
+
_tool_use("create-1", "Bash", SHELL_CREATE_INPUT),
|
|
101
|
+
_tool_result("create-1", "https://github.com/o/r/pull/7", False),
|
|
102
|
+
*DENIED_CREATE_ENTRIES,
|
|
103
|
+
],
|
|
104
|
+
)
|
|
105
|
+
assert gate.gate_edit_reason(_edit_payload(transcript_path, GATE_FILE_PATH)) == GATE_EDIT_BEFORE_ROUTE_REASON
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_should_allow_an_edit_to_another_file_after_the_deny(tmp_path: Path) -> None:
|
|
109
|
+
transcript_path = _transcript(tmp_path, DENIED_CREATE_ENTRIES)
|
|
110
|
+
assert gate.gate_edit_reason(_edit_payload(transcript_path, "/w/hooks/blocking/other_gate.py")) is None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_should_allow_a_gate_edit_in_a_session_this_gate_never_denied(tmp_path: Path) -> None:
|
|
114
|
+
transcript_path = _transcript(
|
|
115
|
+
tmp_path,
|
|
116
|
+
[
|
|
117
|
+
_tool_use("read-1", "Read", {"file_path": GATE_CONSTANTS_PATH}),
|
|
118
|
+
_tool_result("read-1", UNREADABLE_CHANGES_REASON, False),
|
|
119
|
+
_tool_use("other-1", "Bash", {"command": "pytest"}),
|
|
120
|
+
_tool_result("other-1", "1 failed", True),
|
|
121
|
+
],
|
|
122
|
+
)
|
|
123
|
+
assert gate.gate_edit_reason(_edit_payload(transcript_path, GATE_FILE_PATH)) is None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_should_allow_a_gate_edit_without_a_readable_transcript(tmp_path: Path) -> None:
|
|
127
|
+
payload = _edit_payload(str(tmp_path / "missing.jsonl"), GATE_FILE_PATH)
|
|
128
|
+
assert gate.gate_edit_reason(payload) is None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_should_deny_an_apply_patch_to_the_edit_gate_after_its_deny(tmp_path: Path) -> None:
|
|
132
|
+
transcript_path = _transcript(tmp_path, DENIED_CREATE_ENTRIES)
|
|
133
|
+
patch_text = (
|
|
134
|
+
"*** Begin Patch\n*** Update File: hooks/blocking/pull_request_proof_edit_gate.py\n"
|
|
135
|
+
"@@\n-a\n+b\n*** End Patch\n"
|
|
136
|
+
)
|
|
137
|
+
payload = {
|
|
138
|
+
"tool_name": "apply_patch",
|
|
139
|
+
"tool_input": {"command": patch_text},
|
|
140
|
+
"cwd": str(tmp_path),
|
|
141
|
+
"transcript_path": transcript_path,
|
|
142
|
+
}
|
|
143
|
+
assert gate.gate_edit_reason(payload) == GATE_EDIT_BEFORE_ROUTE_REASON
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_should_ignore_an_errored_result_of_another_tool_that_names_the_guide(tmp_path: Path) -> None:
|
|
147
|
+
transcript_path = _transcript(
|
|
148
|
+
tmp_path,
|
|
149
|
+
[
|
|
150
|
+
_tool_use("cat-1", "Bash", {"command": "cat proof-before-pull-request.md"}),
|
|
151
|
+
_tool_result("cat-1", "cat: " + UNREADABLE_CHANGES_REASON, True),
|
|
152
|
+
],
|
|
153
|
+
)
|
|
154
|
+
assert gate.gate_edit_reason(_edit_payload(transcript_path, GATE_FILE_PATH)) is None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_should_skip_malformed_transcript_entries_and_keep_the_deny(tmp_path: Path) -> None:
|
|
158
|
+
transcript_path = _transcript(
|
|
159
|
+
tmp_path,
|
|
160
|
+
[
|
|
161
|
+
{"type": "user", "message": "plain text"},
|
|
162
|
+
_tool_use(["not", "a", "string"], "Bash", SHELL_CREATE_INPUT),
|
|
163
|
+
_tool_result(["not", "a", "string"], "done", False),
|
|
164
|
+
*DENIED_CREATE_ENTRIES,
|
|
165
|
+
],
|
|
166
|
+
)
|
|
167
|
+
assert gate.gate_edit_reason(_edit_payload(transcript_path, GATE_FILE_PATH)) == GATE_EDIT_BEFORE_ROUTE_REASON
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_should_deny_a_multi_edit_of_a_case_variant_windows_path_from_the_agent_transcript(tmp_path: Path) -> None:
|
|
171
|
+
transcript_path = _transcript(tmp_path, DENIED_CREATE_ENTRIES)
|
|
172
|
+
payload = {
|
|
173
|
+
"tool_name": "MultiEdit",
|
|
174
|
+
"tool_input": {"file_path": "C:\\workspace\\x\\hooks\\blocking\\Pull_Request_Proof.PY", "edits": []},
|
|
175
|
+
"agent_transcript_path": transcript_path,
|
|
176
|
+
}
|
|
177
|
+
assert gate.gate_edit_reason(payload) == GATE_EDIT_BEFORE_ROUTE_REASON
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_should_deny_a_subagent_edit_after_a_deny_in_its_own_transcript(tmp_path: Path) -> None:
|
|
181
|
+
session_path = _transcript(tmp_path, [])
|
|
182
|
+
subagent_directory = tmp_path / "session" / "subagents"
|
|
183
|
+
subagent_directory.mkdir(parents=True)
|
|
184
|
+
(subagent_directory / "agent-a8865a3f3c71595ce.jsonl").write_text(
|
|
185
|
+
"\n".join(json.dumps(each_entry) for each_entry in DENIED_CREATE_ENTRIES) + "\n", encoding="utf-8"
|
|
186
|
+
)
|
|
187
|
+
payload = _edit_payload(session_path, GATE_FILE_PATH)
|
|
188
|
+
payload["agent_id"] = "a8865a3f3c71595ce"
|
|
189
|
+
assert gate.gate_edit_reason(payload) == GATE_EDIT_BEFORE_ROUTE_REASON
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def test_should_allow_an_apply_patch_the_patch_parser_cannot_resolve(tmp_path: Path) -> None:
|
|
193
|
+
payload = {
|
|
194
|
+
"tool_name": "apply_patch",
|
|
195
|
+
"tool_input": {"command": "*** Begin Patch\n*** Add File: a\x00b.py\n+x\n*** End Patch\n"},
|
|
196
|
+
"cwd": str(tmp_path),
|
|
197
|
+
"transcript_path": _transcript(tmp_path, DENIED_CREATE_ENTRIES),
|
|
198
|
+
}
|
|
199
|
+
assert gate.gate_edit_reason(payload) is None
|
|
@@ -7,12 +7,14 @@ This family decides whether an agent's file mutation can run.
|
|
|
7
7
|
- `blocking/pre_tool_use_dispatcher.py` runs its native description check and returns one permission decision.
|
|
8
8
|
- `blocking/state_description_blocker.py` rejects historical or comparative prose in supported comments, docstrings, and Markdown.
|
|
9
9
|
- `blocking/context_budget_blocker.py` rejects a write that grows a skill entry, `AGENTS.md`, `CLAUDE.md`, or rule file past its context budget.
|
|
10
|
+
- `blocking/pull_request_proof_edit_gate.py` rejects an edit to the pull request proof gate, its constants, this edit gate, or the dispatcher roster while the proof gate's last deny of a create call in the session has no passing create call after it.
|
|
10
11
|
|
|
11
12
|
## When it fires
|
|
12
13
|
|
|
13
14
|
- `blocking/pre_tool_use_dispatcher.py` runs on `PreToolUse`, matcher `Write|Edit|MultiEdit|apply_patch`, timeout `60` seconds in `hooks.json`.
|
|
14
15
|
- `blocking/state_description_blocker.py` runs through the dispatcher's native evaluator on `PreToolUse`, matcher `Write|Edit|MultiEdit|apply_patch`, timeout `60` seconds. Its evaluator checks `Write`, `Edit`, and `MultiEdit` payloads and skips `apply_patch`.
|
|
15
16
|
- `blocking/context_budget_blocker.py` runs inside that dispatcher as a blocking hosted entry on `PreToolUse`, matcher `Write|Edit|MultiEdit|apply_patch`, timeout `60` seconds. It reads `.claude/context-budget.json` from the edited file's own repository.
|
|
17
|
+
- `blocking/pull_request_proof_edit_gate.py` runs inside that dispatcher as a blocking hosted entry on `PreToolUse` for `Write`, `Edit`, `MultiEdit`, and `apply_patch`, timeout `60` seconds. It reads the session transcript the payload names and, for a subagent, its own transcript.
|
|
16
18
|
|
|
17
19
|
## Proving it
|
|
18
20
|
|
|
@@ -22,11 +24,12 @@ Preconditions:
|
|
|
22
24
|
|
|
23
25
|
- **Dispatcher decision.** Input is a clean Write payload. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_pre_tool_use_dispatcher.py -q`. The adjacent test checks that the dispatcher allows clean writes and carries denials from its checks.
|
|
24
26
|
- **Context budget gate.** Input is a Write of the orchestrator skill at commit `3f2c3ef`, then at `98f45b1`. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_context_budget_blocker.py -q`. The adjacent test observes a denial naming "Oversee delegated work" for the first and an allow for the second.
|
|
27
|
+
- **Proof edit gate.** Input is a transcript with a proof gate deny, then an Edit of `pull_request_proof.py`. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_pull_request_proof_edit_gate.py -q`. The adjacent test observes a denial before a passing create call and an allow after one.
|
|
25
28
|
- **Description gate.** Input is a Write containing historical prose in a Python comment. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_state_description_blocker.py -q`. The adjacent test observes a denial reason.
|
|
26
29
|
|
|
27
30
|
## Gotchas
|
|
28
31
|
|
|
29
|
-
- `ALL_HOSTED_HOOK_ENTRIES` lists the blocking context budget gate. The dispatcher calls the description evaluator through its native hook table.
|
|
32
|
+
- `ALL_HOSTED_HOOK_ENTRIES` lists the blocking context budget gate and the proof edit gate. The dispatcher calls the description evaluator through its native hook table.
|
|
30
33
|
- The context budget gate denies only what an edit adds. A repository with no policy file uses built-in kinds and treats the file's current text as its baseline entry, so a file under its line limit may grow to the limit and a file over it may only shrink. The CI ratchet runs in `scripts/policy_lint/adapter_context_budget.py`, never in the hook. See the [context budget reference](../../.agents/skills/build-eval/reference/context-budget.md).
|
|
31
34
|
- The dispatcher runs hosted entries in roster order. A hosted advisory crash stays silent; a native blocking check can deny the write.
|
|
32
35
|
- `scripts/policy_lint/adapter_detectors.py` also loads `blocking/state_description_blocker.py`. Check that adapter when changing its evaluator.
|
|
@@ -87,4 +87,8 @@ ALL_HOSTED_HOOK_ENTRIES: tuple[HostedHookEntry, ...] = (
|
|
|
87
87
|
script_relative_path="blocking/context_budget_blocker.py",
|
|
88
88
|
applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_APPLY_PATCH_TOOL_NAMES,
|
|
89
89
|
),
|
|
90
|
+
HostedHookEntry(
|
|
91
|
+
script_relative_path="blocking/pull_request_proof_edit_gate.py",
|
|
92
|
+
applicable_tool_names=ALL_WRITE_EDIT_MULTI_EDIT_APPLY_PATCH_TOOL_NAMES,
|
|
93
|
+
),
|
|
90
94
|
)
|
|
@@ -59,9 +59,10 @@ MISSING_LOOK_REASON = (
|
|
|
59
59
|
CHANGED_FILES_SEPARATOR = ", "
|
|
60
60
|
UNREADABLE_CHANGES_REASON = (
|
|
61
61
|
"Git in this directory cannot list the files this pull request changes, so the"
|
|
62
|
-
" picture check cannot tell whether people see the change.
|
|
63
|
-
"
|
|
64
|
-
"
|
|
62
|
+
" picture check cannot tell whether people see the change. Put a screenshot or"
|
|
63
|
+
" Artifact link in the 'Proof in practice' section and run the create call again,"
|
|
64
|
+
" or run it from a checkout of the head branch with origin fetched. Take one of"
|
|
65
|
+
" these routes now, before any change to this gate. Read " + PROOF_GUIDE_PATH + " for the full rule."
|
|
65
66
|
)
|
|
66
67
|
FEATURE_TITLE_PATTERN = r"^\s*feat(\([^)]*\))?!?:"
|
|
67
68
|
EVAL_HEADING_PATTERN = r"^(#{1,6})[ \t]+evals?[ \t]*#*[ \t]*$"
|
|
@@ -83,3 +84,21 @@ MISSING_BUILD_EVAL_REASON = (
|
|
|
83
84
|
+ EVAL_GUIDE_PATH
|
|
84
85
|
+ " for the full rule."
|
|
85
86
|
)
|
|
87
|
+
ALL_PROOF_GATE_DENY_MARKERS = (PROOF_GUIDE_PATH, EXISTING_WORK_GUIDE_PATH, EVAL_GUIDE_PATH)
|
|
88
|
+
ALL_PROOF_GATE_FILE_NAMES = frozenset(
|
|
89
|
+
{
|
|
90
|
+
"pull_request_proof.py",
|
|
91
|
+
"pull_request_proof_constants.py",
|
|
92
|
+
"pull_request_proof_edit_gate.py",
|
|
93
|
+
"pre_tool_use_dispatcher_constants.py",
|
|
94
|
+
}
|
|
95
|
+
)
|
|
96
|
+
GATE_EDIT_BEFORE_ROUTE_REASON = (
|
|
97
|
+
"The pull request proof gate denied a create call in this session, and its deny"
|
|
98
|
+
" named the way through. Take that route now. Add what the deny asked for, such as"
|
|
99
|
+
" a screenshot or Artifact link in the 'Proof in practice' section, then run the"
|
|
100
|
+
" create call again. The deny text is the instruction. Edit this gate only"
|
|
101
|
+
" after a create call passes it. Read " + PROOF_GUIDE_PATH + " for the full rule."
|
|
102
|
+
)
|
|
103
|
+
WINDOWS_PATH_SEPARATOR = "\\"
|
|
104
|
+
POSIX_PATH_SEPARATOR = "/"
|
|
@@ -22,12 +22,15 @@ from hooks_constants.pre_tool_use_dispatcher_constants import (
|
|
|
22
22
|
from pre_tool_use_dispatcher import HostedHookResult, aggregate_hosted_hook_results
|
|
23
23
|
|
|
24
24
|
|
|
25
|
-
def
|
|
26
|
-
assert len(ALL_HOSTED_HOOK_ENTRIES) ==
|
|
27
|
-
|
|
28
|
-
assert
|
|
29
|
-
assert
|
|
30
|
-
assert
|
|
25
|
+
def test_roster_hosts_the_context_budget_and_proof_edit_gates() -> None:
|
|
26
|
+
assert len(ALL_HOSTED_HOOK_ENTRIES) == 2
|
|
27
|
+
context_budget_entry, proof_edit_entry = ALL_HOSTED_HOOK_ENTRIES
|
|
28
|
+
assert context_budget_entry.script_relative_path == "blocking/context_budget_blocker.py"
|
|
29
|
+
assert context_budget_entry.is_blocking
|
|
30
|
+
assert context_budget_entry.applicable_tool_names == ALL_WRITE_EDIT_MULTI_EDIT_APPLY_PATCH_TOOL_NAMES
|
|
31
|
+
assert proof_edit_entry.script_relative_path == "blocking/pull_request_proof_edit_gate.py"
|
|
32
|
+
assert proof_edit_entry.is_blocking
|
|
33
|
+
assert proof_edit_entry.applicable_tool_names == ALL_WRITE_EDIT_MULTI_EDIT_APPLY_PATCH_TOOL_NAMES
|
|
31
34
|
|
|
32
35
|
|
|
33
36
|
def test_every_hosted_script_path_exists_under_the_hooks_root() -> None:
|