claude-dev-env 2.30.1 → 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.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 +57 -31
- 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 +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 +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 +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_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_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_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 +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 +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/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 +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/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
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Unit tests for the pr-description-writer PreToolUse gate on gh pr create."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from unittest import mock
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
_BLOCKING_DIRECTORY = Path(__file__).resolve().parent
|
|
15
|
+
_HOOKS_DIRECTORY = _BLOCKING_DIRECTORY.parent
|
|
16
|
+
for each_directory in (_BLOCKING_DIRECTORY, _HOOKS_DIRECTORY):
|
|
17
|
+
if str(each_directory) not in sys.path:
|
|
18
|
+
sys.path.insert(0, str(each_directory))
|
|
19
|
+
|
|
20
|
+
_GATE_SPEC = importlib.util.spec_from_file_location(
|
|
21
|
+
"pr_description_writer_gate",
|
|
22
|
+
_BLOCKING_DIRECTORY / "pr_description_writer_gate.py",
|
|
23
|
+
)
|
|
24
|
+
assert _GATE_SPEC is not None
|
|
25
|
+
assert _GATE_SPEC.loader is not None
|
|
26
|
+
gate_module = importlib.util.module_from_spec(_GATE_SPEC)
|
|
27
|
+
_GATE_SPEC.loader.exec_module(gate_module)
|
|
28
|
+
|
|
29
|
+
_GATE_CONSTANTS = importlib.import_module(
|
|
30
|
+
"hooks_constants.pr_description_writer_gate_constants"
|
|
31
|
+
)
|
|
32
|
+
_PR_DESCRIPTION_WRITER_SUBAGENT_TYPE = _GATE_CONSTANTS.PR_DESCRIPTION_WRITER_SUBAGENT_TYPE
|
|
33
|
+
|
|
34
|
+
_SESSION_ID = "session-abc123"
|
|
35
|
+
_PLAIN_CREATE_COMMAND = 'gh pr create --draft --title "feat: add it" --body-file body.md'
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _payload(command: str, tool_name: str = "Bash") -> dict[str, object]:
|
|
39
|
+
"""Build a PreToolUse payload carrying one shell command.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
command: The shell command the gate inspects.
|
|
43
|
+
tool_name: The tool the command runs through.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
The payload dictionary the gate reads from stdin.
|
|
47
|
+
"""
|
|
48
|
+
return {
|
|
49
|
+
"tool_name": tool_name,
|
|
50
|
+
"session_id": _SESSION_ID,
|
|
51
|
+
"tool_input": {"command": command},
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _printed_output_for(command: str, temp_directory: Path, tool_name: str = "Bash") -> str:
|
|
56
|
+
"""Run the gate against one command and return everything it printed.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
command: The shell command the gate inspects.
|
|
60
|
+
temp_directory: The directory standing in for the system temp root.
|
|
61
|
+
tool_name: The tool the command runs through.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
The gate's stripped stdout — empty on an allow.
|
|
65
|
+
"""
|
|
66
|
+
captured_stdout = io.StringIO()
|
|
67
|
+
payload_text = json.dumps(_payload(command, tool_name))
|
|
68
|
+
with (
|
|
69
|
+
mock.patch("sys.stdin", io.StringIO(payload_text)),
|
|
70
|
+
mock.patch("sys.stdout", captured_stdout),
|
|
71
|
+
mock.patch.object(gate_module.tempfile, "gettempdir", return_value=str(temp_directory)),
|
|
72
|
+
pytest.raises(SystemExit),
|
|
73
|
+
):
|
|
74
|
+
gate_module.main()
|
|
75
|
+
return captured_stdout.getvalue().strip()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _decision_for(command: str, temp_directory: Path, tool_name: str = "Bash") -> str:
|
|
79
|
+
"""Return the gate's permission decision for one command.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
command: The shell command the gate inspects.
|
|
83
|
+
temp_directory: The directory standing in for the system temp root.
|
|
84
|
+
tool_name: The tool the command runs through.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
The ``permissionDecision`` string, or an empty string on an allow.
|
|
88
|
+
"""
|
|
89
|
+
printed_text = _printed_output_for(command, temp_directory, tool_name)
|
|
90
|
+
if not printed_text:
|
|
91
|
+
return ""
|
|
92
|
+
hook_output = json.loads(printed_text).get("hookSpecificOutput", {})
|
|
93
|
+
return str(hook_output.get("permissionDecision", ""))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _record_spawn(temp_directory: Path, session_id: str = _SESSION_ID) -> None:
|
|
97
|
+
"""Write the marker file that stands for a recorded agent spawn.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
temp_directory: The directory standing in for the system temp root.
|
|
101
|
+
"""
|
|
102
|
+
marker_name = (
|
|
103
|
+
f"{gate_module.SPAWN_MARKER_FILE_PREFIX}{session_id}{gate_module.SPAWN_MARKER_FILE_SUFFIX}"
|
|
104
|
+
)
|
|
105
|
+
(temp_directory / marker_name).touch()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def should_deny_pr_create_with_no_recorded_spawn(tmp_path: Path) -> None:
|
|
109
|
+
"""A gh pr create with no recorded spawn is denied."""
|
|
110
|
+
assert _decision_for(_PLAIN_CREATE_COMMAND, tmp_path) == "deny"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def should_allow_pr_create_after_a_recorded_spawn(tmp_path: Path) -> None:
|
|
114
|
+
"""A gh pr create is allowed once this session recorded a spawn."""
|
|
115
|
+
_record_spawn(tmp_path)
|
|
116
|
+
assert _decision_for(_PLAIN_CREATE_COMMAND, tmp_path) == ""
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def should_allow_pr_create_carrying_the_bypass_marker(tmp_path: Path) -> None:
|
|
120
|
+
"""A trailing bypass comment allows the create with no recorded spawn."""
|
|
121
|
+
command = f"{_PLAIN_CREATE_COMMAND} {gate_module.SPAWN_BYPASS_MARKER}"
|
|
122
|
+
assert _decision_for(command, tmp_path) == ""
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def should_allow_multiline_pr_create_carrying_the_bypass_marker(tmp_path: Path) -> None:
|
|
126
|
+
"""A final bypass comment allows a create after a continued shell line."""
|
|
127
|
+
command = f"cd . && \\\n+{_PLAIN_CREATE_COMMAND} {gate_module.SPAWN_BYPASS_MARKER}"
|
|
128
|
+
assert _decision_for(command, tmp_path) == ""
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def should_deny_when_the_bypass_marker_follows_another_shell_segment(tmp_path: Path) -> None:
|
|
132
|
+
"""A final marker for echo cannot opt a preceding create out."""
|
|
133
|
+
command = f"{_PLAIN_CREATE_COMMAND} && echo {gate_module.SPAWN_BYPASS_MARKER}"
|
|
134
|
+
assert _decision_for(command, tmp_path) == "deny"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def should_deny_when_only_another_session_has_a_spawn_marker(tmp_path: Path) -> None:
|
|
138
|
+
"""A marker from another session cannot unlock this session's create."""
|
|
139
|
+
_record_spawn(tmp_path, session_id="earlier-session")
|
|
140
|
+
assert _decision_for(_PLAIN_CREATE_COMMAND, tmp_path) == "deny"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def should_deny_when_the_marker_text_sits_inside_a_quoted_value(tmp_path: Path) -> None:
|
|
144
|
+
"""The bypass text inside a quoted title leaves the gate running."""
|
|
145
|
+
command = 'gh pr create --title "fix # pr-description-skip" --body-file body.md'
|
|
146
|
+
assert _decision_for(command, tmp_path) == "deny"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def should_deny_a_pr_create_that_follows_another_command(tmp_path: Path) -> None:
|
|
150
|
+
"""A create in a later command segment is still gated."""
|
|
151
|
+
command = f"cd /repo && {_PLAIN_CREATE_COMMAND}"
|
|
152
|
+
assert _decision_for(command, tmp_path) == "deny"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@pytest.mark.parametrize("shell_tool_name", ["Bash", "PowerShell"])
|
|
156
|
+
def should_gate_each_shell_tool(shell_tool_name: str, tmp_path: Path) -> None:
|
|
157
|
+
"""Both shell tools reach the gate."""
|
|
158
|
+
decision = _decision_for(_PLAIN_CREATE_COMMAND, tmp_path, tool_name=shell_tool_name)
|
|
159
|
+
assert decision == "deny"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@pytest.mark.parametrize(
|
|
163
|
+
"unrelated_command",
|
|
164
|
+
[
|
|
165
|
+
"gh pr edit 12 --body-file body.md",
|
|
166
|
+
"gh pr comment 12 --body-file body.md",
|
|
167
|
+
"gh pr ready 12",
|
|
168
|
+
"gh issue create --title x --body-file body.md",
|
|
169
|
+
"git commit -m x",
|
|
170
|
+
"echo gh pr create",
|
|
171
|
+
],
|
|
172
|
+
)
|
|
173
|
+
def should_stay_quiet_for_a_command_that_creates_no_pull_request(
|
|
174
|
+
unrelated_command: str, tmp_path: Path
|
|
175
|
+
) -> None:
|
|
176
|
+
"""A command other than gh pr create passes untouched."""
|
|
177
|
+
assert _decision_for(unrelated_command, tmp_path) == ""
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def should_stay_quiet_for_a_tool_that_runs_no_shell(tmp_path: Path) -> None:
|
|
181
|
+
"""A non-shell tool carrying the same text passes untouched."""
|
|
182
|
+
assert _decision_for(_PLAIN_CREATE_COMMAND, tmp_path, tool_name="Write") == ""
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def should_deny_when_the_payload_names_no_session(tmp_path: Path) -> None:
|
|
186
|
+
"""A payload with no session id reads as no record and denies.
|
|
187
|
+
|
|
188
|
+
A shared fallback marker would let one session's spawn silently clear a
|
|
189
|
+
later session's create, so an unusable session id denies instead.
|
|
190
|
+
"""
|
|
191
|
+
_record_spawn(tmp_path)
|
|
192
|
+
captured_stdout = io.StringIO()
|
|
193
|
+
payload = _payload(_PLAIN_CREATE_COMMAND)
|
|
194
|
+
payload["session_id"] = ""
|
|
195
|
+
with (
|
|
196
|
+
mock.patch("sys.stdin", io.StringIO(json.dumps(payload))),
|
|
197
|
+
mock.patch("sys.stdout", captured_stdout),
|
|
198
|
+
mock.patch.object(gate_module.tempfile, "gettempdir", return_value=str(tmp_path)),
|
|
199
|
+
pytest.raises(SystemExit),
|
|
200
|
+
):
|
|
201
|
+
gate_module.main()
|
|
202
|
+
hook_output = json.loads(captured_stdout.getvalue().strip())["hookSpecificOutput"]
|
|
203
|
+
assert hook_output["permissionDecision"] == "deny"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def should_allow_an_untokenizable_command(tmp_path: Path) -> None:
|
|
207
|
+
"""A command that will not tokenize fails open."""
|
|
208
|
+
assert _decision_for('gh pr create --title "unclosed', tmp_path) == ""
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def should_name_both_ways_forward_in_the_denial(tmp_path: Path) -> None:
|
|
212
|
+
"""The denial text names the agent spawn and the bypass marker."""
|
|
213
|
+
printed_text = _printed_output_for(_PLAIN_CREATE_COMMAND, tmp_path)
|
|
214
|
+
denial_reason = json.loads(printed_text)["hookSpecificOutput"]["permissionDecisionReason"]
|
|
215
|
+
assert _PR_DESCRIPTION_WRITER_SUBAGENT_TYPE in denial_reason
|
|
216
|
+
assert gate_module.SPAWN_BYPASS_MARKER in denial_reason
|
|
@@ -18,6 +18,7 @@ import os
|
|
|
18
18
|
import subprocess
|
|
19
19
|
import sys
|
|
20
20
|
from pathlib import Path
|
|
21
|
+
from typing import Callable
|
|
21
22
|
|
|
22
23
|
import pytest
|
|
23
24
|
|
|
@@ -30,6 +31,8 @@ if str(_HOOKS_ROOT) not in sys.path:
|
|
|
30
31
|
|
|
31
32
|
from hooks_constants.pre_tool_use_dispatcher_constants import ( # noqa: E402, I001
|
|
32
33
|
ALL_HOSTED_HOOK_ENTRIES,
|
|
34
|
+
ALL_IMMEDIATE_HARM_SCRIPT_PATHS,
|
|
35
|
+
APPLY_PATCH_TOOL_NAME,
|
|
33
36
|
BLOCKING_CRASH_DENY_REASON,
|
|
34
37
|
BLOCKING_CRASH_EXIT_CODE,
|
|
35
38
|
DENY_DECISION,
|
|
@@ -49,6 +52,8 @@ from pre_tool_use_dispatcher import ( # noqa: E402, I001
|
|
|
49
52
|
unique_first_seen_strings,
|
|
50
53
|
)
|
|
51
54
|
|
|
55
|
+
pytestmark = pytest.mark.usefixtures("ephemeral_exempt_off")
|
|
56
|
+
|
|
52
57
|
_DISPATCHER_SCRIPT = str(_BLOCKING_DIR / "pre_tool_use_dispatcher.py")
|
|
53
58
|
|
|
54
59
|
_PROSE_STYLE_ENV_VAR = "CLAUDE_PROSE_STYLE_ENFORCEMENT"
|
|
@@ -216,20 +221,21 @@ def _edit_payload(file_path: str, old_string: str, new_string: str) -> str:
|
|
|
216
221
|
)
|
|
217
222
|
|
|
218
223
|
|
|
219
|
-
def
|
|
220
|
-
"""Build
|
|
224
|
+
def _apply_patch_payload(working_directory: str, command: str) -> str:
|
|
225
|
+
"""Build an apply_patch tool payload JSON string.
|
|
221
226
|
|
|
222
227
|
Args:
|
|
223
|
-
|
|
224
|
-
|
|
228
|
+
working_directory: The Codex patch's working directory (top-level cwd).
|
|
229
|
+
command: The Codex-format patch text naming each file operation.
|
|
225
230
|
|
|
226
231
|
Returns:
|
|
227
232
|
JSON-encoded payload string.
|
|
228
233
|
"""
|
|
229
234
|
return json.dumps(
|
|
230
235
|
{
|
|
231
|
-
"tool_name":
|
|
232
|
-
"
|
|
236
|
+
"tool_name": APPLY_PATCH_TOOL_NAME,
|
|
237
|
+
"cwd": working_directory,
|
|
238
|
+
"tool_input": {"command": command},
|
|
233
239
|
}
|
|
234
240
|
)
|
|
235
241
|
|
|
@@ -281,9 +287,11 @@ def test_clean_write_allows_on_edit_tool() -> None:
|
|
|
281
287
|
_assert_dispatcher_matches_individual_hooks(payload_text, EDIT_TOOL_NAME)
|
|
282
288
|
|
|
283
289
|
|
|
284
|
-
def test_clean_write_allows_on_multi_edit_tool(
|
|
290
|
+
def test_clean_write_allows_on_multi_edit_tool(
|
|
291
|
+
multi_edit_payload: Callable[[str, list[dict[str, str]]], str],
|
|
292
|
+
) -> None:
|
|
285
293
|
"""Dispatcher allows a multi-edit that all hosted hooks allow on MultiEdit tool."""
|
|
286
|
-
payload_text =
|
|
294
|
+
payload_text = multi_edit_payload(
|
|
287
295
|
_TEMP_FILE_PATH,
|
|
288
296
|
[{"old_string": "old", "new_string": "new"}],
|
|
289
297
|
)
|
|
@@ -299,13 +307,16 @@ def test_dispatcher_docstring_points_at_roster_not_hardcoded_counts() -> None:
|
|
|
299
307
|
assert "-> 9 hooks" not in dispatcher_source
|
|
300
308
|
|
|
301
309
|
|
|
302
|
-
def
|
|
303
|
-
|
|
310
|
+
def test_multi_edit_payload_runs_only_group_b_hooks(
|
|
311
|
+
multi_edit_payload: Callable[[str, list[dict[str, str]]], str],
|
|
312
|
+
) -> None:
|
|
313
|
+
"""Dispatcher invokes only the hooks registered for MultiEdit, and each allows a clean edit.
|
|
304
314
|
|
|
305
|
-
|
|
306
|
-
so a MultiEdit to any path
|
|
307
|
-
This test
|
|
308
|
-
|
|
315
|
+
A hook whose applicable set omits MultiEdit stays absent from the
|
|
316
|
+
dispatcher's MultiEdit roster, so a MultiEdit to any path such a hook would
|
|
317
|
+
otherwise guard still allows. This test derives the excluded set from the
|
|
318
|
+
live roster rather than a hardcoded name list, so it holds as hooks move
|
|
319
|
+
between applicable sets.
|
|
309
320
|
"""
|
|
310
321
|
all_multi_edit_entries = _applicable_entries_for_tool(MULTI_EDIT_TOOL_NAME)
|
|
311
322
|
all_write_only_entries = [
|
|
@@ -321,7 +332,7 @@ def test_multi_edit_runs_only_group_b_hooks() -> None:
|
|
|
321
332
|
f"Group-A hook {each_group_a_entry.script_relative_path!r} "
|
|
322
333
|
"appears in the MultiEdit applicable set — it must not"
|
|
323
334
|
)
|
|
324
|
-
clean_payload =
|
|
335
|
+
clean_payload = multi_edit_payload(
|
|
325
336
|
_MARKDOWN_FILE_PATH,
|
|
326
337
|
[{"old_string": "old line", "new_string": "New text."}],
|
|
327
338
|
)
|
|
@@ -378,15 +389,18 @@ def test_write_existing_file_blocker_denies_on_write_tool() -> None:
|
|
|
378
389
|
_assert_dispatcher_matches_individual_hooks(payload_text, WRITE_TOOL_NAME)
|
|
379
390
|
|
|
380
391
|
|
|
381
|
-
def
|
|
382
|
-
|
|
392
|
+
def test_write_existing_file_blocker_allows_multi_edit_to_an_existing_path(
|
|
393
|
+
multi_edit_payload: Callable[[str, list[dict[str, str]]], str],
|
|
394
|
+
) -> None:
|
|
395
|
+
"""write_existing_file_blocker runs on MultiEdit but never denies it.
|
|
383
396
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
Uses a non-markdown file so
|
|
397
|
+
MultiEdit carries no create-or-clobber path the hook needs to guard, so it
|
|
398
|
+
always allows a MultiEdit to an existing file path, even though the same
|
|
399
|
+
hook would deny that path on a Write. Uses a non-markdown file so
|
|
400
|
+
markdown-only hooks stay silent.
|
|
387
401
|
"""
|
|
388
402
|
existing_file_path = str(Path(__file__).resolve())
|
|
389
|
-
payload_text =
|
|
403
|
+
payload_text = multi_edit_payload(
|
|
390
404
|
existing_file_path,
|
|
391
405
|
[{"old_string": "old text", "new_string": "new text"}],
|
|
392
406
|
)
|
|
@@ -720,8 +734,13 @@ def test_dispatcher_edit_applies_both_groups() -> None:
|
|
|
720
734
|
)
|
|
721
735
|
|
|
722
736
|
|
|
723
|
-
def
|
|
724
|
-
"""MultiEdit
|
|
737
|
+
def test_dispatcher_multi_edit_reaches_the_sensitive_protector() -> None:
|
|
738
|
+
"""A MultiEdit onto a sensitive path meets the same gate a Write does.
|
|
739
|
+
|
|
740
|
+
test_edit_and_multi_edit_applicable_sets_are_equal in the constants suite
|
|
741
|
+
holds the Edit and MultiEdit rosters equal, so this pins the one hook whose
|
|
742
|
+
absence would let a secret through a MultiEdit.
|
|
743
|
+
"""
|
|
725
744
|
all_multi_edit_entries = _applicable_entries_for_tool(MULTI_EDIT_TOOL_NAME)
|
|
726
745
|
all_multi_edit_script_paths = {
|
|
727
746
|
each_entry.script_relative_path for each_entry in all_multi_edit_entries
|
|
@@ -729,8 +748,24 @@ def test_dispatcher_multi_edit_applies_only_group_b() -> None:
|
|
|
729
748
|
assert "blocking/sensitive_file_protector.py" in all_multi_edit_script_paths, (
|
|
730
749
|
"sensitive_file_protector belongs in the MultiEdit applicable set"
|
|
731
750
|
)
|
|
732
|
-
|
|
733
|
-
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def test_dispatcher_apply_patch_applies_immediate_harm_hooks() -> None:
|
|
754
|
+
"""apply_patch applies to exactly the immediate-harm roster.
|
|
755
|
+
|
|
756
|
+
apply_patch reaches only the immediate-harm-plus-TDD roster the constants
|
|
757
|
+
module names, not the full Write/Edit/MultiEdit lint surface.
|
|
758
|
+
"""
|
|
759
|
+
all_apply_patch_entries = _applicable_entries_for_tool(APPLY_PATCH_TOOL_NAME)
|
|
760
|
+
all_apply_patch_script_paths = {
|
|
761
|
+
each_entry.script_relative_path for each_entry in all_apply_patch_entries
|
|
762
|
+
}
|
|
763
|
+
assert "blocking/sensitive_file_protector.py" in all_apply_patch_script_paths, (
|
|
764
|
+
"sensitive_file_protector belongs in the apply_patch applicable set"
|
|
765
|
+
)
|
|
766
|
+
assert all_apply_patch_script_paths == set(ALL_IMMEDIATE_HARM_SCRIPT_PATHS), (
|
|
767
|
+
"apply_patch must apply to exactly the immediate-harm roster, got: "
|
|
768
|
+
f"{sorted(all_apply_patch_script_paths)}"
|
|
734
769
|
)
|
|
735
770
|
|
|
736
771
|
|
|
@@ -953,6 +988,161 @@ def test_folded_write_edit_hooks_have_no_standalone_hooks_json_entry() -> None:
|
|
|
953
988
|
)
|
|
954
989
|
|
|
955
990
|
|
|
991
|
+
def _standalone_apply_patch_code_rules_commands(all_pre_tool_use_groups: list[dict]) -> list[str]:
|
|
992
|
+
"""Return every standalone apply_patch command naming code_rules_enforcer.py."""
|
|
993
|
+
return [
|
|
994
|
+
each_hook["command"]
|
|
995
|
+
for each_group in all_pre_tool_use_groups
|
|
996
|
+
if each_group["matcher"] == APPLY_PATCH_TOOL_NAME
|
|
997
|
+
for each_hook in each_group["hooks"]
|
|
998
|
+
if "code_rules_enforcer.py" in each_hook["command"]
|
|
999
|
+
]
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
def _dispatcher_matchers(all_pre_tool_use_groups: list[dict]) -> list[str]:
|
|
1003
|
+
"""Return every PreToolUse matcher whose command runs the dispatcher script."""
|
|
1004
|
+
return [
|
|
1005
|
+
each_group["matcher"]
|
|
1006
|
+
for each_group in all_pre_tool_use_groups
|
|
1007
|
+
if "pre_tool_use_dispatcher.py" in each_group["hooks"][0]["command"]
|
|
1008
|
+
]
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
def test_apply_patch_has_no_standalone_code_rules_enforcer_hooks_json_entry() -> None:
|
|
1012
|
+
"""apply_patch's code_rules_enforcer coverage comes only from the dispatcher.
|
|
1013
|
+
|
|
1014
|
+
hooks.json once registered a standalone apply_patch to code_rules_enforcer.py
|
|
1015
|
+
PreToolUse command. That entry is now folded into the dispatcher's own
|
|
1016
|
+
Write|Edit|MultiEdit|apply_patch matcher, so no separate command may still
|
|
1017
|
+
spawn code_rules_enforcer.py for apply_patch on its own.
|
|
1018
|
+
"""
|
|
1019
|
+
hooks_json_text = (_HOOKS_ROOT / "hooks.json").read_text(encoding="utf-8")
|
|
1020
|
+
all_pre_tool_use_groups = json.loads(hooks_json_text)["hooks"]["PreToolUse"]
|
|
1021
|
+
|
|
1022
|
+
standalone_commands = _standalone_apply_patch_code_rules_commands(all_pre_tool_use_groups)
|
|
1023
|
+
assert not standalone_commands, (
|
|
1024
|
+
"apply_patch must have no standalone code_rules_enforcer.py hooks.json "
|
|
1025
|
+
f"entry — coverage comes from the dispatcher. Found: {standalone_commands!r}"
|
|
1026
|
+
)
|
|
1027
|
+
|
|
1028
|
+
dispatcher_matchers = _dispatcher_matchers(all_pre_tool_use_groups)
|
|
1029
|
+
assert APPLY_PATCH_TOOL_NAME in "".join(dispatcher_matchers), (
|
|
1030
|
+
f"the dispatcher's own matcher must still name apply_patch, got: {dispatcher_matchers!r}"
|
|
1031
|
+
)
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
def _codex_add_patch(relative_file_path: str, file_body: str) -> str:
|
|
1035
|
+
"""Build a Codex apply_patch "add" command text for one new file.
|
|
1036
|
+
|
|
1037
|
+
Args:
|
|
1038
|
+
relative_file_path: Path (relative to the patch's cwd) to create.
|
|
1039
|
+
file_body: The full contents the new file should hold.
|
|
1040
|
+
|
|
1041
|
+
Returns:
|
|
1042
|
+
The Codex-format patch command text.
|
|
1043
|
+
"""
|
|
1044
|
+
added_lines = "\n".join(f"+{each_line}" for each_line in file_body.splitlines())
|
|
1045
|
+
return (
|
|
1046
|
+
"*** Begin Patch\n"
|
|
1047
|
+
f"*** Add File: {relative_file_path}\n"
|
|
1048
|
+
f"{added_lines}\n"
|
|
1049
|
+
"*** End Patch"
|
|
1050
|
+
)
|
|
1051
|
+
|
|
1052
|
+
|
|
1053
|
+
def test_dispatcher_denies_apply_patch_add_with_hardcoded_secret(
|
|
1054
|
+
tmp_path: Path,
|
|
1055
|
+
init_bare_git_repo: Callable[[Path], None],
|
|
1056
|
+
synthetic_github_token: str,
|
|
1057
|
+
) -> None:
|
|
1058
|
+
"""The dispatcher denies an apply_patch "add" that writes a hardcoded token.
|
|
1059
|
+
|
|
1060
|
+
Proves Done-when scenario 1 (hardcoded secret/PII denied) reaches apply_patch
|
|
1061
|
+
through the real dispatcher subprocess, not only the standalone
|
|
1062
|
+
pii_prevention_blocker test.
|
|
1063
|
+
"""
|
|
1064
|
+
repository_root = tmp_path / "repo"
|
|
1065
|
+
init_bare_git_repo(repository_root)
|
|
1066
|
+
patch_command = _codex_add_patch("leaked.py", f"token is {synthetic_github_token}\n")
|
|
1067
|
+
payload_text = _apply_patch_payload(str(repository_root), patch_command)
|
|
1068
|
+
|
|
1069
|
+
dispatcher_result = _run_dispatcher(payload_text)
|
|
1070
|
+
is_deny, reason_text = _parse_hook_decision(dispatcher_result)
|
|
1071
|
+
assert is_deny, (
|
|
1072
|
+
"dispatcher must deny an apply_patch add carrying a hardcoded secret, "
|
|
1073
|
+
f"got stdout {dispatcher_result.stdout.strip()!r}"
|
|
1074
|
+
)
|
|
1075
|
+
assert reason_text, "a deny must carry a non-empty reason"
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
def test_dispatcher_denies_apply_patch_targeting_a_sensitive_path(tmp_path: Path) -> None:
|
|
1079
|
+
"""The dispatcher denies an apply_patch "add" that targets a sensitive file.
|
|
1080
|
+
|
|
1081
|
+
Proves Done-when scenario 2 (sensitive path denied) reaches apply_patch
|
|
1082
|
+
through the real dispatcher subprocess.
|
|
1083
|
+
"""
|
|
1084
|
+
patch_command = _codex_add_patch(".env", "SECRET_KEY=irrelevant\n")
|
|
1085
|
+
payload_text = _apply_patch_payload(str(tmp_path), patch_command)
|
|
1086
|
+
|
|
1087
|
+
dispatcher_result = _run_dispatcher(payload_text)
|
|
1088
|
+
is_deny, reason_text = _parse_hook_decision(dispatcher_result)
|
|
1089
|
+
assert is_deny, (
|
|
1090
|
+
"dispatcher must deny an apply_patch add targeting a sensitive path, "
|
|
1091
|
+
f"got stdout {dispatcher_result.stdout.strip()!r}"
|
|
1092
|
+
)
|
|
1093
|
+
assert reason_text, "a deny must carry a non-empty reason"
|
|
1094
|
+
|
|
1095
|
+
|
|
1096
|
+
def test_dispatcher_denies_apply_patch_add_onto_an_existing_path(tmp_path: Path) -> None:
|
|
1097
|
+
"""The dispatcher denies an apply_patch "add" that targets a path already on disk.
|
|
1098
|
+
|
|
1099
|
+
Proves Done-when scenario 3 (overwrite-without-reading denied) reaches
|
|
1100
|
+
apply_patch: a Codex "add" operation onto an existing path is caught by
|
|
1101
|
+
code_rules_enforcer's codex-patch reading, blocking/codex_apply_patch.py's
|
|
1102
|
+
_codex_read_patch_file, which raises CodexPatchError for an add already on
|
|
1103
|
+
disk, reported through the dispatcher's aggregate.
|
|
1104
|
+
"""
|
|
1105
|
+
existing_target = tmp_path / "already_here.py"
|
|
1106
|
+
existing_target.write_text("value = 1\n", encoding="utf-8")
|
|
1107
|
+
patch_command = _codex_add_patch("already_here.py", "value = 2\n")
|
|
1108
|
+
payload_text = _apply_patch_payload(str(tmp_path), patch_command)
|
|
1109
|
+
|
|
1110
|
+
dispatcher_result = _run_dispatcher(payload_text)
|
|
1111
|
+
is_deny, reason_text = _parse_hook_decision(dispatcher_result)
|
|
1112
|
+
assert is_deny, (
|
|
1113
|
+
"dispatcher must deny an apply_patch add onto an already-existing path, "
|
|
1114
|
+
f"got stdout {dispatcher_result.stdout.strip()!r}"
|
|
1115
|
+
)
|
|
1116
|
+
assert reason_text, "a deny must carry a non-empty reason"
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
def test_dispatcher_allows_clean_apply_patch_add(tmp_path: Path) -> None:
|
|
1120
|
+
"""The dispatcher allows an apply_patch "add" whose paired test already exists.
|
|
1121
|
+
|
|
1122
|
+
tdd_enforcer now reaches apply_patch the same way it reaches Write: an
|
|
1123
|
+
untested production file is exactly the immediate-harm case the apply_patch
|
|
1124
|
+
boundary rule names, so "clean" here means test-first was honored before
|
|
1125
|
+
the tool call, not that TDD is skipped for this tool. The paired test file
|
|
1126
|
+
is written to disk first, matching how test_dispatcher_surfaces_migration_
|
|
1127
|
+
warning_for_edit proves a fresh Edit passes tdd_enforcer.
|
|
1128
|
+
"""
|
|
1129
|
+
(tmp_path / "test_services.py").write_text(
|
|
1130
|
+
"def test_add_one():\n assert True\n", encoding="utf-8"
|
|
1131
|
+
)
|
|
1132
|
+
patch_command = _codex_add_patch(
|
|
1133
|
+
"services.py",
|
|
1134
|
+
"def add_one(value: int) -> int:\n return value + 1\n",
|
|
1135
|
+
)
|
|
1136
|
+
payload_text = _apply_patch_payload(str(tmp_path), patch_command)
|
|
1137
|
+
|
|
1138
|
+
dispatcher_result = _run_dispatcher(payload_text)
|
|
1139
|
+
is_deny, reason_text = _parse_hook_decision(dispatcher_result)
|
|
1140
|
+
assert not is_deny, (
|
|
1141
|
+
f"dispatcher must allow an apply_patch add with a fresh paired test, "
|
|
1142
|
+
f"got deny reason {reason_text!r}"
|
|
1143
|
+
)
|
|
1144
|
+
|
|
1145
|
+
|
|
956
1146
|
def test_hosted_hook_set_covers_all_write_edit_blocking_hooks() -> None:
|
|
957
1147
|
"""The hosted hook set covers all previously-registered Write/Edit blocking hooks.
|
|
958
1148
|
|
|
@@ -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)
|