claude-dev-env 1.60.0 → 1.62.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 (41) hide show
  1. package/CLAUDE.md +12 -0
  2. package/audit-rubrics/category_rubrics/category-f-silent-failures.md +1 -1
  3. package/audit-rubrics/prompts/category-e-dead-code.md +17 -4
  4. package/audit-rubrics/prompts/category-f-silent-failures.md +1 -0
  5. package/bin/install.mjs +1 -1
  6. package/docs/CODE_RULES.md +2 -2
  7. package/hooks/blocking/code_rules_annotations_length.py +189 -10
  8. package/hooks/blocking/code_rules_dead_config_field.py +321 -0
  9. package/hooks/blocking/code_rules_enforcer.py +14 -0
  10. package/hooks/blocking/code_rules_orphan_css_class.py +196 -0
  11. package/hooks/blocking/config/verified_commit_constants.py +15 -2
  12. package/hooks/blocking/destructive_command_blocker.py +483 -61
  13. package/hooks/blocking/test_code_rules_enforcer_annotations.py +240 -0
  14. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
  15. package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +432 -0
  16. package/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py +82 -0
  17. package/hooks/blocking/test_code_rules_enforcer_orphan_css_class.py +196 -0
  18. package/hooks/blocking/test_destructive_command_blocker.py +213 -0
  19. package/hooks/blocking/test_verification_verdict_store.py +212 -0
  20. package/hooks/blocking/test_verified_commit_gate.py +159 -0
  21. package/hooks/blocking/test_verifier_verdict_minter.py +74 -95
  22. package/hooks/blocking/verification_verdict_store.py +240 -0
  23. package/hooks/blocking/verified_commit_gate.py +31 -9
  24. package/hooks/blocking/verifier_verdict_minter.py +46 -124
  25. package/hooks/hooks_constants/code_rules_enforcer_constants.py +6 -0
  26. package/hooks/hooks_constants/dead_config_field_constants.py +39 -0
  27. package/hooks/hooks_constants/destructive_command_segment_constants.py +15 -0
  28. package/hooks/hooks_constants/orphan_css_class_constants.py +40 -0
  29. package/hooks/validation/mypy_validator.py +59 -7
  30. package/hooks/validation/test_mypy_validator.py +94 -0
  31. package/package.json +1 -1
  32. package/rules/orphan-css-class.md +23 -0
  33. package/skills/autoconverge/reference/gotchas.md +11 -0
  34. package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +5 -1
  35. package/skills/autoconverge/workflow/converge.contract.test.mjs +202 -13
  36. package/skills/autoconverge/workflow/converge.mjs +392 -51
  37. package/skills/autoconverge/workflow/test_render_report.py +55 -0
  38. package/skills/doc-gist/SKILL.md +3 -2
  39. package/skills/doc-gist/references/examples/21-decision-signoff.html +546 -0
  40. package/skills/doc-gist/references/examples/README.md +2 -2
  41. package/skills/task-build/SKILL.md +31 -0
