claude-dev-env 2.18.0 → 2.20.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 (133) hide show
  1. package/.agents/skills/AGENTS.md +4 -4
  2. package/.agents/skills/_shared/AGENTS.md +1 -0
  3. package/.agents/skills/_shared/pr-loop/AGENTS.md +1 -0
  4. package/.agents/skills/_shared/pr-loop/preflight-proposal.contract.test.mjs +119 -0
  5. package/.agents/skills/_shared/pr-loop/preflight-proposal.md +5 -0
  6. package/.agents/skills/e-code-review/SKILL.md +12 -1
  7. package/.agents/skills/e-code-review/reference/fix.md +5 -1
  8. package/.agents/skills/e-code-review/reference/loop.md +4 -0
  9. package/.agents/skills/e-code-review/reference/mode-contract.test.mjs +66 -0
  10. package/.agents/skills/e-code-review/reference/preflight-proposal.md +40 -0
  11. package/.agents/skills/e-code-review/reference/runner-selection.md +1 -0
  12. package/.agents/skills/pr-cleanup/SKILL.md +105 -75
  13. package/.agents/skills/pr-cleanup/reference/process-inventory.md +15 -0
  14. package/.agents/skills/pr-cleanup/reference/task-seeds.md +20 -0
  15. package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/SKILL.md +18 -12
  16. package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/offense-examples.md +1 -1
  17. package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/task-seeds.md +3 -2
  18. package/.agents/skills/pr-refinement/SKILL.md +31 -0
  19. package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/SKILL.md +24 -7
  20. package/.agents/skills/{small-cl → pr-small-cl}/SKILL.md +1 -1
  21. package/.agents/skills/prototype/scripts/test_probe_sandbox_safety.py +33 -5
  22. package/.agents/skills/skill-builder/SKILL.md +1 -0
  23. package/.agents/skills-archived/bugteam/SKILL.md +1 -1
  24. package/.agents/skills-archived/bugteam/test_skill_additions.py +1 -1
  25. package/.agents/skills-archived/comments/SKILL.md +1 -1
  26. package/.agents/skills-archived/descriptions/SKILL.md +1 -1
  27. package/.agents/skills-archived/reviews/SKILL.md +1 -1
  28. package/AGENTS.md +1 -1
  29. package/_shared/pr-loop/AGENTS.md +2 -1
  30. package/_shared/pr-loop/preflight-proposal.md +56 -0
  31. package/_shared/pr-loop/scripts/code_rules_gate.py +29 -6
  32. package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_arguments.py +15 -3
  33. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_running.py +47 -4
  34. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +4 -0
  35. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +47 -0
  36. package/bin/ever-shipped-skills.mjs +14 -0
  37. package/bin/install-constants.mjs +5 -0
  38. package/bin/install.agents-home.test.mjs +23 -0
  39. package/bin/install.codex-rules.test.mjs +61 -6
  40. package/bin/install.mjs +57 -9
  41. package/bin/install.prune.test.mjs +65 -3
  42. package/bin/install.test.mjs +202 -2
  43. package/docs/CODE_RULES.md +6 -0
  44. package/hooks/advisory/conftest.py +10 -0
  45. package/hooks/advisory/migration_safety_advisor.py +14 -14
  46. package/hooks/advisory/refactor_guard.py +250 -144
  47. package/hooks/advisory/refactor_guard_test_support.py +46 -0
  48. package/hooks/advisory/test_migration_safety_advisor.py +120 -0
  49. package/hooks/advisory/test_refactor_guard_advisory.py +171 -0
  50. package/hooks/advisory/test_refactor_guard_eligibility.py +166 -0
  51. package/hooks/atomic_file_writer.py +81 -0
  52. package/hooks/blocking/AGENTS.md +2 -1
  53. package/hooks/blocking/block_main_commit.py +66 -33
  54. package/hooks/blocking/code_rules_banned_identifiers.py +55 -4
  55. package/hooks/blocking/code_rules_blast_radius.py +194 -0
  56. package/hooks/blocking/code_rules_enforcer.py +12 -0
  57. package/hooks/blocking/code_rules_enforcer_test_support.py +92 -0
  58. package/hooks/blocking/destructive_command_blocker.py +8 -7
  59. package/hooks/blocking/pre_tool_use_dispatcher.py +11 -7
  60. package/hooks/blocking/precommit_code_rules_gate.py +14 -139
  61. package/hooks/blocking/sensitive_file_protector.py +8 -6
  62. package/hooks/blocking/session_edit_stage_gate.py +31 -1
  63. package/hooks/blocking/test_block_main_commit.py +145 -0
  64. package/hooks/blocking/test_claude_md_orphan_file_blocker.py +171 -1
  65. package/hooks/blocking/test_code_rules_blast_radius.py +161 -0
  66. package/hooks/blocking/test_code_rules_enforcer_agent_home_tooling.py +37 -12
  67. package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +54 -0
  68. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +15 -53
  69. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +13 -62
  70. package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +309 -0
  71. package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +14 -71
  72. package/hooks/blocking/test_code_rules_enforcer_scratchpad.py +14 -16
  73. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +31 -46
  74. package/hooks/blocking/test_code_rules_enforcer_stage.py +192 -0
  75. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +13 -59
  76. package/hooks/blocking/test_destructive_command_blocker.py +154 -138
  77. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +85 -25
  78. package/hooks/blocking/test_destructive_command_blocker_no_verify.py +58 -38
  79. package/hooks/blocking/test_destructive_command_blocker_patterns.py +133 -0
  80. package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +113 -1
  81. package/hooks/blocking/test_env_var_table_code_drift_blocker.py +70 -3
  82. package/hooks/blocking/test_hook_subprocess_support.py +296 -0
  83. package/hooks/blocking/test_package_inventory_stale_blocker.py +1 -1
  84. package/hooks/blocking/test_pii_scanner.py +81 -0
  85. package/hooks/blocking/test_pre_tool_use_dispatcher.py +32 -3
  86. package/hooks/blocking/test_precommit_code_rules_gate.py +11 -16
  87. package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +234 -0
  88. package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +25 -18
  89. package/hooks/blocking/test_sensitive_file_protector.py +145 -5
  90. package/hooks/blocking/test_session_edit_stage_gate_staging.py +64 -0
  91. package/hooks/blocking/test_session_edit_stage_gate_support.py +204 -0
  92. package/hooks/blocking/test_session_edit_stage_gate_tracker.py +116 -0
  93. package/hooks/blocking/test_shared_stdin_adoption.py +31 -20
  94. package/hooks/blocking/test_test_preflight_check.py +80 -0
  95. package/hooks/git-hooks/AGENTS.md +1 -1
  96. package/hooks/git-hooks/git_hooks_constants/__init__.py +1 -0
  97. package/hooks/git-hooks/post_commit.py +160 -51
  98. package/hooks/git-hooks/pre_commit.py +9 -5
  99. package/hooks/git-hooks/test_post_commit.py +203 -0
  100. package/hooks/git-hooks/test_pre_commit.py +2 -2
  101. package/hooks/git-hooks/test_pre_push.py +57 -0
  102. package/hooks/hooks_constants/AGENTS.md +3 -1
  103. package/hooks/hooks_constants/atomic_file_writer_constants.py +4 -0
  104. package/hooks/hooks_constants/banned_identifiers_constants.py +1 -0
  105. package/hooks/hooks_constants/blast_radius_constants.py +14 -0
  106. package/hooks/hooks_constants/destructive_command_environment_constants.py +18 -0
  107. package/hooks/hooks_constants/destructive_command_segment_constants.py +1 -11
  108. package/hooks/hooks_constants/hardcoded_user_path_constants.py +9 -3
  109. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +1 -1
  110. package/hooks/hooks_constants/refactor_guard_constants.py +75 -0
  111. package/hooks/hooks_constants/sensitive_file_protector_constants.py +2 -4
  112. package/hooks/hooks_constants/test_refactor_guard_constants.py +21 -0
  113. package/hooks/json_file_reader.py +22 -0
  114. package/hooks/observability/test_instructions_loaded_logger.py +54 -0
  115. package/hooks/pending_sidecars.py +33 -0
  116. package/hooks/session/test_plugin_data_dir_cleanup.py +70 -0
  117. package/hooks/session/test_session_edit_tracker_cleanup.py +16 -3
  118. package/hooks/test_atomic_file_writer.py +73 -0
  119. package/hooks/test_json_file_reader.py +24 -0
  120. package/hooks/test_pending_sidecars.py +25 -0
  121. package/hooks/validation/mypy_validator.py +213 -80
  122. package/hooks/validation/test_mypy_validator.py +288 -13
  123. package/hooks/workflow/auto_formatter.py +225 -93
  124. package/hooks/workflow/investigation_tracker_reset.py +2 -0
  125. package/hooks/workflow/test_auto_formatter.py +261 -12
  126. package/hooks/workflow/test_investigation_tracker_reset.py +90 -0
  127. package/package.json +1 -1
  128. package/rules/failure-blast-radius.md +126 -0
  129. /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/fetch-commands.md +0 -0
  130. /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/report-template.md +0 -0
  131. /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/rule-checklist.md +0 -0
  132. /package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/reference/examples.md +0 -0
  133. /package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/reference/offense-taxonomy.md +0 -0
