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.
Files changed (66) hide show
  1. package/.agents/skills/pr-lifecycle/reference/review-loop-before-merge.md +2 -2
  2. package/bin/install.settings-defaults.test.mjs +27 -2
  3. package/bin/merge_settings_defaults.mjs +39 -21
  4. package/bin/merge_settings_defaults.test.mjs +42 -0
  5. package/docs/account-broker.md +1 -1
  6. package/docs/codex-accounts.md +6 -5
  7. package/hooks/blocking/cloud_graphql_gate.py +213 -0
  8. package/hooks/blocking/headless_claude_broker_gate.py +1 -8
  9. package/hooks/blocking/merge_review_gate.py +65 -3
  10. package/hooks/blocking/pr_lifecycle_skill_gate.py +19 -10
  11. package/hooks/blocking/pull_request_proof.py +42 -13
  12. package/hooks/blocking/pull_request_proof_edit_gate.py +180 -0
  13. package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +5 -1
  14. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +4 -1
  15. package/hooks/blocking/test_cloud_graphql_gate.py +210 -0
  16. package/hooks/blocking/test_headless_claude_broker_gate.py +12 -7
  17. package/hooks/blocking/test_merge_review_gate.py +63 -0
  18. package/hooks/blocking/test_pr_lifecycle_skill_gate.py +11 -0
  19. package/hooks/blocking/test_pre_tool_use_dispatcher.py +1 -0
  20. package/hooks/blocking/test_pull_request_proof.py +7 -0
  21. package/hooks/blocking/test_pull_request_proof_edit_gate.py +199 -0
  22. package/hooks/conftest.py +1 -1
  23. package/hooks/features/bash-dispatchers.md +3 -0
  24. package/hooks/features/conduct-gates.md +2 -2
  25. package/hooks/features/write-edit-blocking.md +4 -1
  26. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -0
  27. package/hooks/hooks_constants/cloud_graphql_gate_constants.py +116 -0
  28. package/hooks/hooks_constants/cloud_session.py +17 -0
  29. package/hooks/hooks_constants/cloud_session_constants.py +11 -0
  30. package/hooks/hooks_constants/gh_command_words.py +52 -0
  31. package/hooks/hooks_constants/gh_command_words_constants.py +64 -0
  32. package/hooks/hooks_constants/headless_claude_broker_gate_constants.py +1 -7
  33. package/hooks/hooks_constants/hook_block_logger.py +21 -3
  34. package/hooks/hooks_constants/merge_review_gate_constants.py +14 -3
  35. package/hooks/hooks_constants/mod_handoff.py +2 -3
  36. package/hooks/hooks_constants/mod_handoff_constants.py +0 -4
  37. package/hooks/hooks_constants/piped_pytest_blocker_constants.py +27 -2
  38. package/hooks/hooks_constants/pr_lifecycle_skill_gate_constants.py +0 -1
  39. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +4 -0
  40. package/hooks/hooks_constants/pull_request_proof_constants.py +22 -3
  41. package/hooks/hooks_constants/shell_command_evaluation_constants.py +67 -0
  42. package/hooks/hooks_constants/shell_command_substitution_constants.py +25 -0
  43. package/hooks/hooks_constants/shell_command_wrappers.py +327 -12
  44. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +5 -1
  45. package/hooks/hooks_constants/test_cloud_session.py +27 -0
  46. package/hooks/hooks_constants/test_gh_command_words.py +45 -0
  47. package/hooks/hooks_constants/test_hook_block_logger.py +61 -0
  48. package/hooks/hooks_constants/test_mod_handoff.py +3 -2
  49. package/hooks/hooks_constants/test_piped_pytest_blocker_constants.py +11 -0
  50. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +9 -6
  51. package/hooks/hooks_constants/test_shell_command_wrappers.py +125 -0
  52. package/hooks/test_conftest.py +1 -1
  53. package/package.json +1 -1
  54. package/scripts/account_broker_support.py +10 -11
  55. package/scripts/codex_account_meters.py +30 -43
  56. package/scripts/dev_env_scripts_constants/account_broker_constants.py +34 -25
  57. package/scripts/dev_env_scripts_constants/claude_account_constants.py +0 -9
  58. package/scripts/dev_env_scripts_constants/codex_account_constants.py +0 -18
  59. package/scripts/dev_env_scripts_constants/test_account_broker_constants.py +42 -0
  60. package/scripts/dev_env_scripts_constants/test_codex_account_constants.py +119 -0
  61. package/scripts/merge_review_receipt.py +56 -12
  62. package/scripts/test_account_broker.py +12 -0
  63. package/scripts/test_account_broker_support.py +32 -6
  64. package/scripts/test_codex_account_meters.py +26 -14
  65. package/scripts/test_merge_review_receipt.py +86 -0
  66. 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.headless_claude_broker_gate_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]