claude-dev-env 1.59.0 → 1.61.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 (81) hide show
  1. package/CLAUDE.md +4 -0
  2. package/audit-rubrics/category_rubrics/category-b-selector-engine-compat.md +1 -1
  3. package/audit-rubrics/category_rubrics/category-e-dead-code.md +1 -0
  4. package/audit-rubrics/category_rubrics/category-f-silent-failures.md +1 -1
  5. package/audit-rubrics/category_rubrics/category-o-docstring-vs-impl-drift.md +1 -1
  6. package/audit-rubrics/prompts/category-b-selector-engine-compat.md +2 -2
  7. package/audit-rubrics/prompts/category-e-dead-code.md +17 -4
  8. package/audit-rubrics/prompts/category-f-silent-failures.md +1 -0
  9. package/docs/CODE_RULES.md +2 -2
  10. package/hooks/blocking/code_rules_annotations_length.py +189 -10
  11. package/hooks/blocking/code_rules_dead_module_constant.py +321 -0
  12. package/hooks/blocking/code_rules_duplicate_body.py +152 -0
  13. package/hooks/blocking/code_rules_enforcer.py +38 -15
  14. package/hooks/blocking/code_rules_orphan_css_class.py +196 -0
  15. package/hooks/blocking/code_rules_typeddict_stub.py +172 -0
  16. package/hooks/blocking/config/__init__.py +5 -0
  17. package/hooks/blocking/config/verified_commit_constants.py +118 -0
  18. package/hooks/blocking/destructive_command_blocker.py +483 -61
  19. package/hooks/blocking/test_code_rules_enforcer_annotations.py +240 -0
  20. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
  21. package/hooks/blocking/test_code_rules_enforcer_cross_skill_duplicate.py +146 -0
  22. package/hooks/blocking/test_code_rules_enforcer_dead_module_constant.py +188 -0
  23. package/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py +82 -0
  24. package/hooks/blocking/test_code_rules_enforcer_orphan_css_class.py +196 -0
  25. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias.py +415 -0
  26. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +156 -0
  27. package/hooks/blocking/test_destructive_command_blocker.py +213 -0
  28. package/hooks/blocking/test_verdict_directory_write_blocker.py +720 -0
  29. package/hooks/blocking/test_verification_verdict_store.py +490 -0
  30. package/hooks/blocking/test_verified_commit_gate.py +495 -0
  31. package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +131 -0
  32. package/hooks/blocking/test_verifier_verdict_minter.py +193 -0
  33. package/hooks/blocking/verdict_directory_write_blocker.py +667 -0
  34. package/hooks/blocking/verification_verdict_store.py +686 -0
  35. package/hooks/blocking/verified_commit_gate.py +535 -0
  36. package/hooks/blocking/verified_commit_message_accuracy_blocker.py +152 -0
  37. package/hooks/blocking/verifier_verdict_minter.py +221 -0
  38. package/hooks/diagnostic/test_hook_log_extractor.py +3 -3
  39. package/hooks/hooks.json +43 -1
  40. package/hooks/hooks_constants/blocking_check_limits.py +1 -0
  41. package/hooks/hooks_constants/code_rules_enforcer_constants.py +6 -0
  42. package/hooks/hooks_constants/dead_module_constant_constants.py +20 -0
  43. package/hooks/hooks_constants/destructive_command_segment_constants.py +15 -0
  44. package/hooks/hooks_constants/duplicate_function_body_constants.py +22 -5
  45. package/hooks/hooks_constants/orphan_css_class_constants.py +40 -0
  46. package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +1 -1
  47. package/hooks/validation/mypy_validator.py +59 -7
  48. package/hooks/validation/test_mypy_validator.py +94 -0
  49. package/package.json +1 -1
  50. package/rules/file-global-constants.md +7 -1
  51. package/rules/no-cross-skill-duplicate-helpers.md +29 -0
  52. package/rules/orphan-css-class.md +23 -0
  53. package/skills/_shared/pr-loop/scripts/preflight_worktree.py +392 -0
  54. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/preflight_constants.py +70 -0
  55. package/skills/_shared/pr-loop/scripts/test_preflight_worktree.py +263 -0
  56. package/skills/autoconverge/SKILL.md +54 -17
  57. package/skills/autoconverge/reference/closing-report.md +59 -17
  58. package/skills/autoconverge/workflow/aggregate_runs.py +371 -0
  59. package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +192 -76
  60. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +76 -0
  61. package/skills/autoconverge/workflow/converge.contract.test.mjs +395 -206
  62. package/skills/autoconverge/workflow/converge.mjs +520 -57
  63. package/skills/autoconverge/workflow/convergence_summary.py +110 -0
  64. package/skills/autoconverge/workflow/fixtures/wf_run/subagents/workflows/wf_881252e6-700/agent-ab1c2d3e4f5a6b7c8.jsonl +2 -0
  65. package/skills/autoconverge/workflow/fixtures/wf_run/workflows/wf_881252e6-700.json +7 -0
  66. package/skills/autoconverge/workflow/render_report.py +488 -397
  67. package/skills/autoconverge/workflow/test_aggregate_runs.py +134 -0
  68. package/skills/autoconverge/workflow/test_convergence_summary.py +132 -0
  69. package/skills/autoconverge/workflow/test_render_report.py +518 -259
  70. package/skills/pr-converge/reference/per-tick.md +28 -8
  71. package/skills/rebase/SKILL.md +2 -4
  72. package/system-prompts/software-engineer.xml +2 -6
  73. package/hooks/blocking/content_search_to_zoekt_redirector.py +0 -59
  74. package/hooks/blocking/content_search_zoekt_bash_block_reason.py +0 -25
  75. package/hooks/blocking/content_search_zoekt_block_payload.py +0 -21
  76. package/hooks/blocking/content_search_zoekt_indexed_paths.py +0 -24
  77. package/hooks/blocking/content_search_zoekt_indexed_roots_config.py +0 -131
  78. package/hooks/blocking/content_search_zoekt_redirect_guidance.py +0 -52
  79. package/hooks/blocking/test_content_search_to_zoekt_redirector_integration.py +0 -61
  80. package/hooks/blocking/test_content_search_to_zoekt_redirector_unit.py +0 -92
  81. package/hooks/blocking/test_content_search_zoekt_indexed_roots_config.py +0 -102
