claude-dev-env 4.1.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 (89) 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/everything-search/SKILL.md +18 -3
  6. package/.agents/skills/everything-search/scripts/everything_search.py +204 -0
  7. package/.agents/skills/everything-search/scripts/everything_search_command_constants/__init__.py +1 -0
  8. package/.agents/skills/everything-search/scripts/everything_search_command_constants/config/__init__.py +1 -0
  9. package/.agents/skills/everything-search/scripts/everything_search_command_constants/config/constants.py +11 -0
  10. package/.agents/skills/everything-search/scripts/test_everything_search.py +172 -0
  11. package/.agents/skills/issue-tracker/SKILL.md +6 -0
  12. package/.agents/skills/pull-request/SKILL.md +218 -0
  13. package/.agents/skills/pull-request/reference/publication-tasks.md +18 -0
  14. package/.agents/skills/pull-request/scripts/github_pr_command_constants/__init__.py +1 -0
  15. package/.agents/skills/pull-request/scripts/github_pr_command_constants/config/__init__.py +1 -0
  16. package/.agents/skills/pull-request/scripts/github_pr_command_constants/config/constants.py +61 -0
  17. package/.agents/skills/pull-request/scripts/pull_request.py +276 -0
  18. package/.agents/skills/pull-request/scripts/recover_legacy_author.py +251 -0
  19. package/.agents/skills/pull-request/scripts/test_github_pr_command_constants.py +49 -0
  20. package/.agents/skills/pull-request/scripts/test_pull_request.py +398 -0
  21. package/.agents/skills/pull-request/scripts/test_recover_legacy_author.py +221 -0
  22. package/.agents/skills/test-runner/SKILL.md +69 -0
  23. package/.agents/skills/test-runner/scripts/preflight_checks.py +365 -0
  24. package/.agents/skills/test-runner/scripts/run_tests.py +316 -0
  25. package/.agents/skills/test-runner/scripts/test_preflight_checks.py +185 -0
  26. package/.agents/skills/test-runner/scripts/test_run_tests.py +281 -0
  27. package/.agents/skills/test-runner/scripts/test_runner_constants/__init__.py +1 -0
  28. package/.agents/skills/test-runner/scripts/test_runner_constants/config/__init__.py +1 -0
  29. package/.agents/skills/test-runner/scripts/test_runner_constants/config/constants.py +96 -0
  30. package/.agents/skills-archived/pr-loop-cloud-transport/reference/identity-and-hooks.md +1 -1
  31. package/bin/ever-shipped-skills.mjs +2 -0
  32. package/bin/ever-shipped-skills.test.mjs +8 -0
  33. package/bin/install.mjs +9 -1
  34. package/bin/install.prune.test.mjs +149 -0
  35. package/bin/install.test.mjs +24 -7
  36. package/hooks/blocking/pii_prevention_blocker.py +32 -51
  37. package/hooks/blocking/test_bash_post_call_dispatcher.py +5 -28
  38. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +1 -4
  39. package/hooks/blocking/test_code_rules_command_dispatch.py +2 -2
  40. package/hooks/blocking/test_pii_prevention_blocker.py +37 -89
  41. package/hooks/gh_post_body_texts.py +195 -0
  42. package/hooks/hooks.json +0 -15
  43. package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +0 -1
  44. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +5 -16
  45. package/hooks/hooks_constants/gh_post_body_texts_constants.py +12 -0
  46. package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +0 -7
  47. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +7 -20
  48. package/hooks/hooks_constants/test_failure_recorder_constants.py +3 -3
  49. package/hooks/hooks_constants/test_gh_post_body_texts_constants.py +19 -0
  50. package/hooks/observability/test_failure_recorder.py +1 -1
  51. package/hooks/pyproject.toml +1 -1
  52. package/hooks/test_gh_post_body_texts.py +79 -0
  53. package/package.json +1 -1
  54. package/rules/durable-post-artifacts.md +31 -20
  55. package/rules/filesystem-search.md +5 -5
  56. package/rules/gh-cli-conventions.md +23 -13
  57. package/rules/git-workflow.md +21 -8
  58. package/scripts/policy_lint/adapter_configuration.py +53 -12
  59. package/scripts/repository_checks/config/constants.py +0 -5
  60. package/scripts/tests/test_adapter_configuration.py +54 -0
  61. package/hooks/_gh_pr_author_swap_utils.py +0 -1225
  62. package/hooks/blocking/conventional_pr_title_gate.py +0 -443
  63. package/hooks/blocking/es_exe_path_rewriter.py +0 -159
  64. package/hooks/blocking/gh_body_arg_blocker.py +0 -176
  65. package/hooks/blocking/gh_pr_author_enforcer.py +0 -487
  66. package/hooks/blocking/gh_pr_author_restore.py +0 -105
  67. package/hooks/blocking/pr_description_writer_gate.py +0 -256
  68. package/hooks/blocking/test_conventional_pr_title_gate.py +0 -640
  69. package/hooks/blocking/test_es_exe_path_rewriter.py +0 -447
  70. package/hooks/blocking/test_gh_body_arg_blocker.py +0 -441
  71. package/hooks/blocking/test_gh_pr_author_enforcer.py +0 -1166
  72. package/hooks/blocking/test_gh_pr_author_restore.py +0 -513
  73. package/hooks/blocking/test_gh_pr_author_swap_utils.py +0 -910
  74. package/hooks/blocking/test_pr_description_writer_gate.py +0 -234
  75. package/hooks/blocking/test_preflight_check.py +0 -343
  76. package/hooks/blocking/test_test_preflight_check.py +0 -80
  77. package/hooks/blocking/test_volatile_path_in_post_blocker.py +0 -322
  78. package/hooks/blocking/volatile_path_in_post_blocker.py +0 -461
  79. package/hooks/hooks_constants/conventional_pr_title_gate_constants.py +0 -58
  80. package/hooks/hooks_constants/gh_pr_author_swap_constants.py +0 -76
  81. package/hooks/hooks_constants/path_rewriter_constants.py +0 -13
  82. package/hooks/hooks_constants/pr_description_writer_gate_constants.py +0 -73
  83. package/hooks/hooks_constants/test_path_rewriter_constants.py +0 -57
  84. package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +0 -55
  85. package/hooks/observability/pr_description_writer_spawn_tracker.py +0 -113
  86. package/hooks/observability/test_pr_description_writer_spawn_tracker.py +0 -150
  87. package/hooks/session/gh_pr_author_session_cleanup.py +0 -175
  88. package/hooks/session/test_gh_pr_author_session_cleanup.py +0 -576
  89. package/hooks/test__gh_pr_author_swap_utils.py +0 -333
