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,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() == ""
|