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,575 @@
1
+ """Unit tests for gh-pr-author-session-cleanup SessionStart hook."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+ import json
7
+ import os
8
+ import pathlib
9
+ import stat
10
+ import sys
11
+ import time
12
+ from typing import Iterator
13
+ from unittest import mock
14
+
15
+ import pytest
16
+
17
+ _SESSION_DIR = pathlib.Path(__file__).resolve().parent
18
+ _HOOKS_ROOT = _SESSION_DIR.parent
19
+ for each_sys_path_entry in (str(_SESSION_DIR), str(_HOOKS_ROOT)):
20
+ if each_sys_path_entry not in sys.path:
21
+ sys.path.insert(0, each_sys_path_entry)
22
+
23
+ hook_module_spec = importlib.util.spec_from_file_location(
24
+ "gh_pr_author_session_cleanup",
25
+ _SESSION_DIR / "gh_pr_author_session_cleanup.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
+ from config.gh_pr_author_swap_constants import ( # noqa: E402
35
+ STATE_FILE_PERMISSION_MODE,
36
+ STATE_FILE_STALE_AGE_SECONDS,
37
+ )
38
+
39
+
40
+ _BACKDATE_SECONDS_BEFORE_NOW: int = STATE_FILE_STALE_AGE_SECONDS * 2
41
+
42
+
43
+ def _backdate_file(state_file: pathlib.Path) -> None:
44
+ backdated_time_seconds = time.time() - _BACKDATE_SECONDS_BEFORE_NOW
45
+ os.utime(state_file, (backdated_time_seconds, backdated_time_seconds))
46
+
47
+
48
+ def _chmod_like_enforcer(state_file: pathlib.Path) -> None:
49
+ """Apply the same 0o600 mode the production enforcer sets on its write.
50
+
51
+ Tests must mirror the enforcer's write contract so the cleanup
52
+ hook's ownership / mode security check sees a "trustworthy" file.
53
+ Without this chmod, every backdated state file is silently skipped
54
+ on POSIX as if it were attacker-planted.
55
+ """
56
+ os.chmod(state_file, STATE_FILE_PERMISSION_MODE)
57
+
58
+
59
+ def _write_state_file(state_file: pathlib.Path, original_account: str) -> None:
60
+ state_file.write_text(
61
+ json.dumps(
62
+ {
63
+ "original_account": original_account,
64
+ "primary_account": "JonEcho",
65
+ }
66
+ ),
67
+ encoding="utf-8",
68
+ )
69
+ _chmod_like_enforcer(state_file)
70
+ _backdate_file(state_file)
71
+
72
+
73
+ @pytest.fixture
74
+ def required_account_jonecho(monkeypatch: pytest.MonkeyPatch) -> Iterator[str]:
75
+ monkeypatch.setenv("GITHUB_DEFAULT_ACCOUNT", "JonEcho")
76
+ yield "JonEcho"
77
+
78
+
79
+ @pytest.fixture
80
+ def isolated_temp_directory(
81
+ monkeypatch: pytest.MonkeyPatch,
82
+ tmp_path: pathlib.Path,
83
+ ) -> Iterator[pathlib.Path]:
84
+ monkeypatch.setattr(hook_module.tempfile, "gettempdir", lambda: str(tmp_path))
85
+ yield tmp_path
86
+
87
+
88
+ def _install_fake_switch(monkeypatch: pytest.MonkeyPatch, switch_succeeds: bool) -> list[str]:
89
+ switch_invocations: list[str] = []
90
+
91
+ def _fake_switch(to_account: str) -> bool:
92
+ switch_invocations.append(to_account)
93
+ return switch_succeeds
94
+
95
+ monkeypatch.setattr(hook_module, "_switch_gh_account", _fake_switch)
96
+ return switch_invocations
97
+
98
+
99
+ def test_main_no_op_when_no_state_files_present(
100
+ monkeypatch: pytest.MonkeyPatch,
101
+ required_account_jonecho: str,
102
+ isolated_temp_directory: pathlib.Path,
103
+ ) -> None:
104
+ switch_invocations = _install_fake_switch(monkeypatch, switch_succeeds=True)
105
+ hook_module.main()
106
+ assert switch_invocations == []
107
+
108
+
109
+ def test_main_restores_one_stale_state_file(
110
+ monkeypatch: pytest.MonkeyPatch,
111
+ required_account_jonecho: str,
112
+ isolated_temp_directory: pathlib.Path,
113
+ ) -> None:
114
+ state_file = isolated_temp_directory / "gh_pr_author_swap_session-A.json"
115
+ _write_state_file(state_file, original_account="jl-cmd")
116
+ switch_invocations = _install_fake_switch(monkeypatch, switch_succeeds=True)
117
+
118
+ hook_module.main()
119
+
120
+ assert switch_invocations == ["jl-cmd"]
121
+ assert not state_file.exists()
122
+
123
+
124
+ def test_main_restores_multiple_stale_state_files(
125
+ monkeypatch: pytest.MonkeyPatch,
126
+ required_account_jonecho: str,
127
+ isolated_temp_directory: pathlib.Path,
128
+ ) -> None:
129
+ state_file_a = isolated_temp_directory / "gh_pr_author_swap_session-A.json"
130
+ state_file_b = isolated_temp_directory / "gh_pr_author_swap_session-B.json"
131
+ state_file_c = isolated_temp_directory / "gh_pr_author_swap_session-C.json"
132
+ _write_state_file(state_file_a, original_account="jl-cmd")
133
+ _write_state_file(state_file_b, original_account="other-user")
134
+ _write_state_file(state_file_c, original_account="third-user")
135
+ switch_invocations = _install_fake_switch(monkeypatch, switch_succeeds=True)
136
+
137
+ hook_module.main()
138
+
139
+ assert sorted(switch_invocations) == ["jl-cmd", "other-user", "third-user"]
140
+ assert not state_file_a.exists()
141
+ assert not state_file_b.exists()
142
+ assert not state_file_c.exists()
143
+
144
+
145
+ def test_main_deletes_malformed_state_file_without_switching(
146
+ monkeypatch: pytest.MonkeyPatch,
147
+ required_account_jonecho: str,
148
+ isolated_temp_directory: pathlib.Path,
149
+ ) -> None:
150
+ malformed_state_file = isolated_temp_directory / "gh_pr_author_swap_broken.json"
151
+ malformed_state_file.write_text("{not valid json", encoding="utf-8")
152
+ _chmod_like_enforcer(malformed_state_file)
153
+ _backdate_file(malformed_state_file)
154
+ switch_invocations = _install_fake_switch(monkeypatch, switch_succeeds=True)
155
+
156
+ hook_module.main()
157
+
158
+ assert switch_invocations == []
159
+ assert not malformed_state_file.exists()
160
+
161
+
162
+ def test_main_no_op_when_required_account_unset(
163
+ monkeypatch: pytest.MonkeyPatch,
164
+ isolated_temp_directory: pathlib.Path,
165
+ ) -> None:
166
+ monkeypatch.delenv("GITHUB_DEFAULT_ACCOUNT", raising=False)
167
+ state_file = isolated_temp_directory / "gh_pr_author_swap_session-A.json"
168
+ _write_state_file(state_file, original_account="jl-cmd")
169
+ switch_invocations = _install_fake_switch(monkeypatch, switch_succeeds=True)
170
+
171
+ hook_module.main()
172
+
173
+ assert switch_invocations == []
174
+ assert state_file.exists()
175
+
176
+
177
+ def test_main_preserves_state_file_when_switch_fails(
178
+ monkeypatch: pytest.MonkeyPatch,
179
+ required_account_jonecho: str,
180
+ isolated_temp_directory: pathlib.Path,
181
+ ) -> None:
182
+ state_file = isolated_temp_directory / "gh_pr_author_swap_session-A.json"
183
+ _write_state_file(state_file, original_account="jl-cmd")
184
+ switch_invocations = _install_fake_switch(monkeypatch, switch_succeeds=False)
185
+
186
+ hook_module.main()
187
+
188
+ assert switch_invocations == ["jl-cmd"]
189
+ assert state_file.exists()
190
+
191
+
192
+ def test_main_no_op_when_required_account_blank(
193
+ monkeypatch: pytest.MonkeyPatch,
194
+ isolated_temp_directory: pathlib.Path,
195
+ ) -> None:
196
+ monkeypatch.setenv("GITHUB_DEFAULT_ACCOUNT", " ")
197
+ state_file = isolated_temp_directory / "gh_pr_author_swap_session-A.json"
198
+ _write_state_file(state_file, original_account="jl-cmd")
199
+ switch_invocations = _install_fake_switch(monkeypatch, switch_succeeds=True)
200
+
201
+ hook_module.main()
202
+
203
+ assert switch_invocations == []
204
+ assert state_file.exists()
205
+
206
+
207
+ def test_main_ignores_unrelated_temp_files(
208
+ monkeypatch: pytest.MonkeyPatch,
209
+ required_account_jonecho: str,
210
+ isolated_temp_directory: pathlib.Path,
211
+ ) -> None:
212
+ unrelated_file = isolated_temp_directory / "unrelated-tempfile.txt"
213
+ unrelated_file.write_text("not a swap state file", encoding="utf-8")
214
+ sibling_swap_file = isolated_temp_directory / "gh_pr_author_swap_session-A.json"
215
+ _write_state_file(sibling_swap_file, original_account="jl-cmd")
216
+ switch_invocations = _install_fake_switch(monkeypatch, switch_succeeds=True)
217
+
218
+ hook_module.main()
219
+
220
+ assert switch_invocations == ["jl-cmd"]
221
+ assert not sibling_swap_file.exists()
222
+ assert unrelated_file.exists()
223
+
224
+
225
+ def test_main_continues_after_per_file_switch_failure(
226
+ monkeypatch: pytest.MonkeyPatch,
227
+ required_account_jonecho: str,
228
+ isolated_temp_directory: pathlib.Path,
229
+ ) -> None:
230
+ state_file_a = isolated_temp_directory / "gh_pr_author_swap_session-A.json"
231
+ state_file_b = isolated_temp_directory / "gh_pr_author_swap_session-B.json"
232
+ _write_state_file(state_file_a, original_account="failing-user")
233
+ _write_state_file(state_file_b, original_account="succeeding-user")
234
+
235
+ switch_invocations: list[str] = []
236
+
237
+ def _fake_switch(to_account: str) -> bool:
238
+ switch_invocations.append(to_account)
239
+ return to_account == "succeeding-user"
240
+
241
+ monkeypatch.setattr(hook_module, "_switch_gh_account", _fake_switch)
242
+
243
+ hook_module.main()
244
+
245
+ assert sorted(switch_invocations) == ["failing-user", "succeeding-user"]
246
+ assert state_file_a.exists()
247
+ assert not state_file_b.exists()
248
+
249
+
250
+ def test_read_original_account_returns_none_for_missing_file(
251
+ isolated_temp_directory: pathlib.Path,
252
+ ) -> None:
253
+ missing_file = isolated_temp_directory / "does_not_exist.json"
254
+ assert hook_module._read_original_account(missing_file) is None
255
+
256
+
257
+ def test_read_original_account_returns_none_for_non_dict_payload(
258
+ isolated_temp_directory: pathlib.Path,
259
+ ) -> None:
260
+ list_payload_file = isolated_temp_directory / "list_payload.json"
261
+ list_payload_file.write_text(json.dumps(["jl-cmd"]), encoding="utf-8")
262
+ assert hook_module._read_original_account(list_payload_file) is None
263
+
264
+
265
+ def test_read_original_account_returns_none_for_non_string_value(
266
+ isolated_temp_directory: pathlib.Path,
267
+ ) -> None:
268
+ bad_type_file = isolated_temp_directory / "bad_type.json"
269
+ bad_type_file.write_text(json.dumps({"original_account": 42}), encoding="utf-8")
270
+ assert hook_module._read_original_account(bad_type_file) is None
271
+
272
+
273
+ def test_read_original_account_returns_none_for_blank_value(
274
+ isolated_temp_directory: pathlib.Path,
275
+ ) -> None:
276
+ blank_value_file = isolated_temp_directory / "blank.json"
277
+ blank_value_file.write_text(json.dumps({"original_account": " "}), encoding="utf-8")
278
+ assert hook_module._read_original_account(blank_value_file) is None
279
+
280
+
281
+ def test_switch_gh_account_returns_true_on_success() -> None:
282
+ completed = mock.Mock(returncode=0, stdout="", stderr="")
283
+ with mock.patch.object(swap_utils_module.subprocess, "run", return_value=completed):
284
+ assert hook_module._switch_gh_account("jl-cmd") is True
285
+
286
+
287
+ def test_switch_gh_account_returns_false_on_nonzero_exit() -> None:
288
+ completed = mock.Mock(returncode=1, stdout="", stderr="boom")
289
+ with mock.patch.object(swap_utils_module.subprocess, "run", return_value=completed):
290
+ assert hook_module._switch_gh_account("jl-cmd") is False
291
+
292
+
293
+ def test_switch_gh_account_returns_false_when_gh_missing() -> None:
294
+ with mock.patch.object(swap_utils_module.subprocess, "run", side_effect=FileNotFoundError):
295
+ assert hook_module._switch_gh_account("jl-cmd") is False
296
+
297
+
298
+ def test_switch_gh_account_returns_false_on_timeout() -> None:
299
+ with mock.patch.object(
300
+ swap_utils_module.subprocess,
301
+ "run",
302
+ side_effect=swap_utils_module.subprocess.TimeoutExpired(cmd="gh", timeout=10),
303
+ ):
304
+ assert hook_module._switch_gh_account("jl-cmd") is False
305
+
306
+
307
+ def test_delete_state_file_is_silent_when_already_absent(
308
+ isolated_temp_directory: pathlib.Path,
309
+ ) -> None:
310
+ missing_file = isolated_temp_directory / "does_not_exist.json"
311
+ hook_module._delete_state_file(missing_file)
312
+ assert not missing_file.exists()
313
+
314
+
315
+ def test_collect_stale_state_files_matches_prefix_and_suffix(
316
+ isolated_temp_directory: pathlib.Path,
317
+ ) -> None:
318
+ matching_a = isolated_temp_directory / "gh_pr_author_swap_session-A.json"
319
+ matching_b = isolated_temp_directory / "gh_pr_author_swap_session-B.json"
320
+ wrong_prefix = isolated_temp_directory / "other_swap_session-C.json"
321
+ wrong_suffix = isolated_temp_directory / "gh_pr_author_swap_session-D.txt"
322
+ for each_file in (matching_a, matching_b, wrong_prefix, wrong_suffix):
323
+ each_file.write_text("{}", encoding="utf-8")
324
+ _chmod_like_enforcer(each_file)
325
+ _backdate_file(each_file)
326
+
327
+ matched_files = hook_module._collect_stale_state_files(isolated_temp_directory)
328
+ matched_names = {each_file.name for each_file in matched_files}
329
+
330
+ assert matched_names == {matching_a.name, matching_b.name}
331
+
332
+
333
+ def test_restore_stale_state_file_logs_when_switch_fails(
334
+ monkeypatch: pytest.MonkeyPatch,
335
+ capsys: pytest.CaptureFixture[str],
336
+ isolated_temp_directory: pathlib.Path,
337
+ ) -> None:
338
+ state_file = isolated_temp_directory / "gh_pr_author_swap_session-A.json"
339
+ _write_state_file(state_file, original_account="jl-cmd")
340
+ _install_fake_switch(monkeypatch, switch_succeeds=False)
341
+
342
+ hook_module._restore_stale_state_file(state_file)
343
+
344
+ captured_streams = capsys.readouterr()
345
+ assert state_file.exists()
346
+ assert "[gh-pr-author-cleanup] failed to restore" in captured_streams.err
347
+ assert "'jl-cmd'" in captured_streams.err
348
+ assert str(state_file) in captured_streams.err
349
+
350
+
351
+ def test_collect_stale_state_files_excludes_recent_files(
352
+ isolated_temp_directory: pathlib.Path,
353
+ ) -> None:
354
+ recent_state_file = isolated_temp_directory / "gh_pr_author_swap_session-recent.json"
355
+ recent_state_file.write_text(
356
+ json.dumps({"original_account": "jl-cmd", "primary_account": "JonEcho"}),
357
+ encoding="utf-8",
358
+ )
359
+ _chmod_like_enforcer(recent_state_file)
360
+
361
+ matched_files = hook_module._collect_stale_state_files(isolated_temp_directory)
362
+
363
+ assert recent_state_file not in matched_files
364
+
365
+
366
+ def test_collect_stale_state_files_includes_old_files(
367
+ isolated_temp_directory: pathlib.Path,
368
+ ) -> None:
369
+ old_state_file = isolated_temp_directory / "gh_pr_author_swap_session-old.json"
370
+ old_state_file.write_text(
371
+ json.dumps({"original_account": "jl-cmd", "primary_account": "JonEcho"}),
372
+ encoding="utf-8",
373
+ )
374
+ _chmod_like_enforcer(old_state_file)
375
+ backdated_time_seconds = time.time() - _BACKDATE_SECONDS_BEFORE_NOW
376
+ os.utime(old_state_file, (backdated_time_seconds, backdated_time_seconds))
377
+
378
+ matched_files = hook_module._collect_stale_state_files(isolated_temp_directory)
379
+
380
+ assert old_state_file in matched_files
381
+
382
+
383
+ def test_collect_stale_state_files_skips_unreadable_stat(
384
+ monkeypatch: pytest.MonkeyPatch,
385
+ isolated_temp_directory: pathlib.Path,
386
+ ) -> None:
387
+ unreadable_state_file = isolated_temp_directory / "gh_pr_author_swap_session-unreadable.json"
388
+ readable_state_file = isolated_temp_directory / "gh_pr_author_swap_session-readable.json"
389
+ for each_file in (unreadable_state_file, readable_state_file):
390
+ each_file.write_text(
391
+ json.dumps({"original_account": "jl-cmd", "primary_account": "JonEcho"}),
392
+ encoding="utf-8",
393
+ )
394
+ _chmod_like_enforcer(each_file)
395
+ _backdate_file(each_file)
396
+
397
+ original_lstat_method = pathlib.Path.lstat
398
+
399
+ def _lstat_with_failure_for_unreadable(
400
+ self: pathlib.Path,
401
+ *call_arguments: object,
402
+ **call_keyword_arguments: object,
403
+ ) -> os.stat_result:
404
+ if self == unreadable_state_file:
405
+ raise OSError("simulated lstat failure")
406
+ return original_lstat_method(self, *call_arguments, **call_keyword_arguments) # type: ignore[arg-type] # forwarding mixed positional/keyword to stdlib Path.lstat
407
+
408
+ monkeypatch.setattr(pathlib.Path, "lstat", _lstat_with_failure_for_unreadable)
409
+
410
+ matched_files = hook_module._collect_stale_state_files(isolated_temp_directory)
411
+
412
+ assert unreadable_state_file not in matched_files
413
+ assert readable_state_file in matched_files
414
+
415
+
416
+ def test_collect_stale_state_files_skips_world_readable_file(
417
+ isolated_temp_directory: pathlib.Path,
418
+ ) -> None:
419
+ """A backdated state file written with 0o644 mode is silently skipped on POSIX.
420
+
421
+ The enforcer creates every file at 0o600. A divergent mode means
422
+ the file was not written by an enforcer running as this user — most
423
+ likely an attacker plant — and must not be allowed to drive
424
+ ``gh auth switch``.
425
+ """
426
+ if not hasattr(os, "getuid"):
427
+ return
428
+ world_readable_state_file = (
429
+ isolated_temp_directory / "gh_pr_author_swap_session-attacker.json"
430
+ )
431
+ world_readable_state_file.write_text(
432
+ json.dumps({"original_account": "attacker", "primary_account": "JonEcho"}),
433
+ encoding="utf-8",
434
+ )
435
+ os.chmod(world_readable_state_file, 0o644)
436
+ _backdate_file(world_readable_state_file)
437
+
438
+ matched_files = hook_module._collect_stale_state_files(isolated_temp_directory)
439
+
440
+ assert world_readable_state_file not in matched_files
441
+
442
+
443
+ def test_session_cleanup_uses_shared_lstat_helper() -> None:
444
+ """Session cleanup must reuse the shared ``_lstat_indicates_attacker_planted``.
445
+
446
+ Two implementations of the lstat-based security check would let the
447
+ permission and ownership logic drift between the restore hook and
448
+ the cleanup hook. The session cleanup module must import the same
449
+ callable the shared utils exposes so a future fix to the check in
450
+ ``_gh_pr_author_swap_utils.py`` lands on both consumers from a
451
+ single edit.
452
+ """
453
+ assert (
454
+ hook_module._lstat_indicates_attacker_planted
455
+ is swap_utils_module._lstat_indicates_attacker_planted
456
+ )
457
+
458
+
459
+ def test_collect_stale_state_files_skips_other_user_owned_file(
460
+ monkeypatch: pytest.MonkeyPatch,
461
+ isolated_temp_directory: pathlib.Path,
462
+ ) -> None:
463
+ """A POSIX file owned by a different uid is silently skipped.
464
+
465
+ The cleanup hook cannot chown without root, so the test fakes a
466
+ foreign uid by monkeypatching ``Path.stat`` to return a synthetic
467
+ ``stat_result`` whose ``st_uid`` does not match ``os.getuid()``.
468
+ """
469
+ if not hasattr(os, "getuid"):
470
+ return
471
+ foreign_owned_state_file = (
472
+ isolated_temp_directory / "gh_pr_author_swap_session-foreign.json"
473
+ )
474
+ foreign_owned_state_file.write_text(
475
+ json.dumps({"original_account": "attacker", "primary_account": "JonEcho"}),
476
+ encoding="utf-8",
477
+ )
478
+ _chmod_like_enforcer(foreign_owned_state_file)
479
+ _backdate_file(foreign_owned_state_file)
480
+
481
+ real_lstat_result = os.lstat(foreign_owned_state_file)
482
+ current_user_id = os.getuid()
483
+ foreign_user_id = current_user_id + 1
484
+ synthetic_stat_fields = (
485
+ stat.S_IFREG | STATE_FILE_PERMISSION_MODE,
486
+ real_lstat_result.st_ino,
487
+ real_lstat_result.st_dev,
488
+ real_lstat_result.st_nlink,
489
+ foreign_user_id,
490
+ real_lstat_result.st_gid,
491
+ real_lstat_result.st_size,
492
+ real_lstat_result.st_atime,
493
+ real_lstat_result.st_mtime,
494
+ real_lstat_result.st_ctime,
495
+ )
496
+ synthetic_stat_result = os.stat_result(synthetic_stat_fields)
497
+ original_lstat_method = pathlib.Path.lstat
498
+
499
+ def _lstat_returning_foreign_uid_for_target(
500
+ self: pathlib.Path,
501
+ *call_arguments: object,
502
+ **call_keyword_arguments: object,
503
+ ) -> os.stat_result:
504
+ if self == foreign_owned_state_file:
505
+ return synthetic_stat_result
506
+ return original_lstat_method(self, *call_arguments, **call_keyword_arguments) # type: ignore[arg-type] # forwarding mixed positional/keyword to stdlib Path.lstat
507
+
508
+ monkeypatch.setattr(pathlib.Path, "lstat", _lstat_returning_foreign_uid_for_target)
509
+
510
+ matched_files = hook_module._collect_stale_state_files(isolated_temp_directory)
511
+
512
+ assert foreign_owned_state_file not in matched_files
513
+
514
+
515
+ def test_collect_stale_state_files_sorts_by_mtime_ascending(
516
+ isolated_temp_directory: pathlib.Path,
517
+ ) -> None:
518
+ """Regression for finding 2: returned paths must be sorted by mtime ascending.
519
+
520
+ A name-order sort would mismatch real age: a session_id starting
521
+ with ``z`` (created earliest) would sort after one starting with
522
+ ``a`` (created latest). Since the caller iterates and runs
523
+ ``gh auth switch`` for each file in order, the LAST switch wins
524
+ globally. Ordering by mtime ascending guarantees the newest stale
525
+ file's original account is the active gh account after the sweep.
526
+ """
527
+ earliest_state_file = isolated_temp_directory / "gh_pr_author_swap_session-z-earliest.json"
528
+ middle_state_file = isolated_temp_directory / "gh_pr_author_swap_session-a-middle.json"
529
+ latest_state_file = isolated_temp_directory / "gh_pr_author_swap_session-m-latest.json"
530
+ for each_state_file in (earliest_state_file, middle_state_file, latest_state_file):
531
+ _write_state_file(each_state_file, original_account="any-account")
532
+ base_backdate_seconds = time.time() - _BACKDATE_SECONDS_BEFORE_NOW
533
+ os.utime(earliest_state_file, (base_backdate_seconds - 100, base_backdate_seconds - 100))
534
+ os.utime(middle_state_file, (base_backdate_seconds - 50, base_backdate_seconds - 50))
535
+ os.utime(latest_state_file, (base_backdate_seconds - 10, base_backdate_seconds - 10))
536
+
537
+ matched_files = hook_module._collect_stale_state_files(isolated_temp_directory)
538
+
539
+ assert matched_files == [earliest_state_file, middle_state_file, latest_state_file]
540
+
541
+
542
+ def test_main_leaves_newest_stale_files_account_active_when_multiple_crashed(
543
+ monkeypatch: pytest.MonkeyPatch,
544
+ required_account_jonecho: str,
545
+ isolated_temp_directory: pathlib.Path,
546
+ ) -> None:
547
+ """Regression for finding 2: the final ``gh auth switch`` must target the newest file's account.
548
+
549
+ Three sessions crashed with different original accounts. The
550
+ cleanup hook must iterate them oldest-first so the final
551
+ invocation of ``gh auth switch`` (the only one that actually wins
552
+ in the global gh CLI state) targets the original account from the
553
+ newest stale file.
554
+ """
555
+ oldest_state_file = isolated_temp_directory / "gh_pr_author_swap_session-z-oldest.json"
556
+ middle_state_file = isolated_temp_directory / "gh_pr_author_swap_session-a-middle.json"
557
+ newest_state_file = isolated_temp_directory / "gh_pr_author_swap_session-m-newest.json"
558
+ _write_state_file(oldest_state_file, original_account="oldest-original-user")
559
+ _write_state_file(middle_state_file, original_account="middle-original-user")
560
+ _write_state_file(newest_state_file, original_account="newest-original-user")
561
+ base_backdate_seconds = time.time() - _BACKDATE_SECONDS_BEFORE_NOW
562
+ os.utime(oldest_state_file, (base_backdate_seconds - 100, base_backdate_seconds - 100))
563
+ os.utime(middle_state_file, (base_backdate_seconds - 50, base_backdate_seconds - 50))
564
+ os.utime(newest_state_file, (base_backdate_seconds - 10, base_backdate_seconds - 10))
565
+
566
+ switch_invocations = _install_fake_switch(monkeypatch, switch_succeeds=True)
567
+
568
+ hook_module.main()
569
+
570
+ assert switch_invocations == [
571
+ "oldest-original-user",
572
+ "middle-original-user",
573
+ "newest-original-user",
574
+ ]
575
+ assert switch_invocations[-1] == "newest-original-user"