claude-dev-env 8.78.3 → 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 (68) 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_over_review_offer_gate.py +114 -0
  10. package/hooks/blocking/merge_review_gate.py +65 -3
  11. package/hooks/blocking/pr_lifecycle_skill_gate.py +6 -7
  12. package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +5 -1
  13. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +4 -1
  14. package/hooks/blocking/test_cloud_graphql_gate.py +210 -0
  15. package/hooks/blocking/test_headless_claude_broker_gate.py +12 -7
  16. package/hooks/blocking/test_merge_over_review_offer_gate.py +138 -0
  17. package/hooks/blocking/test_merge_review_gate.py +63 -0
  18. package/hooks/blocking/test_pr_lifecycle_skill_gate.py +3 -0
  19. package/hooks/conftest.py +1 -1
  20. package/hooks/features/bash-dispatchers.md +3 -0
  21. package/hooks/features/conduct-gates.md +5 -2
  22. package/hooks/hooks.json +10 -0
  23. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -0
  24. package/hooks/hooks_constants/cloud_graphql_gate_constants.py +116 -0
  25. package/hooks/hooks_constants/cloud_session.py +17 -0
  26. package/hooks/hooks_constants/cloud_session_constants.py +11 -0
  27. package/hooks/hooks_constants/gh_command_words.py +52 -0
  28. package/hooks/hooks_constants/gh_command_words_constants.py +64 -0
  29. package/hooks/hooks_constants/headless_claude_broker_gate_constants.py +1 -7
  30. package/hooks/hooks_constants/hook_block_logger.py +21 -3
  31. package/hooks/hooks_constants/merge_over_review_offer_gate_constants.py +34 -0
  32. package/hooks/hooks_constants/merge_review_gate_constants.py +14 -3
  33. package/hooks/hooks_constants/mod_handoff.py +2 -3
  34. package/hooks/hooks_constants/mod_handoff_constants.py +0 -4
  35. package/hooks/hooks_constants/piped_pytest_blocker_constants.py +27 -2
  36. package/hooks/hooks_constants/pr_lifecycle_skill_gate_constants.py +0 -1
  37. package/hooks/hooks_constants/shell_command_evaluation_constants.py +67 -0
  38. package/hooks/hooks_constants/shell_command_substitution_constants.py +25 -0
  39. package/hooks/hooks_constants/shell_command_wrappers.py +327 -12
  40. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +5 -1
  41. package/hooks/hooks_constants/test_cloud_session.py +27 -0
  42. package/hooks/hooks_constants/test_gh_command_words.py +45 -0
  43. package/hooks/hooks_constants/test_hook_block_logger.py +61 -0
  44. package/hooks/hooks_constants/test_mod_handoff.py +3 -2
  45. package/hooks/hooks_constants/test_piped_pytest_blocker_constants.py +11 -0
  46. package/hooks/hooks_constants/test_shell_command_wrappers.py +125 -0
  47. package/hooks/test_conftest.py +1 -1
  48. package/package.json +1 -1
  49. package/scripts/account_broker.py +23 -11
  50. package/scripts/account_broker_support.py +23 -14
  51. package/scripts/codex_account_meters.py +30 -43
  52. package/scripts/conftest.py +25 -0
  53. package/scripts/dev_env_scripts_constants/account_broker_constants.py +36 -25
  54. package/scripts/dev_env_scripts_constants/claude_account_constants.py +0 -9
  55. package/scripts/dev_env_scripts_constants/codex_account_constants.py +0 -18
  56. package/scripts/dev_env_scripts_constants/test_account_broker_constants.py +57 -14
  57. package/scripts/dev_env_scripts_constants/test_codex_account_constants.py +119 -0
  58. package/scripts/merge_review_receipt.py +56 -12
  59. package/scripts/policy_lint/adapter_configuration.py +5 -0
  60. package/scripts/policy_lint/config/constants.py +1 -0
  61. package/scripts/test_account_broker.py +145 -3
  62. package/scripts/test_account_broker_support.py +88 -6
  63. package/scripts/test_claude_chain_usage.py +3 -11
  64. package/scripts/test_codex_account_meters.py +26 -14
  65. package/scripts/test_merge_review_receipt.py +86 -0
  66. package/scripts/test_shared_tree_paths.py +58 -36
  67. package/scripts/tests/test_adapter_configuration.py +8 -0
  68. package/settings.json +3 -0
