claude-dev-env 2.31.0 → 2.32.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 (80) hide show
  1. package/bin/install.test.mjs +11 -3
  2. package/docs/CODE_RULES.md +2 -1
  3. package/hooks/blocking/bash_post_call_dispatcher.py +70 -0
  4. package/hooks/blocking/bash_pre_tool_use_dispatcher.py +8 -21
  5. package/hooks/blocking/code_rules_duplicate_body.py +280 -102
  6. package/hooks/blocking/code_rules_enforcer.py +32 -14
  7. package/hooks/blocking/code_rules_enforcer_test_support.py +39 -0
  8. package/hooks/blocking/code_rules_shared.py +64 -14
  9. package/hooks/blocking/pr_description_writer_gate.py +18 -25
  10. package/hooks/blocking/shell_substitution_blocker.py +175 -8
  11. package/hooks/blocking/stop_dispatcher.py +6 -9
  12. package/hooks/blocking/tdd_enforcer.py +11 -5
  13. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +7 -0
  14. package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +141 -23
  15. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +114 -1
  16. package/hooks/blocking/test_bash_post_call_dispatcher.py +112 -0
  17. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +12 -1
  18. package/hooks/blocking/test_code_rules_enforcer_ephemeral_repository_root.py +118 -0
  19. package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +251 -0
  20. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +22 -0
  21. package/hooks/blocking/test_pr_description_writer_gate.py +21 -3
  22. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +14 -2
  23. package/hooks/blocking/test_shell_substitution_blocker.py +105 -0
  24. package/hooks/blocking/test_state_description_blocker.py +84 -53
  25. package/hooks/blocking/test_tdd_enforcer.py +131 -1
  26. package/hooks/hooks.json +2 -2
  27. package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +22 -0
  28. package/hooks/hooks_constants/hosted_hook_runner.py +53 -0
  29. package/hooks/hooks_constants/piped_pytest_blocker_constants.py +9 -2
  30. package/hooks/hooks_constants/pr_description_writer_gate_constants.py +34 -0
  31. package/hooks/hooks_constants/shell_substitution_blocker_constants.py +10 -0
  32. package/hooks/hooks_constants/state_file_lock.py +76 -0
  33. package/hooks/hooks_constants/subprocess_budget_completeness_content.py +2 -2
  34. package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +27 -0
  35. package/hooks/hooks_constants/test_failure_recorder_constants.py +35 -0
  36. package/hooks/hooks_constants/test_hosted_hook_runner.py +55 -1
  37. package/hooks/hooks_constants/test_multi_edit_reconstruction.py +1 -0
  38. package/hooks/hooks_constants/test_subprocess_budget_completeness_content.py +33 -0
  39. package/hooks/observability/pr_description_writer_spawn_tracker.py +4 -12
  40. package/hooks/observability/session_file_edit_tracker.py +2 -55
  41. package/hooks/observability/test_failure_recorder.py +219 -0
  42. package/hooks/observability/test_pr_description_writer_spawn_tracker.py +6 -2
  43. package/hooks/observability/test_test_failure_recorder.py +173 -0
  44. package/hooks/validators/abbreviation_checks.py +3 -3
  45. package/hooks/validators/code_quality_checks.py +4 -4
  46. package/hooks/validators/config/baseline_identity_constants.py +12 -0
  47. package/hooks/validators/config/directory_exemption_constants.py +42 -8
  48. package/hooks/validators/config/system_temporary_roots_constants.py +15 -0
  49. package/hooks/validators/config/validator_base_constants.py +9 -0
  50. package/hooks/validators/fast_save_validators.py +23 -80
  51. package/hooks/validators/mypy_integration.py +10 -36
  52. package/hooks/validators/pr_reference_checks.py +2 -2
  53. package/hooks/validators/project_roots.py +58 -0
  54. package/hooks/validators/python_antipattern_checks.py +3 -3
  55. package/hooks/validators/python_style_checks.py +3 -2
  56. package/hooks/validators/react_checks.py +2 -9
  57. package/hooks/validators/run_all_validators.py +247 -148
  58. package/hooks/validators/security_checks.py +3 -3
  59. package/hooks/validators/system_temporary_roots.py +1 -1
  60. package/hooks/validators/test_directory_exemption_constants.py +30 -24
  61. package/hooks/validators/test_fast_save_validators.py +16 -16
  62. package/hooks/validators/test_hook_timing_harness.py +10 -1
  63. package/hooks/validators/test_run_all_validators.py +22 -0
  64. package/hooks/validators/test_run_all_validators_mutation_tool_parity.py +249 -0
  65. package/hooks/validators/test_run_all_validators_pretooluse.py +161 -0
  66. package/hooks/validators/test_run_all_validators_reporting.py +66 -0
  67. package/hooks/validators/test_run_all_validators_repository_root.py +202 -0
  68. package/hooks/validators/test_validator_base.py +51 -2
  69. package/hooks/validators/todo_checks.py +2 -2
  70. package/hooks/validators/type_safety_checks.py +3 -3
  71. package/hooks/validators/useless_test_checks.py +3 -3
  72. package/hooks/validators/validator_base.py +73 -1
  73. package/package.json +1 -1
  74. package/rules/shell-invocation.md +1 -1
  75. package/rules/testing.md +4 -0
  76. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +19 -0
  77. package/scripts/dev_env_scripts_constants/shared_tree_constants.py +17 -0
  78. package/scripts/grok_headless_runner.py +15 -12
  79. package/scripts/shared_tree_paths.py +104 -0
  80. package/scripts/test_shared_tree_paths.py +173 -0
