claude-dev-env 8.79.0 → 8.80.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 (57) 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 +6 -7
  11. package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +5 -1
  12. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +4 -1
  13. package/hooks/blocking/test_cloud_graphql_gate.py +210 -0
  14. package/hooks/blocking/test_headless_claude_broker_gate.py +12 -7
  15. package/hooks/blocking/test_merge_review_gate.py +63 -0
  16. package/hooks/blocking/test_pr_lifecycle_skill_gate.py +3 -0
  17. package/hooks/conftest.py +1 -1
  18. package/hooks/features/bash-dispatchers.md +3 -0
  19. package/hooks/features/conduct-gates.md +2 -2
  20. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -0
  21. package/hooks/hooks_constants/cloud_graphql_gate_constants.py +116 -0
  22. package/hooks/hooks_constants/cloud_session.py +17 -0
  23. package/hooks/hooks_constants/cloud_session_constants.py +11 -0
  24. package/hooks/hooks_constants/gh_command_words.py +52 -0
  25. package/hooks/hooks_constants/gh_command_words_constants.py +64 -0
  26. package/hooks/hooks_constants/headless_claude_broker_gate_constants.py +1 -7
  27. package/hooks/hooks_constants/hook_block_logger.py +21 -3
  28. package/hooks/hooks_constants/merge_review_gate_constants.py +14 -3
  29. package/hooks/hooks_constants/mod_handoff.py +2 -3
  30. package/hooks/hooks_constants/mod_handoff_constants.py +0 -4
  31. package/hooks/hooks_constants/piped_pytest_blocker_constants.py +27 -2
  32. package/hooks/hooks_constants/pr_lifecycle_skill_gate_constants.py +0 -1
  33. package/hooks/hooks_constants/shell_command_evaluation_constants.py +67 -0
  34. package/hooks/hooks_constants/shell_command_substitution_constants.py +25 -0
  35. package/hooks/hooks_constants/shell_command_wrappers.py +327 -12
  36. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +5 -1
  37. package/hooks/hooks_constants/test_cloud_session.py +27 -0
  38. package/hooks/hooks_constants/test_gh_command_words.py +45 -0
  39. package/hooks/hooks_constants/test_hook_block_logger.py +61 -0
  40. package/hooks/hooks_constants/test_mod_handoff.py +3 -2
  41. package/hooks/hooks_constants/test_piped_pytest_blocker_constants.py +11 -0
  42. package/hooks/hooks_constants/test_shell_command_wrappers.py +125 -0
  43. package/hooks/test_conftest.py +1 -1
  44. package/package.json +1 -1
  45. package/scripts/account_broker_support.py +10 -11
  46. package/scripts/codex_account_meters.py +30 -43
  47. package/scripts/dev_env_scripts_constants/account_broker_constants.py +34 -25
  48. package/scripts/dev_env_scripts_constants/claude_account_constants.py +0 -9
  49. package/scripts/dev_env_scripts_constants/codex_account_constants.py +0 -18
  50. package/scripts/dev_env_scripts_constants/test_account_broker_constants.py +42 -0
  51. package/scripts/dev_env_scripts_constants/test_codex_account_constants.py +119 -0
  52. package/scripts/merge_review_receipt.py +56 -12
  53. package/scripts/test_account_broker.py +12 -0
  54. package/scripts/test_account_broker_support.py +32 -6
  55. package/scripts/test_codex_account_meters.py +26 -14
  56. package/scripts/test_merge_review_receipt.py +86 -0
  57. package/settings.json +3 -0
@@ -22,7 +22,7 @@ from hooks_constants.bash_pre_tool_use_dispatcher_constants import (
22
22
  )
23
23
 
24
24
 
25
- def test_bash_roster_starts_only_the_msys_rewriter_and_the_two_gates() -> None:
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 == ["blocking/msys_rev_path_rewriter.py"]
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
- def test_should_pass_bare_headless_claude_in_a_cloud_session(monkeypatch: pytest.MonkeyPatch) -> None:
92
- monkeypatch.setenv("CLAUDE_CODE_REMOTE", "true")
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
- def test_should_report_a_cloud_session_only_when_the_marker_is_true(monkeypatch: pytest.MonkeyPatch) -> None:
97
- monkeypatch.setenv("CLAUDE_CODE_REMOTE", "false")
98
- assert gate.is_cloud_session() is False
99
- monkeypatch.setenv("CLAUDE_CODE_REMOTE", "true")
100
- assert gate.is_cloud_session() is True
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"
@@ -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))
@@ -156,6 +156,7 @@ def test_action_command_shapes(tmp_path: Path) -> None:
156
156
  "git --git-dir=.git push origin head",
157
157
  "gh pr create",
158
158
  "gh -R owner/repo pr --repo owner/repo merge",
159
+ "gh -R owner/repo api -X PUT repos/owner/repo/pulls/1/merge",
159
160
  "gh api -X PUT repos/owner/repo/pulls/1/merge",
160
161
  "gh api graphql -f query='mutation { enablePullRequestAutoMerge }'",
161
162
  "gh api graphql -f name=auto-merge",
@@ -180,6 +181,8 @@ def test_non_action_command_shapes(tmp_path: Path) -> None:
180
181
  "gh api repos/owner/repo/pulls/1/mergeability",
181
182
  "gh issue create --body-file message.txt",
182
183
  "gh pr",
184
+ "gh pr --repo owner/repo",
185
+ "gh api -H 'Accept: x' repos/owner/repo/pulls/1/mergeability",
183
186
  "GH_TOKEN=x gh issue list",
184
187
  "env -u GH_TOKEN git status",
185
188
  )
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.
@@ -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]