@@ -1,13 +1,15 @@
1
1
  """Deny an agent's merge until a review of the merged head came back clean.
2
2
 
3
3
  The agent that drives a pull request loops a code review on it, fixing and
4
- pushing each finding, until one pass reports zero findings. The merge then
4
+ pushing each finding, until one pass reports zero findings or every finding
5
+ it reports is ruled out with a link to the posted rulings. The merge then
5
6
  names that exact head::
6
7
 
7
8
  merge, receipt says 0 findings at abc123, merge pinned to abc123 -> allow
9
+ merge, receipt rules out all 3 findings with a link, pinned -> allow
8
10
  merge, receipt at abc123, merge pinned to def456 -> deny, review again
9
11
  merge, receipt at abc123, merge names no head -> deny, pin it
10
- merge, no receipt or the receipt counts findings -> deny, run the loop
12
+ merge, no receipt or the receipt counts open findings -> deny, run the loop
11
13
  auto-merge or enqueue pinned to the clean head -> allow
12
14
  auto-merge that names no head -> deny, pin it
13
15
  repository listed in CLAUDE_MERGE_REVIEW_EXEMPT_REPOSITORIES -> allow
@@ -61,10 +63,13 @@ from hooks_constants.merge_review_gate_constants import (
61
63
  RECEIPT_FILE_NAME_TEMPLATE,
62
64
  RECEIPT_FINDINGS_KEY,
63
65
  RECEIPT_HEAD_KEY,
66
+ RECEIPT_RULED_OUT_KEY,
67
+ RECEIPT_RULINGS_KEY,
64
68
  REPO_FIELD,
65
69
  REPO_OPTION_PATTERN,
66
70
  REPOSITORY_SEPARATOR,
67
71
  REVIEW_COMMAND_ENVIRONMENT_VARIABLE,
72
+ RULINGS_LINK_PATTERN,
68
73
  STALE_REVIEW_REASON_TEMPLATE,
69
74
  UNKNOWN_TARGET_REASON,
70
75
  UNPINNED_MERGE_REASON_TEMPLATE,
@@ -228,12 +233,69 @@ def _clean_head(request: MergeRequest) -> str | None:
228
233
  )
229
234
  except (OSError, ValueError):
230
235
  return None
231
- if not isinstance(receipt, dict) or receipt.get(RECEIPT_FINDINGS_KEY) != 0:
236
+ if not isinstance(receipt, dict) or not is_clean_receipt(
237
+ receipt, request.owner, request.repo, request.number
238
+ ):
232
239
  return None
233
240
  head = receipt.get(RECEIPT_HEAD_KEY)
234
241
  return head if isinstance(head, str) and head else None
235
242
 
236
243
 
244
+ def is_clean_receipt(
245
+ all_receipt_fields: dict[str, object], owner: str, repo: str, number: int
246
+ ) -> bool:
247
+ """Return whether a recorded pass lets the merge through.
248
+
249
+ A pass is clean when it reported zero findings, or when every finding it
250
+ reported is ruled out and the receipt links the rulings comment posted on
251
+ this same pull request::
252
+
253
+ findings 0 -> clean
254
+ findings 2, ruled out 2, .../acme/tools/pull/12#issuecomment-9 -> clean
255
+ findings 2, ruled out 2, .../acme/other/pull/12#issuecomment-9 -> not clean
256
+
257
+ Args:
258
+ all_receipt_fields: The parsed receipt of one review pass.
259
+ owner: Owner of the pull request the merge targets.
260
+ repo: Repository of the pull request the merge targets.
261
+ number: Number of the pull request the merge targets.
262
+
263
+ Returns:
264
+ True when the pass counts as clean.
265
+ """
266
+ finding_count = all_receipt_fields.get(RECEIPT_FINDINGS_KEY)
267
+ if not _is_count(finding_count):
268
+ return False
269
+ return finding_count == 0 or _rules_out_every_finding(
270
+ all_receipt_fields, finding_count, owner, repo, number
271
+ )
272
+
273
+
274
+ def _is_count(candidate: object) -> bool:
275
+ return isinstance(candidate, int) and not isinstance(candidate, bool) and candidate >= 0
276
+
277
+
278
+ def _rules_out_every_finding(
279
+ all_receipt_fields: dict[str, object], finding_count: object, owner: str, repo: str, number: int
280
+ ) -> bool:
281
+ ruled_out_count = all_receipt_fields.get(RECEIPT_RULED_OUT_KEY)
282
+ return (
283
+ _is_count(ruled_out_count)
284
+ and ruled_out_count == finding_count
285
+ and _links_rulings_on(all_receipt_fields.get(RECEIPT_RULINGS_KEY), owner, repo, number)
286
+ )
287
+
288
+
289
+ def _links_rulings_on(rulings: object, owner: str, repo: str, number: int) -> bool:
290
+ rulings_match = RULINGS_LINK_PATTERN.fullmatch(rulings) if isinstance(rulings, str) else None
291
+ return (
292
+ rulings_match is not None
293
+ and rulings_match["owner"].casefold() == owner.casefold()
294
+ and rulings_match["repo"].casefold() == repo.casefold()
295
+ and int(rulings_match["number"]) == number
296
+ )
297
+
298
+
237
299
  def _review_command() -> str:
238
300
  return os.environ.get(REVIEW_COMMAND_ENVIRONMENT_VARIABLE) or DEFAULT_REVIEW_COMMAND
239
301
 
@@ -32,7 +32,6 @@ from hooks_constants.pr_lifecycle_skill_gate_constants import (
32
32
  DENY_REASON,
33
33
  ENVIRONMENT_ASSIGNMENT_PATTERN,
34
34
  GH_API_COMMAND,
35
- GH_OPTIONS_WITH_VALUE,
36
35
  GH_PULL_REQUEST_COMMAND,
37
36
  GIT_ACTIONS,
38
37
  GIT_GLOBAL_OPTIONS_WITH_VALUE,
@@ -49,6 +48,7 @@ from hooks_constants.pr_lifecycle_skill_gate_constants import (
49
48
  SUBAGENT_TRANSCRIPT_DIRECTORY_NAME,
50
49
  SUBAGENT_TRANSCRIPT_FILE_TEMPLATE,
51
50
  )
51
+ from hooks_constants.gh_command_words import gh_command_words
52
52
  from hooks_constants.hook_specific_output_keys import (
53
53
  HOOK_SPECIFIC_OUTPUT_KEY,
54
54
  PERMISSION_DECISION_KEY,
@@ -113,13 +113,12 @@ def _is_git_action(all_words: list[str]) -> bool:
113
113
 
114
114
 
115
115
  def _is_gh_action(all_words: list[str]) -> bool:
116
- action, next_index = _next_command_word(all_words, 1, GH_OPTIONS_WITH_VALUE)
117
- if action == GH_PULL_REQUEST_COMMAND:
118
- subcommand, _ = _next_command_word(all_words, next_index, GH_OPTIONS_WITH_VALUE)
119
- return subcommand is not None
120
- if action != GH_API_COMMAND:
116
+ all_gh_words = gh_command_words(all_words[1:])
117
+ if all_gh_words[:1] == [GH_PULL_REQUEST_COMMAND]:
118
+ return len(all_gh_words) > 1
119
+ if all_gh_words[:1] != [GH_API_COMMAND]:
121
120
  return False
122
- all_api_arguments = all_words[next_index:]
121
+ all_api_arguments = all_words[all_words.index(GH_API_COMMAND, 1) + 1 :]
123
122
  if any(each_name in each_word for each_word in all_api_arguments for each_name in ALL_API_ACTION_NAMES):
124
123
  return True
125
124
  return any(
@@ -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"
@@ -0,0 +1,138 @@
1
+ """Behavior tests for the merge-over-review decision card gate."""
2
+
3
+ import json
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ HOOKS_DIRECTORY = Path(__file__).resolve().parent.parent
9
+ if str(HOOKS_DIRECTORY) not in sys.path:
10
+ sys.path.insert(0, str(HOOKS_DIRECTORY))
11
+
12
+ from blocking import merge_over_review_offer_gate as gate
13
+
14
+ GATE_SCRIPT = Path(gate.__file__)
15
+
16
+
17
+ def _card(context: str, *options: tuple[str, str]) -> dict[str, object]:
18
+ return {
19
+ "question": "What next for the pull request?",
20
+ "context": context,
21
+ "options": [{"label": label, "consequence": consequence} for label, consequence in options],
22
+ }
23
+
24
+
25
+ def test_merge_option_over_open_findings_should_be_offered_over_review() -> None:
26
+ card = _card("The review found 9 findings.", ("Merge now", "It lands today."), ("Keep looping", "I rerun."))
27
+ assert gate.offers_merge_over_review(card)
28
+
29
+
30
+ def test_merge_option_that_overrides_a_status_should_be_offered_over_review() -> None:
31
+ card = _card("Tests pass.", ("Merge now", "You override the status."))
32
+ assert gate.offers_merge_over_review(card)
33
+
34
+
35
+ def test_card_without_a_merge_option_should_pass() -> None:
36
+ card = _card("The review found 9 findings.", ("Keep looping", "I rerun."), ("Drop shell checks", "Smaller diff."))
37
+ assert not gate.offers_merge_over_review(card)
38
+
39
+
40
+ def test_merge_option_after_a_clean_review_should_pass() -> None:
41
+ card = _card("The review pass on the head came back clean.", ("Merge", "It lands."), ("Hold", "It waits."))
42
+ assert not gate.offers_merge_over_review(card)
43
+
44
+
45
+ def test_merge_question_with_yes_no_options_over_unresolved_threads_should_be_offered_over_review() -> None:
46
+ card = {"question": "Merge PR 41 now?", "context": "3 unresolved review threads.", "options": ["Yes", "No"]}
47
+ assert gate.offers_merge_over_review(card)
48
+
49
+
50
+ def test_string_options_should_be_read() -> None:
51
+ card = {"question": "What next?", "context": "9 findings remain.", "options": ["Merge now", "Keep looping"]}
52
+ assert gate.offers_merge_over_review(card)
53
+
54
+
55
+ def test_merge_queue_option_over_findings_should_be_offered_over_review() -> None:
56
+ card = _card("9 findings remain.", ("Join the merge queue", "It waits for checks."), ("Wait", "It waits."))
57
+ assert gate.offers_merge_over_review(card)
58
+
59
+
60
+ def test_merge_offer_in_context_over_findings_should_be_offered_over_review() -> None:
61
+ card = {
62
+ "question": "Go ahead?",
63
+ "context": "The PR is green and I can merge it now, but the review reports 9 findings.",
64
+ "options": ["Yes", "No"],
65
+ }
66
+ assert gate.offers_merge_over_review(card)
67
+
68
+
69
+ def test_offer_and_findings_in_separate_questions_should_be_offered_over_review() -> None:
70
+ card = {
71
+ "questions": [
72
+ {"question": "The review reports 9 findings. Which next?", "options": ["Keep fixing", "Split it"]},
73
+ {"question": "Merge it now?", "options": ["Yes", "No"]},
74
+ ]
75
+ }
76
+ assert gate.offers_merge_over_review(card)
77
+
78
+
79
+ def test_merge_option_over_unaddressed_review_comments_should_be_offered_over_review() -> None:
80
+ card = _card("3 review comments are unaddressed; 2 changes requested.", ("Merge now", "It lands."))
81
+ assert gate.offers_merge_over_review(card)
82
+
83
+
84
+ def test_ship_card_about_a_pending_translation_should_pass() -> None:
85
+ card = {
86
+ "question": "Ship the README update now? The pending translation can follow.",
87
+ "options": ["Ship it", "Wait"],
88
+ }
89
+ assert not gate.offers_merge_over_review(card)
90
+
91
+
92
+ def test_ask_user_question_merge_option_over_findings_should_be_offered_over_review() -> None:
93
+ card = {
94
+ "questions": [
95
+ {
96
+ "question": "The review failed with 2 findings. What next?",
97
+ "header": "Next step",
98
+ "options": [
99
+ {"label": "Squash and merge", "description": "It lands now."},
100
+ {"label": "Fix first", "description": "I fix both."},
101
+ ],
102
+ }
103
+ ]
104
+ }
105
+ assert gate.offers_merge_over_review(card)
106
+
107
+
108
+ def test_card_about_the_merge_review_check_without_a_merge_offer_should_pass() -> None:
109
+ card = _card(
110
+ "The Merge review check reports 11 findings.", ("Keep fixing", "I rerun."), ("Split the PR", "Smaller diff.")
111
+ )
112
+ assert not gate.offers_merge_over_review(card)
113
+
114
+
115
+ def test_merge_option_after_a_review_with_no_findings_should_pass() -> None:
116
+ card = _card("The review on the head found no findings.", ("Merge", "It lands."), ("Hold", "It waits."))
117
+ assert not gate.offers_merge_over_review(card)
118
+
119
+
120
+ def test_decision_for_should_return_none_for_a_clean_card() -> None:
121
+ payload = {"tool_name": "AskUserQuestion", "tool_input": _card("All checks pass.", ("Merge", "It lands."))}
122
+ assert gate.decision_for(payload) is None
123
+
124
+
125
+ def test_script_should_print_a_deny_for_a_merge_over_findings_card() -> None:
126
+ payload = {
127
+ "tool_name": "mcp__hearthbot__ask_decision",
128
+ "tool_input": _card("9 findings remain.", ("Merge now", "It lands.")),
129
+ }
130
+ completed = subprocess.run(
131
+ [sys.executable, str(GATE_SCRIPT)],
132
+ input=json.dumps(payload),
133
+ capture_output=True,
134
+ text=True,
135
+ check=False,
136
+ )
137
+ assert completed.returncode == 0
138
+ assert json.loads(completed.stdout)["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"