claude-dev-env 2.18.0 → 2.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/AGENTS.md +4 -4
- package/.agents/skills/_shared/AGENTS.md +1 -0
- package/.agents/skills/_shared/pr-loop/AGENTS.md +1 -0
- package/.agents/skills/_shared/pr-loop/preflight-proposal.contract.test.mjs +119 -0
- package/.agents/skills/_shared/pr-loop/preflight-proposal.md +5 -0
- package/.agents/skills/e-code-review/SKILL.md +12 -1
- package/.agents/skills/e-code-review/reference/fix.md +5 -1
- package/.agents/skills/e-code-review/reference/loop.md +4 -0
- package/.agents/skills/e-code-review/reference/mode-contract.test.mjs +66 -0
- package/.agents/skills/e-code-review/reference/preflight-proposal.md +40 -0
- package/.agents/skills/e-code-review/reference/runner-selection.md +1 -0
- package/.agents/skills/pr-cleanup/SKILL.md +105 -75
- package/.agents/skills/pr-cleanup/reference/process-inventory.md +15 -0
- package/.agents/skills/pr-cleanup/reference/task-seeds.md +20 -0
- package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/SKILL.md +18 -12
- package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/offense-examples.md +1 -1
- package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/task-seeds.md +3 -2
- package/.agents/skills/pr-refinement/SKILL.md +31 -0
- package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/SKILL.md +24 -7
- package/.agents/skills/{small-cl → pr-small-cl}/SKILL.md +1 -1
- package/.agents/skills/prototype/scripts/test_probe_sandbox_safety.py +33 -5
- package/.agents/skills/skill-builder/SKILL.md +1 -0
- package/.agents/skills-archived/bugteam/SKILL.md +1 -1
- package/.agents/skills-archived/bugteam/test_skill_additions.py +1 -1
- package/.agents/skills-archived/comments/SKILL.md +1 -1
- package/.agents/skills-archived/descriptions/SKILL.md +1 -1
- package/.agents/skills-archived/reviews/SKILL.md +1 -1
- package/AGENTS.md +1 -1
- package/_shared/pr-loop/AGENTS.md +2 -1
- package/_shared/pr-loop/preflight-proposal.md +56 -0
- package/_shared/pr-loop/scripts/code_rules_gate.py +29 -6
- package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_arguments.py +15 -3
- package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_running.py +47 -4
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +4 -0
- package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +47 -0
- package/bin/ever-shipped-skills.mjs +14 -0
- package/bin/install-constants.mjs +5 -0
- package/bin/install.agents-home.test.mjs +23 -0
- package/bin/install.codex-rules.test.mjs +61 -6
- package/bin/install.mjs +57 -9
- package/bin/install.prune.test.mjs +65 -3
- package/bin/install.test.mjs +202 -2
- package/docs/CODE_RULES.md +6 -0
- package/hooks/advisory/conftest.py +10 -0
- package/hooks/advisory/migration_safety_advisor.py +14 -14
- package/hooks/advisory/refactor_guard.py +250 -144
- package/hooks/advisory/refactor_guard_test_support.py +46 -0
- package/hooks/advisory/test_migration_safety_advisor.py +120 -0
- package/hooks/advisory/test_refactor_guard_advisory.py +171 -0
- package/hooks/advisory/test_refactor_guard_eligibility.py +166 -0
- package/hooks/atomic_file_writer.py +81 -0
- package/hooks/blocking/AGENTS.md +2 -1
- package/hooks/blocking/block_main_commit.py +66 -33
- package/hooks/blocking/code_rules_banned_identifiers.py +55 -4
- package/hooks/blocking/code_rules_blast_radius.py +194 -0
- package/hooks/blocking/code_rules_enforcer.py +12 -0
- package/hooks/blocking/code_rules_enforcer_test_support.py +92 -0
- package/hooks/blocking/destructive_command_blocker.py +8 -7
- package/hooks/blocking/pre_tool_use_dispatcher.py +11 -7
- package/hooks/blocking/precommit_code_rules_gate.py +14 -139
- package/hooks/blocking/sensitive_file_protector.py +8 -6
- package/hooks/blocking/session_edit_stage_gate.py +31 -1
- package/hooks/blocking/test_block_main_commit.py +145 -0
- package/hooks/blocking/test_claude_md_orphan_file_blocker.py +171 -1
- package/hooks/blocking/test_code_rules_blast_radius.py +161 -0
- package/hooks/blocking/test_code_rules_enforcer_agent_home_tooling.py +37 -12
- package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +54 -0
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +15 -53
- package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +13 -62
- package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +309 -0
- package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +14 -71
- package/hooks/blocking/test_code_rules_enforcer_scratchpad.py +14 -16
- package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +31 -46
- package/hooks/blocking/test_code_rules_enforcer_stage.py +192 -0
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +13 -59
- package/hooks/blocking/test_destructive_command_blocker.py +154 -138
- package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +85 -25
- package/hooks/blocking/test_destructive_command_blocker_no_verify.py +58 -38
- package/hooks/blocking/test_destructive_command_blocker_patterns.py +133 -0
- package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +113 -1
- package/hooks/blocking/test_env_var_table_code_drift_blocker.py +70 -3
- package/hooks/blocking/test_hook_subprocess_support.py +296 -0
- package/hooks/blocking/test_package_inventory_stale_blocker.py +1 -1
- package/hooks/blocking/test_pii_scanner.py +81 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +32 -3
- package/hooks/blocking/test_precommit_code_rules_gate.py +11 -16
- package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +234 -0
- package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +25 -18
- package/hooks/blocking/test_sensitive_file_protector.py +145 -5
- package/hooks/blocking/test_session_edit_stage_gate_staging.py +64 -0
- package/hooks/blocking/test_session_edit_stage_gate_support.py +204 -0
- package/hooks/blocking/test_session_edit_stage_gate_tracker.py +116 -0
- package/hooks/blocking/test_shared_stdin_adoption.py +31 -20
- package/hooks/blocking/test_test_preflight_check.py +80 -0
- package/hooks/git-hooks/AGENTS.md +1 -1
- package/hooks/git-hooks/git_hooks_constants/__init__.py +1 -0
- package/hooks/git-hooks/post_commit.py +160 -51
- package/hooks/git-hooks/pre_commit.py +9 -5
- package/hooks/git-hooks/test_post_commit.py +203 -0
- package/hooks/git-hooks/test_pre_commit.py +2 -2
- package/hooks/git-hooks/test_pre_push.py +57 -0
- package/hooks/hooks_constants/AGENTS.md +3 -1
- package/hooks/hooks_constants/atomic_file_writer_constants.py +4 -0
- package/hooks/hooks_constants/banned_identifiers_constants.py +1 -0
- package/hooks/hooks_constants/blast_radius_constants.py +14 -0
- package/hooks/hooks_constants/destructive_command_environment_constants.py +18 -0
- package/hooks/hooks_constants/destructive_command_segment_constants.py +1 -11
- package/hooks/hooks_constants/hardcoded_user_path_constants.py +9 -3
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +1 -1
- package/hooks/hooks_constants/refactor_guard_constants.py +75 -0
- package/hooks/hooks_constants/sensitive_file_protector_constants.py +2 -4
- package/hooks/hooks_constants/test_refactor_guard_constants.py +21 -0
- package/hooks/json_file_reader.py +22 -0
- package/hooks/observability/test_instructions_loaded_logger.py +54 -0
- package/hooks/pending_sidecars.py +33 -0
- package/hooks/session/test_plugin_data_dir_cleanup.py +70 -0
- package/hooks/session/test_session_edit_tracker_cleanup.py +16 -3
- package/hooks/test_atomic_file_writer.py +73 -0
- package/hooks/test_json_file_reader.py +24 -0
- package/hooks/test_pending_sidecars.py +25 -0
- package/hooks/validation/mypy_validator.py +213 -80
- package/hooks/validation/test_mypy_validator.py +288 -13
- package/hooks/workflow/auto_formatter.py +225 -93
- package/hooks/workflow/investigation_tracker_reset.py +2 -0
- package/hooks/workflow/test_auto_formatter.py +261 -12
- package/hooks/workflow/test_investigation_tracker_reset.py +90 -0
- package/package.json +1 -1
- package/rules/failure-blast-radius.md +126 -0
- /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/fetch-commands.md +0 -0
- /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/report-template.md +0 -0
- /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/rule-checklist.md +0 -0
- /package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/reference/examples.md +0 -0
- /package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/reference/offense-taxonomy.md +0 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Production-path tests for native staged-gate ownership.
|
|
2
|
+
|
|
3
|
+
The tests run the Agent Bash dispatcher and a native Git pre-commit hook against
|
|
4
|
+
the same isolated repository. The agent path passes the commit through. The
|
|
5
|
+
native path runs the staged gate and controls the commit.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
import json
|
|
13
|
+
import shutil
|
|
14
|
+
import stat
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import pytest
|
|
20
|
+
|
|
21
|
+
HOOKS_ROOT = Path(__file__).resolve().parent.parent
|
|
22
|
+
BLOCKING_ROOT = HOOKS_ROOT / "blocking"
|
|
23
|
+
GIT_HOOKS_ROOT = HOOKS_ROOT / "git-hooks"
|
|
24
|
+
PRE_COMMIT_SOURCE_PATH = GIT_HOOKS_ROOT / "pre_commit.py"
|
|
25
|
+
GATE_UTILS_SOURCE_PATH = GIT_HOOKS_ROOT / "gate_utils.py"
|
|
26
|
+
GIT_HOOKS_CONSTANTS_SOURCE_PATH = GIT_HOOKS_ROOT / "git_hooks_constants"
|
|
27
|
+
DISPATCHER_SOURCE_PATH = BLOCKING_ROOT / "bash_pre_tool_use_dispatcher.py"
|
|
28
|
+
GIT_COMMAND_TIMEOUT_SECONDS = 60
|
|
29
|
+
CLEAN_MODULE_SOURCE = "def add_one(number: int) -> int:\n return number + 1\n"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def run_git(
|
|
33
|
+
repository_root: Path,
|
|
34
|
+
*git_arguments: str,
|
|
35
|
+
check: bool = True,
|
|
36
|
+
) -> subprocess.CompletedProcess[str]:
|
|
37
|
+
"""Run one isolated Git command and return its captured process record."""
|
|
38
|
+
return subprocess.run(
|
|
39
|
+
["git", *git_arguments],
|
|
40
|
+
cwd=str(repository_root),
|
|
41
|
+
check=check,
|
|
42
|
+
capture_output=True,
|
|
43
|
+
text=True,
|
|
44
|
+
timeout=GIT_COMMAND_TIMEOUT_SECONDS,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def initialize_repository(repository_root: Path) -> None:
|
|
49
|
+
"""Create a repository with one committed base file and no active hook."""
|
|
50
|
+
run_git(repository_root, "init")
|
|
51
|
+
run_git(repository_root, "config", "user.name", "Hook Test")
|
|
52
|
+
run_git(repository_root, "config", "user.email", "hook-test@example.invalid")
|
|
53
|
+
run_git(repository_root, "config", "core.hooksPath", "absent_fixture_hooks")
|
|
54
|
+
(repository_root / "README.md").write_text("base\n", encoding="utf-8")
|
|
55
|
+
run_git(repository_root, "add", "README.md")
|
|
56
|
+
run_git(repository_root, "commit", "-m", "base")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def write_gate_script(gate_path: Path, marker_path: Path, exit_code: int) -> None:
|
|
60
|
+
"""Write a staged-gate fixture that records arguments and exits."""
|
|
61
|
+
gate_path.write_text(
|
|
62
|
+
"import pathlib\n"
|
|
63
|
+
"import sys\n"
|
|
64
|
+
f"pathlib.Path({str(marker_path)!r}).write_text("
|
|
65
|
+
"'\\n'.join(sys.argv[1:]), encoding='utf-8')\n"
|
|
66
|
+
f"sys.exit({exit_code})\n",
|
|
67
|
+
encoding="utf-8",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def install_native_pre_commit(hooks_path: Path) -> None:
|
|
72
|
+
"""Install the native pre-commit module and its self-contained imports."""
|
|
73
|
+
hooks_path.mkdir()
|
|
74
|
+
shutil.copyfile(PRE_COMMIT_SOURCE_PATH, hooks_path / "pre_commit.py")
|
|
75
|
+
shutil.copyfile(GATE_UTILS_SOURCE_PATH, hooks_path / "gate_utils.py")
|
|
76
|
+
shutil.copytree(
|
|
77
|
+
GIT_HOOKS_CONSTANTS_SOURCE_PATH,
|
|
78
|
+
hooks_path / "git_hooks_constants",
|
|
79
|
+
)
|
|
80
|
+
pre_commit_path = hooks_path / "pre-commit"
|
|
81
|
+
pre_commit_path.write_text(
|
|
82
|
+
"#!/usr/bin/env python3\n"
|
|
83
|
+
"import sys\n"
|
|
84
|
+
"from pathlib import Path\n"
|
|
85
|
+
"shim_directory = Path(__file__).resolve().parent\n"
|
|
86
|
+
"sys.path.insert(0, str(shim_directory))\n"
|
|
87
|
+
"import pre_commit\n"
|
|
88
|
+
"sys.exit(pre_commit.main())\n",
|
|
89
|
+
encoding="utf-8",
|
|
90
|
+
)
|
|
91
|
+
pre_commit_path.chmod(pre_commit_path.stat().st_mode | stat.S_IXUSR)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def configured_hooks_path(repository_root: Path) -> str | None:
|
|
95
|
+
"""Return the repository-local hooks path when one is configured."""
|
|
96
|
+
completed_config = run_git(
|
|
97
|
+
repository_root,
|
|
98
|
+
"config",
|
|
99
|
+
"--local",
|
|
100
|
+
"--get",
|
|
101
|
+
"core.hooksPath",
|
|
102
|
+
check=False,
|
|
103
|
+
)
|
|
104
|
+
if completed_config.returncode != 0:
|
|
105
|
+
return None
|
|
106
|
+
return completed_config.stdout.strip()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def restore_hooks_path(repository_root: Path, prior_hooks_path: str | None) -> None:
|
|
110
|
+
"""Restore the repository-local hooks path captured before a benchmark."""
|
|
111
|
+
if prior_hooks_path is None:
|
|
112
|
+
run_git(
|
|
113
|
+
repository_root,
|
|
114
|
+
"config",
|
|
115
|
+
"--local",
|
|
116
|
+
"--unset-all",
|
|
117
|
+
"core.hooksPath",
|
|
118
|
+
check=False,
|
|
119
|
+
)
|
|
120
|
+
return
|
|
121
|
+
run_git(repository_root, "config", "--local", "core.hooksPath", prior_hooks_path)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@contextmanager
|
|
125
|
+
def temporary_hooks_path(repository_root: Path, benchmark_hooks_path: Path) -> Iterator[None]:
|
|
126
|
+
"""Apply a benchmark hooks path and restore the prior local setting."""
|
|
127
|
+
prior_hooks_path = configured_hooks_path(repository_root)
|
|
128
|
+
run_git(repository_root, "config", "--local", "core.hooksPath", str(benchmark_hooks_path))
|
|
129
|
+
try:
|
|
130
|
+
yield
|
|
131
|
+
finally:
|
|
132
|
+
restore_hooks_path(repository_root, prior_hooks_path)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def stage_module(repository_root: Path) -> None:
|
|
136
|
+
"""Write and stage one Python module for the commit fixture."""
|
|
137
|
+
(repository_root / "widget.py").write_text(CLEAN_MODULE_SOURCE, encoding="utf-8")
|
|
138
|
+
run_git(repository_root, "add", "widget.py")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def build_bash_payload(repository_root: Path) -> str:
|
|
142
|
+
"""Build the Bash dispatcher payload for a commit in the fixture repository."""
|
|
143
|
+
commit_command = f'git -C "{repository_root}" commit -m add'
|
|
144
|
+
return json.dumps({"tool_name": "Bash", "tool_input": {"command": commit_command}})
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def run_agent_dispatcher(repository_root: Path) -> subprocess.CompletedProcess[str]:
|
|
148
|
+
"""Run the production Bash dispatcher against the fixture commit command."""
|
|
149
|
+
return subprocess.run(
|
|
150
|
+
[sys.executable, str(DISPATCHER_SOURCE_PATH)],
|
|
151
|
+
cwd=str(repository_root),
|
|
152
|
+
input=build_bash_payload(repository_root),
|
|
153
|
+
capture_output=True,
|
|
154
|
+
text=True,
|
|
155
|
+
timeout=GIT_COMMAND_TIMEOUT_SECONDS,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def run_native_commit(repository_root: Path) -> subprocess.CompletedProcess[str]:
|
|
160
|
+
"""Run the real Git commit that invokes the configured native hook."""
|
|
161
|
+
return run_git(
|
|
162
|
+
repository_root,
|
|
163
|
+
"commit",
|
|
164
|
+
"-m",
|
|
165
|
+
"add widget",
|
|
166
|
+
check=False,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_agent_dispatcher_passes_commit_through_without_running_gate(
|
|
171
|
+
tmp_path: Path,
|
|
172
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
173
|
+
) -> None:
|
|
174
|
+
"""The Agent Bash path continues the commit without running the staged gate."""
|
|
175
|
+
initialize_repository(tmp_path)
|
|
176
|
+
stage_module(tmp_path)
|
|
177
|
+
gate_marker_path = tmp_path / "agent_gate_invocation.txt"
|
|
178
|
+
gate_path = tmp_path / "agent_gate.py"
|
|
179
|
+
write_gate_script(gate_path, gate_marker_path, exit_code=1)
|
|
180
|
+
monkeypatch.setenv("CODE_RULES_GATE_PATH", str(gate_path))
|
|
181
|
+
|
|
182
|
+
completed_dispatcher = run_agent_dispatcher(tmp_path)
|
|
183
|
+
|
|
184
|
+
assert completed_dispatcher.returncode == 0, completed_dispatcher.stderr
|
|
185
|
+
assert completed_dispatcher.stdout.strip() == ""
|
|
186
|
+
assert "Git commit proceeds to configured Git hooks." in completed_dispatcher.stderr
|
|
187
|
+
assert not gate_marker_path.exists()
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@pytest.mark.parametrize("gate_exit_code, expected_commit_exit_code", [(1, 1), (0, 0)])
|
|
191
|
+
def test_installed_native_pre_commit_controls_staged_gate_decision(
|
|
192
|
+
tmp_path: Path,
|
|
193
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
194
|
+
gate_exit_code: int,
|
|
195
|
+
expected_commit_exit_code: int,
|
|
196
|
+
) -> None:
|
|
197
|
+
"""The native Git hook runs the staged gate and returns its commit decision."""
|
|
198
|
+
initialize_repository(tmp_path)
|
|
199
|
+
native_hooks_path = tmp_path / "native_hooks"
|
|
200
|
+
install_native_pre_commit(native_hooks_path)
|
|
201
|
+
stage_module(tmp_path)
|
|
202
|
+
gate_marker_path = tmp_path / "native_gate_invocation.txt"
|
|
203
|
+
gate_path = tmp_path / "native_gate.py"
|
|
204
|
+
write_gate_script(gate_path, gate_marker_path, gate_exit_code)
|
|
205
|
+
monkeypatch.setenv("CODE_RULES_GATE_PATH", str(gate_path))
|
|
206
|
+
|
|
207
|
+
with temporary_hooks_path(tmp_path, native_hooks_path):
|
|
208
|
+
completed_commit = run_native_commit(tmp_path)
|
|
209
|
+
|
|
210
|
+
assert completed_commit.returncode == expected_commit_exit_code
|
|
211
|
+
assert gate_marker_path.read_text(encoding="utf-8") == "--immediate"
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def test_temporary_hooks_path_restores_shared_worktree_config_on_timeout(
|
|
215
|
+
tmp_path: Path,
|
|
216
|
+
) -> None:
|
|
217
|
+
initialize_repository(tmp_path)
|
|
218
|
+
supported_hooks_path = tmp_path / "supported_hooks"
|
|
219
|
+
supported_hooks_path.mkdir()
|
|
220
|
+
run_git(tmp_path, "config", "--local", "core.hooksPath", str(supported_hooks_path))
|
|
221
|
+
unrelated_worktree = tmp_path.parent / "unrelated_worktree"
|
|
222
|
+
run_git(tmp_path, "worktree", "add", "--detach", str(unrelated_worktree))
|
|
223
|
+
benchmark_hooks_path = tmp_path / "benchmark_hooks"
|
|
224
|
+
benchmark_hooks_path.mkdir()
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
with pytest.raises(subprocess.TimeoutExpired):
|
|
228
|
+
with temporary_hooks_path(tmp_path, benchmark_hooks_path):
|
|
229
|
+
raise subprocess.TimeoutExpired("benchmark", 1)
|
|
230
|
+
|
|
231
|
+
assert configured_hooks_path(tmp_path) == str(supported_hooks_path)
|
|
232
|
+
assert configured_hooks_path(unrelated_worktree) == str(supported_hooks_path)
|
|
233
|
+
finally:
|
|
234
|
+
run_git(tmp_path, "worktree", "remove", "--force", str(unrelated_worktree))
|
|
@@ -6,7 +6,8 @@ import subprocess
|
|
|
6
6
|
import sys
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
HOOK_DIRECTORY = Path(__file__).resolve().parent
|
|
10
|
+
sys.path.insert(0, str(HOOK_DIRECTORY))
|
|
10
11
|
|
|
11
12
|
from pytest_testpaths_orphan_blocker import (
|
|
12
13
|
_explicit_testpaths,
|
|
@@ -15,7 +16,7 @@ from pytest_testpaths_orphan_blocker import (
|
|
|
15
16
|
is_test_file,
|
|
16
17
|
)
|
|
17
18
|
|
|
18
|
-
HOOK_SCRIPT_PATH =
|
|
19
|
+
HOOK_SCRIPT_PATH = HOOK_DIRECTORY / "pytest_testpaths_orphan_blocker.py"
|
|
19
20
|
|
|
20
21
|
PYPROJECT_WITH_EXPLICIT_TESTPATHS = (
|
|
21
22
|
"[tool.pytest.ini_options]\n"
|
|
@@ -51,21 +52,22 @@ def _write_package(package_root: Path, pyproject_text: str) -> None:
|
|
|
51
52
|
(package_root / "pyproject.toml").write_text(pyproject_text, encoding="utf-8")
|
|
52
53
|
|
|
53
54
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
55
|
+
def _run_hook(
|
|
56
|
+
tool_name: str, tool_input: dict, home_directory: Path
|
|
57
|
+
) -> subprocess.CompletedProcess:
|
|
58
|
+
"""Run the hook in a subprocess with the supplied tool payload."""
|
|
59
|
+
payload = json.dumps({"tool_name": tool_name, "tool_input": tool_input})
|
|
60
|
+
child_environment = os.environ.copy()
|
|
61
|
+
child_environment["HOME"] = str(home_directory)
|
|
62
|
+
child_environment["USERPROFILE"] = str(home_directory)
|
|
63
|
+
return subprocess.run(
|
|
64
|
+
[sys.executable, HOOK_SCRIPT_PATH],
|
|
65
|
+
input=payload,
|
|
66
|
+
capture_output=True,
|
|
67
|
+
text=True,
|
|
68
|
+
check=True,
|
|
69
|
+
env=child_environment,
|
|
70
|
+
)
|
|
69
71
|
|
|
70
72
|
|
|
71
73
|
def test_is_test_file_accepts_test_prefixed_python_file() -> None:
|
|
@@ -129,11 +131,13 @@ def test_hook_blocks_create_of_unregistered_test_file(tmp_path: Path) -> None:
|
|
|
129
131
|
"file_path": str(unregistered_test_file),
|
|
130
132
|
"content": "def test_x() -> None:\n assert True\n",
|
|
131
133
|
},
|
|
134
|
+
tmp_path,
|
|
132
135
|
)
|
|
133
136
|
decision = json.loads(completed.stdout)
|
|
134
137
|
hook_output = decision["hookSpecificOutput"]
|
|
135
138
|
assert hook_output["permissionDecision"] == "deny"
|
|
136
139
|
assert "theme_assets/tests" in hook_output["permissionDecisionReason"]
|
|
140
|
+
assert (tmp_path / ".claude" / "logs" / "hook-blocks.log").is_file()
|
|
137
141
|
|
|
138
142
|
|
|
139
143
|
def test_hook_allows_create_of_registered_test_file(tmp_path: Path) -> None:
|
|
@@ -146,6 +150,7 @@ def test_hook_allows_create_of_registered_test_file(tmp_path: Path) -> None:
|
|
|
146
150
|
"file_path": str(registered_test_file),
|
|
147
151
|
"content": "def test_x() -> None:\n assert True\n",
|
|
148
152
|
},
|
|
153
|
+
tmp_path,
|
|
149
154
|
)
|
|
150
155
|
assert completed.stdout.strip() == ""
|
|
151
156
|
|
|
@@ -163,6 +168,7 @@ def test_hook_ignores_edit_of_existing_test_file(tmp_path: Path) -> None:
|
|
|
163
168
|
"old_string": "assert True",
|
|
164
169
|
"new_string": "assert 1 == 1",
|
|
165
170
|
},
|
|
171
|
+
tmp_path,
|
|
166
172
|
)
|
|
167
173
|
assert completed.stdout.strip() == ""
|
|
168
174
|
|
|
@@ -174,6 +180,7 @@ def test_hook_ignores_non_test_python_file(tmp_path: Path) -> None:
|
|
|
174
180
|
completed = _run_hook(
|
|
175
181
|
"Write",
|
|
176
182
|
{"file_path": str(production_module), "content": "VALUE = 1\n"},
|
|
183
|
+
tmp_path,
|
|
177
184
|
)
|
|
178
185
|
assert completed.stdout.strip() == ""
|
|
179
186
|
|
|
@@ -242,6 +249,6 @@ def test_hook_does_not_crash_on_scalar_tool_ancestor(tmp_path: Path) -> None:
|
|
|
242
249
|
"file_path": str(any_test_file),
|
|
243
250
|
"content": "def test_x() -> None:\n assert True\n",
|
|
244
251
|
},
|
|
252
|
+
tmp_path,
|
|
245
253
|
)
|
|
246
|
-
assert completed.returncode == 0
|
|
247
254
|
assert completed.stdout.strip() == ""
|
|
@@ -14,20 +14,33 @@ it stays denied.
|
|
|
14
14
|
from __future__ import annotations
|
|
15
15
|
|
|
16
16
|
import json
|
|
17
|
+
import shlex
|
|
17
18
|
import subprocess
|
|
18
19
|
import sys
|
|
20
|
+
from functools import cache
|
|
19
21
|
from pathlib import Path
|
|
20
22
|
|
|
21
23
|
import pytest
|
|
22
24
|
|
|
23
25
|
BLOCKING_DIRECTORY = Path(__file__).resolve().parent
|
|
26
|
+
HOOKS_DIRECTORY = BLOCKING_DIRECTORY.parent
|
|
27
|
+
CLAUDE_DEV_ENV_DIRECTORY = BLOCKING_DIRECTORY.parent.parent
|
|
24
28
|
HOOK_SCRIPT_PATH = BLOCKING_DIRECTORY / "sensitive_file_protector.py"
|
|
29
|
+
DISPATCHER_SCRIPT_PATH = BLOCKING_DIRECTORY / "pre_tool_use_dispatcher.py"
|
|
30
|
+
HOOKS_JSON_PATH = HOOKS_DIRECTORY / "hooks.json"
|
|
31
|
+
DIRECT_HOOK_TIMEOUT_SECONDS = 10
|
|
25
32
|
|
|
26
|
-
WRITE_TOOL_NAME = "Write"
|
|
27
|
-
EDIT_TOOL_NAME = "Edit"
|
|
28
33
|
READ_TOOL_NAME = "Read"
|
|
29
34
|
|
|
30
|
-
|
|
35
|
+
if str(HOOKS_DIRECTORY) not in sys.path:
|
|
36
|
+
sys.path.insert(0, str(HOOKS_DIRECTORY))
|
|
37
|
+
|
|
38
|
+
from hooks_constants.pre_tool_use_dispatcher_constants import ( # noqa: E402, I001
|
|
39
|
+
DENY_DECISION,
|
|
40
|
+
EDIT_TOOL_NAME,
|
|
41
|
+
MULTI_EDIT_TOOL_NAME,
|
|
42
|
+
WRITE_TOOL_NAME,
|
|
43
|
+
)
|
|
31
44
|
|
|
32
45
|
PLACEHOLDER_TEMPLATE_BODY = "API_TOKEN=your-token-here\nDATABASE_HOST=localhost\n"
|
|
33
46
|
ORDINARY_SOURCE_BODY = "def add(left: int, right: int) -> int:\n return left + right\n"
|
|
@@ -65,11 +78,77 @@ ALL_UPPERCASE_DENIED_FILENAMES = (".ENV", "ID_RSA", "CREDENTIALS.JSON", "SERVER.
|
|
|
65
78
|
ALL_ORDINARY_FILENAMES = ("main.py", "README.md", "settings.json")
|
|
66
79
|
|
|
67
80
|
|
|
68
|
-
|
|
81
|
+
@cache
|
|
82
|
+
def _registered_dispatcher_details(
|
|
83
|
+
hooks_json_path: Path = HOOKS_JSON_PATH,
|
|
84
|
+
) -> tuple[list[str], int]:
|
|
85
|
+
hooks_configuration = json.loads(hooks_json_path.read_text(encoding="utf-8"))
|
|
86
|
+
pre_tool_use_registrations = hooks_configuration["hooks"]["PreToolUse"]
|
|
87
|
+
dispatcher_registrations = []
|
|
88
|
+
for each_registration in pre_tool_use_registrations:
|
|
89
|
+
for each_hook in each_registration["hooks"]:
|
|
90
|
+
command_parts = shlex.split(each_hook["command"])
|
|
91
|
+
if Path(command_parts[-1]).name != "pre_tool_use_dispatcher.py":
|
|
92
|
+
continue
|
|
93
|
+
dispatcher_registrations.append(
|
|
94
|
+
(each_registration, each_hook, command_parts)
|
|
95
|
+
)
|
|
96
|
+
assert len(dispatcher_registrations) == 1, (
|
|
97
|
+
"hooks.json must contain exactly one pre_tool_use_dispatcher.py registration; "
|
|
98
|
+
f"found {len(dispatcher_registrations)}"
|
|
99
|
+
)
|
|
100
|
+
each_registration, each_hook, command_parts = dispatcher_registrations[0]
|
|
101
|
+
assert each_registration["matcher"] == "Write|Edit|MultiEdit", (
|
|
102
|
+
"dispatcher registration must cover exactly Write, Edit, and MultiEdit; "
|
|
103
|
+
f"got matcher {each_registration['matcher']!r}"
|
|
104
|
+
)
|
|
105
|
+
assert command_parts[0] == "python3"
|
|
106
|
+
dispatcher_script_path = Path(
|
|
107
|
+
command_parts[-1].replace(
|
|
108
|
+
"${CLAUDE_PLUGIN_ROOT}", str(CLAUDE_DEV_ENV_DIRECTORY)
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
assert dispatcher_script_path.resolve() == DISPATCHER_SCRIPT_PATH.resolve()
|
|
112
|
+
registered_timeout_seconds = each_hook["timeout"]
|
|
113
|
+
assert isinstance(registered_timeout_seconds, int)
|
|
114
|
+
return (
|
|
115
|
+
[sys.executable, str(dispatcher_script_path)],
|
|
116
|
+
registered_timeout_seconds,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _build_file_tool_input(
|
|
121
|
+
tool_name: str, file_path: Path, replacement_content: str
|
|
122
|
+
) -> dict[str, str | list[dict[str, str]]]:
|
|
123
|
+
file_path_text = str(file_path)
|
|
124
|
+
if tool_name == EDIT_TOOL_NAME:
|
|
125
|
+
return {
|
|
126
|
+
"file_path": file_path_text,
|
|
127
|
+
"old_string": "API_TOKEN=old-token\n",
|
|
128
|
+
"new_string": replacement_content,
|
|
129
|
+
}
|
|
130
|
+
if tool_name == MULTI_EDIT_TOOL_NAME:
|
|
131
|
+
return {
|
|
132
|
+
"file_path": file_path_text,
|
|
133
|
+
"edits": [
|
|
134
|
+
{
|
|
135
|
+
"old_string": "API_TOKEN=old-token\n",
|
|
136
|
+
"new_string": replacement_content,
|
|
137
|
+
}
|
|
138
|
+
],
|
|
139
|
+
}
|
|
140
|
+
return {"file_path": file_path_text, "content": replacement_content}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _run_hook(
|
|
144
|
+
tool_name: str, file_path: Path, replacement_content: str
|
|
145
|
+
) -> subprocess.CompletedProcess:
|
|
69
146
|
payload = json.dumps(
|
|
70
147
|
{
|
|
71
148
|
"tool_name": tool_name,
|
|
72
|
-
"tool_input":
|
|
149
|
+
"tool_input": _build_file_tool_input(
|
|
150
|
+
tool_name, file_path, replacement_content
|
|
151
|
+
),
|
|
73
152
|
}
|
|
74
153
|
)
|
|
75
154
|
return _run_hook_with_stdin(payload)
|
|
@@ -82,6 +161,22 @@ def _run_hook_with_stdin(stdin_text: str) -> subprocess.CompletedProcess:
|
|
|
82
161
|
capture_output=True,
|
|
83
162
|
text=True,
|
|
84
163
|
check=False,
|
|
164
|
+
timeout=DIRECT_HOOK_TIMEOUT_SECONDS,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _run_dispatcher(
|
|
169
|
+
tool_name: str, tool_input: dict[str, str | list[dict[str, str]]]
|
|
170
|
+
) -> subprocess.CompletedProcess[str]:
|
|
171
|
+
payload = json.dumps({"tool_name": tool_name, "tool_input": tool_input})
|
|
172
|
+
dispatcher_command, dispatcher_timeout_seconds = _registered_dispatcher_details()
|
|
173
|
+
return subprocess.run(
|
|
174
|
+
dispatcher_command,
|
|
175
|
+
input=payload,
|
|
176
|
+
capture_output=True,
|
|
177
|
+
text=True,
|
|
178
|
+
check=False,
|
|
179
|
+
timeout=dispatcher_timeout_seconds,
|
|
85
180
|
)
|
|
86
181
|
|
|
87
182
|
|
|
@@ -153,6 +248,51 @@ def test_edit_to_a_secrets_file_is_denied(tmp_path: Path) -> None:
|
|
|
153
248
|
assert _permission_decision(completed) == DENY_DECISION
|
|
154
249
|
|
|
155
250
|
|
|
251
|
+
def test_multi_edit_to_a_secrets_file_is_denied(tmp_path: Path) -> None:
|
|
252
|
+
completed = _run_hook(
|
|
253
|
+
MULTI_EDIT_TOOL_NAME, tmp_path / ".env", PLACEHOLDER_TEMPLATE_BODY
|
|
254
|
+
)
|
|
255
|
+
assert completed.returncode == 0, completed.stderr
|
|
256
|
+
assert _permission_decision(completed) == DENY_DECISION
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def test_hooks_json_registers_exact_dispatcher_matcher_and_timeout() -> None:
|
|
260
|
+
dispatcher_command, dispatcher_timeout_seconds = _registered_dispatcher_details()
|
|
261
|
+
assert dispatcher_command[1] == str(DISPATCHER_SCRIPT_PATH)
|
|
262
|
+
assert dispatcher_timeout_seconds == 60
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@pytest.mark.parametrize(
|
|
266
|
+
("tool_name", "target_filename", "expected_permission_decision"),
|
|
267
|
+
[
|
|
268
|
+
pytest.param(WRITE_TOOL_NAME, ".env", DENY_DECISION, id="write-secret-denied"),
|
|
269
|
+
pytest.param(EDIT_TOOL_NAME, ".env", DENY_DECISION, id="edit-secret-denied"),
|
|
270
|
+
pytest.param(
|
|
271
|
+
MULTI_EDIT_TOOL_NAME, ".env", DENY_DECISION, id="multi-edit-secret-denied"
|
|
272
|
+
),
|
|
273
|
+
pytest.param(WRITE_TOOL_NAME, ".env.example", None, id="write-template-allowed"),
|
|
274
|
+
pytest.param(EDIT_TOOL_NAME, ".env.example", None, id="edit-template-allowed"),
|
|
275
|
+
],
|
|
276
|
+
)
|
|
277
|
+
def test_dispatcher_applies_sensitive_filename_contract(
|
|
278
|
+
tmp_path: Path,
|
|
279
|
+
tool_name: str,
|
|
280
|
+
target_filename: str,
|
|
281
|
+
expected_permission_decision: str | None,
|
|
282
|
+
) -> None:
|
|
283
|
+
completed = _run_dispatcher(
|
|
284
|
+
tool_name,
|
|
285
|
+
_build_file_tool_input(
|
|
286
|
+
tool_name, tmp_path / target_filename, PLACEHOLDER_TEMPLATE_BODY
|
|
287
|
+
),
|
|
288
|
+
)
|
|
289
|
+
assert completed.returncode == 0, completed.stderr
|
|
290
|
+
assert _permission_decision(completed) == expected_permission_decision, (
|
|
291
|
+
f"{tool_name} to {target_filename} expected "
|
|
292
|
+
f"{expected_permission_decision!r}, got stdout {completed.stdout!r}"
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
|
|
156
296
|
def test_read_tool_is_not_evaluated(tmp_path: Path) -> None:
|
|
157
297
|
completed = _run_hook(READ_TOOL_NAME, tmp_path / ".env", PLACEHOLDER_TEMPLATE_BODY)
|
|
158
298
|
assert completed.returncode == 0, completed.stderr
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Production-path staging tests for the session-edit stage gate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
_TEST_DIRECTORY = Path(__file__).resolve().parent
|
|
9
|
+
if str(_TEST_DIRECTORY) not in sys.path:
|
|
10
|
+
sys.path.insert(0, str(_TEST_DIRECTORY))
|
|
11
|
+
|
|
12
|
+
from test_session_edit_stage_gate_support import ( # noqa: E402
|
|
13
|
+
make_test_directories,
|
|
14
|
+
parse_dispatcher_decision,
|
|
15
|
+
prepare_repository_with_unstaged_edit,
|
|
16
|
+
run_bash_dispatcher,
|
|
17
|
+
run_tracker_event,
|
|
18
|
+
read_tracker_paths,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
SESSION_ID = "staging-production-session"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_dispatcher_denies_tracker_recorded_unstaged_edit(tmp_path: Path) -> None:
|
|
25
|
+
"""The Bash dispatcher denies a commit for a tracker-recorded edit."""
|
|
26
|
+
repository_root, temp_directory, home_directory = make_test_directories(tmp_path)
|
|
27
|
+
tracked_file = prepare_repository_with_unstaged_edit(repository_root)
|
|
28
|
+
tracker_run = run_tracker_event(
|
|
29
|
+
tracked_file, SESSION_ID, repository_root, temp_directory, home_directory
|
|
30
|
+
)
|
|
31
|
+
assert tracker_run.returncode == 0
|
|
32
|
+
assert read_tracker_paths(temp_directory, SESSION_ID) == [str(tracked_file.resolve())]
|
|
33
|
+
|
|
34
|
+
dispatcher_run = run_bash_dispatcher(
|
|
35
|
+
"git commit -m update",
|
|
36
|
+
SESSION_ID,
|
|
37
|
+
repository_root,
|
|
38
|
+
temp_directory,
|
|
39
|
+
home_directory,
|
|
40
|
+
)
|
|
41
|
+
assert dispatcher_run.returncode == 0
|
|
42
|
+
decision, reason = parse_dispatcher_decision(dispatcher_run.stdout)
|
|
43
|
+
assert decision == "deny"
|
|
44
|
+
assert "widget.py" in reason
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_dispatcher_allows_specific_staging_segment(tmp_path: Path) -> None:
|
|
48
|
+
"""The Bash dispatcher accepts a commit preceded by a specific git add."""
|
|
49
|
+
repository_root, temp_directory, home_directory = make_test_directories(tmp_path)
|
|
50
|
+
tracked_file = prepare_repository_with_unstaged_edit(repository_root)
|
|
51
|
+
tracker_run = run_tracker_event(
|
|
52
|
+
tracked_file, SESSION_ID, repository_root, temp_directory, home_directory
|
|
53
|
+
)
|
|
54
|
+
assert tracker_run.returncode == 0
|
|
55
|
+
|
|
56
|
+
dispatcher_run = run_bash_dispatcher(
|
|
57
|
+
"git add widget.py && git commit -m update",
|
|
58
|
+
SESSION_ID,
|
|
59
|
+
repository_root,
|
|
60
|
+
temp_directory,
|
|
61
|
+
home_directory,
|
|
62
|
+
)
|
|
63
|
+
assert dispatcher_run.returncode == 0
|
|
64
|
+
assert parse_dispatcher_decision(dispatcher_run.stdout) == ("", "")
|