@@ -1,176 +0,0 @@
1
- #!/usr/bin/env python3
2
- """PreToolUse hook: block gh commands that use --body <string> instead of --body-file.
3
-
4
- Root cause: in shell-invoked gh command contexts, passing markdown body text via
5
- --body "..." can cause backticks to be stored as literal backslash-backtick sequences
6
- on GitHub instead of rendering as inline code or code fences. Quoting and escaping
7
- rules vary by execution environment (Bash, PowerShell, CMD) but the failure mode is
8
- the same. The fix is always to write the body to a temp file and pass --body-file.
9
-
10
- Affected subcommands: gh issue create/edit/comment, gh pr create/edit/comment/review.
11
-
12
- Detection strategy: join bash \\ and PowerShell ` line continuations into a single
13
- logical line, then use shlex.split(..., posix=False) on that line so '--body'
14
- appearing inside a quoted flag value or in heredoc body content on non-continuation
15
- lines does not trigger a false positive. Both '--body value' and '--body=value' forms are blocked,
16
- as are their short '-b' equivalents. '--body-file' and '--body-file=...' are allowed.
17
- Fails CLOSED on shlex ValueError when the logical line matches an affected
18
- subcommand AND contains a bare '--body'/'-b' literal (exactly the heredoc
19
- pattern this hook must catch); otherwise approves unparseable input.
20
- """
21
-
22
- import json
23
- import re
24
- import sys
25
- from pathlib import Path
26
-
27
- _hooks_dir = str(Path(__file__).resolve().parent.parent)
28
- if _hooks_dir not in sys.path:
29
- sys.path.insert(0, _hooks_dir)
30
-
31
- from blocking._gh_body_arg_utils import ( # noqa: E402
32
- _is_bash_continuation,
33
- all_body_flags,
34
- all_body_flag_prefixes,
35
- get_logical_first_line,
36
- iter_significant_tokens,
37
- )
38
- from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
39
-
40
- _GH_BODY_SUBCOMMANDS = re.compile(
41
- r"\bgh\s+(?:"
42
- r"issue\s+(?:create|edit|comment)|"
43
- r"pr\s+(?:create|edit|comment|review)"
44
- r")\b",
45
- re.IGNORECASE,
46
- )
47
-
48
- _BARE_BODY_TOKEN_PATTERN = re.compile(
49
- r"(?<!\S)(?:--body|-b)(?:=|(?![-\w]))",
50
- )
51
-
52
- _BASH_TOOL_NAME = "Bash"
53
-
54
- _CORRECTIVE_MESSAGE = (
55
- "BLOCKED [gh-body-file]: gh --body <string> escapes backticks as \\` on GitHub, "
56
- "corrupting inline code and code fences in issues, PRs, comments, and reviews. "
57
- "Write the body to a temp file and use --body-file instead.\n\n"
58
- "Safe Python pattern:\n"
59
- " import tempfile\n"
60
- " with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:\n"
61
- " f.write(body_text)\n"
62
- " body_path = f.name\n"
63
- " # then: gh ... --body-file body_path\n\n"
64
- "Safe PowerShell pattern (BOM-free, works on 5.1 and 7+):\n"
65
- " $bodyPath = [System.IO.Path]::ChangeExtension((New-TemporaryFile).FullName, '.md')\n"
66
- " $body = @'\n"
67
- " <your markdown body>\n"
68
- " '@\n"
69
- " [IO.File]::WriteAllText($bodyPath, $body, [Text.UTF8Encoding]::new($false))\n"
70
- " gh ... --body-file $bodyPath\n\n"
71
- "See ~/.claude/rules/gh-cli-conventions.md for full guidance."
72
- )
73
-
74
-
75
- def _logical_line_has_bare_body_token(logical_line: str) -> bool:
76
- return bool(_BARE_BODY_TOKEN_PATTERN.search(logical_line))
77
-
78
-
79
- def _has_backtick(command: str) -> bool:
80
- """Return True if command contains a literal backtick.
81
-
82
- Joins bash `` \\ `` continuation lines so backticks in multi-line body
83
- values on later non-continuation lines are detected as part of the same
84
- logical command. Under the Bash tool — the only environment this hook
85
- intercepts — a backtick character is a command-substitution delimiter or
86
- body content; it is never a PowerShell line-continuation marker. Every
87
- backtick that survives the bash-continuation join is therefore a literal
88
- backtick that warrants the --body-file safe pattern.
89
-
90
- Scans the entire command string for backtick characters, not just --body
91
- argument content. This is intentionally conservative — any command
92
- containing backticks should use --body-file regardless of where they
93
- appear. False positives (backticks in non-body flags) are safe
94
- over-blocks.
95
- """
96
- continuation_separator = " "
97
- all_joined_lines: list[str] = []
98
- for each_line in command.splitlines():
99
- stripped_line = each_line.rstrip()
100
- if _is_bash_continuation(stripped_line):
101
- all_joined_lines.append(stripped_line[:-1].rstrip() + continuation_separator)
102
- continue
103
- all_joined_lines.append(each_line)
104
- full_logical_command = "".join(all_joined_lines)
105
- return "`" in full_logical_command
106
-
107
-
108
- def _uses_body_string_arg(command: str) -> bool:
109
- """Return True if command calls an affected gh subcommand with --body <string>.
110
-
111
- Joins bash \\ and PowerShell ` line continuations before scanning so that
112
- '--body' on a continuation line is not missed. Uses shlex.split(posix=False)
113
- for Windows-friendly tokenization: backslashes in unquoted paths are preserved
114
- and quoted values retain their surrounding quotes as part of the token, so
115
- '--body' embedded in a quoted value cannot be mistaken for a standalone flag.
116
- Detects both '--body value'/'--body=value' forms and their short '-b'
117
- equivalents. Fails CLOSED on shlex ValueError when the logical line matches
118
- an affected subcommand and contains a bare --body / -b token literal, since
119
- heredoc-wrapped --body arguments are exactly the pattern this hook exists
120
- to block; otherwise approves unparseable input (out of scope).
121
- """
122
- if not _GH_BODY_SUBCOMMANDS.search(get_logical_first_line(command)):
123
- return False
124
- try:
125
- significant_tokens = list(iter_significant_tokens(command))
126
- except ValueError:
127
- return _logical_line_has_bare_body_token(get_logical_first_line(command))
128
- for each_token, _remaining_tokens in significant_tokens:
129
- if each_token in all_body_flags:
130
- return True
131
- if any(each_token.startswith(each_prefix) for each_prefix in all_body_flag_prefixes):
132
- return True
133
- return False
134
-
135
-
136
- def main() -> None:
137
- try:
138
- hook_input = json.load(sys.stdin)
139
- except json.JSONDecodeError:
140
- sys.exit(0)
141
-
142
- tool_name = hook_input.get("tool_name", "")
143
- if tool_name != _BASH_TOOL_NAME:
144
- sys.exit(0)
145
-
146
- command = hook_input.get("tool_input", {}).get("command", "")
147
- if not command:
148
- sys.exit(0)
149
-
150
- if not _uses_body_string_arg(command):
151
- sys.exit(0)
152
-
153
- if not _has_backtick(command):
154
- sys.exit(0)
155
-
156
- deny_payload = {
157
- "hookSpecificOutput": {
158
- "hookEventName": "PreToolUse",
159
- "permissionDecision": "deny",
160
- "permissionDecisionReason": _CORRECTIVE_MESSAGE,
161
- }
162
- }
163
- log_hook_block(
164
- calling_hook_name="gh_body_arg_blocker.py",
165
- hook_event="PreToolUse",
166
- block_reason=_CORRECTIVE_MESSAGE,
167
- tool_name=tool_name,
168
- offending_input_preview=command,
169
- )
170
- print(json.dumps(deny_payload))
171
- sys.stdout.flush()
172
- sys.exit(0)
173
-
174
-
175
- if __name__ == "__main__":
176
- main()
@@ -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()