claude-dev-env 2.17.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/orchestrator/SKILL.md +6 -4
- package/.agents/skills/orchestrator-refresh/SKILL.md +9 -5
- 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/team-advisor/SKILL.md +5 -5
- 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/advisor/AGENTS.md +5 -5
- package/_shared/advisor/advisor-protocol.md +54 -31
- package/_shared/advisor/reference/advisor-block.md +5 -1
- package/_shared/advisor/reference/consult-format.md +1 -1
- package/_shared/advisor/reference/identity.md +28 -0
- package/_shared/advisor/reference/lifecycle.md +8 -1
- package/_shared/advisor/reference/sol-rung.md +12 -7
- package/_shared/advisor/reference/spawn-walk-log.md +6 -5
- package/_shared/advisor/reference/third-party-bind.md +5 -6
- package/_shared/advisor/reference/warm-up.md +9 -2
- package/_shared/advisor/scripts/codex_sol_advisor.py +66 -11
- package/_shared/advisor/scripts/config/advisor_scripts_constants/advisor_route_constants.py +9 -0
- package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +16 -4
- package/_shared/advisor/scripts/config/advisor_scripts_constants/sol_advisor_constants.py +11 -10
- package/_shared/advisor/scripts/model_tier_run_validator.py +50 -23
- package/_shared/advisor/scripts/tests/test_codex_sol_advisor.py +108 -3
- package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +171 -82
- package/_shared/advisor/scripts/tests/test_tier_model_ids.py +43 -0
- package/_shared/advisor/scripts/tier_model_ids.py +75 -7
- 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/_shared/pr-loop/worker-spawn.md +4 -3
- 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/docs/references/team-advisor-skill.md +2 -2
- 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
|
@@ -5,34 +5,30 @@ hook signing and verification. The blocker must ASK before allowing them.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import json
|
|
8
|
-
import os
|
|
9
|
-
import subprocess
|
|
10
8
|
import sys
|
|
11
9
|
from pathlib import Path
|
|
12
10
|
|
|
11
|
+
_BLOCKING_DIRECTORY = Path(__file__).resolve().parent
|
|
12
|
+
if str(_BLOCKING_DIRECTORY) not in sys.path:
|
|
13
|
+
sys.path.insert(0, str(_BLOCKING_DIRECTORY))
|
|
13
14
|
|
|
14
|
-
|
|
15
|
+
import _path_setup # noqa: E402, F401
|
|
15
16
|
|
|
17
|
+
from test_hook_subprocess_support import ( # noqa: E402
|
|
18
|
+
build_bash_payload,
|
|
19
|
+
run_hook_as_subprocess,
|
|
20
|
+
)
|
|
16
21
|
|
|
17
|
-
|
|
18
|
-
child_environment = os.environ.copy()
|
|
19
|
-
return subprocess.run(
|
|
20
|
-
[sys.executable, str(SCRIPT_PATH)],
|
|
21
|
-
input=json.dumps(payload),
|
|
22
|
-
text=True,
|
|
23
|
-
capture_output=True,
|
|
24
|
-
check=False,
|
|
25
|
-
env=child_environment,
|
|
26
|
-
)
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def _make_bash_payload(command: str) -> dict:
|
|
30
|
-
return {"tool_name": "Bash", "tool_input": {"command": command}}
|
|
22
|
+
SCRIPT_PATH = _BLOCKING_DIRECTORY / "destructive_command_blocker.py"
|
|
31
23
|
|
|
32
24
|
|
|
33
|
-
def test_asks_on_git_commit_no_verify() -> None:
|
|
34
|
-
|
|
35
|
-
|
|
25
|
+
def test_asks_on_git_commit_no_verify(tmp_path: Path) -> None:
|
|
26
|
+
result = run_hook_as_subprocess(
|
|
27
|
+
hook_script_path=SCRIPT_PATH,
|
|
28
|
+
payload_text=build_bash_payload('git commit -m "wip" --no-verify'),
|
|
29
|
+
working_directory=tmp_path,
|
|
30
|
+
home_directory=tmp_path,
|
|
31
|
+
)
|
|
36
32
|
response = json.loads(result.stdout)
|
|
37
33
|
assert response["hookSpecificOutput"]["permissionDecision"] == "ask", (
|
|
38
34
|
f"Expected ask for git commit --no-verify, got: {response!r}"
|
|
@@ -42,18 +38,26 @@ def test_asks_on_git_commit_no_verify() -> None:
|
|
|
42
38
|
)
|
|
43
39
|
|
|
44
40
|
|
|
45
|
-
def test_asks_on_git_push_no_verify() -> None:
|
|
46
|
-
|
|
47
|
-
|
|
41
|
+
def test_asks_on_git_push_no_verify(tmp_path: Path) -> None:
|
|
42
|
+
result = run_hook_as_subprocess(
|
|
43
|
+
hook_script_path=SCRIPT_PATH,
|
|
44
|
+
payload_text=build_bash_payload("git push --no-verify origin main"),
|
|
45
|
+
working_directory=tmp_path,
|
|
46
|
+
home_directory=tmp_path,
|
|
47
|
+
)
|
|
48
48
|
response = json.loads(result.stdout)
|
|
49
49
|
assert response["hookSpecificOutput"]["permissionDecision"] == "ask", (
|
|
50
50
|
f"Expected ask for git push --no-verify, got: {response!r}"
|
|
51
51
|
)
|
|
52
52
|
|
|
53
53
|
|
|
54
|
-
def test_asks_on_git_no_gpg_sign() -> None:
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
def test_asks_on_git_no_gpg_sign(tmp_path: Path) -> None:
|
|
55
|
+
result = run_hook_as_subprocess(
|
|
56
|
+
hook_script_path=SCRIPT_PATH,
|
|
57
|
+
payload_text=build_bash_payload("git commit --no-gpg-sign -m wip"),
|
|
58
|
+
working_directory=tmp_path,
|
|
59
|
+
home_directory=tmp_path,
|
|
60
|
+
)
|
|
57
61
|
response = json.loads(result.stdout)
|
|
58
62
|
assert response["hookSpecificOutput"]["permissionDecision"] == "ask", (
|
|
59
63
|
f"Expected ask for git --no-gpg-sign, got: {response!r}"
|
|
@@ -63,36 +67,52 @@ def test_asks_on_git_no_gpg_sign() -> None:
|
|
|
63
67
|
), f"Reason must mention --no-gpg-sign, got: {response!r}"
|
|
64
68
|
|
|
65
69
|
|
|
66
|
-
def test_asks_on_git_commit_with_no_gpg_sign_config() -> None:
|
|
67
|
-
|
|
68
|
-
|
|
70
|
+
def test_asks_on_git_commit_with_no_gpg_sign_config(tmp_path: Path) -> None:
|
|
71
|
+
result = run_hook_as_subprocess(
|
|
72
|
+
hook_script_path=SCRIPT_PATH,
|
|
73
|
+
payload_text=build_bash_payload("git -c commit.gpgsign=false commit -m wip"),
|
|
74
|
+
working_directory=tmp_path,
|
|
75
|
+
home_directory=tmp_path,
|
|
76
|
+
)
|
|
69
77
|
response = json.loads(result.stdout)
|
|
70
78
|
assert response["hookSpecificOutput"]["permissionDecision"] == "ask", (
|
|
71
79
|
f"Expected ask for -c commit.gpgsign=false, got: {response!r}"
|
|
72
80
|
)
|
|
73
81
|
|
|
74
82
|
|
|
75
|
-
def test_asks_on_quoted_gpgsign_config() -> None:
|
|
76
|
-
|
|
77
|
-
|
|
83
|
+
def test_asks_on_quoted_gpgsign_config(tmp_path: Path) -> None:
|
|
84
|
+
result = run_hook_as_subprocess(
|
|
85
|
+
hook_script_path=SCRIPT_PATH,
|
|
86
|
+
payload_text=build_bash_payload("git -c 'commit.gpgsign=false' commit -m wip"),
|
|
87
|
+
working_directory=tmp_path,
|
|
88
|
+
home_directory=tmp_path,
|
|
89
|
+
)
|
|
78
90
|
response = json.loads(result.stdout)
|
|
79
91
|
assert response["hookSpecificOutput"]["permissionDecision"] == "ask", (
|
|
80
92
|
f"Expected ask for quoted -c commit.gpgsign=false, got: {response!r}"
|
|
81
93
|
)
|
|
82
94
|
|
|
83
95
|
|
|
84
|
-
def test_asks_on_value_quoted_gpgsign_config() -> None:
|
|
85
|
-
|
|
86
|
-
|
|
96
|
+
def test_asks_on_value_quoted_gpgsign_config(tmp_path: Path) -> None:
|
|
97
|
+
result = run_hook_as_subprocess(
|
|
98
|
+
hook_script_path=SCRIPT_PATH,
|
|
99
|
+
payload_text=build_bash_payload("git -c commit.gpgsign='false' commit -m wip"),
|
|
100
|
+
working_directory=tmp_path,
|
|
101
|
+
home_directory=tmp_path,
|
|
102
|
+
)
|
|
87
103
|
response = json.loads(result.stdout)
|
|
88
104
|
assert response["hookSpecificOutput"]["permissionDecision"] == "ask", (
|
|
89
105
|
f"Expected ask for value-quoted -c commit.gpgsign='false', got: {response!r}"
|
|
90
106
|
)
|
|
91
107
|
|
|
92
108
|
|
|
93
|
-
def test_normal_git_commit_passes() -> None:
|
|
94
|
-
|
|
95
|
-
|
|
109
|
+
def test_normal_git_commit_passes(tmp_path: Path) -> None:
|
|
110
|
+
result = run_hook_as_subprocess(
|
|
111
|
+
hook_script_path=SCRIPT_PATH,
|
|
112
|
+
payload_text=build_bash_payload('git commit -m "real commit"'),
|
|
113
|
+
working_directory=tmp_path,
|
|
114
|
+
home_directory=tmp_path,
|
|
115
|
+
)
|
|
96
116
|
if not result.stdout.strip():
|
|
97
117
|
return
|
|
98
118
|
response = json.loads(result.stdout)
|
|
@@ -2,11 +2,63 @@
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
import os
|
|
5
|
+
import runpy
|
|
5
6
|
import subprocess
|
|
6
7
|
import sys
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
_local_hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
11
|
+
_all_local_import_directories = (
|
|
12
|
+
str(Path(_local_hooks_directory) / "blocking"),
|
|
13
|
+
_local_hooks_directory,
|
|
14
|
+
)
|
|
15
|
+
_all_normalized_local_import_directories = tuple(
|
|
16
|
+
os.path.normcase(os.path.realpath(each_directory))
|
|
17
|
+
for each_directory in _all_local_import_directories
|
|
18
|
+
)
|
|
19
|
+
_all_normalized_import_directories = tuple(
|
|
20
|
+
(
|
|
21
|
+
os.path.normcase(os.path.realpath(os.fspath(each_directory)))
|
|
22
|
+
if each_directory != "" and isinstance(each_directory, (str, os.PathLike))
|
|
23
|
+
else None
|
|
24
|
+
)
|
|
25
|
+
for each_directory in sys.path
|
|
26
|
+
)
|
|
27
|
+
_all_prioritized_import_directories = [
|
|
28
|
+
*_all_local_import_directories,
|
|
29
|
+
*(
|
|
30
|
+
each_directory
|
|
31
|
+
for each_directory, normalized_directory in zip(
|
|
32
|
+
sys.path,
|
|
33
|
+
_all_normalized_import_directories,
|
|
34
|
+
)
|
|
35
|
+
if normalized_directory not in _all_normalized_local_import_directories
|
|
36
|
+
),
|
|
37
|
+
]
|
|
38
|
+
if sys.path != _all_prioritized_import_directories:
|
|
39
|
+
sys.path[:] = _all_prioritized_import_directories
|
|
40
|
+
|
|
41
|
+
_all_local_module_prefixes = ("docstring_rule_gate_count_blocker", "hooks_constants")
|
|
42
|
+
for each_module_name, each_module in tuple(sys.modules.items()):
|
|
43
|
+
if not isinstance(each_module_name, str) or not any(
|
|
44
|
+
each_module_name == each_prefix or each_module_name.startswith(each_prefix + ".")
|
|
45
|
+
for each_prefix in _all_local_module_prefixes
|
|
46
|
+
):
|
|
47
|
+
continue
|
|
48
|
+
module_file = getattr(each_module, "__file__", None)
|
|
49
|
+
is_local_module = False
|
|
50
|
+
if isinstance(module_file, (str, os.PathLike)):
|
|
51
|
+
try:
|
|
52
|
+
is_local_module = Path(module_file).resolve().is_relative_to(_local_hooks_directory)
|
|
53
|
+
except (OSError, RuntimeError, TypeError, ValueError):
|
|
54
|
+
is_local_module = False
|
|
55
|
+
if not is_local_module:
|
|
56
|
+
sys.modules.pop(each_module_name, None)
|
|
57
|
+
|
|
58
|
+
del _all_local_module_prefixes
|
|
59
|
+
del _all_normalized_import_directories
|
|
60
|
+
del _all_normalized_local_import_directories
|
|
61
|
+
del _all_prioritized_import_directories
|
|
10
62
|
|
|
11
63
|
from docstring_rule_gate_count_blocker import (
|
|
12
64
|
find_gate_count_drift,
|
|
@@ -105,6 +157,66 @@ def _target_rule_path(tmp_path: Path) -> Path:
|
|
|
105
157
|
return tmp_path / TARGET_RULE_BASENAME
|
|
106
158
|
|
|
107
159
|
|
|
160
|
+
def _run_test_module_bootstrap(
|
|
161
|
+
all_import_directories: list[str], run_count: int
|
|
162
|
+
) -> list[str]:
|
|
163
|
+
"""Run this test module against a supplied import path.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
all_import_directories: Import path entries supplied to the module.
|
|
167
|
+
run_count: Number of module executions.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
Import path after the final module execution.
|
|
171
|
+
"""
|
|
172
|
+
all_original_import_directories = list(sys.path)
|
|
173
|
+
try:
|
|
174
|
+
sys.path[:] = all_import_directories
|
|
175
|
+
for each_run_number in range(run_count):
|
|
176
|
+
runpy.run_path(__file__, run_name=f"bootstrap_probe_{each_run_number}")
|
|
177
|
+
return list(sys.path)
|
|
178
|
+
finally:
|
|
179
|
+
sys.path[:] = all_original_import_directories
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def should_prioritize_local_import_directories_over_foreign_hooks(tmp_path: Path) -> None:
|
|
183
|
+
foreign_hooks_directory = str(tmp_path / "hooks")
|
|
184
|
+
all_import_directories = [
|
|
185
|
+
foreign_hooks_directory,
|
|
186
|
+
_local_hooks_directory,
|
|
187
|
+
"",
|
|
188
|
+
"tail",
|
|
189
|
+
]
|
|
190
|
+
|
|
191
|
+
assert _run_test_module_bootstrap(all_import_directories, 1) == [
|
|
192
|
+
*_all_local_import_directories,
|
|
193
|
+
foreign_hooks_directory,
|
|
194
|
+
"",
|
|
195
|
+
"tail",
|
|
196
|
+
]
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def should_prioritize_local_import_directories_idempotently(tmp_path: Path) -> None:
|
|
200
|
+
foreign_hooks_directory = str(tmp_path / "hooks")
|
|
201
|
+
all_import_directories = [
|
|
202
|
+
foreign_hooks_directory,
|
|
203
|
+
_local_hooks_directory,
|
|
204
|
+
_all_local_import_directories[0] + os.sep + ".",
|
|
205
|
+
_local_hooks_directory + os.sep + ".",
|
|
206
|
+
"tail",
|
|
207
|
+
]
|
|
208
|
+
|
|
209
|
+
all_import_directories_after_runs = _run_test_module_bootstrap(all_import_directories, 2)
|
|
210
|
+
|
|
211
|
+
assert all_import_directories_after_runs == [
|
|
212
|
+
*_all_local_import_directories,
|
|
213
|
+
foreign_hooks_directory,
|
|
214
|
+
"tail",
|
|
215
|
+
]
|
|
216
|
+
assert all_import_directories_after_runs.count(_all_local_import_directories[0]) == 1
|
|
217
|
+
assert all_import_directories_after_runs.count(_local_hooks_directory) == 1
|
|
218
|
+
|
|
219
|
+
|
|
108
220
|
def should_flag_target_rule_basename() -> None:
|
|
109
221
|
assert is_target_rule_file("/somewhere/" + TARGET_RULE_BASENAME) is True
|
|
110
222
|
|
|
@@ -8,17 +8,41 @@ environment variable to a code file whose source never references that variable.
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
+
import json
|
|
12
|
+
import subprocess
|
|
11
13
|
import sys
|
|
12
14
|
from pathlib import Path
|
|
13
15
|
|
|
14
|
-
|
|
15
|
-
if
|
|
16
|
-
sys.path.insert(0,
|
|
16
|
+
_BLOCKING_DIRECTORY = Path(__file__).resolve().parent
|
|
17
|
+
if str(_BLOCKING_DIRECTORY) not in sys.path:
|
|
18
|
+
sys.path.insert(0, str(_BLOCKING_DIRECTORY))
|
|
17
19
|
|
|
18
20
|
from env_var_table_code_drift_blocker import ( # noqa: E402
|
|
19
21
|
find_drift_rows,
|
|
20
22
|
is_markdown_file,
|
|
21
23
|
)
|
|
24
|
+
from test_hook_subprocess_support import ( # noqa: E402
|
|
25
|
+
assert_hook_deny_log_contains,
|
|
26
|
+
run_hook_as_subprocess,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
HOOK_PATH = _BLOCKING_DIRECTORY / "env_var_table_code_drift_blocker.py"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _run_hook(
|
|
33
|
+
file_path: Path, content: str, temporary_repository_root: Path
|
|
34
|
+
) -> subprocess.CompletedProcess[str]:
|
|
35
|
+
relative_file_path = file_path.relative_to(temporary_repository_root)
|
|
36
|
+
payload = {
|
|
37
|
+
"tool_name": "Write",
|
|
38
|
+
"tool_input": {"file_path": str(relative_file_path), "content": content},
|
|
39
|
+
}
|
|
40
|
+
return run_hook_as_subprocess(
|
|
41
|
+
hook_script_path=HOOK_PATH,
|
|
42
|
+
payload_text=json.dumps(payload),
|
|
43
|
+
working_directory=temporary_repository_root,
|
|
44
|
+
home_directory=temporary_repository_root,
|
|
45
|
+
)
|
|
22
46
|
|
|
23
47
|
|
|
24
48
|
def _write(file_path: Path, content: str) -> None:
|
|
@@ -64,6 +88,49 @@ def test_passes_when_variable_present_in_code_file(tmp_path: Path) -> None:
|
|
|
64
88
|
assert find_drift_rows(content, doc_path.parent) == []
|
|
65
89
|
|
|
66
90
|
|
|
91
|
+
def test_production_entrypoint_allows_aligned_table(tmp_path: Path) -> None:
|
|
92
|
+
_anchor_repo_root(tmp_path)
|
|
93
|
+
_write(
|
|
94
|
+
tmp_path / "automation_logging" / "__init__.py",
|
|
95
|
+
'topic = os.environ["NTFY_TOPIC"]\n',
|
|
96
|
+
)
|
|
97
|
+
doc_path = tmp_path / "docs" / "configuration.md"
|
|
98
|
+
doc_path.parent.mkdir()
|
|
99
|
+
content = (
|
|
100
|
+
"| Variable | Used By | Purpose |\n"
|
|
101
|
+
"|----------|---------|---------|\n"
|
|
102
|
+
"| `NTFY_TOPIC` | `automation_logging/__init__.py` | ntfy topic |\n"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
completed = _run_hook(doc_path, content, tmp_path)
|
|
106
|
+
|
|
107
|
+
assert completed.returncode == 0, completed.stderr
|
|
108
|
+
assert completed.stdout == ""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_production_entrypoint_denies_drifted_table(tmp_path: Path) -> None:
|
|
112
|
+
_anchor_repo_root(tmp_path)
|
|
113
|
+
_write(tmp_path / "auth" / "google_auth.py", "def load():\n return read_bws_secret()\n")
|
|
114
|
+
doc_path = tmp_path / "docs" / "configuration.md"
|
|
115
|
+
doc_path.parent.mkdir()
|
|
116
|
+
content = (
|
|
117
|
+
"| Variable | Used By | Purpose |\n"
|
|
118
|
+
"|----------|---------|---------|\n"
|
|
119
|
+
"| `GOOGLE_APPLICATION_CREDENTIALS` | `auth/google_auth.py` | Path to JSON |\n"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
completed = _run_hook(doc_path, content, tmp_path)
|
|
123
|
+
|
|
124
|
+
parsed_payload = json.loads(completed.stdout)
|
|
125
|
+
hook_specific_output = parsed_payload["hookSpecificOutput"]
|
|
126
|
+
assert completed.returncode == 0, completed.stderr
|
|
127
|
+
assert hook_specific_output["permissionDecision"] == "deny"
|
|
128
|
+
assert "GOOGLE_APPLICATION_CREDENTIALS -> auth/google_auth.py" in hook_specific_output[
|
|
129
|
+
"permissionDecisionReason"
|
|
130
|
+
]
|
|
131
|
+
assert_hook_deny_log_contains(tmp_path, "env_var_table_code_drift_blocker.py")
|
|
132
|
+
|
|
133
|
+
|
|
67
134
|
def test_skips_row_whose_code_file_is_absent(tmp_path: Path) -> None:
|
|
68
135
|
_anchor_repo_root(tmp_path)
|
|
69
136
|
doc_path = tmp_path / "docs" / "configuration.md"
|
|
@@ -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}
|