claude-dev-env 4.1.0 → 4.3.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 (89) hide show
  1. package/.agents/skills/_shared/pr-loop/scripts/durable_post_lint.py +242 -0
  2. package/.agents/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/durable_post_lint_constants.py +106 -0
  3. package/.agents/skills/_shared/pr-loop/scripts/test_durable_post_lint.py +332 -0
  4. package/.agents/skills/_shared/pr-loop/scripts/test_durable_post_lint_constants.py +25 -0
  5. package/.agents/skills/everything-search/SKILL.md +18 -3
  6. package/.agents/skills/everything-search/scripts/everything_search.py +204 -0
  7. package/.agents/skills/everything-search/scripts/everything_search_command_constants/__init__.py +1 -0
  8. package/.agents/skills/everything-search/scripts/everything_search_command_constants/config/__init__.py +1 -0
  9. package/.agents/skills/everything-search/scripts/everything_search_command_constants/config/constants.py +11 -0
  10. package/.agents/skills/everything-search/scripts/test_everything_search.py +172 -0
  11. package/.agents/skills/issue-tracker/SKILL.md +6 -0
  12. package/.agents/skills/pull-request/SKILL.md +218 -0
  13. package/.agents/skills/pull-request/reference/publication-tasks.md +18 -0
  14. package/.agents/skills/pull-request/scripts/github_pr_command_constants/__init__.py +1 -0
  15. package/.agents/skills/pull-request/scripts/github_pr_command_constants/config/__init__.py +1 -0
  16. package/.agents/skills/pull-request/scripts/github_pr_command_constants/config/constants.py +61 -0
  17. package/.agents/skills/pull-request/scripts/pull_request.py +276 -0
  18. package/.agents/skills/pull-request/scripts/recover_legacy_author.py +251 -0
  19. package/.agents/skills/pull-request/scripts/test_github_pr_command_constants.py +49 -0
  20. package/.agents/skills/pull-request/scripts/test_pull_request.py +398 -0
  21. package/.agents/skills/pull-request/scripts/test_recover_legacy_author.py +221 -0
  22. package/.agents/skills/test-runner/SKILL.md +69 -0
  23. package/.agents/skills/test-runner/scripts/preflight_checks.py +365 -0
  24. package/.agents/skills/test-runner/scripts/run_tests.py +316 -0
  25. package/.agents/skills/test-runner/scripts/test_preflight_checks.py +185 -0
  26. package/.agents/skills/test-runner/scripts/test_run_tests.py +281 -0
  27. package/.agents/skills/test-runner/scripts/test_runner_constants/__init__.py +1 -0
  28. package/.agents/skills/test-runner/scripts/test_runner_constants/config/__init__.py +1 -0
  29. package/.agents/skills/test-runner/scripts/test_runner_constants/config/constants.py +96 -0
  30. package/.agents/skills-archived/pr-loop-cloud-transport/reference/identity-and-hooks.md +1 -1
  31. package/bin/ever-shipped-skills.mjs +2 -0
  32. package/bin/ever-shipped-skills.test.mjs +8 -0
  33. package/bin/install.mjs +9 -1
  34. package/bin/install.prune.test.mjs +149 -0
  35. package/bin/install.test.mjs +24 -7
  36. package/hooks/blocking/pii_prevention_blocker.py +32 -51
  37. package/hooks/blocking/test_bash_post_call_dispatcher.py +5 -28
  38. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +1 -4
  39. package/hooks/blocking/test_code_rules_command_dispatch.py +2 -2
  40. package/hooks/blocking/test_pii_prevention_blocker.py +37 -89
  41. package/hooks/gh_post_body_texts.py +195 -0
  42. package/hooks/hooks.json +0 -15
  43. package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +0 -1
  44. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +5 -16
  45. package/hooks/hooks_constants/gh_post_body_texts_constants.py +12 -0
  46. package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +0 -7
  47. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +7 -20
  48. package/hooks/hooks_constants/test_failure_recorder_constants.py +3 -3
  49. package/hooks/hooks_constants/test_gh_post_body_texts_constants.py +19 -0
  50. package/hooks/observability/test_failure_recorder.py +1 -1
  51. package/hooks/pyproject.toml +1 -1
  52. package/hooks/test_gh_post_body_texts.py +79 -0
  53. package/package.json +1 -1
  54. package/rules/durable-post-artifacts.md +31 -20
  55. package/rules/filesystem-search.md +5 -5
  56. package/rules/gh-cli-conventions.md +23 -13
  57. package/rules/git-workflow.md +21 -8
  58. package/scripts/policy_lint/adapter_configuration.py +53 -12
  59. package/scripts/repository_checks/config/constants.py +0 -5
  60. package/scripts/tests/test_adapter_configuration.py +54 -0
  61. package/hooks/_gh_pr_author_swap_utils.py +0 -1225
  62. package/hooks/blocking/conventional_pr_title_gate.py +0 -443
  63. package/hooks/blocking/es_exe_path_rewriter.py +0 -159
  64. package/hooks/blocking/gh_body_arg_blocker.py +0 -176
  65. package/hooks/blocking/gh_pr_author_enforcer.py +0 -487
  66. package/hooks/blocking/gh_pr_author_restore.py +0 -105
  67. package/hooks/blocking/pr_description_writer_gate.py +0 -256
  68. package/hooks/blocking/test_conventional_pr_title_gate.py +0 -640
  69. package/hooks/blocking/test_es_exe_path_rewriter.py +0 -447
  70. package/hooks/blocking/test_gh_body_arg_blocker.py +0 -441
  71. package/hooks/blocking/test_gh_pr_author_enforcer.py +0 -1166
  72. package/hooks/blocking/test_gh_pr_author_restore.py +0 -513
  73. package/hooks/blocking/test_gh_pr_author_swap_utils.py +0 -910
  74. package/hooks/blocking/test_pr_description_writer_gate.py +0 -234
  75. package/hooks/blocking/test_preflight_check.py +0 -343
  76. package/hooks/blocking/test_test_preflight_check.py +0 -80
  77. package/hooks/blocking/test_volatile_path_in_post_blocker.py +0 -322
  78. package/hooks/blocking/volatile_path_in_post_blocker.py +0 -461
  79. package/hooks/hooks_constants/conventional_pr_title_gate_constants.py +0 -58
  80. package/hooks/hooks_constants/gh_pr_author_swap_constants.py +0 -76
  81. package/hooks/hooks_constants/path_rewriter_constants.py +0 -13
  82. package/hooks/hooks_constants/pr_description_writer_gate_constants.py +0 -73
  83. package/hooks/hooks_constants/test_path_rewriter_constants.py +0 -57
  84. package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +0 -55
  85. package/hooks/observability/pr_description_writer_spawn_tracker.py +0 -113
  86. package/hooks/observability/test_pr_description_writer_spawn_tracker.py +0 -150
  87. package/hooks/session/gh_pr_author_session_cleanup.py +0 -175
  88. package/hooks/session/test_gh_pr_author_session_cleanup.py +0 -576
  89. package/hooks/test__gh_pr_author_swap_utils.py +0 -333
