claude-dev-env 2.30.1 → 2.31.1
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.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 +12 -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_duplicate_body.py +272 -100
- package/hooks/blocking/code_rules_enforcer.py +89 -45
- package/hooks/blocking/code_rules_shared.py +64 -14
- 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 +256 -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 +25 -47
- 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 +63 -16
- 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 +1 -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_ephemeral.py +12 -1
- package/hooks/blocking/test_code_rules_enforcer_ephemeral_repository_root.py +118 -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_same_file_inline_duplicate.py +244 -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 +24 -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_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 +234 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +215 -25
- package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +18 -6
- package/hooks/blocking/test_sensitive_file_protector.py +42 -2
- package/hooks/blocking/test_stale_comment_reference_blocker.py +71 -0
- package/hooks/blocking/test_state_description_blocker.py +163 -49
- package/hooks/blocking/test_subprocess_budget_completeness.py +67 -0
- package/hooks/blocking/test_tdd_enforcer.py +246 -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 +7 -12
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +3 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +4 -0
- 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 +73 -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/state_description_blocker_constants.py +1 -0
- package/hooks/hooks_constants/subprocess_budget_completeness_content.py +71 -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 -0
- package/hooks/hooks_constants/test_multi_edit_reconstruction.py +103 -0
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +139 -25
- package/hooks/hooks_constants/test_subprocess_budget_completeness_content.py +103 -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 +113 -0
- package/hooks/observability/session_file_edit_tracker.py +25 -4
- package/hooks/observability/test_pr_description_writer_spawn_tracker.py +150 -0
- package/hooks/observability/test_session_file_edit_tracker.py +32 -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 +6 -8
- package/hooks/validators/code_quality_checks.py +4 -4
- package/hooks/validators/config/abbreviation_checks_constants.py +3 -0
- package/hooks/validators/config/baseline_identity_constants.py +12 -0
- package/hooks/validators/config/directory_exemption_constants.py +44 -1
- 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/config/validator_base_constants.py +9 -0
- package/hooks/validators/fast_save_validators.py +275 -0
- package/hooks/validators/hook_timing_harness.py +8 -4
- package/hooks/validators/pr_reference_checks.py +2 -2
- package/hooks/validators/python_antipattern_checks.py +3 -3
- package/hooks/validators/python_style_checks.py +37 -62
- package/hooks/validators/python_style_helpers.py +16 -2
- package/hooks/validators/python_style_import_bootstrap.py +83 -0
- package/hooks/validators/react_checks.py +2 -9
- package/hooks/validators/run_all_validators.py +201 -155
- package/hooks/validators/security_checks.py +3 -3
- package/hooks/validators/test_abbreviation_checks.py +20 -0
- package/hooks/validators/test_directory_exemption_constants.py +30 -24
- package/hooks/validators/test_fast_save_validators.py +172 -0
- package/hooks/validators/test_hook_timing_harness.py +10 -1
- 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 +163 -0
- package/hooks/validators/test_run_all_validators_repository_root.py +202 -0
- package/hooks/validators/test_validator_base.py +51 -2
- package/hooks/validators/todo_checks.py +2 -2
- package/hooks/validators/type_safety_checks.py +3 -3
- package/hooks/validators/useless_test_checks.py +3 -3
- package/hooks/validators/validator_base.py +73 -1
- 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/dev_env_scripts_constants/grok_worker_constants.py +15 -0
- package/scripts/grok_headless_runner.py +15 -12
- package/scripts/shared_tree_paths.py +70 -0
- package/scripts/test_shared_tree_paths.py +143 -0
- package/scripts/tests/test_codex_compat_materializer.py +37 -11
- package/hooks/blocking/tests/conftest.py +0 -10
|
@@ -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,191 @@ 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"
|
|
1377
|
+
|
|
1378
|
+
|
|
1379
|
+
def test_should_stay_exempt_for_scratch_outside_repository_root(
|
|
1380
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
1381
|
+
) -> None:
|
|
1382
|
+
"""Repo-root fix: a genuine scratch file outside the repository stays exempt."""
|
|
1383
|
+
monkeypatch.delenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", raising=False)
|
|
1384
|
+
repository_root = "/tmp/pr-alpha"
|
|
1385
|
+
payload = {
|
|
1386
|
+
"tool_name": "Write",
|
|
1387
|
+
"tool_input": {
|
|
1388
|
+
"file_path": "/tmp/scratch/one_off.py",
|
|
1389
|
+
"content": _BEHAVIOR_BEARING_CONTENT,
|
|
1390
|
+
},
|
|
1391
|
+
"cwd": repository_root,
|
|
1392
|
+
}
|
|
1393
|
+
completed = _run_hook_with_payload(payload)
|
|
1394
|
+
assert _decision_from(completed) != "deny", (
|
|
1395
|
+
f"TDD enforcer must not deny scratch outside the repository, got: {completed.stdout!r}"
|
|
1396
|
+
)
|
|
1397
|
+
|
|
1398
|
+
|
|
1399
|
+
def test_should_stay_exempt_for_unrelated_worktree_sibling_under_tmp(
|
|
1400
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
1401
|
+
) -> None:
|
|
1402
|
+
"""Repo-root fix: a sibling sharing the root's name prefix is not inside it.
|
|
1403
|
+
|
|
1404
|
+
Containment is judged by path segment, so /tmp/pr-alpha-sibling is outside
|
|
1405
|
+
/tmp/pr-alpha however much of the name the two share.
|
|
1406
|
+
"""
|
|
1407
|
+
monkeypatch.delenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", raising=False)
|
|
1408
|
+
repository_root = "/tmp/pr-alpha"
|
|
1409
|
+
payload = {
|
|
1410
|
+
"tool_name": "Write",
|
|
1411
|
+
"tool_input": {
|
|
1412
|
+
"file_path": "/tmp/pr-alpha-sibling/hooks/orders.py",
|
|
1413
|
+
"content": _BEHAVIOR_BEARING_CONTENT,
|
|
1414
|
+
},
|
|
1415
|
+
"cwd": repository_root,
|
|
1416
|
+
}
|
|
1417
|
+
completed = _run_hook_with_payload(payload)
|
|
1418
|
+
assert _decision_from(completed) != "deny", (
|
|
1419
|
+
f"TDD enforcer must not deny an unrelated sibling worktree, got: {completed.stdout!r}"
|
|
1420
|
+
)
|
|
1421
|
+
|
|
1422
|
+
|
|
1423
|
+
def test_should_deny_production_file_inside_repository_root_under_tmp(
|
|
1424
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
1425
|
+
) -> None:
|
|
1426
|
+
"""Repo-root fix: a production file inside a repository under /tmp still needs a test."""
|
|
1427
|
+
monkeypatch.delenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", raising=False)
|
|
1428
|
+
repository_root = "/tmp/pr-alpha"
|
|
1429
|
+
payload = {
|
|
1430
|
+
"tool_name": "Write",
|
|
1431
|
+
"tool_input": {
|
|
1432
|
+
"file_path": f"{repository_root}/hooks/blocking/orders.py",
|
|
1433
|
+
"content": _BEHAVIOR_BEARING_CONTENT,
|
|
1434
|
+
},
|
|
1435
|
+
"cwd": repository_root,
|
|
1436
|
+
}
|
|
1437
|
+
completed = _run_hook_with_payload(payload)
|
|
1438
|
+
assert _decision_from(completed) == "deny", (
|
|
1439
|
+
f"TDD enforcer must gate a repository file under /tmp, got: {completed.stdout!r}"
|
|
1440
|
+
)
|
|
@@ -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
|
{
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Tests for the write_existing_file_blocker hook.
|
|
2
|
+
|
|
3
|
+
Each case drives the real hook's ``main()`` through its production stdin path,
|
|
4
|
+
feeding it the PreToolUse JSON payload Claude Code sends and reading the
|
|
5
|
+
permission decision back off stdout.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import io
|
|
12
|
+
import json
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
|
|
18
|
+
import write_existing_file_blocker
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _run_payload(
|
|
22
|
+
payload: Mapping[str, object],
|
|
23
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
24
|
+
capsys: pytest.CaptureFixture[str],
|
|
25
|
+
) -> str:
|
|
26
|
+
"""Run the real hook entry point and return its stdout."""
|
|
27
|
+
monkeypatch.setattr(write_existing_file_blocker.sys, "stdin", io.StringIO(json.dumps(payload)))
|
|
28
|
+
with contextlib.suppress(SystemExit):
|
|
29
|
+
write_existing_file_blocker.main()
|
|
30
|
+
return capsys.readouterr().out
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _is_deny(stdout_text: str) -> bool:
|
|
34
|
+
if not stdout_text.strip():
|
|
35
|
+
return False
|
|
36
|
+
decision = json.loads(stdout_text)
|
|
37
|
+
return decision["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_write_denies_an_existing_target(
|
|
41
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
42
|
+
) -> None:
|
|
43
|
+
"""A Write onto a path that already exists is denied."""
|
|
44
|
+
existing_path = tmp_path / "already_here.py"
|
|
45
|
+
existing_path.write_text("x = 1\n", encoding="utf-8")
|
|
46
|
+
payload = {
|
|
47
|
+
"tool_name": "Write",
|
|
48
|
+
"tool_input": {"file_path": str(existing_path), "content": "x = 2\n"},
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
stdout = _run_payload(payload, monkeypatch, capsys)
|
|
52
|
+
|
|
53
|
+
assert _is_deny(stdout), f"expected a deny, got {stdout!r}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_apply_patch_add_denies_an_existing_target(
|
|
57
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
58
|
+
) -> None:
|
|
59
|
+
"""An apply_patch 'Add File' section targeting an existing path is denied."""
|
|
60
|
+
existing_path = tmp_path / "already_here.py"
|
|
61
|
+
existing_path.write_text("x = 1\n", encoding="utf-8")
|
|
62
|
+
payload = {
|
|
63
|
+
"tool_name": "apply_patch",
|
|
64
|
+
"cwd": str(tmp_path),
|
|
65
|
+
"tool_input": {
|
|
66
|
+
"command": ("*** Begin Patch\n*** Add File: already_here.py\n+x = 2\n*** End Patch")
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
stdout = _run_payload(payload, monkeypatch, capsys)
|
|
71
|
+
|
|
72
|
+
assert _is_deny(stdout), f"expected a deny, got {stdout!r}"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_apply_patch_update_on_an_existing_target_is_allowed(
|
|
76
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
77
|
+
) -> None:
|
|
78
|
+
"""An 'Update File' section on an existing path is not an unread overwrite."""
|
|
79
|
+
existing_path = tmp_path / "already_here.py"
|
|
80
|
+
existing_path.write_text("x = 1\n", encoding="utf-8")
|
|
81
|
+
payload = {
|
|
82
|
+
"tool_name": "apply_patch",
|
|
83
|
+
"cwd": str(tmp_path),
|
|
84
|
+
"tool_input": {
|
|
85
|
+
"command": (
|
|
86
|
+
"*** Begin Patch\n"
|
|
87
|
+
"*** Update File: already_here.py\n"
|
|
88
|
+
"@@\n"
|
|
89
|
+
"-x = 1\n"
|
|
90
|
+
"+x = 2\n"
|
|
91
|
+
"*** End Patch"
|
|
92
|
+
)
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
stdout = _run_payload(payload, monkeypatch, capsys)
|
|
97
|
+
|
|
98
|
+
assert not _is_deny(stdout), f"expected an allow, got {stdout!r}"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_multi_edit_on_an_existing_target_is_allowed(
|
|
102
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
103
|
+
) -> None:
|
|
104
|
+
"""A MultiEdit always targets a file already on disk, so it is never denied here.
|
|
105
|
+
|
|
106
|
+
Every MultiEdit edit must match an ``old_string`` already present in the
|
|
107
|
+
file, so MultiEdit carries no blind create-or-clobber path the way a Write
|
|
108
|
+
or an apply_patch 'Add File' does — this hook allows it unconditionally.
|
|
109
|
+
"""
|
|
110
|
+
existing_path = tmp_path / "already_here.py"
|
|
111
|
+
existing_path.write_text("x = 1\n", encoding="utf-8")
|
|
112
|
+
payload = {
|
|
113
|
+
"tool_name": "MultiEdit",
|
|
114
|
+
"tool_input": {
|
|
115
|
+
"file_path": str(existing_path),
|
|
116
|
+
"edits": [{"old_string": "x = 1", "new_string": "x = 2"}],
|
|
117
|
+
},
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
stdout = _run_payload(payload, monkeypatch, capsys)
|
|
121
|
+
|
|
122
|
+
assert not _is_deny(stdout), f"expected an allow, got {stdout!r}"
|
|
@@ -17,13 +17,22 @@ import re
|
|
|
17
17
|
import sys
|
|
18
18
|
from pathlib import Path
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
sys.path
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
from hooks_constants.
|
|
26
|
-
from hooks_constants.
|
|
20
|
+
try:
|
|
21
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
22
|
+
if _hooks_dir not in sys.path:
|
|
23
|
+
sys.path.insert(0, _hooks_dir)
|
|
24
|
+
|
|
25
|
+
from hooks_constants.hook_block_logger import log_hook_block
|
|
26
|
+
from hooks_constants.multi_edit_reconstruction import joined_new_strings
|
|
27
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
28
|
+
from hooks_constants.windows_rmtree_blocker_constants import (
|
|
29
|
+
PYTHON_FILE_EXTENSION,
|
|
30
|
+
)
|
|
31
|
+
except ImportError as import_error:
|
|
32
|
+
raise ImportError(
|
|
33
|
+
"windows_rmtree_blocker: cannot import its sibling modules; "
|
|
34
|
+
"ensure the hooks directory is importable."
|
|
35
|
+
) from import_error
|
|
27
36
|
|
|
28
37
|
|
|
29
38
|
def payload_contains_unsafe_rmtree(payload_text: str) -> bool:
|
|
@@ -37,6 +46,11 @@ def payload_contains_unsafe_rmtree(payload_text: str) -> bool:
|
|
|
37
46
|
|
|
38
47
|
|
|
39
48
|
def extract_payload_text(tool_name: str, tool_input: dict) -> str:
|
|
49
|
+
if tool_name == "MultiEdit":
|
|
50
|
+
file_path = tool_input.get("file_path", "")
|
|
51
|
+
if file_path and not file_path.endswith(PYTHON_FILE_EXTENSION):
|
|
52
|
+
return ""
|
|
53
|
+
return joined_new_strings(tool_input)
|
|
40
54
|
if tool_name in {"Write", "Edit"}:
|
|
41
55
|
file_path = tool_input.get("file_path", "")
|
|
42
56
|
if file_path and not file_path.endswith(PYTHON_FILE_EXTENSION):
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""PreToolUse
|
|
2
|
+
"""PreToolUse hook — blocks Write and apply_patch "Add File" onto an existing target.
|
|
3
3
|
|
|
4
|
-
Agents should use Edit for modifying existing files
|
|
4
|
+
Agents should use Edit for modifying existing files, and an apply_patch
|
|
5
|
+
"Update File" section rather than "Add File" for a path that already exists.
|
|
5
6
|
Exemptions: Jupyter notebooks (.ipynb) and files in ~/.claude/hooks/ (standalone scripts).
|
|
7
|
+
|
|
8
|
+
MultiEdit carries no create-or-clobber path this hook needs to guard: every
|
|
9
|
+
MultiEdit edit must match an ``old_string`` already present in the target file,
|
|
10
|
+
so a MultiEdit can never introduce content nobody read the way a blind Write or
|
|
11
|
+
an apply_patch "Add File" can. This hook allows MultiEdit unconditionally.
|
|
6
12
|
"""
|
|
7
13
|
|
|
8
14
|
import json
|
|
@@ -14,7 +20,22 @@ _hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
|
14
20
|
if _hooks_dir not in sys.path:
|
|
15
21
|
sys.path.insert(0, _hooks_dir)
|
|
16
22
|
|
|
17
|
-
|
|
23
|
+
try:
|
|
24
|
+
from codex_apply_patch import (
|
|
25
|
+
CODEX_ADD_OPERATION,
|
|
26
|
+
CodexPatchError,
|
|
27
|
+
codex_patch_operation_targets,
|
|
28
|
+
payload_patch_command,
|
|
29
|
+
)
|
|
30
|
+
from hooks_constants.hook_block_logger import log_hook_block
|
|
31
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
32
|
+
from hooks_constants.pre_tool_use_dispatcher_constants import APPLY_PATCH_TOOL_NAME
|
|
33
|
+
from sensitive_file_protector import build_deny_response
|
|
34
|
+
except ImportError as import_error:
|
|
35
|
+
raise ImportError(
|
|
36
|
+
"write_existing_file_blocker: cannot import its sibling modules; "
|
|
37
|
+
"ensure the hooks directory is importable."
|
|
38
|
+
) from import_error
|
|
18
39
|
|
|
19
40
|
JUPYTER_EXTENSION = ".ipynb"
|
|
20
41
|
HOOKS_DIRECTORY = os.path.normpath(os.path.expanduser("~/.claude/hooks"))
|
|
@@ -29,48 +50,73 @@ def is_inside_hooks_directory(file_path: str) -> bool:
|
|
|
29
50
|
return normalized_path.startswith(HOOKS_DIRECTORY)
|
|
30
51
|
|
|
31
52
|
|
|
32
|
-
def
|
|
53
|
+
def _apply_patch_add_deny(payload: dict, tool_input: dict) -> tuple[str, str] | None:
|
|
54
|
+
"""Return (deny_reason, offending_path) for an apply_patch 'Add File' onto an existing path."""
|
|
55
|
+
command, working_directory = payload_patch_command(payload, tool_input)
|
|
56
|
+
if not command:
|
|
57
|
+
return None
|
|
33
58
|
try:
|
|
34
|
-
|
|
35
|
-
except
|
|
36
|
-
|
|
59
|
+
all_operation_targets = codex_patch_operation_targets(command, working_directory)
|
|
60
|
+
except CodexPatchError:
|
|
61
|
+
return None
|
|
62
|
+
for each_operation, each_target_path in all_operation_targets:
|
|
63
|
+
if each_operation != CODEX_ADD_OPERATION:
|
|
64
|
+
continue
|
|
65
|
+
if os.path.exists(each_target_path):
|
|
66
|
+
deny_reason = (
|
|
67
|
+
f"BLOCKED: apply_patch 'Add File' on existing file {each_target_path}. "
|
|
68
|
+
"Use an 'Update File' section instead."
|
|
69
|
+
)
|
|
70
|
+
return deny_reason, each_target_path
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _write_onto_existing_file_deny(tool_input: dict) -> tuple[str, str] | None:
|
|
75
|
+
"""Return (deny_reason, offending_path) for a Write onto an existing target."""
|
|
76
|
+
target_file_path = tool_input.get("file_path", "")
|
|
77
|
+
if not target_file_path:
|
|
78
|
+
return None
|
|
79
|
+
if is_jupyter_notebook(target_file_path):
|
|
80
|
+
return None
|
|
81
|
+
if is_inside_hooks_directory(target_file_path):
|
|
82
|
+
return None
|
|
83
|
+
if not os.path.exists(target_file_path):
|
|
84
|
+
return None
|
|
85
|
+
deny_reason = f"BLOCKED: Write on existing file {target_file_path}. Use Edit tool instead."
|
|
86
|
+
return deny_reason, target_file_path
|
|
37
87
|
|
|
38
|
-
tool_name = input_payload.get("tool_name", "")
|
|
39
|
-
tool_input = input_payload.get("tool_input", {})
|
|
40
88
|
|
|
89
|
+
def _resolve_deny(tool_name: str, payload: dict, tool_input: dict) -> tuple[str, str] | None:
|
|
90
|
+
"""Return (deny_reason, offending_path) for the payload, or None to allow."""
|
|
91
|
+
if tool_name == APPLY_PATCH_TOOL_NAME:
|
|
92
|
+
return _apply_patch_add_deny(payload, tool_input)
|
|
41
93
|
if tool_name != "Write":
|
|
42
|
-
|
|
94
|
+
return None
|
|
95
|
+
return _write_onto_existing_file_deny(tool_input)
|
|
43
96
|
|
|
44
|
-
target_file_path = tool_input.get("file_path", "")
|
|
45
97
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if is_jupyter_notebook(target_file_path):
|
|
98
|
+
def main() -> None:
|
|
99
|
+
input_payload = read_hook_input_dictionary_from_stdin()
|
|
100
|
+
if input_payload is None:
|
|
50
101
|
sys.exit(0)
|
|
51
102
|
|
|
52
|
-
|
|
53
|
-
|
|
103
|
+
raw_tool_name = input_payload.get("tool_name", "")
|
|
104
|
+
raw_tool_input = input_payload.get("tool_input", {})
|
|
105
|
+
tool_name = raw_tool_name if isinstance(raw_tool_name, str) else ""
|
|
106
|
+
tool_input = raw_tool_input if isinstance(raw_tool_input, dict) else {}
|
|
54
107
|
|
|
55
|
-
|
|
108
|
+
resolved_deny = _resolve_deny(tool_name, input_payload, tool_input)
|
|
109
|
+
if resolved_deny is None:
|
|
56
110
|
sys.exit(0)
|
|
111
|
+
deny_reason, offending_path = resolved_deny
|
|
57
112
|
|
|
58
|
-
deny_reason = f"BLOCKED: Write on existing file {target_file_path}. Use Edit tool instead."
|
|
59
|
-
denial = {
|
|
60
|
-
"hookSpecificOutput": {
|
|
61
|
-
"hookEventName": "PreToolUse",
|
|
62
|
-
"permissionDecision": "deny",
|
|
63
|
-
"permissionDecisionReason": deny_reason,
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
113
|
log_hook_block(
|
|
67
114
|
calling_hook_name="write_existing_file_blocker.py",
|
|
68
115
|
hook_event="PreToolUse",
|
|
69
116
|
block_reason=deny_reason,
|
|
70
|
-
|
|
71
|
-
offending_input_preview=target_file_path,
|
|
117
|
+
offending_input_preview=offending_path,
|
|
72
118
|
)
|
|
73
|
-
print(json.dumps(
|
|
119
|
+
print(json.dumps(build_deny_response(deny_reason)))
|
|
74
120
|
sys.exit(0)
|
|
75
121
|
|
|
76
122
|
|