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
|
@@ -16,6 +16,7 @@ from blocking import merge_review_gate as gate
|
|
|
16
16
|
|
|
17
17
|
CLEAN_HEAD = "abc1234def5678abc1234def5678abc1234def56"
|
|
18
18
|
NEW_HEAD = "9999999aaaaaaa9999999aaaaaaa9999999aaaaa"
|
|
19
|
+
RULINGS_LINK = "https://github.com/acme/tools/pull/12#issuecomment-1"
|
|
19
20
|
|
|
20
21
|
|
|
21
22
|
@pytest.fixture(autouse=True)
|
|
@@ -58,6 +59,68 @@ def test_should_allow_a_merge_pinned_to_the_clean_head() -> None:
|
|
|
58
59
|
assert _reason(_merge_tool(CLEAN_HEAD)) is None
|
|
59
60
|
|
|
60
61
|
|
|
62
|
+
def test_should_allow_a_merge_whose_findings_are_all_ruled_out_with_a_link() -> None:
|
|
63
|
+
merge_review_receipt.record_review("acme/tools", 12, CLEAN_HEAD, 2, 2, RULINGS_LINK)
|
|
64
|
+
assert _reason(_merge_tool(CLEAN_HEAD)) is None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_should_deny_a_merge_with_a_finding_left_open() -> None:
|
|
68
|
+
merge_review_receipt.record_review("acme/tools", 12, CLEAN_HEAD, 3, 2, RULINGS_LINK)
|
|
69
|
+
assert "no clean review" in _reason(_merge_tool(CLEAN_HEAD))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@pytest.mark.parametrize(
|
|
73
|
+
"receipt",
|
|
74
|
+
[
|
|
75
|
+
{"head": CLEAN_HEAD, "findings": 2, "ruled_out": 2},
|
|
76
|
+
{"head": CLEAN_HEAD, "findings": 2, "ruled_out": 2, "rulings": " "},
|
|
77
|
+
{"head": CLEAN_HEAD, "findings": 2, "ruled_out": 2, "rulings": "x"},
|
|
78
|
+
{
|
|
79
|
+
"head": CLEAN_HEAD,
|
|
80
|
+
"findings": 2,
|
|
81
|
+
"ruled_out": 2,
|
|
82
|
+
"rulings": "https://github.com/acme/other/pull/12#issuecomment-1",
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
"head": CLEAN_HEAD,
|
|
86
|
+
"findings": 2,
|
|
87
|
+
"ruled_out": 2,
|
|
88
|
+
"rulings": "https://github.com/acme/tools/pull/13#issuecomment-1",
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
"head": CLEAN_HEAD,
|
|
92
|
+
"findings": 2,
|
|
93
|
+
"ruled_out": 2,
|
|
94
|
+
"rulings": "https://evil.example/?https://github.com/acme/tools/pull/12#issuecomment-1",
|
|
95
|
+
},
|
|
96
|
+
{"head": CLEAN_HEAD, "findings": True},
|
|
97
|
+
{"head": CLEAN_HEAD, "findings": "0"},
|
|
98
|
+
{"head": CLEAN_HEAD, "findings": 1, "ruled_out": True, "rulings": RULINGS_LINK},
|
|
99
|
+
{"head": CLEAN_HEAD, "findings": -1, "ruled_out": -1, "rulings": RULINGS_LINK},
|
|
100
|
+
],
|
|
101
|
+
)
|
|
102
|
+
def test_is_clean_receipt_should_reject_a_receipt_it_cannot_back(receipt: dict[str, object]) -> None:
|
|
103
|
+
assert gate.is_clean_receipt(receipt, "acme", "tools", 12) is False
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_is_clean_receipt_should_accept_zero_findings() -> None:
|
|
107
|
+
assert gate.is_clean_receipt({"head": CLEAN_HEAD, "findings": 0}, "acme", "tools", 12) is True
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@pytest.mark.parametrize(
|
|
111
|
+
"rulings", ["x", "https://github.com/acme/tools/pull/13#issuecomment-1"]
|
|
112
|
+
)
|
|
113
|
+
def test_should_deny_a_merge_whose_rulings_link_is_not_a_comment_on_this_pull_request(
|
|
114
|
+
rulings: str,
|
|
115
|
+
) -> None:
|
|
116
|
+
merge_review_receipt.record_review("acme/tools", 12, CLEAN_HEAD, 2, 2, rulings)
|
|
117
|
+
assert "no clean review" in _reason(_merge_tool(CLEAN_HEAD))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_should_name_the_ruled_out_route_in_the_deny_reason() -> None:
|
|
121
|
+
assert "--ruled-out" in _reason(_merge_tool(CLEAN_HEAD))
|
|
122
|
+
|
|
123
|
+
|
|
61
124
|
def test_should_deny_a_merge_pinned_to_a_head_pushed_after_the_review() -> None:
|
|
62
125
|
merge_review_receipt.record_review("acme/tools", 12, CLEAN_HEAD, 0)
|
|
63
126
|
assert "Review the new head" in _reason(_merge_tool(NEW_HEAD))
|
|
@@ -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"
|
|
@@ -156,6 +164,7 @@ def test_action_command_shapes(tmp_path: Path) -> None:
|
|
|
156
164
|
"git --git-dir=.git push origin head",
|
|
157
165
|
"gh pr create",
|
|
158
166
|
"gh -R owner/repo pr --repo owner/repo merge",
|
|
167
|
+
"gh -R owner/repo api -X PUT repos/owner/repo/pulls/1/merge",
|
|
159
168
|
"gh api -X PUT repos/owner/repo/pulls/1/merge",
|
|
160
169
|
"gh api graphql -f query='mutation { enablePullRequestAutoMerge }'",
|
|
161
170
|
"gh api graphql -f name=auto-merge",
|
|
@@ -180,6 +189,8 @@ def test_non_action_command_shapes(tmp_path: Path) -> None:
|
|
|
180
189
|
"gh api repos/owner/repo/pulls/1/mergeability",
|
|
181
190
|
"gh issue create --body-file message.txt",
|
|
182
191
|
"gh pr",
|
|
192
|
+
"gh pr --repo owner/repo",
|
|
193
|
+
"gh api -H 'Accept: x' repos/owner/repo/pulls/1/mergeability",
|
|
183
194
|
"GH_TOKEN=x gh issue list",
|
|
184
195
|
"env -u GH_TOKEN git status",
|
|
185
196
|
)
|
|
@@ -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
|
package/hooks/conftest.py
CHANGED
|
@@ -23,7 +23,7 @@ _CLAUDE_SESSION_ID_ENVIRONMENT_VARIABLE_NAME = importlib.import_module(
|
|
|
23
23
|
).CLAUDE_SESSION_ID_ENVIRONMENT_VARIABLE_NAME
|
|
24
24
|
|
|
25
25
|
_CLOUD_SESSION_ENVIRONMENT_VARIABLE_NAME = importlib.import_module(
|
|
26
|
-
"hooks_constants.
|
|
26
|
+
"hooks_constants.cloud_session_constants"
|
|
27
27
|
).CLOUD_SESSION_ENV_VAR
|
|
28
28
|
|
|
29
29
|
_ROOT_ANCHORED_PROBE_PATH = "/tmp/scratch.py"
|
|
@@ -8,6 +8,7 @@ This family rewrites a Git Bash command when path conversion would change a revi
|
|
|
8
8
|
- `blocking/msys_rev_path_rewriter.py` adds a narrow `MSYS2_ARG_CONV_EXCL` prefix for affected revision and path tokens.
|
|
9
9
|
- `blocking/headless_claude_broker_gate.py` denies a headless `claude -p` or `claude --print` call and names the broker command to run instead.
|
|
10
10
|
- `blocking/gh_global_account_switch_gate.py` denies `gh auth switch`, `gh auth login`, and `gh auth logout` and names the per-command `GH_TOKEN` form to run instead.
|
|
11
|
+
- `blocking/cloud_graphql_gate.py` denies the `gh` commands that call GraphQL (`gh api graphql` with any flags before the endpoint, `gh pr`, `gh issue`, `gh project` and `gh discussion` except `gh pr diff` and `gh pr list --web`, `gh status`, `gh org list`, `gh release list`, `gh release view` with a tag, `gh workflow run` without `--ref`, and a few `repo`, `label`, `search`, `ruleset` and `gist` commands, read past `if`, `!` and command substitutions) and an HTTP client or `python -c` style call to `api.github.com/graphql`, with or without a port, inside a cloud session, where the proxy answers GraphQL with HTTP 403, and names the REST routes to run instead.
|
|
11
12
|
- `blocking/bash_post_call_dispatcher.py` runs hosted observers and joins their context output without blocking the call.
|
|
12
13
|
- `advisory/pr_done_reminder.py` adds a pull request checklist after a successful push or pull request creation.
|
|
13
14
|
|
|
@@ -17,6 +18,7 @@ This family rewrites a Git Bash command when path conversion would change a revi
|
|
|
17
18
|
- `blocking/msys_rev_path_rewriter.py` runs inside that dispatcher on `PreToolUse`, matcher `Bash`, timeout `60` seconds. `ALL_BASH_HOSTED_HOOK_ENTRIES` selects the `Bash` tool.
|
|
18
19
|
- `blocking/headless_claude_broker_gate.py` runs inside that dispatcher on `PreToolUse`, matcher `Bash`, timeout `60` seconds, for the Bash and PowerShell tools.
|
|
19
20
|
- `blocking/gh_global_account_switch_gate.py` runs inside that dispatcher on `PreToolUse`, matcher `Bash`, timeout `60` seconds, for the Bash and PowerShell tools.
|
|
21
|
+
- `blocking/cloud_graphql_gate.py` runs inside that dispatcher on `PreToolUse`, matcher `Bash`, timeout `60` seconds, for the Bash and PowerShell tools, when `CLAUDE_CODE_REMOTE` is `true`.
|
|
20
22
|
- `blocking/bash_post_call_dispatcher.py` runs on `PostToolUse`, matcher `Bash|PowerShell`, timeout `60` seconds in `hooks.json`.
|
|
21
23
|
- `advisory/pr_done_reminder.py` runs inside that dispatcher on `PostToolUse`, matcher `Bash|PowerShell`, timeout `60` seconds. `ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES` selects `Bash` and `PowerShell`.
|
|
22
24
|
|
|
@@ -29,6 +31,7 @@ Preconditions:
|
|
|
29
31
|
- **Pre-call dispatch.** Input is a clean Bash command. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_bash_pre_tool_use_dispatcher.py -q`. The adjacent test observes the combined decision and hosted precedence.
|
|
30
32
|
- **Revision rewrite.** Input is a Git command with `origin/main:.claude/settings.json`. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_msys_rev_path_rewriter.py -q`. The adjacent test observes the `origin/main:` exclusion prefix.
|
|
31
33
|
- **Broker gate.** Input is a `claude -p task` command. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_headless_claude_broker_gate.py -q`. The adjacent test observes a denial that names the broker command.
|
|
34
|
+
- **Cloud GraphQL gate.** Input is a `gh api graphql` command with `CLAUDE_CODE_REMOTE=true`. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_cloud_graphql_gate.py -q`. The adjacent test observes a denial that names the REST routes, and silence outside a cloud session.
|
|
32
35
|
- **Post-call dispatch.** Input is a Bash call that yields hosted context. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_bash_post_call_dispatcher.py -q`. The adjacent test observes a joined context payload and exit code zero.
|
|
33
36
|
- **Pull request reminder.** Input is a completed push or pull request creation. Run `python -m pytest packages/claude-dev-env/hooks/advisory/test_pr_done_reminder.py -q`. The adjacent test checks the reminder trigger and checklist verdict.
|
|
34
37
|
|
|
@@ -12,7 +12,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
|
|
|
12
12
|
- `blocking/step_note_gate.py` requires a status line before a tool call while its opt-in flag is on.
|
|
13
13
|
- `blocking/verify_before_acting.py` blocks a completed mutating call when the agent's reasoning contains an unchecked claim.
|
|
14
14
|
- `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.
|
|
15
|
-
- `blocking/merge_review_gate.py` denies an agent's merge until a review pass on the merged head reported zero findings. It reads the receipt `scripts/merge_review_receipt.py` writes, requires the merge, auto-merge or merge-queue entry to be pinned to that head, and skips repositories listed in `CLAUDE_MERGE_REVIEW_EXEMPT_REPOSITORIES`. Its deny reason names the review command `CLAUDE_MERGE_REVIEW_COMMAND` sets.
|
|
15
|
+
- `blocking/merge_review_gate.py` denies an agent's merge until a review pass on the merged head reported zero findings, or ruled out every finding and linked the posted rulings. It reads the receipt `scripts/merge_review_receipt.py` writes, requires the merge, auto-merge or merge-queue entry to be pinned to that head, and skips repositories listed in `CLAUDE_MERGE_REVIEW_EXEMPT_REPOSITORIES`. Its deny reason names the review command `CLAUDE_MERGE_REVIEW_COMMAND` sets.
|
|
16
16
|
- `blocking/merge_over_review_offer_gate.py` denies a decision card or question that offers a merge while it names open review findings, a red check, unaddressed review comments, or an override. It reads the whole card as one text: every question, header, context, reason, and option, whether the option is a string or a label with a consequence or description.
|
|
17
17
|
- `blocking/artifact_dark_mode_gate.py` denies an Artifact page publish when the page, inside the publish wrapper, keeps a light body or shows text under 4.5:1 contrast in dark mode. `blocking/artifact_dark_mode_render.cjs` renders the page in headless Chromium for it.
|
|
18
18
|
|
|
@@ -57,4 +57,4 @@ Preconditions:
|
|
|
57
57
|
- The handoff close gate reads the `body` and `comment` fields of issue tools only. A `gh issue close --comment` command in Bash and a pull request body that closes the issue with `Closes #N` do not reach it.
|
|
58
58
|
- The lifecycle gate counts only invocations after the last compaction, so a compacted session invokes `pr-lifecycle` again. A missing or unreadable transcript allows the call.
|
|
59
59
|
- The dark mode gate denies a page publish when Node, Playwright or Chromium is missing, and its reason names the install command. It checks both dark signals, `prefers-color-scheme: dark` and `data-theme="dark"` on the root element.
|
|
60
|
-
- The clean-review gate trusts the receipt. A receipt the agent writes by hand with zero findings passes, so the pr-lifecycle skill names the review loop that writes it. A `gh pr merge` that names no repository is denied, because the gate cannot tell which repository the current branch belongs to.
|
|
60
|
+
- The clean-review gate trusts the receipt. A receipt the agent writes by hand with zero findings, or with every finding ruled out and a rulings link, passes, so the pr-lifecycle skill names the review loop that writes it. A `gh pr merge` that names no repository is denied, because the gate cannot tell which repository the current branch belongs to.
|
|
@@ -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.
|
|
@@ -75,4 +75,8 @@ ALL_BASH_HOSTED_HOOK_ENTRIES: tuple[BashHostedHookEntry, ...] = (
|
|
|
75
75
|
applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
|
|
76
76
|
replaced_by_plugin_name=SHELL_GUARDS_PLUGIN_NAME,
|
|
77
77
|
),
|
|
78
|
+
BashHostedHookEntry(
|
|
79
|
+
script_relative_path="blocking/cloud_graphql_gate.py",
|
|
80
|
+
applicable_tool_names=ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
|
|
81
|
+
),
|
|
78
82
|
)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Constants for the gate that stops cloud sessions from calling GitHub GraphQL.
|
|
2
|
+
|
|
3
|
+
Holds the ``gh`` commands that call GraphQL, the ``gh`` subcommands inside
|
|
4
|
+
those groups that call REST, the options that keep a GraphQL command on REST,
|
|
5
|
+
the commands that call GraphQL only with an operand or only with certain
|
|
6
|
+
options, the help flags that reach no network, the HTTP client and script
|
|
7
|
+
interpreter programs and the GraphQL endpoint pattern they could name, the
|
|
8
|
+
hook name the block log records, and the deny message that names the REST
|
|
9
|
+
routes to run instead.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"GH_PROGRAM_NAME",
|
|
18
|
+
"GH_API_SUBCOMMAND",
|
|
19
|
+
"GH_GRAPHQL_ENDPOINT",
|
|
20
|
+
"GH_ENDPOINT_PATH_SEPARATOR",
|
|
21
|
+
"GH_COMMAND_PATH_LENGTH",
|
|
22
|
+
"ALL_GH_GRAPHQL_COMMAND_GROUPS",
|
|
23
|
+
"ALL_GH_REST_COMMAND_PATHS_IN_GRAPHQL_GROUPS",
|
|
24
|
+
"ALL_GH_GRAPHQL_COMMAND_PATHS",
|
|
25
|
+
"ALL_GH_HELP_FLAGS",
|
|
26
|
+
"ALL_GH_REST_OPTIONS_BY_COMMAND_PATH",
|
|
27
|
+
"ALL_GH_COMMAND_PATHS_CALLING_GRAPHQL_WITH_AN_OPERAND",
|
|
28
|
+
"ALL_GH_GRAPHQL_OPTIONS_BY_COMMAND_PATH",
|
|
29
|
+
"ALL_HTTP_CLIENT_PROGRAM_NAMES",
|
|
30
|
+
"ALL_SCRIPT_INTERPRETER_PROGRAM_NAMES",
|
|
31
|
+
"GITHUB_GRAPHQL_ENDPOINT_PATTERN",
|
|
32
|
+
"GATE_HOOK_NAME",
|
|
33
|
+
"CLOUD_GRAPHQL_DENY_REASON",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
GH_PROGRAM_NAME: str = "gh"
|
|
37
|
+
GH_API_SUBCOMMAND: str = "api"
|
|
38
|
+
GH_GRAPHQL_ENDPOINT: str = "graphql"
|
|
39
|
+
GH_ENDPOINT_PATH_SEPARATOR: str = "/"
|
|
40
|
+
GH_COMMAND_PATH_LENGTH: int = 2
|
|
41
|
+
ALL_GH_GRAPHQL_COMMAND_GROUPS: frozenset[str] = frozenset({"pr", "issue", "project", "discussion"})
|
|
42
|
+
ALL_GH_REST_COMMAND_PATHS_IN_GRAPHQL_GROUPS: frozenset[tuple[str, ...]] = frozenset(
|
|
43
|
+
{("pr", "diff")}
|
|
44
|
+
)
|
|
45
|
+
ALL_GH_GRAPHQL_COMMAND_PATHS: frozenset[tuple[str, ...]] = frozenset(
|
|
46
|
+
{
|
|
47
|
+
("status",),
|
|
48
|
+
("org", "list"),
|
|
49
|
+
("repo", "view"),
|
|
50
|
+
("repo", "clone"),
|
|
51
|
+
("repo", "list"),
|
|
52
|
+
("repo", "archive"),
|
|
53
|
+
("repo", "unarchive"),
|
|
54
|
+
("label", "list"),
|
|
55
|
+
("search", "prs"),
|
|
56
|
+
("search", "issues"),
|
|
57
|
+
("ruleset", "list"),
|
|
58
|
+
("gist", "list"),
|
|
59
|
+
("release", "list"),
|
|
60
|
+
("workflow", "run"),
|
|
61
|
+
("repo", "sync"),
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
ALL_GH_HELP_FLAGS: frozenset[str] = frozenset({"--help", "-h"})
|
|
65
|
+
ALL_GH_REST_OPTIONS_BY_COMMAND_PATH: dict[tuple[str, ...], frozenset[str]] = {
|
|
66
|
+
("pr", "list"): frozenset({"--web", "-w"}),
|
|
67
|
+
("workflow", "run"): frozenset({"--ref", "-r"}),
|
|
68
|
+
("repo", "sync"): frozenset({"--branch", "-b"}),
|
|
69
|
+
}
|
|
70
|
+
ALL_GH_GRAPHQL_OPTIONS_BY_COMMAND_PATH: dict[tuple[str, ...], frozenset[str]] = {
|
|
71
|
+
("repo", "create"): frozenset({"--template", "-p"}),
|
|
72
|
+
("repo", "edit"): frozenset(
|
|
73
|
+
{
|
|
74
|
+
"--enable-advanced-security",
|
|
75
|
+
"--enable-secret-scanning",
|
|
76
|
+
"--enable-secret-scanning-push-protection",
|
|
77
|
+
}
|
|
78
|
+
),
|
|
79
|
+
}
|
|
80
|
+
ALL_GH_COMMAND_PATHS_CALLING_GRAPHQL_WITH_AN_OPERAND: frozenset[tuple[str, ...]] = frozenset(
|
|
81
|
+
{("release", "view")}
|
|
82
|
+
)
|
|
83
|
+
ALL_HTTP_CLIENT_PROGRAM_NAMES: frozenset[str] = frozenset(
|
|
84
|
+
{"curl", "wget", "http", "https", "invoke-restmethod", "invoke-webrequest", "irm", "iwr"}
|
|
85
|
+
)
|
|
86
|
+
ALL_SCRIPT_INTERPRETER_PROGRAM_NAMES: frozenset[str] = frozenset(
|
|
87
|
+
{"node", "deno", "bun", "ruby", "perl", "php"}
|
|
88
|
+
)
|
|
89
|
+
GITHUB_GRAPHQL_ENDPOINT_PATTERN: re.Pattern[str] = re.compile(
|
|
90
|
+
r"api\.github\.com(?::\d+)?/graphql", re.IGNORECASE
|
|
91
|
+
)
|
|
92
|
+
GATE_HOOK_NAME: str = "cloud_graphql_gate.py"
|
|
93
|
+
CLOUD_GRAPHQL_DENY_REASON: str = (
|
|
94
|
+
"BLOCKED: the Claude Code cloud proxy answers every GitHub GraphQL request with "
|
|
95
|
+
"HTTP 403, whatever the token. gh api graphql calls it, and so do gh pr and gh issue "
|
|
96
|
+
"(all but gh pr diff and gh pr list --web), gh project, gh discussion, gh status, "
|
|
97
|
+
"gh org list, gh repo view/clone/list/archive/unarchive, gh label list, "
|
|
98
|
+
"gh search prs/issues, gh ruleset list, gh gist list, gh release list, "
|
|
99
|
+
"gh release view with a tag, gh workflow run without --ref, gh repo sync without "
|
|
100
|
+
"--branch, gh repo create --template, and gh repo edit with a security setting. "
|
|
101
|
+
"Use REST through gh api repos/{owner}/{repo}/... or the mcp__github__ tools. "
|
|
102
|
+
"Open a PR: mcp__github__create_pull_request, or gh api repos/{owner}/{repo}/pulls "
|
|
103
|
+
"--input pr.json with title, head, base, body and draft in the JSON. "
|
|
104
|
+
"Read a PR: gh api repos/{owner}/{repo}/pulls/{n}. "
|
|
105
|
+
"Checks: gh api repos/{owner}/{repo}/commits/{sha}/check-runs. "
|
|
106
|
+
"Issues: gh api repos/{owner}/{repo}/issues/{n}. "
|
|
107
|
+
"Clone: git clone https://github.com/{owner}/{repo}. "
|
|
108
|
+
"Review threads: GET /repos/{owner}/{repo}/pulls/{n}/ccr/review_threads. "
|
|
109
|
+
"Resolve a thread: POST /repos/{owner}/{repo}/pulls/{n}/ccr/comments/{comment_id}/resolve "
|
|
110
|
+
"(or /unresolve). "
|
|
111
|
+
"Auto-merge: PUT or DELETE /repos/{owner}/{repo}/pulls/{n}/ccr/auto_merge. "
|
|
112
|
+
"Draft state: POST /repos/{owner}/{repo}/pulls/{n}/ccr/ready_for_review "
|
|
113
|
+
"or /ccr/convert_to_draft. "
|
|
114
|
+
"A query with no REST route cannot run in this session; it needs a machine outside "
|
|
115
|
+
"the cloud proxy."
|
|
116
|
+
)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Tell a hook whether it runs inside a Claude Code cloud session."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from hooks_constants.cloud_session_constants import (
|
|
8
|
+
CLOUD_SESSION_ENV_TRUE_VALUE,
|
|
9
|
+
CLOUD_SESSION_ENV_VAR,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = ["is_cloud_session"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def is_cloud_session() -> bool:
|
|
16
|
+
"""Return True when ``CLAUDE_CODE_REMOTE`` is ``true``, whatever its case or padding."""
|
|
17
|
+
return os.environ.get(CLOUD_SESSION_ENV_VAR, "").strip().lower() == CLOUD_SESSION_ENV_TRUE_VALUE
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Names the environment variable and value a Claude Code cloud session sets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"CLOUD_SESSION_ENV_VAR",
|
|
7
|
+
"CLOUD_SESSION_ENV_TRUE_VALUE",
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
CLOUD_SESSION_ENV_VAR: str = "CLAUDE_CODE_REMOTE"
|
|
11
|
+
CLOUD_SESSION_ENV_TRUE_VALUE: str = "true"
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Read the command words and the standalone options of a ``gh`` call.
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
gh -R o/r pr list --web words ["pr", "list"], options ["-R", "--web"]
|
|
6
|
+
gh api -X POST graphql words ["api", "graphql"], options ["-X"]
|
|
7
|
+
gh pr merge 12 --body -h words ["pr", "merge", "12"], options ["--body"]
|
|
8
|
+
|
|
9
|
+
An option's separate value is neither a word nor an option, so a value that
|
|
10
|
+
looks like a flag, such as the ``-h`` body above, reads as text.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from hooks_constants.gh_command_words_constants import (
|
|
16
|
+
ALL_GH_OPTIONS_WITH_A_VALUE,
|
|
17
|
+
GH_OPTION_PREFIX,
|
|
18
|
+
GH_OPTION_VALUE_SEPARATOR,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = ["gh_command_words", "gh_command_words_and_options"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def gh_command_words_and_options(all_arguments: list[str]) -> tuple[list[str], list[str]]:
|
|
25
|
+
"""Return the command words and the option names in one pass.
|
|
26
|
+
|
|
27
|
+
Each option's separate value is skipped, and an option name drops any
|
|
28
|
+
``=value`` part.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
all_arguments: The tokens after the ``gh`` program name.
|
|
32
|
+
"""
|
|
33
|
+
all_command_words: list[str] = []
|
|
34
|
+
all_option_names: list[str] = []
|
|
35
|
+
is_option_value = False
|
|
36
|
+
for each_argument in all_arguments:
|
|
37
|
+
is_option = not is_option_value and each_argument.startswith(GH_OPTION_PREFIX)
|
|
38
|
+
if is_option:
|
|
39
|
+
all_option_names.append(each_argument.split(GH_OPTION_VALUE_SEPARATOR, 1)[0])
|
|
40
|
+
if not is_option and not is_option_value:
|
|
41
|
+
all_command_words.append(each_argument)
|
|
42
|
+
is_option_value = is_option and each_argument in ALL_GH_OPTIONS_WITH_A_VALUE
|
|
43
|
+
return all_command_words, all_option_names
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def gh_command_words(all_arguments: list[str]) -> list[str]:
|
|
47
|
+
"""Return the gh arguments that are neither an option nor an option's separate value.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
all_arguments: The tokens after the ``gh`` program name.
|
|
51
|
+
"""
|
|
52
|
+
return gh_command_words_and_options(all_arguments)[0]
|