@@ -22,7 +22,13 @@ from hooks_constants.convergence_branch_constants import ( # noqa: E402
22
22
  from hooks_constants.destructive_command_segment_constants import ( # noqa: E402
23
23
  ALL_BENIGN_COMPOUND_SEGMENT_COMMANDS,
24
24
  ALL_COMMAND_LAUNCHER_WRAPPER_COMMANDS,
25
+ ALL_KNOWN_TEMPORARY_ENVIRONMENT_VARIABLE_NAMES,
25
26
  ALL_FILE_WRITING_OUTPUT_FLAGS_BY_BENIGN_PROGRAM,
27
+ ALL_FIND_EXEC_ACTION_FLAGS,
28
+ ALL_FIND_EXEC_ACTION_TERMINATORS,
29
+ ALL_FIND_GLOBAL_OPTION_FLAGS_TAKING_A_VALUE,
30
+ ALL_FIND_GLOBAL_OPTION_FLAGS_WITHOUT_VALUE,
31
+ FIND_OPTIMIZATION_LEVEL_OPTION_PREFIX,
26
32
  ALL_GH_API_GLUED_REQUEST_BODY_FIELD_FLAG_PREFIXES,
27
33
  ALL_GH_API_REQUEST_BODY_FIELD_FLAGS,
28
34
  ALL_GH_HTTP_WRITE_METHOD_FLAGS,
@@ -38,6 +44,7 @@ from hooks_constants.destructive_command_segment_constants import ( # noqa: E40
38
44
  ALL_SHELL_CONTROL_OPERATOR_TOKENS,
39
45
  ALL_STRING_ARGUMENT_EXECUTION_FLAGS,
40
46
  ALL_SUBSHELL_GROUPING_CHARACTERS,
47
+ FIND_PROGRAM_NAME,
41
48
  GH_HTTP_READ_ONLY_METHOD,
42
49
  GH_LONG_METHOD_FLAG_EQUALS_PREFIX,
43
50
  GH_SHORT_METHOD_FLAG_PREFIX,
@@ -56,14 +63,35 @@ def gh_redirect_is_active() -> bool:
56
63
  return env_var_value in GH_REDIRECT_ACTIVE_TRUTHY_VALUES
57
64
 
58
65
  def directory_is_ephemeral(directory_path: str) -> bool:
66
+ """Return True when a directory belongs to the ephemeral auto-allow namespace.
67
+
68
+ A directory is ephemeral when the environment override has not disabled the
69
+ auto-allow and the path matches one of these sources, in order: a path
70
+ containing a ``/worktrees/`` or ``/worktree/`` segment; a path rooted at ``/tmp``
71
+ or ``/temp`` (drive-letter tolerant); a path under the OS temporary root; a path
72
+ git reports inside a worktree admin directory. Returns False when the
73
+ ``CLAUDE_DESTRUCTIVE_DISABLE_EPHEMERAL_AUTO_ALLOW`` override is truthy and when no
74
+ source matches.
75
+
76
+ Args:
77
+ directory_path: The filesystem path to classify.
78
+
79
+ Returns:
80
+ True when the directory belongs to the ephemeral auto-allow namespace.
81
+ """
59
82
  ephemeral_auto_allow_disabled_env_var = "CLAUDE_DESTRUCTIVE_DISABLE_EPHEMERAL_AUTO_ALLOW"
60
83
  truthy_string_values = frozenset({"1", "true", "yes", "on"})
61
84
  if os.environ.get(ephemeral_auto_allow_disabled_env_var, "").strip().lower() in truthy_string_values:
62
85
  return False
63
86
  forward_slash_normalized_directory_path = os.path.normpath(directory_path).replace("\\", "/").lower()
64
- all_ephemeral_path_segments = ("/worktrees/", "/worktree/", "/tmp/", "/temp/")
65
- for each_segment in all_ephemeral_path_segments:
66
- if each_segment in forward_slash_normalized_directory_path + "/":
87
+ all_worktree_path_segments = ("/worktrees/", "/worktree/")
88
+ for each_worktree_segment in all_worktree_path_segments:
89
+ if each_worktree_segment in forward_slash_normalized_directory_path + "/":
90
+ return True
91
+ drive_letter_stripped_path = re.sub(r"^[a-z]:", "", forward_slash_normalized_directory_path)
92
+ all_root_anchored_temporary_directories = ("/tmp", "/temp")
93
+ for each_temporary_root in all_root_anchored_temporary_directories:
94
+ if drive_letter_stripped_path == each_temporary_root or drive_letter_stripped_path.startswith(each_temporary_root + "/"):
67
95
  return True
68
96
  system_temporary_root = os.path.normpath(tempfile.gettempdir()).replace("\\", "/").lower()
69
97
  if forward_slash_normalized_directory_path.startswith(system_temporary_root + "/") or forward_slash_normalized_directory_path == system_temporary_root:
@@ -222,10 +250,26 @@ def _command_contains_windows_style_path(command: str) -> bool:
222
250
 
223
251
 
224
252
  def _split_command_preserving_windows_backslashes(command: str) -> list[str]:
253
+ """Tokenize a command, normalizing Windows backslashes while preserving the find terminator.
254
+
255
+ Plain POSIX ``shlex.split`` unescapes a ``\\;`` find action terminator to a
256
+ standalone ``;`` token. When the command carries Windows backslashes, the global
257
+ backslash-to-forward-slash normalization would otherwise rewrite that ``\\;`` to
258
+ ``/;`` and bury the terminator inside a data token. The find action terminator is
259
+ restored to a standalone ``;`` token before the normalization so action slicing and
260
+ target collection see the same terminator on both platforms.
261
+
262
+ Args:
263
+ command: The raw Bash command string from the tool input.
264
+
265
+ Returns:
266
+ The shlex tokens of the command.
267
+ """
225
268
  if "\\" in command and (
226
269
  os.name == "nt" or _command_contains_windows_style_path(command)
227
270
  ):
228
- forward_slash_normalized_command = command.replace("\\", "/")
271
+ find_terminator_preserved_command = re.sub(r"\\;", " ; ", command)
272
+ forward_slash_normalized_command = find_terminator_preserved_command.replace("\\", "/")
229
273
  return shlex.split(forward_slash_normalized_command)
230
274
  return shlex.split(command)
231
275
 
@@ -423,6 +467,10 @@ def _strip_leading_launcher_wrapper(all_command_tokens: list[str]) -> list[str]
423
467
  ``setsid``, ``ionice``, ``nice``, ``stdbuf``) returns its first positional as the
424
468
  wrapped program. Returns None when the leading program is not a launcher wrapper.
425
469
 
470
+ A leading subshell or brace grouping character glued to the launcher token is
471
+ stripped before the launcher is identified, so ``(timeout N bash -c '...')``
472
+ resolves to its wrapped program.
473
+
426
474
  Args:
427
475
  all_command_tokens: Tokens of one shell segment.
428
476
 
@@ -442,7 +490,7 @@ def _strip_leading_launcher_wrapper(all_command_tokens: list[str]) -> list[str]
442
490
  )
443
491
  if first_program_index is None:
444
492
  return None
445
- leading_command_basename = Path(all_command_tokens[first_program_index]).name.lower()
493
+ leading_command_basename = Path(_strip_leading_subshell_grouping_characters(all_command_tokens[first_program_index])).name.lower()
446
494
  if leading_command_basename not in ALL_COMMAND_LAUNCHER_WRAPPER_COMMANDS:
447
495
  return None
448
496
  launcher_requires_a_positional_value = (
@@ -477,6 +525,43 @@ def _strip_leading_launcher_wrapper(all_command_tokens: list[str]) -> list[str]
477
525
  return []
478
526
 
479
527
 
528
+ def _find_exec_action_program_token_lists(all_segment_tokens: list[str]) -> list[list[str]]:
529
+ """Return the program-token list of each ``find`` ``-exec``/``-execdir`` action.
530
+
531
+ A ``find ... -exec <program> <args...> ;`` (or ``-execdir``, or a ``+``
532
+ terminator) action runs ``<program> <args...>`` against the matched files, so the
533
+ program tokens are every token after the action flag up to the next ``;`` or ``+``
534
+ terminator. One ``find`` may carry several such actions, so every action's program
535
+ tokens are collected. An action flag with no following program tokens before its
536
+ terminator (or before the token list ends) contributes nothing.
537
+
538
+ Args:
539
+ all_segment_tokens: The tokens of a single shell segment.
540
+
541
+ Returns:
542
+ One program-token list per ``-exec``/``-execdir`` action that has program
543
+ tokens, in the order the actions appear.
544
+ """
545
+ all_action_program_token_lists: list[list[str]] = []
546
+ each_token_index = 0
547
+ while each_token_index < len(all_segment_tokens):
548
+ if all_segment_tokens[each_token_index] not in ALL_FIND_EXEC_ACTION_FLAGS:
549
+ each_token_index += 1
550
+ continue
551
+ each_program_token_index = each_token_index + 1
552
+ current_action_program_tokens: list[str] = []
553
+ while (
554
+ each_program_token_index < len(all_segment_tokens)
555
+ and all_segment_tokens[each_program_token_index] not in ALL_FIND_EXEC_ACTION_TERMINATORS
556
+ ):
557
+ current_action_program_tokens.append(all_segment_tokens[each_program_token_index])
558
+ each_program_token_index += 1
559
+ if current_action_program_tokens:
560
+ all_action_program_token_lists.append(current_action_program_tokens)
561
+ each_token_index = each_program_token_index + 1
562
+ return all_action_program_token_lists
563
+
564
+
480
565
  def _command_executes_a_string_argument(all_command_tokens: list[str]) -> bool:
481
566
  """Return True when the command's leading program runs a string argument as code.
482
567
 
@@ -499,6 +584,17 @@ def _command_executes_a_string_argument(all_command_tokens: list[str]) -> bool:
499
584
  ``rm -rf /tmp/scratch``) still reports False and reaches the legitimate-mention
500
585
  path.
501
586
 
587
+ A leading subshell ``(`` or brace ``{`` grouping character glued to the program
588
+ token is stripped before the program is identified, so ``(bash -c 'rm -rf /etc')``
589
+ and ``(timeout N bash -c 'rm -rf /etc')`` are caught.
590
+
591
+ A leading ``find`` runs each ``-exec``/``-execdir`` action's program against the
592
+ matched files, so each action's program tokens are re-evaluated through this same
593
+ detection: ``find . -exec bash -c 'rm -rf /etc' ;`` and
594
+ ``find . -exec python -c '...' ;`` are caught because the action runs an
595
+ interpreter on a quoted string, while ``find . -exec rm -rf {} +`` reports False
596
+ because the action's program ``rm`` executes no quoted string.
597
+
502
598
  Args:
503
599
  all_command_tokens: Tokens produced by shlex tokenization.
504
600
 
@@ -508,7 +604,7 @@ def _command_executes_a_string_argument(all_command_tokens: list[str]) -> bool:
508
604
  leading_command_token = _leading_command_token(all_command_tokens)
509
605
  if leading_command_token is None:
510
606
  return False
511
- leading_command_basename = Path(leading_command_token).name.lower()
607
+ leading_command_basename = Path(_strip_leading_subshell_grouping_characters(leading_command_token)).name.lower()
512
608
  if leading_command_basename in ALL_INTERPRETER_AND_WRAPPER_COMMANDS:
513
609
  return True
514
610
  if leading_command_basename in ALL_COMMAND_LAUNCHER_WRAPPER_COMMANDS:
@@ -516,6 +612,11 @@ def _command_executes_a_string_argument(all_command_tokens: list[str]) -> bool:
516
612
  if not wrapped_program_tokens:
517
613
  return False
518
614
  return _command_executes_a_string_argument(wrapped_program_tokens)
615
+ if leading_command_basename == FIND_PROGRAM_NAME:
616
+ return any(
617
+ _command_executes_a_string_argument(each_action_program_tokens)
618
+ for each_action_program_tokens in _find_exec_action_program_token_lists(all_command_tokens)
619
+ )
519
620
  if leading_command_basename not in ALL_REMOTE_AND_PROGRAM_STRING_EXECUTORS:
520
621
  return False
521
622
  if leading_command_basename == "ssh":
@@ -580,6 +681,15 @@ def _strip_leading_subshell_grouping_characters(token: str) -> str:
580
681
  def _any_shell_segment_executes_a_string_argument(all_command_tokens: list[str]) -> bool:
581
682
  """Return True when any shell segment's leading program runs a string as code.
582
683
 
684
+ A ``find`` ``\\;`` action terminator tokenizes to a bare ``;``, which the
685
+ segment splitter treats as a command separator, so a second
686
+ ``-exec <interpreter> -c '...'`` action becomes its own segment whose leader is
687
+ ``-exec`` — a leader no detector recognizes. Each ``find`` ``-exec``/``-execdir``
688
+ action's program tokens are therefore scanned first on the full pre-split token
689
+ list (where the ``;``/``+`` terminators are still standalone), so a buried
690
+ ``find . -exec touch {} ; -exec bash -c 'rm -rf /etc' ;`` action is caught before
691
+ the per-segment loop runs.
692
+
583
693
  Splits the command into simple-command segments on ``&&`` / ``||`` / ``;`` /
584
694
  ``|`` / ``&`` and applies the leading-program string-execution check to each.
585
695
  A benign program leading the whole command (``echo hi && bash -c 'rm -rf /etc'``,
@@ -595,6 +705,11 @@ def _any_shell_segment_executes_a_string_argument(all_command_tokens: list[str])
595
705
  True when at least one segment's leading program executes a quoted string
596
706
  argument as code.
597
707
  """
708
+ if any(
709
+ _command_executes_a_string_argument(each_action_program_tokens)
710
+ for each_action_program_tokens in _find_exec_action_program_token_lists(all_command_tokens)
711
+ ):
712
+ return True
598
713
  all_exploded_tokens = _explode_glued_shell_control_operators(all_command_tokens)
599
714
  for each_segment in _split_tokens_into_shell_segments(all_exploded_tokens):
600
715
  if each_segment and _command_executes_a_string_argument(each_segment):
@@ -602,6 +717,34 @@ def _any_shell_segment_executes_a_string_argument(all_command_tokens: list[str])
602
717
  return False
603
718
 
604
719
 
720
+ def _command_executes_a_string_in_any_segment(command: str) -> bool:
721
+ """Return True when any segment of any physical line runs a quoted string as code.
722
+
723
+ Splits the command on the POSIX newline and carriage-return terminators,
724
+ tokenizes each line preserving Windows paths, and reports whether any shell
725
+ segment's leading program executes a quoted string argument as code
726
+ (``bash -c '...'``, ``eval '...'``, ``ssh host '...'``, ``python -c '...'``, or a
727
+ launcher wrapping any of these). The broad ephemeral-cwd auto-allow declines such
728
+ a command because the executed string can delete a path outside the ephemeral
729
+ working directory that no plain token names. Fails closed (returns True) when a
730
+ line cannot be tokenized.
731
+
732
+ Args:
733
+ command: The raw Bash command string from the tool input.
734
+
735
+ Returns:
736
+ True when at least one segment executes a quoted string argument as code.
737
+ """
738
+ for each_command_line in re.split(r"[\n\r]+", command):
739
+ try:
740
+ all_command_tokens = _split_command_preserving_windows_backslashes(each_command_line)
741
+ except ValueError:
742
+ return True
743
+ if _any_shell_segment_executes_a_string_argument(all_command_tokens):
744
+ return True
745
+ return False
746
+
747
+
605
748
  def command_has_no_real_rm_invocation(command: str) -> bool:
606
749
  """Return True when no shell token in the command actually invokes ``rm``.
607
750
 
@@ -782,31 +925,58 @@ def _rm_segment_targets_only_absolute_ephemeral_paths(all_rm_segment_tokens: lis
782
925
  return True
783
926
 
784
927
 
928
+ def _path_is_the_null_device(path_token: str) -> bool:
929
+ """Return True when a path token names the null device (``/dev/null`` or ``nul``).
930
+
931
+ Args:
932
+ path_token: A redirect-target path token.
933
+
934
+ Returns:
935
+ True when the token names the null device.
936
+ """
937
+ return path_token.replace("\\", "/").rstrip("/").lower() in ("/dev/null", "nul")
938
+
939
+
785
940
  def _segment_redirects_output_to_a_file(all_segment_tokens: list[str]) -> bool:
786
941
  """Return True when a segment writes its output to a file via shell redirection.
787
942
 
788
943
  An output redirection (a plain, appending, clobbering, or combined operator, with
789
944
  or without a leading file-descriptor number) truncates or rewrites the redirect
790
945
  target, so ``cat /dev/null > /etc/important.conf`` destroys the target file even
791
- though ``cat`` itself is read-only. A file-descriptor duplication that names another
946
+ though ``cat`` itself is read-only. A redirect whose target is the null device
947
+ (``/dev/null`` or ``nul``) writes nothing and stays read-only, so it does not count;
948
+ a redirect to any other file counts. A file-descriptor duplication that names another
792
949
  descriptor as its target writes no file and stays read-only. shlex keeps a
793
950
  redirect operator glued to an adjacent program or target token when no whitespace
794
951
  separates them (``echo pwned>/etc/passwd``, ``cat secret>/etc/x``), so each token is
795
952
  scanned for a redirect operator anywhere within it rather than tested for exact
796
- equality. The benign-segment check declines any segment carrying a redirect operator
797
- so a benign program that overwrites a non-ephemeral file does not ride the ephemeral
798
- ``rm`` auto-allow.
953
+ equality; the target is read from the same token after the operator, or from the next
954
+ token when the operator ends its own token. The benign-segment check declines any
955
+ segment carrying a redirect to a non-null file so a benign program that overwrites a
956
+ non-ephemeral file does not ride the ephemeral ``rm`` auto-allow.
799
957
 
800
958
  Args:
801
959
  all_segment_tokens: Shlex tokens of one shell segment.
802
960
 
803
961
  Returns:
804
- True when any token contains an output-redirection operator.
962
+ True when any token contains a redirect to a file other than the null device.
805
963
  """
806
964
  output_redirection_pattern = re.compile(OUTPUT_REDIRECTION_OPERATOR_PATTERN)
807
- return any(
808
- output_redirection_pattern.search(each_token) for each_token in all_segment_tokens
809
- )
965
+ for each_index, each_token in enumerate(all_segment_tokens):
966
+ operator_match = output_redirection_pattern.search(each_token)
967
+ if operator_match is None:
968
+ continue
969
+ glued_redirect_target = each_token[operator_match.end():]
970
+ if glued_redirect_target:
971
+ redirect_target = glued_redirect_target
972
+ elif each_index + 1 < len(all_segment_tokens):
973
+ redirect_target = all_segment_tokens[each_index + 1]
974
+ else:
975
+ return True
976
+ if _path_is_the_null_device(redirect_target):
977
+ continue
978
+ return True
979
+ return False
810
980
 
811
981
 
812
982
  def _all_positional_tokens_after_leader(all_segment_tokens: list[str]) -> list[str]:
@@ -1219,7 +1389,7 @@ def _compound_segment_auto_allow_verdict(
1219
1389
  (
1220
1390
  index
1221
1391
  for index, token in enumerate(all_segment_tokens)
1222
- if Path(token).name == "rm"
1392
+ if Path(_strip_leading_subshell_grouping_characters(token)).name == "rm"
1223
1393
  ),
1224
1394
  None,
1225
1395
  )
@@ -1432,57 +1602,308 @@ def _command_contains_any_non_cwd_scoped_destructive_pattern(command: str) -> bo
1432
1602
  return False
1433
1603
 
1434
1604
 
1435
- def _command_rm_targets_include_unsafe_path(command: str, tool_input: dict) -> bool:
1436
- """Return True when the command contains an ``rm`` whose targets are unsafe.
1437
-
1438
- Unsafe means any of: bare ephemeral root (``/tmp``, ``/temp``, the OS
1439
- temp root, ``/worktrees``, ``/worktree``), bare named worktrees
1440
- container, absolute path outside the ephemeral namespace, relative
1441
- path that resolves (against the declared effective cwd) outside the
1442
- ephemeral namespace, wildcard glob metacharacter in the target
1443
- basename, or unsafe ``rm`` flag before ``--`` (``--files0-from=...``,
1444
- unknown long option, non-whitelisted short flag) as enforced by
1445
- ``_rm_flags_before_double_dash_are_unsafe``.
1446
-
1447
- Fails closed: returns True on parse failure (``ValueError`` from
1448
- unbalanced quotes) or when a relative target is encountered without
1449
- a declared effective cwd to resolve it against. The broad auto-allow
1450
- must decline rather than grant on input the hook cannot conclusively
1451
- bound.
1605
+ def _rm_target_resolves_outside_ephemeral_namespace(target_token: str, declared_effective_cwd: str | None) -> bool:
1606
+ """Return True when an ``rm`` target token resolves outside the ephemeral namespace.
1607
+
1608
+ A token carrying command substitution (``$(...)``) or a backtick subshell is
1609
+ unsafe because the shell resolves it before ``rm`` runs and the hook cannot
1610
+ statically bound the deletion target. A token carrying a brace group with a
1611
+ comma list or ``..`` range is unsafe because the shell expands it into multiple
1612
+ targets the hook cannot bound; a bare ``{}`` placeholder (no comma, no range)
1613
+ does not match and stays bounded. A token referencing an environment variable
1614
+ other than a known temporary one (``TEMP``, ``TMP``, ``TMPDIR``,
1615
+ ``CLAUDE_JOB_DIR``) is unsafe; a known temporary variable spliced after an
1616
+ absolute (or ``/``-rooted) literal prefix is unsafe because the literal prefix,
1617
+ not the variable, roots the path. A token referencing only known temporary
1618
+ variables with an empty or ``~`` literal prefix resolves with each reference
1619
+ rewritten to the system temporary root. A ``~``-prefixed or absolute token
1620
+ resolves as written; a relative token resolves against ``declared_effective_cwd``
1621
+ (``None`` is unsafe because the target cannot be bounded). The resolved path is
1622
+ unsafe when its basename is a glob wildcard, when it is a bare ephemeral root,
1623
+ when it is a bare named-worktrees container, or when it is not ephemeral.
1624
+
1625
+ Args:
1626
+ target_token: A single ``rm`` target token from the segment.
1627
+ declared_effective_cwd: The declared effective working directory, or ``None``
1628
+ when the command declares none.
1629
+
1630
+ Returns:
1631
+ True when the target resolves outside the ephemeral namespace.
1452
1632
  """
1453
- try:
1454
- all_command_tokens = _split_command_preserving_windows_backslashes(command)
1455
- except ValueError:
1633
+ if "$(" in target_token or "`" in target_token:
1456
1634
  return True
1457
- declared_effective_cwd = _resolve_declared_effective_working_directory(command, tool_input)
1458
- for each_token_index in range(len(all_command_tokens)):
1459
- if all_command_tokens[each_token_index] != "rm":
1635
+ if re.search(r"\{[^{}]*(?:,|\.\.)[^{}]*\}", target_token):
1636
+ return True
1637
+ known_temporary_environment_variable_names = ALL_KNOWN_TEMPORARY_ENVIRONMENT_VARIABLE_NAMES
1638
+ environment_variable_reference_pattern = re.compile(r"\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?|%([A-Za-z_][A-Za-z0-9_]*)%")
1639
+ all_referenced_variable_names = [
1640
+ next(each_group for each_group in each_match.groups() if each_group is not None)
1641
+ for each_match in environment_variable_reference_pattern.finditer(target_token)
1642
+ ]
1643
+ resolved_token = target_token
1644
+ if all_referenced_variable_names:
1645
+ for each_variable_name in all_referenced_variable_names:
1646
+ if each_variable_name not in known_temporary_environment_variable_names:
1647
+ return True
1648
+ first_variable_reference = environment_variable_reference_pattern.search(target_token)
1649
+ assert first_variable_reference is not None
1650
+ literal_prefix_before_first_variable = target_token[: first_variable_reference.start()]
1651
+ if literal_prefix_before_first_variable not in ("", "~") and (
1652
+ os.path.isabs(literal_prefix_before_first_variable)
1653
+ or literal_prefix_before_first_variable.replace("\\", "/").startswith("/")
1654
+ ):
1655
+ return True
1656
+ system_temporary_root = os.path.normpath(tempfile.gettempdir()).replace("\\", "/")
1657
+ resolved_token = environment_variable_reference_pattern.sub(lambda each_match: system_temporary_root, target_token)
1658
+ each_expanded_target = os.path.expanduser(resolved_token)
1659
+ each_is_absolute = (
1660
+ os.path.isabs(each_expanded_target)
1661
+ or each_expanded_target.replace("\\", "/").startswith("/")
1662
+ )
1663
+ if each_is_absolute:
1664
+ each_resolved_target = os.path.normpath(each_expanded_target)
1665
+ elif declared_effective_cwd is None:
1666
+ return True
1667
+ else:
1668
+ each_resolved_target = os.path.normpath(os.path.join(declared_effective_cwd, each_expanded_target))
1669
+ if _path_basename_is_shell_glob_wildcard(each_resolved_target):
1670
+ return True
1671
+ if _path_is_bare_ephemeral_root(each_resolved_target):
1672
+ return True
1673
+ if _path_is_bare_named_worktrees_container(each_resolved_target):
1674
+ return True
1675
+ return not directory_is_ephemeral(each_resolved_target)
1676
+
1677
+
1678
+ def _rm_segment_targets_escape_ephemeral_cwd(all_rm_segment_tokens: list[str], declared_effective_cwd: str | None) -> bool:
1679
+ """Return True when an ``rm`` segment's redirect, flags, or targets escape the ephemeral cwd.
1680
+
1681
+ The segment tokens begin at the ``rm`` program token. An output redirection
1682
+ escapes (``rm -rf /tmp/x>/etc/passwd`` truncates ``/etc/passwd`` even when the
1683
+ deletion target is ephemeral; shlex keeps the ``>`` glued to the target token when
1684
+ no whitespace separates them). Unsafe ``rm`` flags before ``--`` (as enforced by
1685
+ ``_rm_flags_before_double_dash_are_unsafe``) escape; otherwise any collected target
1686
+ token that resolves outside the ephemeral namespace escapes.
1687
+
1688
+ Args:
1689
+ all_rm_segment_tokens: The segment tokens starting at the ``rm`` token.
1690
+ declared_effective_cwd: The declared effective working directory, or ``None``
1691
+ when the command declares none.
1692
+
1693
+ Returns:
1694
+ True when the segment's redirect, flags, or any target escape the ephemeral cwd.
1695
+ """
1696
+ if _segment_redirects_output_to_a_file(all_rm_segment_tokens):
1697
+ return True
1698
+ tokens_after_rm = all_rm_segment_tokens[1:]
1699
+ if _rm_flags_before_double_dash_are_unsafe(tokens_after_rm):
1700
+ return True
1701
+ return any(
1702
+ _rm_target_resolves_outside_ephemeral_namespace(each_target_token, declared_effective_cwd)
1703
+ for each_target_token in _collect_rm_target_tokens(tokens_after_rm)
1704
+ )
1705
+
1706
+
1707
+ def _collect_find_search_root_tokens(all_tokens_after_find: list[str]) -> list[str]:
1708
+ """Return the path-operand search roots that follow ``find``, skipping global options.
1709
+
1710
+ GNU ``find`` accepts global options before the path operands: the flag-only
1711
+ options ``-H``/``-L``/``-P`` (each its own token, possibly in sequence), the
1712
+ value-taking option ``-D`` (which consumes the following debug-options token), and
1713
+ an optimization-level option ``-O<level>`` whose level is glued to the flag
1714
+ (``-O3``), so the ``-O``-prefixed token is skipped as a single token and a
1715
+ following path operand is collected as a search root rather than swallowed as a
1716
+ level. The leading run of such global options
1717
+ is skipped first, then the path operands are collected: every non-dash token up to
1718
+ the first ``-``-prefixed expression primary (``-name``, ``-type``, ``-exec``). A
1719
+ ``find`` whose first post-option token is already an expression primary declares no
1720
+ path operand and returns an empty list, so it defaults to the ephemeral cwd.
1721
+
1722
+ Args:
1723
+ all_tokens_after_find: The segment tokens that follow the ``find`` program token.
1724
+
1725
+ Returns:
1726
+ The path-operand search-root tokens, in order; empty when ``find`` declares none.
1727
+ """
1728
+ each_token_index = 0
1729
+ while each_token_index < len(all_tokens_after_find):
1730
+ each_token = all_tokens_after_find[each_token_index]
1731
+ if each_token in ALL_FIND_GLOBAL_OPTION_FLAGS_WITHOUT_VALUE:
1732
+ each_token_index += 1
1460
1733
  continue
1461
- tokens_after_rm = all_command_tokens[each_token_index + 1:]
1462
- if _rm_flags_before_double_dash_are_unsafe(tokens_after_rm):
1734
+ if each_token in ALL_FIND_GLOBAL_OPTION_FLAGS_TAKING_A_VALUE:
1735
+ each_token_index += 1
1736
+ if each_token_index < len(all_tokens_after_find):
1737
+ each_token_index += 1
1738
+ continue
1739
+ if each_token.startswith(FIND_OPTIMIZATION_LEVEL_OPTION_PREFIX):
1740
+ each_token_index += 1
1741
+ continue
1742
+ break
1743
+ all_search_root_tokens: list[str] = []
1744
+ for each_token in all_tokens_after_find[each_token_index:]:
1745
+ if each_token.startswith("-"):
1746
+ break
1747
+ all_search_root_tokens.append(each_token)
1748
+ return all_search_root_tokens
1749
+
1750
+
1751
+ def _find_exec_rm_search_root_escapes_ephemeral_cwd(
1752
+ all_segment_tokens: list[str], declared_effective_cwd: str | None
1753
+ ) -> bool:
1754
+ """Return True when a ``find ... -exec rm`` segment's search root escapes the ephemeral namespace.
1755
+
1756
+ A ``find <roots...> ... -exec rm ...`` (or ``-execdir``) segment deletes whatever
1757
+ ``find`` matches under its leading search-root arguments; the ``rm``'s own ``{}``
1758
+ and ``+`` placeholders name no deletion target, ``find``'s roots do. The leading run
1759
+ of ``find`` global options (``-H``/``-L``/``-P``, ``-D debugopts``, ``-Olevel``) is
1760
+ skipped before the path operands are read, so a global option before the roots does
1761
+ not hide them. The segment is unsafe when any path-operand search root resolves
1762
+ outside the ephemeral namespace. Returns False when the segment contains no ``find``
1763
+ token, when it has no ``-exec`` or ``-execdir`` action after ``find``, or when
1764
+ ``find`` declares no path operand (``find`` then defaults to the ephemeral cwd).
1765
+
1766
+ Args:
1767
+ all_segment_tokens: The tokens of a single shell segment.
1768
+ declared_effective_cwd: The declared effective working directory, or ``None``
1769
+ when the command declares none.
1770
+
1771
+ Returns:
1772
+ True when any of ``find``'s search roots escapes the ephemeral namespace.
1773
+ """
1774
+ find_token_index = next(
1775
+ (
1776
+ index
1777
+ for index, token in enumerate(all_segment_tokens)
1778
+ if Path(_strip_leading_subshell_grouping_characters(token)).name == FIND_PROGRAM_NAME
1779
+ ),
1780
+ None,
1781
+ )
1782
+ if find_token_index is None:
1783
+ return False
1784
+ if not any(each_token in ALL_FIND_EXEC_ACTION_FLAGS for each_token in all_segment_tokens[find_token_index:]):
1785
+ return False
1786
+ all_search_root_tokens = _collect_find_search_root_tokens(all_segment_tokens[find_token_index + 1 :])
1787
+ return any(
1788
+ _rm_target_resolves_outside_ephemeral_namespace(each_search_root_token, declared_effective_cwd)
1789
+ for each_search_root_token in all_search_root_tokens
1790
+ )
1791
+
1792
+
1793
+ def _command_changes_directory_beyond_leading_cd(command: str) -> bool:
1794
+ """Return True when a directory change beyond a single leading ``cd`` runs.
1795
+
1796
+ The broad ephemeral auto-allow resolves a relative ``rm`` target against the
1797
+ declared effective working directory, which is established only by the
1798
+ command's single leading ``cd``. Any further directory change moves the base a
1799
+ later relative target resolves against, so the declared cwd no longer bounds
1800
+ the deletion: a second top-level ``cd`` (``cd /tmp/x && cd / && rm -rf etc``),
1801
+ a ``cd`` inside a subshell group (``(cd /; rm -rf etc)``), or a ``pushd`` /
1802
+ ``popd`` anywhere. The caller nulls the declared cwd when this returns True, so
1803
+ every relative target fails closed while absolute targets — which no directory
1804
+ change can redirect — still resolve.
1805
+
1806
+ A leading ``cd`` is the first simple-command segment of the first physical line
1807
+ whose leading program, after leading ``VAR=value`` assignments and
1808
+ subshell-grouping characters are stripped, is ``cd``. Counts every segment whose
1809
+ leading program is ``cd``, ``pushd`` or ``popd`` and returns True when more such
1810
+ segments exist than the single leading ``cd``. Fails closed (returns True) when a
1811
+ physical line cannot be tokenized.
1812
+
1813
+ Args:
1814
+ command: The raw Bash command string from the tool input.
1815
+
1816
+ Returns:
1817
+ True when a directory change beyond a single leading ``cd`` is present.
1818
+ """
1819
+ all_directory_changing_program_names = ("cd", "pushd", "popd")
1820
+ leading_cd_segment_count = 0
1821
+ directory_changing_segment_count = 0
1822
+ for each_line_index, each_command_line in enumerate(re.split(r"[\n\r]+", command)):
1823
+ try:
1824
+ all_command_tokens = _split_command_preserving_windows_backslashes(each_command_line)
1825
+ except ValueError:
1463
1826
  return True
1464
- all_target_tokens = _collect_rm_target_tokens(tokens_after_rm)
1465
- for each_target_token in all_target_tokens:
1466
- each_expanded_target = os.path.expanduser(each_target_token)
1467
- each_is_absolute = (
1468
- os.path.isabs(each_expanded_target)
1469
- or each_expanded_target.replace("\\", "/").startswith("/")
1470
- )
1471
- if each_is_absolute:
1472
- each_resolved_target = os.path.normpath(each_expanded_target)
1473
- else:
1474
- if declared_effective_cwd is None:
1475
- return True
1476
- each_resolved_target = os.path.normpath(
1477
- os.path.join(declared_effective_cwd, each_expanded_target)
1478
- )
1479
- if _path_basename_is_shell_glob_wildcard(each_resolved_target):
1480
- return True
1481
- if _path_is_bare_ephemeral_root(each_resolved_target):
1482
- return True
1483
- if _path_is_bare_named_worktrees_container(each_resolved_target):
1827
+ all_operator_split_tokens = _explode_glued_shell_control_operators(all_command_tokens)
1828
+ for each_segment_index, each_segment in enumerate(_split_tokens_into_shell_segments(all_operator_split_tokens)):
1829
+ leading_program_token = _leading_command_token(each_segment)
1830
+ if leading_program_token is None:
1831
+ continue
1832
+ is_subshell_wrapped = leading_program_token[:1] in ALL_SUBSHELL_GROUPING_CHARACTERS
1833
+ stripped_program_name = Path(_strip_leading_subshell_grouping_characters(leading_program_token)).name
1834
+ if stripped_program_name not in all_directory_changing_program_names:
1835
+ continue
1836
+ directory_changing_segment_count += 1
1837
+ if (
1838
+ stripped_program_name == "cd"
1839
+ and each_line_index == 0
1840
+ and each_segment_index == 0
1841
+ and not is_subshell_wrapped
1842
+ ):
1843
+ leading_cd_segment_count += 1
1844
+ return directory_changing_segment_count > leading_cd_segment_count
1845
+
1846
+
1847
+ def _command_rm_targets_include_unsafe_path(command: str, tool_input: dict) -> bool:
1848
+ """Return True when an ``rm`` in any segment targets a path outside the ephemeral cwd.
1849
+
1850
+ Tokenizes each physical line, explodes glued shell control operators, and splits
1851
+ into shell segments. A ``find ... -exec rm`` segment escapes when any of ``find``'s
1852
+ leading search roots resolves outside the ephemeral namespace, because those roots,
1853
+ not the ``rm``'s ``{}``/``+`` placeholders, name what gets deleted; that segment is
1854
+ then also checked as an ordinary ``rm`` segment, where the placeholder ``{}``/``+``
1855
+ targets resolve under the ephemeral cwd and a trailing redirect to the null device is
1856
+ benign while a redirect to any other file escapes. Within each segment, the first
1857
+ token whose basename is ``rm`` begins an ``rm`` invocation;
1858
+ its flags and targets are checked in isolation, so a sibling segment's flags
1859
+ (``mkdir -p``) or absolute paths (a ``python`` interpreter path) never count as this
1860
+ ``rm``'s targets. A segment escapes when an unsafe flag precedes ``--`` or a target
1861
+ resolves outside the ephemeral namespace: an absolute non-ephemeral path, a relative
1862
+ path that resolves outside (or with no declared cwd to resolve against), a reference
1863
+ to a non-temporary environment variable, a bare ephemeral root, a bare
1864
+ named-worktrees container, or a glob wildcard basename.
1865
+
1866
+ A relative target fails closed when the command changes directory beyond a single
1867
+ leading ``cd`` (a second top-level ``cd``, a ``cd`` inside a subshell group, or a
1868
+ ``pushd`` / ``popd``): the declared cwd is nulled so the leading ``cd`` no longer
1869
+ bounds the deletion, while absolute targets — which no directory change can
1870
+ redirect — still resolve.
1871
+
1872
+ Fails closed: returns True on parse failure (``ValueError`` from unbalanced quotes).
1873
+ The broad auto-allow must decline rather than grant on input the hook cannot
1874
+ conclusively bound.
1875
+
1876
+ Args:
1877
+ command: The raw Bash command string from the tool input.
1878
+ tool_input: The Bash tool input mapping, used to resolve the declared effective
1879
+ working directory.
1880
+
1881
+ Returns:
1882
+ True when any ``rm`` segment's flags or targets escape the ephemeral cwd.
1883
+ """
1884
+ declared_effective_cwd = _resolve_declared_effective_working_directory(command, tool_input)
1885
+ if _command_changes_directory_beyond_leading_cd(command):
1886
+ declared_effective_cwd = None
1887
+ for each_command_line in re.split(r"[\n\r]+", command):
1888
+ try:
1889
+ all_command_tokens = _split_command_preserving_windows_backslashes(each_command_line)
1890
+ except ValueError:
1891
+ return True
1892
+ all_operator_split_tokens = _explode_glued_shell_control_operators(all_command_tokens)
1893
+ for each_segment in _split_tokens_into_shell_segments(all_operator_split_tokens):
1894
+ if _find_exec_rm_search_root_escapes_ephemeral_cwd(each_segment, declared_effective_cwd):
1484
1895
  return True
1485
- if not directory_is_ephemeral(each_resolved_target):
1896
+ each_rm_token_index = next(
1897
+ (
1898
+ index
1899
+ for index, token in enumerate(each_segment)
1900
+ if Path(_strip_leading_subshell_grouping_characters(token)).name == "rm"
1901
+ ),
1902
+ None,
1903
+ )
1904
+ if each_rm_token_index is None:
1905
+ continue
1906
+ if _rm_segment_targets_escape_ephemeral_cwd(each_segment[each_rm_token_index:], declared_effective_cwd):
1486
1907
  return True
1487
1908
  return False
1488
1909
 
@@ -1583,6 +2004,7 @@ def main() -> None:
1583
2004
  matched_description is not None
1584
2005
  and _destructive_match_is_cwd_scoped(matched_description)
1585
2006
  and _effective_working_directory_is_ephemeral(command, tool_input)
2007
+ and not _command_executes_a_string_in_any_segment(command)
1586
2008
  and not _command_rm_targets_include_unsafe_path(command, tool_input)
1587
2009
  and not _command_contains_any_non_cwd_scoped_destructive_pattern(command)
1588
2010
  ):