claude-dev-env 2.30.0 → 2.31.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/.agents/agents/pr-description-writer.md +1 -1
- package/.agents/skills/e-simplify/SKILL.md +20 -8
- package/AGENTS.md +6 -0
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +37 -0
- package/bin/install.prune.test.mjs +4 -2
- package/docs/CODE_RULES.md +11 -0
- package/hooks/advisory/migration_safety_advisor.py +25 -1
- package/hooks/advisory/refactor_guard.py +85 -18
- package/hooks/advisory/test_migration_safety_advisor.py +89 -5
- package/hooks/advisory/test_refactor_guard.py +87 -0
- package/hooks/advisory/test_refactor_guard_advisory.py +72 -1
- package/hooks/blocking/code_rules_constants_config.py +7 -6
- package/hooks/blocking/code_rules_enforcer.py +59 -36
- package/hooks/blocking/codex_apply_patch.py +136 -21
- package/hooks/blocking/config/codex_apply_patch_constants.py +13 -0
- package/hooks/blocking/conftest.py +70 -0
- package/hooks/blocking/duplicate_rmtree_helper_blocker.py +26 -16
- package/hooks/blocking/hook_prose_detector_consistency.py +30 -20
- package/hooks/blocking/pii_payload_scan.py +61 -0
- package/hooks/blocking/pii_prevention_blocker.py +5 -0
- package/hooks/blocking/pr_description_writer_gate.py +263 -0
- package/hooks/blocking/pre_tool_use_dispatcher.py +1 -1
- package/hooks/blocking/sensitive_file_protector.py +18 -3
- package/hooks/blocking/shell_substitution_blocker.py +62 -8
- package/hooks/blocking/stale_comment_reference_blocker.py +94 -24
- package/hooks/blocking/state_description_blocker.py +39 -26
- package/hooks/blocking/subprocess_budget_completeness.py +17 -43
- package/hooks/blocking/tdd_enforcer.py +57 -14
- package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +11 -1
- package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +180 -0
- package/hooks/blocking/tdd_enforcer_parts/decisions.py +6 -2
- package/hooks/blocking/tdd_enforcer_parts/freshness.py +18 -3
- package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +66 -1
- package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +143 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +12 -0
- package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +63 -0
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +15 -0
- package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +81 -2
- package/hooks/blocking/test_code_rules_enforcer_codex_apply_patch.py +35 -0
- package/hooks/blocking/test_code_rules_enforcer_config_path.py +29 -16
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_multi_edit.py +118 -0
- package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +29 -28
- package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_stage.py +2 -0
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +2 -0
- package/hooks/blocking/test_code_rules_validation_phase.py +51 -0
- package/hooks/blocking/test_codex_apply_patch.py +43 -0
- package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +68 -0
- package/hooks/blocking/test_hook_prose_detector_consistency.py +41 -0
- package/hooks/blocking/test_pii_payload_scan.py +65 -2
- package/hooks/blocking/test_pii_prevention_blocker.py +80 -10
- package/hooks/blocking/test_pr_description_writer_gate.py +216 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +215 -25
- package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +9 -4
- package/hooks/blocking/test_sensitive_file_protector.py +42 -2
- package/hooks/blocking/test_shell_substitution_blocker.py +69 -0
- package/hooks/blocking/test_stale_comment_reference_blocker.py +71 -0
- package/hooks/blocking/test_state_description_blocker.py +68 -0
- package/hooks/blocking/test_subprocess_budget_completeness.py +67 -0
- package/hooks/blocking/test_tdd_enforcer.py +182 -2
- package/hooks/blocking/test_windows_rmtree_blocker.py +63 -0
- package/hooks/blocking/test_write_existing_file_blocker.py +122 -0
- package/hooks/blocking/windows_rmtree_blocker.py +21 -7
- package/hooks/blocking/write_existing_file_blocker.py +75 -29
- package/hooks/conftest.py +47 -0
- package/hooks/hooks.json +8 -13
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +3 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +4 -1
- package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +3 -0
- package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +7 -2
- package/hooks/hooks_constants/migration_safety_advisor_constants.py +7 -0
- package/hooks/hooks_constants/multi_edit_reconstruction.py +44 -3
- package/hooks/hooks_constants/pii_prevention_constants.py +9 -7
- package/hooks/hooks_constants/pr_description_writer_gate_constants.py +39 -0
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +31 -14
- package/hooks/hooks_constants/session_edit_stage_gate_constants.py +15 -1
- package/hooks/hooks_constants/shell_substitution_blocker_constants.py +4 -1
- package/hooks/hooks_constants/state_description_blocker_constants.py +1 -0
- package/hooks/hooks_constants/subprocess_budget_completeness_content.py +71 -0
- package/hooks/hooks_constants/task_tool_prompt_constants.py +13 -0
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +2 -0
- package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +2 -9
- package/hooks/hooks_constants/test_multi_edit_reconstruction.py +102 -0
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +139 -25
- package/hooks/hooks_constants/test_subprocess_budget_completeness_content.py +70 -0
- package/hooks/hooks_constants/validation_phase_constants.py +2 -0
- package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +7 -3
- package/hooks/observability/pr_description_writer_spawn_tracker.py +121 -0
- package/hooks/observability/session_file_edit_tracker.py +25 -4
- package/hooks/observability/test_pr_description_writer_spawn_tracker.py +146 -0
- package/hooks/observability/test_session_file_edit_tracker.py +32 -0
- package/hooks/session/task_tool_prompt.py +37 -0
- package/hooks/session/test_task_tool_prompt.py +65 -0
- package/hooks/validation/hook_format_validator.py +23 -9
- package/hooks/validation/test_hook_format_validator.py +58 -0
- package/hooks/validators/README.md +1 -1
- package/hooks/validators/abbreviation_checks.py +3 -5
- package/hooks/validators/config/abbreviation_checks_constants.py +3 -0
- package/hooks/validators/config/fast_save_validator_constants.py +32 -0
- package/hooks/validators/config/hook_timing_harness_constants.py +3 -1
- package/hooks/validators/config/test_abbreviation_checks_constants.py +11 -0
- package/hooks/validators/fast_save_validators.py +332 -0
- package/hooks/validators/hook_timing_harness.py +8 -4
- package/hooks/validators/python_style_checks.py +34 -60
- package/hooks/validators/python_style_helpers.py +16 -2
- package/hooks/validators/python_style_import_bootstrap.py +83 -0
- package/hooks/validators/run_all_validators.py +42 -41
- package/hooks/validators/test_abbreviation_checks.py +20 -0
- package/hooks/validators/test_fast_save_validators.py +172 -0
- package/hooks/validators/test_python_style_checks.py +9 -0
- package/hooks/validators/test_python_style_checks_import_order.py +81 -0
- package/hooks/validators/test_python_style_helpers.py +35 -0
- package/hooks/validators/test_python_style_import_bootstrap.py +69 -0
- package/hooks/validators/test_run_all_validators.py +46 -0
- package/hooks/validators/test_run_all_validators_pretooluse.py +2 -0
- package/package.json +1 -1
- package/rules/docstring-prose-matches-implementation.md +1 -1
- package/rules/git-workflow.md +2 -0
- package/rules/testing.md +4 -0
- package/scripts/codex_compat_materializer.py +5 -0
- package/scripts/tests/test_codex_compat_materializer.py +37 -11
- package/hooks/blocking/tests/conftest.py +0 -10
- package/hooks/hooks_constants/task_list_loop_starter_constants.py +0 -19
- package/hooks/session/task_list_loop_starter.py +0 -45
- package/hooks/session/test_task_list_loop_starter.py +0 -63
|
@@ -234,16 +234,21 @@ def test_state_description_native_edit_deny_matches_script_reason() -> None:
|
|
|
234
234
|
assert native_reason is not None
|
|
235
235
|
assert native_reason == script_reason
|
|
236
236
|
|
|
237
|
-
def
|
|
238
|
-
"""state_description_blocker native
|
|
237
|
+
def test_state_description_native_multi_edit_deny_matches_script() -> None:
|
|
238
|
+
"""state_description_blocker native denies MultiEdit, matching the script.
|
|
239
|
+
|
|
240
|
+
MultiEdit is a target tool for this hook: a historical phrase in any edit's
|
|
241
|
+
new_string denies the same way it would for Write or Edit.
|
|
242
|
+
"""
|
|
239
243
|
payload_dictionary = _multi_edit_payload_dictionary(
|
|
240
244
|
_MARKDOWN_PATH, _STATE_DESCRIPTION_DENY_CONTENT
|
|
241
245
|
)
|
|
242
246
|
native_reason = _evaluate_with_prose_style_opt_in(state_description_blocker.evaluate, payload_dictionary)
|
|
243
247
|
script_stdout = _run_script_subprocess(_STATE_DESCRIPTION_SCRIPT, payload_dictionary)
|
|
244
248
|
script_reason = _deny_reason_from_script_stdout(script_stdout)
|
|
245
|
-
assert native_reason is None
|
|
246
|
-
assert
|
|
249
|
+
assert native_reason is not None
|
|
250
|
+
assert native_reason == script_reason
|
|
251
|
+
|
|
247
252
|
|
|
248
253
|
def test_dispatcher_native_path_denies_state_description() -> None:
|
|
249
254
|
"""The dispatcher's native path denies a state_description_blocker violation."""
|
|
@@ -98,8 +98,8 @@ def _registered_dispatcher_details(
|
|
|
98
98
|
f"found {len(dispatcher_registrations)}"
|
|
99
99
|
)
|
|
100
100
|
each_registration, each_hook, command_parts = dispatcher_registrations[0]
|
|
101
|
-
assert each_registration["matcher"] == "Write|Edit|MultiEdit", (
|
|
102
|
-
"dispatcher registration must cover
|
|
101
|
+
assert each_registration["matcher"] == "Write|Edit|MultiEdit|apply_patch", (
|
|
102
|
+
"dispatcher registration must cover Write, Edit, MultiEdit, and apply_patch; "
|
|
103
103
|
f"got matcher {each_registration['matcher']!r}"
|
|
104
104
|
)
|
|
105
105
|
assert command_parts[0] == "python3"
|
|
@@ -256,6 +256,46 @@ def test_multi_edit_to_a_secrets_file_is_denied(tmp_path: Path) -> None:
|
|
|
256
256
|
assert _permission_decision(completed) == DENY_DECISION
|
|
257
257
|
|
|
258
258
|
|
|
259
|
+
def test_apply_patch_add_targeting_a_secrets_file_is_denied(tmp_path: Path) -> None:
|
|
260
|
+
payload = json.dumps(
|
|
261
|
+
{
|
|
262
|
+
"tool_name": "apply_patch",
|
|
263
|
+
"cwd": str(tmp_path),
|
|
264
|
+
"tool_input": {
|
|
265
|
+
"command": (
|
|
266
|
+
"*** Begin Patch\n"
|
|
267
|
+
"*** Add File: .env\n"
|
|
268
|
+
"+API_TOKEN=your-token-here\n"
|
|
269
|
+
"*** End Patch"
|
|
270
|
+
)
|
|
271
|
+
},
|
|
272
|
+
}
|
|
273
|
+
)
|
|
274
|
+
completed = _run_hook_with_stdin(payload)
|
|
275
|
+
assert completed.returncode == 0, completed.stderr
|
|
276
|
+
assert _permission_decision(completed) == DENY_DECISION
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def test_apply_patch_add_targeting_an_ordinary_file_is_allowed(tmp_path: Path) -> None:
|
|
280
|
+
payload = json.dumps(
|
|
281
|
+
{
|
|
282
|
+
"tool_name": "apply_patch",
|
|
283
|
+
"cwd": str(tmp_path),
|
|
284
|
+
"tool_input": {
|
|
285
|
+
"command": (
|
|
286
|
+
"*** Begin Patch\n"
|
|
287
|
+
"*** Add File: main.py\n"
|
|
288
|
+
"+x = 1\n"
|
|
289
|
+
"*** End Patch"
|
|
290
|
+
)
|
|
291
|
+
},
|
|
292
|
+
}
|
|
293
|
+
)
|
|
294
|
+
completed = _run_hook_with_stdin(payload)
|
|
295
|
+
assert completed.returncode == 0, completed.stderr
|
|
296
|
+
assert _permission_decision(completed) is None
|
|
297
|
+
|
|
298
|
+
|
|
259
299
|
def test_hooks_json_registers_exact_dispatcher_matcher_and_timeout() -> None:
|
|
260
300
|
dispatcher_command, dispatcher_timeout_seconds = _registered_dispatcher_details()
|
|
261
301
|
assert dispatcher_command[1] == str(DISPATCHER_SCRIPT_PATH)
|
|
@@ -122,3 +122,72 @@ def test_allows_arithmetic_expansion() -> None:
|
|
|
122
122
|
"tool_input": {"command": "echo $((2+2))"},
|
|
123
123
|
}
|
|
124
124
|
assert _run_hook(payload).stdout == ""
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_allows_arithmetic_expansion_with_nested_parens() -> None:
|
|
128
|
+
payload = {
|
|
129
|
+
"tool_name": "Bash",
|
|
130
|
+
"tool_input": {"command": "echo $(( (1+2) * 3 ))"},
|
|
131
|
+
}
|
|
132
|
+
assert _run_hook(payload).stdout == ""
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_allows_arithmetic_expansion_whose_body_bash_itself_rejects() -> None:
|
|
136
|
+
payload = {
|
|
137
|
+
"tool_name": "Bash",
|
|
138
|
+
"tool_input": {"command": "echo $((echo hi))"},
|
|
139
|
+
}
|
|
140
|
+
assert _run_hook(payload).stdout == ""
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_allows_arithmetic_lookalike_inside_single_quotes() -> None:
|
|
144
|
+
payload = {
|
|
145
|
+
"tool_name": "Bash",
|
|
146
|
+
"tool_input": {"command": "echo '$((cd /tmp) && pwd)'"},
|
|
147
|
+
}
|
|
148
|
+
assert _run_hook(payload).stdout == ""
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_denies_subshell_disguised_as_arithmetic_expansion() -> None:
|
|
152
|
+
payload = {
|
|
153
|
+
"tool_name": "Bash",
|
|
154
|
+
"tool_input": {"command": "echo $((cd /tmp) && pwd)"},
|
|
155
|
+
}
|
|
156
|
+
response = json.loads(_run_hook(payload).stdout)
|
|
157
|
+
assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_denies_parenthesized_command_word_disguised_as_arithmetic() -> None:
|
|
161
|
+
payload = {
|
|
162
|
+
"tool_name": "Bash",
|
|
163
|
+
"tool_input": {"command": "echo $((1+2) )"},
|
|
164
|
+
}
|
|
165
|
+
response = json.loads(_run_hook(payload).stdout)
|
|
166
|
+
assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_denies_command_substitution_nested_inside_arithmetic_expansion() -> None:
|
|
170
|
+
payload = {
|
|
171
|
+
"tool_name": "Bash",
|
|
172
|
+
"tool_input": {"command": "echo $(( $(id -u) + 1 ))"},
|
|
173
|
+
}
|
|
174
|
+
response = json.loads(_run_hook(payload).stdout)
|
|
175
|
+
assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_denies_backtick_substitution_nested_inside_arithmetic_expansion() -> None:
|
|
179
|
+
payload = {
|
|
180
|
+
"tool_name": "Bash",
|
|
181
|
+
"tool_input": {"command": "echo $(( `id -u` + 1 ))"},
|
|
182
|
+
}
|
|
183
|
+
response = json.loads(_run_hook(payload).stdout)
|
|
184
|
+
assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_denies_nested_command_substitution_without_arithmetic() -> None:
|
|
188
|
+
payload = {
|
|
189
|
+
"tool_name": "Bash",
|
|
190
|
+
"tool_input": {"command": "echo $(echo $(whoami))"},
|
|
191
|
+
}
|
|
192
|
+
response = json.loads(_run_hook(payload).stdout)
|
|
193
|
+
assert response["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
@@ -207,6 +207,77 @@ def test_allows_old_string_not_found(tmp_path: Path) -> None:
|
|
|
207
207
|
assert outcome.stdout == ""
|
|
208
208
|
|
|
209
209
|
|
|
210
|
+
def test_multi_edit_denies_when_the_first_edit_writes_the_stale_comment(
|
|
211
|
+
tmp_path: Path,
|
|
212
|
+
) -> None:
|
|
213
|
+
"""The comment the second edit orphans is one the first edit just wrote.
|
|
214
|
+
|
|
215
|
+
A non-cumulative check reads the ORIGINAL "# Old note" comment and finds
|
|
216
|
+
nothing to orphan; only applying the first edit before the second reveals
|
|
217
|
+
"# Mock asyncio" above the removed line.
|
|
218
|
+
"""
|
|
219
|
+
written_module_path = _write_module(
|
|
220
|
+
tmp_path,
|
|
221
|
+
"def launch_export() -> None:\n # Old note\n" + PATCHED_SLEEP_LINE,
|
|
222
|
+
)
|
|
223
|
+
all_edits = [
|
|
224
|
+
{"old_string": " # Old note\n", "new_string": KEPT_COMMENT_LINE},
|
|
225
|
+
{
|
|
226
|
+
"old_string": PATCHED_SLEEP_LINE.rstrip("\n"),
|
|
227
|
+
"new_string": PATCHED_POLL_LINE.rstrip("\n"),
|
|
228
|
+
},
|
|
229
|
+
]
|
|
230
|
+
outcome = _run_hook(
|
|
231
|
+
"MultiEdit", {"file_path": str(written_module_path), "edits": all_edits}
|
|
232
|
+
)
|
|
233
|
+
assert outcome.returncode == 0
|
|
234
|
+
decision = json.loads(outcome.stdout)
|
|
235
|
+
assert decision["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
236
|
+
assert "asyncio" in decision["hookSpecificOutput"]["permissionDecisionReason"]
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def test_multi_edit_allows_two_unrelated_edits(tmp_path: Path) -> None:
|
|
240
|
+
written_module_path = _write_module(
|
|
241
|
+
tmp_path,
|
|
242
|
+
"def launch_export() -> None:\n" + KEPT_COMMENT_LINE + PATCHED_SLEEP_LINE,
|
|
243
|
+
)
|
|
244
|
+
all_edits = [
|
|
245
|
+
{
|
|
246
|
+
"old_string": "def launch_export() -> None:",
|
|
247
|
+
"new_string": "def launch_export_now() -> None:",
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
"old_string": PATCHED_SLEEP_LINE,
|
|
251
|
+
"new_string": KEPT_COMMENT_LINE + PATCHED_SLEEP_LINE,
|
|
252
|
+
},
|
|
253
|
+
]
|
|
254
|
+
outcome = _run_hook(
|
|
255
|
+
"MultiEdit", {"file_path": str(written_module_path), "edits": all_edits}
|
|
256
|
+
)
|
|
257
|
+
assert outcome.returncode == 0
|
|
258
|
+
assert outcome.stdout == ""
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def test_multi_edit_skips_a_step_whose_old_string_is_absent(tmp_path: Path) -> None:
|
|
262
|
+
"""A MultiEdit step whose old_string never occurs in the current content is skipped."""
|
|
263
|
+
written_module_path = _write_module(
|
|
264
|
+
tmp_path,
|
|
265
|
+
"def launch_export() -> None:\n" + KEPT_COMMENT_LINE + PATCHED_SLEEP_LINE,
|
|
266
|
+
)
|
|
267
|
+
all_edits = [
|
|
268
|
+
{"old_string": "never present anywhere", "new_string": "still never present"},
|
|
269
|
+
{
|
|
270
|
+
"old_string": PATCHED_SLEEP_LINE,
|
|
271
|
+
"new_string": KEPT_COMMENT_LINE + PATCHED_SLEEP_LINE,
|
|
272
|
+
},
|
|
273
|
+
]
|
|
274
|
+
outcome = _run_hook(
|
|
275
|
+
"MultiEdit", {"file_path": str(written_module_path), "edits": all_edits}
|
|
276
|
+
)
|
|
277
|
+
assert outcome.returncode == 0
|
|
278
|
+
assert outcome.stdout == ""
|
|
279
|
+
|
|
280
|
+
|
|
210
281
|
def test_deny_payload_logs_the_block(tmp_path: Path) -> None:
|
|
211
282
|
"""One record lands in the block journal for each denial built through
|
|
212
283
|
evaluate plus build_deny_payload.
|
|
@@ -295,6 +295,74 @@ def test_clean_edit_passes():
|
|
|
295
295
|
assert result.stdout == ""
|
|
296
296
|
|
|
297
297
|
|
|
298
|
+
def test_detects_multi_edit_second_new_string() -> None:
|
|
299
|
+
"""A violation in the second edit of a MultiEdit is denied, not only the first."""
|
|
300
|
+
result = _run_hook(
|
|
301
|
+
"MultiEdit",
|
|
302
|
+
{
|
|
303
|
+
"file_path": "src/main.py",
|
|
304
|
+
"edits": [
|
|
305
|
+
{"old_string": "a = 1", "new_string": CLEAN_PYTHON},
|
|
306
|
+
{"old_string": "old_comment", "new_string": VIOLATION_PREVIOUSLY_COMMENT},
|
|
307
|
+
],
|
|
308
|
+
},
|
|
309
|
+
)
|
|
310
|
+
assert result.returncode == 0
|
|
311
|
+
output = json.loads(result.stdout)
|
|
312
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
313
|
+
assert "previously" in output["hookSpecificOutput"]["permissionDecisionReason"]
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def test_clean_multi_edit_passes() -> None:
|
|
317
|
+
result = _run_hook(
|
|
318
|
+
"MultiEdit",
|
|
319
|
+
{
|
|
320
|
+
"file_path": "src/main.py",
|
|
321
|
+
"edits": [
|
|
322
|
+
{"old_string": "a = 1", "new_string": CLEAN_PYTHON},
|
|
323
|
+
{"old_string": "b = 2", "new_string": CLEAN_COMMENT},
|
|
324
|
+
],
|
|
325
|
+
},
|
|
326
|
+
)
|
|
327
|
+
assert result.returncode == 0
|
|
328
|
+
assert result.stdout == ""
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def test_multi_edit_new_strings_join_on_a_separator_not_concatenation() -> None:
|
|
332
|
+
"""Two clean edit fragments stay clean even when one ends where the next begins.
|
|
333
|
+
|
|
334
|
+
The first edit's new_string ends in "# describes the previous", and the
|
|
335
|
+
second's begins with "ly implemented step.". Concatenated with no
|
|
336
|
+
separator the two would spell "previously" and trip a false deny; joined
|
|
337
|
+
on a real separator they stay two distinct fragments.
|
|
338
|
+
"""
|
|
339
|
+
result = _run_hook(
|
|
340
|
+
"MultiEdit",
|
|
341
|
+
{
|
|
342
|
+
"file_path": "src/main.py",
|
|
343
|
+
"edits": [
|
|
344
|
+
{"old_string": "a = 1", "new_string": "# describes the previous"},
|
|
345
|
+
{"old_string": "b = 2", "new_string": "ly implemented step."},
|
|
346
|
+
],
|
|
347
|
+
},
|
|
348
|
+
)
|
|
349
|
+
assert result.returncode == 0
|
|
350
|
+
assert result.stdout == ""
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def test_multi_edit_with_a_single_edit_still_denies() -> None:
|
|
354
|
+
result = _run_hook(
|
|
355
|
+
"MultiEdit",
|
|
356
|
+
{
|
|
357
|
+
"file_path": "src/main.py",
|
|
358
|
+
"edits": [{"old_string": "old_comment", "new_string": VIOLATION_PREVIOUSLY_COMMENT}],
|
|
359
|
+
},
|
|
360
|
+
)
|
|
361
|
+
assert result.returncode == 0
|
|
362
|
+
output = json.loads(result.stdout)
|
|
363
|
+
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
364
|
+
|
|
365
|
+
|
|
298
366
|
def test_system_message_and_suppress_output():
|
|
299
367
|
result = _run_hook(
|
|
300
368
|
"Write",
|
|
@@ -586,3 +586,70 @@ def test_full_hook_denies_edit_that_adds_a_timeout_to_a_non_budget_helper(
|
|
|
586
586
|
hook_output = json.loads(completed_hook.stdout)
|
|
587
587
|
assert hook_output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
588
588
|
assert "45s" in hook_output["hookSpecificOutput"]["permissionDecisionReason"]
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
_HARMLESS_COMMENT_EDIT = {
|
|
592
|
+
"old_string": "return git_check.returncode != 0\n",
|
|
593
|
+
"new_string": "return git_check.returncode != 0 # checked\n",
|
|
594
|
+
}
|
|
595
|
+
_UNCOUNTED_TIMEOUT_EDIT = {
|
|
596
|
+
"old_string": (
|
|
597
|
+
' git_check = subprocess.run(["git", "ls-files", file_path],'
|
|
598
|
+
" timeout=GIT_CHECK_TIMEOUT_SECONDS)\n"
|
|
599
|
+
),
|
|
600
|
+
"new_string": ' git_check = subprocess.run(["git", "ls-files", file_path], timeout=45)\n',
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _run_multi_edit_hook(edited_module_path: pathlib.Path, all_edits: list[dict[str, str]]):
|
|
605
|
+
hook_input = json.dumps(
|
|
606
|
+
{
|
|
607
|
+
"tool_name": "MultiEdit",
|
|
608
|
+
"tool_input": {"file_path": str(edited_module_path), "edits": all_edits},
|
|
609
|
+
}
|
|
610
|
+
)
|
|
611
|
+
return subprocess.run(
|
|
612
|
+
[sys.executable, str(_HOOK_DIR / "subprocess_budget_completeness.py")],
|
|
613
|
+
input=hook_input,
|
|
614
|
+
capture_output=True,
|
|
615
|
+
text=True,
|
|
616
|
+
timeout=15,
|
|
617
|
+
check=False,
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def test_resolved_content_reconstructs_the_full_file_for_a_multi_edit(
|
|
622
|
+
tmp_path: pathlib.Path,
|
|
623
|
+
) -> None:
|
|
624
|
+
"""The whole reconstructed file reflects every edit, not only the first."""
|
|
625
|
+
edited_module_path = tmp_path / "timing_module.py"
|
|
626
|
+
edited_module_path.write_text(_BUDGET_COUNTS_EVERY_TIMEOUT, encoding="utf-8")
|
|
627
|
+
reconstructed_content = resolved_content(
|
|
628
|
+
{
|
|
629
|
+
"file_path": str(edited_module_path),
|
|
630
|
+
"edits": [_HARMLESS_COMMENT_EDIT, _UNCOUNTED_TIMEOUT_EDIT],
|
|
631
|
+
}
|
|
632
|
+
)
|
|
633
|
+
assert "timeout=45" in reconstructed_content
|
|
634
|
+
assert "# checked" in reconstructed_content
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def test_full_hook_denies_multi_edit_that_adds_a_timeout_in_the_second_edit(
|
|
638
|
+
production_module_path: pathlib.Path,
|
|
639
|
+
) -> None:
|
|
640
|
+
"""A new timeout introduced by the second edit of a MultiEdit is caught."""
|
|
641
|
+
production_module_path.write_text(_BUDGET_COUNTS_EVERY_TIMEOUT, encoding="utf-8")
|
|
642
|
+
completed_hook = _run_multi_edit_hook(
|
|
643
|
+
production_module_path, [_HARMLESS_COMMENT_EDIT, _UNCOUNTED_TIMEOUT_EDIT]
|
|
644
|
+
)
|
|
645
|
+
assert completed_hook.returncode == 0
|
|
646
|
+
hook_output = json.loads(completed_hook.stdout)
|
|
647
|
+
assert hook_output["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
648
|
+
assert "45s" in hook_output["hookSpecificOutput"]["permissionDecisionReason"]
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def test_full_hook_allows_a_clean_multi_edit(production_module_path: pathlib.Path) -> None:
|
|
652
|
+
production_module_path.write_text(_BUDGET_COUNTS_EVERY_TIMEOUT, encoding="utf-8")
|
|
653
|
+
completed_hook = _run_multi_edit_hook(production_module_path, [_HARMLESS_COMMENT_EDIT])
|
|
654
|
+
assert completed_hook.returncode == 0
|
|
655
|
+
assert completed_hook.stdout == ""
|
|
@@ -10,6 +10,9 @@ import time
|
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
|
|
12
12
|
import pytest
|
|
13
|
+
from tdd_enforcer_parts import content_hash_store
|
|
14
|
+
|
|
15
|
+
pytestmark = pytest.mark.usefixtures("ephemeral_exempt_off")
|
|
13
16
|
|
|
14
17
|
SCRIPT_PATH = Path(__file__).parent / "tdd_enforcer.py"
|
|
15
18
|
|
|
@@ -49,6 +52,14 @@ def _make_write_payload(file_path: Path, content: str = "") -> dict:
|
|
|
49
52
|
}
|
|
50
53
|
|
|
51
54
|
|
|
55
|
+
def _make_apply_patch_payload(working_directory: Path, patch_command: str) -> dict:
|
|
56
|
+
return {
|
|
57
|
+
"tool_name": "apply_patch",
|
|
58
|
+
"cwd": str(working_directory),
|
|
59
|
+
"tool_input": {"command": patch_command},
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
52
63
|
def _decision_from(completed: subprocess.CompletedProcess[str]) -> str | None:
|
|
53
64
|
if not completed.stdout:
|
|
54
65
|
return None
|
|
@@ -76,6 +87,49 @@ def test_should_allow_when_sibling_test_file_exists_and_recent(tmp_path: Path) -
|
|
|
76
87
|
assert _decision_from(completed) == "allow"
|
|
77
88
|
|
|
78
89
|
|
|
90
|
+
def test_should_deny_apply_patch_add_with_no_fresh_test(tmp_path: Path) -> None:
|
|
91
|
+
sandbox = _sandbox(tmp_path)
|
|
92
|
+
patch_command = (
|
|
93
|
+
"*** Begin Patch\n"
|
|
94
|
+
"*** Add File: orders.py\n"
|
|
95
|
+
"+def fulfill(): pass\n"
|
|
96
|
+
"*** End Patch"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
completed = _run_hook_with_payload(
|
|
100
|
+
_make_apply_patch_payload(sandbox, patch_command),
|
|
101
|
+
extra_env={"CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT": "1"},
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
assert _decision_from(completed) == "deny"
|
|
105
|
+
parsed = json.loads(completed.stdout)
|
|
106
|
+
reason = parsed["hookSpecificOutput"]["permissionDecisionReason"]
|
|
107
|
+
assert "test_orders.py" in reason
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_should_allow_apply_patch_update_with_fresh_sibling_test(tmp_path: Path) -> None:
|
|
111
|
+
sandbox = _sandbox(tmp_path)
|
|
112
|
+
production_file = sandbox / "orders.py"
|
|
113
|
+
production_file.write_text("def fulfill(): pass\n")
|
|
114
|
+
sibling_test = sandbox / "test_orders.py"
|
|
115
|
+
sibling_test.write_text("def test_fulfill(): pass\n")
|
|
116
|
+
patch_command = (
|
|
117
|
+
"*** Begin Patch\n"
|
|
118
|
+
"*** Update File: orders.py\n"
|
|
119
|
+
"@@\n"
|
|
120
|
+
"-def fulfill(): pass\n"
|
|
121
|
+
"+def fulfill(): return True\n"
|
|
122
|
+
"*** End Patch"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
completed = _run_hook_with_payload(
|
|
126
|
+
_make_apply_patch_payload(sandbox, patch_command),
|
|
127
|
+
extra_env={"CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT": "1"},
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
assert _decision_from(completed) == "allow"
|
|
131
|
+
|
|
132
|
+
|
|
79
133
|
def test_should_deny_when_no_test_file_exists(tmp_path: Path) -> None:
|
|
80
134
|
sandbox = _sandbox(tmp_path)
|
|
81
135
|
production_file = sandbox / "orders.py"
|
|
@@ -834,8 +888,9 @@ def _run_hook_with_payload_and_env(
|
|
|
834
888
|
_BEHAVIOR_BEARING_CONTENT = "def fulfill_order(order: str) -> str:\n return order\n"
|
|
835
889
|
|
|
836
890
|
|
|
837
|
-
def test_should_exit_zero_for_ephemeral_scratch_python() -> None:
|
|
891
|
+
def test_should_exit_zero_for_ephemeral_scratch_python(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
838
892
|
"""B16: behavior-bearing scratch .py under root-anchored /tmp exits 0 with no deny."""
|
|
893
|
+
monkeypatch.delenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", raising=False)
|
|
839
894
|
ephemeral_path = "/tmp/scratch_work.py"
|
|
840
895
|
payload = _make_write_payload(Path(ephemeral_path), _BEHAVIOR_BEARING_CONTENT)
|
|
841
896
|
completed = _run_hook_with_payload(payload)
|
|
@@ -844,8 +899,9 @@ def test_should_exit_zero_for_ephemeral_scratch_python() -> None:
|
|
|
844
899
|
)
|
|
845
900
|
|
|
846
901
|
|
|
847
|
-
def test_should_exit_zero_for_ephemeral_scratch_typescript() -> None:
|
|
902
|
+
def test_should_exit_zero_for_ephemeral_scratch_typescript(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
848
903
|
"""B17: ephemeral .ts scratch file under root-anchored /tmp exits 0 with no deny."""
|
|
904
|
+
monkeypatch.delenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", raising=False)
|
|
849
905
|
ephemeral_path = "/tmp/scratch_work.ts"
|
|
850
906
|
payload = {
|
|
851
907
|
"tool_name": "Write",
|
|
@@ -1194,3 +1250,127 @@ def test_should_deny_changing_a_future_import_on_a_constants_only_file(tmp_path:
|
|
|
1194
1250
|
)
|
|
1195
1251
|
|
|
1196
1252
|
assert _decision_from(completed) == "deny"
|
|
1253
|
+
|
|
1254
|
+
|
|
1255
|
+
def _make_write_payload_for_session(
|
|
1256
|
+
file_path: Path, content: str, session_id: str, repository_root: Path
|
|
1257
|
+
) -> dict:
|
|
1258
|
+
return {
|
|
1259
|
+
"session_id": session_id,
|
|
1260
|
+
"cwd": str(repository_root),
|
|
1261
|
+
"tool_name": "Write",
|
|
1262
|
+
"tool_input": {"file_path": str(file_path), "content": content},
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
|
|
1266
|
+
def _seed_stale_recorded_hash(
|
|
1267
|
+
candidate: Path, session_id: str, repository_root: Path, seconds_past_window: int
|
|
1268
|
+
) -> None:
|
|
1269
|
+
"""Plant a session record as if the gate had genuinely seen *candidate* long ago."""
|
|
1270
|
+
state_file = content_hash_store._state_file_path(session_id, str(repository_root))
|
|
1271
|
+
candidate_key = content_hash_store._state_key_for(candidate)
|
|
1272
|
+
recorded_hash = content_hash_store._content_hash(candidate.read_text())
|
|
1273
|
+
stale_changed_at = time.time() - FRESHNESS_SECONDS - seconds_past_window
|
|
1274
|
+
state = {
|
|
1275
|
+
candidate_key: {
|
|
1276
|
+
content_hash_store.STORED_CONTENT_HASH_KEY: recorded_hash,
|
|
1277
|
+
content_hash_store.STORED_CHANGED_AT_KEY: stale_changed_at,
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
content_hash_store._atomic_write_state(state_file, state)
|
|
1281
|
+
|
|
1282
|
+
|
|
1283
|
+
def test_should_deny_when_test_file_was_recorded_then_only_touched_after_window(
|
|
1284
|
+
tmp_path: Path,
|
|
1285
|
+
) -> None:
|
|
1286
|
+
"""The exact loophole: a session-recorded test, later only touched, stays denied."""
|
|
1287
|
+
sandbox = _sandbox(tmp_path)
|
|
1288
|
+
production_file = sandbox / "orders.py"
|
|
1289
|
+
production_file.write_text("def fulfill(): pass\n")
|
|
1290
|
+
sibling_test = sandbox / "test_orders.py"
|
|
1291
|
+
sibling_test.write_text("def test_fulfill(): pass\n")
|
|
1292
|
+
session_id = "touch-loophole-session"
|
|
1293
|
+
_seed_stale_recorded_hash(sibling_test, session_id, sandbox, seconds_past_window=60)
|
|
1294
|
+
|
|
1295
|
+
sibling_test.touch()
|
|
1296
|
+
|
|
1297
|
+
completed = _run_hook_with_payload(
|
|
1298
|
+
_make_write_payload_for_session(
|
|
1299
|
+
production_file, "def fulfill(): return 1\n", session_id, sandbox
|
|
1300
|
+
)
|
|
1301
|
+
)
|
|
1302
|
+
|
|
1303
|
+
assert _decision_from(completed) == "deny"
|
|
1304
|
+
|
|
1305
|
+
|
|
1306
|
+
def test_should_allow_when_test_file_content_actually_changed_after_window(
|
|
1307
|
+
tmp_path: Path,
|
|
1308
|
+
) -> None:
|
|
1309
|
+
sandbox = _sandbox(tmp_path)
|
|
1310
|
+
production_file = sandbox / "orders.py"
|
|
1311
|
+
production_file.write_text("def fulfill(): pass\n")
|
|
1312
|
+
sibling_test = sandbox / "test_orders.py"
|
|
1313
|
+
sibling_test.write_text("def test_fulfill(): pass\n")
|
|
1314
|
+
session_id = "touch-loophole-real-edit-session"
|
|
1315
|
+
_seed_stale_recorded_hash(sibling_test, session_id, sandbox, seconds_past_window=60)
|
|
1316
|
+
|
|
1317
|
+
sibling_test.write_text("def test_fulfill(): assert True\n")
|
|
1318
|
+
|
|
1319
|
+
completed = _run_hook_with_payload(
|
|
1320
|
+
_make_write_payload_for_session(
|
|
1321
|
+
production_file, "def fulfill(): return 1\n", session_id, sandbox
|
|
1322
|
+
)
|
|
1323
|
+
)
|
|
1324
|
+
|
|
1325
|
+
assert _decision_from(completed) == "allow"
|
|
1326
|
+
|
|
1327
|
+
|
|
1328
|
+
def test_should_allow_repeat_production_write_within_window_without_further_test_change(
|
|
1329
|
+
tmp_path: Path,
|
|
1330
|
+
) -> None:
|
|
1331
|
+
"""GREEN/REFACTOR needs several production writes after one real test edit."""
|
|
1332
|
+
sandbox = _sandbox(tmp_path)
|
|
1333
|
+
production_file = sandbox / "orders.py"
|
|
1334
|
+
production_file.write_text("def fulfill(): pass\n")
|
|
1335
|
+
sibling_test = sandbox / "test_orders.py"
|
|
1336
|
+
sibling_test.write_text("def test_fulfill(): pass\n")
|
|
1337
|
+
session_id = "green-refactor-session"
|
|
1338
|
+
|
|
1339
|
+
first_write = _run_hook_with_payload(
|
|
1340
|
+
_make_write_payload_for_session(
|
|
1341
|
+
production_file, "def fulfill(): return 1\n", session_id, sandbox
|
|
1342
|
+
)
|
|
1343
|
+
)
|
|
1344
|
+
assert _decision_from(first_write) == "allow"
|
|
1345
|
+
|
|
1346
|
+
second_write = _run_hook_with_payload(
|
|
1347
|
+
_make_write_payload_for_session(
|
|
1348
|
+
production_file, "def fulfill(): return 2\n", session_id, sandbox
|
|
1349
|
+
)
|
|
1350
|
+
)
|
|
1351
|
+
|
|
1352
|
+
assert _decision_from(second_write) == "allow"
|
|
1353
|
+
|
|
1354
|
+
|
|
1355
|
+
def test_should_deny_apply_patch_that_adds_test_and_production_file_in_one_transaction(
|
|
1356
|
+
tmp_path: Path,
|
|
1357
|
+
) -> None:
|
|
1358
|
+
"""One patch adding both files denies: the test is not yet on disk when read.
|
|
1359
|
+
|
|
1360
|
+
``apply_patch`` is a PreToolUse hook; it sees the patch text before the
|
|
1361
|
+
patch runs, so a same-transaction test file is not readable yet even
|
|
1362
|
+
though it names a real, matching test.
|
|
1363
|
+
"""
|
|
1364
|
+
sandbox = _sandbox(tmp_path)
|
|
1365
|
+
patch_command = (
|
|
1366
|
+
"*** Begin Patch\n"
|
|
1367
|
+
"*** Add File: orders.py\n"
|
|
1368
|
+
"+def fulfill(): pass\n"
|
|
1369
|
+
"*** Add File: test_orders.py\n"
|
|
1370
|
+
"+def test_fulfill(): pass\n"
|
|
1371
|
+
"*** End Patch"
|
|
1372
|
+
)
|
|
1373
|
+
|
|
1374
|
+
completed = _run_hook_with_payload(_make_apply_patch_payload(sandbox, patch_command))
|
|
1375
|
+
|
|
1376
|
+
assert _decision_from(completed) == "deny"
|
|
@@ -165,6 +165,31 @@ def test_extract_payload_returns_content_for_write_without_file_path() -> None:
|
|
|
165
165
|
assert extracted == "some python code"
|
|
166
166
|
|
|
167
167
|
|
|
168
|
+
def test_extract_payload_reads_every_multi_edit_new_string() -> None:
|
|
169
|
+
extracted = extract_payload_text(
|
|
170
|
+
"MultiEdit",
|
|
171
|
+
{
|
|
172
|
+
"file_path": "foo.py",
|
|
173
|
+
"edits": [
|
|
174
|
+
{"old_string": "a = 1", "new_string": "a = 11"},
|
|
175
|
+
{"old_string": "b = 2", "new_string": DANGEROUS_RMTREE_SNIPPET},
|
|
176
|
+
],
|
|
177
|
+
},
|
|
178
|
+
)
|
|
179
|
+
assert DANGEROUS_RMTREE_SNIPPET in extracted
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_extract_payload_reads_a_single_item_multi_edit() -> None:
|
|
183
|
+
extracted = extract_payload_text(
|
|
184
|
+
"MultiEdit",
|
|
185
|
+
{
|
|
186
|
+
"file_path": "foo.py",
|
|
187
|
+
"edits": [{"old_string": "a = 1", "new_string": DANGEROUS_RMTREE_SNIPPET}],
|
|
188
|
+
},
|
|
189
|
+
)
|
|
190
|
+
assert extracted == DANGEROUS_RMTREE_SNIPPET
|
|
191
|
+
|
|
192
|
+
|
|
168
193
|
def _run_hook_with_stdin_text(stdin_text: str) -> tuple[str, str, int]:
|
|
169
194
|
captured_stdout = io.StringIO()
|
|
170
195
|
captured_stderr = io.StringIO()
|
|
@@ -207,6 +232,44 @@ def test_main_blocks_unsafe_bash_command() -> None:
|
|
|
207
232
|
assert "windows-rmtree" in decision_block["permissionDecisionReason"]
|
|
208
233
|
|
|
209
234
|
|
|
235
|
+
def test_main_blocks_unsafe_rmtree_introduced_by_second_multi_edit() -> None:
|
|
236
|
+
"""An unsafe rmtree call in the second edit of a MultiEdit is caught, not only the first."""
|
|
237
|
+
stdout_text, exit_code = _run_hook(
|
|
238
|
+
{
|
|
239
|
+
"tool_name": "MultiEdit",
|
|
240
|
+
"tool_input": {
|
|
241
|
+
"file_path": "cleanup.py",
|
|
242
|
+
"edits": [
|
|
243
|
+
{"old_string": "a = 1", "new_string": "a = 11"},
|
|
244
|
+
{"old_string": "b = 2", "new_string": DANGEROUS_RMTREE_SNIPPET},
|
|
245
|
+
],
|
|
246
|
+
},
|
|
247
|
+
}
|
|
248
|
+
)
|
|
249
|
+
assert exit_code == 0
|
|
250
|
+
response_payload = json.loads(stdout_text)
|
|
251
|
+
decision_block = response_payload["hookSpecificOutput"]
|
|
252
|
+
assert decision_block["permissionDecision"] == "deny"
|
|
253
|
+
assert "windows-rmtree" in decision_block["permissionDecisionReason"]
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def test_main_allows_clean_multi_edit() -> None:
|
|
257
|
+
stdout_text, exit_code = _run_hook(
|
|
258
|
+
{
|
|
259
|
+
"tool_name": "MultiEdit",
|
|
260
|
+
"tool_input": {
|
|
261
|
+
"file_path": "cleanup.py",
|
|
262
|
+
"edits": [
|
|
263
|
+
{"old_string": "a = 1", "new_string": "a = 11"},
|
|
264
|
+
{"old_string": "b = 2", "new_string": "shutil.rmtree(path, onexc=handler)"},
|
|
265
|
+
],
|
|
266
|
+
},
|
|
267
|
+
}
|
|
268
|
+
)
|
|
269
|
+
assert exit_code == 0
|
|
270
|
+
assert stdout_text == ""
|
|
271
|
+
|
|
272
|
+
|
|
210
273
|
def test_main_passes_through_safe_write() -> None:
|
|
211
274
|
stdout_text, exit_code = _run_hook(
|
|
212
275
|
{
|