claude-dev-env 1.40.0 → 1.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CLAUDE.md +9 -1
  2. package/_shared/pr-loop/scripts/_claude_permissions_common.py +231 -3
  3. package/_shared/pr-loop/scripts/config/claude_permissions_constants.py +56 -2
  4. package/_shared/pr-loop/scripts/config/claude_settings_keys_constants.py +2 -0
  5. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +173 -6
  6. package/_shared/pr-loop/scripts/post_audit_thread.py +2 -2
  7. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +135 -14
  8. package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +385 -0
  9. package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +33 -0
  10. package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +1 -1
  11. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +4 -2
  12. package/hooks/_gh_pr_author_swap_utils.py +1211 -0
  13. package/hooks/blocking/gh_body_arg_blocker.py +9 -6
  14. package/hooks/blocking/gh_pr_author_enforcer.py +480 -0
  15. package/hooks/blocking/gh_pr_author_restore.py +100 -0
  16. package/hooks/blocking/pr_converge_bugteam_enforcer.py +170 -0
  17. package/hooks/blocking/pr_description_enforcer.py +1 -3
  18. package/hooks/blocking/test_gh_body_arg_blocker.py +25 -3
  19. package/hooks/blocking/test_gh_pr_author_enforcer.py +1166 -0
  20. package/hooks/blocking/test_gh_pr_author_restore.py +512 -0
  21. package/hooks/blocking/test_gh_pr_author_swap_utils.py +910 -0
  22. package/hooks/blocking/test_pr_converge_bugteam_enforcer.py +311 -0
  23. package/hooks/config/gh_pr_author_swap_constants.py +76 -0
  24. package/hooks/config/pr_converge_bugteam_enforcer_constants.py +55 -0
  25. package/hooks/config/pr_converge_bugteam_enforcer_state.py +67 -0
  26. package/hooks/config/pr_description_enforcer_constants.py +5 -0
  27. package/hooks/config/test_pr_description_enforcer_constants.py +82 -0
  28. package/hooks/hooks.json +40 -0
  29. package/hooks/lifecycle/pr_converge_bugteam_skill_tracker.py +204 -0
  30. package/hooks/lifecycle/test_pr_converge_bugteam_skill_tracker.py +283 -0
  31. package/hooks/session/gh_pr_author_session_cleanup.py +171 -0
  32. package/hooks/session/test_gh_pr_author_session_cleanup.py +575 -0
  33. package/hooks/test__gh_pr_author_swap_utils.py +333 -0
  34. package/package.json +1 -1
  35. package/skills/_shared/pr-loop/scripts/write_audit_outcomes.py +2 -2
  36. package/skills/_shared/pr-loop/scripts/write_fix_outcomes.py +2 -2
  37. package/skills/bugteam/reference/audit-contract.md +22 -0
  38. package/skills/bugteam/reference/github-pr-reviews.md +1 -1
  39. package/skills/bugteam/scripts/_claude_permissions_common.py +109 -0
  40. package/skills/bugteam/scripts/bugteam_fix_hookspath.py +8 -2
  41. package/skills/bugteam/scripts/config/claude_permissions_common_constants.py +51 -2
  42. package/skills/bugteam/scripts/grant_project_claude_permissions.py +115 -4
  43. package/skills/bugteam/scripts/revoke_project_claude_permissions.py +69 -17
  44. package/skills/bugteam/scripts/test__claude_permissions_common.py +48 -0
  45. package/skills/bugteam/scripts/test_agent_config_carveout.py +356 -0
  46. package/skills/bugteam/scripts/test_claude_permissions_common.py +18 -10
  47. package/skills/implement/SKILL.md +66 -0
  48. package/skills/implement/scripts/append_note.py +133 -0
  49. package/skills/implement/scripts/config/__init__.py +0 -0
  50. package/skills/implement/scripts/config/notes_constants.py +12 -0
  51. package/skills/implement/scripts/test_append_note.py +191 -0
  52. package/skills/pr-converge/SKILL.md +8 -2
  53. package/skills/pr-converge/config/constants.py +7 -1
  54. package/skills/pr-converge/reference/state-schema.md +36 -8
  55. package/skills/pr-converge/scripts/check_bugbot_ci.py +1 -1
  56. package/skills/pr-converge/scripts/check_convergence.py +167 -28
  57. package/skills/pr-converge/scripts/check_pending_reviews.py +1 -1
  58. package/skills/pr-converge/scripts/conftest.py +60 -0
  59. package/skills/pr-converge/scripts/fetch_copilot_reviews.py +1 -1
  60. package/skills/pr-converge/scripts/post_fix_reply.py +1 -1
  61. package/skills/pr-converge/scripts/test_check_bugbot_ci.py +1 -1
  62. package/skills/pr-converge/scripts/test_check_convergence.py +306 -0
  63. package/skills/pr-converge/scripts/test_evict_cached_config_modules.py +1 -1
  64. package/skills/refine/SKILL.md +257 -0
  65. package/skills/refine/templates/implementation-notes-template.html +56 -0
  66. package/skills/refine/templates/plan-template.md +60 -0
