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
|
@@ -71,12 +71,15 @@ def _logical_line_has_bare_body_token(logical_line: str) -> bool:
|
|
|
71
71
|
|
|
72
72
|
|
|
73
73
|
def _has_backtick(command: str) -> bool:
|
|
74
|
-
"""Return True if command contains a backtick
|
|
75
|
-
|
|
76
|
-
Joins
|
|
77
|
-
values on later non-continuation lines are
|
|
78
|
-
|
|
79
|
-
backtick
|
|
74
|
+
"""Return True if command contains a literal backtick.
|
|
75
|
+
|
|
76
|
+
Joins bash `` \\ `` continuation lines so backticks in multi-line body
|
|
77
|
+
values on later non-continuation lines are detected as part of the same
|
|
78
|
+
logical command. Under the Bash tool — the only environment this hook
|
|
79
|
+
intercepts — a backtick character is a command-substitution delimiter or
|
|
80
|
+
body content; it is never a PowerShell line-continuation marker. Every
|
|
81
|
+
backtick that survives the bash-continuation join is therefore a literal
|
|
82
|
+
backtick that warrants the --body-file safe pattern.
|
|
80
83
|
|
|
81
84
|
Scans the entire command string for backtick characters, not just --body
|
|
82
85
|
argument content. This is intentionally conservative — any command
|
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse hook: auto-switch the active gh CLI account to GITHUB_DEFAULT_ACCOUNT for `gh pr create`.
|
|
3
|
+
|
|
4
|
+
Pinning every PR to a single canonical author makes the /bugteam and /qbug
|
|
5
|
+
follow-up swap deterministic. Those skills refuse to post REQUEST_CHANGES
|
|
6
|
+
reviews when the active gh CLI account matches the PR author (the GitHub
|
|
7
|
+
API returns HTTP 422 — "cannot review own pull request"). When every PR
|
|
8
|
+
has the same author, the swap step before bugteam is the same single
|
|
9
|
+
command every time.
|
|
10
|
+
|
|
11
|
+
Behavior:
|
|
12
|
+
- No-op when the bash command does not invoke `gh pr create`.
|
|
13
|
+
- No-op when `--web` / `-w` is present, since the browser flow does not
|
|
14
|
+
create the PR via the gh CLI token.
|
|
15
|
+
- No-op when GITHUB_DEFAULT_ACCOUNT is unset (other users without this
|
|
16
|
+
workflow are unaffected).
|
|
17
|
+
- No-op when the active gh account cannot be determined (gh missing,
|
|
18
|
+
network failure) — defers to gh's own error path rather than blocking
|
|
19
|
+
a command that may already be broken for other reasons.
|
|
20
|
+
- No-op when the active gh account already equals GITHUB_DEFAULT_ACCOUNT.
|
|
21
|
+
- Otherwise runs `gh auth switch --user <required>` silently and writes
|
|
22
|
+
a per-session state file recording the original account. The PostToolUse
|
|
23
|
+
companion (gh_pr_author_restore.py) reads that state file and swaps
|
|
24
|
+
back after `gh pr create` finishes. On switch failure the hook falls
|
|
25
|
+
back to the deny payload with the manual command.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import subprocess
|
|
33
|
+
import sys
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
_hooks_tree_path = str(Path(__file__).absolute().parent.parent)
|
|
37
|
+
if _hooks_tree_path not in sys.path:
|
|
38
|
+
sys.path.insert(0, _hooks_tree_path)
|
|
39
|
+
|
|
40
|
+
from _gh_pr_author_swap_utils import ( # noqa: E402 # sys.path shim above must run first
|
|
41
|
+
_all_gh_pr_create_segments,
|
|
42
|
+
_command_invokes_gh_pr_create_in_stripped,
|
|
43
|
+
_delete_state_file,
|
|
44
|
+
_preprocess_command_for_matching,
|
|
45
|
+
_state_file_path,
|
|
46
|
+
_strip_substitution_bodies,
|
|
47
|
+
_switch_gh_account,
|
|
48
|
+
_write_line,
|
|
49
|
+
)
|
|
50
|
+
from config.gh_pr_author_swap_constants import ( # noqa: E402 # sys.path shim above must run first
|
|
51
|
+
ALL_GH_API_USER_COMMAND,
|
|
52
|
+
BASH_TOOL_NAME,
|
|
53
|
+
GH_API_USER_TIMEOUT_SECONDS,
|
|
54
|
+
OS_O_NOFOLLOW_ATTRIBUTE_NAME,
|
|
55
|
+
REQUIRED_ACCOUNT_ENV_VAR,
|
|
56
|
+
STATE_FILE_ORIGINAL_ACCOUNT_KEY,
|
|
57
|
+
STATE_FILE_PAYLOAD_TEXT_ENCODING_NAME,
|
|
58
|
+
STATE_FILE_PERMISSION_MODE,
|
|
59
|
+
STATE_FILE_PRIMARY_ACCOUNT_KEY,
|
|
60
|
+
WEB_FLAG_PATTERN,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _active_gh_account() -> str | None:
|
|
65
|
+
"""Return the login of the active gh CLI account, or None when undetermined.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
The login string from ``gh api user --jq .login`` on success.
|
|
69
|
+
None when gh is missing, the gh binary lacks executable permission,
|
|
70
|
+
the command fails, times out, or returns an empty value.
|
|
71
|
+
``OSError`` covers every spawn-time failure
|
|
72
|
+
(``FileNotFoundError`` when gh is absent, ``PermissionError``
|
|
73
|
+
when gh exists but is not executable, and any other
|
|
74
|
+
platform-specific spawn errors) so the hook follows its
|
|
75
|
+
documented "skip the check" failure path rather than crashing.
|
|
76
|
+
The caller treats None as "skip the check."
|
|
77
|
+
"""
|
|
78
|
+
try:
|
|
79
|
+
completed_process = subprocess.run(
|
|
80
|
+
list(ALL_GH_API_USER_COMMAND),
|
|
81
|
+
capture_output=True,
|
|
82
|
+
text=True,
|
|
83
|
+
timeout=GH_API_USER_TIMEOUT_SECONDS,
|
|
84
|
+
check=False,
|
|
85
|
+
)
|
|
86
|
+
except (OSError, subprocess.SubprocessError):
|
|
87
|
+
return None
|
|
88
|
+
if completed_process.returncode != 0:
|
|
89
|
+
return None
|
|
90
|
+
stripped_login = completed_process.stdout.strip()
|
|
91
|
+
return stripped_login or None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _write_swap_state(
|
|
95
|
+
state_file: Path,
|
|
96
|
+
original_account: str,
|
|
97
|
+
primary_account: str,
|
|
98
|
+
) -> bool:
|
|
99
|
+
"""Persist the swap-back state for the PostToolUse restore hook.
|
|
100
|
+
|
|
101
|
+
The state file is created atomically with ``os.open`` using
|
|
102
|
+
``O_WRONLY | O_CREAT | O_EXCL`` (plus ``O_NOFOLLOW`` on platforms
|
|
103
|
+
that expose it) so an attacker on a shared POSIX workstation cannot
|
|
104
|
+
pre-create the predictable path as a symlink pointing at an
|
|
105
|
+
arbitrary writable file. The mode bits are set at create time so
|
|
106
|
+
the file is never momentarily world-readable between ``open`` and
|
|
107
|
+
``chmod``. A defense-in-depth ``chmod`` call follows the write in
|
|
108
|
+
case the platform's umask honored the ``mode`` argument differently
|
|
109
|
+
than expected.
|
|
110
|
+
|
|
111
|
+
A stale file left by a crashed prior session can collide with the
|
|
112
|
+
``O_EXCL`` guard. The function unlinks such a file and retries the
|
|
113
|
+
create exactly once; a second collision is treated as a write
|
|
114
|
+
failure so the caller does not silently overwrite something it did
|
|
115
|
+
not create.
|
|
116
|
+
|
|
117
|
+
A failure after a successful write unlinks the partially-written
|
|
118
|
+
file via ``_delete_state_file`` before returning False so the caller
|
|
119
|
+
does not leave a world-readable state file behind for the
|
|
120
|
+
SessionStart cleanup hook to later pick up and trigger an unexpected
|
|
121
|
+
``gh auth switch``.
|
|
122
|
+
|
|
123
|
+
Every ``os.close`` call is guarded by ``try``/``except OSError``
|
|
124
|
+
because delayed-writeback filesystems (NFS, FUSE) can surface a
|
|
125
|
+
write error at close time rather than at write time. On the
|
|
126
|
+
post-successful-write branch, an ``OSError`` from ``os.close`` is
|
|
127
|
+
treated as a write failure: the file is unlinked and False is
|
|
128
|
+
returned so the caller rolls back the gh auth switch. On the
|
|
129
|
+
partial-write failure branch the file is already being unlinked,
|
|
130
|
+
so an ``OSError`` from ``os.close`` is swallowed — re-raising
|
|
131
|
+
would crash the hook mid-rollback.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
state_file: Destination path returned by ``_state_file_path``.
|
|
135
|
+
original_account: Login that was active before the swap.
|
|
136
|
+
primary_account: Login swapped to (always ``GITHUB_DEFAULT_ACCOUNT``).
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
True when the atomic create, write, and chmod all succeed.
|
|
140
|
+
False on any filesystem failure. A failure at any stage unlinks
|
|
141
|
+
any partially-written file so the caller does not leave a
|
|
142
|
+
world-readable state file behind.
|
|
143
|
+
"""
|
|
144
|
+
swap_state = {
|
|
145
|
+
STATE_FILE_ORIGINAL_ACCOUNT_KEY: original_account,
|
|
146
|
+
STATE_FILE_PRIMARY_ACCOUNT_KEY: primary_account,
|
|
147
|
+
}
|
|
148
|
+
serialized_payload = json.dumps(swap_state).encode(STATE_FILE_PAYLOAD_TEXT_ENCODING_NAME)
|
|
149
|
+
open_flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
|
150
|
+
if hasattr(os, OS_O_NOFOLLOW_ATTRIBUTE_NAME):
|
|
151
|
+
open_flags |= os.O_NOFOLLOW # type: ignore[attr-defined] # POSIX-only flag, guarded by hasattr above
|
|
152
|
+
file_descriptor = _open_state_file_with_retry(state_file, open_flags)
|
|
153
|
+
if file_descriptor is None:
|
|
154
|
+
return False
|
|
155
|
+
if not _write_payload_completely(file_descriptor, serialized_payload):
|
|
156
|
+
try:
|
|
157
|
+
os.close(file_descriptor)
|
|
158
|
+
except OSError:
|
|
159
|
+
pass
|
|
160
|
+
_delete_state_file(state_file)
|
|
161
|
+
return False
|
|
162
|
+
try:
|
|
163
|
+
os.close(file_descriptor)
|
|
164
|
+
except OSError:
|
|
165
|
+
_delete_state_file(state_file)
|
|
166
|
+
return False
|
|
167
|
+
try:
|
|
168
|
+
os.chmod(state_file, STATE_FILE_PERMISSION_MODE)
|
|
169
|
+
except OSError:
|
|
170
|
+
_delete_state_file(state_file)
|
|
171
|
+
return False
|
|
172
|
+
return True
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _write_payload_completely(file_descriptor: int, serialized_payload: bytes) -> bool:
|
|
176
|
+
"""Write every byte of ``serialized_payload`` to ``file_descriptor``.
|
|
177
|
+
|
|
178
|
+
``os.write`` is documented to potentially write fewer bytes than
|
|
179
|
+
requested, especially on pipes and non-blocking descriptors. The
|
|
180
|
+
state file is opened in blocking mode, but the contract holds — a
|
|
181
|
+
signal arriving mid-write can cut a write short, and partial writes
|
|
182
|
+
have been observed across NFS mounts and FUSE filesystems. Treating
|
|
183
|
+
the first ``os.write`` return value as authoritative would leave a
|
|
184
|
+
truncated JSON state file on disk; the restore hook would then
|
|
185
|
+
parse the truncated file, log "malformed state file", delete it,
|
|
186
|
+
and leave the active gh CLI account stranded on the canonical
|
|
187
|
+
author instead of restoring the original account.
|
|
188
|
+
|
|
189
|
+
The loop reissues ``os.write`` against a slice of the payload that
|
|
190
|
+
starts at the byte count already written, until every byte has been
|
|
191
|
+
emitted. A return value of zero from ``os.write`` indicates the
|
|
192
|
+
underlying file descriptor cannot accept any more bytes and is
|
|
193
|
+
treated as a write failure so the caller can roll back rather than
|
|
194
|
+
spin forever.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
file_descriptor: Open file descriptor returned by ``os.open``.
|
|
198
|
+
The caller retains ownership and is responsible for closing
|
|
199
|
+
the descriptor whether this function returns True or False.
|
|
200
|
+
serialized_payload: Encoded JSON payload to write. The caller
|
|
201
|
+
encodes via ``STATE_FILE_PAYLOAD_TEXT_ENCODING_NAME`` before
|
|
202
|
+
invoking this helper.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
True when every byte of ``serialized_payload`` was written.
|
|
206
|
+
False on any ``OSError`` raised by ``os.write`` or when
|
|
207
|
+
``os.write`` returns zero before the payload is complete.
|
|
208
|
+
"""
|
|
209
|
+
payload_length = len(serialized_payload)
|
|
210
|
+
bytes_already_written = 0
|
|
211
|
+
while bytes_already_written < payload_length:
|
|
212
|
+
try:
|
|
213
|
+
bytes_just_written = os.write(
|
|
214
|
+
file_descriptor,
|
|
215
|
+
serialized_payload[bytes_already_written:],
|
|
216
|
+
)
|
|
217
|
+
except OSError:
|
|
218
|
+
return False
|
|
219
|
+
if bytes_just_written == 0:
|
|
220
|
+
return False
|
|
221
|
+
bytes_already_written += bytes_just_written
|
|
222
|
+
return True
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _open_state_file_with_retry(state_file: Path, open_flags: int) -> int | None:
|
|
226
|
+
"""Open the state file atomically, unlinking a stale collision once.
|
|
227
|
+
|
|
228
|
+
The enforcer can race against a state file left behind by a prior
|
|
229
|
+
crashed session at the same predictable path. The first ``O_EXCL``
|
|
230
|
+
open raises ``FileExistsError`` in that case; the function unlinks
|
|
231
|
+
the stale file and retries exactly once. A second ``FileExistsError``
|
|
232
|
+
is treated as a genuine race against a concurrent process and
|
|
233
|
+
surfaces as ``None`` so the caller can fall back to its deny path.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
state_file: Destination path returned by ``_state_file_path``.
|
|
237
|
+
open_flags: Bitmask passed to ``os.open`` — must include
|
|
238
|
+
``O_EXCL`` so this retry logic can distinguish "stale file
|
|
239
|
+
collision" from "wrote a fresh file".
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
A file descriptor on success. ``None`` when both the initial
|
|
243
|
+
open and the post-unlink retry fail.
|
|
244
|
+
"""
|
|
245
|
+
try:
|
|
246
|
+
return os.open(state_file, open_flags, STATE_FILE_PERMISSION_MODE)
|
|
247
|
+
except FileExistsError:
|
|
248
|
+
try:
|
|
249
|
+
state_file.unlink()
|
|
250
|
+
except OSError:
|
|
251
|
+
return None
|
|
252
|
+
except OSError:
|
|
253
|
+
return None
|
|
254
|
+
try:
|
|
255
|
+
return os.open(state_file, open_flags, STATE_FILE_PERMISSION_MODE)
|
|
256
|
+
except OSError:
|
|
257
|
+
return None
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _build_switch_failure_message(required_account: str, current_account: str) -> str:
|
|
261
|
+
"""Build the deny reason emitted when the silent auto-switch fails.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
required_account: Value of GITHUB_DEFAULT_ACCOUNT.
|
|
265
|
+
current_account: Login returned by gh before the failed switch.
|
|
266
|
+
|
|
267
|
+
Returns:
|
|
268
|
+
A multi-line corrective message naming both accounts and giving
|
|
269
|
+
the exact ``gh auth switch`` command the user should run.
|
|
270
|
+
"""
|
|
271
|
+
return (
|
|
272
|
+
f"BLOCKED [gh-pr-author]: tried to auto-switch the active gh CLI "
|
|
273
|
+
f"account from `{current_account}` to `{required_account}` so "
|
|
274
|
+
f"`gh pr create` would author from the canonical account, but "
|
|
275
|
+
f"`gh auth switch` failed.\n\n"
|
|
276
|
+
f" Current: {current_account}\n"
|
|
277
|
+
f" Required: {required_account} (from ${REQUIRED_ACCOUNT_ENV_VAR})\n\n"
|
|
278
|
+
f"Run first:\n"
|
|
279
|
+
f" gh auth switch --user {required_account}\n\n"
|
|
280
|
+
f"If you genuinely want to author this PR from a different account "
|
|
281
|
+
f"in this one case, switch to that account and retry. To create the "
|
|
282
|
+
f"PR through the browser instead (uses your browser's GitHub session, "
|
|
283
|
+
f"not the gh CLI token), add `--web`."
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _build_state_write_failure_message(
|
|
288
|
+
required_account: str,
|
|
289
|
+
current_account: str,
|
|
290
|
+
state_file: Path,
|
|
291
|
+
has_rollback_succeeded: bool,
|
|
292
|
+
) -> str:
|
|
293
|
+
"""Build the deny reason emitted when state-file persistence fails after a successful swap.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
required_account: Value of GITHUB_DEFAULT_ACCOUNT (the swap target).
|
|
297
|
+
current_account: Login that was active before the swap (the
|
|
298
|
+
restore target the failed state file should have recorded).
|
|
299
|
+
state_file: Path the enforcer tried and failed to write.
|
|
300
|
+
has_rollback_succeeded: True when the reverse ``gh auth switch``
|
|
301
|
+
back to ``current_account`` returned zero, so the user is
|
|
302
|
+
on the original account again. False when the reverse switch
|
|
303
|
+
also failed and the user is still on ``required_account``.
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
A multi-line corrective message explaining the swap and
|
|
307
|
+
rollback outcome. The lead-in sentence describes the actual
|
|
308
|
+
state — "the swap was reversed" when the rollback succeeded,
|
|
309
|
+
"the reverse-switch also failed and you are still on
|
|
310
|
+
``required_account``" when it did not. The trailing
|
|
311
|
+
``gh auth status`` / ``gh auth switch`` recovery commands are
|
|
312
|
+
emitted in both branches so the user can verify and recover
|
|
313
|
+
regardless of where the gh CLI ended up.
|
|
314
|
+
"""
|
|
315
|
+
if has_rollback_succeeded:
|
|
316
|
+
rollback_outcome_sentence = (
|
|
317
|
+
f"The swap was reversed to put `{current_account}` back in place, "
|
|
318
|
+
f"and `gh pr create` is being denied to prevent leaving the "
|
|
319
|
+
f"workflow in an inconsistent state."
|
|
320
|
+
)
|
|
321
|
+
else:
|
|
322
|
+
rollback_outcome_sentence = (
|
|
323
|
+
f"The reverse `gh auth switch` to put `{current_account}` back "
|
|
324
|
+
f"in place ALSO failed, so the active gh CLI account is still "
|
|
325
|
+
f"`{required_account}`. `gh pr create` is being denied so the "
|
|
326
|
+
f"user can recover the original account before re-running."
|
|
327
|
+
)
|
|
328
|
+
return (
|
|
329
|
+
f"BLOCKED [gh-pr-author]: swapped the active gh CLI account "
|
|
330
|
+
f"from `{current_account}` to `{required_account}` so "
|
|
331
|
+
f"`gh pr create` would author from the canonical account, but "
|
|
332
|
+
f"writing the per-session state file used to restore the prior "
|
|
333
|
+
f"account afterward failed. {rollback_outcome_sentence}\n\n"
|
|
334
|
+
f" Original: {current_account}\n"
|
|
335
|
+
f" Required: {required_account} (from ${REQUIRED_ACCOUNT_ENV_VAR})\n"
|
|
336
|
+
f" State file (failed): {state_file}\n\n"
|
|
337
|
+
f"Verify the active account and recover manually:\n"
|
|
338
|
+
f" gh auth status\n"
|
|
339
|
+
f" gh auth switch --user {current_account}\n\n"
|
|
340
|
+
f"Then re-run `gh pr create` so the enforcer can retry the swap."
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _command_uses_web_flag_in_stripped(preprocessed_command: str) -> bool:
|
|
345
|
+
"""Return True when EVERY ``gh pr create`` segment uses ``--web`` / ``-w``.
|
|
346
|
+
|
|
347
|
+
The flag is only relevant when it modifies the ``gh pr create``
|
|
348
|
+
invocation itself. A ``-w`` token belonging to an unrelated command
|
|
349
|
+
(for example ``curl -w '%{http_code}'``) before ``gh pr create``, or
|
|
350
|
+
a flag attached to a chained command after a separator like ``&&`` /
|
|
351
|
+
``||`` / ``;`` / ``|`` / newline, must not flip the enforcer into
|
|
352
|
+
the browser-flow no-op path. A ``-w`` sitting inside a quoted
|
|
353
|
+
argument (for example ``--body "see -w docs"``) likewise must not
|
|
354
|
+
match — the caller blanks those out via
|
|
355
|
+
``_preprocess_command_for_matching`` before passing the command in
|
|
356
|
+
here.
|
|
357
|
+
|
|
358
|
+
A ``--web`` token sitting inside a substitution body (for example
|
|
359
|
+
``gh pr create --title "$(echo --web)"``) is an argument to the
|
|
360
|
+
subshell command, not a flag on the outer ``gh pr create`` —
|
|
361
|
+
``_strip_substitution_bodies`` blanks the substitution before the
|
|
362
|
+
segment search so the false-positive does not skip the swap. The
|
|
363
|
+
safety bias is intentional: a substitution that genuinely expands
|
|
364
|
+
to ``--web`` is now treated as a non-web invocation and still
|
|
365
|
+
triggers the account swap, which is harmless because ``gh pr create``
|
|
366
|
+
with both ``--web`` and the canonical author swapped in just runs
|
|
367
|
+
the browser flow.
|
|
368
|
+
|
|
369
|
+
When the command chains multiple ``gh pr create`` invocations
|
|
370
|
+
(``gh pr create --web && gh pr create --title T``), the enforcer
|
|
371
|
+
must trigger as long as ANY of them omits the web flag — otherwise
|
|
372
|
+
the second invocation would slip through under the active account.
|
|
373
|
+
A short-circuiting ``all()`` over every segment gives that
|
|
374
|
+
"browser-flow only when EVERY segment opts in" semantics.
|
|
375
|
+
|
|
376
|
+
Args:
|
|
377
|
+
preprocessed_command: Output of ``_preprocess_command_for_matching`` —
|
|
378
|
+
the caller is responsible for blanking inert quoted regions
|
|
379
|
+
and bash comments before passing in. ``main()`` computes
|
|
380
|
+
this once and passes it to both this helper and
|
|
381
|
+
``_command_invokes_gh_pr_create_in_stripped`` so the
|
|
382
|
+
character-walk preprocessing runs exactly once per command.
|
|
383
|
+
|
|
384
|
+
Returns:
|
|
385
|
+
True when every ``gh pr create`` segment in the preprocessed
|
|
386
|
+
command carries ``--web`` or ``-w`` as a whole token. False
|
|
387
|
+
when ``gh pr create`` is absent, or when any segment lacks the
|
|
388
|
+
flag (including segments whose only ``--web`` token sat inside
|
|
389
|
+
a substitution body that has now been blanked).
|
|
390
|
+
"""
|
|
391
|
+
all_gh_pr_create_segments = _all_gh_pr_create_segments(preprocessed_command)
|
|
392
|
+
if not all_gh_pr_create_segments:
|
|
393
|
+
return False
|
|
394
|
+
return all(
|
|
395
|
+
bool(WEB_FLAG_PATTERN.search(_strip_substitution_bodies(each_segment)))
|
|
396
|
+
for each_segment in all_gh_pr_create_segments
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _emit_deny_payload(reason_text: str) -> None:
|
|
401
|
+
"""Write the JSON deny payload to stdout for Claude Code to consume.
|
|
402
|
+
|
|
403
|
+
Args:
|
|
404
|
+
reason_text: User-facing explanation displayed by Claude Code.
|
|
405
|
+
"""
|
|
406
|
+
deny_payload = {
|
|
407
|
+
"hookSpecificOutput": {
|
|
408
|
+
"hookEventName": "PreToolUse",
|
|
409
|
+
"permissionDecision": "deny",
|
|
410
|
+
"permissionDecisionReason": reason_text,
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
_write_line(json.dumps(deny_payload), sys.stdout)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def main() -> None:
|
|
417
|
+
"""Read PreToolUse hook input on stdin and auto-switch the gh account when warranted.
|
|
418
|
+
|
|
419
|
+
Exits 0 in all paths. On the silent-switch success path no output is
|
|
420
|
+
produced. On switch-failure the JSON deny payload is written to
|
|
421
|
+
stdout. On every no-op condition nothing is written.
|
|
422
|
+
"""
|
|
423
|
+
try:
|
|
424
|
+
hook_input = json.load(sys.stdin)
|
|
425
|
+
except json.JSONDecodeError:
|
|
426
|
+
sys.exit(0)
|
|
427
|
+
|
|
428
|
+
if hook_input.get("tool_name") != BASH_TOOL_NAME:
|
|
429
|
+
sys.exit(0)
|
|
430
|
+
|
|
431
|
+
command = hook_input.get("tool_input", {}).get("command", "")
|
|
432
|
+
if not command:
|
|
433
|
+
sys.exit(0)
|
|
434
|
+
|
|
435
|
+
preprocessed_command = _preprocess_command_for_matching(command)
|
|
436
|
+
if not _command_invokes_gh_pr_create_in_stripped(preprocessed_command):
|
|
437
|
+
sys.exit(0)
|
|
438
|
+
|
|
439
|
+
if _command_uses_web_flag_in_stripped(preprocessed_command):
|
|
440
|
+
sys.exit(0)
|
|
441
|
+
|
|
442
|
+
required_account = os.environ.get(REQUIRED_ACCOUNT_ENV_VAR, "").strip()
|
|
443
|
+
if not required_account:
|
|
444
|
+
sys.exit(0)
|
|
445
|
+
|
|
446
|
+
current_account = _active_gh_account()
|
|
447
|
+
if current_account is None:
|
|
448
|
+
sys.exit(0)
|
|
449
|
+
if current_account.casefold() == required_account.casefold():
|
|
450
|
+
sys.exit(0)
|
|
451
|
+
|
|
452
|
+
has_switched_account = _switch_gh_account(required_account)
|
|
453
|
+
if not has_switched_account:
|
|
454
|
+
_emit_deny_payload(
|
|
455
|
+
_build_switch_failure_message(required_account, current_account)
|
|
456
|
+
)
|
|
457
|
+
sys.exit(0)
|
|
458
|
+
|
|
459
|
+
session_id = str(hook_input.get("session_id") or "")
|
|
460
|
+
state_file = _state_file_path(session_id)
|
|
461
|
+
has_written_state = _write_swap_state(
|
|
462
|
+
state_file,
|
|
463
|
+
original_account=current_account,
|
|
464
|
+
primary_account=required_account,
|
|
465
|
+
)
|
|
466
|
+
if not has_written_state:
|
|
467
|
+
has_rollback_succeeded = _switch_gh_account(current_account)
|
|
468
|
+
_emit_deny_payload(
|
|
469
|
+
_build_state_write_failure_message(
|
|
470
|
+
required_account,
|
|
471
|
+
current_account,
|
|
472
|
+
state_file,
|
|
473
|
+
has_rollback_succeeded,
|
|
474
|
+
)
|
|
475
|
+
)
|
|
476
|
+
sys.exit(0)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
if __name__ == "__main__":
|
|
480
|
+
main()
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PostToolUse hook: restore the prior gh CLI account after `gh pr create` runs.
|
|
3
|
+
|
|
4
|
+
Companion to ``gh_pr_author_enforcer.py``. When the PreToolUse enforcer
|
|
5
|
+
silently swaps the active gh account to ``GITHUB_DEFAULT_ACCOUNT`` and
|
|
6
|
+
records the original account in a per-session state file, this hook
|
|
7
|
+
reads that state file after the matching Bash invocation finishes and
|
|
8
|
+
runs ``gh auth switch --user <original>`` to put the prior account back
|
|
9
|
+
in place.
|
|
10
|
+
|
|
11
|
+
The state file is deleted only when the restore switch succeeds. If
|
|
12
|
+
``gh auth switch`` fails the state file is left in place so the
|
|
13
|
+
SessionStart cleanup hook (``gh_pr_author_session_cleanup.py``) can
|
|
14
|
+
retry on the next session start instead of stranding the user on the
|
|
15
|
+
canonical author account.
|
|
16
|
+
|
|
17
|
+
Behavior:
|
|
18
|
+
- No-op when tool_name is not Bash.
|
|
19
|
+
- No-op when the command did not invoke ``gh pr create`` (uses the same
|
|
20
|
+
regex as the enforcer so the pair stays in sync).
|
|
21
|
+
- No-op when no per-session state file exists — means the enforcer
|
|
22
|
+
never swapped on this command.
|
|
23
|
+
- Otherwise reads the state file, runs ``gh auth switch --user <original>``,
|
|
24
|
+
and deletes the state file only when the switch succeeded. Failures
|
|
25
|
+
are logged to stderr; this hook never blocks the workflow.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
import sys
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
_hooks_tree_path = str(Path(__file__).absolute().parent.parent)
|
|
35
|
+
if _hooks_tree_path not in sys.path:
|
|
36
|
+
sys.path.insert(0, _hooks_tree_path)
|
|
37
|
+
|
|
38
|
+
from _gh_pr_author_swap_utils import ( # noqa: E402 # sys.path shim above must run first
|
|
39
|
+
_command_invokes_gh_pr_create_in_stripped,
|
|
40
|
+
_delete_state_file,
|
|
41
|
+
_preprocess_command_for_matching,
|
|
42
|
+
_read_original_account,
|
|
43
|
+
_state_file_is_attacker_planted,
|
|
44
|
+
_state_file_path,
|
|
45
|
+
_switch_gh_account,
|
|
46
|
+
_write_line,
|
|
47
|
+
)
|
|
48
|
+
from config.gh_pr_author_swap_constants import BASH_TOOL_NAME # noqa: E402 # sys.path shim above must run first
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def main() -> None:
|
|
52
|
+
"""Read PostToolUse hook input on stdin and restore the prior gh account.
|
|
53
|
+
|
|
54
|
+
Exits 0 in every path. Errors are logged to stderr only — this hook
|
|
55
|
+
must never block subsequent commands.
|
|
56
|
+
"""
|
|
57
|
+
try:
|
|
58
|
+
hook_input = json.load(sys.stdin)
|
|
59
|
+
except json.JSONDecodeError:
|
|
60
|
+
sys.exit(0)
|
|
61
|
+
|
|
62
|
+
if hook_input.get("tool_name") != BASH_TOOL_NAME:
|
|
63
|
+
sys.exit(0)
|
|
64
|
+
|
|
65
|
+
command = hook_input.get("tool_input", {}).get("command", "")
|
|
66
|
+
if not command:
|
|
67
|
+
sys.exit(0)
|
|
68
|
+
preprocessed_command = _preprocess_command_for_matching(command)
|
|
69
|
+
if not _command_invokes_gh_pr_create_in_stripped(preprocessed_command):
|
|
70
|
+
sys.exit(0)
|
|
71
|
+
|
|
72
|
+
session_id = str(hook_input.get("session_id") or "")
|
|
73
|
+
state_file = _state_file_path(session_id)
|
|
74
|
+
if _state_file_is_attacker_planted(state_file):
|
|
75
|
+
_write_line(
|
|
76
|
+
f"[gh-pr-author-restore] state file at {state_file} has unexpected mode/owner; "
|
|
77
|
+
f"skipping restore and preserving file for inspection",
|
|
78
|
+
sys.stderr,
|
|
79
|
+
)
|
|
80
|
+
sys.exit(0)
|
|
81
|
+
original_account = _read_original_account(state_file)
|
|
82
|
+
if original_account is None:
|
|
83
|
+
if state_file.exists():
|
|
84
|
+
_delete_state_file(state_file)
|
|
85
|
+
sys.exit(0)
|
|
86
|
+
|
|
87
|
+
has_restored_account = _switch_gh_account(original_account)
|
|
88
|
+
if has_restored_account:
|
|
89
|
+
_delete_state_file(state_file)
|
|
90
|
+
else:
|
|
91
|
+
_write_line(
|
|
92
|
+
f"[gh-pr-author-restore] failed to restore active gh account to {original_account!r}; "
|
|
93
|
+
f"state file {state_file} left in place so the SessionStart cleanup hook can retry",
|
|
94
|
+
sys.stderr,
|
|
95
|
+
)
|
|
96
|
+
sys.exit(0)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
if __name__ == "__main__":
|
|
100
|
+
main()
|