claude-dev-env 1.39.0 → 1.41.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 (60) hide show
  1. package/CLAUDE.md +1 -1
  2. package/_shared/pr-loop/scripts/config/post_audit_thread_constants.py +10 -0
  3. package/_shared/pr-loop/scripts/config/reviews_disabled_constants.py +8 -0
  4. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +53 -3
  5. package/_shared/pr-loop/scripts/post_audit_thread.py +298 -3
  6. package/_shared/pr-loop/scripts/preflight.py +129 -2
  7. package/_shared/pr-loop/scripts/reviews_disabled.py +59 -0
  8. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +68 -3
  9. package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +1 -1
  10. package/_shared/pr-loop/scripts/tests/test_post_audit_thread.py +194 -1
  11. package/_shared/pr-loop/scripts/tests/test_preflight.py +41 -0
  12. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +36 -0
  13. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +1 -1
  14. package/agents/pr-description-writer.md +150 -52
  15. package/docs/PR_DESCRIPTION_GUIDE.md +127 -64
  16. package/hooks/_gh_pr_author_swap_utils.py +1211 -0
  17. package/hooks/blocking/gh_body_arg_blocker.py +9 -6
  18. package/hooks/blocking/gh_pr_author_enforcer.py +480 -0
  19. package/hooks/blocking/gh_pr_author_restore.py +100 -0
  20. package/hooks/blocking/pr_converge_bugteam_enforcer.py +170 -0
  21. package/hooks/blocking/pr_description_enforcer.py +56 -23
  22. package/hooks/blocking/test_gh_body_arg_blocker.py +25 -3
  23. package/hooks/blocking/test_gh_pr_author_enforcer.py +1166 -0
  24. package/hooks/blocking/test_gh_pr_author_restore.py +512 -0
  25. package/hooks/blocking/test_gh_pr_author_swap_utils.py +910 -0
  26. package/hooks/blocking/test_pr_converge_bugteam_enforcer.py +311 -0
  27. package/hooks/blocking/test_pr_description_enforcer.py +69 -8
  28. package/hooks/config/gh_pr_author_swap_constants.py +76 -0
  29. package/hooks/config/pr_converge_bugteam_enforcer_constants.py +55 -0
  30. package/hooks/config/pr_converge_bugteam_enforcer_state.py +67 -0
  31. package/hooks/config/pr_description_enforcer_constants.py +19 -0
  32. package/hooks/config/test_pr_description_enforcer_constants.py +82 -0
  33. package/hooks/hooks.json +40 -0
  34. package/hooks/lifecycle/pr_converge_bugteam_skill_tracker.py +204 -0
  35. package/hooks/lifecycle/test_pr_converge_bugteam_skill_tracker.py +283 -0
  36. package/hooks/session/gh_pr_author_session_cleanup.py +171 -0
  37. package/hooks/session/test_gh_pr_author_session_cleanup.py +575 -0
  38. package/hooks/test__gh_pr_author_swap_utils.py +333 -0
  39. package/package.json +1 -1
  40. package/skills/_shared/pr-loop/scripts/write_audit_outcomes.py +2 -2
  41. package/skills/_shared/pr-loop/scripts/write_fix_outcomes.py +2 -2
  42. package/skills/bugteam/SKILL.md +28 -10
  43. package/skills/bugteam/reference/audit-contract.md +22 -0
  44. package/skills/bugteam/reference/github-pr-reviews.md +1 -1
  45. package/skills/bugteam/reference/team-setup.md +5 -0
  46. package/skills/bugteam/scripts/bugteam_fix_hookspath.py +8 -2
  47. package/skills/bugteam/scripts/bugteam_preflight.py +36 -2
  48. package/skills/bugteam/scripts/test__claude_permissions_common.py +48 -0
  49. package/skills/bugteam/scripts/test_bugteam_preflight.py +41 -0
  50. package/skills/bugteam/scripts/test_claude_permissions_common.py +18 -10
  51. package/skills/copilot-review/SKILL.md +16 -0
  52. package/skills/findbugs/SKILL.md +35 -7
  53. package/skills/monitor-open-prs/SKILL.md +2 -1
  54. package/skills/pr-converge/SKILL.md +11 -3
  55. package/skills/pr-converge/config/constants.py +3 -1
  56. package/skills/pr-converge/reference/per-tick.md +17 -0
  57. package/skills/pr-converge/reference/state-schema.md +36 -8
  58. package/skills/pr-converge/scripts/check_bugbot_ci.py +113 -8
  59. package/skills/pr-converge/scripts/test_check_bugbot_ci.py +312 -0
  60. package/skills/qbug/SKILL.md +33 -8