@@ -0,0 +1,512 @@
1
+ """Unit tests for gh-pr-author-restore PostToolUse hook."""
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
+ from config.gh_pr_author_swap_constants import STATE_FILE_PERMISSION_MODE
18
+
19
+ _HOOK_DIR = pathlib.Path(__file__).parent
20
+ if str(_HOOK_DIR) not in sys.path:
21
+ sys.path.insert(0, str(_HOOK_DIR))
22
+
23
+ hook_module_spec = importlib.util.spec_from_file_location(
24
+ "gh_pr_author_restore",
25
+ _HOOK_DIR / "gh_pr_author_restore.py",
26
+ )
27
+ assert hook_module_spec is not None
28
+ assert hook_module_spec.loader is not None
29
+ hook_module = importlib.util.module_from_spec(hook_module_spec)
30
+ hook_module_spec.loader.exec_module(hook_module)
31
+
32
+ import _gh_pr_author_swap_utils as swap_utils_module # noqa: E402
33
+
34
+
35
+ def _make_stdin_payload(
36
+ command: str,
37
+ session_id: str = "test-session-001",
38
+ tool_name: str = "Bash",
39
+ ) -> str:
40
+ return json.dumps(
41
+ {
42
+ "tool_name": tool_name,
43
+ "tool_input": {"command": command},
44
+ "session_id": session_id,
45
+ }
46
+ )
47
+
48
+
49
+ def _write_state_file(state_file: pathlib.Path, original_account: str) -> None:
50
+ state_file.write_text(
51
+ json.dumps(
52
+ {
53
+ "original_account": original_account,
54
+ "primary_account": "JonEcho",
55
+ }
56
+ ),
57
+ encoding="utf-8",
58
+ )
59
+
60
+
61
+ @pytest.fixture
62
+ def isolated_state_directory(
63
+ monkeypatch: pytest.MonkeyPatch,
64
+ tmp_path: pathlib.Path,
65
+ ) -> Iterator[pathlib.Path]:
66
+ monkeypatch.setattr(swap_utils_module.tempfile, "gettempdir", lambda: str(tmp_path))
67
+ yield tmp_path
68
+
69
+
70
+ def _run_hook_with(
71
+ stdin_text: str,
72
+ monkeypatch: pytest.MonkeyPatch,
73
+ switch_succeeds: bool,
74
+ ) -> tuple[int, str, list[str]]:
75
+ monkeypatch.setattr(sys, "stdin", io.StringIO(stdin_text))
76
+ captured_stdout = io.StringIO()
77
+ monkeypatch.setattr(sys, "stdout", captured_stdout)
78
+ switch_invocations: list[str] = []
79
+
80
+ def _fake_switch(to_account: str) -> bool:
81
+ switch_invocations.append(to_account)
82
+ return switch_succeeds
83
+
84
+ monkeypatch.setattr(hook_module, "_switch_gh_account", _fake_switch)
85
+ with pytest.raises(SystemExit) as exit_info:
86
+ hook_module.main()
87
+ exit_code = exit_info.value.code if isinstance(exit_info.value.code, int) else 0
88
+ return exit_code, captured_stdout.getvalue(), switch_invocations
89
+
90
+
91
+ def test_command_invokes_gh_pr_create_matches_basic_form() -> None:
92
+ assert hook_module._command_invokes_gh_pr_create_in_stripped(
93
+ hook_module._preprocess_command_for_matching("gh pr create --title T")
94
+ )
95
+
96
+
97
+ def test_command_invokes_gh_pr_create_rejects_pr_edit() -> None:
98
+ assert not hook_module._command_invokes_gh_pr_create_in_stripped(
99
+ hook_module._preprocess_command_for_matching("gh pr edit 10")
100
+ )
101
+
102
+
103
+ def test_state_file_path_uses_session_id(
104
+ isolated_state_directory: pathlib.Path,
105
+ ) -> None:
106
+ state_file = hook_module._state_file_path("abc-123")
107
+ assert state_file.parent == isolated_state_directory
108
+ assert state_file.name == "gh_pr_author_swap_abc-123.json"
109
+
110
+
111
+ def test_state_file_path_falls_back_to_default_when_session_id_empty(
112
+ isolated_state_directory: pathlib.Path,
113
+ ) -> None:
114
+ state_file = hook_module._state_file_path("")
115
+ assert state_file.name == "gh_pr_author_swap_default.json"
116
+
117
+
118
+ def test_main_no_op_when_tool_name_not_bash(
119
+ monkeypatch: pytest.MonkeyPatch,
120
+ isolated_state_directory: pathlib.Path,
121
+ ) -> None:
122
+ state_file = hook_module._state_file_path("test-session-001")
123
+ _write_state_file(state_file, original_account="jl-cmd")
124
+
125
+ exit_code, stdout_text, switch_invocations = _run_hook_with(
126
+ _make_stdin_payload("gh pr create --title T", tool_name="Write"),
127
+ monkeypatch=monkeypatch,
128
+ switch_succeeds=True,
129
+ )
130
+ assert exit_code == 0
131
+ assert stdout_text == ""
132
+ assert switch_invocations == []
133
+ assert state_file.exists()
134
+
135
+
136
+ def test_main_no_op_when_command_does_not_match_pr_create(
137
+ monkeypatch: pytest.MonkeyPatch,
138
+ isolated_state_directory: pathlib.Path,
139
+ ) -> None:
140
+ state_file = hook_module._state_file_path("test-session-001")
141
+ _write_state_file(state_file, original_account="jl-cmd")
142
+
143
+ exit_code, stdout_text, switch_invocations = _run_hook_with(
144
+ _make_stdin_payload("git status"),
145
+ monkeypatch=monkeypatch,
146
+ switch_succeeds=True,
147
+ )
148
+ assert exit_code == 0
149
+ assert stdout_text == ""
150
+ assert switch_invocations == []
151
+ assert state_file.exists()
152
+
153
+
154
+ def test_main_no_op_when_state_file_absent(
155
+ monkeypatch: pytest.MonkeyPatch,
156
+ isolated_state_directory: pathlib.Path,
157
+ ) -> None:
158
+ exit_code, stdout_text, switch_invocations = _run_hook_with(
159
+ _make_stdin_payload("gh pr create --title T"),
160
+ monkeypatch=monkeypatch,
161
+ switch_succeeds=True,
162
+ )
163
+ assert exit_code == 0
164
+ assert stdout_text == ""
165
+ assert switch_invocations == []
166
+
167
+
168
+ def test_main_switches_back_and_deletes_state_file(
169
+ monkeypatch: pytest.MonkeyPatch,
170
+ isolated_state_directory: pathlib.Path,
171
+ ) -> None:
172
+ state_file = hook_module._state_file_path("test-session-001")
173
+ _write_state_file(state_file, original_account="jl-cmd")
174
+
175
+ exit_code, stdout_text, switch_invocations = _run_hook_with(
176
+ _make_stdin_payload("gh pr create --title T"),
177
+ monkeypatch=monkeypatch,
178
+ switch_succeeds=True,
179
+ )
180
+ assert exit_code == 0
181
+ assert stdout_text == ""
182
+ assert switch_invocations == ["jl-cmd"]
183
+ assert not state_file.exists()
184
+
185
+
186
+ def test_main_preserves_state_file_when_switch_fails(
187
+ monkeypatch: pytest.MonkeyPatch,
188
+ isolated_state_directory: pathlib.Path,
189
+ ) -> None:
190
+ state_file = hook_module._state_file_path("test-session-001")
191
+ _write_state_file(state_file, original_account="jl-cmd")
192
+
193
+ exit_code, stdout_text, switch_invocations = _run_hook_with(
194
+ _make_stdin_payload("gh pr create --title T"),
195
+ monkeypatch=monkeypatch,
196
+ switch_succeeds=False,
197
+ )
198
+ assert exit_code == 0
199
+ assert stdout_text == ""
200
+ assert switch_invocations == ["jl-cmd"]
201
+ assert state_file.exists()
202
+
203
+
204
+ def test_main_no_op_on_invalid_stdin_json(
205
+ monkeypatch: pytest.MonkeyPatch,
206
+ isolated_state_directory: pathlib.Path,
207
+ ) -> None:
208
+ exit_code, stdout_text, switch_invocations = _run_hook_with(
209
+ "not-json",
210
+ monkeypatch=monkeypatch,
211
+ switch_succeeds=True,
212
+ )
213
+ assert exit_code == 0
214
+ assert stdout_text == ""
215
+ assert switch_invocations == []
216
+
217
+
218
+ def test_main_no_op_on_malformed_state_file(
219
+ monkeypatch: pytest.MonkeyPatch,
220
+ isolated_state_directory: pathlib.Path,
221
+ ) -> None:
222
+ state_file = hook_module._state_file_path("test-session-001")
223
+ state_file.write_text("{not valid json", encoding="utf-8")
224
+
225
+ exit_code, stdout_text, switch_invocations = _run_hook_with(
226
+ _make_stdin_payload("gh pr create --title T"),
227
+ monkeypatch=monkeypatch,
228
+ switch_succeeds=True,
229
+ )
230
+ assert exit_code == 0
231
+ assert stdout_text == ""
232
+ assert switch_invocations == []
233
+ assert not state_file.exists()
234
+
235
+
236
+ def test_main_no_op_when_state_file_missing_original_account(
237
+ monkeypatch: pytest.MonkeyPatch,
238
+ isolated_state_directory: pathlib.Path,
239
+ ) -> None:
240
+ state_file = hook_module._state_file_path("test-session-001")
241
+ state_file.write_text(
242
+ json.dumps({"primary_account": "JonEcho"}),
243
+ encoding="utf-8",
244
+ )
245
+
246
+ exit_code, stdout_text, switch_invocations = _run_hook_with(
247
+ _make_stdin_payload("gh pr create --title T"),
248
+ monkeypatch=monkeypatch,
249
+ switch_succeeds=True,
250
+ )
251
+ assert exit_code == 0
252
+ assert stdout_text == ""
253
+ assert switch_invocations == []
254
+ assert not state_file.exists()
255
+
256
+
257
+ def test_main_deletes_state_file_when_original_account_wrong_type(
258
+ monkeypatch: pytest.MonkeyPatch,
259
+ isolated_state_directory: pathlib.Path,
260
+ ) -> None:
261
+ state_file = hook_module._state_file_path("test-session-001")
262
+ state_file.write_text(
263
+ json.dumps({"original_account": 42, "primary_account": "JonEcho"}),
264
+ encoding="utf-8",
265
+ )
266
+
267
+ exit_code, stdout_text, switch_invocations = _run_hook_with(
268
+ _make_stdin_payload("gh pr create --title T"),
269
+ monkeypatch=monkeypatch,
270
+ switch_succeeds=True,
271
+ )
272
+ assert exit_code == 0
273
+ assert stdout_text == ""
274
+ assert switch_invocations == []
275
+ assert not state_file.exists()
276
+
277
+
278
+ def test_main_deletes_state_file_when_original_account_blank(
279
+ monkeypatch: pytest.MonkeyPatch,
280
+ isolated_state_directory: pathlib.Path,
281
+ ) -> None:
282
+ state_file = hook_module._state_file_path("test-session-001")
283
+ state_file.write_text(
284
+ json.dumps({"original_account": " ", "primary_account": "JonEcho"}),
285
+ encoding="utf-8",
286
+ )
287
+
288
+ exit_code, stdout_text, switch_invocations = _run_hook_with(
289
+ _make_stdin_payload("gh pr create --title T"),
290
+ monkeypatch=monkeypatch,
291
+ switch_succeeds=True,
292
+ )
293
+ assert exit_code == 0
294
+ assert stdout_text == ""
295
+ assert switch_invocations == []
296
+ assert not state_file.exists()
297
+
298
+
299
+ def test_main_no_op_does_not_create_state_file_when_absent(
300
+ monkeypatch: pytest.MonkeyPatch,
301
+ isolated_state_directory: pathlib.Path,
302
+ ) -> None:
303
+ state_file = hook_module._state_file_path("test-session-001")
304
+ assert not state_file.exists()
305
+
306
+ exit_code, stdout_text, switch_invocations = _run_hook_with(
307
+ _make_stdin_payload("gh pr create --title T"),
308
+ monkeypatch=monkeypatch,
309
+ switch_succeeds=True,
310
+ )
311
+ assert exit_code == 0
312
+ assert stdout_text == ""
313
+ assert switch_invocations == []
314
+ assert not state_file.exists()
315
+
316
+
317
+ def test_main_per_session_key_isolation(
318
+ monkeypatch: pytest.MonkeyPatch,
319
+ isolated_state_directory: pathlib.Path,
320
+ ) -> None:
321
+ state_file_a = hook_module._state_file_path("session-A")
322
+ state_file_b = hook_module._state_file_path("session-B")
323
+ _write_state_file(state_file_a, original_account="jl-cmd")
324
+ _write_state_file(state_file_b, original_account="other-user")
325
+
326
+ exit_code, _stdout_text, switch_invocations = _run_hook_with(
327
+ _make_stdin_payload("gh pr create --title T", session_id="session-A"),
328
+ monkeypatch=monkeypatch,
329
+ switch_succeeds=True,
330
+ )
331
+ assert exit_code == 0
332
+ assert switch_invocations == ["jl-cmd"]
333
+ assert not state_file_a.exists()
334
+ assert state_file_b.exists()
335
+
336
+
337
+ def test_read_original_account_returns_none_for_missing_file(
338
+ isolated_state_directory: pathlib.Path,
339
+ ) -> None:
340
+ missing_file = isolated_state_directory / "does_not_exist.json"
341
+ assert hook_module._read_original_account(missing_file) is None
342
+
343
+
344
+ def test_read_original_account_returns_none_for_non_dict_payload(
345
+ isolated_state_directory: pathlib.Path,
346
+ ) -> None:
347
+ list_payload_file = isolated_state_directory / "list_payload.json"
348
+ list_payload_file.write_text(json.dumps(["jl-cmd"]), encoding="utf-8")
349
+ assert hook_module._read_original_account(list_payload_file) is None
350
+
351
+
352
+ def test_read_original_account_returns_none_for_non_string_value(
353
+ isolated_state_directory: pathlib.Path,
354
+ ) -> None:
355
+ bad_type_file = isolated_state_directory / "bad_type.json"
356
+ bad_type_file.write_text(json.dumps({"original_account": 42}), encoding="utf-8")
357
+ assert hook_module._read_original_account(bad_type_file) is None
358
+
359
+
360
+ def test_read_original_account_returns_none_for_blank_value(
361
+ isolated_state_directory: pathlib.Path,
362
+ ) -> None:
363
+ blank_value_file = isolated_state_directory / "blank.json"
364
+ blank_value_file.write_text(json.dumps({"original_account": " "}), encoding="utf-8")
365
+ assert hook_module._read_original_account(blank_value_file) is None
366
+
367
+
368
+ def test_switch_gh_account_returns_true_on_success() -> None:
369
+ completed = mock.Mock(returncode=0, stdout="", stderr="")
370
+ with mock.patch.object(swap_utils_module.subprocess, "run", return_value=completed):
371
+ assert hook_module._switch_gh_account("jl-cmd") is True
372
+
373
+
374
+ def test_switch_gh_account_returns_false_on_nonzero_exit() -> None:
375
+ completed = mock.Mock(returncode=1, stdout="", stderr="boom")
376
+ with mock.patch.object(swap_utils_module.subprocess, "run", return_value=completed):
377
+ assert hook_module._switch_gh_account("jl-cmd") is False
378
+
379
+
380
+ def test_switch_gh_account_returns_false_when_gh_missing() -> None:
381
+ with mock.patch.object(swap_utils_module.subprocess, "run", side_effect=FileNotFoundError):
382
+ assert hook_module._switch_gh_account("jl-cmd") is False
383
+
384
+
385
+ def test_switch_gh_account_returns_false_on_timeout() -> None:
386
+ with mock.patch.object(
387
+ swap_utils_module.subprocess,
388
+ "run",
389
+ side_effect=swap_utils_module.subprocess.TimeoutExpired(cmd="gh", timeout=10),
390
+ ):
391
+ assert hook_module._switch_gh_account("jl-cmd") is False
392
+
393
+
394
+ def test_delete_state_file_is_silent_when_already_absent(
395
+ isolated_state_directory: pathlib.Path,
396
+ ) -> None:
397
+ missing_file = isolated_state_directory / "does_not_exist.json"
398
+ hook_module._delete_state_file(missing_file)
399
+ assert not missing_file.exists()
400
+
401
+
402
+ def test_main_logs_high_level_failure_when_restore_switch_fails(
403
+ monkeypatch: pytest.MonkeyPatch,
404
+ capsys: pytest.CaptureFixture[str],
405
+ isolated_state_directory: pathlib.Path,
406
+ ) -> None:
407
+ state_file = hook_module._state_file_path("test-session-001")
408
+ _write_state_file(state_file, original_account="jl-cmd")
409
+
410
+ monkeypatch.setattr(sys, "stdin", io.StringIO(_make_stdin_payload("gh pr create --title T")))
411
+
412
+ def _fake_switch(to_account: str) -> bool:
413
+ return False
414
+
415
+ monkeypatch.setattr(hook_module, "_switch_gh_account", _fake_switch)
416
+
417
+ with pytest.raises(SystemExit) as exit_info:
418
+ hook_module.main()
419
+
420
+ exit_code = exit_info.value.code if isinstance(exit_info.value.code, int) else 0
421
+ captured_streams = capsys.readouterr()
422
+
423
+ assert exit_code == 0
424
+ assert state_file.exists()
425
+ assert "[gh-pr-author-restore] failed to restore" in captured_streams.err
426
+ assert "'jl-cmd'" in captured_streams.err
427
+ assert str(state_file) in captured_streams.err
428
+
429
+
430
+ def test_main_skips_switch_and_preserves_state_file_when_planted_with_wrong_mode(
431
+ monkeypatch: pytest.MonkeyPatch,
432
+ capsys: pytest.CaptureFixture[str],
433
+ isolated_state_directory: pathlib.Path,
434
+ ) -> None:
435
+ """A state file with mode 0o644 must not drive a gh-account switch.
436
+
437
+ Regression guard: an attacker on the same workstation could plant a
438
+ state file at the predictable swap-state path with an
439
+ attacker-controlled ``original_account`` value. The restore hook
440
+ must validate the file's mode and owner before reading the
441
+ payload — a divergent mode signals the file was not written by the
442
+ enforcer running as this user. The hook must skip the switch, leave
443
+ the file on disk for inspection, and log a rejection line to
444
+ stderr.
445
+ """
446
+ if not hasattr(os, "getuid"):
447
+ return
448
+ state_file = hook_module._state_file_path("test-session-001")
449
+ _write_state_file(state_file, original_account="attacker")
450
+ os.chmod(state_file, 0o644)
451
+
452
+ monkeypatch.setattr(
453
+ sys, "stdin", io.StringIO(_make_stdin_payload("gh pr create --title T"))
454
+ )
455
+ switch_invocations: list[str] = []
456
+
457
+ def _fake_switch(to_account: str) -> bool:
458
+ switch_invocations.append(to_account)
459
+ return True
460
+
461
+ monkeypatch.setattr(hook_module, "_switch_gh_account", _fake_switch)
462
+
463
+ with pytest.raises(SystemExit) as exit_info:
464
+ hook_module.main()
465
+
466
+ exit_code = exit_info.value.code if isinstance(exit_info.value.code, int) else 0
467
+ captured_streams = capsys.readouterr()
468
+
469
+ assert exit_code == 0
470
+ assert switch_invocations == []
471
+ assert state_file.exists()
472
+ expected_mode_bits = stat.S_IMODE(state_file.stat().st_mode)
473
+ assert expected_mode_bits == 0o644
474
+ assert "[gh-pr-author-restore]" in captured_streams.err
475
+ assert "unexpected mode" in captured_streams.err or "unexpected" in captured_streams.err
476
+ assert str(state_file) in captured_streams.err
477
+
478
+
479
+ def test_module_imports_and_main_runs_under_production_sys_path_layout(
480
+ monkeypatch: pytest.MonkeyPatch,
481
+ ) -> None:
482
+ """Module imports cleanly AND main() executes a no-op path when only blocking/ is on sys.path.
483
+
484
+ pytest's ``pythonpath = packages/claude-dev-env/hooks`` lets the
485
+ in-test import work even without the sys.path shim. The Claude Code
486
+ hook runner does NOT set that path — it invokes
487
+ ``python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/gh_pr_author_restore.py``,
488
+ so only ``blocking/`` lands on sys.path. This test reproduces that
489
+ layout, imports the module via its own sys.path shim, then exercises
490
+ ``main()`` against a non-Bash tool_name so the no-op path runs end to
491
+ end — proving the module not only imports without
492
+ ``ModuleNotFoundError`` but also executes correctly under the
493
+ production layout.
494
+ """
495
+ blocking_dir = pathlib.Path(__file__).resolve().parent
496
+ monkeypatch.setattr(sys, "path", [str(blocking_dir)])
497
+ spec = importlib.util.spec_from_file_location(
498
+ "gh_pr_author_restore_production_path_check",
499
+ blocking_dir / "gh_pr_author_restore.py",
500
+ )
501
+ assert spec is not None
502
+ assert spec.loader is not None
503
+ fresh_module = importlib.util.module_from_spec(spec)
504
+ spec.loader.exec_module(fresh_module)
505
+ non_bash_hook_payload = json.dumps({"tool_name": "Read", "tool_input": {}})
506
+ monkeypatch.setattr(sys, "stdin", io.StringIO(non_bash_hook_payload))
507
+ captured_stdout = io.StringIO()
508
+ monkeypatch.setattr(sys, "stdout", captured_stdout)
509
+ with pytest.raises(SystemExit) as exit_info:
510
+ fresh_module.main()
511
+ assert exit_info.value.code == 0
512
+ assert captured_stdout.getvalue() == ""