claude-dev-env 1.40.0 → 1.42.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +9 -1
- package/_shared/pr-loop/scripts/_claude_permissions_common.py +231 -3
- package/_shared/pr-loop/scripts/config/claude_permissions_constants.py +56 -2
- package/_shared/pr-loop/scripts/config/claude_settings_keys_constants.py +2 -0
- package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +173 -6
- package/_shared/pr-loop/scripts/post_audit_thread.py +2 -2
- package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +135 -14
- package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +385 -0
- package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +33 -0
- 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 +4 -2
- 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/_claude_permissions_common.py +109 -0
- package/skills/bugteam/scripts/bugteam_fix_hookspath.py +8 -2
- package/skills/bugteam/scripts/config/claude_permissions_common_constants.py +51 -2
- package/skills/bugteam/scripts/grant_project_claude_permissions.py +115 -4
- package/skills/bugteam/scripts/revoke_project_claude_permissions.py +69 -17
- package/skills/bugteam/scripts/test__claude_permissions_common.py +48 -0
- package/skills/bugteam/scripts/test_agent_config_carveout.py +356 -0
- package/skills/bugteam/scripts/test_claude_permissions_common.py +18 -10
- package/skills/implement/SKILL.md +66 -0
- package/skills/implement/scripts/append_note.py +133 -0
- package/skills/implement/scripts/config/__init__.py +0 -0
- package/skills/implement/scripts/config/notes_constants.py +12 -0
- package/skills/implement/scripts/test_append_note.py +191 -0
- package/skills/pr-converge/SKILL.md +8 -2
- package/skills/pr-converge/config/constants.py +7 -1
- package/skills/pr-converge/reference/state-schema.md +36 -8
- package/skills/pr-converge/scripts/check_bugbot_ci.py +1 -1
- package/skills/pr-converge/scripts/check_convergence.py +167 -28
- package/skills/pr-converge/scripts/check_pending_reviews.py +1 -1
- package/skills/pr-converge/scripts/conftest.py +60 -0
- package/skills/pr-converge/scripts/fetch_copilot_reviews.py +1 -1
- package/skills/pr-converge/scripts/post_fix_reply.py +1 -1
- package/skills/pr-converge/scripts/test_check_bugbot_ci.py +1 -1
- package/skills/pr-converge/scripts/test_check_convergence.py +306 -0
- package/skills/pr-converge/scripts/test_evict_cached_config_modules.py +1 -1
- package/skills/refine/SKILL.md +257 -0
- package/skills/refine/templates/implementation-notes-template.html +56 -0
- package/skills/refine/templates/plan-template.md +60 -0
|
@@ -0,0 +1,1166 @@
|
|
|
1
|
+
"""Unit tests for gh-pr-author-enforcer PreToolUse hook (auto-switch behavior)."""
|
|
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
|
+
_HOOK_DIR = pathlib.Path(__file__).parent
|
|
18
|
+
if str(_HOOK_DIR) not in sys.path:
|
|
19
|
+
sys.path.insert(0, str(_HOOK_DIR))
|
|
20
|
+
|
|
21
|
+
hook_module_spec = importlib.util.spec_from_file_location(
|
|
22
|
+
"gh_pr_author_enforcer",
|
|
23
|
+
_HOOK_DIR / "gh_pr_author_enforcer.py",
|
|
24
|
+
)
|
|
25
|
+
assert hook_module_spec is not None
|
|
26
|
+
assert hook_module_spec.loader is not None
|
|
27
|
+
hook_module = importlib.util.module_from_spec(hook_module_spec)
|
|
28
|
+
hook_module_spec.loader.exec_module(hook_module)
|
|
29
|
+
|
|
30
|
+
import _gh_pr_author_swap_utils as swap_utils_module # noqa: E402
|
|
31
|
+
|
|
32
|
+
from config.gh_pr_author_swap_constants import STATE_FILE_PERMISSION_MODE # noqa: E402
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _make_stdin_payload(command: str, session_id: str = "test-session-001") -> str:
|
|
36
|
+
return json.dumps(
|
|
37
|
+
{
|
|
38
|
+
"tool_name": "Bash",
|
|
39
|
+
"tool_input": {"command": command},
|
|
40
|
+
"session_id": session_id,
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@pytest.fixture
|
|
46
|
+
def required_account_jonecho(monkeypatch: pytest.MonkeyPatch) -> Iterator[str]:
|
|
47
|
+
monkeypatch.setenv("GITHUB_DEFAULT_ACCOUNT", "JonEcho")
|
|
48
|
+
yield "JonEcho"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@pytest.fixture
|
|
52
|
+
def isolated_state_directory(
|
|
53
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
54
|
+
tmp_path: pathlib.Path,
|
|
55
|
+
) -> Iterator[pathlib.Path]:
|
|
56
|
+
monkeypatch.setattr(swap_utils_module.tempfile, "gettempdir", lambda: str(tmp_path))
|
|
57
|
+
yield tmp_path
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _run_hook_with(
|
|
61
|
+
stdin_text: str,
|
|
62
|
+
active_account_or_none: str | None,
|
|
63
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
64
|
+
switch_succeeds: bool,
|
|
65
|
+
) -> tuple[int, str, list[str]]:
|
|
66
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO(stdin_text))
|
|
67
|
+
captured_stdout = io.StringIO()
|
|
68
|
+
monkeypatch.setattr(sys, "stdout", captured_stdout)
|
|
69
|
+
monkeypatch.setattr(hook_module, "_active_gh_account", lambda: active_account_or_none)
|
|
70
|
+
switch_invocations: list[str] = []
|
|
71
|
+
|
|
72
|
+
def _fake_switch(to_account: str) -> bool:
|
|
73
|
+
switch_invocations.append(to_account)
|
|
74
|
+
return switch_succeeds
|
|
75
|
+
|
|
76
|
+
monkeypatch.setattr(hook_module, "_switch_gh_account", _fake_switch)
|
|
77
|
+
with pytest.raises(SystemExit) as exit_info:
|
|
78
|
+
hook_module.main()
|
|
79
|
+
exit_code = exit_info.value.code if isinstance(exit_info.value.code, int) else 0
|
|
80
|
+
return exit_code, captured_stdout.getvalue(), switch_invocations
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_command_invokes_gh_pr_create_matches_basic_form() -> None:
|
|
84
|
+
assert hook_module._command_invokes_gh_pr_create_in_stripped(
|
|
85
|
+
hook_module._preprocess_command_for_matching("gh pr create --title T")
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_command_invokes_gh_pr_create_matches_chained_form() -> None:
|
|
90
|
+
assert hook_module._command_invokes_gh_pr_create_in_stripped(
|
|
91
|
+
hook_module._preprocess_command_for_matching("git push && gh pr create")
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_command_invokes_gh_pr_create_rejects_pr_edit() -> None:
|
|
96
|
+
assert not hook_module._command_invokes_gh_pr_create_in_stripped(
|
|
97
|
+
hook_module._preprocess_command_for_matching("gh pr edit 10 --title X")
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_command_invokes_gh_pr_create_rejects_substring() -> None:
|
|
102
|
+
assert not hook_module._command_invokes_gh_pr_create_in_stripped(
|
|
103
|
+
hook_module._preprocess_command_for_matching("some-gh pr created-by")
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_command_uses_web_flag_matches_long_form() -> None:
|
|
108
|
+
assert hook_module._command_uses_web_flag_in_stripped(
|
|
109
|
+
hook_module._preprocess_command_for_matching("gh pr create --web")
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_command_uses_web_flag_matches_short_form() -> None:
|
|
114
|
+
assert hook_module._command_uses_web_flag_in_stripped(
|
|
115
|
+
hook_module._preprocess_command_for_matching("gh pr create -w")
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_command_uses_web_flag_rejects_webhook_substring() -> None:
|
|
120
|
+
assert not hook_module._command_uses_web_flag_in_stripped(
|
|
121
|
+
hook_module._preprocess_command_for_matching("gh pr create --webhook=foo")
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_command_uses_web_flag_ignores_curl_w_flag_before_gh() -> None:
|
|
126
|
+
assert not hook_module._command_uses_web_flag_in_stripped(
|
|
127
|
+
hook_module._preprocess_command_for_matching(
|
|
128
|
+
"curl -w '%{http_code}' url && gh pr create --title T"
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_command_uses_web_flag_ignores_w_after_separator() -> None:
|
|
134
|
+
assert not hook_module._command_uses_web_flag_in_stripped(
|
|
135
|
+
hook_module._preprocess_command_for_matching(
|
|
136
|
+
"gh pr create --title T && other-cmd -w"
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_command_uses_web_flag_detects_web_inside_gh_pr_create() -> None:
|
|
142
|
+
assert hook_module._command_uses_web_flag_in_stripped(
|
|
143
|
+
hook_module._preprocess_command_for_matching("gh pr create --web --title T")
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_command_uses_web_flag_detects_short_w_inside_gh_pr_create() -> None:
|
|
148
|
+
assert hook_module._command_uses_web_flag_in_stripped(
|
|
149
|
+
hook_module._preprocess_command_for_matching("gh pr create -w --title T")
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_command_uses_web_flag_handles_gh_pr_create_without_web() -> None:
|
|
154
|
+
assert not hook_module._command_uses_web_flag_in_stripped(
|
|
155
|
+
hook_module._preprocess_command_for_matching(
|
|
156
|
+
"gh pr create --title T --body-file B"
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_command_uses_web_flag_returns_false_when_gh_pr_create_absent() -> None:
|
|
162
|
+
assert not hook_module._command_uses_web_flag_in_stripped(
|
|
163
|
+
hook_module._preprocess_command_for_matching("curl -w '%{http_code}' url")
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_command_uses_web_flag_ignores_w_after_pipe_separator() -> None:
|
|
168
|
+
assert not hook_module._command_uses_web_flag_in_stripped(
|
|
169
|
+
hook_module._preprocess_command_for_matching(
|
|
170
|
+
"gh pr create --title T | tee -w log"
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_command_uses_web_flag_ignores_w_after_semicolon_separator() -> None:
|
|
176
|
+
assert not hook_module._command_uses_web_flag_in_stripped(
|
|
177
|
+
hook_module._preprocess_command_for_matching(
|
|
178
|
+
"gh pr create --title T ; other-cmd -w"
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def test_command_uses_web_flag_ignores_w_after_or_separator() -> None:
|
|
184
|
+
assert not hook_module._command_uses_web_flag_in_stripped(
|
|
185
|
+
hook_module._preprocess_command_for_matching(
|
|
186
|
+
"gh pr create --title T || fallback -w"
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_command_uses_web_flag_ignores_w_after_background_separator() -> None:
|
|
192
|
+
"""`gh pr create & other-cmd -w` does not pick up the trailing -w."""
|
|
193
|
+
assert not hook_module._command_uses_web_flag_in_stripped(
|
|
194
|
+
hook_module._preprocess_command_for_matching(
|
|
195
|
+
"gh pr create --title T & other-cmd -w"
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_main_auto_switches_when_active_account_mismatches(
|
|
201
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
202
|
+
required_account_jonecho: str,
|
|
203
|
+
isolated_state_directory: pathlib.Path,
|
|
204
|
+
) -> None:
|
|
205
|
+
exit_code, stdout_text, switch_invocations = _run_hook_with(
|
|
206
|
+
_make_stdin_payload("gh pr create --title T --body-file B"),
|
|
207
|
+
active_account_or_none="jl-cmd",
|
|
208
|
+
monkeypatch=monkeypatch,
|
|
209
|
+
switch_succeeds=True,
|
|
210
|
+
)
|
|
211
|
+
assert exit_code == 0
|
|
212
|
+
assert stdout_text == ""
|
|
213
|
+
assert switch_invocations == ["JonEcho"]
|
|
214
|
+
state_file = hook_module._state_file_path("test-session-001")
|
|
215
|
+
assert state_file.exists()
|
|
216
|
+
persisted_state = json.loads(state_file.read_text(encoding="utf-8"))
|
|
217
|
+
assert persisted_state == {
|
|
218
|
+
"original_account": "jl-cmd",
|
|
219
|
+
"primary_account": "JonEcho",
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def test_main_denies_when_auto_switch_fails(
|
|
224
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
225
|
+
required_account_jonecho: str,
|
|
226
|
+
isolated_state_directory: pathlib.Path,
|
|
227
|
+
) -> None:
|
|
228
|
+
exit_code, stdout_text, switch_invocations = _run_hook_with(
|
|
229
|
+
_make_stdin_payload("gh pr create --title T --body-file B"),
|
|
230
|
+
active_account_or_none="jl-cmd",
|
|
231
|
+
monkeypatch=monkeypatch,
|
|
232
|
+
switch_succeeds=False,
|
|
233
|
+
)
|
|
234
|
+
assert exit_code == 0
|
|
235
|
+
assert switch_invocations == ["JonEcho"]
|
|
236
|
+
payload = json.loads(stdout_text)
|
|
237
|
+
assert payload["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
238
|
+
deny_reason = payload["hookSpecificOutput"]["permissionDecisionReason"]
|
|
239
|
+
assert "JonEcho" in deny_reason
|
|
240
|
+
assert "jl-cmd" in deny_reason
|
|
241
|
+
assert "gh auth switch --user JonEcho" in deny_reason
|
|
242
|
+
state_file = hook_module._state_file_path("test-session-001")
|
|
243
|
+
assert not state_file.exists()
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def test_main_no_op_when_active_account_matches(
|
|
247
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
248
|
+
required_account_jonecho: str,
|
|
249
|
+
isolated_state_directory: pathlib.Path,
|
|
250
|
+
) -> None:
|
|
251
|
+
exit_code, stdout_text, switch_invocations = _run_hook_with(
|
|
252
|
+
_make_stdin_payload("gh pr create --title T --body-file B"),
|
|
253
|
+
active_account_or_none="JonEcho",
|
|
254
|
+
monkeypatch=monkeypatch,
|
|
255
|
+
switch_succeeds=True,
|
|
256
|
+
)
|
|
257
|
+
assert exit_code == 0
|
|
258
|
+
assert stdout_text == ""
|
|
259
|
+
assert switch_invocations == []
|
|
260
|
+
state_file = hook_module._state_file_path("test-session-001")
|
|
261
|
+
assert not state_file.exists()
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def test_main_allows_when_active_account_matches_case_insensitively(
|
|
265
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
266
|
+
isolated_state_directory: pathlib.Path,
|
|
267
|
+
) -> None:
|
|
268
|
+
"""GitHub usernames are case-insensitive; ``jonecho`` env value matches ``JonEcho`` canonical login."""
|
|
269
|
+
monkeypatch.setenv("GITHUB_DEFAULT_ACCOUNT", "jonecho")
|
|
270
|
+
exit_code, stdout_text, switch_invocations = _run_hook_with(
|
|
271
|
+
_make_stdin_payload("gh pr create --title T --body-file B"),
|
|
272
|
+
active_account_or_none="JonEcho",
|
|
273
|
+
monkeypatch=monkeypatch,
|
|
274
|
+
switch_succeeds=True,
|
|
275
|
+
)
|
|
276
|
+
assert exit_code == 0
|
|
277
|
+
assert stdout_text == ""
|
|
278
|
+
assert switch_invocations == []
|
|
279
|
+
state_file = hook_module._state_file_path("test-session-001")
|
|
280
|
+
assert not state_file.exists()
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def test_main_allows_when_active_account_matches_canonical_case(
|
|
284
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
285
|
+
isolated_state_directory: pathlib.Path,
|
|
286
|
+
) -> None:
|
|
287
|
+
"""Symmetric to the previous test: canonical-case env value matches lower-case login response."""
|
|
288
|
+
monkeypatch.setenv("GITHUB_DEFAULT_ACCOUNT", "JonEcho")
|
|
289
|
+
exit_code, stdout_text, switch_invocations = _run_hook_with(
|
|
290
|
+
_make_stdin_payload("gh pr create --title T --body-file B"),
|
|
291
|
+
active_account_or_none="jonecho",
|
|
292
|
+
monkeypatch=monkeypatch,
|
|
293
|
+
switch_succeeds=True,
|
|
294
|
+
)
|
|
295
|
+
assert exit_code == 0
|
|
296
|
+
assert stdout_text == ""
|
|
297
|
+
assert switch_invocations == []
|
|
298
|
+
state_file = hook_module._state_file_path("test-session-001")
|
|
299
|
+
assert not state_file.exists()
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def test_main_allows_when_required_account_unset(
|
|
303
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
304
|
+
isolated_state_directory: pathlib.Path,
|
|
305
|
+
) -> None:
|
|
306
|
+
monkeypatch.delenv("GITHUB_DEFAULT_ACCOUNT", raising=False)
|
|
307
|
+
exit_code, stdout_text, switch_invocations = _run_hook_with(
|
|
308
|
+
_make_stdin_payload("gh pr create --title T --body-file B"),
|
|
309
|
+
active_account_or_none="jl-cmd",
|
|
310
|
+
monkeypatch=monkeypatch,
|
|
311
|
+
switch_succeeds=True,
|
|
312
|
+
)
|
|
313
|
+
assert exit_code == 0
|
|
314
|
+
assert stdout_text == ""
|
|
315
|
+
assert switch_invocations == []
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def test_main_allows_web_flow_even_when_mismatched(
|
|
319
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
320
|
+
required_account_jonecho: str,
|
|
321
|
+
isolated_state_directory: pathlib.Path,
|
|
322
|
+
) -> None:
|
|
323
|
+
exit_code, stdout_text, switch_invocations = _run_hook_with(
|
|
324
|
+
_make_stdin_payload("gh pr create --web --title T"),
|
|
325
|
+
active_account_or_none="jl-cmd",
|
|
326
|
+
monkeypatch=monkeypatch,
|
|
327
|
+
switch_succeeds=True,
|
|
328
|
+
)
|
|
329
|
+
assert exit_code == 0
|
|
330
|
+
assert stdout_text == ""
|
|
331
|
+
assert switch_invocations == []
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def test_main_allows_short_web_flag_even_when_mismatched(
|
|
335
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
336
|
+
required_account_jonecho: str,
|
|
337
|
+
isolated_state_directory: pathlib.Path,
|
|
338
|
+
) -> None:
|
|
339
|
+
exit_code, stdout_text, switch_invocations = _run_hook_with(
|
|
340
|
+
_make_stdin_payload("gh pr create -w --title T"),
|
|
341
|
+
active_account_or_none="jl-cmd",
|
|
342
|
+
monkeypatch=monkeypatch,
|
|
343
|
+
switch_succeeds=True,
|
|
344
|
+
)
|
|
345
|
+
assert exit_code == 0
|
|
346
|
+
assert stdout_text == ""
|
|
347
|
+
assert switch_invocations == []
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def test_main_allows_non_bash_tool(
|
|
351
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
352
|
+
required_account_jonecho: str,
|
|
353
|
+
isolated_state_directory: pathlib.Path,
|
|
354
|
+
) -> None:
|
|
355
|
+
stdin_text = json.dumps(
|
|
356
|
+
{
|
|
357
|
+
"tool_name": "Write",
|
|
358
|
+
"tool_input": {"file_path": "x", "content": "y"},
|
|
359
|
+
"session_id": "test-session-001",
|
|
360
|
+
}
|
|
361
|
+
)
|
|
362
|
+
exit_code, stdout_text, switch_invocations = _run_hook_with(
|
|
363
|
+
stdin_text,
|
|
364
|
+
active_account_or_none="jl-cmd",
|
|
365
|
+
monkeypatch=monkeypatch,
|
|
366
|
+
switch_succeeds=True,
|
|
367
|
+
)
|
|
368
|
+
assert exit_code == 0
|
|
369
|
+
assert stdout_text == ""
|
|
370
|
+
assert switch_invocations == []
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def test_main_allows_unrelated_bash_command(
|
|
374
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
375
|
+
required_account_jonecho: str,
|
|
376
|
+
isolated_state_directory: pathlib.Path,
|
|
377
|
+
) -> None:
|
|
378
|
+
exit_code, stdout_text, switch_invocations = _run_hook_with(
|
|
379
|
+
_make_stdin_payload("git status"),
|
|
380
|
+
active_account_or_none="jl-cmd",
|
|
381
|
+
monkeypatch=monkeypatch,
|
|
382
|
+
switch_succeeds=True,
|
|
383
|
+
)
|
|
384
|
+
assert exit_code == 0
|
|
385
|
+
assert stdout_text == ""
|
|
386
|
+
assert switch_invocations == []
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def test_main_allows_gh_pr_edit(
|
|
390
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
391
|
+
required_account_jonecho: str,
|
|
392
|
+
isolated_state_directory: pathlib.Path,
|
|
393
|
+
) -> None:
|
|
394
|
+
exit_code, stdout_text, switch_invocations = _run_hook_with(
|
|
395
|
+
_make_stdin_payload("gh pr edit 10 --title X"),
|
|
396
|
+
active_account_or_none="jl-cmd",
|
|
397
|
+
monkeypatch=monkeypatch,
|
|
398
|
+
switch_succeeds=True,
|
|
399
|
+
)
|
|
400
|
+
assert exit_code == 0
|
|
401
|
+
assert stdout_text == ""
|
|
402
|
+
assert switch_invocations == []
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def test_main_allows_when_active_account_undetermined(
|
|
406
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
407
|
+
required_account_jonecho: str,
|
|
408
|
+
isolated_state_directory: pathlib.Path,
|
|
409
|
+
) -> None:
|
|
410
|
+
exit_code, stdout_text, switch_invocations = _run_hook_with(
|
|
411
|
+
_make_stdin_payload("gh pr create --title T"),
|
|
412
|
+
active_account_or_none=None,
|
|
413
|
+
monkeypatch=monkeypatch,
|
|
414
|
+
switch_succeeds=True,
|
|
415
|
+
)
|
|
416
|
+
assert exit_code == 0
|
|
417
|
+
assert stdout_text == ""
|
|
418
|
+
assert switch_invocations == []
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def test_main_allows_invalid_stdin_json(
|
|
422
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
423
|
+
required_account_jonecho: str,
|
|
424
|
+
isolated_state_directory: pathlib.Path,
|
|
425
|
+
) -> None:
|
|
426
|
+
exit_code, stdout_text, switch_invocations = _run_hook_with(
|
|
427
|
+
"not-json",
|
|
428
|
+
active_account_or_none="jl-cmd",
|
|
429
|
+
monkeypatch=monkeypatch,
|
|
430
|
+
switch_succeeds=True,
|
|
431
|
+
)
|
|
432
|
+
assert exit_code == 0
|
|
433
|
+
assert stdout_text == ""
|
|
434
|
+
assert switch_invocations == []
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def test_state_file_path_uses_session_id(
|
|
438
|
+
isolated_state_directory: pathlib.Path,
|
|
439
|
+
) -> None:
|
|
440
|
+
state_file = hook_module._state_file_path("abc-123")
|
|
441
|
+
assert state_file.parent == isolated_state_directory
|
|
442
|
+
assert state_file.name == "gh_pr_author_swap_abc-123.json"
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def test_state_file_path_falls_back_to_default_when_session_id_empty(
|
|
446
|
+
isolated_state_directory: pathlib.Path,
|
|
447
|
+
) -> None:
|
|
448
|
+
state_file = hook_module._state_file_path("")
|
|
449
|
+
assert state_file.name == "gh_pr_author_swap_default.json"
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def test_active_gh_account_returns_login_on_success() -> None:
|
|
453
|
+
completed = mock.Mock(returncode=0, stdout="JonEcho\n")
|
|
454
|
+
with mock.patch.object(hook_module.subprocess, "run", return_value=completed):
|
|
455
|
+
assert hook_module._active_gh_account() == "JonEcho"
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def test_active_gh_account_returns_none_on_nonzero_exit() -> None:
|
|
459
|
+
completed = mock.Mock(returncode=1, stdout="")
|
|
460
|
+
with mock.patch.object(hook_module.subprocess, "run", return_value=completed):
|
|
461
|
+
assert hook_module._active_gh_account() is None
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def test_active_gh_account_returns_none_when_gh_missing() -> None:
|
|
465
|
+
with mock.patch.object(hook_module.subprocess, "run", side_effect=FileNotFoundError):
|
|
466
|
+
assert hook_module._active_gh_account() is None
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def test_active_gh_account_returns_none_on_timeout() -> None:
|
|
470
|
+
with mock.patch.object(
|
|
471
|
+
hook_module.subprocess,
|
|
472
|
+
"run",
|
|
473
|
+
side_effect=hook_module.subprocess.TimeoutExpired(cmd="gh", timeout=5),
|
|
474
|
+
):
|
|
475
|
+
assert hook_module._active_gh_account() is None
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def test_switch_gh_account_returns_true_on_success() -> None:
|
|
479
|
+
completed = mock.Mock(returncode=0, stdout="", stderr="")
|
|
480
|
+
with mock.patch.object(hook_module.subprocess, "run", return_value=completed):
|
|
481
|
+
assert hook_module._switch_gh_account("JonEcho") is True
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def test_switch_gh_account_returns_false_on_nonzero_exit() -> None:
|
|
485
|
+
completed = mock.Mock(returncode=1, stdout="", stderr="auth failed")
|
|
486
|
+
with mock.patch.object(hook_module.subprocess, "run", return_value=completed):
|
|
487
|
+
assert hook_module._switch_gh_account("JonEcho") is False
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def test_switch_gh_account_returns_false_when_gh_missing() -> None:
|
|
491
|
+
with mock.patch.object(hook_module.subprocess, "run", side_effect=FileNotFoundError):
|
|
492
|
+
assert hook_module._switch_gh_account("JonEcho") is False
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def test_switch_gh_account_returns_false_on_timeout() -> None:
|
|
496
|
+
with mock.patch.object(
|
|
497
|
+
hook_module.subprocess,
|
|
498
|
+
"run",
|
|
499
|
+
side_effect=hook_module.subprocess.TimeoutExpired(cmd="gh", timeout=10),
|
|
500
|
+
):
|
|
501
|
+
assert hook_module._switch_gh_account("JonEcho") is False
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def test_main_denies_and_reverses_switch_when_state_write_fails(
|
|
505
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
506
|
+
required_account_jonecho: str,
|
|
507
|
+
isolated_state_directory: pathlib.Path,
|
|
508
|
+
) -> None:
|
|
509
|
+
monkeypatch.setattr(
|
|
510
|
+
hook_module,
|
|
511
|
+
"_write_swap_state",
|
|
512
|
+
lambda state_file, original_account, primary_account: False,
|
|
513
|
+
)
|
|
514
|
+
exit_code, stdout_text, switch_invocations = _run_hook_with(
|
|
515
|
+
_make_stdin_payload("gh pr create --title T --body-file B"),
|
|
516
|
+
active_account_or_none="jl-cmd",
|
|
517
|
+
monkeypatch=monkeypatch,
|
|
518
|
+
switch_succeeds=True,
|
|
519
|
+
)
|
|
520
|
+
assert exit_code == 0
|
|
521
|
+
assert switch_invocations == ["JonEcho", "jl-cmd"]
|
|
522
|
+
payload = json.loads(stdout_text)
|
|
523
|
+
assert payload["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
524
|
+
deny_reason = payload["hookSpecificOutput"]["permissionDecisionReason"]
|
|
525
|
+
assert "state file" in deny_reason.lower()
|
|
526
|
+
assert "JonEcho" in deny_reason
|
|
527
|
+
assert "jl-cmd" in deny_reason
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def test_main_emits_deny_even_when_reverse_switch_also_fails(
|
|
531
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
532
|
+
required_account_jonecho: str,
|
|
533
|
+
isolated_state_directory: pathlib.Path,
|
|
534
|
+
) -> None:
|
|
535
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO(_make_stdin_payload("gh pr create --title T")))
|
|
536
|
+
captured_stdout = io.StringIO()
|
|
537
|
+
monkeypatch.setattr(sys, "stdout", captured_stdout)
|
|
538
|
+
monkeypatch.setattr(hook_module, "_active_gh_account", lambda: "jl-cmd")
|
|
539
|
+
monkeypatch.setattr(
|
|
540
|
+
hook_module,
|
|
541
|
+
"_write_swap_state",
|
|
542
|
+
lambda state_file, original_account, primary_account: False,
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
switch_invocations: list[str] = []
|
|
546
|
+
|
|
547
|
+
def _fake_switch_first_succeeds_second_fails(to_account: str) -> bool:
|
|
548
|
+
switch_invocations.append(to_account)
|
|
549
|
+
return len(switch_invocations) == 1
|
|
550
|
+
|
|
551
|
+
monkeypatch.setattr(hook_module, "_switch_gh_account", _fake_switch_first_succeeds_second_fails)
|
|
552
|
+
|
|
553
|
+
with pytest.raises(SystemExit) as exit_info:
|
|
554
|
+
hook_module.main()
|
|
555
|
+
exit_code = exit_info.value.code if isinstance(exit_info.value.code, int) else 0
|
|
556
|
+
|
|
557
|
+
assert exit_code == 0
|
|
558
|
+
assert switch_invocations == ["JonEcho", "jl-cmd"]
|
|
559
|
+
payload = json.loads(captured_stdout.getvalue())
|
|
560
|
+
assert payload["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
561
|
+
deny_reason = payload["hookSpecificOutput"]["permissionDecisionReason"]
|
|
562
|
+
assert "state file" in deny_reason.lower()
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def test_strip_quoted_regions_preserves_offsets_for_double_quotes() -> None:
|
|
566
|
+
original_command = "gh pr create --body \"some text\" --title T"
|
|
567
|
+
stripped_command = hook_module._preprocess_command_for_matching(original_command)
|
|
568
|
+
assert len(stripped_command) == len(original_command)
|
|
569
|
+
assert "some text" not in stripped_command
|
|
570
|
+
assert "gh pr create" in stripped_command
|
|
571
|
+
assert "--title T" in stripped_command
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def test_strip_quoted_regions_preserves_offsets_for_single_quotes() -> None:
|
|
575
|
+
original_command = "gh pr create --body 'single quoted body' --title T"
|
|
576
|
+
stripped_command = hook_module._preprocess_command_for_matching(original_command)
|
|
577
|
+
assert len(stripped_command) == len(original_command)
|
|
578
|
+
assert "single quoted body" not in stripped_command
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def test_strip_quoted_regions_preserves_backtick_substitution_body() -> None:
|
|
582
|
+
"""Backticks delimit command substitution, which executes — the body must remain scannable."""
|
|
583
|
+
original_command = "echo `inner cmd` && gh pr create --title T"
|
|
584
|
+
stripped_command = hook_module._preprocess_command_for_matching(original_command)
|
|
585
|
+
assert len(stripped_command) == len(original_command)
|
|
586
|
+
assert "inner cmd" in stripped_command
|
|
587
|
+
assert "gh pr create" in stripped_command
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def test_strip_quoted_regions_preserves_dollar_paren_substitution_body() -> None:
|
|
591
|
+
"""``$(...)`` substitution body must remain scannable for the same reason as backticks."""
|
|
592
|
+
original_command = "echo $(inner cmd) && gh pr create --title T"
|
|
593
|
+
stripped_command = hook_module._preprocess_command_for_matching(original_command)
|
|
594
|
+
assert len(stripped_command) == len(original_command)
|
|
595
|
+
assert "inner cmd" in stripped_command
|
|
596
|
+
assert "gh pr create" in stripped_command
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def test_strip_quoted_regions_preserves_dollar_paren_inside_double_quotes() -> None:
|
|
600
|
+
"""``"$(...)"`` substitution body remains scannable even when wrapped in double quotes."""
|
|
601
|
+
original_command = 'echo "$(inner cmd)" && gh pr create --title T'
|
|
602
|
+
stripped_command = hook_module._preprocess_command_for_matching(original_command)
|
|
603
|
+
assert len(stripped_command) == len(original_command)
|
|
604
|
+
assert "inner cmd" in stripped_command
|
|
605
|
+
assert "gh pr create" in stripped_command
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def test_strip_quoted_regions_handles_escaped_quote_inside_double_quotes() -> None:
|
|
609
|
+
original_command = "gh pr create --body \"escaped \\\" quote\" --title T"
|
|
610
|
+
stripped_command = hook_module._preprocess_command_for_matching(original_command)
|
|
611
|
+
assert len(stripped_command) == len(original_command)
|
|
612
|
+
assert "escaped" not in stripped_command
|
|
613
|
+
assert "--title T" in stripped_command
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def test_command_invokes_gh_pr_create_ignores_literal_inside_quotes() -> None:
|
|
617
|
+
assert not hook_module._command_invokes_gh_pr_create_in_stripped(
|
|
618
|
+
hook_module._preprocess_command_for_matching("echo \"gh pr create docs\"")
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def test_command_invokes_gh_pr_create_ignores_literal_inside_single_quotes() -> None:
|
|
623
|
+
assert not hook_module._command_invokes_gh_pr_create_in_stripped(
|
|
624
|
+
hook_module._preprocess_command_for_matching("echo 'gh pr create docs'")
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def test_command_invokes_gh_pr_create_still_matches_unquoted_invocation() -> None:
|
|
629
|
+
assert hook_module._command_invokes_gh_pr_create_in_stripped(
|
|
630
|
+
hook_module._preprocess_command_for_matching(
|
|
631
|
+
"gh pr create --body \"see docs about gh pr create\""
|
|
632
|
+
)
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def test_command_uses_web_flag_ignores_dash_w_inside_body_string() -> None:
|
|
637
|
+
assert not hook_module._command_uses_web_flag_in_stripped(
|
|
638
|
+
hook_module._preprocess_command_for_matching(
|
|
639
|
+
"gh pr create --title T --body \"see -w for web\""
|
|
640
|
+
)
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def test_command_uses_web_flag_handles_separator_inside_quoted_body() -> None:
|
|
645
|
+
assert hook_module._command_uses_web_flag_in_stripped(
|
|
646
|
+
hook_module._preprocess_command_for_matching(
|
|
647
|
+
"gh pr create --title \"T | foo\" --web"
|
|
648
|
+
)
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def test_command_uses_web_flag_ignores_long_web_inside_quoted_body() -> None:
|
|
653
|
+
assert not hook_module._command_uses_web_flag_in_stripped(
|
|
654
|
+
hook_module._preprocess_command_for_matching(
|
|
655
|
+
"gh pr create --title T --body \"docs --web link\""
|
|
656
|
+
)
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def test_write_swap_state_uses_owner_only_permissions(
|
|
661
|
+
required_account_jonecho: str,
|
|
662
|
+
isolated_state_directory: pathlib.Path,
|
|
663
|
+
) -> None:
|
|
664
|
+
"""On POSIX the state file is chmod'd to 0o600 after write."""
|
|
665
|
+
if sys.platform.startswith("win"):
|
|
666
|
+
return
|
|
667
|
+
state_file = hook_module._state_file_path("perm-test-session")
|
|
668
|
+
has_written_state = hook_module._write_swap_state(
|
|
669
|
+
state_file,
|
|
670
|
+
original_account="jl-cmd",
|
|
671
|
+
primary_account="JonEcho",
|
|
672
|
+
)
|
|
673
|
+
assert has_written_state is True
|
|
674
|
+
file_mode_bits = stat.S_IMODE(os.stat(state_file).st_mode)
|
|
675
|
+
assert file_mode_bits == STATE_FILE_PERMISSION_MODE
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def test_write_swap_state_unlinks_file_when_chmod_fails(
|
|
679
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
680
|
+
isolated_state_directory: pathlib.Path,
|
|
681
|
+
) -> None:
|
|
682
|
+
"""A chmod failure after write unlinks the file so it cannot leak."""
|
|
683
|
+
def _fake_chmod(*_args: object, **_kwargs: object) -> None:
|
|
684
|
+
raise OSError("chmod failed")
|
|
685
|
+
|
|
686
|
+
monkeypatch.setattr(hook_module.os, "chmod", _fake_chmod)
|
|
687
|
+
state_file = hook_module._state_file_path("chmod-fail-session")
|
|
688
|
+
has_written_state = hook_module._write_swap_state(
|
|
689
|
+
state_file,
|
|
690
|
+
original_account="jl-cmd",
|
|
691
|
+
primary_account="JonEcho",
|
|
692
|
+
)
|
|
693
|
+
assert has_written_state is False
|
|
694
|
+
assert not state_file.exists()
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def test_module_imports_and_main_runs_under_production_sys_path_layout(
|
|
698
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
699
|
+
) -> None:
|
|
700
|
+
"""Module imports cleanly AND main() executes a no-op path when only blocking/ is on sys.path.
|
|
701
|
+
|
|
702
|
+
pytest's ``pythonpath = packages/claude-dev-env/hooks`` lets the
|
|
703
|
+
in-test import work even without the sys.path shim. The Claude Code
|
|
704
|
+
hook runner does NOT set that path — it invokes
|
|
705
|
+
``python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/gh_pr_author_enforcer.py``,
|
|
706
|
+
so only ``blocking/`` lands on sys.path. This test reproduces that
|
|
707
|
+
layout, imports the module via its own sys.path shim, then exercises
|
|
708
|
+
``main()`` against a non-Bash tool_name so the no-op path runs end to
|
|
709
|
+
end — proving the module not only imports without
|
|
710
|
+
``ModuleNotFoundError`` but also executes correctly under the
|
|
711
|
+
production layout.
|
|
712
|
+
"""
|
|
713
|
+
blocking_dir = pathlib.Path(__file__).resolve().parent
|
|
714
|
+
monkeypatch.setattr(sys, "path", [str(blocking_dir)])
|
|
715
|
+
spec = importlib.util.spec_from_file_location(
|
|
716
|
+
"gh_pr_author_enforcer_production_path_check",
|
|
717
|
+
blocking_dir / "gh_pr_author_enforcer.py",
|
|
718
|
+
)
|
|
719
|
+
assert spec is not None
|
|
720
|
+
assert spec.loader is not None
|
|
721
|
+
fresh_module = importlib.util.module_from_spec(spec)
|
|
722
|
+
spec.loader.exec_module(fresh_module)
|
|
723
|
+
non_bash_hook_payload = json.dumps({"tool_name": "Read", "tool_input": {}})
|
|
724
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO(non_bash_hook_payload))
|
|
725
|
+
captured_stdout = io.StringIO()
|
|
726
|
+
monkeypatch.setattr(sys, "stdout", captured_stdout)
|
|
727
|
+
with pytest.raises(SystemExit) as exit_info:
|
|
728
|
+
fresh_module.main()
|
|
729
|
+
assert exit_info.value.code == 0
|
|
730
|
+
assert captured_stdout.getvalue() == ""
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def test_command_uses_web_flag_false_when_one_of_two_gh_pr_create_lacks_web() -> None:
|
|
734
|
+
"""Chained ``gh pr create --web && gh pr create --title T`` must trigger the enforcer.
|
|
735
|
+
|
|
736
|
+
The first segment's ``--web`` does not exempt the second segment.
|
|
737
|
+
A short-circuiting ``all()`` over every segment returns False when
|
|
738
|
+
any segment lacks the flag, so ``_command_uses_web_flag_in_stripped``
|
|
739
|
+
returns False here and the enforcer proceeds to its swap path.
|
|
740
|
+
"""
|
|
741
|
+
assert not hook_module._command_uses_web_flag_in_stripped(
|
|
742
|
+
hook_module._preprocess_command_for_matching(
|
|
743
|
+
"gh pr create --web && gh pr create --title T"
|
|
744
|
+
)
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def test_command_uses_web_flag_true_when_both_gh_pr_create_have_web() -> None:
|
|
749
|
+
"""Two chained ``gh pr create`` invocations both carrying ``--web`` are still browser-flow."""
|
|
750
|
+
assert hook_module._command_uses_web_flag_in_stripped(
|
|
751
|
+
hook_module._preprocess_command_for_matching(
|
|
752
|
+
"gh pr create --web && gh pr create --web --title T"
|
|
753
|
+
)
|
|
754
|
+
)
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def test_command_uses_web_flag_ignores_w_after_newline_separator() -> None:
|
|
758
|
+
"""Newline counts as a command separator; ``-w`` on the next line does not bind to gh pr create."""
|
|
759
|
+
assert not hook_module._command_uses_web_flag_in_stripped(
|
|
760
|
+
hook_module._preprocess_command_for_matching(
|
|
761
|
+
"gh pr create --title T\ncurl -w '%{http_code}'"
|
|
762
|
+
)
|
|
763
|
+
)
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def test_command_substitution_with_gh_pr_create_inside_is_still_detected() -> None:
|
|
767
|
+
"""``$(...)`` substitution body executes, so an inner ``gh pr create`` is real."""
|
|
768
|
+
assert hook_module._command_invokes_gh_pr_create_in_stripped(
|
|
769
|
+
hook_module._preprocess_command_for_matching('echo "$(gh pr create --title T)"')
|
|
770
|
+
)
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def test_backtick_substitution_with_gh_pr_create_inside_is_still_detected() -> None:
|
|
774
|
+
"""Backtick substitution body executes, so an inner ``gh pr create`` is real."""
|
|
775
|
+
assert hook_module._command_invokes_gh_pr_create_in_stripped(
|
|
776
|
+
hook_module._preprocess_command_for_matching("echo `gh pr create --title T`")
|
|
777
|
+
)
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def test_write_swap_state_does_not_overwrite_symlink_target(
|
|
781
|
+
isolated_state_directory: pathlib.Path,
|
|
782
|
+
) -> None:
|
|
783
|
+
"""A symlink at the predictable state path must never let the enforcer overwrite the target.
|
|
784
|
+
|
|
785
|
+
On POSIX ``O_NOFOLLOW`` causes the atomic ``os.open`` to fail
|
|
786
|
+
immediately, so ``_write_swap_state`` returns False and the
|
|
787
|
+
attacker's target file is untouched. On Windows ``O_NOFOLLOW`` is
|
|
788
|
+
not exposed, but ``O_EXCL`` still rejects the create against the
|
|
789
|
+
existing symlink — the retry then unlinks the symlink (not the
|
|
790
|
+
target) and writes a fresh state file at the predictable path,
|
|
791
|
+
again leaving the attacker's target untouched.
|
|
792
|
+
|
|
793
|
+
The security guarantee being tested is "the attacker file is not
|
|
794
|
+
written to," which holds on both platforms.
|
|
795
|
+
"""
|
|
796
|
+
if not hasattr(os, "symlink"):
|
|
797
|
+
return
|
|
798
|
+
state_file = hook_module._state_file_path("symlink-attack-session")
|
|
799
|
+
attacker_target_file = isolated_state_directory / "attacker_target.txt"
|
|
800
|
+
untouched_marker_text = "untouched-by-attack"
|
|
801
|
+
attacker_target_file.write_text(untouched_marker_text, encoding="utf-8")
|
|
802
|
+
try:
|
|
803
|
+
os.symlink(attacker_target_file, state_file)
|
|
804
|
+
except (OSError, NotImplementedError):
|
|
805
|
+
return
|
|
806
|
+
hook_module._write_swap_state(
|
|
807
|
+
state_file,
|
|
808
|
+
original_account="jl-cmd",
|
|
809
|
+
primary_account="JonEcho",
|
|
810
|
+
)
|
|
811
|
+
assert attacker_target_file.read_text(encoding="utf-8") == untouched_marker_text
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
def test_write_swap_state_recovers_after_stale_file_collision(
|
|
815
|
+
isolated_state_directory: pathlib.Path,
|
|
816
|
+
) -> None:
|
|
817
|
+
"""A stale file at the predictable path is unlinked and the create retried once."""
|
|
818
|
+
state_file = hook_module._state_file_path("stale-collision-session")
|
|
819
|
+
state_file.write_text("stale-prior-session-contents", encoding="utf-8")
|
|
820
|
+
has_written_state = hook_module._write_swap_state(
|
|
821
|
+
state_file,
|
|
822
|
+
original_account="jl-cmd",
|
|
823
|
+
primary_account="JonEcho",
|
|
824
|
+
)
|
|
825
|
+
assert has_written_state is True
|
|
826
|
+
persisted_state = json.loads(state_file.read_text(encoding="utf-8"))
|
|
827
|
+
assert persisted_state == {
|
|
828
|
+
"original_account": "jl-cmd",
|
|
829
|
+
"primary_account": "JonEcho",
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def test_write_swap_state_loops_through_short_writes(
|
|
834
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
835
|
+
isolated_state_directory: pathlib.Path,
|
|
836
|
+
) -> None:
|
|
837
|
+
"""Regression for finding 1: short os.write returns must not truncate the JSON state file.
|
|
838
|
+
|
|
839
|
+
The fake ``os.write`` writes at most three bytes per call, simulating
|
|
840
|
+
a kernel that signals partial writes. The helper must loop until
|
|
841
|
+
every byte lands on disk so the resulting file holds the complete
|
|
842
|
+
JSON payload and the restore hook can parse it successfully.
|
|
843
|
+
"""
|
|
844
|
+
real_os_write = hook_module.os.write
|
|
845
|
+
|
|
846
|
+
def _short_writer(file_descriptor: int, payload: bytes) -> int:
|
|
847
|
+
return real_os_write(file_descriptor, payload[:3])
|
|
848
|
+
|
|
849
|
+
monkeypatch.setattr(hook_module.os, "write", _short_writer)
|
|
850
|
+
state_file = hook_module._state_file_path("short-write-session")
|
|
851
|
+
has_written_state = hook_module._write_swap_state(
|
|
852
|
+
state_file,
|
|
853
|
+
original_account="jl-cmd",
|
|
854
|
+
primary_account="JonEcho",
|
|
855
|
+
)
|
|
856
|
+
assert has_written_state is True
|
|
857
|
+
persisted_state = json.loads(state_file.read_text(encoding="utf-8"))
|
|
858
|
+
assert persisted_state == {
|
|
859
|
+
"original_account": "jl-cmd",
|
|
860
|
+
"primary_account": "JonEcho",
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def test_write_swap_state_returns_false_when_os_write_keeps_returning_zero(
|
|
865
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
866
|
+
isolated_state_directory: pathlib.Path,
|
|
867
|
+
) -> None:
|
|
868
|
+
"""Regression for finding 1 guard: ``os.write`` returning 0 must terminate as a failure.
|
|
869
|
+
|
|
870
|
+
A descriptor that cannot accept any more bytes signals zero from
|
|
871
|
+
``os.write``. The helper must treat that as a write failure and
|
|
872
|
+
unlink the partially-written file, rather than spinning forever or
|
|
873
|
+
leaving a truncated file on disk.
|
|
874
|
+
"""
|
|
875
|
+
monkeypatch.setattr(hook_module.os, "write", lambda *_args, **_kwargs: 0)
|
|
876
|
+
state_file = hook_module._state_file_path("zero-write-session")
|
|
877
|
+
has_written_state = hook_module._write_swap_state(
|
|
878
|
+
state_file,
|
|
879
|
+
original_account="jl-cmd",
|
|
880
|
+
primary_account="JonEcho",
|
|
881
|
+
)
|
|
882
|
+
assert has_written_state is False
|
|
883
|
+
assert not state_file.exists()
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def test_command_uses_web_flag_ignores_web_inside_substitution_body() -> None:
|
|
887
|
+
"""Regression for finding 4: ``$(echo --web)`` body must not flip the enforcer into browser-flow.
|
|
888
|
+
|
|
889
|
+
``--web`` inside a substitution is an argument to the subshell
|
|
890
|
+
command (``echo``), not a flag on the outer ``gh pr create``. The
|
|
891
|
+
web-flag detector blanks substitution bodies before searching, so
|
|
892
|
+
this command continues to trigger the account swap.
|
|
893
|
+
"""
|
|
894
|
+
assert not hook_module._command_uses_web_flag_in_stripped(
|
|
895
|
+
hook_module._preprocess_command_for_matching(
|
|
896
|
+
'gh pr create --title "$(echo --web)" --body-file B'
|
|
897
|
+
)
|
|
898
|
+
)
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def test_command_uses_web_flag_ignores_web_after_inline_bash_comment() -> None:
|
|
902
|
+
"""Regression for findings 3 & 4: ``# --web`` is a comment and must not match the web flag."""
|
|
903
|
+
assert not hook_module._command_uses_web_flag_in_stripped(
|
|
904
|
+
hook_module._preprocess_command_for_matching(
|
|
905
|
+
"gh pr create --title T # --web"
|
|
906
|
+
)
|
|
907
|
+
)
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def test_active_gh_account_returns_none_on_permission_error(
|
|
911
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
912
|
+
) -> None:
|
|
913
|
+
"""Regression for finding 6: ``PermissionError`` from subprocess.run must not crash the hook."""
|
|
914
|
+
monkeypatch.setattr(
|
|
915
|
+
hook_module.subprocess,
|
|
916
|
+
"run",
|
|
917
|
+
mock.Mock(side_effect=PermissionError("not executable")),
|
|
918
|
+
)
|
|
919
|
+
assert hook_module._active_gh_account() is None
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
def test_active_gh_account_returns_none_on_generic_os_error(
|
|
923
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
924
|
+
) -> None:
|
|
925
|
+
"""Any ``OSError`` subclass from subprocess.run must follow the documented skip path."""
|
|
926
|
+
monkeypatch.setattr(
|
|
927
|
+
hook_module.subprocess,
|
|
928
|
+
"run",
|
|
929
|
+
mock.Mock(side_effect=OSError("spawn refused")),
|
|
930
|
+
)
|
|
931
|
+
assert hook_module._active_gh_account() is None
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
def test_write_swap_state_unlinks_file_when_os_close_raises_after_successful_write(
|
|
935
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
936
|
+
isolated_state_directory: pathlib.Path,
|
|
937
|
+
) -> None:
|
|
938
|
+
"""An ``OSError`` from ``os.close`` after a successful write rolls back the state file.
|
|
939
|
+
|
|
940
|
+
Delayed-writeback filesystems (NFS, FUSE) can surface a write error
|
|
941
|
+
at close time rather than at write time. The helper must treat
|
|
942
|
+
that as a write failure: unlink the partially-written file and
|
|
943
|
+
return False so the caller reverses the gh auth switch.
|
|
944
|
+
"""
|
|
945
|
+
real_os_close = hook_module.os.close
|
|
946
|
+
real_os_write = hook_module.os.write
|
|
947
|
+
write_invocation_counter = {"value": 0}
|
|
948
|
+
|
|
949
|
+
def _counting_os_write(file_descriptor: int, payload: bytes) -> int:
|
|
950
|
+
write_invocation_counter["value"] += 1
|
|
951
|
+
return real_os_write(file_descriptor, payload)
|
|
952
|
+
|
|
953
|
+
def _close_raises_after_successful_write(file_descriptor: int) -> None:
|
|
954
|
+
real_os_close(file_descriptor)
|
|
955
|
+
if write_invocation_counter["value"] > 0:
|
|
956
|
+
raise OSError("delayed writeback failure on close")
|
|
957
|
+
|
|
958
|
+
monkeypatch.setattr(hook_module.os, "write", _counting_os_write)
|
|
959
|
+
monkeypatch.setattr(hook_module.os, "close", _close_raises_after_successful_write)
|
|
960
|
+
state_file = hook_module._state_file_path("close-fail-session")
|
|
961
|
+
has_written_state = hook_module._write_swap_state(
|
|
962
|
+
state_file,
|
|
963
|
+
original_account="jl-cmd",
|
|
964
|
+
primary_account="JonEcho",
|
|
965
|
+
)
|
|
966
|
+
assert has_written_state is False
|
|
967
|
+
assert not state_file.exists()
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
def test_command_invokes_gh_pr_create_matches_if_keyword_prefix() -> None:
|
|
971
|
+
"""Regression for finding 1: ``if gh pr create ...; then`` must trigger the enforcer."""
|
|
972
|
+
assert hook_module._command_invokes_gh_pr_create_in_stripped(
|
|
973
|
+
hook_module._preprocess_command_for_matching(
|
|
974
|
+
"if gh pr create --title T; then echo ok; fi"
|
|
975
|
+
)
|
|
976
|
+
)
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def test_command_invokes_gh_pr_create_matches_then_keyword_prefix() -> None:
|
|
980
|
+
"""Regression for finding 1: ``if foo; then gh pr create`` slipping past needs catching."""
|
|
981
|
+
assert hook_module._command_invokes_gh_pr_create_in_stripped(
|
|
982
|
+
hook_module._preprocess_command_for_matching(
|
|
983
|
+
"if foo; then gh pr create --title T; fi"
|
|
984
|
+
)
|
|
985
|
+
)
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
def test_command_invokes_gh_pr_create_matches_else_keyword_prefix() -> None:
|
|
989
|
+
"""Regression for finding 1: ``else gh pr create`` after an if branch must match."""
|
|
990
|
+
assert hook_module._command_invokes_gh_pr_create_in_stripped(
|
|
991
|
+
hook_module._preprocess_command_for_matching(
|
|
992
|
+
"if foo; then bar; else gh pr create --title T; fi"
|
|
993
|
+
)
|
|
994
|
+
)
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
def test_command_invokes_gh_pr_create_matches_elif_keyword_prefix() -> None:
|
|
998
|
+
"""Regression for finding 1: ``elif`` precedes a real ``gh pr create`` in the same shape."""
|
|
999
|
+
assert hook_module._command_invokes_gh_pr_create_in_stripped(
|
|
1000
|
+
hook_module._preprocess_command_for_matching(
|
|
1001
|
+
"if foo; then bar; elif gh pr create --title T; then ok; fi"
|
|
1002
|
+
)
|
|
1003
|
+
)
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
def test_command_invokes_gh_pr_create_matches_while_keyword_prefix() -> None:
|
|
1007
|
+
"""Regression for finding 1: ``while gh pr create`` loop guard is a real invocation."""
|
|
1008
|
+
assert hook_module._command_invokes_gh_pr_create_in_stripped(
|
|
1009
|
+
hook_module._preprocess_command_for_matching(
|
|
1010
|
+
"while gh pr create --title T; do echo loop; done"
|
|
1011
|
+
)
|
|
1012
|
+
)
|
|
1013
|
+
|
|
1014
|
+
|
|
1015
|
+
def test_command_invokes_gh_pr_create_matches_until_keyword_prefix() -> None:
|
|
1016
|
+
"""Regression for finding 1: ``until gh pr create`` loop guard is a real invocation."""
|
|
1017
|
+
assert hook_module._command_invokes_gh_pr_create_in_stripped(
|
|
1018
|
+
hook_module._preprocess_command_for_matching(
|
|
1019
|
+
"until gh pr create --title T; do sleep 1; done"
|
|
1020
|
+
)
|
|
1021
|
+
)
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
def test_command_invokes_gh_pr_create_matches_do_keyword_prefix() -> None:
|
|
1025
|
+
"""Regression for finding 1: ``for ...; do gh pr create`` body must match."""
|
|
1026
|
+
assert hook_module._command_invokes_gh_pr_create_in_stripped(
|
|
1027
|
+
hook_module._preprocess_command_for_matching(
|
|
1028
|
+
"for tag in T1 T2; do gh pr create --title $tag; done"
|
|
1029
|
+
)
|
|
1030
|
+
)
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
def test_command_invokes_gh_pr_create_matches_bang_negation_prefix() -> None:
|
|
1034
|
+
"""Regression for finding 1: ``! gh pr create`` (negate exit status) is a real invocation."""
|
|
1035
|
+
assert hook_module._command_invokes_gh_pr_create_in_stripped(
|
|
1036
|
+
hook_module._preprocess_command_for_matching(
|
|
1037
|
+
"! gh pr create --title T"
|
|
1038
|
+
)
|
|
1039
|
+
)
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
def test_command_invokes_gh_pr_create_still_rejects_keyword_substring() -> None:
|
|
1043
|
+
"""A bash keyword substring inside a longer identifier must not flip the matcher.
|
|
1044
|
+
|
|
1045
|
+
``notify_then gh pr create`` is a single hyphenated/underscored
|
|
1046
|
+
identifier followed by text; the regex must not detect ``then`` as
|
|
1047
|
+
a real keyword prefix here.
|
|
1048
|
+
"""
|
|
1049
|
+
assert not hook_module._command_invokes_gh_pr_create_in_stripped(
|
|
1050
|
+
hook_module._preprocess_command_for_matching("notify_then gh pr create")
|
|
1051
|
+
)
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
def test_build_state_write_failure_message_describes_rollback_success(
|
|
1055
|
+
isolated_state_directory: pathlib.Path,
|
|
1056
|
+
) -> None:
|
|
1057
|
+
"""Regression for finding 6: the deny text on rollback success names the original account.
|
|
1058
|
+
|
|
1059
|
+
When the reverse ``gh auth switch`` succeeds the message must
|
|
1060
|
+
describe the swap as reversed and tell the user the original
|
|
1061
|
+
account is back in place.
|
|
1062
|
+
"""
|
|
1063
|
+
deny_text = hook_module._build_state_write_failure_message(
|
|
1064
|
+
required_account="JonEcho",
|
|
1065
|
+
current_account="jl-cmd",
|
|
1066
|
+
state_file=isolated_state_directory / "stub_state.json",
|
|
1067
|
+
has_rollback_succeeded=True,
|
|
1068
|
+
)
|
|
1069
|
+
assert "swap was reversed" in deny_text
|
|
1070
|
+
assert "JonEcho" in deny_text
|
|
1071
|
+
assert "jl-cmd" in deny_text
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
def test_build_state_write_failure_message_describes_rollback_failure(
|
|
1075
|
+
isolated_state_directory: pathlib.Path,
|
|
1076
|
+
) -> None:
|
|
1077
|
+
"""Regression for finding 6: the deny text on rollback failure flags the still-swapped state.
|
|
1078
|
+
|
|
1079
|
+
When the reverse switch ALSO fails the message must surface that
|
|
1080
|
+
the user is still on the required account so the user knows the
|
|
1081
|
+
rollback did not succeed and recovery is required.
|
|
1082
|
+
"""
|
|
1083
|
+
deny_text = hook_module._build_state_write_failure_message(
|
|
1084
|
+
required_account="JonEcho",
|
|
1085
|
+
current_account="jl-cmd",
|
|
1086
|
+
state_file=isolated_state_directory / "stub_state.json",
|
|
1087
|
+
has_rollback_succeeded=False,
|
|
1088
|
+
)
|
|
1089
|
+
assert "reverse" in deny_text.lower()
|
|
1090
|
+
assert "ALSO failed" in deny_text
|
|
1091
|
+
assert "still" in deny_text.lower()
|
|
1092
|
+
assert "JonEcho" in deny_text
|
|
1093
|
+
assert "jl-cmd" in deny_text
|
|
1094
|
+
|
|
1095
|
+
|
|
1096
|
+
def test_main_deny_message_names_rollback_failure_when_reverse_switch_fails(
|
|
1097
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
1098
|
+
required_account_jonecho: str,
|
|
1099
|
+
isolated_state_directory: pathlib.Path,
|
|
1100
|
+
) -> None:
|
|
1101
|
+
"""Regression for finding 6: end-to-end check that the deny message branch wires up correctly.
|
|
1102
|
+
|
|
1103
|
+
When state-write fails AND the rollback switch also fails, the
|
|
1104
|
+
deny payload must include the "ALSO failed" language so the user
|
|
1105
|
+
is told the gh CLI is still on ``required_account``.
|
|
1106
|
+
"""
|
|
1107
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO(_make_stdin_payload("gh pr create --title T")))
|
|
1108
|
+
captured_stdout = io.StringIO()
|
|
1109
|
+
monkeypatch.setattr(sys, "stdout", captured_stdout)
|
|
1110
|
+
monkeypatch.setattr(hook_module, "_active_gh_account", lambda: "jl-cmd")
|
|
1111
|
+
monkeypatch.setattr(
|
|
1112
|
+
hook_module,
|
|
1113
|
+
"_write_swap_state",
|
|
1114
|
+
lambda state_file, original_account, primary_account: False,
|
|
1115
|
+
)
|
|
1116
|
+
|
|
1117
|
+
switch_invocations: list[str] = []
|
|
1118
|
+
|
|
1119
|
+
def _fake_switch_first_succeeds_second_fails(to_account: str) -> bool:
|
|
1120
|
+
switch_invocations.append(to_account)
|
|
1121
|
+
return len(switch_invocations) == 1
|
|
1122
|
+
|
|
1123
|
+
monkeypatch.setattr(hook_module, "_switch_gh_account", _fake_switch_first_succeeds_second_fails)
|
|
1124
|
+
|
|
1125
|
+
with pytest.raises(SystemExit):
|
|
1126
|
+
hook_module.main()
|
|
1127
|
+
|
|
1128
|
+
assert switch_invocations == ["JonEcho", "jl-cmd"]
|
|
1129
|
+
payload = json.loads(captured_stdout.getvalue())
|
|
1130
|
+
deny_reason = payload["hookSpecificOutput"]["permissionDecisionReason"]
|
|
1131
|
+
assert "ALSO failed" in deny_reason
|
|
1132
|
+
assert "still" in deny_reason.lower()
|
|
1133
|
+
|
|
1134
|
+
|
|
1135
|
+
def test_main_deny_message_keeps_reversal_language_when_rollback_succeeds(
|
|
1136
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
1137
|
+
required_account_jonecho: str,
|
|
1138
|
+
isolated_state_directory: pathlib.Path,
|
|
1139
|
+
) -> None:
|
|
1140
|
+
"""Regression for finding 6 guard: rollback-success path must NOT carry the failure language."""
|
|
1141
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO(_make_stdin_payload("gh pr create --title T")))
|
|
1142
|
+
captured_stdout = io.StringIO()
|
|
1143
|
+
monkeypatch.setattr(sys, "stdout", captured_stdout)
|
|
1144
|
+
monkeypatch.setattr(hook_module, "_active_gh_account", lambda: "jl-cmd")
|
|
1145
|
+
monkeypatch.setattr(
|
|
1146
|
+
hook_module,
|
|
1147
|
+
"_write_swap_state",
|
|
1148
|
+
lambda state_file, original_account, primary_account: False,
|
|
1149
|
+
)
|
|
1150
|
+
|
|
1151
|
+
switch_invocations: list[str] = []
|
|
1152
|
+
|
|
1153
|
+
def _fake_switch_always_succeeds(to_account: str) -> bool:
|
|
1154
|
+
switch_invocations.append(to_account)
|
|
1155
|
+
return True
|
|
1156
|
+
|
|
1157
|
+
monkeypatch.setattr(hook_module, "_switch_gh_account", _fake_switch_always_succeeds)
|
|
1158
|
+
|
|
1159
|
+
with pytest.raises(SystemExit):
|
|
1160
|
+
hook_module.main()
|
|
1161
|
+
|
|
1162
|
+
assert switch_invocations == ["JonEcho", "jl-cmd"]
|
|
1163
|
+
payload = json.loads(captured_stdout.getvalue())
|
|
1164
|
+
deny_reason = payload["hookSpecificOutput"]["permissionDecisionReason"]
|
|
1165
|
+
assert "swap was reversed" in deny_reason
|
|
1166
|
+
assert "ALSO failed" not in deny_reason
|