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,1225 +0,0 @@
1
- """Shared utilities for the gh-pr-author swap hook trio.
2
-
3
- The PreToolUse enforcer (``hooks/blocking/gh_pr_author_enforcer.py``), the
4
- PostToolUse restore (``hooks/blocking/gh_pr_author_restore.py``), and the
5
- SessionStart cleanup (``hooks/session/gh_pr_author_session_cleanup.py``)
6
- all share a small set of helpers: write a line to a stream, build the
7
- per-session state-file path, run ``gh auth switch``, read the
8
- original-account login from a state file, delete a state file, and
9
- detect a ``gh pr create`` invocation while ignoring quoted regions.
10
-
11
- Centralising these helpers keeps the three hooks' contracts in
12
- lock-step — a fix in the shared ``_command_invokes_gh_pr_create_in_stripped``
13
- detector lands in the enforcer and the restore hook from a single edit,
14
- and the state-file path and gh subprocess shape stay uniform across the
15
- trio so a file written by the enforcer is always resolvable by the
16
- restore and cleanup hooks.
17
-
18
- Layout: a leading underscore marks the module as internal to the swap
19
- feature, and the file lives directly under ``hooks/`` so both
20
- ``hooks/blocking/`` and ``hooks/session/`` consumers can import it
21
- without a per-directory path shim.
22
- """
23
-
24
- from __future__ import annotations
25
-
26
- import json
27
- import os
28
- import stat
29
- import subprocess
30
- import sys
31
- import tempfile
32
- from pathlib import Path
33
- from typing import TextIO
34
-
35
- from hooks_constants.gh_pr_author_swap_constants import (
36
- ALL_GH_AUTH_SWITCH_COMMAND_HEAD,
37
- ALL_SHELL_QUOTE_CHARACTERS,
38
- BASH_COMMENT_INTRODUCER_CHARACTER,
39
- COMMAND_SEPARATOR_PATTERN,
40
- COMMAND_SUBSTITUTION_OPENER_LENGTH,
41
- GH_AUTH_SWITCH_TIMEOUT_SECONDS,
42
- GH_PR_CREATE_PATTERN,
43
- HEREDOC_OPENER_TAG_PATTERN,
44
- HEREDOC_OPENER_TOKEN_LENGTH,
45
- SESSION_ID_UNSAFE_CHARACTERS_PATTERN,
46
- SHELL_BACKSLASH_CHARACTER,
47
- SHELL_BACKSLASH_ESCAPE_PAIR_LENGTH,
48
- SHELL_BACKTICK_CHARACTER,
49
- SHELL_DOLLAR_CHARACTER,
50
- SHELL_LESS_THAN_CHARACTER,
51
- SHELL_NEWLINE_CHARACTER,
52
- SHELL_PAREN_CLOSE_CHARACTER,
53
- SHELL_PAREN_OPEN_CHARACTER,
54
- SHELL_QUOTE_REPLACEMENT_CHARACTER,
55
- STATE_FILE_DEFAULT_SESSION_ID,
56
- STATE_FILE_ORIGINAL_ACCOUNT_KEY,
57
- STATE_FILE_PERMISSION_MODE,
58
- STATE_FILE_PREFIX,
59
- STATE_FILE_SUFFIX,
60
- )
61
-
62
-
63
- def _write_line(message: str, into_stream: TextIO) -> None:
64
- """Write a single line to the caller-provided text stream.
65
-
66
- Wrapping ``stream.write`` in a function that accepts an explicit
67
- ``into_stream`` parameter satisfies the project's logging rule
68
- (route through logger or accept an explicit stream parameter) without
69
- pulling the logging module into a self-contained hook script.
70
-
71
- Args:
72
- message: Single line of output. A trailing newline is appended.
73
- into_stream: Destination stream (typically ``sys.stdout`` for
74
- the JSON deny payload or ``sys.stderr`` for diagnostics).
75
- Each caller formats its own prefix into ``message``.
76
- """
77
- into_stream.write(message + "\n")
78
- into_stream.flush()
79
-
80
-
81
- def _sanitize_session_id(session_id: str) -> str:
82
- """Strip every character outside ``[A-Za-z0-9_-]`` from a session id.
83
-
84
- The raw session id comes from the Claude Code hook input JSON, which
85
- is attacker-influenceable. Path-traversal characters (``/``, ``\\``,
86
- ``..``), NUL bytes, and any other shell-metacharacter must be removed
87
- before the value participates in a filename so the produced path
88
- stays anchored inside ``tempfile.gettempdir()``.
89
-
90
- Args:
91
- session_id: Raw session id value.
92
-
93
- Returns:
94
- The input with every unsafe character removed. An empty result
95
- signals the caller to fall back to the default session id.
96
- """
97
- return SESSION_ID_UNSAFE_CHARACTERS_PATTERN.sub("", session_id)
98
-
99
-
100
- def _state_file_path(session_id: str) -> Path:
101
- """Return the per-session state-file path used by the hook trio.
102
-
103
- The enforcer writes the file, the restore hook reads and deletes it,
104
- and the session-cleanup hook globs the prefix to recover stranded
105
- files. All three share this naming convention so a state file
106
- written by one hook is always resolvable by the others.
107
-
108
- Args:
109
- session_id: ``session_id`` from the Claude Code hook input JSON.
110
- Empty string falls back to ``STATE_FILE_DEFAULT_SESSION_ID``.
111
- Unsafe characters (path-traversal, NUL, shell metacharacters)
112
- are stripped before the value participates in the filename so
113
- the returned path stays anchored inside the temp directory.
114
-
115
- Returns:
116
- Absolute path to the state file in the system temp directory.
117
- """
118
- sanitized_session_id = _sanitize_session_id(session_id)
119
- effective_session_id = sanitized_session_id or STATE_FILE_DEFAULT_SESSION_ID
120
- filename = f"{STATE_FILE_PREFIX}{effective_session_id}{STATE_FILE_SUFFIX}"
121
- return Path(tempfile.gettempdir()) / filename
122
-
123
-
124
- def _switch_gh_account(to_account: str) -> bool:
125
- """Run ``gh auth switch --user <to_account>`` and report success.
126
-
127
- Diagnostics on failure are intentionally not written here. Callers
128
- decide whether a failed switch is worth a stderr line (the restore
129
- and cleanup hooks log; the enforcer suppresses to keep the deny-path
130
- payload the only output on the failure branch).
131
-
132
- Args:
133
- to_account: Login to switch the active gh CLI account to.
134
-
135
- Returns:
136
- True when the switch command exits zero. False when gh is missing,
137
- the switch command exits non-zero, times out, lacks executable
138
- permission on the gh binary, or otherwise fails. ``OSError``
139
- covers every spawn-time failure (``FileNotFoundError`` when gh is
140
- absent, ``PermissionError`` when gh exists but is not executable,
141
- and any other platform-specific spawn errors) so the hook follows
142
- its documented non-blocking failure path rather than crashing.
143
- """
144
- switch_command = list(ALL_GH_AUTH_SWITCH_COMMAND_HEAD) + [to_account]
145
- try:
146
- completed_process = subprocess.run(
147
- switch_command,
148
- capture_output=True,
149
- text=True,
150
- timeout=GH_AUTH_SWITCH_TIMEOUT_SECONDS,
151
- check=False,
152
- )
153
- except (OSError, subprocess.SubprocessError):
154
- return False
155
- return completed_process.returncode == 0
156
-
157
-
158
- def _build_restore_failure_message(
159
- hook_label: str,
160
- original_account: str,
161
- state_file: Path,
162
- state_file_context: str,
163
- ) -> str:
164
- """Build the shared diagnostic for a failed account restoration."""
165
- return (
166
- f"[{hook_label}] Restore the gh account to {original_account!r}; "
167
- f"gh account switch requires attention for {state_file_context} {state_file}. "
168
- "The file remains for the next session."
169
- )
170
-
171
-
172
- def _read_original_account(state_file: Path) -> str | None:
173
- """Read the original-account login from a swap-state file.
174
-
175
- Args:
176
- state_file: Path produced by ``_state_file_path``.
177
-
178
- Returns:
179
- The original account login when the file exists and parses to a
180
- JSON object with a non-empty string ``original_account`` value.
181
- None when the file is absent, unreadable, malformed JSON, the
182
- wrong shape, missing the key, holds a non-string value, or holds
183
- a blank value. Diagnostics for unreadable or malformed files are
184
- written to stderr so the caller can see why a state file was not
185
- consumed.
186
- """
187
- try:
188
- raw_contents = state_file.read_text(encoding="utf-8")
189
- except FileNotFoundError:
190
- return None
191
- except OSError as os_error:
192
- _write_line(
193
- f"[gh-pr-author-utils] failed to read state file {state_file}: {os_error}",
194
- sys.stderr,
195
- )
196
- return None
197
- try:
198
- parsed_state = json.loads(raw_contents)
199
- except json.JSONDecodeError as decode_error:
200
- _write_line(
201
- f"[gh-pr-author-utils] malformed state file {state_file}: {decode_error}",
202
- sys.stderr,
203
- )
204
- return None
205
- if not isinstance(parsed_state, dict):
206
- return None
207
- original_account = parsed_state.get(STATE_FILE_ORIGINAL_ACCOUNT_KEY, "")
208
- if not isinstance(original_account, str):
209
- return None
210
- stripped_original_account = original_account.strip()
211
- return stripped_original_account or None
212
-
213
-
214
- def _delete_state_file(state_file: Path) -> None:
215
- """Remove a state file, ignoring an already-absent file.
216
-
217
- Args:
218
- state_file: Path produced by ``_state_file_path``.
219
- """
220
- try:
221
- state_file.unlink()
222
- except FileNotFoundError:
223
- return
224
- except OSError as os_error:
225
- _write_line(
226
- f"[gh-pr-author-utils] failed to delete state file {state_file}: {os_error}",
227
- sys.stderr,
228
- )
229
-
230
-
231
- def _state_file_is_attacker_planted(state_file: Path) -> bool:
232
- """Return True when the state file's mode or owner does not match an enforcer-written file.
233
-
234
- The enforcer atomically creates each swap-state file with mode
235
- ``STATE_FILE_PERMISSION_MODE`` (``0o600``) owned by the current
236
- user. A file at the predictable swap-state path that diverges on
237
- either axis is overwhelmingly likely to be an attacker plant —
238
- another user on the same workstation pre-creating a file to trick
239
- the restore or cleanup hook into running
240
- ``gh auth switch --user <attacker-controlled-login>``.
241
-
242
- The candidate is inspected via ``lstat`` rather than ``stat`` so a
243
- symlink at the predictable path is screened on its own metadata,
244
- not on whatever the symlink resolves to. The enforcer creates state
245
- files with ``O_NOFOLLOW`` to prevent symlink hijacking; this helper
246
- mirrors that contract.
247
-
248
- The mode-bit and uid checks only apply on POSIX. Windows reports
249
- ``0o666`` from ``stat`` for files chmod'd to ``0o600`` because
250
- ``os.chmod`` on Windows only toggles the read-only attribute, and
251
- ``os.getuid`` is absent there. ``tempfile.gettempdir()`` on Windows
252
- is already per-user (``%LOCALAPPDATA%\\Temp``), which closes the
253
- cross-user attack surface this check guards against on POSIX, so
254
- the check is a no-op on Windows.
255
-
256
- A missing file returns False so callers can treat the missing-file
257
- case the same way they treat a normal absent-state-file path.
258
-
259
- For callers that already hold an ``lstat`` result for the candidate
260
- path, prefer ``_lstat_indicates_attacker_planted`` to avoid a
261
- redundant syscall and the TOCTOU window between two ``lstat`` calls.
262
-
263
- Args:
264
- state_file: Path produced by ``_state_file_path``.
265
-
266
- Returns:
267
- True when the file exists on POSIX with wrong mode bits or
268
- wrong uid, or the path is not a regular file (symlink, FIFO,
269
- device, etc.), or ``os.lstat`` raises ``OSError``. False when
270
- the file matches the enforcer's write contract, is absent, or
271
- the platform lacks POSIX ownership semantics.
272
- """
273
- if not hasattr(os, "getuid"):
274
- return False
275
- try:
276
- file_lstat_result = state_file.lstat()
277
- except FileNotFoundError:
278
- return False
279
- except OSError:
280
- return True
281
- return _lstat_indicates_attacker_planted(file_lstat_result)
282
-
283
-
284
- def _lstat_indicates_attacker_planted(file_lstat_result: os.stat_result) -> bool:
285
- """Return True when an ``lstat`` result does not match an enforcer-written state file.
286
-
287
- Callers that already hold a fresh ``lstat`` result for a candidate
288
- state-file path use this helper directly instead of
289
- ``_state_file_is_attacker_planted``, which would re-stat the path.
290
- Skipping the second stat avoids a redundant syscall and the TOCTOU
291
- window where an attacker could swap the inode between the two stat
292
- calls.
293
-
294
- The mode-bit and uid checks only apply on POSIX. Windows ``stat``
295
- semantics differ (see ``_state_file_is_attacker_planted`` for the
296
- full rationale) so the helper is a no-op on platforms without
297
- ``os.getuid``.
298
-
299
- Args:
300
- file_lstat_result: Result of ``os.lstat`` (or ``Path.lstat``)
301
- on the candidate state-file path. The caller is responsible
302
- for using ``lstat`` rather than ``stat`` so symlinks are
303
- screened on their own metadata.
304
-
305
- Returns:
306
- True on POSIX when the file is not a regular file, the mode
307
- bits do not equal ``STATE_FILE_PERMISSION_MODE``, or the uid
308
- does not match the current user. False on POSIX when the
309
- candidate matches the enforcer's write contract, and on Windows
310
- unconditionally.
311
- """
312
- if not hasattr(os, "getuid"):
313
- return False
314
- if not stat.S_ISREG(file_lstat_result.st_mode):
315
- return True
316
- actual_permission_bits = stat.S_IMODE(file_lstat_result.st_mode)
317
- if actual_permission_bits != STATE_FILE_PERMISSION_MODE:
318
- return True
319
- current_user_id = os.getuid()
320
- if file_lstat_result.st_uid != current_user_id:
321
- return True
322
- return False
323
-
324
-
325
- def _index_after_command_substitution(all_scanned_characters: list[str], opener_index: int) -> int:
326
- """Return the index one past the closing ``)`` of a ``$(...)`` substitution.
327
-
328
- Walks from the opening ``$(`` past nested ``$(...)`` substitutions
329
- and through inner quoted regions and backtick substitutions so the
330
- closing paren matched is the one that actually balances the opener.
331
- Bash executes the substitution body as its own command, so the
332
- walker treats the body the same way the outer ``_strip_quoted_regions``
333
- scan treats top-level text: single- and double-quoted regions inside
334
- the body are BLANKED (replaced with spaces) so a literal token
335
- sitting inside a quoted argument cannot leak out as if it were a
336
- command. For example, ``$(printf 'gh pr create')`` runs ``printf``
337
- against the literal data ``gh pr create`` — the data must not be
338
- confused with a real ``gh pr create`` invocation.
339
-
340
- Quote handling mirrors ``_strip_quoted_regions``:
341
-
342
- * A single-quoted region (``'...'``) has no escape mechanism in bash —
343
- the walker advances to the next ``'`` and blanks every character
344
- between the openers.
345
- * A double-quoted region (``"..."``) honors backslash escapes — a
346
- ``\\`` followed by any character is consumed as a two-character
347
- unit, so ``\\"`` does not terminate the region. Backslash-escape
348
- pairs are blanked too.
349
- * A backtick substitution (``` `...` ```) inside the ``$(...)`` body
350
- is itself a subshell — the walker advances past the next backtick
351
- so that a ``)`` sitting inside the backtick body does not flip the
352
- surrounding paren depth. Backtick bodies are kept scannable for
353
- the same reason as ``$(...)`` bodies: bash executes them, so any
354
- ``gh pr create`` token sitting inside is a real invocation.
355
-
356
- Bare ``(`` and ``)`` characters inside the substitution body
357
- (bash subshells like ``(echo b)``, array assignments like
358
- ``arr=(a b c)``, function definitions like ``f() { ...; }``) also
359
- track paren depth so they cancel out before a bare ``)`` can
360
- prematurely close the outer ``$(...)`` substitution.
361
-
362
- Unterminated quotes and backticks consume to the end of the buffer,
363
- matching the behavior of ``_strip_quoted_regions``.
364
-
365
- Args:
366
- all_scanned_characters: Mutable list view of the command string.
367
- The walker MUTATES the buffer to blank quoted regions inside
368
- the substitution body. The substitution opener (``$(``) and
369
- closer (``)``) and any unquoted body characters remain
370
- intact so the outer matcher can scan the body for real
371
- commands.
372
- opener_index: Index of the ``$`` that begins ``$(``.
373
-
374
- Returns:
375
- The index just past the matching ``)``. When no closing paren is
376
- found the length of the buffer is returned, matching how an
377
- interactive shell would consume the rest of the input on an
378
- unterminated substitution.
379
- """
380
- paren_depth = 1
381
- interior_index = opener_index + COMMAND_SUBSTITUTION_OPENER_LENGTH
382
- buffer_length = len(all_scanned_characters)
383
- while interior_index < buffer_length and paren_depth > 0:
384
- interior_character = all_scanned_characters[interior_index]
385
- if (
386
- interior_character == SHELL_DOLLAR_CHARACTER
387
- and interior_index + 1 < buffer_length
388
- and all_scanned_characters[interior_index + 1] == SHELL_PAREN_OPEN_CHARACTER
389
- ):
390
- paren_depth += 1
391
- interior_index += COMMAND_SUBSTITUTION_OPENER_LENGTH
392
- continue
393
- if interior_character == SHELL_BACKTICK_CHARACTER:
394
- interior_index = _index_after_backtick_substitution(
395
- all_scanned_characters, interior_index, buffer_length
396
- )
397
- continue
398
- if interior_character in ALL_SHELL_QUOTE_CHARACTERS:
399
- interior_index = _blank_quoted_region(
400
- all_scanned_characters, interior_index, buffer_length, interior_character
401
- )
402
- continue
403
- if interior_character == SHELL_PAREN_OPEN_CHARACTER:
404
- paren_depth += 1
405
- interior_index += 1
406
- continue
407
- if interior_character == SHELL_PAREN_CLOSE_CHARACTER:
408
- paren_depth -= 1
409
- interior_index += 1
410
- continue
411
- interior_index += 1
412
- return interior_index
413
-
414
-
415
- def _index_after_backtick_substitution(
416
- all_scanned_characters: list[str],
417
- opener_index: int,
418
- buffer_length: int,
419
- ) -> int:
420
- """Return the index one past the closing backtick of a ``` `...` ``` region.
421
-
422
- The backtick body is executed by bash, so the walker mirrors the
423
- ``$(...)`` helper: a single- or double-quoted region inside the
424
- body is blanked via ``_blank_quoted_region`` so a literal token
425
- sitting inside a quoted argument (for example
426
- ``` `printf ';gh pr create'` ```) cannot leak out as if it were a
427
- real command.
428
-
429
- Args:
430
- all_scanned_characters: Mutable list view of the command string.
431
- The walker MUTATES the buffer to blank quoted regions inside
432
- the substitution body.
433
- opener_index: Index of the opening backtick.
434
- buffer_length: Length of ``all_scanned_characters``, hoisted by
435
- the caller to avoid a recomputation per call.
436
-
437
- Returns:
438
- The index just past the matching backtick, or ``buffer_length``
439
- when the backtick region is unterminated.
440
- """
441
- interior_index = opener_index + 1
442
- while interior_index < buffer_length:
443
- interior_character = all_scanned_characters[interior_index]
444
- if interior_character == SHELL_BACKTICK_CHARACTER:
445
- return interior_index + 1
446
- if interior_character in ALL_SHELL_QUOTE_CHARACTERS:
447
- interior_index = _blank_quoted_region(
448
- all_scanned_characters, interior_index, buffer_length, interior_character
449
- )
450
- continue
451
- interior_index += 1
452
- return interior_index
453
-
454
-
455
- def _blank_quoted_region(
456
- all_scanned_characters: list[str],
457
- opener_index: int,
458
- buffer_length: int,
459
- quote_character: str,
460
- ) -> int:
461
- """Blank the interior of a ``'...'`` or ``"..."`` region in place.
462
-
463
- The opening quote, every character inside the region, and the closing
464
- quote are all replaced with ``SHELL_QUOTE_REPLACEMENT_CHARACTER`` so
465
- that downstream regex matching sees only whitespace where quoted text
466
- used to live. Offsets are preserved end-to-end — the returned index
467
- always lands one past the position the closing quote occupied
468
- (whether or not a closing quote was found).
469
-
470
- Single quotes have no escape mechanism in bash, so the walker advances
471
- to the next matching ``'`` and blanks every character along the way.
472
- Double quotes honor ``\\`` escapes, so a ``\\`` followed by any
473
- character is blanked as a two-character unit (``\\"`` does not
474
- terminate the region).
475
-
476
- Within a double-quoted region, bash still expands ``$(...)`` and
477
- ``` `...` ``` substitutions. The walker recognizes both openers and
478
- descends into their matching closer via the substitution helpers
479
- instead of blanking, so a ``gh pr create`` token sitting inside the
480
- substitution body remains scannable while the surrounding quoted
481
- text is blanked. Single-quoted regions intentionally do NOT descend
482
- into substitutions because ``$`` and ``` ` ``` are literal text
483
- inside ``'...'``.
484
-
485
- Args:
486
- all_scanned_characters: Mutable list view of the command string.
487
- The walker MUTATES the buffer to blank the entire quoted
488
- region (both quotes included).
489
- opener_index: Index of the opening quote.
490
- buffer_length: Length of ``all_scanned_characters``, hoisted by
491
- the caller to avoid a recomputation per call.
492
- quote_character: ``'`` or ``"`` — the quote whose match closes
493
- the region.
494
-
495
- Returns:
496
- The index just past the matching closing quote, or ``buffer_length``
497
- when the quoted region is unterminated.
498
- """
499
- all_scanned_characters[opener_index] = SHELL_QUOTE_REPLACEMENT_CHARACTER
500
- interior_index = opener_index + 1
501
- while interior_index < buffer_length:
502
- interior_character = all_scanned_characters[interior_index]
503
- if (
504
- quote_character == '"'
505
- and interior_character == "\\"
506
- and interior_index + 1 < buffer_length
507
- ):
508
- all_scanned_characters[interior_index] = SHELL_QUOTE_REPLACEMENT_CHARACTER
509
- all_scanned_characters[interior_index + 1] = SHELL_QUOTE_REPLACEMENT_CHARACTER
510
- interior_index += SHELL_BACKSLASH_ESCAPE_PAIR_LENGTH
511
- continue
512
- if (
513
- quote_character == '"'
514
- and interior_character == SHELL_DOLLAR_CHARACTER
515
- and interior_index + 1 < buffer_length
516
- and all_scanned_characters[interior_index + 1] == SHELL_PAREN_OPEN_CHARACTER
517
- ):
518
- interior_index = _index_after_command_substitution(
519
- all_scanned_characters, interior_index
520
- )
521
- continue
522
- if quote_character == '"' and interior_character == SHELL_BACKTICK_CHARACTER:
523
- interior_index = _index_after_backtick_substitution(
524
- all_scanned_characters, interior_index, buffer_length
525
- )
526
- continue
527
- if interior_character == quote_character:
528
- all_scanned_characters[interior_index] = SHELL_QUOTE_REPLACEMENT_CHARACTER
529
- return interior_index + 1
530
- all_scanned_characters[interior_index] = SHELL_QUOTE_REPLACEMENT_CHARACTER
531
- interior_index += 1
532
- return interior_index
533
-
534
-
535
- def _strip_quoted_regions(command: str) -> str:
536
- """Replace inert quoted regions with spaces, leaving substitutions scannable.
537
-
538
- Single quotes (``'...'``) and double quotes (``"..."``) wrap inert
539
- text in bash, so their interior is replaced with spaces. ``$(...)``
540
- command substitution and backtick command substitution
541
- (``` `...` ```) execute their bodies in a subshell, so the substitution
542
- OPENER and CLOSER and the unquoted body characters are left intact —
543
- any ``gh pr create`` token sitting unquoted inside either form must
544
- remain visible to the matchers, otherwise the enforcer would
545
- silently no-op on ``echo "$(gh pr create --title T)"``.
546
-
547
- The substitution bodies themselves are recursively quote-stripped:
548
- a single- or double-quoted argument INSIDE a substitution body is
549
- blanked the same way as a top-level quoted region. That keeps shapes
550
- like ``echo $(printf 'gh pr create')`` from leaking the literal
551
- ``gh pr create`` string out of ``printf``'s single-quoted argument
552
- and tricking the matcher into thinking the substitution invokes
553
- ``gh pr create`` when it actually invokes ``printf`` against the
554
- literal data.
555
-
556
- Within a double-quoted region, ``$(...)`` substitution windows are
557
- still expanded, so the walker recognizes the ``$(`` opener inside
558
- the quoted scan and stops stripping until the matching ``)`` —
559
- leaving the substitution body scannable while keeping the surrounding
560
- quoted text inert. Backtick command substitution (``` `...` ```) is
561
- likewise expanded by bash inside double quotes, so the same
562
- skip-past-body behavior applies: the walker advances past the closing
563
- backtick without stripping the interior, so any ``gh pr create`` token
564
- sitting inside ``"`...`"`` remains visible to the matcher.
565
-
566
- Backslash-escaped quotes inside a double-quoted segment (``\\"``) do
567
- not terminate the region. An unterminated quote consumes the rest of
568
- the string, matching how an interactive shell parses the same input.
569
-
570
- Args:
571
- command: Raw bash command string from PreToolUse hook input.
572
-
573
- Returns:
574
- A string of identical length to ``command`` with single- and
575
- double-quoted region interiors replaced by spaces — including
576
- quoted regions nested inside ``$(...)`` / ``` `...` ``` bodies —
577
- and the unquoted body characters of the substitutions themselves
578
- left intact.
579
- """
580
- all_scanned_characters = list(command)
581
- cursor_index = 0
582
- command_length = len(command)
583
- while cursor_index < command_length:
584
- current_character = all_scanned_characters[cursor_index]
585
- if (
586
- current_character == SHELL_DOLLAR_CHARACTER
587
- and cursor_index + 1 < command_length
588
- and all_scanned_characters[cursor_index + 1] == SHELL_PAREN_OPEN_CHARACTER
589
- ):
590
- cursor_index = _index_after_command_substitution(all_scanned_characters, cursor_index)
591
- continue
592
- if current_character == SHELL_BACKTICK_CHARACTER:
593
- cursor_index = _index_after_backtick_substitution(
594
- all_scanned_characters, cursor_index, command_length
595
- )
596
- continue
597
- if current_character not in ALL_SHELL_QUOTE_CHARACTERS:
598
- cursor_index += 1
599
- continue
600
- cursor_index = _blank_quoted_region(
601
- all_scanned_characters, cursor_index, command_length, current_character
602
- )
603
- return "".join(all_scanned_characters)
604
-
605
-
606
- def _strip_bash_comments(quote_stripped_command: str) -> str:
607
- """Replace bash comments with spaces so a hash-prefixed token is inert.
608
-
609
- Bash treats a hash character as the start of a comment only when it
610
- appears at the beginning of the command or immediately after
611
- whitespace. Inside a quoted region the hash character is literal,
612
- but the caller is responsible for running ``_strip_quoted_regions``
613
- first, which already blanks quoted text, so any hash character
614
- reaching this helper is either a real comment introducer or a
615
- token-internal character (for example
616
- ``--body-file body.md@@HASH@@fragment`` where ``@@HASH@@`` stands
617
- in for the literal hash byte).
618
-
619
- The walker is substitution-aware: it descends INTO ``$(...)`` and
620
- ``` `...` ``` bodies so a hash character inside a substitution
621
- body is processed as a comment too — bash executes the substitution
622
- body as its own command, so a hash after whitespace inside the
623
- body really does start a comment. The substitution-bounded comment
624
- runs to the next newline OR to the substitution closer (``)`` for
625
- ``$(...)``, backtick for ``` `...` ```), whichever comes first.
626
- The substitution OPENER and CLOSER characters themselves are
627
- preserved so the outer paren-depth tracking remains intact and the
628
- surrounding command structure stays scannable.
629
-
630
- A hash character at the top level (outside every substitution)
631
- consumes until the next newline as usual. A comment inside a
632
- substitution body must NOT escape outward — that is why the closer
633
- bounds the consumption. Without that bound, a flat regex sweep
634
- would consume the closing ``)`` or backtick and every byte after
635
- it on the same line, silently erasing a real ``gh pr create``
636
- invocation that follows the substitution.
637
-
638
- Args:
639
- quote_stripped_command: Output of ``_strip_quoted_regions``.
640
-
641
- Returns:
642
- A string of identical length with every bash comment replaced
643
- by spaces. The trailing newline of each commented line is
644
- retained so the matcher can still tell where one command ended
645
- and the next began. Substitution openers and closers stay
646
- intact.
647
- """
648
- all_scanned_characters = list(quote_stripped_command)
649
- command_length = len(quote_stripped_command)
650
- _walk_and_blank_comments(
651
- all_scanned_characters,
652
- cursor_start_index=0,
653
- end_index=command_length,
654
- bounded_by_substitution_closer=None,
655
- )
656
- return "".join(all_scanned_characters)
657
-
658
-
659
- def _walk_and_blank_comments(
660
- all_scanned_characters: list[str],
661
- cursor_start_index: int,
662
- end_index: int,
663
- bounded_by_substitution_closer: str | None,
664
- ) -> int:
665
- """Walk a region of the character buffer and blank every comment found.
666
-
667
- The walker is recursive over substitution bodies: when it encounters
668
- a ``$(...)`` or ``` `...` ``` opener it descends into the body with
669
- a substitution closer in hand, blanks any comment that appears
670
- inside the body, and returns control to the outer caller once the
671
- closer is consumed. The substitution opener and closer characters
672
- themselves are preserved so paren-depth tracking elsewhere
673
- (``_index_after_command_substitution``,
674
- ``_strip_substitution_bodies``) remains correct on the blanked
675
- string.
676
-
677
- Args:
678
- all_scanned_characters: Mutable list view of the command string.
679
- The walker MUTATES the buffer to blank comment text in place.
680
- cursor_start_index: Index at which to begin scanning.
681
- end_index: Index at which to stop scanning. The caller passes
682
- ``len(all_scanned_characters)`` for the top-level walk and
683
- the same value for substitution-body walks (each recursive
684
- call independently checks for its closer).
685
- bounded_by_substitution_closer: ``None`` for the top-level
686
- walk. ``")"`` when walking inside a ``$(...)`` body so a
687
- comment inside the body terminates at the matching ``)``.
688
- When bounded by ``)``, paren depth is tracked so a bare
689
- ``(`` inside the body matches its own bare ``)`` rather
690
- than letting that ``)`` exit the walker prematurely.
691
- ``"`"`` when walking inside a ``` `...` ``` body so a
692
- comment inside the body terminates at the matching
693
- backtick. Backtick bodies do not need depth tracking
694
- because backticks do not nest in unescaped form.
695
-
696
- Returns:
697
- The index just past the substitution closer when bounded, or
698
- ``end_index`` when the walker reaches the end of the buffer.
699
- """
700
- cursor_index = cursor_start_index
701
- paren_depth = (
702
- 1 if bounded_by_substitution_closer == SHELL_PAREN_CLOSE_CHARACTER else 0
703
- )
704
- while cursor_index < end_index:
705
- current_character = all_scanned_characters[cursor_index]
706
- if (
707
- bounded_by_substitution_closer == SHELL_PAREN_CLOSE_CHARACTER
708
- and current_character == SHELL_PAREN_CLOSE_CHARACTER
709
- ):
710
- paren_depth -= 1
711
- if paren_depth == 0:
712
- return cursor_index + 1
713
- cursor_index += 1
714
- continue
715
- if (
716
- bounded_by_substitution_closer == SHELL_BACKTICK_CHARACTER
717
- and current_character == SHELL_BACKTICK_CHARACTER
718
- ):
719
- return cursor_index + 1
720
- if (
721
- current_character == SHELL_DOLLAR_CHARACTER
722
- and cursor_index + 1 < end_index
723
- and all_scanned_characters[cursor_index + 1] == SHELL_PAREN_OPEN_CHARACTER
724
- ):
725
- cursor_index = _walk_and_blank_comments(
726
- all_scanned_characters,
727
- cursor_start_index=cursor_index + COMMAND_SUBSTITUTION_OPENER_LENGTH,
728
- end_index=end_index,
729
- bounded_by_substitution_closer=SHELL_PAREN_CLOSE_CHARACTER,
730
- )
731
- continue
732
- if current_character == SHELL_BACKTICK_CHARACTER:
733
- cursor_index = _walk_and_blank_comments(
734
- all_scanned_characters,
735
- cursor_start_index=cursor_index + 1,
736
- end_index=end_index,
737
- bounded_by_substitution_closer=SHELL_BACKTICK_CHARACTER,
738
- )
739
- continue
740
- if (
741
- bounded_by_substitution_closer == SHELL_PAREN_CLOSE_CHARACTER
742
- and current_character == SHELL_PAREN_OPEN_CHARACTER
743
- ):
744
- paren_depth += 1
745
- cursor_index += 1
746
- continue
747
- if (
748
- current_character == BASH_COMMENT_INTRODUCER_CHARACTER
749
- and _is_comment_introducer_position(all_scanned_characters, cursor_index)
750
- ):
751
- cursor_index = _blank_bounded_comment(
752
- all_scanned_characters,
753
- cursor_index,
754
- end_index,
755
- bounded_by_substitution_closer,
756
- )
757
- continue
758
- cursor_index += 1
759
- return cursor_index
760
-
761
-
762
- def _blank_bounded_comment(
763
- all_scanned_characters: list[str],
764
- hash_index: int,
765
- end_index: int,
766
- bounded_by_substitution_closer: str | None,
767
- ) -> int:
768
- """Blank a comment in place up to a newline or substitution closer.
769
-
770
- A comment at the top level runs from the hash character to the next
771
- newline. A comment inside a substitution body has the same upper
772
- bound but ALSO terminates at the substitution closer (``)`` or
773
- backtick), so the closer character itself is preserved and the
774
- outer walker can continue from there.
775
-
776
- Args:
777
- all_scanned_characters: Mutable list view of the command string.
778
- The walker MUTATES the buffer to blank the comment body.
779
- hash_index: Index of the hash character that introduces the
780
- comment.
781
- end_index: Buffer length, hoisted by the caller to avoid a
782
- recomputation per call.
783
- bounded_by_substitution_closer: ``None`` for the top-level
784
- walk. ``")"`` when inside a ``$(...)`` body. ``"`"`` when
785
- inside a ``` `...` ``` body.
786
-
787
- Returns:
788
- The cursor position the outer walker should resume from. The
789
- preserved terminating newline (when present) is included in
790
- the returned range so command-separator detection still sees
791
- the line break. When the comment terminates at a substitution
792
- closer, the closer's own index is returned so the recursive
793
- caller picks up the closer on its next iteration.
794
- """
795
- blanking_index = hash_index
796
- while blanking_index < end_index:
797
- current_character = all_scanned_characters[blanking_index]
798
- if current_character == "\n":
799
- return blanking_index
800
- if (
801
- bounded_by_substitution_closer == SHELL_PAREN_CLOSE_CHARACTER
802
- and current_character == SHELL_PAREN_CLOSE_CHARACTER
803
- ):
804
- return blanking_index
805
- if (
806
- bounded_by_substitution_closer == SHELL_BACKTICK_CHARACTER
807
- and current_character == SHELL_BACKTICK_CHARACTER
808
- ):
809
- return blanking_index
810
- all_scanned_characters[blanking_index] = SHELL_QUOTE_REPLACEMENT_CHARACTER
811
- blanking_index += 1
812
- return blanking_index
813
-
814
-
815
- def _is_comment_introducer_position(
816
- all_scanned_characters: list[str], hash_index: int
817
- ) -> bool:
818
- """Return True when the hash at ``hash_index`` introduces a bash comment.
819
-
820
- Bash treats a hash as a comment introducer only at the start of the
821
- command or immediately after whitespace. This mirrors the lookbehind
822
- branch of the bash comment rule (``(?<=\\s)|^``) while operating on
823
- a mutable character list so the substitution-aware walker can
824
- consult it inline.
825
-
826
- Args:
827
- all_scanned_characters: Character buffer being walked.
828
- hash_index: Index of the hash character under test.
829
-
830
- Returns:
831
- True when ``hash_index`` is zero or the prior character is a
832
- Python ``str.isspace`` whitespace character.
833
- """
834
- if hash_index == 0:
835
- return True
836
- prior_character = all_scanned_characters[hash_index - 1]
837
- return prior_character.isspace()
838
-
839
-
840
- def _strip_substitution_bodies(quote_stripped_command: str) -> str:
841
- """Replace ``$(...)`` and ``` `...` ``` bodies with spaces.
842
-
843
- The ``gh pr create`` detection path relies on the substitution body
844
- being scannable so that ``echo $(gh pr create)`` triggers the
845
- enforcer. The web-flag detection path has the opposite requirement:
846
- a ``--web`` token appearing inside a substitution body is an
847
- argument to whatever command the subshell executes, not a flag on
848
- the outer ``gh pr create`` invocation. ``gh pr create --title "$(echo --web)"``
849
- should still trip the enforcer because ``--web`` belongs to ``echo``,
850
- not to ``gh pr create``.
851
-
852
- This helper blanks the OPENER, the body, and the CLOSER of every
853
- top-level ``$(...)`` and ``` `...` ``` substitution so the
854
- web-flag matcher sees only whitespace where a substitution used to
855
- live. Offsets are preserved so the segment-extraction in
856
- ``_all_gh_pr_create_segments`` still works on the resulting string.
857
-
858
- Args:
859
- quote_stripped_command: Output of ``_strip_quoted_regions`` —
860
- quotes must already be blanked so this helper does not need
861
- to re-track quoted boundaries.
862
-
863
- Returns:
864
- A string of identical length with every substitution body
865
- replaced by spaces.
866
- """
867
- all_scanned_characters = list(quote_stripped_command)
868
- cursor_index = 0
869
- command_length = len(quote_stripped_command)
870
- while cursor_index < command_length:
871
- current_character = all_scanned_characters[cursor_index]
872
- if (
873
- current_character == SHELL_DOLLAR_CHARACTER
874
- and cursor_index + 1 < command_length
875
- and all_scanned_characters[cursor_index + 1] == SHELL_PAREN_OPEN_CHARACTER
876
- ):
877
- substitution_end_index = _index_after_command_substitution(
878
- all_scanned_characters, cursor_index
879
- )
880
- for each_blank_target_index in range(cursor_index, substitution_end_index):
881
- all_scanned_characters[each_blank_target_index] = SHELL_QUOTE_REPLACEMENT_CHARACTER
882
- cursor_index = substitution_end_index
883
- continue
884
- if current_character == SHELL_BACKTICK_CHARACTER:
885
- substitution_end_index = _index_after_backtick_substitution(
886
- all_scanned_characters, cursor_index, command_length
887
- )
888
- for each_blank_target_index in range(cursor_index, substitution_end_index):
889
- all_scanned_characters[each_blank_target_index] = SHELL_QUOTE_REPLACEMENT_CHARACTER
890
- cursor_index = substitution_end_index
891
- continue
892
- cursor_index += 1
893
- return "".join(all_scanned_characters)
894
-
895
-
896
- def _strip_heredoc_bodies(command: str) -> str:
897
- """Replace heredoc bodies with spaces so embedded text is inert.
898
-
899
- A here-document opener (``<<TAG``, ``<<'TAG'``, ``<<"TAG"``, or
900
- ``<<-TAG``) followed by a newline begins a body whose contents bash
901
- treats as literal data, not as commands. The body terminates at the
902
- first subsequent line whose only content is the tag — or, for the
903
- ``<<-`` form, leading TAB characters followed by the tag. Any
904
- ``gh pr create`` token sitting inside a heredoc body is data being
905
- fed to a command like ``cat`` or ``ssh``, not a command the shell
906
- will execute, so the matcher must not see it.
907
-
908
- Quote tracking is required because a literal ``<<EOF`` sitting
909
- inside a quoted argument (for example ``echo "use <<EOF in your
910
- script"``) is not a heredoc opener — it is literal text. The walker
911
- skips past single- and double-quoted regions so the opener detector
912
- only fires on syntactically real openers. ``<<<`` (here-string) is
913
- explicitly skipped because it carries no body.
914
-
915
- Body characters between the end of the opener line (the newline
916
- after the opener) and the start of the closing tag line are
917
- replaced with ``SHELL_QUOTE_REPLACEMENT_CHARACTER`` so offsets are
918
- preserved end-to-end. The opener line and the closing tag line are
919
- left intact so the surrounding command structure stays scannable.
920
-
921
- When the closing tag is never found, no blanking happens — the
922
- function returns the buffer unchanged for that opener. This
923
- conservative branch protects against false positives where an
924
- apparent ``<<TAG`` opener inside an unusual context lacks a real
925
- matching closer; without it the walker would consume to end of
926
- buffer and silently erase any real ``gh pr create`` that follows.
927
-
928
- Multiple heredocs in one command are all handled — after each
929
- successful blanking the walker resumes scanning from the closing
930
- tag line so a second ``<<TAG2`` opener later in the command is
931
- processed independently.
932
-
933
- Args:
934
- command: Raw bash command string from the PreToolUse or
935
- PostToolUse hook input. The helper runs BEFORE
936
- ``_strip_quoted_regions`` so a heredoc opener whose tag
937
- is itself quoted (``<<'EOF'``) still has its tag visible
938
- for closing-tag matching.
939
-
940
- Returns:
941
- A string of identical length to ``command`` with every heredoc
942
- body blanked to spaces and opener/closer lines left intact.
943
- """
944
- all_scanned_characters = list(command)
945
- command_length = len(command)
946
- cursor_index = 0
947
- while cursor_index < command_length:
948
- current_character = all_scanned_characters[cursor_index]
949
- if current_character == "'":
950
- cursor_index = _advance_past_single_quoted_region(
951
- all_scanned_characters, cursor_index, command_length
952
- )
953
- continue
954
- if current_character == "\"":
955
- cursor_index = _advance_past_double_quoted_region(
956
- all_scanned_characters, cursor_index, command_length
957
- )
958
- continue
959
- if not _is_heredoc_opener_position(all_scanned_characters, cursor_index, command_length):
960
- cursor_index += 1
961
- continue
962
- advance_after_blanking = _try_blank_one_heredoc_body(
963
- all_scanned_characters, cursor_index, command_length
964
- )
965
- if advance_after_blanking is None:
966
- cursor_index += 1
967
- continue
968
- cursor_index = advance_after_blanking
969
- return "".join(all_scanned_characters)
970
-
971
-
972
- def _advance_past_single_quoted_region(
973
- all_scanned_characters: list[str],
974
- opener_index: int,
975
- buffer_length: int,
976
- ) -> int:
977
- """Return the index one past the closing ``'`` without mutating the buffer."""
978
- cursor_index = opener_index + 1
979
- while cursor_index < buffer_length:
980
- if all_scanned_characters[cursor_index] == "'":
981
- return cursor_index + 1
982
- cursor_index += 1
983
- return cursor_index
984
-
985
-
986
- def _advance_past_double_quoted_region(
987
- all_scanned_characters: list[str],
988
- opener_index: int,
989
- buffer_length: int,
990
- ) -> int:
991
- """Return the index one past the closing ``"`` honoring ``\\`` escapes."""
992
- cursor_index = opener_index + 1
993
- while cursor_index < buffer_length:
994
- current_character = all_scanned_characters[cursor_index]
995
- if (
996
- current_character == SHELL_BACKSLASH_CHARACTER
997
- and cursor_index + 1 < buffer_length
998
- ):
999
- cursor_index += SHELL_BACKSLASH_ESCAPE_PAIR_LENGTH
1000
- continue
1001
- if current_character == "\"":
1002
- return cursor_index + 1
1003
- cursor_index += 1
1004
- return cursor_index
1005
-
1006
-
1007
- def _is_heredoc_opener_position(
1008
- all_scanned_characters: list[str],
1009
- cursor_index: int,
1010
- buffer_length: int,
1011
- ) -> bool:
1012
- """Return True when the cursor sits at a ``<<`` (but not ``<<<``) heredoc opener."""
1013
- if cursor_index + 1 >= buffer_length:
1014
- return False
1015
- if all_scanned_characters[cursor_index] != SHELL_LESS_THAN_CHARACTER:
1016
- return False
1017
- if all_scanned_characters[cursor_index + 1] != SHELL_LESS_THAN_CHARACTER:
1018
- return False
1019
- if (
1020
- cursor_index + HEREDOC_OPENER_TOKEN_LENGTH < buffer_length
1021
- and all_scanned_characters[cursor_index + HEREDOC_OPENER_TOKEN_LENGTH]
1022
- == SHELL_LESS_THAN_CHARACTER
1023
- ):
1024
- return False
1025
- return True
1026
-
1027
-
1028
- def _try_blank_one_heredoc_body(
1029
- all_scanned_characters: list[str],
1030
- opener_index: int,
1031
- buffer_length: int,
1032
- ) -> int | None:
1033
- """Blank one heredoc body in place when a matching closing tag is found.
1034
-
1035
- Args:
1036
- all_scanned_characters: Mutable list view of the command string.
1037
- The function MUTATES the buffer to blank body characters
1038
- with ``SHELL_QUOTE_REPLACEMENT_CHARACTER``.
1039
- opener_index: Index of the first ``<`` in the ``<<`` opener.
1040
- buffer_length: Length of ``all_scanned_characters``, hoisted by
1041
- the caller.
1042
-
1043
- Returns:
1044
- The index just past the closing tag line on a successful match.
1045
- ``None`` when no tag could be parsed or no matching closing tag
1046
- was found — the buffer is left unchanged in either case so the
1047
- outer walker can advance by one and continue scanning.
1048
- """
1049
- after_opener_index = opener_index + HEREDOC_OPENER_TOKEN_LENGTH
1050
- tag_match = HEREDOC_OPENER_TAG_PATTERN.match(
1051
- "".join(all_scanned_characters), after_opener_index
1052
- )
1053
- if tag_match is None:
1054
- return None
1055
- parsed_tag = (
1056
- tag_match.group("sq_tag")
1057
- or tag_match.group("dq_tag")
1058
- or tag_match.group("bare_tag")
1059
- )
1060
- if not parsed_tag:
1061
- return None
1062
- tag_allows_leading_tabs = tag_match.group("dash") == "-"
1063
- end_of_opener_line_index = _index_of_next_newline(
1064
- all_scanned_characters, tag_match.end(), buffer_length
1065
- )
1066
- if end_of_opener_line_index >= buffer_length:
1067
- return None
1068
- body_start_index = end_of_opener_line_index + 1
1069
- closing_tag_line_start, closing_tag_line_end = _find_closing_heredoc_tag_line(
1070
- all_scanned_characters,
1071
- body_start_index,
1072
- buffer_length,
1073
- parsed_tag,
1074
- tag_allows_leading_tabs,
1075
- )
1076
- if closing_tag_line_start is None:
1077
- return None
1078
- for each_blank_target_index in range(body_start_index, closing_tag_line_start):
1079
- all_scanned_characters[each_blank_target_index] = SHELL_QUOTE_REPLACEMENT_CHARACTER
1080
- return closing_tag_line_end
1081
-
1082
-
1083
- def _index_of_next_newline(
1084
- all_scanned_characters: list[str],
1085
- start_index: int,
1086
- buffer_length: int,
1087
- ) -> int:
1088
- """Return the index of the next newline at or after ``start_index``."""
1089
- cursor_index = start_index
1090
- while cursor_index < buffer_length:
1091
- if all_scanned_characters[cursor_index] == SHELL_NEWLINE_CHARACTER:
1092
- return cursor_index
1093
- cursor_index += 1
1094
- return cursor_index
1095
-
1096
-
1097
- def _find_closing_heredoc_tag_line(
1098
- all_scanned_characters: list[str],
1099
- body_start_index: int,
1100
- buffer_length: int,
1101
- expected_tag: str,
1102
- tag_allows_leading_tabs: bool,
1103
- ) -> tuple[int | None, int]:
1104
- """Return ``(start_of_closing_tag_line, end_of_closing_tag_line)`` for the tag.
1105
-
1106
- The closing tag must appear on its own line — the entire line, after
1107
- any allowed leading tabs (only when the opener used ``<<-``), is the
1108
- tag and nothing else. Trailing carriage returns and trailing spaces
1109
- are tolerated so heredocs authored in CRLF files still match.
1110
-
1111
- Args:
1112
- all_scanned_characters: Character buffer being walked.
1113
- body_start_index: Index of the first byte of the heredoc body
1114
- (the character after the newline that ended the opener line).
1115
- buffer_length: Length of ``all_scanned_characters``.
1116
- expected_tag: The tag extracted from the opener.
1117
- tag_allows_leading_tabs: True when the opener used ``<<-`` (so
1118
- leading TAB characters on the closing line are allowed).
1119
-
1120
- Returns:
1121
- ``(start_of_closing_tag_line, end_of_closing_tag_line)`` when a
1122
- matching closing line is found. ``(None, body_start_index)``
1123
- when no matching line is found.
1124
- """
1125
- line_start_index = body_start_index
1126
- while line_start_index < buffer_length:
1127
- line_end_index = _index_of_next_newline(
1128
- all_scanned_characters, line_start_index, buffer_length
1129
- )
1130
- line_text = "".join(
1131
- all_scanned_characters[line_start_index:line_end_index]
1132
- )
1133
- stripped_line_text = line_text.rstrip(" \t\r")
1134
- if tag_allows_leading_tabs:
1135
- stripped_line_text = stripped_line_text.lstrip("\t")
1136
- if stripped_line_text == expected_tag:
1137
- return line_start_index, line_end_index
1138
- line_start_index = line_end_index + 1
1139
- return None, body_start_index
1140
-
1141
-
1142
- def _preprocess_command_for_matching(command: str) -> str:
1143
- """Return the canonical preprocessed form used by every command-shape matcher.
1144
-
1145
- The enforcer, the restore hook, and the web-flag detector all share
1146
- the same preprocessing pipeline: blank heredoc bodies first so a
1147
- literal ``gh pr create`` inside heredoc data text cannot leak out
1148
- as a real command; blank inert quoted regions next; blank bash
1149
- comments last. Running every pass through a single helper keeps the
1150
- three callers in lock-step — adding a new preprocessing pass lands
1151
- on every consumer automatically.
1152
-
1153
- Heredoc stripping runs BEFORE quoted-region stripping because a
1154
- heredoc tag can itself be quoted (``<<'EOF'``); stripping quotes
1155
- first would blank the tag and break the closing-tag match. The
1156
- heredoc walker carries its own minimal quote skip so a literal
1157
- ``<<EOF`` sitting inside a string is not mistaken for a real opener.
1158
-
1159
- Args:
1160
- command: Raw bash command string from the PreToolUse or
1161
- PostToolUse hook input.
1162
-
1163
- Returns:
1164
- The command with heredoc bodies, quoted regions, and bash
1165
- comments blanked, substitution bodies kept scannable, and
1166
- original offsets preserved end-to-end.
1167
- """
1168
- return _strip_bash_comments(_strip_quoted_regions(_strip_heredoc_bodies(command)))
1169
-
1170
-
1171
- def _all_gh_pr_create_segments(quote_stripped_command: str) -> list[str]:
1172
- """Return every ``gh pr create`` segment in the (quote-stripped) command.
1173
-
1174
- A "segment" is the substring from the end of a ``gh pr create`` match
1175
- up to the next shell command separator (``&&``, ``||``, ``;``,
1176
- ``|``, ``&``, newline) or the end of the string. The enforcer's
1177
- web-flag detection runs against each segment independently so a
1178
- chained ``gh pr create --web && gh pr create --title T`` does not
1179
- let the second invocation slip through on the strength of the
1180
- first segment's ``--web`` flag.
1181
-
1182
- Args:
1183
- quote_stripped_command: Output of ``_strip_quoted_regions`` —
1184
- the caller is responsible for stripping inert quoted regions
1185
- before passing in.
1186
-
1187
- Returns:
1188
- List of segment strings, one per ``gh pr create`` invocation
1189
- found in the command. Empty list when the command does not
1190
- invoke ``gh pr create`` at all.
1191
- """
1192
- all_segments: list[str] = []
1193
- command_length = len(quote_stripped_command)
1194
- for each_gh_pr_create_match in GH_PR_CREATE_PATTERN.finditer(quote_stripped_command):
1195
- segment_start = each_gh_pr_create_match.end()
1196
- separator_match = COMMAND_SEPARATOR_PATTERN.search(quote_stripped_command, segment_start)
1197
- segment_end = separator_match.start() if separator_match else command_length
1198
- all_segments.append(quote_stripped_command[segment_start:segment_end])
1199
- return all_segments
1200
-
1201
-
1202
- def _command_invokes_gh_pr_create_in_stripped(quote_stripped_command: str) -> bool:
1203
- """Return True when the (quote-stripped) command contains a ``gh pr create`` invocation.
1204
-
1205
- Both the enforcer's PreToolUse gate and the restore hook's
1206
- PostToolUse gate share this function, so the pair stays in sync —
1207
- a fix here lands on both ends of the swap-restore pair at once.
1208
- A literal ``gh pr create`` inside ``echo "..."`` or any other quoted
1209
- argument is intentionally ignored because the caller has already run
1210
- ``_strip_quoted_regions`` to blank out inert quoted text.
1211
-
1212
- Args:
1213
- quote_stripped_command: Output of ``_strip_quoted_regions`` —
1214
- the caller is responsible for stripping inert quoted regions
1215
- before passing in. ``main()`` in the enforcer computes this
1216
- once and passes it to both this helper and
1217
- ``_command_uses_web_flag_in_stripped`` so the character-walk
1218
- in ``_strip_quoted_regions`` runs exactly once per command.
1219
-
1220
- Returns:
1221
- True when ``gh pr create`` appears as a whole-word match in the
1222
- already-stripped command. Matches regardless of whether ``gh``
1223
- is at the start of the command or embedded in a chained pipeline.
1224
- """
1225
- return bool(GH_PR_CREATE_PATTERN.search(quote_stripped_command))