claude-dev-env 2.18.0 → 2.19.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 +89 -0
- package/.agents/skills/_shared/pr-loop/preflight-proposal.md +5 -0
- package/.agents/skills/pr-cleanup/SKILL.md +17 -85
- 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_parts/tests/test_gate_running.py +47 -4
- 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 +4 -0
- package/hooks/advisory/migration_safety_advisor.py +14 -14
- package/hooks/advisory/test_migration_safety_advisor.py +120 -0
- package/hooks/atomic_file_writer.py +81 -0
- package/hooks/blocking/AGENTS.md +2 -1
- package/hooks/blocking/code_rules_banned_identifiers.py +55 -4
- 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_claude_md_orphan_file_blocker.py +171 -1
- 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 +308 -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_deny_mode.py +42 -25
- package/hooks/blocking/test_destructive_command_blocker_no_verify.py +58 -38
- 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 +168 -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/pre_commit.py +6 -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/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/sensitive_file_protector_constants.py +2 -4
- package/hooks/json_file_reader.py +22 -0
- package/hooks/pending_sidecars.py +33 -0
- 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/package.json +1 -1
- /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
|
@@ -61,6 +61,87 @@ def test_flags_unix_home_path() -> None:
|
|
|
61
61
|
assert any(each.category == "home-path" for each in all_unix_hits)
|
|
62
62
|
|
|
63
63
|
|
|
64
|
+
def test_flags_raw_escaped_windows_home_path() -> None:
|
|
65
|
+
all_home_hits = scan_text_for_pii(r"path is C:\\Users\\concrete-user\\file")
|
|
66
|
+
assert [each.matched_text for each in all_home_hits] == [
|
|
67
|
+
r"C:\\Users\\concrete-user"
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_flags_unc_windows_home_path_at_each_share_depth() -> None:
|
|
72
|
+
all_home_hits = scan_text_for_pii(
|
|
73
|
+
r"path is \\server\Users\concrete-user\file"
|
|
74
|
+
"\n"
|
|
75
|
+
r"path is \\server name\My Share\Users\other-user\file"
|
|
76
|
+
)
|
|
77
|
+
assert [each.matched_text for each in all_home_hits] == [
|
|
78
|
+
r"\\server\Users\concrete-user",
|
|
79
|
+
r"\\server name\My Share\Users\other-user",
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_flags_forward_slash_unc_home_paths() -> None:
|
|
84
|
+
all_home_hits = scan_text_for_pii(
|
|
85
|
+
"path is //server/Users/concrete-user/file"
|
|
86
|
+
"\n"
|
|
87
|
+
"path is //server/share/Users/other-user/file"
|
|
88
|
+
)
|
|
89
|
+
assert [each.matched_text for each in all_home_hits] == [
|
|
90
|
+
"//server/Users/concrete-user",
|
|
91
|
+
"//server/share/Users/other-user",
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_allows_http_urls_with_users_segments() -> None:
|
|
96
|
+
all_home_hits = scan_text_for_pii(
|
|
97
|
+
"https://docs-7.example.com/Users/concrete-user/file"
|
|
98
|
+
) + scan_text_for_pii("http://server-2/share/Users/concrete-user/file")
|
|
99
|
+
assert all_home_hits == []
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_allows_http_urls_with_home_segments() -> None:
|
|
103
|
+
assert scan_text_for_pii("https://home/user/file") == []
|
|
104
|
+
assert scan_text_for_pii("https://Users/user/file") == []
|
|
105
|
+
assert scan_text_for_pii("https://[::1]/home/user/file") == []
|
|
106
|
+
assert scan_text_for_pii("https://[2001:db8::1]/Users/user/file") == []
|
|
107
|
+
assert scan_text_for_pii("https://docs-7.example.com/home/concrete-user/file") == []
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_flags_absolute_and_file_uri_home_paths() -> None:
|
|
111
|
+
all_home_hits = scan_text_for_pii("path is /home/concrete-user/file")
|
|
112
|
+
all_home_hits += scan_text_for_pii("path is file:///home/concrete-user/file")
|
|
113
|
+
all_home_hits += scan_text_for_pii("path is file://server/home/concrete-user/file")
|
|
114
|
+
all_home_hits += scan_text_for_pii("path is FILE://server/home/other-user/file")
|
|
115
|
+
all_home_hits += scan_text_for_pii(
|
|
116
|
+
"path is FILE://server/share/home/third-user/file"
|
|
117
|
+
)
|
|
118
|
+
assert [each.matched_text for each in all_home_hits] == [
|
|
119
|
+
"/home/concrete-user",
|
|
120
|
+
"file:///home/concrete-user",
|
|
121
|
+
"file://server/home/concrete-user",
|
|
122
|
+
"FILE://server/home/other-user",
|
|
123
|
+
"FILE://server/share/home/third-user",
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_flags_file_uri_home_path() -> None:
|
|
128
|
+
all_home_hits = scan_text_for_pii(
|
|
129
|
+
"path is file:///Users/concrete-user/file"
|
|
130
|
+
"\n"
|
|
131
|
+
"path is file://server/Users/concrete-user/file"
|
|
132
|
+
"\n"
|
|
133
|
+
"path is FILE:///Users/other-user/file"
|
|
134
|
+
"\n"
|
|
135
|
+
"path is FILE://server/share/Users/third-user/file"
|
|
136
|
+
)
|
|
137
|
+
assert [each.matched_text for each in all_home_hits] == [
|
|
138
|
+
"file:///Users/concrete-user",
|
|
139
|
+
"file://server/Users/concrete-user",
|
|
140
|
+
"FILE:///Users/other-user",
|
|
141
|
+
"FILE://server/share/Users/third-user",
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
|
|
64
145
|
def test_flags_private_ip_and_allows_public_ip() -> None:
|
|
65
146
|
all_lan_hits = scan_text_for_pii(f"host {SYNTHETIC_PRIVATE_IP}")
|
|
66
147
|
all_dns_hits = scan_text_for_pii(f"dns {SYNTHETIC_PUBLIC_IP}")
|
|
@@ -42,6 +42,7 @@ from hooks_constants.pre_tool_use_dispatcher_constants import ( # noqa: E402, I
|
|
|
42
42
|
from pre_tool_use_dispatcher import ( # noqa: E402, I001
|
|
43
43
|
DispatcherDecision,
|
|
44
44
|
HostedHookResult,
|
|
45
|
+
_emit_allow_decision,
|
|
45
46
|
_emit_deny_decision,
|
|
46
47
|
aggregate_hosted_hook_results,
|
|
47
48
|
run_hosted_hook,
|
|
@@ -649,6 +650,28 @@ def test_emit_deny_does_not_cross_collapse_reason_and_context(
|
|
|
649
650
|
assert parsed["hookSpecificOutput"]["additionalContext"] == shared_text
|
|
650
651
|
|
|
651
652
|
|
|
653
|
+
def test_emit_allow_preserves_system_message_and_additional_context(
|
|
654
|
+
capsys: pytest.CaptureFixture[str],
|
|
655
|
+
) -> None:
|
|
656
|
+
"""An allow payload carries the advisory's system and context messages."""
|
|
657
|
+
decision = DispatcherDecision(
|
|
658
|
+
should_deny=False,
|
|
659
|
+
should_allow=True,
|
|
660
|
+
all_deny_reasons=[],
|
|
661
|
+
all_system_messages=["system-a", "system-b"],
|
|
662
|
+
all_additional_context=["context-a", "context-b"],
|
|
663
|
+
should_suppress_output=False,
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
_emit_allow_decision(decision)
|
|
667
|
+
|
|
668
|
+
parsed = json.loads(capsys.readouterr().out)
|
|
669
|
+
hook_specific = parsed["hookSpecificOutput"]
|
|
670
|
+
assert hook_specific["permissionDecision"] == "allow"
|
|
671
|
+
assert hook_specific["additionalContext"] == "context-a\ncontext-b"
|
|
672
|
+
assert parsed["systemMessage"] == "system-a\nsystem-b"
|
|
673
|
+
|
|
674
|
+
|
|
652
675
|
def test_later_hook_deny_survives_early_hook_exit() -> None:
|
|
653
676
|
"""Dispatcher denies even when an earlier hook exits cleanly before a later hook denies.
|
|
654
677
|
|
|
@@ -698,10 +721,16 @@ def test_dispatcher_edit_applies_both_groups() -> None:
|
|
|
698
721
|
|
|
699
722
|
|
|
700
723
|
def test_dispatcher_multi_edit_applies_only_group_b() -> None:
|
|
701
|
-
"""MultiEdit
|
|
724
|
+
"""MultiEdit applies to 9 hosted Group-B hooks, including the sensitive protector."""
|
|
702
725
|
all_multi_edit_entries = _applicable_entries_for_tool(MULTI_EDIT_TOOL_NAME)
|
|
703
|
-
|
|
704
|
-
|
|
726
|
+
all_multi_edit_script_paths = {
|
|
727
|
+
each_entry.script_relative_path for each_entry in all_multi_edit_entries
|
|
728
|
+
}
|
|
729
|
+
assert "blocking/sensitive_file_protector.py" in all_multi_edit_script_paths, (
|
|
730
|
+
"sensitive_file_protector belongs in the MultiEdit applicable set"
|
|
731
|
+
)
|
|
732
|
+
assert len(all_multi_edit_entries) == 9, (
|
|
733
|
+
f"MultiEdit tool must apply to exactly 9 Group-B hooks, got {len(all_multi_edit_entries)}"
|
|
705
734
|
)
|
|
706
735
|
|
|
707
736
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
"""Behavior tests for the precommit_code_rules_gate
|
|
1
|
+
"""Behavior tests for the pass-through precommit_code_rules_gate hook.
|
|
2
2
|
|
|
3
3
|
Each test builds a real git repository in a temporary directory, stages
|
|
4
4
|
real files, and runs the hook script as a subprocess with a PreToolUse
|
|
5
|
-
JSON payload on stdin
|
|
5
|
+
JSON payload on stdin through the exact production invocation path.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
import json
|
|
@@ -140,10 +140,6 @@ def run_hook(bash_command: str, working_directory: Path) -> subprocess.Completed
|
|
|
140
140
|
)
|
|
141
141
|
|
|
142
142
|
|
|
143
|
-
def parse_denial(hook_stdout: str) -> dict:
|
|
144
|
-
return json.loads(hook_stdout)["hookSpecificOutput"]
|
|
145
|
-
|
|
146
|
-
|
|
147
143
|
def test_non_commit_command_passes_through(tmp_path: Path) -> None:
|
|
148
144
|
initialize_repository(tmp_path)
|
|
149
145
|
completed_hook = run_hook("git status", tmp_path)
|
|
@@ -157,20 +153,19 @@ def test_commit_with_clean_staged_python_file_is_allowed(tmp_path: Path) -> None
|
|
|
157
153
|
completed_hook = run_hook("git commit -m add", tmp_path)
|
|
158
154
|
assert completed_hook.returncode == 0
|
|
159
155
|
assert completed_hook.stdout.strip() == ""
|
|
156
|
+
assert "Git commit proceeds to configured Git hooks." in completed_hook.stderr
|
|
160
157
|
|
|
161
158
|
|
|
162
|
-
def
|
|
159
|
+
def test_commit_with_staged_python_file_passes_through(tmp_path: Path) -> None:
|
|
163
160
|
initialize_repository(tmp_path)
|
|
164
161
|
stage_file(tmp_path, "totals.py", VIOLATING_MODULE_SOURCE)
|
|
165
162
|
completed_hook = run_hook("git commit -m add", tmp_path)
|
|
166
163
|
assert completed_hook.returncode == 0
|
|
167
|
-
|
|
168
|
-
assert
|
|
169
|
-
assert "totals.py" in denial["permissionDecisionReason"]
|
|
170
|
-
assert "Line" in denial["permissionDecisionReason"]
|
|
164
|
+
assert completed_hook.stdout.strip() == ""
|
|
165
|
+
assert "Git commit proceeds to configured Git hooks." in completed_hook.stderr
|
|
171
166
|
|
|
172
167
|
|
|
173
|
-
def
|
|
168
|
+
def test_git_dash_c_commit_form_passes_through(tmp_path: Path) -> None:
|
|
174
169
|
repository_root = tmp_path / "repo"
|
|
175
170
|
repository_root.mkdir()
|
|
176
171
|
initialize_repository(repository_root)
|
|
@@ -180,9 +175,8 @@ def test_git_dash_c_commit_form_is_blocked(tmp_path: Path) -> None:
|
|
|
180
175
|
quoted_root = str(repository_root)
|
|
181
176
|
completed_hook = run_hook(f'git -C "{quoted_root}" commit -m add', elsewhere)
|
|
182
177
|
assert completed_hook.returncode == 0
|
|
183
|
-
|
|
184
|
-
assert
|
|
185
|
-
assert "totals.py" in denial["permissionDecisionReason"]
|
|
178
|
+
assert completed_hook.stdout.strip() == ""
|
|
179
|
+
assert "Git commit proceeds to configured Git hooks." in completed_hook.stderr
|
|
186
180
|
|
|
187
181
|
|
|
188
182
|
def test_commit_with_no_staged_python_files_is_allowed(tmp_path: Path) -> None:
|
|
@@ -193,7 +187,7 @@ def test_commit_with_no_staged_python_files_is_allowed(tmp_path: Path) -> None:
|
|
|
193
187
|
assert completed_hook.stdout.strip() == ""
|
|
194
188
|
|
|
195
189
|
|
|
196
|
-
def
|
|
190
|
+
def test_worktree_commit_passes_through(
|
|
197
191
|
tmp_path: Path,
|
|
198
192
|
) -> None:
|
|
199
193
|
main_checkout = tmp_path / "main_checkout"
|
|
@@ -213,3 +207,4 @@ def test_worktree_commit_reads_worktree_paired_tests_not_sibling_checkout(
|
|
|
213
207
|
)
|
|
214
208
|
assert completed_hook.returncode == 0
|
|
215
209
|
assert completed_hook.stdout.strip() == ""
|
|
210
|
+
assert "Git commit proceeds to configured Git hooks." in completed_hook.stderr
|
|
@@ -0,0 +1,168 @@
|
|
|
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
|
+
import json
|
|
11
|
+
import shutil
|
|
12
|
+
import stat
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import pytest
|
|
18
|
+
|
|
19
|
+
HOOKS_ROOT = Path(__file__).resolve().parent.parent
|
|
20
|
+
BLOCKING_ROOT = HOOKS_ROOT / "blocking"
|
|
21
|
+
GIT_HOOKS_ROOT = HOOKS_ROOT / "git-hooks"
|
|
22
|
+
PRE_COMMIT_SOURCE_PATH = GIT_HOOKS_ROOT / "pre_commit.py"
|
|
23
|
+
GATE_UTILS_SOURCE_PATH = GIT_HOOKS_ROOT / "gate_utils.py"
|
|
24
|
+
GIT_HOOKS_CONSTANTS_SOURCE_PATH = GIT_HOOKS_ROOT / "git_hooks_constants"
|
|
25
|
+
DISPATCHER_SOURCE_PATH = BLOCKING_ROOT / "bash_pre_tool_use_dispatcher.py"
|
|
26
|
+
GIT_COMMAND_TIMEOUT_SECONDS = 60
|
|
27
|
+
CLEAN_MODULE_SOURCE = "def add_one(number: int) -> int:\n return number + 1\n"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def run_git(
|
|
31
|
+
repository_root: Path,
|
|
32
|
+
*git_arguments: str,
|
|
33
|
+
check: bool = True,
|
|
34
|
+
) -> subprocess.CompletedProcess[str]:
|
|
35
|
+
"""Run one isolated Git command and return its captured process record."""
|
|
36
|
+
return subprocess.run(
|
|
37
|
+
["git", *git_arguments],
|
|
38
|
+
cwd=str(repository_root),
|
|
39
|
+
check=check,
|
|
40
|
+
capture_output=True,
|
|
41
|
+
text=True,
|
|
42
|
+
timeout=GIT_COMMAND_TIMEOUT_SECONDS,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def initialize_repository(repository_root: Path) -> None:
|
|
47
|
+
"""Create a repository with one committed base file and no active hook."""
|
|
48
|
+
run_git(repository_root, "init")
|
|
49
|
+
run_git(repository_root, "config", "user.name", "Hook Test")
|
|
50
|
+
run_git(repository_root, "config", "user.email", "hook-test@example.invalid")
|
|
51
|
+
run_git(repository_root, "config", "core.hooksPath", "absent_fixture_hooks")
|
|
52
|
+
(repository_root / "README.md").write_text("base\n", encoding="utf-8")
|
|
53
|
+
run_git(repository_root, "add", "README.md")
|
|
54
|
+
run_git(repository_root, "commit", "-m", "base")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def write_gate_script(gate_path: Path, marker_path: Path, exit_code: int) -> None:
|
|
58
|
+
"""Write a staged-gate fixture that records arguments and exits."""
|
|
59
|
+
gate_path.write_text(
|
|
60
|
+
"import pathlib\n"
|
|
61
|
+
"import sys\n"
|
|
62
|
+
f"pathlib.Path({str(marker_path)!r}).write_text("
|
|
63
|
+
"'\\n'.join(sys.argv[1:]), encoding='utf-8')\n"
|
|
64
|
+
f"sys.exit({exit_code})\n",
|
|
65
|
+
encoding="utf-8",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def install_native_pre_commit(repository_root: Path, hooks_path: Path) -> None:
|
|
70
|
+
"""Install the native pre-commit module and its self-contained imports."""
|
|
71
|
+
hooks_path.mkdir()
|
|
72
|
+
shutil.copyfile(PRE_COMMIT_SOURCE_PATH, hooks_path / "pre_commit.py")
|
|
73
|
+
shutil.copyfile(GATE_UTILS_SOURCE_PATH, hooks_path / "gate_utils.py")
|
|
74
|
+
shutil.copytree(
|
|
75
|
+
GIT_HOOKS_CONSTANTS_SOURCE_PATH,
|
|
76
|
+
hooks_path / "git_hooks_constants",
|
|
77
|
+
)
|
|
78
|
+
pre_commit_path = hooks_path / "pre-commit"
|
|
79
|
+
pre_commit_path.write_text(
|
|
80
|
+
"#!/usr/bin/env python3\n"
|
|
81
|
+
"import sys\n"
|
|
82
|
+
"from pathlib import Path\n"
|
|
83
|
+
"shim_directory = Path(__file__).resolve().parent\n"
|
|
84
|
+
"sys.path.insert(0, str(shim_directory))\n"
|
|
85
|
+
"import pre_commit\n"
|
|
86
|
+
"sys.exit(pre_commit.main())\n",
|
|
87
|
+
encoding="utf-8",
|
|
88
|
+
)
|
|
89
|
+
pre_commit_path.chmod(pre_commit_path.stat().st_mode | stat.S_IXUSR)
|
|
90
|
+
run_git(repository_root, "config", "core.hooksPath", str(hooks_path))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def stage_module(repository_root: Path) -> None:
|
|
94
|
+
"""Write and stage one Python module for the commit fixture."""
|
|
95
|
+
(repository_root / "widget.py").write_text(CLEAN_MODULE_SOURCE, encoding="utf-8")
|
|
96
|
+
run_git(repository_root, "add", "widget.py")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def build_bash_payload(repository_root: Path) -> str:
|
|
100
|
+
"""Build the Bash dispatcher payload for a commit in the fixture repository."""
|
|
101
|
+
commit_command = f'git -C "{repository_root}" commit -m add'
|
|
102
|
+
return json.dumps({"tool_name": "Bash", "tool_input": {"command": commit_command}})
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def run_agent_dispatcher(repository_root: Path) -> subprocess.CompletedProcess[str]:
|
|
106
|
+
"""Run the production Bash dispatcher against the fixture commit command."""
|
|
107
|
+
return subprocess.run(
|
|
108
|
+
[sys.executable, str(DISPATCHER_SOURCE_PATH)],
|
|
109
|
+
cwd=str(repository_root),
|
|
110
|
+
input=build_bash_payload(repository_root),
|
|
111
|
+
capture_output=True,
|
|
112
|
+
text=True,
|
|
113
|
+
timeout=GIT_COMMAND_TIMEOUT_SECONDS,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def run_native_commit(repository_root: Path) -> subprocess.CompletedProcess[str]:
|
|
118
|
+
"""Run the real Git commit that invokes the configured native hook."""
|
|
119
|
+
return run_git(
|
|
120
|
+
repository_root,
|
|
121
|
+
"commit",
|
|
122
|
+
"-m",
|
|
123
|
+
"add widget",
|
|
124
|
+
check=False,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_agent_dispatcher_passes_commit_through_without_running_gate(
|
|
129
|
+
tmp_path: Path,
|
|
130
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
131
|
+
) -> None:
|
|
132
|
+
"""The Agent Bash path continues the commit without running the staged gate."""
|
|
133
|
+
initialize_repository(tmp_path)
|
|
134
|
+
stage_module(tmp_path)
|
|
135
|
+
gate_marker_path = tmp_path / "agent_gate_invocation.txt"
|
|
136
|
+
gate_path = tmp_path / "agent_gate.py"
|
|
137
|
+
write_gate_script(gate_path, gate_marker_path, exit_code=1)
|
|
138
|
+
monkeypatch.setenv("CODE_RULES_GATE_PATH", str(gate_path))
|
|
139
|
+
|
|
140
|
+
completed_dispatcher = run_agent_dispatcher(tmp_path)
|
|
141
|
+
|
|
142
|
+
assert completed_dispatcher.returncode == 0, completed_dispatcher.stderr
|
|
143
|
+
assert completed_dispatcher.stdout.strip() == ""
|
|
144
|
+
assert "Git commit proceeds to configured Git hooks." in completed_dispatcher.stderr
|
|
145
|
+
assert not gate_marker_path.exists()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@pytest.mark.parametrize("gate_exit_code, expected_commit_exit_code", [(1, 1), (0, 0)])
|
|
149
|
+
def test_installed_native_pre_commit_controls_staged_gate_decision(
|
|
150
|
+
tmp_path: Path,
|
|
151
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
152
|
+
gate_exit_code: int,
|
|
153
|
+
expected_commit_exit_code: int,
|
|
154
|
+
) -> None:
|
|
155
|
+
"""The native Git hook runs the staged gate and returns its commit decision."""
|
|
156
|
+
initialize_repository(tmp_path)
|
|
157
|
+
native_hooks_path = tmp_path / "native_hooks"
|
|
158
|
+
install_native_pre_commit(tmp_path, native_hooks_path)
|
|
159
|
+
stage_module(tmp_path)
|
|
160
|
+
gate_marker_path = tmp_path / "native_gate_invocation.txt"
|
|
161
|
+
gate_path = tmp_path / "native_gate.py"
|
|
162
|
+
write_gate_script(gate_path, gate_marker_path, gate_exit_code)
|
|
163
|
+
monkeypatch.setenv("CODE_RULES_GATE_PATH", str(gate_path))
|
|
164
|
+
|
|
165
|
+
completed_commit = run_native_commit(tmp_path)
|
|
166
|
+
|
|
167
|
+
assert completed_commit.returncode == expected_commit_exit_code
|
|
168
|
+
assert gate_marker_path.read_text(encoding="utf-8") == "--staged"
|
|
@@ -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
|