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