claude-dev-env 1.40.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.
- package/CLAUDE.md +1 -1
- package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +53 -3
- package/_shared/pr-loop/scripts/post_audit_thread.py +2 -2
- package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +68 -3
- package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +1 -1
- package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +1 -1
- package/hooks/_gh_pr_author_swap_utils.py +1211 -0
- package/hooks/blocking/gh_body_arg_blocker.py +9 -6
- package/hooks/blocking/gh_pr_author_enforcer.py +480 -0
- package/hooks/blocking/gh_pr_author_restore.py +100 -0
- package/hooks/blocking/pr_converge_bugteam_enforcer.py +170 -0
- package/hooks/blocking/pr_description_enforcer.py +1 -3
- package/hooks/blocking/test_gh_body_arg_blocker.py +25 -3
- package/hooks/blocking/test_gh_pr_author_enforcer.py +1166 -0
- package/hooks/blocking/test_gh_pr_author_restore.py +512 -0
- package/hooks/blocking/test_gh_pr_author_swap_utils.py +910 -0
- package/hooks/blocking/test_pr_converge_bugteam_enforcer.py +311 -0
- package/hooks/config/gh_pr_author_swap_constants.py +76 -0
- package/hooks/config/pr_converge_bugteam_enforcer_constants.py +55 -0
- package/hooks/config/pr_converge_bugteam_enforcer_state.py +67 -0
- package/hooks/config/pr_description_enforcer_constants.py +5 -0
- package/hooks/config/test_pr_description_enforcer_constants.py +82 -0
- package/hooks/hooks.json +40 -0
- package/hooks/lifecycle/pr_converge_bugteam_skill_tracker.py +204 -0
- package/hooks/lifecycle/test_pr_converge_bugteam_skill_tracker.py +283 -0
- package/hooks/session/gh_pr_author_session_cleanup.py +171 -0
- package/hooks/session/test_gh_pr_author_session_cleanup.py +575 -0
- package/hooks/test__gh_pr_author_swap_utils.py +333 -0
- package/package.json +1 -1
- package/skills/_shared/pr-loop/scripts/write_audit_outcomes.py +2 -2
- package/skills/_shared/pr-loop/scripts/write_fix_outcomes.py +2 -2
- package/skills/bugteam/reference/audit-contract.md +22 -0
- package/skills/bugteam/reference/github-pr-reviews.md +1 -1
- package/skills/bugteam/scripts/bugteam_fix_hookspath.py +8 -2
- package/skills/bugteam/scripts/test__claude_permissions_common.py +48 -0
- package/skills/bugteam/scripts/test_claude_permissions_common.py +18 -10
- package/skills/pr-converge/SKILL.md +8 -2
- package/skills/pr-converge/config/constants.py +2 -1
- package/skills/pr-converge/reference/state-schema.md +36 -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"
|