@@ -7,8 +7,17 @@ import sys
7
7
  import tempfile
8
8
  from pathlib import Path
9
9
 
10
+ import pytest
10
11
 
11
- SCRIPT_PATH = Path(__file__).parent / "destructive_command_blocker.py"
12
+ _BLOCKING_DIRECTORY = Path(__file__).resolve().parent
13
+ if str(_BLOCKING_DIRECTORY) not in sys.path:
14
+ sys.path.insert(0, str(_BLOCKING_DIRECTORY))
15
+
16
+ import _path_setup # noqa: E402, F401
17
+
18
+ from test_hook_subprocess_support import build_bash_payload # noqa: E402
19
+
20
+ SCRIPT_PATH = _BLOCKING_DIRECTORY / "destructive_command_blocker.py"
12
21
  GH_GATE_USER_FACING_PREFIX = "[gh-gate]"
13
22
  GH_REDIRECT_ACTIVE_ENV_VAR = "CLAUDE_GH_REDIRECT_ACTIVE"
14
23
  GH_REDIRECT_ACTIVE_VALUE = "1"
@@ -23,7 +32,7 @@ def _posix_style_system_temporary_root() -> str:
23
32
 
24
33
 
25
34
  def _run_hook(
26
- payload: dict,
35
+ payload: str,
27
36
  gh_redirect_active: bool = True,
28
37
  ) -> subprocess.CompletedProcess[str]:
29
38
  child_environment = os.environ.copy()