@@ -0,0 +1,1166 @@
1
+ """Unit tests for gh-pr-author-enforcer PreToolUse hook (auto-switch behavior)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+ import io
7
+ import json
8
+ import os
9
+ import pathlib
10
+ import stat
11
+ import sys
12
+ from typing import Iterator
13
+ from unittest import mock
14
+
15
+ import pytest
16
+
17
+ _HOOK_DIR = pathlib.Path(__file__).parent
18
+ if str(_HOOK_DIR) not in sys.path:
19
+ sys.path.insert(0, str(_HOOK_DIR))
20
+
21
+ hook_module_spec = importlib.util.spec_from_file_location(
22
+ "gh_pr_author_enforcer",
23
+ _HOOK_DIR / "gh_pr_author_enforcer.py",
24
+ )
25
+ assert hook_module_spec is not None
26
+ assert hook_module_spec.loader is not None
27
+ hook_module = importlib.util.module_from_spec(hook_module_spec)
28
+ hook_module_spec.loader.exec_module(hook_module)
29
+
30
+ import _gh_pr_author_swap_utils as swap_utils_module # noqa: E402
31
+
32
+ from config.gh_pr_author_swap_constants import STATE_FILE_PERMISSION_MODE # noqa: E402
33
+
34
+
35
+ def _make_stdin_payload(command: str, session_id: str = "test-session-001") -> str:
36
+ return json.dumps(
37
+ {
38
+ "tool_name": "Bash",
39
+ "tool_input": {"command": command},
40
+ "session_id": session_id,
41
+ }
42
+ )
43
+
44
+
45
+ @pytest.fixture
46
+ def required_account_jonecho(monkeypatch: pytest.MonkeyPatch) -> Iterator[str]:
47
+ monkeypatch.setenv("GITHUB_DEFAULT_ACCOUNT", "JonEcho")
48
+ yield "JonEcho"
49
+
50
+
51
+ @pytest.fixture
52
+ def isolated_state_directory(
53
+ monkeypatch: pytest.MonkeyPatch,
54
+ tmp_path: pathlib.Path,
55
+ ) -> Iterator[pathlib.Path]:
56
+ monkeypatch.setattr(swap_utils_module.tempfile, "gettempdir", lambda: str(tmp_path))
57
+ yield tmp_path
58
+
59
+
60
+ def _run_hook_with(
61
+ stdin_text: str,
62
+ active_account_or_none: str | None,
63
+ monkeypatch: pytest.MonkeyPatch,
64
+ switch_succeeds: bool,
65
+ ) -> tuple[int, str, list[str]]:
66
+ monkeypatch.setattr(sys, "stdin", io.StringIO(stdin_text))
67
+ captured_stdout = io.StringIO()
68
+ monkeypatch.setattr(sys, "stdout", captured_stdout)
69
+ monkeypatch.setattr(hook_module, "_active_gh_account", lambda: active_account_or_none)
70
+ switch_invocations: list[str] = []
71
+
72
+ def _fake_switch(to_account: str) -> bool:
73
+ switch_invocations.append(to_account)
74
+ return switch_succeeds
75
+
76
+ monkeypatch.setattr(hook_module, "_switch_gh_account", _fake_switch)
77
+ with pytest.raises(SystemExit) as exit_info:
78
+ hook_module.main()
79
+ exit_code = exit_info.value.code if isinstance(exit_info.value.code, int) else 0
80
+ return exit_code, captured_stdout.getvalue(), switch_invocations
81
+
82
+
83
+ def test_command_invokes_gh_pr_create_matches_basic_form() -> None:
84
+ assert hook_module._command_invokes_gh_pr_create_in_stripped(
85
+ hook_module._preprocess_command_for_matching("gh pr create --title T")
86
+ )
87
+
88
+
89
+ def test_command_invokes_gh_pr_create_matches_chained_form() -> None:
90
+ assert hook_module._command_invokes_gh_pr_create_in_stripped(
91
+ hook_module._preprocess_command_for_matching("git push && gh pr create")
92
+ )
93
+
94
+
95
+ def test_command_invokes_gh_pr_create_rejects_pr_edit() -> None:
96
+ assert not hook_module._command_invokes_gh_pr_create_in_stripped(
97
+ hook_module._preprocess_command_for_matching("gh pr edit 10 --title X")
98
+ )
99
+
100
+
101
+ def test_command_invokes_gh_pr_create_rejects_substring() -> None:
102
+ assert not hook_module._command_invokes_gh_pr_create_in_stripped(
103
+ hook_module._preprocess_command_for_matching("some-gh pr created-by")
104
+ )
105
+
106
+
107
+ def test_command_uses_web_flag_matches_long_form() -> None:
108
+ assert hook_module._command_uses_web_flag_in_stripped(
109
+ hook_module._preprocess_command_for_matching("gh pr create --web")
110
+ )
111
+
112
+
113
+ def test_command_uses_web_flag_matches_short_form() -> None:
114
+ assert hook_module._command_uses_web_flag_in_stripped(
115
+ hook_module._preprocess_command_for_matching("gh pr create -w")
116
+ )
117
+
118
+
119
+ def test_command_uses_web_flag_rejects_webhook_substring() -> None:
120
+ assert not hook_module._command_uses_web_flag_in_stripped(
121
+ hook_module._preprocess_command_for_matching("gh pr create --webhook=foo")
122
+ )
123
+
124
+
125
+ def test_command_uses_web_flag_ignores_curl_w_flag_before_gh() -> None:
126
+ assert not hook_module._command_uses_web_flag_in_stripped(
127
+ hook_module._preprocess_command_for_matching(
128
+ "curl -w '%{http_code}' url && gh pr create --title T"
129
+ )
130
+ )
131
+
132
+
133
+ def test_command_uses_web_flag_ignores_w_after_separator() -> None:
134
+ assert not hook_module._command_uses_web_flag_in_stripped(
135
+ hook_module._preprocess_command_for_matching(
136
+ "gh pr create --title T && other-cmd -w"
137
+ )
138
+ )
139
+
140
+
141
+ def test_command_uses_web_flag_detects_web_inside_gh_pr_create() -> None:
142
+ assert hook_module._command_uses_web_flag_in_stripped(
143
+ hook_module._preprocess_command_for_matching("gh pr create --web --title T")
144
+ )
145
+
146
+
147
+ def test_command_uses_web_flag_detects_short_w_inside_gh_pr_create() -> None:
148
+ assert hook_module._command_uses_web_flag_in_stripped(
149
+ hook_module._preprocess_command_for_matching("gh pr create -w --title T")
150
+ )
151
+
152
+
153
+ def test_command_uses_web_flag_handles_gh_pr_create_without_web() -> None:
154
+ assert not hook_module._command_uses_web_flag_in_stripped(
155
+ hook_module._preprocess_command_for_matching(
156
+ "gh pr create --title T --body-file B"
157
+ )
158
+ )
159
+
160
+
161
+ def test_command_uses_web_flag_returns_false_when_gh_pr_create_absent() -> None:
162
+ assert not hook_module._command_uses_web_flag_in_stripped(
163
+ hook_module._preprocess_command_for_matching("curl -w '%{http_code}' url")
164
+ )
165
+
166
+
167
+ def test_command_uses_web_flag_ignores_w_after_pipe_separator() -> None:
168
+ assert not hook_module._command_uses_web_flag_in_stripped(
169
+ hook_module._preprocess_command_for_matching(
170
+ "gh pr create --title T | tee -w log"
171
+ )
172
+ )
173
+
174
+
175
+ def test_command_uses_web_flag_ignores_w_after_semicolon_separator() -> None:
176
+ assert not hook_module._command_uses_web_flag_in_stripped(
177
+ hook_module._preprocess_command_for_matching(
178
+ "gh pr create --title T ; other-cmd -w"
179
+ )
180
+ )
181
+
182
+
183
+ def test_command_uses_web_flag_ignores_w_after_or_separator() -> None:
184
+ assert not hook_module._command_uses_web_flag_in_stripped(
185
+ hook_module._preprocess_command_for_matching(
186
+ "gh pr create --title T || fallback -w"
187
+ )
188
+ )
189
+
190
+
191
+ def test_command_uses_web_flag_ignores_w_after_background_separator() -> None:
192
+ """`gh pr create & other-cmd -w` does not pick up the trailing -w."""
193
+ assert not hook_module._command_uses_web_flag_in_stripped(
194
+ hook_module._preprocess_command_for_matching(
195
+ "gh pr create --title T & other-cmd -w"
196
+ )
197
+ )
198
+
199
+
200
+ def test_main_auto_switches_when_active_account_mismatches(
201
+ monkeypatch: pytest.MonkeyPatch,
202
+ required_account_jonecho: str,
203
+ isolated_state_directory: pathlib.Path,
204
+ ) -> None:
205
+ exit_code, stdout_text, switch_invocations = _run_hook_with(
206
+ _make_stdin_payload("gh pr create --title T --body-file B"),
207
+ active_account_or_none="jl-cmd",
208
+ monkeypatch=monkeypatch,
209
+ switch_succeeds=True,
210
+ )
211
+ assert exit_code == 0
212
+ assert stdout_text == ""
213
+ assert switch_invocations == ["JonEcho"]
214
+ state_file = hook_module._state_file_path("test-session-001")
215
+ assert state_file.exists()
216
+ persisted_state = json.loads(state_file.read_text(encoding="utf-8"))
217
+ assert persisted_state == {
218
+ "original_account": "jl-cmd",
219
+ "primary_account": "JonEcho",
220
+ }
221
+
222
+
223
+ def test_main_denies_when_auto_switch_fails(
224
+ monkeypatch: pytest.MonkeyPatch,
225
+ required_account_jonecho: str,
226
+ isolated_state_directory: pathlib.Path,
227
+ ) -> None:
228
+ exit_code, stdout_text, switch_invocations = _run_hook_with(
229
+ _make_stdin_payload("gh pr create --title T --body-file B"),
230
+ active_account_or_none="jl-cmd",
231
+ monkeypatch=monkeypatch,
232
+ switch_succeeds=False,
233
+ )
234
+ assert exit_code == 0
235
+ assert switch_invocations == ["JonEcho"]
236
+ payload = json.loads(stdout_text)
237
+ assert payload["hookSpecificOutput"]["permissionDecision"] == "deny"
238
+ deny_reason = payload["hookSpecificOutput"]["permissionDecisionReason"]
239
+ assert "JonEcho" in deny_reason
240
+ assert "jl-cmd" in deny_reason
241
+ assert "gh auth switch --user JonEcho" in deny_reason
242
+ state_file = hook_module._state_file_path("test-session-001")
243
+ assert not state_file.exists()
244
+
245
+
246
+ def test_main_no_op_when_active_account_matches(
247
+ monkeypatch: pytest.MonkeyPatch,
248
+ required_account_jonecho: str,
249
+ isolated_state_directory: pathlib.Path,
250
+ ) -> None:
251
+ exit_code, stdout_text, switch_invocations = _run_hook_with(
252
+ _make_stdin_payload("gh pr create --title T --body-file B"),
253
+ active_account_or_none="JonEcho",
254
+ monkeypatch=monkeypatch,
255
+ switch_succeeds=True,
256
+ )
257
+ assert exit_code == 0
258
+ assert stdout_text == ""
259
+ assert switch_invocations == []
260
+ state_file = hook_module._state_file_path("test-session-001")
261
+ assert not state_file.exists()
262
+
263
+
264
+ def test_main_allows_when_active_account_matches_case_insensitively(
265
+ monkeypatch: pytest.MonkeyPatch,
266
+ isolated_state_directory: pathlib.Path,
267
+ ) -> None:
268
+ """GitHub usernames are case-insensitive; ``jonecho`` env value matches ``JonEcho`` canonical login."""
269
+ monkeypatch.setenv("GITHUB_DEFAULT_ACCOUNT", "jonecho")
270
+ exit_code, stdout_text, switch_invocations = _run_hook_with(
271
+ _make_stdin_payload("gh pr create --title T --body-file B"),
272
+ active_account_or_none="JonEcho",
273
+ monkeypatch=monkeypatch,
274
+ switch_succeeds=True,
275
+ )
276
+ assert exit_code == 0
277
+ assert stdout_text == ""
278
+ assert switch_invocations == []
279
+ state_file = hook_module._state_file_path("test-session-001")
280
+ assert not state_file.exists()
281
+
282
+
283
+ def test_main_allows_when_active_account_matches_canonical_case(
284
+ monkeypatch: pytest.MonkeyPatch,
285
+ isolated_state_directory: pathlib.Path,
286
+ ) -> None:
287
+ """Symmetric to the previous test: canonical-case env value matches lower-case login response."""
288
+ monkeypatch.setenv("GITHUB_DEFAULT_ACCOUNT", "JonEcho")
289
+ exit_code, stdout_text, switch_invocations = _run_hook_with(
290
+ _make_stdin_payload("gh pr create --title T --body-file B"),
291
+ active_account_or_none="jonecho",
292
+ monkeypatch=monkeypatch,
293
+ switch_succeeds=True,
294
+ )
295
+ assert exit_code == 0
296
+ assert stdout_text == ""
297
+ assert switch_invocations == []
298
+ state_file = hook_module._state_file_path("test-session-001")
299
+ assert not state_file.exists()
300
+
301
+
302
+ def test_main_allows_when_required_account_unset(
303
+ monkeypatch: pytest.MonkeyPatch,
304
+ isolated_state_directory: pathlib.Path,
305
+ ) -> None:
306
+ monkeypatch.delenv("GITHUB_DEFAULT_ACCOUNT", raising=False)
307
+ exit_code, stdout_text, switch_invocations = _run_hook_with(
308
+ _make_stdin_payload("gh pr create --title T --body-file B"),
309
+ active_account_or_none="jl-cmd",
310
+ monkeypatch=monkeypatch,
311
+ switch_succeeds=True,
312
+ )
313
+ assert exit_code == 0
314
+ assert stdout_text == ""
315
+ assert switch_invocations == []
316
+
317
+
318
+ def test_main_allows_web_flow_even_when_mismatched(
319
+ monkeypatch: pytest.MonkeyPatch,
320
+ required_account_jonecho: str,
321
+ isolated_state_directory: pathlib.Path,
322
+ ) -> None:
323
+ exit_code, stdout_text, switch_invocations = _run_hook_with(
324
+ _make_stdin_payload("gh pr create --web --title T"),
325
+ active_account_or_none="jl-cmd",
326
+ monkeypatch=monkeypatch,
327
+ switch_succeeds=True,
328
+ )
329
+ assert exit_code == 0
330
+ assert stdout_text == ""
331
+ assert switch_invocations == []
332
+
333
+
334
+ def test_main_allows_short_web_flag_even_when_mismatched(
335
+ monkeypatch: pytest.MonkeyPatch,
336
+ required_account_jonecho: str,
337
+ isolated_state_directory: pathlib.Path,
338
+ ) -> None:
339
+ exit_code, stdout_text, switch_invocations = _run_hook_with(
340
+ _make_stdin_payload("gh pr create -w --title T"),
341
+ active_account_or_none="jl-cmd",
342
+ monkeypatch=monkeypatch,
343
+ switch_succeeds=True,
344
+ )
345
+ assert exit_code == 0
346
+ assert stdout_text == ""
347
+ assert switch_invocations == []
348
+
349
+
350
+ def test_main_allows_non_bash_tool(
351
+ monkeypatch: pytest.MonkeyPatch,
352
+ required_account_jonecho: str,
353
+ isolated_state_directory: pathlib.Path,
354
+ ) -> None:
355
+ stdin_text = json.dumps(
356
+ {
357
+ "tool_name": "Write",
358
+ "tool_input": {"file_path": "x", "content": "y"},
359
+ "session_id": "test-session-001",
360
+ }
361
+ )
362
+ exit_code, stdout_text, switch_invocations = _run_hook_with(
363
+ stdin_text,
364
+ active_account_or_none="jl-cmd",
365
+ monkeypatch=monkeypatch,
366
+ switch_succeeds=True,
367
+ )
368
+ assert exit_code == 0
369
+ assert stdout_text == ""
370
+ assert switch_invocations == []
371
+
372
+
373
+ def test_main_allows_unrelated_bash_command(
374
+ monkeypatch: pytest.MonkeyPatch,
375
+ required_account_jonecho: str,
376
+ isolated_state_directory: pathlib.Path,
377
+ ) -> None:
378
+ exit_code, stdout_text, switch_invocations = _run_hook_with(
379
+ _make_stdin_payload("git status"),
380
+ active_account_or_none="jl-cmd",
381
+ monkeypatch=monkeypatch,
382
+ switch_succeeds=True,
383
+ )
384
+ assert exit_code == 0
385
+ assert stdout_text == ""
386
+ assert switch_invocations == []
387
+
388
+
389
+ def test_main_allows_gh_pr_edit(
390
+ monkeypatch: pytest.MonkeyPatch,
391
+ required_account_jonecho: str,
392
+ isolated_state_directory: pathlib.Path,
393
+ ) -> None:
394
+ exit_code, stdout_text, switch_invocations = _run_hook_with(
395
+ _make_stdin_payload("gh pr edit 10 --title X"),
396
+ active_account_or_none="jl-cmd",
397
+ monkeypatch=monkeypatch,
398
+ switch_succeeds=True,
399
+ )
400
+ assert exit_code == 0
401
+ assert stdout_text == ""
402
+ assert switch_invocations == []
403
+
404
+
405
+ def test_main_allows_when_active_account_undetermined(
406
+ monkeypatch: pytest.MonkeyPatch,
407
+ required_account_jonecho: str,
408
+ isolated_state_directory: pathlib.Path,
409
+ ) -> None:
410
+ exit_code, stdout_text, switch_invocations = _run_hook_with(
411
+ _make_stdin_payload("gh pr create --title T"),
412
+ active_account_or_none=None,
413
+ monkeypatch=monkeypatch,
414
+ switch_succeeds=True,
415
+ )
416
+ assert exit_code == 0
417
+ assert stdout_text == ""
418
+ assert switch_invocations == []
419
+
420
+
421
+ def test_main_allows_invalid_stdin_json(
422
+ monkeypatch: pytest.MonkeyPatch,
423
+ required_account_jonecho: str,
424
+ isolated_state_directory: pathlib.Path,
425
+ ) -> None:
426
+ exit_code, stdout_text, switch_invocations = _run_hook_with(
427
+ "not-json",
428
+ active_account_or_none="jl-cmd",
429
+ monkeypatch=monkeypatch,
430
+ switch_succeeds=True,
431
+ )
432
+ assert exit_code == 0
433
+ assert stdout_text == ""
434
+ assert switch_invocations == []
435
+
436
+
437
+ def test_state_file_path_uses_session_id(
438
+ isolated_state_directory: pathlib.Path,
439
+ ) -> None:
440
+ state_file = hook_module._state_file_path("abc-123")
441
+ assert state_file.parent == isolated_state_directory
442
+ assert state_file.name == "gh_pr_author_swap_abc-123.json"
443
+
444
+
445
+ def test_state_file_path_falls_back_to_default_when_session_id_empty(
446
+ isolated_state_directory: pathlib.Path,
447
+ ) -> None:
448
+ state_file = hook_module._state_file_path("")
449
+ assert state_file.name == "gh_pr_author_swap_default.json"
450
+
451
+
452
+ def test_active_gh_account_returns_login_on_success() -> None:
453
+ completed = mock.Mock(returncode=0, stdout="JonEcho\n")
454
+ with mock.patch.object(hook_module.subprocess, "run", return_value=completed):
455
+ assert hook_module._active_gh_account() == "JonEcho"
456
+
457
+
458
+ def test_active_gh_account_returns_none_on_nonzero_exit() -> None:
459
+ completed = mock.Mock(returncode=1, stdout="")
460
+ with mock.patch.object(hook_module.subprocess, "run", return_value=completed):
461
+ assert hook_module._active_gh_account() is None
462
+
463
+
464
+ def test_active_gh_account_returns_none_when_gh_missing() -> None:
465
+ with mock.patch.object(hook_module.subprocess, "run", side_effect=FileNotFoundError):
466
+ assert hook_module._active_gh_account() is None
467
+
468
+
469
+ def test_active_gh_account_returns_none_on_timeout() -> None:
470
+ with mock.patch.object(
471
+ hook_module.subprocess,
472
+ "run",
473
+ side_effect=hook_module.subprocess.TimeoutExpired(cmd="gh", timeout=5),
474
+ ):
475
+ assert hook_module._active_gh_account() is None
476
+
477
+
478
+ def test_switch_gh_account_returns_true_on_success() -> None:
479
+ completed = mock.Mock(returncode=0, stdout="", stderr="")
480
+ with mock.patch.object(hook_module.subprocess, "run", return_value=completed):
481
+ assert hook_module._switch_gh_account("JonEcho") is True
482
+
483
+
484
+ def test_switch_gh_account_returns_false_on_nonzero_exit() -> None:
485
+ completed = mock.Mock(returncode=1, stdout="", stderr="auth failed")
486
+ with mock.patch.object(hook_module.subprocess, "run", return_value=completed):
487
+ assert hook_module._switch_gh_account("JonEcho") is False
488
+
489
+
490
+ def test_switch_gh_account_returns_false_when_gh_missing() -> None:
491
+ with mock.patch.object(hook_module.subprocess, "run", side_effect=FileNotFoundError):
492
+ assert hook_module._switch_gh_account("JonEcho") is False
493
+
494
+
495
+ def test_switch_gh_account_returns_false_on_timeout() -> None:
496
+ with mock.patch.object(
497
+ hook_module.subprocess,
498
+ "run",
499
+ side_effect=hook_module.subprocess.TimeoutExpired(cmd="gh", timeout=10),
500
+ ):
501
+ assert hook_module._switch_gh_account("JonEcho") is False
502
+
503
+
504
+ def test_main_denies_and_reverses_switch_when_state_write_fails(
505
+ monkeypatch: pytest.MonkeyPatch,
506
+ required_account_jonecho: str,
507
+ isolated_state_directory: pathlib.Path,
508
+ ) -> None:
509
+ monkeypatch.setattr(
510
+ hook_module,
511
+ "_write_swap_state",
512
+ lambda state_file, original_account, primary_account: False,
513
+ )
514
+ exit_code, stdout_text, switch_invocations = _run_hook_with(
515
+ _make_stdin_payload("gh pr create --title T --body-file B"),
516
+ active_account_or_none="jl-cmd",
517
+ monkeypatch=monkeypatch,
518
+ switch_succeeds=True,
519
+ )
520
+ assert exit_code == 0
521
+ assert switch_invocations == ["JonEcho", "jl-cmd"]
522
+ payload = json.loads(stdout_text)
523
+ assert payload["hookSpecificOutput"]["permissionDecision"] == "deny"
524
+ deny_reason = payload["hookSpecificOutput"]["permissionDecisionReason"]
525
+ assert "state file" in deny_reason.lower()
526
+ assert "JonEcho" in deny_reason
527
+ assert "jl-cmd" in deny_reason
528
+
529
+
530
+ def test_main_emits_deny_even_when_reverse_switch_also_fails(
531
+ monkeypatch: pytest.MonkeyPatch,
532
+ required_account_jonecho: str,
533
+ isolated_state_directory: pathlib.Path,
534
+ ) -> None:
535
+ monkeypatch.setattr(sys, "stdin", io.StringIO(_make_stdin_payload("gh pr create --title T")))
536
+ captured_stdout = io.StringIO()
537
+ monkeypatch.setattr(sys, "stdout", captured_stdout)
538
+ monkeypatch.setattr(hook_module, "_active_gh_account", lambda: "jl-cmd")
539
+ monkeypatch.setattr(
540
+ hook_module,
541
+ "_write_swap_state",
542
+ lambda state_file, original_account, primary_account: False,
543
+ )
544
+
545
+ switch_invocations: list[str] = []
546
+
547
+ def _fake_switch_first_succeeds_second_fails(to_account: str) -> bool:
548
+ switch_invocations.append(to_account)
549
+ return len(switch_invocations) == 1
550
+
551
+ monkeypatch.setattr(hook_module, "_switch_gh_account", _fake_switch_first_succeeds_second_fails)
552
+
553
+ with pytest.raises(SystemExit) as exit_info:
554
+ hook_module.main()
555
+ exit_code = exit_info.value.code if isinstance(exit_info.value.code, int) else 0
556
+
557
+ assert exit_code == 0
558
+ assert switch_invocations == ["JonEcho", "jl-cmd"]
559
+ payload = json.loads(captured_stdout.getvalue())
560
+ assert payload["hookSpecificOutput"]["permissionDecision"] == "deny"
561
+ deny_reason = payload["hookSpecificOutput"]["permissionDecisionReason"]
562
+ assert "state file" in deny_reason.lower()
563
+
564
+
565
+ def test_strip_quoted_regions_preserves_offsets_for_double_quotes() -> None:
566
+ original_command = "gh pr create --body \"some text\" --title T"
567
+ stripped_command = hook_module._preprocess_command_for_matching(original_command)
568
+ assert len(stripped_command) == len(original_command)
569
+ assert "some text" not in stripped_command
570
+ assert "gh pr create" in stripped_command
571
+ assert "--title T" in stripped_command
572
+
573
+
574
+ def test_strip_quoted_regions_preserves_offsets_for_single_quotes() -> None:
575
+ original_command = "gh pr create --body 'single quoted body' --title T"
576
+ stripped_command = hook_module._preprocess_command_for_matching(original_command)
577
+ assert len(stripped_command) == len(original_command)
578
+ assert "single quoted body" not in stripped_command
579
+
580
+
581
+ def test_strip_quoted_regions_preserves_backtick_substitution_body() -> None:
582
+ """Backticks delimit command substitution, which executes — the body must remain scannable."""
583
+ original_command = "echo `inner cmd` && gh pr create --title T"
584
+ stripped_command = hook_module._preprocess_command_for_matching(original_command)
585
+ assert len(stripped_command) == len(original_command)
586
+ assert "inner cmd" in stripped_command
587
+ assert "gh pr create" in stripped_command
588
+
589
+
590
+ def test_strip_quoted_regions_preserves_dollar_paren_substitution_body() -> None:
591
+ """``$(...)`` substitution body must remain scannable for the same reason as backticks."""
592
+ original_command = "echo $(inner cmd) && gh pr create --title T"
593
+ stripped_command = hook_module._preprocess_command_for_matching(original_command)
594
+ assert len(stripped_command) == len(original_command)
595
+ assert "inner cmd" in stripped_command
596
+ assert "gh pr create" in stripped_command
597
+
598
+
599
+ def test_strip_quoted_regions_preserves_dollar_paren_inside_double_quotes() -> None:
600
+ """``"$(...)"`` substitution body remains scannable even when wrapped in double quotes."""
601
+ original_command = 'echo "$(inner cmd)" && gh pr create --title T'
602
+ stripped_command = hook_module._preprocess_command_for_matching(original_command)
603
+ assert len(stripped_command) == len(original_command)
604
+ assert "inner cmd" in stripped_command
605
+ assert "gh pr create" in stripped_command
606
+
607
+
608
+ def test_strip_quoted_regions_handles_escaped_quote_inside_double_quotes() -> None:
609
+ original_command = "gh pr create --body \"escaped \\\" quote\" --title T"
610
+ stripped_command = hook_module._preprocess_command_for_matching(original_command)
611
+ assert len(stripped_command) == len(original_command)
612
+ assert "escaped" not in stripped_command
613
+ assert "--title T" in stripped_command
614
+
615
+
616
+ def test_command_invokes_gh_pr_create_ignores_literal_inside_quotes() -> None:
617
+ assert not hook_module._command_invokes_gh_pr_create_in_stripped(
618
+ hook_module._preprocess_command_for_matching("echo \"gh pr create docs\"")
619
+ )
620
+
621
+
622
+ def test_command_invokes_gh_pr_create_ignores_literal_inside_single_quotes() -> None:
623
+ assert not hook_module._command_invokes_gh_pr_create_in_stripped(
624
+ hook_module._preprocess_command_for_matching("echo 'gh pr create docs'")
625
+ )
626
+
627
+
628
+ def test_command_invokes_gh_pr_create_still_matches_unquoted_invocation() -> None:
629
+ assert hook_module._command_invokes_gh_pr_create_in_stripped(
630
+ hook_module._preprocess_command_for_matching(
631
+ "gh pr create --body \"see docs about gh pr create\""
632
+ )
633
+ )
634
+
635
+
636
+ def test_command_uses_web_flag_ignores_dash_w_inside_body_string() -> None:
637
+ assert not hook_module._command_uses_web_flag_in_stripped(
638
+ hook_module._preprocess_command_for_matching(
639
+ "gh pr create --title T --body \"see -w for web\""
640
+ )
641
+ )
642
+
643
+
644
+ def test_command_uses_web_flag_handles_separator_inside_quoted_body() -> None:
645
+ assert hook_module._command_uses_web_flag_in_stripped(
646
+ hook_module._preprocess_command_for_matching(
647
+ "gh pr create --title \"T | foo\" --web"
648
+ )
649
+ )
650
+
651
+
652
+ def test_command_uses_web_flag_ignores_long_web_inside_quoted_body() -> None:
653
+ assert not hook_module._command_uses_web_flag_in_stripped(
654
+ hook_module._preprocess_command_for_matching(
655
+ "gh pr create --title T --body \"docs --web link\""
656
+ )
657
+ )
658
+
659
+
660
+ def test_write_swap_state_uses_owner_only_permissions(
661
+ required_account_jonecho: str,
662
+ isolated_state_directory: pathlib.Path,
663
+ ) -> None:
664
+ """On POSIX the state file is chmod'd to 0o600 after write."""
665
+ if sys.platform.startswith("win"):
666
+ return
667
+ state_file = hook_module._state_file_path("perm-test-session")
668
+ has_written_state = hook_module._write_swap_state(
669
+ state_file,
670
+ original_account="jl-cmd",
671
+ primary_account="JonEcho",
672
+ )
673
+ assert has_written_state is True
674
+ file_mode_bits = stat.S_IMODE(os.stat(state_file).st_mode)
675
+ assert file_mode_bits == STATE_FILE_PERMISSION_MODE
676
+
677
+
678
+ def test_write_swap_state_unlinks_file_when_chmod_fails(
679
+ monkeypatch: pytest.MonkeyPatch,
680
+ isolated_state_directory: pathlib.Path,
681
+ ) -> None:
682
+ """A chmod failure after write unlinks the file so it cannot leak."""
683
+ def _fake_chmod(*_args: object, **_kwargs: object) -> None:
684
+ raise OSError("chmod failed")
685
+
686
+ monkeypatch.setattr(hook_module.os, "chmod", _fake_chmod)
687
+ state_file = hook_module._state_file_path("chmod-fail-session")
688
+ has_written_state = hook_module._write_swap_state(
689
+ state_file,
690
+ original_account="jl-cmd",
691
+ primary_account="JonEcho",
692
+ )
693
+ assert has_written_state is False
694
+ assert not state_file.exists()
695
+
696
+
697
+ def test_module_imports_and_main_runs_under_production_sys_path_layout(
698
+ monkeypatch: pytest.MonkeyPatch,
699
+ ) -> None:
700
+ """Module imports cleanly AND main() executes a no-op path when only blocking/ is on sys.path.
701
+
702
+ pytest's ``pythonpath = packages/claude-dev-env/hooks`` lets the
703
+ in-test import work even without the sys.path shim. The Claude Code
704
+ hook runner does NOT set that path — it invokes
705
+ ``python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/gh_pr_author_enforcer.py``,
706
+ so only ``blocking/`` lands on sys.path. This test reproduces that
707
+ layout, imports the module via its own sys.path shim, then exercises
708
+ ``main()`` against a non-Bash tool_name so the no-op path runs end to
709
+ end — proving the module not only imports without
710
+ ``ModuleNotFoundError`` but also executes correctly under the
711
+ production layout.
712
+ """
713
+ blocking_dir = pathlib.Path(__file__).resolve().parent
714
+ monkeypatch.setattr(sys, "path", [str(blocking_dir)])
715
+ spec = importlib.util.spec_from_file_location(
716
+ "gh_pr_author_enforcer_production_path_check",
717
+ blocking_dir / "gh_pr_author_enforcer.py",
718
+ )
719
+ assert spec is not None
720
+ assert spec.loader is not None
721
+ fresh_module = importlib.util.module_from_spec(spec)
722
+ spec.loader.exec_module(fresh_module)
723
+ non_bash_hook_payload = json.dumps({"tool_name": "Read", "tool_input": {}})
724
+ monkeypatch.setattr(sys, "stdin", io.StringIO(non_bash_hook_payload))
725
+ captured_stdout = io.StringIO()
726
+ monkeypatch.setattr(sys, "stdout", captured_stdout)
727
+ with pytest.raises(SystemExit) as exit_info:
728
+ fresh_module.main()
729
+ assert exit_info.value.code == 0
730
+ assert captured_stdout.getvalue() == ""
731
+
732
+
733
+ def test_command_uses_web_flag_false_when_one_of_two_gh_pr_create_lacks_web() -> None:
734
+ """Chained ``gh pr create --web && gh pr create --title T`` must trigger the enforcer.
735
+
736
+ The first segment's ``--web`` does not exempt the second segment.
737
+ A short-circuiting ``all()`` over every segment returns False when
738
+ any segment lacks the flag, so ``_command_uses_web_flag_in_stripped``
739
+ returns False here and the enforcer proceeds to its swap path.
740
+ """
741
+ assert not hook_module._command_uses_web_flag_in_stripped(
742
+ hook_module._preprocess_command_for_matching(
743
+ "gh pr create --web && gh pr create --title T"
744
+ )
745
+ )
746
+
747
+
748
+ def test_command_uses_web_flag_true_when_both_gh_pr_create_have_web() -> None:
749
+ """Two chained ``gh pr create`` invocations both carrying ``--web`` are still browser-flow."""
750
+ assert hook_module._command_uses_web_flag_in_stripped(
751
+ hook_module._preprocess_command_for_matching(
752
+ "gh pr create --web && gh pr create --web --title T"
753
+ )
754
+ )
755
+
756
+
757
+ def test_command_uses_web_flag_ignores_w_after_newline_separator() -> None:
758
+ """Newline counts as a command separator; ``-w`` on the next line does not bind to gh pr create."""
759
+ assert not hook_module._command_uses_web_flag_in_stripped(
760
+ hook_module._preprocess_command_for_matching(
761
+ "gh pr create --title T\ncurl -w '%{http_code}'"
762
+ )
763
+ )
764
+
765
+
766
+ def test_command_substitution_with_gh_pr_create_inside_is_still_detected() -> None:
767
+ """``$(...)`` substitution body executes, so an inner ``gh pr create`` is real."""
768
+ assert hook_module._command_invokes_gh_pr_create_in_stripped(
769
+ hook_module._preprocess_command_for_matching('echo "$(gh pr create --title T)"')
770
+ )
771
+
772
+
773
+ def test_backtick_substitution_with_gh_pr_create_inside_is_still_detected() -> None:
774
+ """Backtick substitution body executes, so an inner ``gh pr create`` is real."""
775
+ assert hook_module._command_invokes_gh_pr_create_in_stripped(
776
+ hook_module._preprocess_command_for_matching("echo `gh pr create --title T`")
777
+ )
778
+
779
+
780
+ def test_write_swap_state_does_not_overwrite_symlink_target(
781
+ isolated_state_directory: pathlib.Path,
782
+ ) -> None:
783
+ """A symlink at the predictable state path must never let the enforcer overwrite the target.
784
+
785
+ On POSIX ``O_NOFOLLOW`` causes the atomic ``os.open`` to fail
786
+ immediately, so ``_write_swap_state`` returns False and the
787
+ attacker's target file is untouched. On Windows ``O_NOFOLLOW`` is
788
+ not exposed, but ``O_EXCL`` still rejects the create against the
789
+ existing symlink — the retry then unlinks the symlink (not the
790
+ target) and writes a fresh state file at the predictable path,
791
+ again leaving the attacker's target untouched.
792
+
793
+ The security guarantee being tested is "the attacker file is not
794
+ written to," which holds on both platforms.
795
+ """
796
+ if not hasattr(os, "symlink"):
797
+ return
798
+ state_file = hook_module._state_file_path("symlink-attack-session")
799
+ attacker_target_file = isolated_state_directory / "attacker_target.txt"
800
+ untouched_marker_text = "untouched-by-attack"
801
+ attacker_target_file.write_text(untouched_marker_text, encoding="utf-8")
802
+ try:
803
+ os.symlink(attacker_target_file, state_file)
804
+ except (OSError, NotImplementedError):
805
+ return
806
+ hook_module._write_swap_state(
807
+ state_file,
808
+ original_account="jl-cmd",
809
+ primary_account="JonEcho",
810
+ )
811
+ assert attacker_target_file.read_text(encoding="utf-8") == untouched_marker_text
812
+
813
+
814
+ def test_write_swap_state_recovers_after_stale_file_collision(
815
+ isolated_state_directory: pathlib.Path,
816
+ ) -> None:
817
+ """A stale file at the predictable path is unlinked and the create retried once."""
818
+ state_file = hook_module._state_file_path("stale-collision-session")
819
+ state_file.write_text("stale-prior-session-contents", encoding="utf-8")
820
+ has_written_state = hook_module._write_swap_state(
821
+ state_file,
822
+ original_account="jl-cmd",
823
+ primary_account="JonEcho",
824
+ )
825
+ assert has_written_state is True
826
+ persisted_state = json.loads(state_file.read_text(encoding="utf-8"))
827
+ assert persisted_state == {
828
+ "original_account": "jl-cmd",
829
+ "primary_account": "JonEcho",
830
+ }
831
+
832
+
833
+ def test_write_swap_state_loops_through_short_writes(
834
+ monkeypatch: pytest.MonkeyPatch,
835
+ isolated_state_directory: pathlib.Path,
836
+ ) -> None:
837
+ """Regression for finding 1: short os.write returns must not truncate the JSON state file.
838
+
839
+ The fake ``os.write`` writes at most three bytes per call, simulating
840
+ a kernel that signals partial writes. The helper must loop until
841
+ every byte lands on disk so the resulting file holds the complete
842
+ JSON payload and the restore hook can parse it successfully.
843
+ """
844
+ real_os_write = hook_module.os.write
845
+
846
+ def _short_writer(file_descriptor: int, payload: bytes) -> int:
847
+ return real_os_write(file_descriptor, payload[:3])
848
+
849
+ monkeypatch.setattr(hook_module.os, "write", _short_writer)
850
+ state_file = hook_module._state_file_path("short-write-session")
851
+ has_written_state = hook_module._write_swap_state(
852
+ state_file,
853
+ original_account="jl-cmd",
854
+ primary_account="JonEcho",
855
+ )
856
+ assert has_written_state is True
857
+ persisted_state = json.loads(state_file.read_text(encoding="utf-8"))
858
+ assert persisted_state == {
859
+ "original_account": "jl-cmd",
860
+ "primary_account": "JonEcho",
861
+ }
862
+
863
+
864
+ def test_write_swap_state_returns_false_when_os_write_keeps_returning_zero(
865
+ monkeypatch: pytest.MonkeyPatch,
866
+ isolated_state_directory: pathlib.Path,
867
+ ) -> None:
868
+ """Regression for finding 1 guard: ``os.write`` returning 0 must terminate as a failure.
869
+
870
+ A descriptor that cannot accept any more bytes signals zero from
871
+ ``os.write``. The helper must treat that as a write failure and
872
+ unlink the partially-written file, rather than spinning forever or
873
+ leaving a truncated file on disk.
874
+ """
875
+ monkeypatch.setattr(hook_module.os, "write", lambda *_args, **_kwargs: 0)
876
+ state_file = hook_module._state_file_path("zero-write-session")
877
+ has_written_state = hook_module._write_swap_state(
878
+ state_file,
879
+ original_account="jl-cmd",
880
+ primary_account="JonEcho",
881
+ )
882
+ assert has_written_state is False
883
+ assert not state_file.exists()
884
+
885
+
886
+ def test_command_uses_web_flag_ignores_web_inside_substitution_body() -> None:
887
+ """Regression for finding 4: ``$(echo --web)`` body must not flip the enforcer into browser-flow.
888
+
889
+ ``--web`` inside a substitution is an argument to the subshell
890
+ command (``echo``), not a flag on the outer ``gh pr create``. The
891
+ web-flag detector blanks substitution bodies before searching, so
892
+ this command continues to trigger the account swap.
893
+ """
894
+ assert not hook_module._command_uses_web_flag_in_stripped(
895
+ hook_module._preprocess_command_for_matching(
896
+ 'gh pr create --title "$(echo --web)" --body-file B'
897
+ )
898
+ )
899
+
900
+
901
+ def test_command_uses_web_flag_ignores_web_after_inline_bash_comment() -> None:
902
+ """Regression for findings 3 & 4: ``# --web`` is a comment and must not match the web flag."""
903
+ assert not hook_module._command_uses_web_flag_in_stripped(
904
+ hook_module._preprocess_command_for_matching(
905
+ "gh pr create --title T # --web"
906
+ )
907
+ )
908
+
909
+
910
+ def test_active_gh_account_returns_none_on_permission_error(
911
+ monkeypatch: pytest.MonkeyPatch,
912
+ ) -> None:
913
+ """Regression for finding 6: ``PermissionError`` from subprocess.run must not crash the hook."""
914
+ monkeypatch.setattr(
915
+ hook_module.subprocess,
916
+ "run",
917
+ mock.Mock(side_effect=PermissionError("not executable")),
918
+ )
919
+ assert hook_module._active_gh_account() is None
920
+
921
+
922
+ def test_active_gh_account_returns_none_on_generic_os_error(
923
+ monkeypatch: pytest.MonkeyPatch,
924
+ ) -> None:
925
+ """Any ``OSError`` subclass from subprocess.run must follow the documented skip path."""
926
+ monkeypatch.setattr(
927
+ hook_module.subprocess,
928
+ "run",
929
+ mock.Mock(side_effect=OSError("spawn refused")),
930
+ )
931
+ assert hook_module._active_gh_account() is None
932
+
933
+
934
+ def test_write_swap_state_unlinks_file_when_os_close_raises_after_successful_write(
935
+ monkeypatch: pytest.MonkeyPatch,
936
+ isolated_state_directory: pathlib.Path,
937
+ ) -> None:
938
+ """An ``OSError`` from ``os.close`` after a successful write rolls back the state file.
939
+
940
+ Delayed-writeback filesystems (NFS, FUSE) can surface a write error
941
+ at close time rather than at write time. The helper must treat
942
+ that as a write failure: unlink the partially-written file and
943
+ return False so the caller reverses the gh auth switch.
944
+ """
945
+ real_os_close = hook_module.os.close
946
+ real_os_write = hook_module.os.write
947
+ write_invocation_counter = {"value": 0}
948
+
949
+ def _counting_os_write(file_descriptor: int, payload: bytes) -> int:
950
+ write_invocation_counter["value"] += 1
951
+ return real_os_write(file_descriptor, payload)
952
+
953
+ def _close_raises_after_successful_write(file_descriptor: int) -> None:
954
+ real_os_close(file_descriptor)
955
+ if write_invocation_counter["value"] > 0:
956
+ raise OSError("delayed writeback failure on close")
957
+
958
+ monkeypatch.setattr(hook_module.os, "write", _counting_os_write)
959
+ monkeypatch.setattr(hook_module.os, "close", _close_raises_after_successful_write)
960
+ state_file = hook_module._state_file_path("close-fail-session")
961
+ has_written_state = hook_module._write_swap_state(
962
+ state_file,
963
+ original_account="jl-cmd",
964
+ primary_account="JonEcho",
965
+ )
966
+ assert has_written_state is False
967
+ assert not state_file.exists()
968
+
969
+
970
+ def test_command_invokes_gh_pr_create_matches_if_keyword_prefix() -> None:
971
+ """Regression for finding 1: ``if gh pr create ...; then`` must trigger the enforcer."""
972
+ assert hook_module._command_invokes_gh_pr_create_in_stripped(
973
+ hook_module._preprocess_command_for_matching(
974
+ "if gh pr create --title T; then echo ok; fi"
975
+ )
976
+ )
977
+
978
+
979
+ def test_command_invokes_gh_pr_create_matches_then_keyword_prefix() -> None:
980
+ """Regression for finding 1: ``if foo; then gh pr create`` slipping past needs catching."""
981
+ assert hook_module._command_invokes_gh_pr_create_in_stripped(
982
+ hook_module._preprocess_command_for_matching(
983
+ "if foo; then gh pr create --title T; fi"
984
+ )
985
+ )
986
+
987
+
988
+ def test_command_invokes_gh_pr_create_matches_else_keyword_prefix() -> None:
989
+ """Regression for finding 1: ``else gh pr create`` after an if branch must match."""
990
+ assert hook_module._command_invokes_gh_pr_create_in_stripped(
991
+ hook_module._preprocess_command_for_matching(
992
+ "if foo; then bar; else gh pr create --title T; fi"
993
+ )
994
+ )
995
+
996
+
997
+ def test_command_invokes_gh_pr_create_matches_elif_keyword_prefix() -> None:
998
+ """Regression for finding 1: ``elif`` precedes a real ``gh pr create`` in the same shape."""
999
+ assert hook_module._command_invokes_gh_pr_create_in_stripped(
1000
+ hook_module._preprocess_command_for_matching(
1001
+ "if foo; then bar; elif gh pr create --title T; then ok; fi"
1002
+ )
1003
+ )
1004
+
1005
+
1006
+ def test_command_invokes_gh_pr_create_matches_while_keyword_prefix() -> None:
1007
+ """Regression for finding 1: ``while gh pr create`` loop guard is a real invocation."""
1008
+ assert hook_module._command_invokes_gh_pr_create_in_stripped(
1009
+ hook_module._preprocess_command_for_matching(
1010
+ "while gh pr create --title T; do echo loop; done"
1011
+ )
1012
+ )
1013
+
1014
+
1015
+ def test_command_invokes_gh_pr_create_matches_until_keyword_prefix() -> None:
1016
+ """Regression for finding 1: ``until gh pr create`` loop guard is a real invocation."""
1017
+ assert hook_module._command_invokes_gh_pr_create_in_stripped(
1018
+ hook_module._preprocess_command_for_matching(
1019
+ "until gh pr create --title T; do sleep 1; done"
1020
+ )
1021
+ )
1022
+
1023
+
1024
+ def test_command_invokes_gh_pr_create_matches_do_keyword_prefix() -> None:
1025
+ """Regression for finding 1: ``for ...; do gh pr create`` body must match."""
1026
+ assert hook_module._command_invokes_gh_pr_create_in_stripped(
1027
+ hook_module._preprocess_command_for_matching(
1028
+ "for tag in T1 T2; do gh pr create --title $tag; done"
1029
+ )
1030
+ )
1031
+
1032
+
1033
+ def test_command_invokes_gh_pr_create_matches_bang_negation_prefix() -> None:
1034
+ """Regression for finding 1: ``! gh pr create`` (negate exit status) is a real invocation."""
1035
+ assert hook_module._command_invokes_gh_pr_create_in_stripped(
1036
+ hook_module._preprocess_command_for_matching(
1037
+ "! gh pr create --title T"
1038
+ )
1039
+ )
1040
+
1041
+
1042
+ def test_command_invokes_gh_pr_create_still_rejects_keyword_substring() -> None:
1043
+ """A bash keyword substring inside a longer identifier must not flip the matcher.
1044
+
1045
+ ``notify_then gh pr create`` is a single hyphenated/underscored
1046
+ identifier followed by text; the regex must not detect ``then`` as
1047
+ a real keyword prefix here.
1048
+ """
1049
+ assert not hook_module._command_invokes_gh_pr_create_in_stripped(
1050
+ hook_module._preprocess_command_for_matching("notify_then gh pr create")
1051
+ )
1052
+
1053
+
1054
+ def test_build_state_write_failure_message_describes_rollback_success(
1055
+ isolated_state_directory: pathlib.Path,
1056
+ ) -> None:
1057
+ """Regression for finding 6: the deny text on rollback success names the original account.
1058
+
1059
+ When the reverse ``gh auth switch`` succeeds the message must
1060
+ describe the swap as reversed and tell the user the original
1061
+ account is back in place.
1062
+ """
1063
+ deny_text = hook_module._build_state_write_failure_message(
1064
+ required_account="JonEcho",
1065
+ current_account="jl-cmd",
1066
+ state_file=isolated_state_directory / "stub_state.json",
1067
+ has_rollback_succeeded=True,
1068
+ )
1069
+ assert "swap was reversed" in deny_text
1070
+ assert "JonEcho" in deny_text
1071
+ assert "jl-cmd" in deny_text
1072
+
1073
+
1074
+ def test_build_state_write_failure_message_describes_rollback_failure(
1075
+ isolated_state_directory: pathlib.Path,
1076
+ ) -> None:
1077
+ """Regression for finding 6: the deny text on rollback failure flags the still-swapped state.
1078
+
1079
+ When the reverse switch ALSO fails the message must surface that
1080
+ the user is still on the required account so the user knows the
1081
+ rollback did not succeed and recovery is required.
1082
+ """
1083
+ deny_text = hook_module._build_state_write_failure_message(
1084
+ required_account="JonEcho",
1085
+ current_account="jl-cmd",
1086
+ state_file=isolated_state_directory / "stub_state.json",
1087
+ has_rollback_succeeded=False,
1088
+ )
1089
+ assert "reverse" in deny_text.lower()
1090
+ assert "ALSO failed" in deny_text
1091
+ assert "still" in deny_text.lower()
1092
+ assert "JonEcho" in deny_text
1093
+ assert "jl-cmd" in deny_text
1094
+
1095
+
1096
+ def test_main_deny_message_names_rollback_failure_when_reverse_switch_fails(
1097
+ monkeypatch: pytest.MonkeyPatch,
1098
+ required_account_jonecho: str,
1099
+ isolated_state_directory: pathlib.Path,
1100
+ ) -> None:
1101
+ """Regression for finding 6: end-to-end check that the deny message branch wires up correctly.
1102
+
1103
+ When state-write fails AND the rollback switch also fails, the
1104
+ deny payload must include the "ALSO failed" language so the user
1105
+ is told the gh CLI is still on ``required_account``.
1106
+ """
1107
+ monkeypatch.setattr(sys, "stdin", io.StringIO(_make_stdin_payload("gh pr create --title T")))
1108
+ captured_stdout = io.StringIO()
1109
+ monkeypatch.setattr(sys, "stdout", captured_stdout)
1110
+ monkeypatch.setattr(hook_module, "_active_gh_account", lambda: "jl-cmd")
1111
+ monkeypatch.setattr(
1112
+ hook_module,
1113
+ "_write_swap_state",
1114
+ lambda state_file, original_account, primary_account: False,
1115
+ )
1116
+
1117
+ switch_invocations: list[str] = []
1118
+
1119
+ def _fake_switch_first_succeeds_second_fails(to_account: str) -> bool:
1120
+ switch_invocations.append(to_account)
1121
+ return len(switch_invocations) == 1
1122
+
1123
+ monkeypatch.setattr(hook_module, "_switch_gh_account", _fake_switch_first_succeeds_second_fails)
1124
+
1125
+ with pytest.raises(SystemExit):
1126
+ hook_module.main()
1127
+
1128
+ assert switch_invocations == ["JonEcho", "jl-cmd"]
1129
+ payload = json.loads(captured_stdout.getvalue())
1130
+ deny_reason = payload["hookSpecificOutput"]["permissionDecisionReason"]
1131
+ assert "ALSO failed" in deny_reason
1132
+ assert "still" in deny_reason.lower()
1133
+
1134
+
1135
+ def test_main_deny_message_keeps_reversal_language_when_rollback_succeeds(
1136
+ monkeypatch: pytest.MonkeyPatch,
1137
+ required_account_jonecho: str,
1138
+ isolated_state_directory: pathlib.Path,
1139
+ ) -> None:
1140
+ """Regression for finding 6 guard: rollback-success path must NOT carry the failure language."""
1141
+ monkeypatch.setattr(sys, "stdin", io.StringIO(_make_stdin_payload("gh pr create --title T")))
1142
+ captured_stdout = io.StringIO()
1143
+ monkeypatch.setattr(sys, "stdout", captured_stdout)
1144
+ monkeypatch.setattr(hook_module, "_active_gh_account", lambda: "jl-cmd")
1145
+ monkeypatch.setattr(
1146
+ hook_module,
1147
+ "_write_swap_state",
1148
+ lambda state_file, original_account, primary_account: False,
1149
+ )
1150
+
1151
+ switch_invocations: list[str] = []
1152
+
1153
+ def _fake_switch_always_succeeds(to_account: str) -> bool:
1154
+ switch_invocations.append(to_account)
1155
+ return True
1156
+
1157
+ monkeypatch.setattr(hook_module, "_switch_gh_account", _fake_switch_always_succeeds)
1158
+
1159
+ with pytest.raises(SystemExit):
1160
+ hook_module.main()
1161
+
1162
+ assert switch_invocations == ["JonEcho", "jl-cmd"]
1163
+ payload = json.loads(captured_stdout.getvalue())
1164
+ deny_reason = payload["hookSpecificOutput"]["permissionDecisionReason"]
1165
+ assert "swap was reversed" in deny_reason
1166
+ assert "ALSO failed" not in deny_reason