@@ -0,0 +1,82 @@
1
+ """Meta-test asserting every check_* function is dispatched from validate_content.
2
+
3
+ The per-check test modules each prove one ``check_*`` function flags the right
4
+ violation, but none proves the enforcer actually calls that function. A refactor
5
+ that drops a dispatch line from ``validate_content`` leaves every per-check test
6
+ green while the check stops firing at Write/Edit time — the precise failure mode
7
+ that would let a dead module-level constant (the ``MEDIUM_TEXT`` class) or an
8
+ orphan CSS class slip past the gate again.
9
+
10
+ This module reads ``validate_content``'s source and asserts every ``check_*``
11
+ attribute on the enforcer module appears in it. A check that is intentionally
12
+ not wired must be listed in ``KNOWN_UNDISPATCHED_CHECKS`` with a reason in this
13
+ docstring; no such checks exist today. The companion ``test_code_rules_enforcer_
14
+ cap_meta.py`` guards the payload-cap convention; this module guards the wiring.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import importlib.util
20
+ import inspect
21
+ import pathlib
22
+ import sys
23
+
24
+ _HOOK_DIRECTORY = pathlib.Path(__file__).parent
25
+ if str(_HOOK_DIRECTORY) not in sys.path:
26
+ sys.path.insert(0, str(_HOOK_DIRECTORY))
27
+
28
+ _hook_specification = importlib.util.spec_from_file_location(
29
+ "code_rules_enforcer",
30
+ _HOOK_DIRECTORY / "code_rules_enforcer.py",
31
+ )
32
+ assert _hook_specification is not None
33
+ assert _hook_specification.loader is not None
34
+ _hook_module = importlib.util.module_from_spec(_hook_specification)
35
+ _hook_specification.loader.exec_module(_hook_module)
36
+
37
+ KNOWN_UNDISPATCHED_CHECKS: frozenset[str] = frozenset()
38
+
39
+
40
+ def _all_check_function_names() -> list[str]:
41
+ return [
42
+ each_attribute_name
43
+ for each_attribute_name in dir(_hook_module)
44
+ if each_attribute_name.startswith("check_")
45
+ and callable(getattr(_hook_module, each_attribute_name))
46
+ ]
47
+
48
+
49
+ def _validate_content_source() -> str:
50
+ return inspect.getsource(_hook_module.validate_content)
51
+
52
+
53
+ def test_every_check_function_is_called_in_validate_content() -> None:
54
+ all_check_names = set(_all_check_function_names())
55
+ validate_content_source = _validate_content_source()
56
+ undispatched_check_names = {
57
+ each_name for each_name in all_check_names if each_name not in validate_content_source
58
+ }
59
+ unexpected_undispatched = undispatched_check_names - KNOWN_UNDISPATCHED_CHECKS
60
+ assert unexpected_undispatched == set(), (
61
+ f"check_* functions are imported but never called in validate_content: "
62
+ f"{sorted(unexpected_undispatched)}. Wire each into validate_content so the "
63
+ f"check fires at Write/Edit time, or list it in KNOWN_UNDISPATCHED_CHECKS "
64
+ f"with a reason in the test header docstring."
65
+ )
66
+
67
+
68
+ def test_dead_module_constant_check_stays_wired() -> None:
69
+ validate_content_source = _validate_content_source()
70
+ assert "check_dead_module_constants" in validate_content_source, (
71
+ "check_dead_module_constants must stay dispatched from validate_content so a "
72
+ "dead exported constant (the MEDIUM_TEXT class) is blocked at Write/Edit time."
73
+ )
74
+
75
+
76
+ def test_known_undispatched_set_lists_only_existing_checks() -> None:
77
+ all_check_names = set(_all_check_function_names())
78
+ stale_names = KNOWN_UNDISPATCHED_CHECKS - all_check_names
79
+ assert stale_names == set(), (
80
+ f"KNOWN_UNDISPATCHED_CHECKS lists functions that no longer exist: "
81
+ f"{sorted(stale_names)}. Restore the function or remove it from the set."
82
+ )
@@ -0,0 +1,196 @@
1
+ """Unit tests for the orphan-CSS-class check in code_rules_enforcer hook."""
2
+
3
+ import importlib.util
4
+ import pathlib
5
+ import sys
6
+ import tempfile
7
+ import textwrap
8
+ from collections.abc import Iterator
9
+ from pathlib import Path
10
+
11
+ import pytest
12
+
13
+ _HOOK_DIR = pathlib.Path(__file__).parent
14
+ if str(_HOOK_DIR) not in sys.path:
15
+ sys.path.insert(0, str(_HOOK_DIR))
16
+
17
+ hook_spec = importlib.util.spec_from_file_location(
18
+ "code_rules_orphan_css_class",
19
+ _HOOK_DIR / "code_rules_orphan_css_class.py",
20
+ )
21
+ assert hook_spec is not None
22
+ assert hook_spec.loader is not None
23
+ hook_module = importlib.util.module_from_spec(hook_spec)
24
+ hook_spec.loader.exec_module(hook_module)
25
+ check_orphan_css_classes = hook_module.check_orphan_css_classes
26
+
27
+ PRODUCTION_FILE_PATH = "packages/app/render/report.py"
28
+ TEST_FILE_PATH = "packages/app/render/test_report.py"
29
+
30
+ MARKUP_WITH_ORPHAN = (
31
+ "def render() -> str:\n"
32
+ ' style = "<style>.card { color: red; }</style>"\n'
33
+ ' body = \'<div class="card">x</div><div class="ghost">y</div>\'\n'
34
+ " return style + body\n"
35
+ )
36
+
37
+ MARKUP_ALL_DEFINED = (
38
+ "def render() -> str:\n"
39
+ ' style = "<style>.card { color: red; } .row { margin: 0; }</style>"\n'
40
+ ' body = \'<div class="card"><span class="row">x</span></div>\'\n'
41
+ " return style + body\n"
42
+ )
43
+
44
+
45
+ def test_should_flag_class_with_no_matching_selector() -> None:
46
+ issues = check_orphan_css_classes(MARKUP_WITH_ORPHAN, PRODUCTION_FILE_PATH)
47
+ assert any("'ghost'" in each_issue for each_issue in issues), (
48
+ f"Expected 'ghost' flagged as orphan, got: {issues}"
49
+ )
50
+
51
+
52
+ def test_should_not_flag_class_with_matching_selector() -> None:
53
+ issues = check_orphan_css_classes(MARKUP_WITH_ORPHAN, PRODUCTION_FILE_PATH)
54
+ assert not any("'card'" in each_issue for each_issue in issues), (
55
+ f"'card' has a .card selector and must not flag, got: {issues}"
56
+ )
57
+
58
+
59
+ def test_should_not_flag_when_every_class_is_defined() -> None:
60
+ issues = check_orphan_css_classes(MARKUP_ALL_DEFINED, PRODUCTION_FILE_PATH)
61
+ assert issues == [], f"Every class is defined; expected no issues, got: {issues}"
62
+
63
+
64
+ def test_should_flag_each_class_in_a_multi_class_attribute() -> None:
65
+ content = (
66
+ "def render() -> str:\n"
67
+ ' style = "<style>.pf { padding: 0; }</style>"\n'
68
+ " body = '<div class=\"pf problem\">x</div>'\n"
69
+ " return style + body\n"
70
+ )
71
+ issues = check_orphan_css_classes(content, PRODUCTION_FILE_PATH)
72
+ assert any("'problem'" in each_issue for each_issue in issues), (
73
+ f"Second class 'problem' in the attribute must flag, got: {issues}"
74
+ )
75
+ assert not any("'pf'" in each_issue for each_issue in issues), (
76
+ f"First class 'pf' has a selector and must not flag, got: {issues}"
77
+ )
78
+
79
+
80
+ def test_should_not_flag_when_no_style_block_present() -> None:
81
+ content = "def render() -> str:\n return '<div class=\"card\">x</div>'\n"
82
+ issues = check_orphan_css_classes(content, PRODUCTION_FILE_PATH)
83
+ assert issues == [], (
84
+ f"No <style> source nearby; the stylesheet lives outside the scan, got: {issues}"
85
+ )
86
+
87
+
88
+ def test_should_not_flag_when_no_class_attribute_present() -> None:
89
+ content = (
90
+ 'def render() -> str:\n return "<style>.card { color: red; }</style><div>x</div>"\n'
91
+ )
92
+ issues = check_orphan_css_classes(content, PRODUCTION_FILE_PATH)
93
+ assert issues == [], f"No class attribute in markup; got: {issues}"
94
+
95
+
96
+ def test_should_skip_test_files() -> None:
97
+ issues = check_orphan_css_classes(MARKUP_WITH_ORPHAN, TEST_FILE_PATH)
98
+ assert issues == [], f"Test files are exempt; got: {issues}"
99
+
100
+
101
+ def test_should_include_line_number_and_class_name() -> None:
102
+ issues = check_orphan_css_classes(MARKUP_WITH_ORPHAN, PRODUCTION_FILE_PATH)
103
+ orphan_issue = next(each for each in issues if "'ghost'" in each)
104
+ assert "Line 3" in orphan_issue, (
105
+ f"Orphan 'ghost' sits on line 3 of the markup; got: {orphan_issue}"
106
+ )
107
+
108
+
109
+ def test_should_report_each_orphan_class_once() -> None:
110
+ content = (
111
+ "def render() -> str:\n"
112
+ ' style = "<style>.card { color: red; }</style>"\n'
113
+ " a = '<div class=\"ghost\">x</div>'\n"
114
+ " b = '<div class=\"ghost\">y</div>'\n"
115
+ " return style + a + b\n"
116
+ )
117
+ issues = check_orphan_css_classes(content, PRODUCTION_FILE_PATH)
118
+ ghost_issues = [each for each in issues if "'ghost'" in each]
119
+ assert len(ghost_issues) == 1, f"A repeated orphan class reports once; got: {ghost_issues}"
120
+
121
+
122
+ def test_should_handle_syntax_error_gracefully() -> None:
123
+ content = "def broken(\n this is not python\n"
124
+ issues = check_orphan_css_classes(content, PRODUCTION_FILE_PATH)
125
+ assert issues == [], f"A syntax error yields no issues; got: {issues}"
126
+
127
+
128
+ _SIBLING_MARKUP_SOURCE = textwrap.dedent(
129
+ """\
130
+ from report_constants import HTML_STYLE_BLOCK
131
+
132
+
133
+ def render() -> str:
134
+ body = (
135
+ '<details class="appendix">'
136
+ '<div class="appendix-body">x</div></details>'
137
+ )
138
+ return HTML_STYLE_BLOCK + body
139
+ """
140
+ )
141
+
142
+
143
+ @pytest.fixture
144
+ def production_render_package() -> Iterator[Path]:
145
+ """Yield a neutrally named package directory for the markup and constants modules.
146
+
147
+ pytest's ``tmp_path`` carries the test function name, so any path under it
148
+ holds a ``test_`` segment that the enforcer's ``is_test_file`` predicate
149
+ matches and exempts. A package created under the default ``tempfile`` prefix
150
+ keeps that segment out of the path, so the cross-module orphan-class check
151
+ runs against the markup module as production code.
152
+
153
+ Yields:
154
+ A freshly created package directory, removed when the test finishes.
155
+ """
156
+ with tempfile.TemporaryDirectory() as base_directory:
157
+ package_directory = Path(base_directory) / "render"
158
+ package_directory.mkdir()
159
+ yield package_directory
160
+
161
+
162
+ def test_should_resolve_selectors_from_a_sibling_module(
163
+ production_render_package: Path,
164
+ ) -> None:
165
+ constants_module = production_render_package / "report_constants.py"
166
+ constants_module.write_text(
167
+ 'HTML_STYLE_BLOCK = "<style>.appendix { margin: 0; }'
168
+ ' .appendix-body { padding: 0; }</style>"\n',
169
+ encoding="utf-8",
170
+ )
171
+ markup_module = production_render_package / "report.py"
172
+ markup_module.write_text(_SIBLING_MARKUP_SOURCE, encoding="utf-8")
173
+ issues = check_orphan_css_classes(_SIBLING_MARKUP_SOURCE, str(markup_module))
174
+ assert issues == [], (
175
+ f"A sibling module defines every selector; expected no issues, got: {issues}"
176
+ )
177
+
178
+
179
+ def test_should_flag_orphan_even_when_a_sibling_defines_other_selectors(
180
+ production_render_package: Path,
181
+ ) -> None:
182
+ constants_module = production_render_package / "report_constants.py"
183
+ constants_module.write_text(
184
+ 'HTML_STYLE_BLOCK = "<style>.appendix { margin: 0; }</style>"\n',
185
+ encoding="utf-8",
186
+ )
187
+ markup_module = production_render_package / "report.py"
188
+ markup_module.write_text(_SIBLING_MARKUP_SOURCE, encoding="utf-8")
189
+ issues = check_orphan_css_classes(_SIBLING_MARKUP_SOURCE, str(markup_module))
190
+ assert any("'appendix-body'" in each_issue for each_issue in issues), (
191
+ f"'appendix-body' has no selector in any sibling; must flag, got: {issues}"
192
+ )
193
+ assert not any(
194
+ "'appendix'" in each_issue and "appendix-body" not in each_issue
195
+ for each_issue in issues
196
+ ), f"'appendix' is defined in the sibling and must not flag, got: {issues}"
@@ -247,6 +247,10 @@ def test_rm_rf_asks_when_any_target_is_non_ephemeral() -> None:
247
247
  assert response["hookSpecificOutput"]["permissionDecision"] == "ask"
248
248
 
249
249
 
250
+ def test_rm_rf_asks_when_target_has_nested_temp_segment_not_at_root() -> None:
251
+ _assert_hook_asks("rm -rf /home/victim/temp/secret")
252
+
253
+
250
254
  def test_rm_rf_asks_when_double_dash_includes_hyphen_prefixed_non_ephemeral_target() -> None:
251
255
  payload = _make_bash_payload("rm -rf -- /tmp/scratch -non_ephemeral")
252
256
 
@@ -404,6 +408,26 @@ def test_rm_rf_asks_when_tool_input_cwd_is_ephemeral_but_rm_target_is_absolute_n
404
408
  assert response["hookSpecificOutput"]["permissionDecision"] == "ask"
405
409
 
406
410
 
411
+ def test_rm_rf_asks_when_subshell_cd_changes_dir_before_relative_rm() -> None:
412
+ _assert_hook_asks('cd "/tmp/scratch" && (cd /; rm -rf etc)')
413
+
414
+
415
+ def test_rm_rf_asks_when_second_top_level_cd_changes_dir_before_relative_rm() -> None:
416
+ _assert_hook_asks('cd "/tmp/scratch" && cd / && rm -rf etc')
417
+
418
+
419
+ def test_rm_rf_asks_when_pushd_changes_dir_before_relative_rm() -> None:
420
+ _assert_hook_asks('cd "/tmp/scratch" && pushd / && rm -rf etc')
421
+
422
+
423
+ def test_rm_rf_allowed_when_subshell_cd_present_but_rm_target_is_absolute_ephemeral() -> None:
424
+ _assert_hook_allows('cd "/tmp/scratch" && (cd /; rm -rf /tmp/scratch/keep)')
425
+
426
+
427
+ def test_rm_rf_asks_when_cd_ephemeral_but_target_has_nested_tmp_segment_not_at_root() -> None:
428
+ _assert_hook_asks('cd "/tmp/scratch" && rm -rf /home/victim/tmp/secret')
429
+
430
+
407
431
  def test_git_push_force_asks_when_leading_cd_into_ephemeral_subdirectory() -> None:
408
432
  payload = _make_bash_payload('cd "/tmp/bugteam_scratch" && git push --force')
409
433
 
@@ -1038,6 +1062,14 @@ def test_compound_rm_allowed_when_two_absolute_ephemeral_targets_then_echo() ->
1038
1062
  _assert_hook_allows("rm -rf /tmp/pr136 /tmp/difftest && echo 'cleaned'")
1039
1063
 
1040
1064
 
1065
+ def test_compound_rm_allowed_when_subshell_paren_glued_rm_targets_absolute_ephemeral() -> None:
1066
+ _assert_hook_allows("rm -rf /tmp/a && (rm -rf /tmp/b)")
1067
+
1068
+
1069
+ def test_compound_rm_asks_when_subshell_paren_glued_rm_targets_non_ephemeral() -> None:
1070
+ _assert_hook_asks("rm -rf /tmp/a && (rm -rf /etc)")
1071
+
1072
+
1041
1073
  def test_compound_rm_allowed_when_followed_by_gh_pipeline_and_echo() -> None:
1042
1074
  _assert_hook_allows('rm -rf /tmp/reply && gh pr checks 19 2>&1 | head -5 && echo "x"')
1043
1075
 
@@ -1142,6 +1174,14 @@ def test_compound_rm_asks_when_second_rm_target_glues_redirect_to_non_ephemeral_
1142
1174
  _assert_hook_asks("rm -rf /tmp/a /tmp/b>/etc/hosts")
1143
1175
 
1144
1176
 
1177
+ def test_rm_rf_asks_when_cd_ephemeral_but_rm_segment_redirects_to_non_ephemeral_file() -> None:
1178
+ _assert_hook_asks('cd "/tmp/scratch" && rm -rf /tmp/x>/etc/passwd')
1179
+
1180
+
1181
+ def test_rm_rf_asks_when_cd_ephemeral_but_relative_rm_target_redirects_to_non_ephemeral_file() -> None:
1182
+ _assert_hook_asks('cd "/tmp/scratch" && rm -rf build>/etc/passwd')
1183
+
1184
+
1145
1185
  def test_compound_rm_asks_when_git_config_sets_value_after_ephemeral_rm() -> None:
1146
1186
  _assert_hook_asks("rm -rf /tmp/x && git config --global user.name evil")
1147
1187
 
@@ -1517,6 +1557,26 @@ def test_subshell_grouped_rm_asks_when_benign_command_precedes_grouped_rm() -> N
1517
1557
  _assert_hook_asks("echo hi; (rm -rf /etc)")
1518
1558
 
1519
1559
 
1560
+ def test_string_execution_asks_when_subshell_paren_glued_to_bash_dash_c() -> None:
1561
+ _assert_hook_asks('cd "/tmp/scratch" && (bash -c \'rm -rf /etc\')')
1562
+
1563
+
1564
+ def test_string_execution_asks_when_subshell_paren_glued_to_timeout_wrapping_bash() -> None:
1565
+ _assert_hook_asks('cd "/tmp/scratch" && (timeout 5 bash -c \'rm -rf /etc\')')
1566
+
1567
+
1568
+ def test_rm_rf_asks_when_cd_ephemeral_but_subshell_paren_glued_to_rm_targets_etc() -> None:
1569
+ _assert_hook_asks('cd "/tmp/scratch" && (rm -rf /etc)')
1570
+
1571
+
1572
+ def test_rm_rf_asks_when_cd_ephemeral_but_brace_glued_to_rm_targets_etc() -> None:
1573
+ _assert_hook_asks('cd "/tmp/scratch" && {rm -rf /etc;}')
1574
+
1575
+
1576
+ def test_rm_rf_allowed_when_cd_ephemeral_and_subshell_paren_wraps_relative_ephemeral_target() -> None:
1577
+ _assert_hook_allows('cd "/tmp/scratch" && (rm -rf build)')
1578
+
1579
+
1520
1580
  # --- convergence branch exemption unit tests ---
1521
1581
 
1522
1582
  import importlib.util
@@ -1773,3 +1833,156 @@ def test_launcher_execution_allows_when_timeout_infinity_wraps_ephemeral_rm() ->
1773
1833
 
1774
1834
  def test_launcher_execution_allows_when_timeout_seconds_wraps_ephemeral_rm() -> None:
1775
1835
  _assert_hook_allows("timeout 5 rm -rf /tmp/scratch")
1836
+
1837
+
1838
+ def test_rm_rf_allowed_when_cd_worktree_then_temp_env_var_rm_then_mkdir_tar_compound() -> None:
1839
+ _assert_hook_allows(
1840
+ 'cd "/Users/dev/proj/.git/worktrees/spindle" '
1841
+ '&& rm -rf "$TEMP/pr621_check" '
1842
+ '&& mkdir -p "$TEMP/pr621_check" '
1843
+ "&& git archive HEAD packages | tar -x -C \"$TEMP/pr621_check\" "
1844
+ '&& ls "$TEMP/pr621_check/packages" | head -40'
1845
+ )
1846
+
1847
+
1848
+ def test_rm_rf_allowed_when_cd_worktree_then_find_exec_rm_then_pytest_compound() -> None:
1849
+ _assert_hook_allows(
1850
+ 'cd "/Users/dev/proj/worktrees/os-update-system" '
1851
+ '&& find shared_utils/samsung_utils -name "__pycache__" -type d '
1852
+ "-exec rm -rf {} + 2>/dev/null"
1853
+ '; PYTHONPATH="/Users/dev/proj/worktrees/os-update-system" '
1854
+ 'C:/Python313/python.exe -m pytest "tests/" -p no:cacheprovider -q 2>&1 | tail -15'
1855
+ )
1856
+
1857
+
1858
+ def test_rm_rf_allowed_when_cd_ephemeral_and_sibling_mkdir_has_dash_p_flag() -> None:
1859
+ _assert_hook_allows('cd "/tmp/scratch" && rm -rf build && mkdir -p out')
1860
+
1861
+
1862
+ def test_rm_rf_allowed_when_cd_ephemeral_and_rm_target_uses_temp_env_var() -> None:
1863
+ _assert_hook_allows('cd "/tmp/scratch" && rm -rf "$TEMP/build"')
1864
+
1865
+
1866
+ def test_rm_rf_asks_when_cd_ephemeral_but_bash_dash_c_executes_rm_on_non_ephemeral() -> None:
1867
+ _assert_hook_asks("cd \"/tmp/scratch\" && rm -rf build && bash -c 'rm -rf /etc'")
1868
+
1869
+
1870
+ def test_rm_rf_asks_when_cd_ephemeral_but_rm_target_uses_non_temp_env_var() -> None:
1871
+ _assert_hook_asks('cd "/tmp/scratch" && rm -rf "$HOME/important"')
1872
+
1873
+
1874
+ def test_rm_rf_asks_when_cd_ephemeral_and_second_rm_segment_targets_non_ephemeral() -> None:
1875
+ _assert_hook_asks('cd "/tmp/scratch" && rm -rf build && rm -rf /etc/passwd')
1876
+
1877
+
1878
+ def test_rm_rf_asks_when_cd_ephemeral_but_bin_rm_targets_non_ephemeral() -> None:
1879
+ _assert_hook_asks('cd "/tmp/scratch" && /bin/rm -rf /etc')
1880
+
1881
+
1882
+ def test_rm_rf_asks_when_cd_ephemeral_but_target_is_command_substitution() -> None:
1883
+ _assert_hook_asks('cd "/tmp/scratch" && rm -rf $(somecmd)')
1884
+
1885
+
1886
+ def test_rm_rf_asks_when_cd_ephemeral_but_target_is_brace_expansion_escaping_namespace() -> None:
1887
+ _assert_hook_asks('cd "/tmp/scratch" && rm -rf {build,/etc}')
1888
+
1889
+
1890
+ def test_rm_rf_asks_when_cd_ephemeral_but_temp_var_splices_after_absolute_literal_prefix() -> None:
1891
+ _assert_hook_asks('cd "/tmp/scratch" && rm -rf /data$TMP/x')
1892
+
1893
+
1894
+ def test_rm_rf_asks_when_cd_ephemeral_but_find_exec_rm_search_root_escapes_namespace() -> None:
1895
+ _assert_hook_asks('cd "/tmp/scratch" && find /etc -name x -exec rm -rf {} +')
1896
+
1897
+
1898
+ def test_rm_rf_asks_when_cd_ephemeral_but_subshell_find_exec_rm_search_root_escapes() -> None:
1899
+ _assert_hook_asks('cd "/tmp/scratch" && (find /etc -exec rm -rf {} +)')
1900
+
1901
+
1902
+ def test_rm_rf_asks_when_find_exec_rm_safe_but_sibling_standalone_rm_targets_non_ephemeral() -> None:
1903
+ _assert_hook_asks(
1904
+ 'cd "/tmp/scratch" && find . -name x -exec rm -rf {} + ; rm -rf /etc/passwd'
1905
+ )
1906
+
1907
+
1908
+ def test_rm_rf_asks_when_cd_ephemeral_but_find_exec_rm_redirects_to_non_ephemeral_file() -> None:
1909
+ _assert_hook_asks('cd "/tmp/scratch" && find /tmp/scratch -exec rm -rf {} + >/etc/passwd')
1910
+
1911
+
1912
+ def test_rm_rf_allowed_when_cd_ephemeral_and_relative_build_target() -> None:
1913
+ _assert_hook_allows('cd "/tmp/scratch" && rm -rf build')
1914
+
1915
+
1916
+ def test_rm_rf_allowed_when_cd_ephemeral_and_find_exec_rm_search_root_is_dot() -> None:
1917
+ _assert_hook_allows('cd "/tmp/scratch" && find . -name x -exec rm -rf {} +')
1918
+
1919
+
1920
+ def test_rm_rf_asks_when_cd_ephemeral_but_find_exec_bash_dash_c_deletes_non_ephemeral() -> None:
1921
+ _assert_hook_asks("cd \"/tmp/scratch\" && find . -exec bash -c 'rm -rf /etc' \\;")
1922
+
1923
+
1924
+ def test_rm_rf_asks_when_cd_ephemeral_but_find_exec_sh_dash_c_deletes_non_ephemeral() -> None:
1925
+ _assert_hook_asks("cd \"/tmp/scratch\" && find . -exec sh -c 'rm -rf /etc' \\;")
1926
+
1927
+
1928
+ def test_rm_rf_asks_when_cd_ephemeral_but_find_execdir_bash_dash_c_deletes_non_ephemeral() -> None:
1929
+ _assert_hook_asks("cd \"/tmp/scratch\" && find . -execdir bash -c 'rm -rf /etc' \\;")
1930
+
1931
+
1932
+ def test_rm_rf_asks_when_cd_ephemeral_but_find_exec_python_dash_c_deletes_non_ephemeral() -> None:
1933
+ _assert_hook_asks(
1934
+ "cd \"/tmp/scratch\" && find . -exec python -c 'import os; os.system(\"rm -rf /etc\")' \\;"
1935
+ )
1936
+
1937
+
1938
+ # H1: find global option before the search root must not defeat the escape check
1939
+
1940
+
1941
+ def test_rm_rf_asks_when_find_dash_l_global_option_precedes_non_ephemeral_search_root() -> None:
1942
+ _assert_hook_asks('cd "/tmp/scratch" && find -L /etc -name x -exec rm -rf {} +')
1943
+
1944
+
1945
+ def test_rm_rf_asks_when_find_dash_p_global_option_precedes_non_ephemeral_execdir_root() -> None:
1946
+ _assert_hook_asks('cd "/tmp/scratch" && find -P /etc -execdir rm -rf {} +')
1947
+
1948
+
1949
+ def test_rm_rf_asks_when_find_optimization_level_option_precedes_non_ephemeral_search_root() -> None:
1950
+ _assert_hook_asks('cd "/tmp/scratch" && find -O3 /etc -exec rm -rf {} +')
1951
+
1952
+
1953
+ def test_rm_rf_asks_when_standalone_find_optimization_option_precedes_non_ephemeral_search_root() -> None:
1954
+ _assert_hook_asks('cd "/tmp/scratch" && find -O /etc -exec rm -rf {} +')
1955
+
1956
+
1957
+ def test_rm_rf_asks_when_find_debug_option_value_precedes_non_ephemeral_search_root() -> None:
1958
+ _assert_hook_asks('cd "/tmp/scratch" && find -D tree /etc -exec rm -rf {} +')
1959
+
1960
+
1961
+ def test_rm_rf_allowed_when_find_global_option_precedes_ephemeral_dot_search_root() -> None:
1962
+ _assert_hook_allows('cd "/tmp/scratch" && find -L . -name x -exec rm -rf {} +')
1963
+
1964
+
1965
+ # H2: multi -exec with a \\; terminator must not sever the destructive action from detection
1966
+
1967
+
1968
+ def test_rm_rf_asks_when_multi_exec_second_action_runs_bash_dash_c_deleting_non_ephemeral() -> None:
1969
+ _assert_hook_asks(
1970
+ "cd \"/tmp/scratch\" && find . -exec touch {} \\; -exec bash -c 'rm -rf /etc' \\;"
1971
+ )
1972
+
1973
+
1974
+ def test_rm_rf_asks_when_multi_exec_second_action_runs_sh_dash_c_deleting_non_ephemeral() -> None:
1975
+ _assert_hook_asks(
1976
+ "cd \"/tmp/scratch\" && find . -exec echo {} \\; -exec sh -c 'rm -rf /etc' \\;"
1977
+ )
1978
+
1979
+
1980
+ def test_rm_rf_allowed_when_multi_exec_both_actions_target_only_ephemeral_paths() -> None:
1981
+ _assert_hook_allows("cd \"/tmp/scratch\" && find . -exec echo {} \\; -exec rm -rf {} \\;")
1982
+
1983
+
1984
+ # H3: parallel forwarding an interpreter that deletes a non-ephemeral path must ask
1985
+
1986
+
1987
+ def test_rm_rf_asks_when_parallel_forwards_bash_dash_c_deleting_non_ephemeral() -> None:
1988
+ _assert_hook_asks("cd \"/tmp/scratch\" && parallel bash -c 'rm -rf /etc' ::: x")