claude-dev-env 8.79.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/.agents/skills/pr-lifecycle/reference/review-loop-before-merge.md +2 -2
- package/bin/install.settings-defaults.test.mjs +27 -2
- package/bin/merge_settings_defaults.mjs +39 -21
- package/bin/merge_settings_defaults.test.mjs +42 -0
- package/docs/account-broker.md +1 -1
- package/docs/codex-accounts.md +6 -5
- package/hooks/blocking/cloud_graphql_gate.py +213 -0
- package/hooks/blocking/headless_claude_broker_gate.py +1 -8
- package/hooks/blocking/merge_review_gate.py +65 -3
- package/hooks/blocking/pr_lifecycle_skill_gate.py +19 -10
- package/hooks/blocking/pull_request_proof.py +42 -13
- package/hooks/blocking/pull_request_proof_edit_gate.py +180 -0
- package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +5 -1
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +4 -1
- package/hooks/blocking/test_cloud_graphql_gate.py +210 -0
- package/hooks/blocking/test_headless_claude_broker_gate.py +12 -7
- package/hooks/blocking/test_merge_review_gate.py +63 -0
- package/hooks/blocking/test_pr_lifecycle_skill_gate.py +11 -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/conftest.py +1 -1
- package/hooks/features/bash-dispatchers.md +3 -0
- package/hooks/features/conduct-gates.md +2 -2
- package/hooks/features/write-edit-blocking.md +4 -1
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -0
- package/hooks/hooks_constants/cloud_graphql_gate_constants.py +116 -0
- package/hooks/hooks_constants/cloud_session.py +17 -0
- package/hooks/hooks_constants/cloud_session_constants.py +11 -0
- package/hooks/hooks_constants/gh_command_words.py +52 -0
- package/hooks/hooks_constants/gh_command_words_constants.py +64 -0
- package/hooks/hooks_constants/headless_claude_broker_gate_constants.py +1 -7
- package/hooks/hooks_constants/hook_block_logger.py +21 -3
- package/hooks/hooks_constants/merge_review_gate_constants.py +14 -3
- package/hooks/hooks_constants/mod_handoff.py +2 -3
- package/hooks/hooks_constants/mod_handoff_constants.py +0 -4
- package/hooks/hooks_constants/piped_pytest_blocker_constants.py +27 -2
- package/hooks/hooks_constants/pr_lifecycle_skill_gate_constants.py +0 -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/shell_command_evaluation_constants.py +67 -0
- package/hooks/hooks_constants/shell_command_substitution_constants.py +25 -0
- package/hooks/hooks_constants/shell_command_wrappers.py +327 -12
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +5 -1
- package/hooks/hooks_constants/test_cloud_session.py +27 -0
- package/hooks/hooks_constants/test_gh_command_words.py +45 -0
- package/hooks/hooks_constants/test_hook_block_logger.py +61 -0
- package/hooks/hooks_constants/test_mod_handoff.py +3 -2
- package/hooks/hooks_constants/test_piped_pytest_blocker_constants.py +11 -0
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +9 -6
- package/hooks/hooks_constants/test_shell_command_wrappers.py +125 -0
- package/hooks/test_conftest.py +1 -1
- package/package.json +1 -1
- package/scripts/account_broker_support.py +10 -11
- package/scripts/codex_account_meters.py +30 -43
- package/scripts/dev_env_scripts_constants/account_broker_constants.py +34 -25
- package/scripts/dev_env_scripts_constants/claude_account_constants.py +0 -9
- package/scripts/dev_env_scripts_constants/codex_account_constants.py +0 -18
- package/scripts/dev_env_scripts_constants/test_account_broker_constants.py +42 -0
- package/scripts/dev_env_scripts_constants/test_codex_account_constants.py +119 -0
- package/scripts/merge_review_receipt.py +56 -12
- package/scripts/test_account_broker.py +12 -0
- package/scripts/test_account_broker_support.py +32 -6
- package/scripts/test_codex_account_meters.py +26 -14
- package/scripts/test_merge_review_receipt.py +86 -0
- package/settings.json +3 -0
|
@@ -32,7 +32,6 @@ from hooks_constants.pr_lifecycle_skill_gate_constants import (
|
|
|
32
32
|
DENY_REASON,
|
|
33
33
|
ENVIRONMENT_ASSIGNMENT_PATTERN,
|
|
34
34
|
GH_API_COMMAND,
|
|
35
|
-
GH_OPTIONS_WITH_VALUE,
|
|
36
35
|
GH_PULL_REQUEST_COMMAND,
|
|
37
36
|
GIT_ACTIONS,
|
|
38
37
|
GIT_GLOBAL_OPTIONS_WITH_VALUE,
|
|
@@ -49,6 +48,7 @@ from hooks_constants.pr_lifecycle_skill_gate_constants import (
|
|
|
49
48
|
SUBAGENT_TRANSCRIPT_DIRECTORY_NAME,
|
|
50
49
|
SUBAGENT_TRANSCRIPT_FILE_TEMPLATE,
|
|
51
50
|
)
|
|
51
|
+
from hooks_constants.gh_command_words import gh_command_words
|
|
52
52
|
from hooks_constants.hook_specific_output_keys import (
|
|
53
53
|
HOOK_SPECIFIC_OUTPUT_KEY,
|
|
54
54
|
PERMISSION_DECISION_KEY,
|
|
@@ -113,13 +113,12 @@ def _is_git_action(all_words: list[str]) -> bool:
|
|
|
113
113
|
|
|
114
114
|
|
|
115
115
|
def _is_gh_action(all_words: list[str]) -> bool:
|
|
116
|
-
|
|
117
|
-
if
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
if action != GH_API_COMMAND:
|
|
116
|
+
all_gh_words = gh_command_words(all_words[1:])
|
|
117
|
+
if all_gh_words[:1] == [GH_PULL_REQUEST_COMMAND]:
|
|
118
|
+
return len(all_gh_words) > 1
|
|
119
|
+
if all_gh_words[:1] != [GH_API_COMMAND]:
|
|
121
120
|
return False
|
|
122
|
-
all_api_arguments = all_words[
|
|
121
|
+
all_api_arguments = all_words[all_words.index(GH_API_COMMAND, 1) + 1 :]
|
|
123
122
|
if any(each_name in each_word for each_word in all_api_arguments for each_name in ALL_API_ACTION_NAMES):
|
|
124
123
|
return True
|
|
125
124
|
return any(
|
|
@@ -262,14 +261,24 @@ def decision_for(all_payload_fields: dict[str, object]) -> dict[str, object] | N
|
|
|
262
261
|
return None if duplicate_reason is None else _deny(duplicate_reason)
|
|
263
262
|
|
|
264
263
|
|
|
265
|
-
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
|
+
"""
|
|
266
275
|
paths = [all_payload_fields.get(field) for field in ALL_TRANSCRIPT_PATH_FIELDS]
|
|
267
276
|
paths.append(_subagent_transcript_path(all_payload_fields))
|
|
268
277
|
return list(dict.fromkeys(path for path in paths if isinstance(path, str) and path))
|
|
269
278
|
|
|
270
279
|
|
|
271
280
|
def _is_skill_unloaded(all_payload_fields: dict[str, object]) -> bool:
|
|
272
|
-
readable_paths =
|
|
281
|
+
readable_paths = transcript_paths(all_payload_fields)
|
|
273
282
|
if not readable_paths:
|
|
274
283
|
return False
|
|
275
284
|
statuses = [_scanned_transcript(path, _shows_skill_load) for path in readable_paths]
|
|
@@ -279,7 +288,7 @@ def _is_skill_unloaded(all_payload_fields: dict[str, object]) -> bool:
|
|
|
279
288
|
def _is_build_eval_missing(all_payload_fields: dict[str, object]) -> bool:
|
|
280
289
|
all_statuses = [
|
|
281
290
|
_scanned_transcript(each_path, _shows_build_eval)
|
|
282
|
-
for each_path in
|
|
291
|
+
for each_path in transcript_paths(all_payload_fields)
|
|
283
292
|
]
|
|
284
293
|
all_read_statuses = [each_status for each_status in all_statuses if each_status is not None]
|
|
285
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())
|
|
@@ -22,7 +22,7 @@ from hooks_constants.bash_pre_tool_use_dispatcher_constants import (
|
|
|
22
22
|
)
|
|
23
23
|
|
|
24
24
|
|
|
25
|
-
def
|
|
25
|
+
def test_bash_roster_starts_only_the_msys_rewriter_and_the_three_gates() -> None:
|
|
26
26
|
assert ALL_BASH_HOSTED_HOOK_ENTRIES == (
|
|
27
27
|
BashHostedHookEntry(
|
|
28
28
|
script_relative_path="blocking/msys_rev_path_rewriter.py",
|
|
@@ -38,4 +38,8 @@ def test_bash_roster_starts_only_the_msys_rewriter_and_the_two_gates() -> None:
|
|
|
38
38
|
applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
|
|
39
39
|
replaced_by_plugin_name="shell-guards",
|
|
40
40
|
),
|
|
41
|
+
BashHostedHookEntry(
|
|
42
|
+
script_relative_path="blocking/cloud_graphql_gate.py",
|
|
43
|
+
applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
|
|
44
|
+
),
|
|
41
45
|
)
|
|
@@ -270,7 +270,10 @@ def test_should_drop_the_gates_the_shell_guards_mod_holds_while_it_is_on(
|
|
|
270
270
|
) -> None:
|
|
271
271
|
_enable_shell_guards(tmp_path, monkeypatch)
|
|
272
272
|
all_script_paths = [each_entry.script_relative_path for each_entry in select_applicable_entries(BASH_TOOL_NAME)]
|
|
273
|
-
assert all_script_paths == [
|
|
273
|
+
assert all_script_paths == [
|
|
274
|
+
"blocking/msys_rev_path_rewriter.py",
|
|
275
|
+
"blocking/cloud_graphql_gate.py",
|
|
276
|
+
]
|
|
274
277
|
|
|
275
278
|
|
|
276
279
|
def test_should_pass_a_headless_claude_command_while_the_shell_guards_mod_is_on(
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Tests for cloud_graphql_gate, which stops cloud sessions from calling GitHub GraphQL."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
from io import StringIO
|
|
8
|
+
from unittest.mock import patch
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
import cloud_graphql_gate as gate
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _stdout_from_main(payload: dict[str, object]) -> str:
|
|
16
|
+
"""Return what main() wrote to stdout for one hook payload."""
|
|
17
|
+
captured_stdout = StringIO()
|
|
18
|
+
with (
|
|
19
|
+
patch("sys.stdin", StringIO(json.dumps(payload))),
|
|
20
|
+
patch("sys.stdout", captured_stdout),
|
|
21
|
+
patch.object(gate, "log_hook_block"),
|
|
22
|
+
):
|
|
23
|
+
gate.main()
|
|
24
|
+
return captured_stdout.getvalue()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_ENCODED_GH_PR_LIST = base64.b64encode("gh pr list".encode("utf-16-le")).decode()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@pytest.fixture
|
|
31
|
+
def cloud_session(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
32
|
+
"""Mark the hook process as running inside a Claude Code cloud session."""
|
|
33
|
+
monkeypatch.setenv("CLAUDE_CODE_REMOTE", "true")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest.mark.parametrize(
|
|
37
|
+
"command",
|
|
38
|
+
[
|
|
39
|
+
"gh api graphql -f query='query{viewer{login}}'",
|
|
40
|
+
"gh.exe api graphql -F owner=o -f query=@q.graphql",
|
|
41
|
+
"cd repo && gh api graphql --paginate -f query=@q.graphql",
|
|
42
|
+
'bash -c "gh api graphql -f query=@q.graphql"',
|
|
43
|
+
"curl -sS -X POST https://api.github.com/graphql -d @q.json",
|
|
44
|
+
"curl -H 'Authorization: Bearer x' HTTPS://API.GITHUB.COM/GRAPHQL",
|
|
45
|
+
"wget --post-data=@q.json https://api.github.com/graphql",
|
|
46
|
+
"Invoke-RestMethod -Method Post -Uri https://api.github.com/graphql -Body $q",
|
|
47
|
+
"gh pr create --draft --title t --body-file b.md",
|
|
48
|
+
"gh pr view 12 --json title",
|
|
49
|
+
"gh pr checks 12",
|
|
50
|
+
"gh issue list -L 1",
|
|
51
|
+
"gh repo clone o/r",
|
|
52
|
+
"gh repo view o/r",
|
|
53
|
+
"gh label list",
|
|
54
|
+
"gh search prs repo:o/r",
|
|
55
|
+
"gh ruleset list",
|
|
56
|
+
"gh gist list",
|
|
57
|
+
"gh api -X POST graphql -f query='mutation{x}'",
|
|
58
|
+
"gh api --method POST graphql -f query=@q.graphql",
|
|
59
|
+
"gh api -H 'Accept: application/json' graphql -f query=@q",
|
|
60
|
+
"gh api /graphql -f query=@q",
|
|
61
|
+
"gh api https://api.github.com/graphql -f query=@q",
|
|
62
|
+
"gh -R o/r pr list",
|
|
63
|
+
"gh pr -R o/r list",
|
|
64
|
+
"gh pr --repo o/r view 12",
|
|
65
|
+
"gh pr merge 12 --body -h",
|
|
66
|
+
"gh pr view 12 --web",
|
|
67
|
+
"gh issue list --web",
|
|
68
|
+
"gh project item-list 1 --owner o",
|
|
69
|
+
"gh discussion list",
|
|
70
|
+
"gh status",
|
|
71
|
+
"gh status -o o",
|
|
72
|
+
"gh org list",
|
|
73
|
+
"gh repo archive o/r --yes",
|
|
74
|
+
"gh repo unarchive o/r --yes",
|
|
75
|
+
"curl -X POST https://api.github.com:443/graphql -d @q.json",
|
|
76
|
+
"if gh pr view 12; then echo ok; fi",
|
|
77
|
+
"if true; then gh api graphql -f query=@q; fi",
|
|
78
|
+
"while gh pr list; do sleep 1; done",
|
|
79
|
+
"! gh pr list",
|
|
80
|
+
"command gh pr list",
|
|
81
|
+
"exec gh issue list",
|
|
82
|
+
'echo "$(gh pr list)"',
|
|
83
|
+
"echo `gh pr view 12`",
|
|
84
|
+
"PR_URL=$(gh pr create --title t --body-file b.md)",
|
|
85
|
+
"(gh pr view 12)",
|
|
86
|
+
"python3 -c \"import requests; requests.post('https://api.github.com/graphql')\"",
|
|
87
|
+
"node -e \"fetch('https://api.github.com/graphql', {method: 'POST'})\"",
|
|
88
|
+
"gh release list -L 1",
|
|
89
|
+
"gh release view v1 --json name",
|
|
90
|
+
"gh workflow run ci.yml",
|
|
91
|
+
"gh workflow run ci.yml -f -r",
|
|
92
|
+
"gh pr list --search -w",
|
|
93
|
+
"echo \"$(printf ')'; gh pr list)\"",
|
|
94
|
+
"x=`echo \\`gh pr list\\``",
|
|
95
|
+
'echo $(echo "$(gh api graphql -f query=@q)")',
|
|
96
|
+
"python3.12 -c \"u('https://api.github.com/graphql')\"",
|
|
97
|
+
"pypy3 -c \"u('https://api.github.com/graphql')\"",
|
|
98
|
+
'eval "gh api graphql -f query=@q"',
|
|
99
|
+
"find . -maxdepth 0 -exec gh api graphql -f query=@q \\;",
|
|
100
|
+
"Invoke-Expression 'gh pr list'",
|
|
101
|
+
'iex "gh issue list"',
|
|
102
|
+
"find . -exec true \\; -exec gh api graphql -f query=@q \\;",
|
|
103
|
+
"gh repo sync o/r",
|
|
104
|
+
"gh repo create n --template o/t",
|
|
105
|
+
"gh repo create n -p o/t --private",
|
|
106
|
+
"gh repo edit o/r --enable-secret-scanning",
|
|
107
|
+
"gh repo edit --enable-advanced-security=false",
|
|
108
|
+
'Start-Process gh -ArgumentList "api graphql -f query=@q"',
|
|
109
|
+
"Start-Process -FilePath gh -ArgumentList 'pr','list' -Wait",
|
|
110
|
+
"Start-Process -FilePath:gh -ArgumentList:'api graphql -f query=@q'",
|
|
111
|
+
"GH.EXE pr list",
|
|
112
|
+
"pwsh -EncodedCommand " + _ENCODED_GH_PR_LIST,
|
|
113
|
+
],
|
|
114
|
+
)
|
|
115
|
+
def test_should_detect_a_github_graphql_call(command: str) -> None:
|
|
116
|
+
assert gate.calls_github_graphql(command) is True
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@pytest.mark.parametrize(
|
|
120
|
+
"command",
|
|
121
|
+
[
|
|
122
|
+
"gh api repos/o/r/pulls/1/ccr/review_threads",
|
|
123
|
+
"gh api user",
|
|
124
|
+
"gh pr diff 12",
|
|
125
|
+
"gh pr create --help",
|
|
126
|
+
"gh issue view -h",
|
|
127
|
+
"gh pr list -R o/r --help",
|
|
128
|
+
"gh pr list --web",
|
|
129
|
+
"gh pr list -w -R o/r",
|
|
130
|
+
"gh project",
|
|
131
|
+
"gh api repos/o/r/contents/docs/graphql.md",
|
|
132
|
+
"gh api repos/o/r/issues -f title=graphql",
|
|
133
|
+
"gh repo fork o/r",
|
|
134
|
+
"gh repo rename new-name",
|
|
135
|
+
"echo '$(gh pr list)'",
|
|
136
|
+
"command -v gh",
|
|
137
|
+
"gh pr view -w -h",
|
|
138
|
+
"gh pr list --state open --web",
|
|
139
|
+
"gh release view --json name",
|
|
140
|
+
"gh workflow run ci.yml --ref main",
|
|
141
|
+
"gh workflow run ci.yml -r main",
|
|
142
|
+
"gh workflow run ci.yml --ref=main",
|
|
143
|
+
"gh search repos claude-dev-env",
|
|
144
|
+
"gh run view 12",
|
|
145
|
+
"python3 scripts/review_closure.py o/r 1",
|
|
146
|
+
'eval "gh api repos/o/r/pulls/1"',
|
|
147
|
+
"gh repo sync o/r --branch main",
|
|
148
|
+
"gh repo sync -b main",
|
|
149
|
+
"gh repo create n --private",
|
|
150
|
+
"gh repo edit o/r --description d",
|
|
151
|
+
"gh api repos/o/r --preview graphql",
|
|
152
|
+
"gh api repos/o/r/issues --jq graphql",
|
|
153
|
+
"Start-Process notepad",
|
|
154
|
+
"find . -name '*.py' -exec grep -l graphql {} +",
|
|
155
|
+
"gh pr",
|
|
156
|
+
"gh run list -L 1",
|
|
157
|
+
"gh workflow list",
|
|
158
|
+
"gh label create bug",
|
|
159
|
+
"curl -sS https://api.github.com/repos/o/r",
|
|
160
|
+
'grep -rn "api.github.com/graphql" scripts',
|
|
161
|
+
'echo "gh api graphql"',
|
|
162
|
+
'git commit -m "drop gh api graphql calls"',
|
|
163
|
+
],
|
|
164
|
+
)
|
|
165
|
+
def test_should_pass_rest_calls_and_text_that_only_mentions_graphql(command: str) -> None:
|
|
166
|
+
assert gate.calls_github_graphql(command) is False
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_should_deny_gh_api_graphql_in_a_cloud_session_and_name_the_rest_routes(
|
|
170
|
+
cloud_session: None,
|
|
171
|
+
) -> None:
|
|
172
|
+
decision = json.loads(
|
|
173
|
+
_stdout_from_main(
|
|
174
|
+
{"tool_name": "Bash", "tool_input": {"command": "gh api graphql -f query=@q"}}
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
specific_output = decision["hookSpecificOutput"]
|
|
178
|
+
assert specific_output["permissionDecision"] == "deny"
|
|
179
|
+
reason = specific_output["permissionDecisionReason"]
|
|
180
|
+
assert "GET /repos/{owner}/{repo}/pulls/{n}/ccr/review_threads" in reason
|
|
181
|
+
assert "gh api repos/{owner}/{repo}/..." in reason
|
|
182
|
+
assert "mcp__github__create_pull_request" in reason
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_should_deny_a_powershell_endpoint_call_in_a_cloud_session(cloud_session: None) -> None:
|
|
186
|
+
command = "Invoke-RestMethod -Uri https://api.github.com/graphql -Method Post"
|
|
187
|
+
decision = json.loads(
|
|
188
|
+
_stdout_from_main({"tool_name": "PowerShell", "tool_input": {"command": command}})
|
|
189
|
+
)
|
|
190
|
+
assert decision["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_should_stay_quiet_outside_a_cloud_session() -> None:
|
|
194
|
+
assert (
|
|
195
|
+
_stdout_from_main(
|
|
196
|
+
{"tool_name": "Bash", "tool_input": {"command": "gh api graphql -f query=@q"}}
|
|
197
|
+
)
|
|
198
|
+
== ""
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_should_stay_quiet_for_a_rest_call_in_a_cloud_session(cloud_session: None) -> None:
|
|
203
|
+
rest_command = "gh api repos/o/r/pulls/1/ccr/review_threads"
|
|
204
|
+
assert _stdout_from_main({"tool_name": "Bash", "tool_input": {"command": rest_command}}) == ""
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def test_should_stay_quiet_for_another_tool_in_a_cloud_session(cloud_session: None) -> None:
|
|
208
|
+
assert (
|
|
209
|
+
_stdout_from_main({"tool_name": "Write", "tool_input": {"command": "gh api graphql"}}) == ""
|
|
210
|
+
)
|
|
@@ -88,13 +88,18 @@ def test_other_tool_payload_emits_nothing() -> None:
|
|
|
88
88
|
assert _stdout_from_main({"tool_name": "Write", "tool_input": {"command": 'claude -p "task"'}}) == ""
|
|
89
89
|
|
|
90
90
|
|
|
91
|
-
|
|
92
|
-
|
|
91
|
+
@pytest.mark.parametrize("marker_value", ["true", " TRUE "])
|
|
92
|
+
def test_should_pass_bare_headless_claude_in_a_cloud_session(
|
|
93
|
+
monkeypatch: pytest.MonkeyPatch, marker_value: str
|
|
94
|
+
) -> None:
|
|
95
|
+
monkeypatch.setenv("CLAUDE_CODE_REMOTE", marker_value)
|
|
93
96
|
assert _stdout_from_main({"tool_name": "Bash", "tool_input": {"command": 'claude -p "task"'}}) == ""
|
|
94
97
|
|
|
95
98
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
99
|
+
@pytest.mark.parametrize("marker_value", ["false", "1", ""])
|
|
100
|
+
def test_should_deny_bare_headless_claude_when_the_marker_is_not_true(
|
|
101
|
+
monkeypatch: pytest.MonkeyPatch, marker_value: str
|
|
102
|
+
) -> None:
|
|
103
|
+
monkeypatch.setenv("CLAUDE_CODE_REMOTE", marker_value)
|
|
104
|
+
decision = json.loads(_stdout_from_main({"tool_name": "Bash", "tool_input": {"command": 'claude -p "task"'}}))
|
|
105
|
+
assert decision["hookSpecificOutput"]["permissionDecision"] == "deny"
|