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.
Files changed (66) hide show
  1. package/CLAUDE.md +9 -1
  2. package/_shared/pr-loop/scripts/_claude_permissions_common.py +231 -3
  3. package/_shared/pr-loop/scripts/config/claude_permissions_constants.py +56 -2
  4. package/_shared/pr-loop/scripts/config/claude_settings_keys_constants.py +2 -0
  5. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +173 -6
  6. package/_shared/pr-loop/scripts/post_audit_thread.py +2 -2
  7. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +135 -14
  8. package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +385 -0
  9. package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +33 -0
  10. package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +1 -1
  11. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +4 -2
  12. package/hooks/_gh_pr_author_swap_utils.py +1211 -0
  13. package/hooks/blocking/gh_body_arg_blocker.py +9 -6
  14. package/hooks/blocking/gh_pr_author_enforcer.py +480 -0
  15. package/hooks/blocking/gh_pr_author_restore.py +100 -0
  16. package/hooks/blocking/pr_converge_bugteam_enforcer.py +170 -0
  17. package/hooks/blocking/pr_description_enforcer.py +1 -3
  18. package/hooks/blocking/test_gh_body_arg_blocker.py +25 -3
  19. package/hooks/blocking/test_gh_pr_author_enforcer.py +1166 -0
  20. package/hooks/blocking/test_gh_pr_author_restore.py +512 -0
  21. package/hooks/blocking/test_gh_pr_author_swap_utils.py +910 -0
  22. package/hooks/blocking/test_pr_converge_bugteam_enforcer.py +311 -0
  23. package/hooks/config/gh_pr_author_swap_constants.py +76 -0
  24. package/hooks/config/pr_converge_bugteam_enforcer_constants.py +55 -0
  25. package/hooks/config/pr_converge_bugteam_enforcer_state.py +67 -0
  26. package/hooks/config/pr_description_enforcer_constants.py +5 -0
  27. package/hooks/config/test_pr_description_enforcer_constants.py +82 -0
  28. package/hooks/hooks.json +40 -0
  29. package/hooks/lifecycle/pr_converge_bugteam_skill_tracker.py +204 -0
  30. package/hooks/lifecycle/test_pr_converge_bugteam_skill_tracker.py +283 -0
  31. package/hooks/session/gh_pr_author_session_cleanup.py +171 -0
  32. package/hooks/session/test_gh_pr_author_session_cleanup.py +575 -0
  33. package/hooks/test__gh_pr_author_swap_utils.py +333 -0
  34. package/package.json +1 -1
  35. package/skills/_shared/pr-loop/scripts/write_audit_outcomes.py +2 -2
  36. package/skills/_shared/pr-loop/scripts/write_fix_outcomes.py +2 -2
  37. package/skills/bugteam/reference/audit-contract.md +22 -0
  38. package/skills/bugteam/reference/github-pr-reviews.md +1 -1
  39. package/skills/bugteam/scripts/_claude_permissions_common.py +109 -0
  40. package/skills/bugteam/scripts/bugteam_fix_hookspath.py +8 -2
  41. package/skills/bugteam/scripts/config/claude_permissions_common_constants.py +51 -2
  42. package/skills/bugteam/scripts/grant_project_claude_permissions.py +115 -4
  43. package/skills/bugteam/scripts/revoke_project_claude_permissions.py +69 -17
  44. package/skills/bugteam/scripts/test__claude_permissions_common.py +48 -0
  45. package/skills/bugteam/scripts/test_agent_config_carveout.py +356 -0
  46. package/skills/bugteam/scripts/test_claude_permissions_common.py +18 -10
  47. package/skills/implement/SKILL.md +66 -0
  48. package/skills/implement/scripts/append_note.py +133 -0
  49. package/skills/implement/scripts/config/__init__.py +0 -0
  50. package/skills/implement/scripts/config/notes_constants.py +12 -0
  51. package/skills/implement/scripts/test_append_note.py +191 -0
  52. package/skills/pr-converge/SKILL.md +8 -2
  53. package/skills/pr-converge/config/constants.py +7 -1
  54. package/skills/pr-converge/reference/state-schema.md +36 -8
  55. package/skills/pr-converge/scripts/check_bugbot_ci.py +1 -1
  56. package/skills/pr-converge/scripts/check_convergence.py +167 -28
  57. package/skills/pr-converge/scripts/check_pending_reviews.py +1 -1
  58. package/skills/pr-converge/scripts/conftest.py +60 -0
  59. package/skills/pr-converge/scripts/fetch_copilot_reviews.py +1 -1
  60. package/skills/pr-converge/scripts/post_fix_reply.py +1 -1
  61. package/skills/pr-converge/scripts/test_check_bugbot_ci.py +1 -1
  62. package/skills/pr-converge/scripts/test_check_convergence.py +306 -0
  63. package/skills/pr-converge/scripts/test_evict_cached_config_modules.py +1 -1
  64. package/skills/refine/SKILL.md +257 -0
  65. package/skills/refine/templates/implementation-notes-template.html +56 -0
  66. 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 that is not a bash continuation.
75
-
76
- Joins all bash `` \\ `` continuation lines so backticks in multi-line body
77
- values on later non-continuation lines are not missed. Only strips bash
78
- continuations this hook runs on the Bash tool, and PowerShell-style
79
- backtick continuations are not continuation markers in bash.
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()