@@ -1,576 +0,0 @@
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 hooks_constants.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] Restore the gh account" in captured_streams.err
347
- assert "gh account switch requires attention" in captured_streams.err
348
- assert "'jl-cmd'" in captured_streams.err
349
- assert str(state_file) in captured_streams.err
350
-
351
-
352
- def test_collect_stale_state_files_excludes_recent_files(
353
- isolated_temp_directory: pathlib.Path,
354
- ) -> None:
355
- recent_state_file = isolated_temp_directory / "gh_pr_author_swap_session-recent.json"
356
- recent_state_file.write_text(
357
- json.dumps({"original_account": "jl-cmd", "primary_account": "JonEcho"}),
358
- encoding="utf-8",
359
- )
360
- _chmod_like_enforcer(recent_state_file)
361
-
362
- matched_files = hook_module._collect_stale_state_files(isolated_temp_directory)
363
-
364
- assert recent_state_file not in matched_files
365
-
366
-
367
- def test_collect_stale_state_files_includes_old_files(
368
- isolated_temp_directory: pathlib.Path,
369
- ) -> None:
370
- old_state_file = isolated_temp_directory / "gh_pr_author_swap_session-old.json"
371
- old_state_file.write_text(
372
- json.dumps({"original_account": "jl-cmd", "primary_account": "JonEcho"}),
373
- encoding="utf-8",
374
- )
375
- _chmod_like_enforcer(old_state_file)
376
- backdated_time_seconds = time.time() - _BACKDATE_SECONDS_BEFORE_NOW
377
- os.utime(old_state_file, (backdated_time_seconds, backdated_time_seconds))
378
-
379
- matched_files = hook_module._collect_stale_state_files(isolated_temp_directory)
380
-
381
- assert old_state_file in matched_files
382
-
383
-
384
- def test_collect_stale_state_files_skips_unreadable_stat(
385
- monkeypatch: pytest.MonkeyPatch,
386
- isolated_temp_directory: pathlib.Path,
387
- ) -> None:
388
- unreadable_state_file = isolated_temp_directory / "gh_pr_author_swap_session-unreadable.json"
389
- readable_state_file = isolated_temp_directory / "gh_pr_author_swap_session-readable.json"
390
- for each_file in (unreadable_state_file, readable_state_file):
391
- each_file.write_text(
392
- json.dumps({"original_account": "jl-cmd", "primary_account": "JonEcho"}),
393
- encoding="utf-8",
394
- )
395
- _chmod_like_enforcer(each_file)
396
- _backdate_file(each_file)
397
-
398
- original_lstat_method = pathlib.Path.lstat
399
-
400
- def _lstat_with_failure_for_unreadable(
401
- self: pathlib.Path,
402
- *call_arguments: object,
403
- **call_keyword_arguments: object,
404
- ) -> os.stat_result:
405
- if self == unreadable_state_file:
406
- raise OSError("simulated lstat failure")
407
- return original_lstat_method(self, *call_arguments, **call_keyword_arguments) # type: ignore[arg-type] # forwarding mixed positional/keyword to stdlib Path.lstat
408
-
409
- monkeypatch.setattr(pathlib.Path, "lstat", _lstat_with_failure_for_unreadable)
410
-
411
- matched_files = hook_module._collect_stale_state_files(isolated_temp_directory)
412
-
413
- assert unreadable_state_file not in matched_files
414
- assert readable_state_file in matched_files
415
-
416
-
417
- def test_collect_stale_state_files_skips_world_readable_file(
418
- isolated_temp_directory: pathlib.Path,
419
- ) -> None:
420
- """A backdated state file written with 0o644 mode is silently skipped on POSIX.
421
-
422
- The enforcer creates every file at 0o600. A divergent mode means
423
- the file was not written by an enforcer running as this user — most
424
- likely an attacker plant — and must not be allowed to drive
425
- ``gh auth switch``.
426
- """
427
- if not hasattr(os, "getuid"):
428
- return
429
- world_readable_state_file = (
430
- isolated_temp_directory / "gh_pr_author_swap_session-attacker.json"
431
- )
432
- world_readable_state_file.write_text(
433
- json.dumps({"original_account": "attacker", "primary_account": "JonEcho"}),
434
- encoding="utf-8",
435
- )
436
- os.chmod(world_readable_state_file, 0o644)
437
- _backdate_file(world_readable_state_file)
438
-
439
- matched_files = hook_module._collect_stale_state_files(isolated_temp_directory)
440
-
441
- assert world_readable_state_file not in matched_files
442
-
443
-
444
- def test_session_cleanup_uses_shared_lstat_helper() -> None:
445
- """Session cleanup must reuse the shared ``_lstat_indicates_attacker_planted``.
446
-
447
- Two implementations of the lstat-based security check would let the
448
- permission and ownership logic drift between the restore hook and
449
- the cleanup hook. The session cleanup module must import the same
450
- callable the shared utils exposes so a future fix to the check in
451
- ``_gh_pr_author_swap_utils.py`` lands on both consumers from a
452
- single edit.
453
- """
454
- assert (
455
- hook_module._lstat_indicates_attacker_planted
456
- is swap_utils_module._lstat_indicates_attacker_planted
457
- )
458
-
459
-
460
- def test_collect_stale_state_files_skips_other_user_owned_file(
461
- monkeypatch: pytest.MonkeyPatch,
462
- isolated_temp_directory: pathlib.Path,
463
- ) -> None:
464
- """A POSIX file owned by a different uid is silently skipped.
465
-
466
- The cleanup hook cannot chown without root, so the test fakes a
467
- foreign uid by monkeypatching ``Path.stat`` to return a synthetic
468
- ``stat_result`` whose ``st_uid`` does not match ``os.getuid()``.
469
- """
470
- if not hasattr(os, "getuid"):
471
- return
472
- foreign_owned_state_file = (
473
- isolated_temp_directory / "gh_pr_author_swap_session-foreign.json"
474
- )
475
- foreign_owned_state_file.write_text(
476
- json.dumps({"original_account": "attacker", "primary_account": "JonEcho"}),
477
- encoding="utf-8",
478
- )
479
- _chmod_like_enforcer(foreign_owned_state_file)
480
- _backdate_file(foreign_owned_state_file)
481
-
482
- real_lstat_result = os.lstat(foreign_owned_state_file)
483
- current_user_id = os.getuid()
484
- foreign_user_id = current_user_id + 1
485
- synthetic_stat_fields = (
486
- stat.S_IFREG | STATE_FILE_PERMISSION_MODE,
487
- real_lstat_result.st_ino,
488
- real_lstat_result.st_dev,
489
- real_lstat_result.st_nlink,
490
- foreign_user_id,
491
- real_lstat_result.st_gid,
492
- real_lstat_result.st_size,
493
- real_lstat_result.st_atime,
494
- real_lstat_result.st_mtime,
495
- real_lstat_result.st_ctime,
496
- )
497
- synthetic_stat_result = os.stat_result(synthetic_stat_fields)
498
- original_lstat_method = pathlib.Path.lstat
499
-
500
- def _lstat_returning_foreign_uid_for_target(
501
- self: pathlib.Path,
502
- *call_arguments: object,
503
- **call_keyword_arguments: object,
504
- ) -> os.stat_result:
505
- if self == foreign_owned_state_file:
506
- return synthetic_stat_result
507
- return original_lstat_method(self, *call_arguments, **call_keyword_arguments) # type: ignore[arg-type] # forwarding mixed positional/keyword to stdlib Path.lstat
508
-
509
- monkeypatch.setattr(pathlib.Path, "lstat", _lstat_returning_foreign_uid_for_target)
510
-
511
- matched_files = hook_module._collect_stale_state_files(isolated_temp_directory)
512
-
513
- assert foreign_owned_state_file not in matched_files
514
-
515
-
516
- def test_collect_stale_state_files_sorts_by_mtime_ascending(
517
- isolated_temp_directory: pathlib.Path,
518
- ) -> None:
519
- """Regression for finding 2: returned paths must be sorted by mtime ascending.
520
-
521
- A name-order sort would mismatch real age: a session_id starting
522
- with ``z`` (created earliest) would sort after one starting with
523
- ``a`` (created latest). Since the caller iterates and runs
524
- ``gh auth switch`` for each file in order, the LAST switch wins
525
- globally. Ordering by mtime ascending guarantees the newest stale
526
- file's original account is the active gh account after the sweep.
527
- """
528
- earliest_state_file = isolated_temp_directory / "gh_pr_author_swap_session-z-earliest.json"
529
- middle_state_file = isolated_temp_directory / "gh_pr_author_swap_session-a-middle.json"
530
- latest_state_file = isolated_temp_directory / "gh_pr_author_swap_session-m-latest.json"
531
- for each_state_file in (earliest_state_file, middle_state_file, latest_state_file):
532
- _write_state_file(each_state_file, original_account="any-account")
533
- base_backdate_seconds = time.time() - _BACKDATE_SECONDS_BEFORE_NOW
534
- os.utime(earliest_state_file, (base_backdate_seconds - 100, base_backdate_seconds - 100))
535
- os.utime(middle_state_file, (base_backdate_seconds - 50, base_backdate_seconds - 50))
536
- os.utime(latest_state_file, (base_backdate_seconds - 10, base_backdate_seconds - 10))
537
-
538
- matched_files = hook_module._collect_stale_state_files(isolated_temp_directory)
539
-
540
- assert matched_files == [earliest_state_file, middle_state_file, latest_state_file]
541
-
542
-
543
- def test_main_leaves_newest_stale_files_account_active_when_multiple_crashed(
544
- monkeypatch: pytest.MonkeyPatch,
545
- required_account_jonecho: str,
546
- isolated_temp_directory: pathlib.Path,
547
- ) -> None:
548
- """Regression for finding 2: the final ``gh auth switch`` must target the newest file's account.
549
-
550
- Three sessions crashed with different original accounts. The
551
- cleanup hook must iterate them oldest-first so the final
552
- invocation of ``gh auth switch`` (the only one that actually wins
553
- in the global gh CLI state) targets the original account from the
554
- newest stale file.
555
- """
556
- oldest_state_file = isolated_temp_directory / "gh_pr_author_swap_session-z-oldest.json"
557
- middle_state_file = isolated_temp_directory / "gh_pr_author_swap_session-a-middle.json"
558
- newest_state_file = isolated_temp_directory / "gh_pr_author_swap_session-m-newest.json"
559
- _write_state_file(oldest_state_file, original_account="oldest-original-user")
560
- _write_state_file(middle_state_file, original_account="middle-original-user")
561
- _write_state_file(newest_state_file, original_account="newest-original-user")
562
- base_backdate_seconds = time.time() - _BACKDATE_SECONDS_BEFORE_NOW
563
- os.utime(oldest_state_file, (base_backdate_seconds - 100, base_backdate_seconds - 100))
564
- os.utime(middle_state_file, (base_backdate_seconds - 50, base_backdate_seconds - 50))
565
- os.utime(newest_state_file, (base_backdate_seconds - 10, base_backdate_seconds - 10))
566
-
567
- switch_invocations = _install_fake_switch(monkeypatch, switch_succeeds=True)
568
-
569
- hook_module.main()
570
-
571
- assert switch_invocations == [
572
- "oldest-original-user",
573
- "middle-original-user",
574
- "newest-original-user",
575
- ]
576
- assert switch_invocations[-1] == "newest-original-user"