claude-dev-env 1.94.0 → 1.95.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/_shared/advisor/CLAUDE.md +2 -2
- package/_shared/advisor/advisor-protocol.md +35 -27
- package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +3 -2
- package/_shared/advisor/scripts/model_tier_run_validator.py +23 -15
- package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +81 -17
- package/bin/CLAUDE.md +10 -1
- package/bin/ever-shipped-skills.mjs +70 -0
- package/bin/install.mjs +136 -6
- package/bin/install.prune.test.mjs +457 -0
- package/docs/CODE_RULES.md +1 -1
- package/hooks/blocking/code_rules_enforcer.py +4 -0
- package/hooks/blocking/code_rules_shared.py +82 -0
- package/hooks/blocking/code_rules_test_layout.py +9 -3
- package/hooks/blocking/plain_language_blocker.py +138 -4
- package/hooks/blocking/sensitive_file_protector.py +114 -48
- package/hooks/blocking/tdd_enforcer.py +9 -2
- package/hooks/blocking/test_code_rules_enforcer_scratchpad.py +105 -0
- package/hooks/blocking/test_code_rules_shared.py +181 -0
- package/hooks/blocking/test_plain_language_blocker_allowlist.py +184 -0
- package/hooks/blocking/test_sensitive_file_protector.py +185 -0
- package/hooks/blocking/test_tdd_enforcer_scratchpad.py +105 -0
- package/hooks/hooks_constants/CLAUDE.md +2 -0
- package/hooks/hooks_constants/harness_scratchpad_constants.py +17 -0
- package/hooks/hooks_constants/plain_language_blocker_constants.py +5 -0
- package/hooks/hooks_constants/sensitive_file_protector_constants.py +42 -0
- package/hooks/pyproject.toml +75 -4
- package/hooks/validators/CLAUDE.md +1 -1
- package/hooks/validators/README.md +2 -0
- package/hooks/validators/python_style_checks.py +114 -136
- package/hooks/validators/python_style_helpers.py +95 -0
- package/hooks/validators/test_python_style_checks.py +0 -164
- package/hooks/validators/test_python_style_checks_decorator_gap.py +119 -0
- package/hooks/validators/test_python_style_fixes.py +251 -0
- package/hooks/validators/test_python_style_helpers.py +125 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/anti-corollary-tests.md +69 -0
- package/rules/bdd.md +1 -3
- package/rules/code-reviews.md +1 -1
- package/rules/gh-paginate.md +1 -1
- package/rules/plain-language.md +2 -0
- package/skills/CLAUDE.md +4 -3
- package/skills/autoconverge/workflow/converge.mjs +2 -2
- package/skills/bugteam/reference/README.md +2 -3
- package/skills/closeout/SKILL.md +153 -0
- package/skills/closeout/reference/handoff-prompt-template.md +72 -0
- package/skills/closeout/reference/issue-body-templates.md +108 -0
- package/skills/closeout/reference/pii-redaction-checklist.md +36 -0
- package/skills/orchestrator/SKILL.md +27 -21
- package/skills/orchestrator-refresh/SKILL.md +12 -8
- package/skills/pr-converge/CLAUDE.md +1 -1
- package/skills/pr-fix-protocol/SKILL.md +65 -0
- package/skills/skill-builder/references/skill-modularity.md +1 -1
- package/skills/team-advisor/SKILL.md +15 -11
- package/system-prompts/software-engineer.xml +7 -6
- package/hooks/validators/test_verify_paths.py +0 -32
- package/hooks/validators/verify_paths.py +0 -57
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Tests for the harness session scratchpad exemption in code_rules_shared."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import os
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from types import ModuleType
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
SHARED_MODULE_PATH = Path(__file__).parent / "code_rules_shared.py"
|
|
12
|
+
FIXED_USER_ID = 4242
|
|
13
|
+
WORKING_DIRECTORY = "/home/user/project"
|
|
14
|
+
SESSION_ID = "session-abc-123"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _load_shared_module() -> ModuleType:
|
|
18
|
+
module_spec = importlib.util.spec_from_file_location(
|
|
19
|
+
"code_rules_shared_under_test", SHARED_MODULE_PATH
|
|
20
|
+
)
|
|
21
|
+
assert module_spec is not None and module_spec.loader is not None
|
|
22
|
+
loaded_module = importlib.util.module_from_spec(module_spec)
|
|
23
|
+
module_spec.loader.exec_module(loaded_module)
|
|
24
|
+
return loaded_module
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_SHARED_MODULE = _load_shared_module()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _install_fixed_user_id(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
31
|
+
monkeypatch.setattr(os, "getuid", lambda: FIXED_USER_ID, raising=False)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _point_temporary_directory_at(monkeypatch: pytest.MonkeyPatch, temporary_root: Path) -> None:
|
|
35
|
+
monkeypatch.setattr(tempfile, "gettempdir", lambda: str(temporary_root))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _build_scratchpad_directory(temporary_root: Path) -> Path:
|
|
39
|
+
mangled_working_directory = WORKING_DIRECTORY.replace("/", "-")
|
|
40
|
+
scratchpad_directory = (
|
|
41
|
+
temporary_root
|
|
42
|
+
/ f"claude-{FIXED_USER_ID}"
|
|
43
|
+
/ mangled_working_directory
|
|
44
|
+
/ SESSION_ID
|
|
45
|
+
/ "scratchpad"
|
|
46
|
+
)
|
|
47
|
+
scratchpad_directory.mkdir(parents=True)
|
|
48
|
+
return scratchpad_directory
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _session_payload() -> dict[str, str]:
|
|
52
|
+
return {"cwd": WORKING_DIRECTORY, "session_id": SESSION_ID}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_returns_true_for_file_inside_reconstructed_scratchpad(
|
|
56
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
57
|
+
) -> None:
|
|
58
|
+
_install_fixed_user_id(monkeypatch)
|
|
59
|
+
_point_temporary_directory_at(monkeypatch, tmp_path)
|
|
60
|
+
scratchpad_directory = _build_scratchpad_directory(tmp_path)
|
|
61
|
+
throwaway_script = scratchpad_directory / "one_off_tool.py"
|
|
62
|
+
|
|
63
|
+
assert (
|
|
64
|
+
_SHARED_MODULE.is_under_session_scratchpad(str(throwaway_script), _session_payload())
|
|
65
|
+
is True
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_returns_false_for_file_outside_scratchpad(
|
|
70
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
71
|
+
) -> None:
|
|
72
|
+
_install_fixed_user_id(monkeypatch)
|
|
73
|
+
_point_temporary_directory_at(monkeypatch, tmp_path)
|
|
74
|
+
_build_scratchpad_directory(tmp_path)
|
|
75
|
+
project_module = tmp_path / "elsewhere" / "orders.py"
|
|
76
|
+
|
|
77
|
+
assert (
|
|
78
|
+
_SHARED_MODULE.is_under_session_scratchpad(str(project_module), _session_payload()) is False
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_resolves_symlink_into_scratchpad_to_true(
|
|
83
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
84
|
+
) -> None:
|
|
85
|
+
_install_fixed_user_id(monkeypatch)
|
|
86
|
+
_point_temporary_directory_at(monkeypatch, tmp_path)
|
|
87
|
+
scratchpad_directory = _build_scratchpad_directory(tmp_path)
|
|
88
|
+
real_script = scratchpad_directory / "real_tool.py"
|
|
89
|
+
real_script.write_text("value = 1\n")
|
|
90
|
+
link_outside = tmp_path / "link_to_scratchpad.py"
|
|
91
|
+
link_outside.symlink_to(real_script)
|
|
92
|
+
|
|
93
|
+
assert _SHARED_MODULE.is_under_session_scratchpad(str(link_outside), _session_payload()) is True
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_symlink_resolving_outside_scratchpad_is_false(
|
|
97
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
98
|
+
) -> None:
|
|
99
|
+
_install_fixed_user_id(monkeypatch)
|
|
100
|
+
_point_temporary_directory_at(monkeypatch, tmp_path)
|
|
101
|
+
scratchpad_directory = _build_scratchpad_directory(tmp_path)
|
|
102
|
+
real_target_outside = tmp_path / "outside_target.py"
|
|
103
|
+
real_target_outside.write_text("value = 1\n")
|
|
104
|
+
link_inside_scratchpad = scratchpad_directory / "sneaky_link.py"
|
|
105
|
+
link_inside_scratchpad.symlink_to(real_target_outside)
|
|
106
|
+
|
|
107
|
+
assert (
|
|
108
|
+
_SHARED_MODULE.is_under_session_scratchpad(str(link_inside_scratchpad), _session_payload())
|
|
109
|
+
is False
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_missing_session_id_signal_returns_false(
|
|
114
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
115
|
+
) -> None:
|
|
116
|
+
_install_fixed_user_id(monkeypatch)
|
|
117
|
+
_point_temporary_directory_at(monkeypatch, tmp_path)
|
|
118
|
+
scratchpad_directory = _build_scratchpad_directory(tmp_path)
|
|
119
|
+
throwaway_script = scratchpad_directory / "one_off_tool.py"
|
|
120
|
+
payload_without_session = {"cwd": WORKING_DIRECTORY}
|
|
121
|
+
|
|
122
|
+
assert (
|
|
123
|
+
_SHARED_MODULE.is_under_session_scratchpad(str(throwaway_script), payload_without_session)
|
|
124
|
+
is False
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_missing_working_directory_signal_returns_false(
|
|
129
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
130
|
+
) -> None:
|
|
131
|
+
_install_fixed_user_id(monkeypatch)
|
|
132
|
+
_point_temporary_directory_at(monkeypatch, tmp_path)
|
|
133
|
+
scratchpad_directory = _build_scratchpad_directory(tmp_path)
|
|
134
|
+
throwaway_script = scratchpad_directory / "one_off_tool.py"
|
|
135
|
+
payload_without_cwd = {"session_id": SESSION_ID}
|
|
136
|
+
|
|
137
|
+
assert (
|
|
138
|
+
_SHARED_MODULE.is_under_session_scratchpad(str(throwaway_script), payload_without_cwd)
|
|
139
|
+
is False
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_absent_getuid_returns_false(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
144
|
+
_install_fixed_user_id(monkeypatch)
|
|
145
|
+
_point_temporary_directory_at(monkeypatch, tmp_path)
|
|
146
|
+
scratchpad_directory = _build_scratchpad_directory(tmp_path)
|
|
147
|
+
throwaway_script = scratchpad_directory / "one_off_tool.py"
|
|
148
|
+
monkeypatch.delattr(os, "getuid", raising=False)
|
|
149
|
+
|
|
150
|
+
assert (
|
|
151
|
+
_SHARED_MODULE.is_under_session_scratchpad(str(throwaway_script), _session_payload())
|
|
152
|
+
is False
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_nonexistent_scratchpad_directory_returns_false(
|
|
157
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
158
|
+
) -> None:
|
|
159
|
+
_install_fixed_user_id(monkeypatch)
|
|
160
|
+
_point_temporary_directory_at(monkeypatch, tmp_path)
|
|
161
|
+
mangled_working_directory = WORKING_DIRECTORY.replace("/", "-")
|
|
162
|
+
unbuilt_target = (
|
|
163
|
+
tmp_path
|
|
164
|
+
/ f"claude-{FIXED_USER_ID}"
|
|
165
|
+
/ mangled_working_directory
|
|
166
|
+
/ SESSION_ID
|
|
167
|
+
/ "scratchpad"
|
|
168
|
+
/ "one_off_tool.py"
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
assert (
|
|
172
|
+
_SHARED_MODULE.is_under_session_scratchpad(str(unbuilt_target), _session_payload()) is False
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_empty_file_path_returns_false(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
177
|
+
_install_fixed_user_id(monkeypatch)
|
|
178
|
+
_point_temporary_directory_at(monkeypatch, tmp_path)
|
|
179
|
+
_build_scratchpad_directory(tmp_path)
|
|
180
|
+
|
|
181
|
+
assert _SHARED_MODULE.is_under_session_scratchpad("", _session_payload()) is False
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Tests for the per-project domain-vocabulary allowlist in plain_language_blocker."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from types import ModuleType
|
|
7
|
+
|
|
8
|
+
BLOCKER_PATH = Path(__file__).parent / "plain_language_blocker.py"
|
|
9
|
+
ALLOWLIST_RELATIVE_PATH = Path(".claude") / "plain-language-allow.json"
|
|
10
|
+
ALWAYS_HEAVY_WORD = "utilize"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _load_blocker() -> ModuleType:
|
|
14
|
+
module_spec = importlib.util.spec_from_file_location(
|
|
15
|
+
"plain_language_blocker_allowlist_under_test", BLOCKER_PATH
|
|
16
|
+
)
|
|
17
|
+
assert module_spec is not None and module_spec.loader is not None
|
|
18
|
+
loaded_module = importlib.util.module_from_spec(module_spec)
|
|
19
|
+
module_spec.loader.exec_module(loaded_module)
|
|
20
|
+
return loaded_module
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_BLOCKER = _load_blocker()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _init_repo(root: Path) -> Path:
|
|
27
|
+
(root / ".git").mkdir(parents=True, exist_ok=True)
|
|
28
|
+
return root
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _write_allowlist(project_root: Path, all_words: list[str]) -> None:
|
|
32
|
+
allowlist_path = project_root / ALLOWLIST_RELATIVE_PATH
|
|
33
|
+
allowlist_path.parent.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
allowlist_path.write_text(json.dumps(all_words), encoding="utf-8")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _write_raw_allowlist(project_root: Path, raw_text: str) -> None:
|
|
38
|
+
allowlist_path = project_root / ALLOWLIST_RELATIVE_PATH
|
|
39
|
+
allowlist_path.parent.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
allowlist_path.write_text(raw_text, encoding="utf-8")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _markdown_write_payload(project_root: Path, prose: str) -> dict[str, object]:
|
|
44
|
+
document_path = project_root / "docs" / "notes.md"
|
|
45
|
+
return {
|
|
46
|
+
"tool_name": "Write",
|
|
47
|
+
"cwd": str(project_root),
|
|
48
|
+
"tool_input": {"file_path": str(document_path), "content": prose},
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _ask_user_question_payload(project_root: Path, prose: str) -> dict[str, object]:
|
|
53
|
+
return {
|
|
54
|
+
"tool_name": "AskUserQuestion",
|
|
55
|
+
"cwd": str(project_root),
|
|
56
|
+
"tool_input": {"questions": [{"question": prose, "options": []}]},
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_allowlisted_word_passes_in_markdown_write(tmp_path: Path) -> None:
|
|
61
|
+
prose = "Please submit the release notes."
|
|
62
|
+
project_without_allowlist = _init_repo(tmp_path / "control")
|
|
63
|
+
project_with_allowlist = _init_repo(tmp_path / "domain")
|
|
64
|
+
_write_allowlist(project_with_allowlist, ["submit"])
|
|
65
|
+
|
|
66
|
+
control_deny_reason = _BLOCKER.evaluate(
|
|
67
|
+
_markdown_write_payload(project_without_allowlist, prose)
|
|
68
|
+
)
|
|
69
|
+
allowlisted_deny_reason = _BLOCKER.evaluate(
|
|
70
|
+
_markdown_write_payload(project_with_allowlist, prose)
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
assert control_deny_reason is not None and "submit" in control_deny_reason
|
|
74
|
+
assert allowlisted_deny_reason is None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_allowlisted_word_passes_in_ask_user_question(tmp_path: Path) -> None:
|
|
78
|
+
prose = "Which theme should we identify first?"
|
|
79
|
+
project_without_allowlist = _init_repo(tmp_path / "control")
|
|
80
|
+
project_with_allowlist = _init_repo(tmp_path / "domain")
|
|
81
|
+
_write_allowlist(project_with_allowlist, ["identify"])
|
|
82
|
+
|
|
83
|
+
control_deny_reason = _BLOCKER.evaluate(
|
|
84
|
+
_ask_user_question_payload(project_without_allowlist, prose)
|
|
85
|
+
)
|
|
86
|
+
allowlisted_deny_reason = _BLOCKER.evaluate(
|
|
87
|
+
_ask_user_question_payload(project_with_allowlist, prose)
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
assert control_deny_reason is not None and "identify" in control_deny_reason
|
|
91
|
+
assert allowlisted_deny_reason is None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_non_allowlisted_heavy_word_still_blocked(tmp_path: Path) -> None:
|
|
95
|
+
_init_repo(tmp_path)
|
|
96
|
+
_write_allowlist(tmp_path, ["submit"])
|
|
97
|
+
|
|
98
|
+
deny_reason = _BLOCKER.evaluate(
|
|
99
|
+
_markdown_write_payload(tmp_path, f"Please {ALWAYS_HEAVY_WORD} the cache now.")
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
assert deny_reason is not None and ALWAYS_HEAVY_WORD in deny_reason
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_allowlist_match_is_case_insensitive(tmp_path: Path) -> None:
|
|
106
|
+
prose = "Please SUBMIT the release notes."
|
|
107
|
+
project_without_allowlist = _init_repo(tmp_path / "control")
|
|
108
|
+
project_with_allowlist = _init_repo(tmp_path / "domain")
|
|
109
|
+
_write_allowlist(project_with_allowlist, ["Submit"])
|
|
110
|
+
|
|
111
|
+
control_deny_reason = _BLOCKER.evaluate(
|
|
112
|
+
_markdown_write_payload(project_without_allowlist, prose)
|
|
113
|
+
)
|
|
114
|
+
allowlisted_deny_reason = _BLOCKER.evaluate(
|
|
115
|
+
_markdown_write_payload(project_with_allowlist, prose)
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
assert control_deny_reason is not None and "submit" in control_deny_reason
|
|
119
|
+
assert allowlisted_deny_reason is None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_malformed_allowlist_json_is_ignored(tmp_path: Path) -> None:
|
|
123
|
+
_init_repo(tmp_path)
|
|
124
|
+
_write_raw_allowlist(tmp_path, "{ this is not valid json ")
|
|
125
|
+
|
|
126
|
+
deny_reason = _BLOCKER.evaluate(
|
|
127
|
+
_markdown_write_payload(tmp_path, "Please submit the release notes.")
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
assert deny_reason is not None and "submit" in deny_reason
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_allowlist_in_a_different_project_root_is_not_applied(tmp_path: Path) -> None:
|
|
134
|
+
project_with_allowlist = _init_repo(tmp_path / "project_a")
|
|
135
|
+
project_without_allowlist = _init_repo(tmp_path / "project_b")
|
|
136
|
+
_write_allowlist(project_with_allowlist, ["submit"])
|
|
137
|
+
|
|
138
|
+
deny_reason = _BLOCKER.evaluate(
|
|
139
|
+
_markdown_write_payload(project_without_allowlist, "Please submit the notes.")
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
assert deny_reason is not None and "submit" in deny_reason
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_allowlist_above_the_repo_root_is_not_applied(tmp_path: Path) -> None:
|
|
146
|
+
_write_allowlist(tmp_path, ["submit"])
|
|
147
|
+
repository_root = _init_repo(tmp_path / "repo")
|
|
148
|
+
|
|
149
|
+
deny_reason = _BLOCKER.evaluate(
|
|
150
|
+
_markdown_write_payload(repository_root, "Please submit the notes.")
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
assert deny_reason is not None and "submit" in deny_reason
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_allowlist_at_the_repo_root_is_applied(tmp_path: Path) -> None:
|
|
157
|
+
repository_root = _init_repo(tmp_path / "repo")
|
|
158
|
+
_write_allowlist(repository_root, ["submit"])
|
|
159
|
+
|
|
160
|
+
deny_reason = _BLOCKER.evaluate(
|
|
161
|
+
_markdown_write_payload(repository_root, "Please submit the notes.")
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
assert deny_reason is None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_allowlist_without_a_repo_root_is_not_applied(tmp_path: Path) -> None:
|
|
168
|
+
_write_allowlist(tmp_path, ["submit"])
|
|
169
|
+
|
|
170
|
+
deny_reason = _BLOCKER.evaluate(
|
|
171
|
+
_markdown_write_payload(tmp_path, "Please submit the notes.")
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
assert deny_reason is not None and "submit" in deny_reason
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_find_banned_terms_skips_allowlisted_terms() -> None:
|
|
178
|
+
matched_without_allowlist = _BLOCKER.find_banned_terms("Please submit the notes.")
|
|
179
|
+
matched_with_allowlist = _BLOCKER.find_banned_terms(
|
|
180
|
+
"Please submit the notes.", frozenset({"submit"})
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
assert any(each_term == "submit" for each_term, _ in matched_without_allowlist)
|
|
184
|
+
assert all(each_term != "submit" for each_term, _ in matched_with_allowlist)
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Decision tests for the sensitive-file filename guard.
|
|
2
|
+
|
|
3
|
+
Each case drives the real hook script through its production ``__main__``
|
|
4
|
+
stdin path, feeding it the PreToolUse JSON payload Claude Code sends and
|
|
5
|
+
reading the permission decision back off stdout.
|
|
6
|
+
|
|
7
|
+
The guard denies a filename that names a live secret or a lock file, and
|
|
8
|
+
steps aside for a placeholders-only committed template (``.env.example``
|
|
9
|
+
and its ``.sample`` / ``.template`` spellings). The template exemption is
|
|
10
|
+
narrow: ``.env.local`` is a ``.env.*`` filename that is not a template, and
|
|
11
|
+
it stays denied.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
import pytest
|
|
22
|
+
|
|
23
|
+
BLOCKING_DIRECTORY = Path(__file__).resolve().parent
|
|
24
|
+
HOOK_SCRIPT_PATH = BLOCKING_DIRECTORY / "sensitive_file_protector.py"
|
|
25
|
+
|
|
26
|
+
WRITE_TOOL_NAME = "Write"
|
|
27
|
+
EDIT_TOOL_NAME = "Edit"
|
|
28
|
+
READ_TOOL_NAME = "Read"
|
|
29
|
+
|
|
30
|
+
DENY_DECISION = "deny"
|
|
31
|
+
|
|
32
|
+
PLACEHOLDER_TEMPLATE_BODY = "API_TOKEN=your-token-here\nDATABASE_HOST=localhost\n"
|
|
33
|
+
ORDINARY_SOURCE_BODY = "def add(left: int, right: int) -> int:\n return left + right\n"
|
|
34
|
+
|
|
35
|
+
ALL_TEMPLATE_FILENAMES = (
|
|
36
|
+
".env.example",
|
|
37
|
+
".env.sample",
|
|
38
|
+
".env.template",
|
|
39
|
+
".ENV.Example",
|
|
40
|
+
"settings.json.template",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
ALL_DENIED_FILENAMES = (
|
|
44
|
+
".env",
|
|
45
|
+
".env.local",
|
|
46
|
+
".env.production",
|
|
47
|
+
"credentials.json",
|
|
48
|
+
"secrets.json",
|
|
49
|
+
"id_rsa",
|
|
50
|
+
"id_ed25519",
|
|
51
|
+
"server.pem",
|
|
52
|
+
"signing.key",
|
|
53
|
+
"bundle.p12",
|
|
54
|
+
"bundle.pfx",
|
|
55
|
+
"package-lock.json",
|
|
56
|
+
"yarn.lock",
|
|
57
|
+
"Pipfile.lock",
|
|
58
|
+
"poetry.lock",
|
|
59
|
+
"pnpm-lock.yaml",
|
|
60
|
+
"composer.lock",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
ALL_UPPERCASE_DENIED_FILENAMES = (".ENV", "ID_RSA", "CREDENTIALS.JSON", "SERVER.PEM")
|
|
64
|
+
|
|
65
|
+
ALL_ORDINARY_FILENAMES = ("main.py", "README.md", "settings.json")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _run_hook(tool_name: str, file_path: Path, content: str) -> subprocess.CompletedProcess:
|
|
69
|
+
payload = json.dumps(
|
|
70
|
+
{
|
|
71
|
+
"tool_name": tool_name,
|
|
72
|
+
"tool_input": {"file_path": str(file_path), "content": content},
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
return _run_hook_with_stdin(payload)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _run_hook_with_stdin(stdin_text: str) -> subprocess.CompletedProcess:
|
|
79
|
+
return subprocess.run(
|
|
80
|
+
[sys.executable, str(HOOK_SCRIPT_PATH)],
|
|
81
|
+
input=stdin_text,
|
|
82
|
+
capture_output=True,
|
|
83
|
+
text=True,
|
|
84
|
+
check=False,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _permission_decision(completed: subprocess.CompletedProcess) -> str | None:
|
|
89
|
+
if not completed.stdout.strip():
|
|
90
|
+
return None
|
|
91
|
+
parsed_decision = json.loads(completed.stdout)
|
|
92
|
+
return parsed_decision["hookSpecificOutput"]["permissionDecision"]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _deny_reason(completed: subprocess.CompletedProcess) -> str:
|
|
96
|
+
parsed_decision = json.loads(completed.stdout)
|
|
97
|
+
return parsed_decision["hookSpecificOutput"]["permissionDecisionReason"]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@pytest.mark.parametrize("template_filename", ALL_TEMPLATE_FILENAMES)
|
|
101
|
+
def test_template_filename_is_allowed(tmp_path: Path, template_filename: str) -> None:
|
|
102
|
+
completed = _run_hook(WRITE_TOOL_NAME, tmp_path / template_filename, PLACEHOLDER_TEMPLATE_BODY)
|
|
103
|
+
assert completed.returncode == 0, completed.stderr
|
|
104
|
+
assert _permission_decision(completed) is None, (
|
|
105
|
+
f"{template_filename} is a committed template and must not be denied by filename; "
|
|
106
|
+
f"got stdout {completed.stdout!r}"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@pytest.mark.parametrize("sensitive_filename", ALL_DENIED_FILENAMES)
|
|
111
|
+
def test_sensitive_filename_is_denied(tmp_path: Path, sensitive_filename: str) -> None:
|
|
112
|
+
completed = _run_hook(WRITE_TOOL_NAME, tmp_path / sensitive_filename, PLACEHOLDER_TEMPLATE_BODY)
|
|
113
|
+
assert completed.returncode == 0, completed.stderr
|
|
114
|
+
assert _permission_decision(completed) == DENY_DECISION, (
|
|
115
|
+
f"{sensitive_filename} must stay denied; got stdout {completed.stdout!r}"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@pytest.mark.parametrize("uppercase_filename", ALL_UPPERCASE_DENIED_FILENAMES)
|
|
120
|
+
def test_uppercase_sensitive_filename_is_denied(tmp_path: Path, uppercase_filename: str) -> None:
|
|
121
|
+
completed = _run_hook(WRITE_TOOL_NAME, tmp_path / uppercase_filename, PLACEHOLDER_TEMPLATE_BODY)
|
|
122
|
+
assert completed.returncode == 0, completed.stderr
|
|
123
|
+
assert _permission_decision(completed) == DENY_DECISION, (
|
|
124
|
+
f"{uppercase_filename} names a secret in an uppercase spelling and must stay denied like "
|
|
125
|
+
f"its lowercase form; got stdout {completed.stdout!r}"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@pytest.mark.parametrize("ordinary_filename", ALL_ORDINARY_FILENAMES)
|
|
130
|
+
def test_ordinary_source_file_is_allowed(tmp_path: Path, ordinary_filename: str) -> None:
|
|
131
|
+
completed = _run_hook(WRITE_TOOL_NAME, tmp_path / ordinary_filename, ORDINARY_SOURCE_BODY)
|
|
132
|
+
assert completed.returncode == 0, completed.stderr
|
|
133
|
+
assert _permission_decision(completed) is None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_dot_env_local_deny_reason_names_the_file_and_the_pattern(tmp_path: Path) -> None:
|
|
137
|
+
completed = _run_hook(WRITE_TOOL_NAME, tmp_path / ".env.local", PLACEHOLDER_TEMPLATE_BODY)
|
|
138
|
+
assert _permission_decision(completed) == DENY_DECISION
|
|
139
|
+
deny_reason = _deny_reason(completed)
|
|
140
|
+
assert ".env.local" in deny_reason
|
|
141
|
+
assert ".env.*" in deny_reason
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_edit_to_a_template_is_allowed(tmp_path: Path) -> None:
|
|
145
|
+
completed = _run_hook(EDIT_TOOL_NAME, tmp_path / ".env.example", PLACEHOLDER_TEMPLATE_BODY)
|
|
146
|
+
assert completed.returncode == 0, completed.stderr
|
|
147
|
+
assert _permission_decision(completed) is None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_edit_to_a_secrets_file_is_denied(tmp_path: Path) -> None:
|
|
151
|
+
completed = _run_hook(EDIT_TOOL_NAME, tmp_path / ".env", PLACEHOLDER_TEMPLATE_BODY)
|
|
152
|
+
assert completed.returncode == 0, completed.stderr
|
|
153
|
+
assert _permission_decision(completed) == DENY_DECISION
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_read_tool_is_not_evaluated(tmp_path: Path) -> None:
|
|
157
|
+
completed = _run_hook(READ_TOOL_NAME, tmp_path / ".env", PLACEHOLDER_TEMPLATE_BODY)
|
|
158
|
+
assert completed.returncode == 0, completed.stderr
|
|
159
|
+
assert _permission_decision(completed) is None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_payload_without_a_file_path_is_allowed() -> None:
|
|
163
|
+
completed = _run_hook_with_stdin(json.dumps({"tool_name": WRITE_TOOL_NAME, "tool_input": {}}))
|
|
164
|
+
assert completed.returncode == 0, completed.stderr
|
|
165
|
+
assert _permission_decision(completed) is None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_malformed_payload_fails_open() -> None:
|
|
169
|
+
completed = _run_hook_with_stdin("{not valid json")
|
|
170
|
+
assert completed.returncode == 0, completed.stderr
|
|
171
|
+
assert completed.stdout.strip() == ""
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@pytest.mark.parametrize("trailing_suffix_filename", (".env.example.bak", ".env.template.old", ".env.sample.orig"))
|
|
175
|
+
def test_template_suffix_must_be_last_to_earn_the_exemption(
|
|
176
|
+
tmp_path: Path, trailing_suffix_filename: str
|
|
177
|
+
) -> None:
|
|
178
|
+
completed = _run_hook(
|
|
179
|
+
WRITE_TOOL_NAME, tmp_path / trailing_suffix_filename, PLACEHOLDER_TEMPLATE_BODY
|
|
180
|
+
)
|
|
181
|
+
assert completed.returncode == 0, completed.stderr
|
|
182
|
+
assert _permission_decision(completed) == DENY_DECISION, (
|
|
183
|
+
f"{trailing_suffix_filename} is a copy of a live secrets file, not a template; "
|
|
184
|
+
f"only a trailing template suffix earns the exemption, got {completed.stdout!r}"
|
|
185
|
+
)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Tests for the session-scratchpad exemption wired into tdd_enforcer.main."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from types import ModuleType
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
TDD_ENFORCER_PATH = Path(__file__).parent / "tdd_enforcer.py"
|
|
15
|
+
FIXED_USER_ID = 5150
|
|
16
|
+
WORKING_DIRECTORY = "/home/user/project"
|
|
17
|
+
SESSION_ID = "tdd-session-987"
|
|
18
|
+
PRODUCTION_CONTENT = "def fulfill_order():\n return True\n"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _load_tdd_enforcer() -> ModuleType:
|
|
22
|
+
module_spec = importlib.util.spec_from_file_location(
|
|
23
|
+
"tdd_enforcer_scratchpad_under_test", TDD_ENFORCER_PATH
|
|
24
|
+
)
|
|
25
|
+
assert module_spec is not None and module_spec.loader is not None
|
|
26
|
+
loaded_module = importlib.util.module_from_spec(module_spec)
|
|
27
|
+
module_spec.loader.exec_module(loaded_module)
|
|
28
|
+
return loaded_module
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_TDD_ENFORCER = _load_tdd_enforcer()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _install_scratchpad_signals(monkeypatch: pytest.MonkeyPatch, temporary_root: Path) -> Path:
|
|
35
|
+
monkeypatch.setattr(os, "getuid", lambda: FIXED_USER_ID, raising=False)
|
|
36
|
+
monkeypatch.setattr(tempfile, "gettempdir", lambda: str(temporary_root))
|
|
37
|
+
mangled_working_directory = WORKING_DIRECTORY.replace("/", "-")
|
|
38
|
+
scratchpad_directory = (
|
|
39
|
+
temporary_root
|
|
40
|
+
/ f"claude-{FIXED_USER_ID}"
|
|
41
|
+
/ mangled_working_directory
|
|
42
|
+
/ SESSION_ID
|
|
43
|
+
/ "scratchpad"
|
|
44
|
+
)
|
|
45
|
+
scratchpad_directory.mkdir(parents=True)
|
|
46
|
+
return scratchpad_directory
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _write_payload(target: Path) -> dict[str, object]:
|
|
50
|
+
return {
|
|
51
|
+
"tool_name": "Write",
|
|
52
|
+
"cwd": WORKING_DIRECTORY,
|
|
53
|
+
"session_id": SESSION_ID,
|
|
54
|
+
"tool_input": {"file_path": str(target), "content": PRODUCTION_CONTENT},
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _run_main(
|
|
59
|
+
monkeypatch: pytest.MonkeyPatch, payload: dict[str, object]
|
|
60
|
+
) -> tuple[int | None, str]:
|
|
61
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload)))
|
|
62
|
+
captured_stdout = io.StringIO()
|
|
63
|
+
monkeypatch.setattr(sys, "stdout", captured_stdout)
|
|
64
|
+
exit_code: int | None = None
|
|
65
|
+
try:
|
|
66
|
+
_TDD_ENFORCER.main()
|
|
67
|
+
except SystemExit as raised_exit:
|
|
68
|
+
raw_code = raised_exit.code
|
|
69
|
+
exit_code = raw_code if isinstance(raw_code, int) else None
|
|
70
|
+
return exit_code, captured_stdout.getvalue()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _decision_from(stdout_text: str) -> str | None:
|
|
74
|
+
if not stdout_text.strip():
|
|
75
|
+
return None
|
|
76
|
+
parsed = json.loads(stdout_text)
|
|
77
|
+
return parsed.get("hookSpecificOutput", {}).get("permissionDecision")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_scratchpad_production_write_is_exempt_from_tdd_gate(
|
|
81
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
82
|
+
) -> None:
|
|
83
|
+
scratchpad_directory = _install_scratchpad_signals(monkeypatch, tmp_path)
|
|
84
|
+
monkeypatch.setenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", "1")
|
|
85
|
+
throwaway_script = scratchpad_directory / "one_off_probe.py"
|
|
86
|
+
|
|
87
|
+
exit_code, stdout_text = _run_main(monkeypatch, _write_payload(throwaway_script))
|
|
88
|
+
|
|
89
|
+
assert exit_code == 0
|
|
90
|
+
assert _decision_from(stdout_text) != "deny"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_identical_non_scratchpad_write_is_still_blocked(
|
|
94
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
95
|
+
) -> None:
|
|
96
|
+
_install_scratchpad_signals(monkeypatch, tmp_path)
|
|
97
|
+
monkeypatch.setenv("CLAUDE_CODE_RULES_DISABLE_EPHEMERAL_EXEMPT", "1")
|
|
98
|
+
outside_directory = tmp_path / "project" / "orders"
|
|
99
|
+
outside_directory.mkdir(parents=True)
|
|
100
|
+
production_module = outside_directory / "one_off_probe.py"
|
|
101
|
+
|
|
102
|
+
exit_code, stdout_text = _run_main(monkeypatch, _write_payload(production_module))
|
|
103
|
+
|
|
104
|
+
assert exit_code == 0
|
|
105
|
+
assert _decision_from(stdout_text) == "deny"
|
|
@@ -32,6 +32,7 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
|
|
|
32
32
|
| `dynamic_stderr_handler.py` | `DynamicStderrHandler` — a logging handler that resolves `sys.stderr` at emit time (for testability) |
|
|
33
33
|
| `gh_pr_author_swap_constants.py` | Constants for the PR-author swap enforcement hooks |
|
|
34
34
|
| `hardcoded_user_path_constants.py` | Patterns for detecting hardcoded home-directory paths |
|
|
35
|
+
| `harness_scratchpad_constants.py` | Fixed path components (`claude-` user-directory prefix, separator replacement, `scratchpad` leaf name) and payload keys that rebuild the harness session scratchpad directory for the code-rules and TDD gate exemptions |
|
|
35
36
|
| `hook_block_logger.py` | `log_hook_block()` — shared fail-safe logger every blocking hook calls to append a JSON record of each block decision to `~/.claude/logs/hook-blocks.log` |
|
|
36
37
|
| `hook_log_extractor_constants.py` | Neon table name, offset state file path, timeouts, and outcome-type mapping for the hook-log extractor |
|
|
37
38
|
| `hook_prose_detector_consistency_constants.py` | Trigger patterns and corrective messages for the hook-prose consistency checker |
|
|
@@ -60,6 +61,7 @@ Shared constant modules imported by hooks throughout the `hooks/` tree. Each fil
|
|
|
60
61
|
| `python_style_checks_constants.py` | Command-line argument count and blank-line count between top-level functions for the style validator |
|
|
61
62
|
| `reviewer_spawn_gate_constants.py` | Bash tool name, the sentinel marker, the Copilot and Bugbot trigger markers, the availability-script relative path and override env-var name, and the deny-message template for the reviewer-spawn gate |
|
|
62
63
|
| `send_user_file_open_locally_blocker_constants.py` | Tool name, proactive status, and the block message for the open-locally attach blocker |
|
|
64
|
+
| `sensitive_file_protector_constants.py` | Sensitive filename patterns, the committed-template suffixes that earn an exemption, the write/edit tool names, and the deny decision and message template for `sensitive_file_protector` |
|
|
63
65
|
| `session_edit_stage_gate_constants.py` | Tracker filename prefix/suffix, JSON payload key, edit tool name set, session-id sanitize pattern, lock filename suffix and lock-acquire timing, git diff command, commit flag escapes, and deny-message template shared by the session edit stage gate trio |
|
|
64
66
|
| `session_env_cleanup_constants.py` | Stale-age threshold and directory names for the session-env cleanup hook |
|
|
65
67
|
| `session_handoff_blocker_constants.py` | Trigger phrases for the session-handoff blocker |
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Constants for resolving the Claude Code harness session scratchpad directory.
|
|
2
|
+
|
|
3
|
+
The harness gives each session a throwaway scratch directory it announces in
|
|
4
|
+
the session's system prompt, laid out as
|
|
5
|
+
``<tempdir>/claude-<uid>/<mangled-cwd>/<session-id>/scratchpad``. These constants
|
|
6
|
+
name the fixed path components the code-rules and TDD gates read to rebuild that
|
|
7
|
+
directory from the signals a hook process can see.
|
|
8
|
+
|
|
9
|
+
The exemption is POSIX-only: without ``os.getuid`` (Windows) it never fires,
|
|
10
|
+
so full enforcement stays in place there.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
HARNESS_SCRATCHPAD_USER_DIRECTORY_PREFIX: str = "claude-"
|
|
14
|
+
HARNESS_SCRATCHPAD_PATH_SEPARATOR_REPLACEMENT: str = "-"
|
|
15
|
+
HARNESS_SCRATCHPAD_LEAF_DIRECTORY_NAME: str = "scratchpad"
|
|
16
|
+
HOOK_PAYLOAD_SESSION_ID_KEY: str = "session_id"
|
|
17
|
+
HOOK_PAYLOAD_WORKING_DIRECTORY_KEY: str = "cwd"
|