@@ -33,7 +42,7 @@ def _run_hook(
33
42
  child_environment.pop(GH_REDIRECT_ACTIVE_ENV_VAR, None)
34
43
  return subprocess.run(
35
44
  [sys.executable, str(SCRIPT_PATH)],
36
- input=json.dumps(payload),
45
+ input=payload,
37
46
  text=True,
38
47
  capture_output=True,
39
48
  check=False,
@@ -41,15 +50,8 @@ def _run_hook(
41
50
  )
42
51
 
43
52
 
44
- def _make_bash_payload(command: str) -> dict:
45
- return {
46
- "tool_name": "Bash",
47
- "tool_input": {"command": command},
48
- }
49
-
50
-
51
53
  def test_denies_gh_issue_comment_as_redirect_duplicate_guard() -> None:
52
- payload = _make_bash_payload(
54
+ payload = build_bash_payload(
53
55
  'gh issue comment 83 --repo jl-cmd/claude-dev-env --body "hello"'
54
56
  )
55
57
  result = _run_hook(payload)
@@ -65,7 +67,7 @@ def test_denies_gh_issue_comment_as_redirect_duplicate_guard() -> None:
65
67
 
66
68
 
67
69
  def test_denies_gh_pr_comment_as_redirect_duplicate_guard() -> None:
68
- payload = _make_bash_payload('gh pr comment 42 --body "ok"')
70
+ payload = build_bash_payload('gh pr comment 42 --body "ok"')
69
71
  result = _run_hook(payload)
70
72
  response = json.loads(result.stdout)
71
73
  assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
@@ -73,7 +75,7 @@ def test_denies_gh_pr_comment_as_redirect_duplicate_guard() -> None:
73
75
 
74
76
 
75
77
  def test_denies_gh_pr_review_as_redirect_duplicate_guard() -> None:
76
- payload = _make_bash_payload("gh pr review 42 --approve")
78
+ payload = build_bash_payload("gh pr review 42 --approve")
77
79
  result = _run_hook(payload)
78
80
  response = json.loads(result.stdout)
79
81
  assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
@@ -81,7 +83,7 @@ def test_denies_gh_pr_review_as_redirect_duplicate_guard() -> None:
81
83
 
82
84
 
83
85
  def test_denies_gh_api_post_comment_as_redirect_duplicate_guard() -> None:
84
- payload = _make_bash_payload(
86
+ payload = build_bash_payload(
85
87
  "gh api /repos/owner/name/issues/1/comments -X POST -f body=hello"
86
88
  )
87
89
  result = _run_hook(payload)
@@ -90,7 +92,7 @@ def test_denies_gh_api_post_comment_as_redirect_duplicate_guard() -> None:
90
92
 
91
93
 
92
94
  def test_suppresses_output_on_gh_redirect_deny() -> None:
93
- payload = _make_bash_payload('gh issue comment 1 --body "x"')
95
+ payload = build_bash_payload('gh issue comment 1 --body "x"')
94
96
  result = _run_hook(payload)
95
97
  response = json.loads(result.stdout)
96
98
  assert response["suppressOutput"] is True
@@ -98,7 +100,7 @@ def test_suppresses_output_on_gh_redirect_deny() -> None:
98
100
 
99
101
 
100
102
  def test_asks_on_rm_rf_still_works() -> None:
101
- payload = _make_bash_payload("rm -rf /var/log/myapp")
103
+ payload = build_bash_payload("rm -rf /var/log/myapp")
102
104
  result = _run_hook(payload)
103
105
  response = json.loads(result.stdout)
104
106
  assert response["hookSpecificOutput"]["permissionDecision"] == "ask"
@@ -106,7 +108,7 @@ def test_asks_on_rm_rf_still_works() -> None:
106
108
 
107
109
 
108
110
  def test_asks_on_git_push_force_still_works() -> None:
109
- payload = _make_bash_payload("git push --force origin main")
111
+ payload = build_bash_payload("git push --force origin main")
110
112
  result = _run_hook(payload)
111
113
  response = json.loads(result.stdout)
112
114
  assert response["hookSpecificOutput"]["permissionDecision"] == "ask"
@@ -116,21 +118,23 @@ def test_asks_on_git_push_force_still_works() -> None:
116
118
 
117
119
 
118
120
  def test_allows_plain_command_without_destructive_pattern() -> None:
119
- payload = _make_bash_payload("ls -la")
121
+ payload = build_bash_payload("ls -la")
120
122
  result = _run_hook(payload)
121
123
  assert result.stdout.strip() == ""
122
124
  assert result.returncode == 0
123
125
 
124
126
 
125
127
  def test_ignores_non_bash_tool() -> None:
126
- payload = {"tool_name": "Read", "tool_input": {"file_path": "/tmp/x"}}
128
+ payload = json.dumps(
129
+ {"tool_name": "Read", "tool_input": {"file_path": "/tmp/x"}}
130
+ )
127
131
  result = _run_hook(payload)
128
132
  assert result.stdout.strip() == ""
129
133
  assert result.returncode == 0
130
134
 
131
135
 
132
136
  def test_gh_issue_comment_is_allowed_when_redirect_env_var_is_unset() -> None:
133
- payload = _make_bash_payload('gh issue comment 83 --body "hello"')
137
+ payload = build_bash_payload('gh issue comment 83 --body "hello"')
134
138
 
135
139
  result = _run_hook(payload, gh_redirect_active=False)
136
140
 
@@ -139,7 +143,7 @@ def test_gh_issue_comment_is_allowed_when_redirect_env_var_is_unset() -> None:
139
143
 
140
144
 
141
145
  def test_gh_pr_comment_is_allowed_when_redirect_env_var_is_unset() -> None:
142
- payload = _make_bash_payload('gh pr comment 42 --body "ok"')
146
+ payload = build_bash_payload('gh pr comment 42 --body "ok"')
143
147
 
144
148
  result = _run_hook(payload, gh_redirect_active=False)
145
149
 
@@ -148,7 +152,7 @@ def test_gh_pr_comment_is_allowed_when_redirect_env_var_is_unset() -> None:
148
152
 
149
153
 
150
154
  def test_gh_pr_review_is_allowed_when_redirect_env_var_is_unset() -> None:
151
- payload = _make_bash_payload("gh pr review 42 --approve")
155
+ payload = build_bash_payload("gh pr review 42 --approve")
152
156
 
153
157
  result = _run_hook(payload, gh_redirect_active=False)
154
158
 
@@ -157,7 +161,7 @@ def test_gh_pr_review_is_allowed_when_redirect_env_var_is_unset() -> None:
157
161
 
158
162
 
159
163
  def test_gh_api_post_comment_is_allowed_when_redirect_env_var_is_unset() -> None:
160
- payload = _make_bash_payload(
164
+ payload = build_bash_payload(
161
165
  "gh api /repos/owner/name/issues/1/comments -X POST -f body=hello"
162
166
  )
163
167
 
@@ -167,13 +171,13 @@ def test_gh_api_post_comment_is_allowed_when_redirect_env_var_is_unset() -> None
167
171
  assert result.returncode == 0
168
172
 
169
173
 
170
- def _run_rm_hook(payload: dict) -> subprocess.CompletedProcess[str]:
174
+ def _run_rm_hook(payload: str) -> subprocess.CompletedProcess[str]:
171
175
  child_environment = os.environ.copy()
172
176
  child_environment.pop(GH_REDIRECT_ACTIVE_ENV_VAR, None)
173
177
  child_environment.pop("CLAUDE_DESTRUCTIVE_DISABLE_EPHEMERAL_AUTO_ALLOW", None)
174
178
  return subprocess.run(
175
179
  [sys.executable, str(SCRIPT_PATH)],
176
- input=json.dumps(payload),
180
+ input=payload,
177
181
  text=True,
178
182
  capture_output=True,
179
183
  check=False,
@@ -182,7 +186,7 @@ def _run_rm_hook(payload: dict) -> subprocess.CompletedProcess[str]:
182
186
 
183
187
 
184
188
  def test_rm_rf_asks_when_target_is_non_ephemeral_path() -> None:
185
- payload = _make_bash_payload("rm -rf /var/log/myapp")
189
+ payload = build_bash_payload("rm -rf /var/log/myapp")
186
190
 
187
191
  result = _run_rm_hook(payload)
188
192
 
@@ -191,7 +195,7 @@ def test_rm_rf_asks_when_target_is_non_ephemeral_path() -> None:
191
195
 
192
196
 
193
197
  def test_rm_rf_allowed_when_target_is_under_tmp_segment() -> None:
194
- payload = _make_bash_payload("rm -rf /tmp/some_scratch_dir")
198
+ payload = build_bash_payload("rm -rf /tmp/some_scratch_dir")
195
199
 
196
200
  result = _run_rm_hook(payload)
197
201
 
@@ -199,10 +203,15 @@ def test_rm_rf_allowed_when_target_is_under_tmp_segment() -> None:
199
203
  assert result.returncode == 0
200
204
 
201
205
 
202
- def test_rm_rf_allowed_when_target_is_under_os_temp_directory() -> None:
203
- system_temp_subdirectory = Path(tempfile.mkdtemp(prefix="rm_target_"))
206
+ def test_rm_rf_allowed_when_target_is_under_os_temp_directory(
207
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
208
+ ) -> None:
209
+ monkeypatch.setenv("TEMP", str(tmp_path))
210
+ monkeypatch.setenv("TMP", str(tmp_path))
211
+ system_temp_subdirectory = tmp_path / "rm_target"
212
+ system_temp_subdirectory.mkdir()
204
213
  forward_slash_temp_path = str(system_temp_subdirectory).replace("\\", "/")
205
- payload = _make_bash_payload(f"rm -rf {forward_slash_temp_path}")
214
+ payload = build_bash_payload(f"rm -rf {forward_slash_temp_path}")
206
215
 
207
216
  result = _run_rm_hook(payload)
208
217
 
@@ -211,7 +220,7 @@ def test_rm_rf_allowed_when_target_is_under_os_temp_directory() -> None:
211
220
 
212
221
 
213
222
  def test_rm_rf_allowed_when_target_is_under_worktrees_segment() -> None:
214
- payload = _make_bash_payload("rm -rf /Users/me/repo/worktrees/feature_branch/build")
223
+ payload = build_bash_payload("rm -rf /Users/me/repo/worktrees/feature_branch/build")
215
224
 
216
225
  result = _run_rm_hook(payload)
217
226
 
@@ -220,7 +229,7 @@ def test_rm_rf_allowed_when_target_is_under_worktrees_segment() -> None:
220
229
 
221
230
 
222
231
  def test_rm_rf_asks_when_target_is_bare_worktrees_directory() -> None:
223
- payload = _make_bash_payload("rm -rf /Users/me/repo/worktrees")
232
+ payload = build_bash_payload("rm -rf /Users/me/repo/worktrees")
224
233
 
225
234
  result = _run_rm_hook(payload)
226
235
 
@@ -229,7 +238,7 @@ def test_rm_rf_asks_when_target_is_bare_worktrees_directory() -> None:
229
238
 
230
239
 
231
240
  def test_rm_rf_asks_when_rm_includes_option_with_equals_sign() -> None:
232
- payload = _make_bash_payload("rm -rf --files0-from=/tmp/list /tmp/scratch")
241
+ payload = build_bash_payload("rm -rf --files0-from=/tmp/list /tmp/scratch")
233
242
 
234
243
  result = _run_rm_hook(payload)
235
244
 
@@ -238,7 +247,7 @@ def test_rm_rf_asks_when_rm_includes_option_with_equals_sign() -> None:
238
247
 
239
248
 
240
249
  def test_rm_rf_allowed_when_both_targets_are_ephemeral() -> None:
241
- payload = _make_bash_payload("rm -rf /tmp/first_dir /tmp/second_dir")
250
+ payload = build_bash_payload("rm -rf /tmp/first_dir /tmp/second_dir")
242
251
 
243
252
  result = _run_rm_hook(payload)
244
253
 
@@ -247,7 +256,7 @@ def test_rm_rf_allowed_when_both_targets_are_ephemeral() -> None:
247
256
 
248
257
 
249
258
  def test_rm_rf_asks_when_any_target_is_non_ephemeral() -> None:
250
- payload = _make_bash_payload("rm -rf /tmp/scratch /etc/passwd")
259
+ payload = build_bash_payload("rm -rf /tmp/scratch /etc/passwd")
251
260
 
252
261
  result = _run_rm_hook(payload)
253
262
 
@@ -260,7 +269,7 @@ def test_rm_rf_asks_when_target_has_nested_temp_segment_not_at_root() -> None:
260
269
 
261
270
 
262
271
  def test_rm_rf_asks_when_double_dash_includes_hyphen_prefixed_non_ephemeral_target() -> None:
263
- payload = _make_bash_payload("rm -rf -- /tmp/scratch -non_ephemeral")
272
+ payload = build_bash_payload("rm -rf -- /tmp/scratch -non_ephemeral")
264
273
 
265
274
  result = _run_rm_hook(payload)
266
275
 
@@ -269,7 +278,7 @@ def test_rm_rf_asks_when_double_dash_includes_hyphen_prefixed_non_ephemeral_targ
269
278
 
270
279
 
271
280
  def test_rm_rf_allowed_when_compound_with_ampersand_and_absolute_ephemeral_target() -> None:
272
- payload = _make_bash_payload("rm -rf /tmp/reply && gh pr checks 19")
281
+ payload = build_bash_payload("rm -rf /tmp/reply && gh pr checks 19")
273
282
 
274
283
  result = _run_rm_hook(payload)
275
284
 
@@ -278,7 +287,7 @@ def test_rm_rf_allowed_when_compound_with_ampersand_and_absolute_ephemeral_targe
278
287
 
279
288
 
280
289
  def test_rm_rf_allowed_when_leading_cd_into_ephemeral_subdirectory_double_quoted() -> None:
281
- payload = _make_bash_payload('cd "/tmp/bugteam_scratch" && rm -rf .bugteam-tmp')
290
+ payload = build_bash_payload('cd "/tmp/bugteam_scratch" && rm -rf .bugteam-tmp')
282
291
 
283
292
  result = _run_rm_hook(payload)
284
293
 
@@ -287,7 +296,7 @@ def test_rm_rf_allowed_when_leading_cd_into_ephemeral_subdirectory_double_quoted
287
296
 
288
297
 
289
298
  def test_rm_rf_allowed_when_leading_cd_into_ephemeral_subdirectory_single_quoted() -> None:
290
- payload = _make_bash_payload("cd '/tmp/bugteam_scratch' && rm -rf .bugteam-tmp")
299
+ payload = build_bash_payload("cd '/tmp/bugteam_scratch' && rm -rf .bugteam-tmp")
291
300
 
292
301
  result = _run_rm_hook(payload)
293
302
 
@@ -296,7 +305,7 @@ def test_rm_rf_allowed_when_leading_cd_into_ephemeral_subdirectory_single_quoted
296
305
 
297
306
 
298
307
  def test_rm_rf_allowed_when_leading_cd_into_ephemeral_subdirectory_unquoted() -> None:
299
- payload = _make_bash_payload("cd /tmp/bugteam_scratch && rm -rf .bugteam-tmp")
308
+ payload = build_bash_payload("cd /tmp/bugteam_scratch && rm -rf .bugteam-tmp")
300
309
 
301
310
  result = _run_rm_hook(payload)
302
311
 
@@ -309,7 +318,7 @@ def test_rm_rf_allowed_when_leading_cd_into_windows_temp_worktree_subdirectory()
309
318
  _windows_style_system_temporary_root()
310
319
  + r"\bugteam-pr-58-20260424071040\pr-58\worktree"
311
320
  )
312
- payload = _make_bash_payload(
321
+ payload = build_bash_payload(
313
322
  f'cd "{windows_style_temp_worktree}" && rm -rf .bugteam-tmp'
314
323
  )
315
324
 
@@ -320,7 +329,7 @@ def test_rm_rf_allowed_when_leading_cd_into_windows_temp_worktree_subdirectory()
320
329
 
321
330
 
322
331
  def test_rm_rf_allowed_when_leading_cd_into_ephemeral_with_extra_compound_after_rm() -> None:
323
- payload = _make_bash_payload(
332
+ payload = build_bash_payload(
324
333
  'cd "/tmp/bugteam_scratch" && rm -rf .bugteam-tmp && gh pr checks 19'
325
334
  )
326
335
 
@@ -331,7 +340,7 @@ def test_rm_rf_allowed_when_leading_cd_into_ephemeral_with_extra_compound_after_
331
340
 
332
341
 
333
342
  def test_rm_rf_asks_when_leading_cd_into_ephemeral_with_wildcard_target() -> None:
334
- payload = _make_bash_payload('cd "/tmp/bugteam_scratch" && rm -rf *')
343
+ payload = build_bash_payload('cd "/tmp/bugteam_scratch" && rm -rf *')
335
344
 
336
345
  result = _run_rm_hook(payload)
337
346
 
@@ -340,7 +349,7 @@ def test_rm_rf_asks_when_leading_cd_into_ephemeral_with_wildcard_target() -> Non
340
349
 
341
350
 
342
351
  def test_rm_rf_asks_when_cwd_ephemeral_and_relative_target_escapes_via_dotdot() -> None:
343
- payload = _make_bash_payload('cd "/tmp/bugteam_scratch" && rm -rf ../../etc')
352
+ payload = build_bash_payload('cd "/tmp/bugteam_scratch" && rm -rf ../../etc')
344
353
 
345
354
  result = _run_rm_hook(payload)
346
355
 
@@ -349,7 +358,7 @@ def test_rm_rf_asks_when_cwd_ephemeral_and_relative_target_escapes_via_dotdot()
349
358
 
350
359
 
351
360
  def test_rm_rf_asks_when_rm_uses_files0_from_long_option_with_equals() -> None:
352
- payload = _make_bash_payload(
361
+ payload = build_bash_payload(
353
362
  'cd "/tmp/bugteam_scratch" && rm -rf --files0-from=/etc/passwd'
354
363
  )
355
364
 
@@ -360,7 +369,7 @@ def test_rm_rf_asks_when_rm_uses_files0_from_long_option_with_equals() -> None:
360
369
 
361
370
 
362
371
  def test_rm_rf_asks_when_rm_uses_unknown_long_option() -> None:
363
- payload = _make_bash_payload(
372
+ payload = build_bash_payload(
364
373
  'cd "/tmp/bugteam_scratch" && rm -rf --nuke /tmp/bugteam_scratch/stuff'
365
374
  )
366
375
 
@@ -371,7 +380,7 @@ def test_rm_rf_asks_when_rm_uses_unknown_long_option() -> None:
371
380
 
372
381
 
373
382
  def test_rm_rf_asks_when_rm_target_uses_windows_backslash_absolute_path_unquoted() -> None:
374
- payload = _make_bash_payload(
383
+ payload = build_bash_payload(
375
384
  r'cd "/tmp/bugteam_scratch" && rm -rf C:\sensitive\path'
376
385
  )
377
386
 
@@ -382,10 +391,7 @@ def test_rm_rf_asks_when_rm_target_uses_windows_backslash_absolute_path_unquoted
382
391
 
383
392
 
384
393
  def test_rm_rf_asks_when_relative_target_without_declared_cwd_fails_closed() -> None:
385
- payload_with_no_cwd = {
386
- "tool_name": "Bash",
387
- "tool_input": {"command": "rm -rf relative/path"},
388
- }
394
+ payload_with_no_cwd = build_bash_payload("rm -rf relative/path")
389
395
 
390
396
  result = _run_rm_hook(payload_with_no_cwd)
391
397
 
@@ -394,7 +400,7 @@ def test_rm_rf_asks_when_relative_target_without_declared_cwd_fails_closed() ->
394
400
 
395
401
 
396
402
  def test_rm_rf_allowed_when_cwd_ephemeral_and_relative_target_stays_within() -> None:
397
- payload = _make_bash_payload('cd "/tmp/bugteam_scratch" && rm -rf ./build')
403
+ payload = build_bash_payload('cd "/tmp/bugteam_scratch" && rm -rf ./build')
398
404
 
399
405
  result = _run_rm_hook(payload)
400
406
 
@@ -403,13 +409,9 @@ def test_rm_rf_allowed_when_cwd_ephemeral_and_relative_target_stays_within() ->
403
409
 
404
410
 
405
411
  def test_rm_rf_asks_when_tool_input_cwd_is_ephemeral_but_rm_target_is_absolute_non_ephemeral() -> None:
406
- payload_with_tool_input_cwd = {
407
- "tool_name": "Bash",
408
- "tool_input": {
409
- "command": "rm -rf /var/log/myapp",
410
- "cwd": "/tmp/bugteam_scratch",
411
- },
412
- }
412
+ payload_with_tool_input_cwd = build_bash_payload(
413
+ "rm -rf /var/log/myapp", tool_input_cwd="/tmp/bugteam_scratch"
414
+ )
413
415
 
414
416
  result = _run_rm_hook(payload_with_tool_input_cwd)
415
417
 
@@ -438,7 +440,7 @@ def test_rm_rf_asks_when_cd_ephemeral_but_target_has_nested_tmp_segment_not_at_r
438
440
 
439
441
 
440
442
  def test_git_push_force_asks_when_leading_cd_into_ephemeral_subdirectory() -> None:
441
- payload = _make_bash_payload('cd "/tmp/bugteam_scratch" && git push --force')
443
+ payload = build_bash_payload('cd "/tmp/bugteam_scratch" && git push --force')
442
444
 
443
445
  result = _run_rm_hook(payload)
444
446
 
@@ -448,7 +450,7 @@ def test_git_push_force_asks_when_leading_cd_into_ephemeral_subdirectory() -> No
448
450
 
449
451
 
450
452
  def test_git_clean_force_recursive_asks_when_leading_cd_into_ephemeral_subdirectory() -> None:
451
- payload = _make_bash_payload('cd "/tmp/bugteam_scratch" && git clean -fd')
453
+ payload = build_bash_payload('cd "/tmp/bugteam_scratch" && git clean -fd')
452
454
 
453
455
  result = _run_rm_hook(payload)
454
456
 
@@ -458,7 +460,7 @@ def test_git_clean_force_recursive_asks_when_leading_cd_into_ephemeral_subdirect
458
460
 
459
461
 
460
462
  def test_rm_rf_plus_git_push_force_piggyback_asks_when_leading_cd_into_ephemeral() -> None:
461
- payload = _make_bash_payload(
463
+ payload = build_bash_payload(
462
464
  'cd "/tmp/bugteam_scratch" && rm -rf cache && git push --force'
463
465
  )
464
466
 
@@ -469,7 +471,7 @@ def test_rm_rf_plus_git_push_force_piggyback_asks_when_leading_cd_into_ephemeral
469
471
 
470
472
 
471
473
  def test_rm_rf_plus_git_clean_piggyback_asks_when_leading_cd_into_ephemeral() -> None:
472
- payload = _make_bash_payload(
474
+ payload = build_bash_payload(
473
475
  'cd "/tmp/bugteam_scratch" && rm -rf cache && git clean -fd'
474
476
  )
475
477
 
@@ -480,7 +482,7 @@ def test_rm_rf_plus_git_clean_piggyback_asks_when_leading_cd_into_ephemeral() ->
480
482
 
481
483
 
482
484
  def test_rm_rf_plus_mkfs_piggyback_asks_when_leading_cd_into_ephemeral() -> None:
483
- payload = _make_bash_payload(
485
+ payload = build_bash_payload(
484
486
  'cd "/tmp/bugteam_scratch" && rm -rf cache && mkfs.ext4 /dev/sda1'
485
487
  )
486
488
 
@@ -491,7 +493,7 @@ def test_rm_rf_plus_mkfs_piggyback_asks_when_leading_cd_into_ephemeral() -> None
491
493
 
492
494
 
493
495
  def test_rm_rf_plus_drop_table_piggyback_asks_when_leading_cd_into_ephemeral() -> None:
494
- payload = _make_bash_payload(
496
+ payload = build_bash_payload(
495
497
  'cd "/tmp/bugteam_scratch" && rm -rf cache && psql -c "DROP TABLE users"'
496
498
  )
497
499
 
@@ -502,7 +504,7 @@ def test_rm_rf_plus_drop_table_piggyback_asks_when_leading_cd_into_ephemeral() -
502
504
 
503
505
 
504
506
  def test_rm_rf_asks_when_leading_cd_into_ephemeral_but_rm_target_is_bare_tmp_root() -> None:
505
- payload = _make_bash_payload('cd "/tmp/bugteam_scratch" && rm -rf /tmp')
507
+ payload = build_bash_payload('cd "/tmp/bugteam_scratch" && rm -rf /tmp')
506
508
 
507
509
  result = _run_rm_hook(payload)
508
510
 
@@ -511,7 +513,7 @@ def test_rm_rf_asks_when_leading_cd_into_ephemeral_but_rm_target_is_bare_tmp_roo
511
513
 
512
514
 
513
515
  def test_rm_rf_asks_when_leading_cd_into_ephemeral_but_rm_target_is_bare_worktrees_root() -> None:
514
- payload = _make_bash_payload('cd "/tmp/bugteam_scratch" && rm -rf /worktrees')
516
+ payload = build_bash_payload('cd "/tmp/bugteam_scratch" && rm -rf /worktrees')
515
517
 
516
518
  result = _run_rm_hook(payload)
517
519
 
@@ -520,13 +522,9 @@ def test_rm_rf_asks_when_leading_cd_into_ephemeral_but_rm_target_is_bare_worktre
520
522
 
521
523
 
522
524
  def test_rm_rf_asks_when_command_fails_shlex_parse_with_unbalanced_quotes() -> None:
523
- payload_with_tool_input_cwd = {
524
- "tool_name": "Bash",
525
- "tool_input": {
526
- "command": 'rm -rf "unclosed_quote',
527
- "cwd": "/tmp/bugteam_scratch",
528
- },
529
- }
525
+ payload_with_tool_input_cwd = build_bash_payload(
526
+ 'rm -rf "unclosed_quote', tool_input_cwd="/tmp/bugteam_scratch"
527
+ )
530
528
 
531
529
  result = _run_rm_hook(payload_with_tool_input_cwd)
532
530
 
@@ -535,7 +533,7 @@ def test_rm_rf_asks_when_command_fails_shlex_parse_with_unbalanced_quotes() -> N
535
533
 
536
534
 
537
535
  def test_rm_rf_asks_when_leading_cd_target_is_non_ephemeral_directory() -> None:
538
- payload = _make_bash_payload('cd "/etc" && rm -rf scratch')
536
+ payload = build_bash_payload('cd "/etc" && rm -rf scratch')
539
537
 
540
538
  result = _run_rm_hook(payload)
541
539
 
@@ -544,7 +542,7 @@ def test_rm_rf_asks_when_leading_cd_target_is_non_ephemeral_directory() -> None:
544
542
 
545
543
 
546
544
  def test_rm_rf_asks_when_leading_cd_target_is_bare_ephemeral_root() -> None:
547
- payload = _make_bash_payload('cd "/tmp" && rm -rf scratch')
545
+ payload = build_bash_payload('cd "/tmp" && rm -rf scratch')
548
546
 
549
547
  result = _run_rm_hook(payload)
550
548
 
@@ -553,7 +551,7 @@ def test_rm_rf_asks_when_leading_cd_target_is_bare_ephemeral_root() -> None:
553
551
 
554
552
 
555
553
  def test_rm_rf_allowed_when_leading_cd_target_is_git_worktrees_directory() -> None:
556
- payload = _make_bash_payload('cd "/Users/me/repo/worktrees" && rm -rf feature')
554
+ payload = build_bash_payload('cd "/Users/me/repo/worktrees" && rm -rf feature')
557
555
 
558
556
  result = _run_rm_hook(payload)
559
557
 
@@ -562,7 +560,7 @@ def test_rm_rf_allowed_when_leading_cd_target_is_git_worktrees_directory() -> No
562
560
 
563
561
 
564
562
  def test_rm_rf_asks_when_leading_cd_target_is_relative_path() -> None:
565
- payload = _make_bash_payload('cd "./scratch" && rm -rf inner')
563
+ payload = build_bash_payload('cd "./scratch" && rm -rf inner')
566
564
 
567
565
  result = _run_rm_hook(payload)
568
566
 
@@ -571,7 +569,7 @@ def test_rm_rf_asks_when_leading_cd_target_is_relative_path() -> None:
571
569
 
572
570
 
573
571
  def test_rm_rf_allowed_for_chat_observed_bugteam_backslash_worktree_scratch_cleanup() -> None:
574
- payload = _make_bash_payload(
572
+ payload = build_bash_payload(
575
573
  'cd "'
576
574
  + _windows_style_system_temporary_root()
577
575
  + r'\bugteam-pr-58-20260424071040\pr-58\worktree" && rm -rf .bugteam-tmp'
@@ -584,7 +582,7 @@ def test_rm_rf_allowed_for_chat_observed_bugteam_backslash_worktree_scratch_clea
584
582
 
585
583
 
586
584
  def test_rm_rf_allowed_for_chat_observed_bugteam_forward_slash_worktree_scratch_cleanup() -> None:
587
- payload = _make_bash_payload(
585
+ payload = build_bash_payload(
588
586
  'cd "'
589
587
  + _posix_style_system_temporary_root()
590
588
  + '/bugteam-pr-58-20260424071040/pr-58/worktree" && rm -rf .bugteam-tmp'
@@ -597,7 +595,7 @@ def test_rm_rf_allowed_for_chat_observed_bugteam_forward_slash_worktree_scratch_
597
595
 
598
596
 
599
597
  def test_rm_rf_allowed_for_chat_observed_bugfix_reply_scratch_file_cleanup() -> None:
600
- payload = _make_bash_payload(
598
+ payload = build_bash_payload(
601
599
  'cd "'
602
600
  + _posix_style_system_temporary_root()
603
601
  + '/bugteam-pr-58-20260424071040/pr-58/worktree" && rm -rf tmp_reply_loop1-1.md'
@@ -610,7 +608,7 @@ def test_rm_rf_allowed_for_chat_observed_bugfix_reply_scratch_file_cleanup() ->
610
608
 
611
609
 
612
610
  def test_rm_rf_allowed_for_chat_observed_bugfind_multiple_scratch_files_cleanup() -> None:
613
- payload = _make_bash_payload(
611
+ payload = build_bash_payload(
614
612
  'cd "'
615
613
  + _posix_style_system_temporary_root()
616
614
  + '/bugteam-pr-58-20260424071040/pr-256/worktree" && rm -rf tmp_review_body.md tmp_finding_1.md'
@@ -623,14 +621,13 @@ def test_rm_rf_allowed_for_chat_observed_bugfind_multiple_scratch_files_cleanup(
623
621
 
624
622
 
625
623
  def test_rm_rf_allowed_via_tool_input_cwd_pointing_at_chat_observed_bugteam_worktree() -> None:
626
- payload_with_tool_input_cwd = {
627
- "tool_name": "Bash",
628
- "tool_input": {
629
- "command": "rm -rf .bugteam-tmp",
630
- "cwd": _posix_style_system_temporary_root()
631
- + "/bugteam-pr-58-20260424071040/pr-58/worktree",
632
- },
633
- }
624
+ payload_with_tool_input_cwd = build_bash_payload(
625
+ "rm -rf .bugteam-tmp",
626
+ tool_input_cwd=(
627
+ _posix_style_system_temporary_root()
628
+ + "/bugteam-pr-58-20260424071040/pr-58/worktree"
629
+ ),
630
+ )
634
631
 
635
632
  result = _run_rm_hook(payload_with_tool_input_cwd)
636
633
 
@@ -639,7 +636,7 @@ def test_rm_rf_allowed_via_tool_input_cwd_pointing_at_chat_observed_bugteam_work
639
636
 
640
637
 
641
638
  def test_rm_rf_allowed_for_chat_observed_absolute_path_in_bugteam_windows_worktree_scratch() -> None:
642
- payload = _make_bash_payload(
639
+ payload = build_bash_payload(
643
640
  'rm -rf "'
644
641
  + _posix_style_system_temporary_root()
645
642
  + '/bugteam-pr-58-20260424071040/pr-58/worktree/.bugteam-tmp"'
@@ -652,7 +649,7 @@ def test_rm_rf_allowed_for_chat_observed_absolute_path_in_bugteam_windows_worktr
652
649
 
653
650
 
654
651
  def test_rm_rf_asks_when_leading_cd_target_contains_command_substitution_dollar_parenthesis() -> None:
655
- payload = _make_bash_payload(
652
+ payload = build_bash_payload(
656
653
  'cd "/tmp/$(rm -rf ~/.ssh)" && ls'
657
654
  )
658
655
 
@@ -663,7 +660,7 @@ def test_rm_rf_asks_when_leading_cd_target_contains_command_substitution_dollar_
663
660
 
664
661
 
665
662
  def test_rm_rf_asks_when_leading_cd_target_contains_backtick_command_substitution() -> None:
666
- payload = _make_bash_payload(
663
+ payload = build_bash_payload(
667
664
  'cd "/tmp/`rm -rf ~/.ssh`" && rm -rf .bugteam-tmp'
668
665
  )
669
666
 
@@ -674,7 +671,7 @@ def test_rm_rf_asks_when_leading_cd_target_contains_backtick_command_substitutio
674
671
 
675
672
 
676
673
  def test_rm_rf_asks_when_leading_cd_target_contains_variable_expansion() -> None:
677
- payload = _make_bash_payload(
674
+ payload = build_bash_payload(
678
675
  'cd "/tmp/$SNEAKY_VAR" && rm -rf .bugteam-tmp'
679
676
  )
680
677
 
@@ -685,7 +682,7 @@ def test_rm_rf_asks_when_leading_cd_target_contains_variable_expansion() -> None
685
682
 
686
683
 
687
684
  def test_rm_rf_asks_when_leading_cd_adjacent_quoted_strings_resolve_outside_ephemeral() -> None:
688
- payload = _make_bash_payload(
685
+ payload = build_bash_payload(
689
686
  'cd "/tmp/a""/../../etc" && rm -rf .'
690
687
  )
691
688
 
@@ -696,7 +693,7 @@ def test_rm_rf_asks_when_leading_cd_adjacent_quoted_strings_resolve_outside_ephe
696
693
 
697
694
 
698
695
  def test_rm_rf_asks_when_leading_cd_adjacent_quoted_strings_use_mixed_quotes() -> None:
699
- payload = _make_bash_payload(
696
+ payload = build_bash_payload(
700
697
  """cd "/tmp/a"'/../../etc' && rm -rf ."""
701
698
  )
702
699
 
@@ -707,7 +704,7 @@ def test_rm_rf_asks_when_leading_cd_adjacent_quoted_strings_use_mixed_quotes() -
707
704
 
708
705
 
709
706
  def test_rm_rf_asks_when_target_is_bare_tmp_root() -> None:
710
- payload = _make_bash_payload("rm -rf /tmp")
707
+ payload = build_bash_payload("rm -rf /tmp")
711
708
 
712
709
  result = _run_rm_hook(payload)
713
710
 
@@ -716,7 +713,7 @@ def test_rm_rf_asks_when_target_is_bare_tmp_root() -> None:
716
713
 
717
714
 
718
715
  def test_rm_rf_asks_when_target_is_double_slash_tmp_root() -> None:
719
- payload = _make_bash_payload("rm -rf //tmp")
716
+ payload = build_bash_payload("rm -rf //tmp")
720
717
 
721
718
  result = _run_rm_hook(payload)
722
719
 
@@ -724,8 +721,13 @@ def test_rm_rf_asks_when_target_is_double_slash_tmp_root() -> None:
724
721
  assert response["hookSpecificOutput"]["permissionDecision"] == "ask"
725
722
 
726
723
 
727
- def test_rm_rf_asks_when_target_is_bare_os_temp_root() -> None:
728
- payload = _make_bash_payload(f"rm -rf {tempfile.gettempdir()}")
724
+ def test_rm_rf_asks_when_target_is_bare_os_temp_root(
725
+ monkeypatch: pytest.MonkeyPatch,
726
+ ) -> None:
727
+ system_temporary_root = tempfile.gettempdir()
728
+ monkeypatch.setenv("TEMP", system_temporary_root)
729
+ monkeypatch.setenv("TMP", system_temporary_root)
730
+ payload = build_bash_payload(f"rm -rf {system_temporary_root}")
729
731
 
730
732
  result = _run_rm_hook(payload)
731
733
 
@@ -734,14 +736,14 @@ def test_rm_rf_asks_when_target_is_bare_os_temp_root() -> None:
734
736
 
735
737
 
736
738
  def test_rm_rf_asks_when_ephemeral_auto_allow_disabled_via_env_var() -> None:
737
- payload = _make_bash_payload("rm -rf /tmp/scratch")
739
+ payload = build_bash_payload("rm -rf /tmp/scratch")
738
740
 
739
741
  child_environment = os.environ.copy()
740
742
  child_environment.pop(GH_REDIRECT_ACTIVE_ENV_VAR, None)
741
743
  child_environment["CLAUDE_DESTRUCTIVE_DISABLE_EPHEMERAL_AUTO_ALLOW"] = "1"
742
744
  result = subprocess.run(
743
745
  [sys.executable, str(SCRIPT_PATH)],
744
- input=json.dumps(payload),
746
+ input=payload,
745
747
  text=True,
746
748
  capture_output=True,
747
749
  check=False,
@@ -753,7 +755,7 @@ def test_rm_rf_asks_when_ephemeral_auto_allow_disabled_via_env_var() -> None:
753
755
 
754
756
 
755
757
  def test_rm_recursive_force_long_flags_allowed_under_tmp() -> None:
756
- payload = _make_bash_payload("rm --recursive --force /tmp/long_flag_scratch")
758
+ payload = build_bash_payload("rm --recursive --force /tmp/long_flag_scratch")
757
759
 
758
760
  result = _run_rm_hook(payload)
759
761
 
@@ -762,7 +764,7 @@ def test_rm_recursive_force_long_flags_allowed_under_tmp() -> None:
762
764
 
763
765
 
764
766
  def test_rm_rf_asks_when_quoted_dotdot_traverses_out_of_ephemeral_root() -> None:
765
- payload = _make_bash_payload('rm -rf /tmp/".."/etc')
767
+ payload = build_bash_payload('rm -rf /tmp/".."/etc')
766
768
 
767
769
  result = _run_rm_hook(payload)
768
770
 
@@ -771,7 +773,7 @@ def test_rm_rf_asks_when_quoted_dotdot_traverses_out_of_ephemeral_root() -> None
771
773
 
772
774
 
773
775
  def test_rm_rf_allowed_when_quoted_path_is_legitimate_ephemeral() -> None:
774
- payload = _make_bash_payload('rm -rf "/tmp/some scratch dir"')
776
+ payload = build_bash_payload('rm -rf "/tmp/some scratch dir"')
775
777
 
776
778
  result = _run_rm_hook(payload)
777
779
 
@@ -780,7 +782,7 @@ def test_rm_rf_allowed_when_quoted_path_is_legitimate_ephemeral() -> None:
780
782
 
781
783
 
782
784
  def test_rm_rf_asks_when_single_quoted_dotdot_traverses_out_of_ephemeral() -> None:
783
- payload = _make_bash_payload("rm -rf /tmp/'..'/etc")
785
+ payload = build_bash_payload("rm -rf /tmp/'..'/etc")
784
786
 
785
787
  result = _run_rm_hook(payload)
786
788
 
@@ -789,7 +791,7 @@ def test_rm_rf_asks_when_single_quoted_dotdot_traverses_out_of_ephemeral() -> No
789
791
 
790
792
 
791
793
  def test_rm_rf_asks_when_target_is_glob_wildcard_under_tmp() -> None:
792
- payload = _make_bash_payload("rm -rf /tmp/*")
794
+ payload = build_bash_payload("rm -rf /tmp/*")
793
795
 
794
796
  result = _run_rm_hook(payload)
795
797
 
@@ -798,7 +800,7 @@ def test_rm_rf_asks_when_target_is_glob_wildcard_under_tmp() -> None:
798
800
 
799
801
 
800
802
  def test_rm_rf_asks_when_target_is_question_mark_glob_under_tmp() -> None:
801
- payload = _make_bash_payload("rm -rf /tmp/?")
803
+ payload = build_bash_payload("rm -rf /tmp/?")
802
804
 
803
805
  result = _run_rm_hook(payload)
804
806
 
@@ -807,7 +809,7 @@ def test_rm_rf_asks_when_target_is_question_mark_glob_under_tmp() -> None:
807
809
 
808
810
 
809
811
  def test_rm_rf_asks_when_target_is_bracket_glob_under_tmp() -> None:
810
- payload = _make_bash_payload("rm -rf /tmp/[abc]")
812
+ payload = build_bash_payload("rm -rf /tmp/[abc]")
811
813
 
812
814
  result = _run_rm_hook(payload)
813
815
 
@@ -816,7 +818,7 @@ def test_rm_rf_asks_when_target_is_bracket_glob_under_tmp() -> None:
816
818
 
817
819
 
818
820
  def test_rm_rf_asks_when_target_is_worktrees_glob() -> None:
819
- payload = _make_bash_payload("rm -rf /worktrees/*")
821
+ payload = build_bash_payload("rm -rf /worktrees/*")
820
822
 
821
823
  result = _run_rm_hook(payload)
822
824
 
@@ -824,10 +826,14 @@ def test_rm_rf_asks_when_target_is_worktrees_glob() -> None:
824
826
  assert response["hookSpecificOutput"]["permissionDecision"] == "ask"
825
827
 
826
828
 
827
- def test_rm_rf_asks_when_target_is_os_temp_root_glob() -> None:
828
- system_temporary_root = tempfile.gettempdir()
829
+ def test_rm_rf_asks_when_target_is_os_temp_root_glob(
830
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
831
+ ) -> None:
832
+ monkeypatch.setenv("TEMP", str(tmp_path))
833
+ monkeypatch.setenv("TMP", str(tmp_path))
834
+ system_temporary_root = str(tmp_path)
829
835
  forward_slash_temp_root = system_temporary_root.replace("\\", "/")
830
- payload = _make_bash_payload(f"rm -rf {forward_slash_temp_root}/*")
836
+ payload = build_bash_payload(f"rm -rf {forward_slash_temp_root}/*")
831
837
 
832
838
  result = _run_rm_hook(payload)
833
839
 
@@ -835,10 +841,14 @@ def test_rm_rf_asks_when_target_is_os_temp_root_glob() -> None:
835
841
  assert response["hookSpecificOutput"]["permissionDecision"] == "ask"
836
842
 
837
843
 
838
- def test_rm_rf_allowed_when_unquoted_windows_backslash_target_is_ephemeral() -> None:
839
- system_temporary_root = tempfile.gettempdir()
844
+ def test_rm_rf_allowed_when_unquoted_windows_backslash_target_is_ephemeral(
845
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
846
+ ) -> None:
847
+ monkeypatch.setenv("TEMP", str(tmp_path))
848
+ monkeypatch.setenv("TMP", str(tmp_path))
849
+ system_temporary_root = str(tmp_path)
840
850
  windows_style_target = system_temporary_root.replace("/", "\\") + "\\scratch"
841
- payload = _make_bash_payload(f"rm -rf {windows_style_target}")
851
+ payload = build_bash_payload(f"rm -rf {windows_style_target}")
842
852
 
843
853
  result = _run_rm_hook(payload)
844
854
 
@@ -847,7 +857,7 @@ def test_rm_rf_allowed_when_unquoted_windows_backslash_target_is_ephemeral() ->
847
857
 
848
858
 
849
859
  def test_rm_rf_allowed_when_unquoted_windows_backslash_target_contains_worktrees_segment() -> None:
850
- payload = _make_bash_payload(
860
+ payload = build_bash_payload(
851
861
  r"rm -rf C:\Users\developer\project\worktrees\feature_branch\build"
852
862
  )
853
863
 
@@ -858,7 +868,7 @@ def test_rm_rf_allowed_when_unquoted_windows_backslash_target_contains_worktrees
858
868
 
859
869
 
860
870
  def test_rm_rf_allowed_when_finding_example_windows_backslash_ephemeral_target() -> None:
861
- payload = _make_bash_payload(
871
+ payload = build_bash_payload(
862
872
  "rm -rf " + _windows_style_system_temporary_root() + r"\scratch"
863
873
  )
864
874
 
@@ -869,7 +879,7 @@ def test_rm_rf_allowed_when_finding_example_windows_backslash_ephemeral_target()
869
879
 
870
880
 
871
881
  def test_rm_rf_asks_when_target_is_literal_tmp_star_finding_example() -> None:
872
- payload = _make_bash_payload("rm -rf /tmp/*")
882
+ payload = build_bash_payload("rm -rf /tmp/*")
873
883
 
874
884
  result = _run_rm_hook(payload)
875
885
 
@@ -878,7 +888,7 @@ def test_rm_rf_asks_when_target_is_literal_tmp_star_finding_example() -> None:
878
888
 
879
889
 
880
890
  def test_rm_rf_asks_when_target_basename_is_wildcard_with_prefix_under_tmp() -> None:
881
- payload = _make_bash_payload("rm -rf /tmp/foo*")
891
+ payload = build_bash_payload("rm -rf /tmp/foo*")
882
892
 
883
893
  result = _run_rm_hook(payload)
884
894
 
@@ -887,7 +897,7 @@ def test_rm_rf_asks_when_target_basename_is_wildcard_with_prefix_under_tmp() ->
887
897
 
888
898
 
889
899
  def _run_hook_with_fake_home(
890
- payload: dict,
900
+ payload: str,
891
901
  fake_home: Path,
892
902
  working_directory: Path,
893
903
  disable_ephemeral_auto_allow: bool = True,
@@ -902,7 +912,7 @@ def _run_hook_with_fake_home(
902
912
  child_environment.pop("CLAUDE_DESTRUCTIVE_DISABLE_EPHEMERAL_AUTO_ALLOW", None)
903
913
  return subprocess.run(
904
914
  [sys.executable, str(SCRIPT_PATH)],
905
- input=json.dumps(payload),
915
+ input=payload,
906
916
  text=True,
907
917
  capture_output=True,
908
918
  check=False,
@@ -927,7 +937,7 @@ def test_git_reset_hard_allowed_when_cwd_matches_settings_allow_list(tmp_path: P
927
937
  project_directory.mkdir()
928
938
  project_path_forward = str(project_directory).replace("\\", "/")
929
939
  _write_settings_with_allow_list(fake_home, [project_path_forward])
930
- payload = _make_bash_payload("git reset --hard origin/main")
940
+ payload = build_bash_payload("git reset --hard origin/main")
931
941
 
932
942
  result = _run_hook_with_fake_home(payload, fake_home, project_directory)
933
943
 
@@ -944,7 +954,7 @@ def test_git_reset_hard_asks_when_cwd_not_in_settings_allow_list(tmp_path: Path)
944
954
  unapproved_directory.mkdir()
945
955
  approved_path_forward = str(approved_directory).replace("\\", "/")
946
956
  _write_settings_with_allow_list(fake_home, [approved_path_forward])
947
- payload = _make_bash_payload("git reset --hard origin/main")
957
+ payload = build_bash_payload("git reset --hard origin/main")
948
958
 
949
959
  result = _run_hook_with_fake_home(payload, fake_home, unapproved_directory)
950
960
 
@@ -958,7 +968,7 @@ def test_git_reset_hard_asks_when_settings_missing(tmp_path: Path) -> None:
958
968
  fake_home.mkdir()
959
969
  project_directory = tmp_path / "unapproved_project"
960
970
  project_directory.mkdir()
961
- payload = _make_bash_payload("git reset --hard origin/main")
971
+ payload = build_bash_payload("git reset --hard origin/main")
962
972
 
963
973
  result = _run_hook_with_fake_home(payload, fake_home, project_directory)
964
974
 
@@ -972,7 +982,7 @@ def test_git_reset_hard_asks_when_allow_list_is_empty(tmp_path: Path) -> None:
972
982
  project_directory = tmp_path / "some_project"
973
983
  project_directory.mkdir()
974
984
  _write_settings_with_allow_list(fake_home, [])
975
- payload = _make_bash_payload("git reset --hard origin/main")
985
+ payload = build_bash_payload("git reset --hard origin/main")
976
986
 
977
987
  result = _run_hook_with_fake_home(payload, fake_home, project_directory)
978
988
 
@@ -993,7 +1003,7 @@ def test_git_reset_hard_allowed_in_linked_git_worktree(tmp_path: Path) -> None:
993
1003
  cwd=main_repository,
994
1004
  check=True,
995
1005
  )
996
- payload = _make_bash_payload("git reset --hard origin/main")
1006
+ payload = build_bash_payload("git reset --hard origin/main")
997
1007
 
998
1008
  result = _run_hook_with_fake_home(payload, fake_home, worktree_directory, disable_ephemeral_auto_allow=False)
999
1009
 
@@ -1006,7 +1016,7 @@ def test_git_reset_hard_allowed_when_path_contains_worktrees_segment(tmp_path: P
1006
1016
  fake_home.mkdir()
1007
1017
  worktree_directory = tmp_path / "worktrees" / "some_feature"
1008
1018
  worktree_directory.mkdir(parents=True)
1009
- payload = _make_bash_payload("git reset --hard origin/main")
1019
+ payload = build_bash_payload("git reset --hard origin/main")
1010
1020
 
1011
1021
  result = _run_hook_with_fake_home(payload, fake_home, worktree_directory, disable_ephemeral_auto_allow=False)
1012
1022
 
@@ -1014,10 +1024,16 @@ def test_git_reset_hard_allowed_when_path_contains_worktrees_segment(tmp_path: P
1014
1024
  assert result.returncode == 0
1015
1025
 
1016
1026
 
1017
- def test_git_reset_hard_allowed_under_os_temp_directory() -> None:
1018
- fake_home = Path(tempfile.mkdtemp(prefix="home_"))
1019
- working_directory = Path(tempfile.mkdtemp(prefix="ephemeral_work_"))
1020
- payload = _make_bash_payload("git reset --hard origin/main")
1027
+ def test_git_reset_hard_allowed_under_os_temp_directory(
1028
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1029
+ ) -> None:
1030
+ monkeypatch.setenv("TEMP", str(tmp_path))
1031
+ monkeypatch.setenv("TMP", str(tmp_path))
1032
+ fake_home = tmp_path / "home"
1033
+ fake_home.mkdir()
1034
+ working_directory = tmp_path / "ephemeral_work"
1035
+ working_directory.mkdir()
1036
+ payload = build_bash_payload("git reset --hard origin/main")
1021
1037
 
1022
1038
  result = _run_hook_with_fake_home(payload, fake_home, working_directory, disable_ephemeral_auto_allow=False)
1023
1039
 
@@ -1030,7 +1046,7 @@ def test_git_reset_hard_asks_in_plain_directory_with_no_settings(tmp_path: Path)
1030
1046
  fake_home.mkdir()
1031
1047
  plain_directory = tmp_path / "regular_project"
1032
1048
  plain_directory.mkdir()
1033
- payload = _make_bash_payload("git reset --hard origin/main")
1049
+ payload = build_bash_payload("git reset --hard origin/main")
1034
1050
 
1035
1051
  result = _run_hook_with_fake_home(payload, fake_home, plain_directory)
1036
1052
 
@@ -1046,7 +1062,7 @@ def test_git_reset_hard_asks_when_settings_file_is_invalid_json(tmp_path: Path)
1046
1062
  )
1047
1063
  project_directory = tmp_path / "unapproved_project"
1048
1064
  project_directory.mkdir()
1049
- payload = _make_bash_payload("git reset --hard origin/main")
1065
+ payload = build_bash_payload("git reset --hard origin/main")
1050
1066
 
1051
1067
  result = _run_hook_with_fake_home(payload, fake_home, project_directory)
1052
1068
 
@@ -1058,7 +1074,7 @@ def test_git_reset_hard_asks_when_settings_file_is_invalid_json(tmp_path: Path)
1058
1074
 
1059
1075
 
1060
1076
  def _assert_hook_allows(command: str) -> None:
1061
- result = _run_rm_hook(_make_bash_payload(command))
1077
+ result = _run_rm_hook(build_bash_payload(command))
1062
1078
  assert result.stdout.strip() == "", (
1063
1079
  f"Expected allow (no output) for {command!r}, got: {result.stdout!r}"
1064
1080
  )
@@ -1066,7 +1082,7 @@ def _assert_hook_allows(command: str) -> None:
1066
1082
 
1067
1083
 
1068
1084
  def _assert_hook_asks(command: str, expected_reason_fragment: str | None = None) -> None:
1069
- result = _run_rm_hook(_make_bash_payload(command))
1085
+ result = _run_rm_hook(build_bash_payload(command))
1070
1086
  response = json.loads(result.stdout)
1071
1087
  assert response["hookSpecificOutput"]["permissionDecision"] == "ask", (
1072
1088
  f"Expected ask for {command!r}, got: {response!r}"
@@ -1721,7 +1737,7 @@ def test_all_refspecs_convergence_branch_with_flags() -> None:
1721
1737
 
1722
1738
 
1723
1739
  def test_force_push_convergence_with_no_verify_blocked() -> None:
1724
- payload = _make_bash_payload(
1740
+ payload = build_bash_payload(
1725
1741
  "git push --force origin --no-verify claude/fix-123"
1726
1742
  )
1727
1743
 
@@ -1733,7 +1749,7 @@ def test_force_push_convergence_with_no_verify_blocked() -> None:
1733
1749
 
1734
1750
 
1735
1751
  def test_force_push_convergence_with_no_gpg_sign_blocked() -> None:
1736
- payload = _make_bash_payload(
1752
+ payload = build_bash_payload(
1737
1753
  "git push --force origin --no-gpg-sign claude/fix-123"
1738
1754
  )
1739
1755