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,296 @@
|
|
|
1
|
+
"""Shared subprocess support for blocking-hook behavior tests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
HOOK_SUBPROCESS_TIMEOUT_SECONDS: int = 10
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def run_hook_as_subprocess(
|
|
18
|
+
hook_script_path: str | Path,
|
|
19
|
+
payload_text: str,
|
|
20
|
+
working_directory: str | Path,
|
|
21
|
+
home_directory: str | Path,
|
|
22
|
+
*,
|
|
23
|
+
all_environment_names_to_remove: tuple[str, ...] = (),
|
|
24
|
+
environment_updates_by_name: Mapping[str, str] | None = None,
|
|
25
|
+
) -> subprocess.CompletedProcess[str]:
|
|
26
|
+
"""Run a hook through its real ``__main__`` path with captured text streams."""
|
|
27
|
+
resolved_hook_script_path = Path(hook_script_path).resolve()
|
|
28
|
+
environment_update_by_name = environment_updates_by_name or {}
|
|
29
|
+
all_environment_names_to_exclude = {
|
|
30
|
+
_environment_name_comparison_key(each_environment_name)
|
|
31
|
+
for each_environment_name in (
|
|
32
|
+
*all_environment_names_to_remove,
|
|
33
|
+
*environment_update_by_name,
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
environment_variable_by_name = {
|
|
37
|
+
each_environment_name: each_environment_value
|
|
38
|
+
for each_environment_name, each_environment_value in os.environ.items()
|
|
39
|
+
if _environment_name_comparison_key(each_environment_name)
|
|
40
|
+
not in all_environment_names_to_exclude
|
|
41
|
+
}
|
|
42
|
+
environment_variable_by_name.update(environment_update_by_name)
|
|
43
|
+
resolved_home_directory = Path(home_directory).resolve()
|
|
44
|
+
for each_environment_name in ("HOME", "USERPROFILE", "TEMP", "TMP", "TMPDIR"):
|
|
45
|
+
environment_variable_by_name[each_environment_name] = str(resolved_home_directory)
|
|
46
|
+
environment_variable_by_name["PYTHONIOENCODING"] = "utf-8"
|
|
47
|
+
return subprocess.run(
|
|
48
|
+
[sys.executable, str(resolved_hook_script_path)],
|
|
49
|
+
input=payload_text,
|
|
50
|
+
cwd=working_directory,
|
|
51
|
+
env=environment_variable_by_name,
|
|
52
|
+
capture_output=True,
|
|
53
|
+
encoding="utf-8",
|
|
54
|
+
check=False,
|
|
55
|
+
timeout=HOOK_SUBPROCESS_TIMEOUT_SECONDS,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def build_bash_payload(command: str, *, tool_input_cwd: str | Path | None = None) -> str:
|
|
60
|
+
"""Build the serialized Bash tool payload for a command."""
|
|
61
|
+
command_field_by_name = {"command": command}
|
|
62
|
+
if tool_input_cwd is not None:
|
|
63
|
+
command_field_by_name["cwd"] = str(tool_input_cwd)
|
|
64
|
+
return json.dumps(
|
|
65
|
+
{
|
|
66
|
+
"tool_name": "Bash",
|
|
67
|
+
"tool_input": command_field_by_name,
|
|
68
|
+
}
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def read_hook_permission_decision(stdout_text: str) -> tuple[str, str]:
|
|
73
|
+
"""Read the hook permission decision and reason from serialized output."""
|
|
74
|
+
if not stdout_text.strip():
|
|
75
|
+
return "", ""
|
|
76
|
+
hook_output_by_name = json.loads(stdout_text)["hookSpecificOutput"]
|
|
77
|
+
return (
|
|
78
|
+
hook_output_by_name["permissionDecision"],
|
|
79
|
+
hook_output_by_name["permissionDecisionReason"],
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _environment_name_comparison_key(environment_name: str) -> str:
|
|
84
|
+
if os.name == "nt":
|
|
85
|
+
return environment_name.casefold()
|
|
86
|
+
return environment_name
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def assert_hook_deny_log_contains(
|
|
90
|
+
home_directory: str | Path, hook_filename: str
|
|
91
|
+
) -> None:
|
|
92
|
+
"""Assert that the isolated hook-block log records the named hook."""
|
|
93
|
+
deny_log_path = Path(home_directory) / ".claude" / "logs" / "hook-blocks.log"
|
|
94
|
+
assert deny_log_path.is_file(), f"Missing deny log: {deny_log_path}"
|
|
95
|
+
deny_log_text = deny_log_path.read_text(encoding="utf-8")
|
|
96
|
+
assert hook_filename in deny_log_text, (
|
|
97
|
+
f"Missing {hook_filename} in deny log: {deny_log_text}"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_run_hook_as_subprocess_resolves_relative_path_and_isolates_home(
|
|
102
|
+
tmp_path: Path,
|
|
103
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
104
|
+
) -> None:
|
|
105
|
+
absolute_hook_script_path = (
|
|
106
|
+
Path(__file__).resolve().parent / "open_questions_in_plans_blocker.py"
|
|
107
|
+
)
|
|
108
|
+
monkeypatch.chdir(absolute_hook_script_path.parent)
|
|
109
|
+
relative_hook_script_path = Path(absolute_hook_script_path.name)
|
|
110
|
+
isolated_home_directory = tmp_path / "isolated-home"
|
|
111
|
+
isolated_home_directory.mkdir()
|
|
112
|
+
plan_path = tmp_path / "docs" / "plans" / "feature.md"
|
|
113
|
+
payload_text = json.dumps(
|
|
114
|
+
{
|
|
115
|
+
"tool_name": "Write",
|
|
116
|
+
"tool_input": {
|
|
117
|
+
"file_path": str(plan_path),
|
|
118
|
+
"content": "# Feature Plan\n\n## Open Questions\n\n- Choose a value.\n",
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
completed = run_hook_as_subprocess(
|
|
124
|
+
hook_script_path=relative_hook_script_path,
|
|
125
|
+
payload_text=payload_text,
|
|
126
|
+
working_directory=tmp_path,
|
|
127
|
+
home_directory=isolated_home_directory,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
parsed_stdout = json.loads(completed.stdout)
|
|
131
|
+
assert completed.returncode == 0, completed.stderr
|
|
132
|
+
assert parsed_stdout["hookSpecificOutput"]["permissionDecision"] == "deny"
|
|
133
|
+
assert_hook_deny_log_contains(
|
|
134
|
+
isolated_home_directory, "open_questions_in_plans_blocker.py"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_run_hook_as_subprocess_returns_nonzero_probe_streams(
|
|
139
|
+
tmp_path: Path,
|
|
140
|
+
) -> None:
|
|
141
|
+
probe_script_path = tmp_path / "probe.py"
|
|
142
|
+
probe_script_path.write_text(
|
|
143
|
+
"import sys\n"
|
|
144
|
+
"sys.stdout.write('probe stdout\\n')\n"
|
|
145
|
+
"sys.stderr.write('probe stderr\\n')\n"
|
|
146
|
+
"sys.exit(7)\n",
|
|
147
|
+
encoding="utf-8",
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
completed = run_hook_as_subprocess(
|
|
151
|
+
hook_script_path=probe_script_path,
|
|
152
|
+
payload_text="",
|
|
153
|
+
working_directory=tmp_path,
|
|
154
|
+
home_directory=tmp_path / "isolated-home",
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
assert completed.returncode == 7, completed.stderr
|
|
158
|
+
assert completed.stdout == "probe stdout\n"
|
|
159
|
+
assert completed.stderr == "probe stderr\n"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_build_bash_payload_omits_optional_cwd() -> None:
|
|
163
|
+
payload_text = build_bash_payload("rm -rf /tmp/example")
|
|
164
|
+
|
|
165
|
+
assert json.loads(payload_text) == {
|
|
166
|
+
"tool_name": "Bash",
|
|
167
|
+
"tool_input": {
|
|
168
|
+
"command": "rm -rf /tmp/example",
|
|
169
|
+
},
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_build_bash_payload_includes_optional_cwd() -> None:
|
|
174
|
+
payload_text = build_bash_payload(
|
|
175
|
+
"rm -rf /tmp/example", tool_input_cwd="/tmp/work"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
assert json.loads(payload_text) == {
|
|
179
|
+
"tool_name": "Bash",
|
|
180
|
+
"tool_input": {
|
|
181
|
+
"command": "rm -rf /tmp/example",
|
|
182
|
+
"cwd": "/tmp/work",
|
|
183
|
+
},
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_run_hook_as_subprocess_handles_unicode_stdin_as_utf8(
|
|
188
|
+
tmp_path: Path,
|
|
189
|
+
) -> None:
|
|
190
|
+
probe_script_path = tmp_path / "probe.py"
|
|
191
|
+
probe_script_path.write_text(
|
|
192
|
+
"import sys\n"
|
|
193
|
+
"sys.stdout.write(','.join(str(ord(each_character)) for each_character in sys.stdin.read()))\n",
|
|
194
|
+
encoding="utf-8",
|
|
195
|
+
)
|
|
196
|
+
unicode_payload = "café 日本語"
|
|
197
|
+
|
|
198
|
+
completed = run_hook_as_subprocess(
|
|
199
|
+
hook_script_path=probe_script_path,
|
|
200
|
+
payload_text=unicode_payload,
|
|
201
|
+
working_directory=tmp_path,
|
|
202
|
+
home_directory=tmp_path / "isolated-home",
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
assert completed.returncode == 0, completed.stderr
|
|
206
|
+
assert completed.stdout == "99,97,102,233,32,26085,26412,35486"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_run_hook_as_subprocess_preserves_posix_mixed_case_environment_names(
|
|
210
|
+
tmp_path: Path,
|
|
211
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
212
|
+
) -> None:
|
|
213
|
+
probe_script_path = tmp_path / "probe.py"
|
|
214
|
+
probe_script_path.write_text(
|
|
215
|
+
"import json\n"
|
|
216
|
+
"import os\n"
|
|
217
|
+
"print(json.dumps(sorted((each_name, each_value) for each_name, each_value in os.environ.items() if each_name.casefold() == 'casesensitiveprobe')))\n",
|
|
218
|
+
encoding="utf-8",
|
|
219
|
+
)
|
|
220
|
+
monkeypatch.setenv("CaseSensitiveProbe", "before")
|
|
221
|
+
|
|
222
|
+
completed = run_hook_as_subprocess(
|
|
223
|
+
hook_script_path=probe_script_path,
|
|
224
|
+
payload_text="",
|
|
225
|
+
working_directory=tmp_path,
|
|
226
|
+
home_directory=tmp_path / "isolated-home",
|
|
227
|
+
environment_updates_by_name={"CASESENSITIVEPROBE": "after"},
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
child_environment_variable_by_name = json.loads(completed.stdout)
|
|
231
|
+
if os.name == "nt":
|
|
232
|
+
assert child_environment_variable_by_name == [["CASESENSITIVEPROBE", "after"]]
|
|
233
|
+
else:
|
|
234
|
+
assert child_environment_variable_by_name == [
|
|
235
|
+
["CASESENSITIVEPROBE", "after"],
|
|
236
|
+
["CaseSensitiveProbe", "before"],
|
|
237
|
+
]
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def test_run_hook_as_subprocess_handles_windows_mixed_case_environment_names(
|
|
241
|
+
tmp_path: Path,
|
|
242
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
243
|
+
) -> None:
|
|
244
|
+
probe_script_path = tmp_path / "probe.py"
|
|
245
|
+
probe_script_path.write_text(
|
|
246
|
+
"import json\n"
|
|
247
|
+
"import os\n"
|
|
248
|
+
"print(json.dumps(sorted((each_name, each_value) for each_name, each_value in os.environ.items() if each_name.casefold() == 'casesensitiveprobe')))\n",
|
|
249
|
+
encoding="utf-8",
|
|
250
|
+
)
|
|
251
|
+
monkeypatch.setenv("CaseSensitiveProbe", "before")
|
|
252
|
+
|
|
253
|
+
completed = run_hook_as_subprocess(
|
|
254
|
+
hook_script_path=probe_script_path,
|
|
255
|
+
payload_text="",
|
|
256
|
+
working_directory=tmp_path,
|
|
257
|
+
home_directory=tmp_path / "isolated-home",
|
|
258
|
+
all_environment_names_to_remove=("casesensitiveprobe",),
|
|
259
|
+
environment_updates_by_name={"CASESENSITIVEPROBE": "after"},
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
child_environment_variable_by_name = json.loads(completed.stdout)
|
|
263
|
+
if os.name == "nt":
|
|
264
|
+
assert child_environment_variable_by_name == [["CASESENSITIVEPROBE", "after"]]
|
|
265
|
+
else:
|
|
266
|
+
assert child_environment_variable_by_name == [
|
|
267
|
+
["CASESENSITIVEPROBE", "after"],
|
|
268
|
+
["CaseSensitiveProbe", "before"],
|
|
269
|
+
]
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def test_run_hook_as_subprocess_keeps_default_home_and_temp_isolation(
|
|
273
|
+
tmp_path: Path,
|
|
274
|
+
) -> None:
|
|
275
|
+
probe_script_path = tmp_path / "probe.py"
|
|
276
|
+
probe_script_path.write_text(
|
|
277
|
+
"import json\n"
|
|
278
|
+
"import os\n"
|
|
279
|
+
"print(json.dumps({each_name: os.environ.get(each_name) for each_name in (\n"
|
|
280
|
+
" 'HOME', 'USERPROFILE', 'TEMP', 'TMP', 'TMPDIR'\n"
|
|
281
|
+
")}))\n",
|
|
282
|
+
encoding="utf-8",
|
|
283
|
+
)
|
|
284
|
+
isolated_home_directory = tmp_path / "isolated-home"
|
|
285
|
+
isolated_home_directory.mkdir()
|
|
286
|
+
|
|
287
|
+
completed = run_hook_as_subprocess(
|
|
288
|
+
hook_script_path=probe_script_path,
|
|
289
|
+
payload_text="",
|
|
290
|
+
working_directory=tmp_path,
|
|
291
|
+
home_directory=isolated_home_directory,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
child_environment_variable_by_name = json.loads(completed.stdout)
|
|
295
|
+
expected_home = str(isolated_home_directory.resolve())
|
|
296
|
+
assert set(child_environment_variable_by_name.values()) == {expected_home}
|
|
@@ -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
|