@@ -247,11 +247,11 @@ def _validated_phase(phase: str) -> str:
247
247
  return phase
248
248
 
249
249
 
250
- def _codex_patch_issues(each_patch_file: CodexPatchFile) -> list[str]:
250
+ def _codex_patch_issues(each_patch_file: CodexPatchFile, repository_root: str = "") -> list[str]:
251
251
  """Run the existing code-rules verdict over one Codex patch view."""
252
252
  if not each_patch_file.post_content and each_patch_file.operation == "delete":
253
253
  return []
254
- if _is_hook_infrastructure_python_target(each_patch_file.file_path):
254
+ if _is_hook_infrastructure_python_target(each_patch_file.file_path, repository_root):
255
255
  all_issues = _hook_infrastructure_blocking_issues(
256
256
  each_patch_file.post_content,
257
257
  each_patch_file.file_path,
@@ -259,7 +259,7 @@ def _codex_patch_issues(each_patch_file: CodexPatchFile) -> list[str]:
259
259
  each_patch_file.prior_content,
260
260
  phase=EDIT_LANE_PHASE,
261
261
  )
262
- elif _is_validated_target(each_patch_file.file_path):
262
+ elif _is_validated_target(each_patch_file.file_path, repository_root):
263
263
  all_issues = validate_content_for_edit_lane(
264
264
  each_patch_file.post_content,
265
265
  each_patch_file.file_path,
@@ -306,7 +306,7 @@ def _report_codex_patch_payload(
306
306
  all_issues = [
307
307
  each_issue
308
308
  for each_patch_file in all_patch_files
309
- for each_issue in _codex_patch_issues(each_patch_file)
309
+ for each_issue in _codex_patch_issues(each_patch_file, working_directory or "")
310
310
  ]
311
311
  if all_issues:
312
312
  _write_deny_payload(
@@ -708,14 +708,16 @@ def validate_content_for_full_gate(
708
708
 
709
709
 
710
710
  def prior_and_post_edit_content(
711
- file_path: str, old_string: str, new_string: str,
711
+ file_path: str, old_string: str, new_string: str, is_replace_all: bool = False,
712
712
  ) -> tuple[str | None, str | None]:
713
713
  """Return the pre-edit and post-edit file content from a single disk read.
714
714
 
715
715
  Reads ``file_path`` once and derives both views from that single read so the
716
716
  prior and the reconstruction never diverge across two independent reads.
717
- The post-edit view replaces the first occurrence of ``old_string`` with
718
- ``new_string``, mirroring how the Edit tool itself applies a single
717
+ The post-edit view comes from apply_edits, the same reconstruction the
718
+ MultiEdit lane uses, so one payload never reconstructs two ways. It replaces
719
+ the first occurrence of ``old_string`` with ``new_string``, or every occurrence
720
+ when the Edit carries ``replace_all``, mirroring how the Edit tool applies a
719
721
  replacement.
720
722
 
721
723
  Returns ``(None, None)`` when the file cannot be read, ``old_string`` is
@@ -729,6 +731,7 @@ def prior_and_post_edit_content(
729
731
  file_path: The path of the file the Edit targets.
730
732
  old_string: The Edit's ``old_string`` fragment.
731
733
  new_string: The Edit's ``new_string`` fragment.
734
+ is_replace_all: Whether the Edit carries ``replace_all``.
732
735
 
733
736
  Returns:
734
737
  A ``(prior_content, post_edit_content)`` pair, or ``(None, None)`` when
@@ -741,14 +744,22 @@ def prior_and_post_edit_content(
741
744
  return None, None
742
745
  if old_string not in existing_content:
743
746
  return None, None
744
- return existing_content, existing_content.replace(old_string, new_string, 1)
747
+ one_edit = {
748
+ "old_string": old_string,
749
+ "new_string": new_string,
750
+ "replace_all": is_replace_all,
751
+ }
752
+ return existing_content, apply_edits(existing_content, [one_edit])
745
753
 
746
754
 
747
- def _is_validated_target(file_path: str) -> bool:
755
+ def _is_validated_target(file_path: str, repository_root: str = "") -> bool:
748
756
  """Return whether the path is subject to code-rules validation.
749
757
 
750
758
  Args:
751
759
  file_path: The destination path of the write, edit, or pre-check target.
760
+ repository_root: The session's working directory (the PreToolUse
761
+ payload's ``cwd``), so a path inside it is never exempted as
762
+ scratch merely because the repository sits under ``/tmp``.
752
763
 
753
764
  Returns:
754
765
  True when the path is non-empty, outside hook infrastructure, and
@@ -756,14 +767,14 @@ def _is_validated_target(file_path: str) -> bool:
756
767
  """
757
768
  if not file_path:
758
769
  return False
759
- if is_ephemeral_script_path(file_path):
770
+ if is_ephemeral_script_path(file_path, repository_root):
760
771
  return False
761
772
  if is_hook_infrastructure(file_path):
762
773
  return False
763
774
  return get_file_extension(file_path) in ALL_CODE_EXTENSIONS
764
775
 
765
776
 
766
- def _is_hook_infrastructure_python_target(file_path: str) -> bool:
777
+ def _is_hook_infrastructure_python_target(file_path: str, repository_root: str = "") -> bool:
767
778
  """Return whether the path is a hook-infrastructure Python file.
768
779
 
769
780
  The full code-rules suite exempts hook-infrastructure files, but the
@@ -773,13 +784,16 @@ def _is_hook_infrastructure_python_target(file_path: str) -> bool:
773
784
 
774
785
  Args:
775
786
  file_path: The destination path of the write, edit, or pre-check target.
787
+ repository_root: The session's working directory (the PreToolUse
788
+ payload's ``cwd``), so a path inside it is never exempted as
789
+ scratch merely because the repository sits under ``/tmp``.
776
790
 
777
791
  Returns:
778
792
  True when the path names a Python file inside hook infrastructure.
779
793
  """
780
794
  if not file_path:
781
795
  return False
782
- if is_ephemeral_script_path(file_path):
796
+ if is_ephemeral_script_path(file_path, repository_root):
783
797
  return False
784
798
  if not is_hook_infrastructure(file_path):
785
799
  return False
@@ -1170,6 +1184,7 @@ def _contents_for_validation(
1170
1184
  if tool_name == "Edit":
1171
1185
  prior_content, full_file_content_after_edit = prior_and_post_edit_content(
1172
1186
  file_path, old_string, new_string,
1187
+ is_replace_all=all_tool_input.get("replace_all") is True,
1173
1188
  )
1174
1189
  if full_file_content_after_edit is None:
1175
1190
  full_file_content_after_edit = existing_file_content(file_path)
@@ -1372,8 +1387,11 @@ def main(all_arguments: list[str]) -> None:
1372
1387
  if is_under_session_scratchpad(file_path, pretooluse_payload):
1373
1388
  sys.exit(0)
1374
1389
 
1375
- runs_full_verdict = _is_validated_target(file_path)
1376
- if not runs_full_verdict and not _is_hook_infrastructure_python_target(file_path):
1390
+ repository_root = str(pretooluse_payload.get("cwd") or "")
1391
+ runs_full_verdict = _is_validated_target(file_path, repository_root)
1392
+ if not runs_full_verdict and not _is_hook_infrastructure_python_target(
1393
+ file_path, repository_root
1394
+ ):
1377
1395
  sys.exit(0)
1378
1396
 
1379
1397
  validation_contents = _contents_for_validation(
@@ -14,6 +14,45 @@ from pathlib import Path
14
14
  _ENFORCER_SCRIPT_PATH = Path(__file__).with_name("code_rules_enforcer.py")
15
15
 
16
16
 
17
+ def repository_root() -> Path:
18
+ """Return the root of the checkout these tests run inside.
19
+
20
+ ::
21
+
22
+ <checkout>/packages/claude-dev-env/hooks/blocking/test_x.py -> <checkout>
23
+
24
+ Found by walking up for a ``.git`` entry rather than by counting
25
+ directories, so moving a test file does not silently anchor it somewhere
26
+ else. Falls back to the four-levels-up ancestor when no marker is found,
27
+ which is where the checkout root sits for this directory.
28
+
29
+ Returns:
30
+ The checkout root directory.
31
+ """
32
+ all_ancestors = Path(__file__).resolve().parents
33
+ for each_ancestor in all_ancestors:
34
+ if (each_ancestor / ".git").exists():
35
+ return each_ancestor
36
+ return all_ancestors[3]
37
+
38
+
39
+ def repository_file_path(relative_path: str) -> str:
40
+ """Return the absolute path of a repository-relative file in this checkout.
41
+
42
+ A hook resolves a bare relative path against the process working
43
+ directory, so a payload carrying one answers to wherever pytest was
44
+ invoked from. An absolute path under the checkout root keeps the
45
+ decision stable however the suite is launched.
46
+
47
+ Args:
48
+ relative_path: The target file's path relative to the checkout root.
49
+
50
+ Returns:
51
+ That file's absolute path inside this checkout.
52
+ """
53
+ return str(repository_root() / relative_path)
54
+
55
+
17
56
  def build_write_payload(file_path: str, content: str) -> str:
18
57
  """Build the JSON payload sent by a Write tool call."""
19
58
  return json.dumps(
@@ -251,10 +251,50 @@ def _extract_fstring_literal_parts(
251
251
  return "".join(display_segments), "".join(shape_segments)
252
252
 
253
253
 
254
- def is_ephemeral_script_path(file_path: str) -> bool:
254
+ def _normalized_absolute_comparison_path(raw_path: str) -> str:
255
+ """Return raw_path as an absolute, lowercase, forward-slash path for root comparison.
256
+
257
+ Strips a leading Windows drive letter after normalizing, so a POSIX root
258
+ check and a Windows root check compare on the same shape.
259
+
260
+ Args:
261
+ raw_path: The path to normalize.
262
+
263
+ Returns:
264
+ The absolute, lowercase, forward-slash path with any leading drive
265
+ letter removed.
266
+ """
267
+ return LEADING_DRIVE_LETTER_PATTERN.sub("", os.path.abspath(raw_path).replace("\\", "/").lower())
268
+
269
+
270
+ def _is_at_or_under_root(normalized_candidate: str, normalized_root: str) -> bool:
271
+ """Return whether normalized_candidate sits at or under normalized_root.
272
+
273
+ Requires a path-segment boundary (an exact match or a ``/`` before the
274
+ remainder), so ``/tmp/repo-sibling`` never counts as under ``/tmp/repo``.
275
+
276
+ Args:
277
+ normalized_candidate: A path already run through
278
+ ``_normalized_absolute_comparison_path``.
279
+ normalized_root: A root path already run through
280
+ ``_normalized_absolute_comparison_path``.
281
+
282
+ Returns:
283
+ True when normalized_candidate equals normalized_root or names a
284
+ descendant of it.
285
+ """
286
+ return normalized_candidate == normalized_root or normalized_candidate.startswith(normalized_root + "/")
287
+
288
+
289
+ def is_ephemeral_script_path(file_path: str, repository_root: str = "") -> bool:
255
290
  """Return True when the path is rooted at a throwaway scratch directory.
256
291
 
257
292
  Checks these sources in order:
293
+ - ``repository_root`` — when given, a path inside it is never ephemeral,
294
+ whatever directory the repository itself is checked out under. This
295
+ keeps a repository placed under ``/tmp`` fully gated: the exemption
296
+ below is for scratch outside every repository, not for the repository's
297
+ own files.
258
298
  - ``$CLAUDE_JOB_DIR/tmp`` — only when ``CLAUDE_JOB_DIR`` is set.
259
299
  - Root-anchored ``/tmp`` and ``/temp`` (drive-letter tolerant).
260
300
 
@@ -262,20 +302,28 @@ def is_ephemeral_script_path(file_path: str) -> bool:
262
302
  its sandbox fixtures there, so matching it would exempt the suite's own
263
303
  targets. Returns False when ``CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT``
264
304
  is truthy, when ``file_path`` is empty, and when no root matches. Path
265
- classification is string-only; the file need not exist.
305
+ classification is string-only; neither path need exist.
266
306
 
267
307
  Args:
268
308
  file_path: The candidate path to classify.
309
+ repository_root: The session's working directory — typically the
310
+ PreToolUse payload's ``cwd`` — or empty when no such directory is
311
+ known. A path outside this directory is unaffected by it.
269
312
 
270
313
  Returns:
271
- True when the path is rooted at a recognized ephemeral scratch directory.
314
+ True when the path is rooted at a recognized ephemeral scratch
315
+ directory and does not sit inside ``repository_root``.
272
316
  """
273
317
  if not file_path:
274
318
  return False
275
319
  disable_value = os.environ.get(EPHEMERAL_EXEMPT_DISABLE_ENVIRONMENT_VARIABLE_NAME, "").strip().lower()
276
320
  if disable_value in ALL_EPHEMERAL_EXEMPT_DISABLE_TRUTHY_VALUES:
277
321
  return False
278
- normalized = LEADING_DRIVE_LETTER_PATTERN.sub("", os.path.abspath(file_path).replace("\\", "/").lower())
322
+ normalized = _normalized_absolute_comparison_path(file_path)
323
+ if repository_root and _is_at_or_under_root(
324
+ normalized, _normalized_absolute_comparison_path(repository_root)
325
+ ):
326
+ return False
279
327
  all_temp_roots: list[str] = []
280
328
  job_dir = os.environ.get(CLAUDE_JOB_DIR_ENVIRONMENT_VARIABLE_NAME)
281
329
  if job_dir:
@@ -286,7 +334,7 @@ def is_ephemeral_script_path(file_path: str) -> bool:
286
334
  for each_root in ALL_ROOT_ANCHORED_EPHEMERAL_DIRECTORIES:
287
335
  all_temp_roots.append(each_root)
288
336
  for each_temp_root in all_temp_roots:
289
- if normalized == each_temp_root or normalized.startswith(each_temp_root + "/"):
337
+ if _is_at_or_under_root(normalized, each_temp_root):
290
338
  return True
291
339
  return False
292
340
 
@@ -426,23 +474,25 @@ def is_ephemeral_path(file_path: str, hook_payload: dict | None = None) -> bool:
426
474
  Combines the path families a repo gate skips: the root-anchored ephemeral
427
475
  scratch directories (``/tmp`` and ``$CLAUDE_JOB_DIR/tmp``), the harness
428
476
  session scratchpad, and a coding agent's own home-directory tooling such as
429
- ``~/.grok/runs/``. The session scratchpad match reads the session id from the
430
- payload when one is supplied, and from the harness environment variable
431
- otherwise, so a caller that holds no payload still gets the match. The
432
- run_all_validators PreToolUse gate calls this predicate to skip an exempt
433
- target before it validates. The code-rules and TDD gates call the underlying
434
- predicates ``is_ephemeral_script_path``, ``is_agent_home_tooling``, and
477
+ ``~/.grok/runs/``. A path inside the payload's own working directory
478
+ (``cwd``) is never treated as scratch, so a repository checked out under
479
+ ``/tmp`` still receives full enforcement. The scratchpad match reads the
480
+ session id from the payload when supplied, else the harness environment
481
+ variable. The code-rules and TDD gates call the underlying predicates
482
+ ``is_ephemeral_script_path``, ``is_agent_home_tooling``, and
435
483
  ``is_under_session_scratchpad`` directly.
436
484
 
437
485
  Args:
438
486
  file_path: The candidate path to classify.
439
- hook_payload: The PreToolUse payload carrying the session id, or None to
440
- read the session id from the environment alone.
487
+ hook_payload: The PreToolUse payload carrying the session id and the
488
+ working directory (``cwd``), or None to read the session id from
489
+ the environment alone.
441
490
 
442
491
  Returns:
443
492
  True when the path is scratch, agent tooling, or the session scratchpad.
444
493
  """
445
- if is_ephemeral_script_path(file_path):
494
+ repository_root = str((hook_payload or {}).get("cwd") or "")
495
+ if is_ephemeral_script_path(file_path, repository_root):
446
496
  return True
447
497
  if is_agent_home_tooling(file_path):
448
498
  return True
@@ -26,7 +26,6 @@ from __future__ import annotations
26
26
  import json
27
27
  import shlex
28
28
  import sys
29
- import tempfile
30
29
  from pathlib import Path
31
30
 
32
31
  _blocking_dir = str(Path(__file__).resolve().parent)
@@ -41,6 +40,9 @@ from hooks_constants.bash_pre_tool_use_dispatcher_constants import ( # noqa: E4
41
40
  ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
42
41
  )
43
42
  from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
43
+ from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
44
+ read_hook_input_dictionary_from_stdin,
45
+ )
44
46
  from hooks_constants.pr_description_writer_gate_constants import ( # noqa: E402
45
47
  ALL_GH_EXECUTABLE_BASENAMES,
46
48
  COMMAND_FIELD_NAME,
@@ -49,15 +51,14 @@ from hooks_constants.pr_description_writer_gate_constants import ( # noqa: E402
49
51
  GH_PR_CREATE_MINIMUM_TOKEN_COUNT,
50
52
  PR_SUBCOMMAND_TOKEN,
51
53
  SPAWN_BYPASS_MARKER,
52
- SPAWN_MARKER_FILE_PREFIX,
53
- SPAWN_MARKER_FILE_SUFFIX,
54
54
  TOOL_INPUT_FIELD_NAME,
55
55
  TOOL_NAME_FIELD_NAME,
56
+ spawn_marker_path,
56
57
  )
57
58
  from hooks_constants.session_edit_stage_gate_constants import ( # noqa: E402
58
59
  ALL_COMMAND_SEPARATOR_TOKENS,
59
- SESSION_ID_UNSAFE_CHARACTERS_PATTERN,
60
60
  )
61
+ from sensitive_file_protector import build_deny_response # noqa: E402
61
62
 
62
63
 
63
64
  def _all_command_tokens(shell_command: str) -> list[str] | None:
@@ -149,7 +150,9 @@ def _all_pull_request_creation_segment_indexes(all_segments: list[list[str]]) ->
149
150
 
150
151
 
151
152
  def _only_final_create_carries_bypass(
152
- all_tokens: list[str], all_creation_segment_indexes: list[int]
153
+ all_tokens: list[str],
154
+ all_segments: list[list[str]],
155
+ all_creation_segment_indexes: list[int],
153
156
  ) -> bool:
154
157
  """Report whether one final gh pr create carries the final bypass comment.
155
158
 
@@ -159,6 +162,7 @@ def _only_final_create_carries_bypass(
159
162
 
160
163
  Args:
161
164
  all_tokens: The tokens of the whole command.
165
+ all_segments: The command's token segments, already split by the caller.
162
166
  all_creation_segment_indexes: Indexes of segments that create pull requests.
163
167
 
164
168
  Returns:
@@ -166,7 +170,7 @@ def _only_final_create_carries_bypass(
166
170
  """
167
171
  if not _carries_trailing_bypass_marker(all_tokens):
168
172
  return False
169
- final_segment_index = len(_all_command_segments(all_tokens)) - 1
173
+ final_segment_index = len(all_segments) - 1
170
174
  return all_creation_segment_indexes == [final_segment_index]
171
175
 
172
176
 
@@ -182,11 +186,8 @@ def _spawn_is_recorded(session_id: str) -> bool:
182
186
  Returns:
183
187
  True when this session's marker file is present.
184
188
  """
185
- sanitized_session_id = SESSION_ID_UNSAFE_CHARACTERS_PATTERN.sub("", session_id)
186
- if not sanitized_session_id:
187
- return False
188
- file_name = f"{SPAWN_MARKER_FILE_PREFIX}{sanitized_session_id}{SPAWN_MARKER_FILE_SUFFIX}"
189
- return (Path(tempfile.gettempdir()) / file_name).exists()
189
+ marker_file = spawn_marker_path(session_id)
190
+ return marker_file is not None and marker_file.exists()
190
191
 
191
192
 
192
193
  def _shell_command_or_none(all_hook_payload: dict[str, object]) -> str | None:
@@ -211,19 +212,12 @@ def _shell_command_or_none(all_hook_payload: dict[str, object]) -> str | None:
211
212
 
212
213
  def _emit_denial() -> None:
213
214
  """Write the PreToolUse deny payload to stdout and log the block."""
214
- deny_payload = {
215
- "hookSpecificOutput": {
216
- "hookEventName": "PreToolUse",
217
- "permissionDecision": "deny",
218
- "permissionDecisionReason": CORRECTIVE_MESSAGE,
219
- }
220
- }
221
215
  log_hook_block(
222
216
  calling_hook_name="pr_description_writer_gate.py",
223
217
  hook_event="PreToolUse",
224
218
  block_reason=CORRECTIVE_MESSAGE,
225
219
  )
226
- sys.stdout.write(json.dumps(deny_payload) + "\n")
220
+ sys.stdout.write(json.dumps(build_deny_response(CORRECTIVE_MESSAGE)) + "\n")
227
221
  sys.stdout.flush()
228
222
 
229
223
 
@@ -235,11 +229,8 @@ def main() -> None:
235
229
  tokenize, a command opening no pull request, a trailing bypass marker, and
236
230
  a recorded spawn each pass through untouched.
237
231
  """
238
- try:
239
- hook_payload = json.load(sys.stdin)
240
- except json.JSONDecodeError:
241
- sys.exit(0)
242
- if not isinstance(hook_payload, dict):
232
+ hook_payload = read_hook_input_dictionary_from_stdin()
233
+ if hook_payload is None:
243
234
  sys.exit(0)
244
235
  shell_command = _shell_command_or_none(hook_payload)
245
236
  if shell_command is None:
@@ -251,7 +242,9 @@ def main() -> None:
251
242
  all_creation_segment_indexes = _all_pull_request_creation_segment_indexes(all_segments)
252
243
  if not all_creation_segment_indexes:
253
244
  sys.exit(0)
254
- if _only_final_create_carries_bypass(all_tokens, all_creation_segment_indexes):
245
+ if _only_final_create_carries_bypass(
246
+ all_tokens, all_segments, all_creation_segment_indexes
247
+ ):
255
248
  sys.exit(0)
256
249
  if _spawn_is_recorded(str(hook_payload.get("session_id") or "")):
257
250
  sys.exit(0)
@@ -23,6 +23,12 @@ ignored because bash performs no substitution inside them (for example
23
23
  are treated as literal only when the count of preceding backslashes is odd;
24
24
  an even count (including zero) means the backtick is a live substitution.
25
25
 
26
+ A quoted heredoc body is stripped for the same reason. `<<'EOF'`, `<<"EOF"`
27
+ and `<<\\EOF` each tell bash to expand nothing between the opener and the
28
+ terminator, so a backtick there is text the file receives rather than a
29
+ command the shell runs. A bare `<<EOF` expands its body and keeps its scan,
30
+ and text outside any heredoc is scanned either way.
31
+
26
32
  Bash arithmetic expansion ``$((...))`` is explicitly NOT blocked: it does not
27
33
  spawn a subshell and does not defeat the allowlist matcher. Telling it apart
28
34
  from a disguised command substitution takes more than counting two open
@@ -41,11 +47,6 @@ another ``)`` (an ``))`` pair), the expansion is arithmetic and passes. When
41
47
  the closer that brings depth to zero is a lone ``)``, bash has fallen back to
42
48
  running a parenthesized command inside the substitution, so it is blocked.
43
49
  This hook applies that walk, tracking paren depth from the opening ``$((``.
44
-
45
- False positives are still possible when the user is intentionally writing
46
- shell-script content (for example, authoring a .sh file via heredoc) and the
47
- substitution is literal payload rather than something to execute. In that
48
- case, author the file with the Write tool, not a Bash heredoc.
49
50
  """
50
51
 
51
52
  from __future__ import annotations
@@ -53,6 +54,7 @@ from __future__ import annotations
53
54
  import json
54
55
  import sys
55
56
  from pathlib import Path
57
+ from re import Match
56
58
 
57
59
  _hooks_dir = str(Path(__file__).resolve().parent.parent)
58
60
  if _hooks_dir not in sys.path:
@@ -74,6 +76,11 @@ from hooks_constants.shell_substitution_blocker_constants import ( # noqa: E402
74
76
  PERMISSION_DECISION_KEY,
75
77
  PERMISSION_DECISION_REASON_KEY,
76
78
  PROCESS_SUBSTITUTION_PATTERN,
79
+ BACKSLASH_CHARACTER,
80
+ COMMAND_LINE_SEPARATOR,
81
+ DOUBLE_QUOTE_CHARACTER,
82
+ NO_TERMINATOR_INDEX,
83
+ SINGLE_QUOTE_CHARACTER,
77
84
  SINGLE_QUOTED_RUN_PATTERN,
78
85
  STRIPPED_RUN_REPLACEMENT,
79
86
  TOOL_INPUT_KEY,
@@ -81,10 +88,169 @@ from hooks_constants.shell_substitution_blocker_constants import ( # noqa: E402
81
88
  )
82
89
 
83
90
 
91
+ from hooks_constants.piped_pytest_blocker_constants import ( # noqa: E402
92
+ HEREDOC_ESCAPE_GROUP,
93
+ HEREDOC_OPENER_PATTERN,
94
+ HEREDOC_QUOTE_GROUP,
95
+ HEREDOC_TAB_STRIP_GROUP,
96
+ HEREDOC_TAB_STRIP_MARKER,
97
+ HEREDOC_TERMINATOR_GROUP,
98
+ HEREDOC_UNQUOTED_MARKER,
99
+ )
100
+ from hooks_constants.shell_command_pipeline import ( # noqa: E402
101
+ PendingHeredoc,
102
+ closes_the_heredoc,
103
+ )
104
+
105
+
84
106
  def _strip_single_quoted_runs(command: str) -> str:
85
107
  return SINGLE_QUOTED_RUN_PATTERN.sub(STRIPPED_RUN_REPLACEMENT, command)
86
108
 
87
109
 
110
+ def _opener_suppresses_expansion(opener_match: Match[str]) -> bool:
111
+ """Return whether an opener's delimiter is quoted or backslash-escaped.
112
+
113
+ ::
114
+
115
+ <<'EOF' -> True quoted, the body is literal
116
+ <<EOF -> False bare, bash expands the body
117
+
118
+ Args:
119
+ opener_match: One HEREDOC_OPENER_PATTERN match from a command line.
120
+
121
+ Returns:
122
+ True when bash expands nothing down to this opener's terminator.
123
+ """
124
+ is_quoted = opener_match.group(HEREDOC_QUOTE_GROUP) != HEREDOC_UNQUOTED_MARKER
125
+ is_escaped = opener_match.group(HEREDOC_ESCAPE_GROUP) != HEREDOC_UNQUOTED_MARKER
126
+ return is_quoted or is_escaped
127
+
128
+
129
+ def _sits_outside_quotes(command_line: str, character_index: int) -> bool:
130
+ """Return whether a position on a line sits outside every quoted run.
131
+
132
+ ::
133
+
134
+ cat <<'EOF' index of << -> True bash reads an opener
135
+ echo "<<'EOF'" index of << -> False bash reads a string
136
+
137
+ Counting unescaped quotes before the position tells the two apart: an even
138
+ count of each quote character means every run opened before it also closed.
139
+
140
+ Args:
141
+ command_line: One line of the command.
142
+ character_index: Position of the candidate opener on that line.
143
+
144
+ Returns:
145
+ True when bash reads this position as shell syntax.
146
+ """
147
+ single_quote_count = 0
148
+ double_quote_count = 0
149
+ is_escaped = False
150
+ for each_character in command_line[:character_index]:
151
+ if is_escaped:
152
+ is_escaped = False
153
+ continue
154
+ if each_character == BACKSLASH_CHARACTER:
155
+ is_escaped = True
156
+ continue
157
+ if each_character == SINGLE_QUOTE_CHARACTER:
158
+ single_quote_count += 1
159
+ elif each_character == DOUBLE_QUOTE_CHARACTER:
160
+ double_quote_count += 1
161
+ return single_quote_count % 2 == 0 and double_quote_count % 2 == 0
162
+
163
+
164
+ def _first_literal_opener(command_line: str) -> Match[str] | None:
165
+ """Return the line's first heredoc opener whose body bash leaves literal.
166
+
167
+ An opener spelled inside a quoted string is text bash passes on, so it
168
+ opens nothing and the lines below it stay in the scan.
169
+
170
+ Args:
171
+ command_line: One line of the command.
172
+
173
+ Returns:
174
+ The matching opener, or None when the line opens no literal heredoc.
175
+ """
176
+ for each_opener in HEREDOC_OPENER_PATTERN.finditer(command_line):
177
+ if not _sits_outside_quotes(command_line, each_opener.start()):
178
+ continue
179
+ if _opener_suppresses_expansion(each_opener):
180
+ return each_opener
181
+ return None
182
+
183
+
184
+ def _index_past_literal_body(
185
+ all_lines: list[str], body_start_index: int, opener_match: Match[str]
186
+ ) -> int:
187
+ """Return the index of the line that closes a literal heredoc body.
188
+
189
+ The terminator match, including the ``<<-`` tab-strip rule, comes from
190
+ ``closes_the_heredoc`` so this hook and the shared pipeline agree on what
191
+ ends a body.
192
+
193
+ Args:
194
+ all_lines: Every line of the command.
195
+ body_start_index: Index of the body's first line.
196
+ opener_match: The opener whose terminator closes this body.
197
+
198
+ Returns:
199
+ The index of the terminator line, or NO_TERMINATOR_INDEX when the
200
+ command holds none.
201
+ """
202
+ pending_heredoc = PendingHeredoc(
203
+ terminator=opener_match.group(HEREDOC_TERMINATOR_GROUP),
204
+ allows_leading_tabs=(
205
+ opener_match.group(HEREDOC_TAB_STRIP_GROUP) == HEREDOC_TAB_STRIP_MARKER
206
+ ),
207
+ )
208
+ for each_index in range(body_start_index, len(all_lines)):
209
+ if closes_the_heredoc(all_lines[each_index], pending_heredoc):
210
+ return each_index
211
+ return NO_TERMINATOR_INDEX
212
+
213
+
214
+ def _strip_quoted_heredoc_bodies(command: str) -> str:
215
+ """Return the command holding its openers, terminators, and shell text.
216
+
217
+ ::
218
+
219
+ cat <<'EOF' -> cat <<'EOF'
220
+ inert text EOF
221
+ EOF
222
+
223
+ A quoted or escaped delimiter makes the lines below it text the shell hands
224
+ on, so a scan of those lines reports a substitution bash never runs. A bare
225
+ delimiter keeps its body, and text outside any heredoc keeps its scan.
226
+
227
+ An opener with no terminator returns the command whole. Bash consumes the
228
+ rest of the input in that case, and dropping those lines would hide a live
229
+ substitution among them.
230
+
231
+ Args:
232
+ command: The raw Bash command string from the tool input.
233
+
234
+ Returns:
235
+ The command with each literal heredoc body dropped.
236
+ """
237
+ all_lines = command.split(COMMAND_LINE_SEPARATOR)
238
+ all_kept_lines: list[str] = []
239
+ each_index = 0
240
+ while each_index < len(all_lines):
241
+ current_line = all_lines[each_index]
242
+ all_kept_lines.append(current_line)
243
+ each_index += 1
244
+ opener_match = _first_literal_opener(current_line)
245
+ if opener_match is None:
246
+ continue
247
+ terminator_index = _index_past_literal_body(all_lines, each_index, opener_match)
248
+ if terminator_index == NO_TERMINATOR_INDEX:
249
+ return command
250
+ each_index = terminator_index
251
+ return COMMAND_LINE_SEPARATOR.join(all_kept_lines)
252
+
253
+
88
254
  def _closes_as_arithmetic_expansion(command: str, scan_start_index: int) -> bool:
89
255
  """Return True when a ``$((`` expansion closes on an ``))`` pair.
90
256
 
@@ -132,8 +298,9 @@ def has_shell_substitution(command: str) -> bool:
132
298
  echo '$(not-executed)' ok: single-quoted, inert
133
299
  echo $((2 + 2)) ok: arithmetic expansion
134
300
 
135
- Single-quoted runs are stripped first because bash substitutes nothing
136
- inside them. A backtick preceded by an odd backslash count is escaped.
301
+ Literal heredoc bodies are dropped first, then single-quoted runs are
302
+ stripped, because bash substitutes nothing inside either. A backtick
303
+ preceded by an odd backslash count is escaped.
137
304
  A `$((` pair is walked to its terminator (module docstring) to tell
138
305
  real arithmetic from a disguised subshell.
139
306
 
@@ -143,7 +310,7 @@ def has_shell_substitution(command: str) -> bool:
143
310
  Returns:
144
311
  True when a substitution the matcher cannot descend into is present.
145
312
  """
146
- scannable_command = _strip_single_quoted_runs(command)
313
+ scannable_command = _strip_single_quoted_runs(_strip_quoted_heredoc_bodies(command))
147
314
  if _has_unsafe_dollar_paren_expansion(scannable_command):
148
315
  return True
149
316
  if EVEN_BACKSLASH_BACKTICK_PATTERN.search(scannable_command):