claude-dev-env 1.60.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.
- package/CLAUDE.md +4 -0
- package/audit-rubrics/category_rubrics/category-f-silent-failures.md +1 -1
- package/audit-rubrics/prompts/category-e-dead-code.md +17 -4
- package/audit-rubrics/prompts/category-f-silent-failures.md +1 -0
- package/docs/CODE_RULES.md +2 -2
- package/hooks/blocking/code_rules_annotations_length.py +189 -10
- package/hooks/blocking/code_rules_enforcer.py +8 -0
- package/hooks/blocking/code_rules_orphan_css_class.py +196 -0
- package/hooks/blocking/config/verified_commit_constants.py +14 -2
- package/hooks/blocking/destructive_command_blocker.py +483 -61
- package/hooks/blocking/test_code_rules_enforcer_annotations.py +240 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
- package/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py +82 -0
- package/hooks/blocking/test_code_rules_enforcer_orphan_css_class.py +196 -0
- package/hooks/blocking/test_destructive_command_blocker.py +213 -0
- package/hooks/blocking/test_verification_verdict_store.py +212 -0
- package/hooks/blocking/test_verified_commit_gate.py +127 -0
- package/hooks/blocking/test_verifier_verdict_minter.py +74 -95
- package/hooks/blocking/verification_verdict_store.py +240 -0
- package/hooks/blocking/verified_commit_gate.py +20 -8
- package/hooks/blocking/verifier_verdict_minter.py +46 -124
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +6 -0
- package/hooks/hooks_constants/destructive_command_segment_constants.py +15 -0
- package/hooks/hooks_constants/orphan_css_class_constants.py +40 -0
- package/hooks/validation/mypy_validator.py +59 -7
- package/hooks/validation/test_mypy_validator.py +94 -0
- package/package.json +1 -1
- package/rules/orphan-css-class.md +23 -0
- package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +0 -1
- package/skills/autoconverge/workflow/converge.contract.test.mjs +202 -13
- package/skills/autoconverge/workflow/converge.mjs +392 -51
- package/skills/autoconverge/workflow/test_render_report.py +30 -0
|
@@ -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
|
-
|
|
65
|
-
for
|
|
66
|
-
if
|
|
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
|
-
|
|
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
|
|
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
|
|
797
|
-
|
|
798
|
-
|
|
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
|
|
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
|
-
|
|
808
|
-
output_redirection_pattern.search(each_token)
|
|
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
|
|
1436
|
-
"""Return True when
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
``
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
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
|
-
|
|
1462
|
-
|
|
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
|
-
|
|
1465
|
-
for
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
)
|
|
1471
|
-
if
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
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
|
-
|
|
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
|
):
|