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.
Files changed (57) hide show
  1. package/_shared/advisor/CLAUDE.md +2 -2
  2. package/_shared/advisor/advisor-protocol.md +35 -27
  3. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +3 -2
  4. package/_shared/advisor/scripts/model_tier_run_validator.py +23 -15
  5. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +81 -17
  6. package/bin/CLAUDE.md +10 -1
  7. package/bin/ever-shipped-skills.mjs +70 -0
  8. package/bin/install.mjs +136 -6
  9. package/bin/install.prune.test.mjs +457 -0
  10. package/docs/CODE_RULES.md +1 -1
  11. package/hooks/blocking/code_rules_enforcer.py +4 -0
  12. package/hooks/blocking/code_rules_shared.py +82 -0
  13. package/hooks/blocking/code_rules_test_layout.py +9 -3
  14. package/hooks/blocking/plain_language_blocker.py +138 -4
  15. package/hooks/blocking/sensitive_file_protector.py +114 -48
  16. package/hooks/blocking/tdd_enforcer.py +9 -2
  17. package/hooks/blocking/test_code_rules_enforcer_scratchpad.py +105 -0
  18. package/hooks/blocking/test_code_rules_shared.py +181 -0
  19. package/hooks/blocking/test_plain_language_blocker_allowlist.py +184 -0
  20. package/hooks/blocking/test_sensitive_file_protector.py +185 -0
  21. package/hooks/blocking/test_tdd_enforcer_scratchpad.py +105 -0
  22. package/hooks/hooks_constants/CLAUDE.md +2 -0
  23. package/hooks/hooks_constants/harness_scratchpad_constants.py +17 -0
  24. package/hooks/hooks_constants/plain_language_blocker_constants.py +5 -0
  25. package/hooks/hooks_constants/sensitive_file_protector_constants.py +42 -0
  26. package/hooks/pyproject.toml +75 -4
  27. package/hooks/validators/CLAUDE.md +1 -1
  28. package/hooks/validators/README.md +2 -0
  29. package/hooks/validators/python_style_checks.py +114 -136
  30. package/hooks/validators/python_style_helpers.py +95 -0
  31. package/hooks/validators/test_python_style_checks.py +0 -164
  32. package/hooks/validators/test_python_style_checks_decorator_gap.py +119 -0
  33. package/hooks/validators/test_python_style_fixes.py +251 -0
  34. package/hooks/validators/test_python_style_helpers.py +125 -0
  35. package/package.json +1 -1
  36. package/rules/CLAUDE.md +1 -0
  37. package/rules/anti-corollary-tests.md +69 -0
  38. package/rules/bdd.md +1 -3
  39. package/rules/code-reviews.md +1 -1
  40. package/rules/gh-paginate.md +1 -1
  41. package/rules/plain-language.md +2 -0
  42. package/skills/CLAUDE.md +4 -3
  43. package/skills/autoconverge/workflow/converge.mjs +2 -2
  44. package/skills/bugteam/reference/README.md +2 -3
  45. package/skills/closeout/SKILL.md +153 -0
  46. package/skills/closeout/reference/handoff-prompt-template.md +72 -0
  47. package/skills/closeout/reference/issue-body-templates.md +108 -0
  48. package/skills/closeout/reference/pii-redaction-checklist.md +36 -0
  49. package/skills/orchestrator/SKILL.md +27 -21
  50. package/skills/orchestrator-refresh/SKILL.md +12 -8
  51. package/skills/pr-converge/CLAUDE.md +1 -1
  52. package/skills/pr-fix-protocol/SKILL.md +65 -0
  53. package/skills/skill-builder/references/skill-modularity.md +1 -1
  54. package/skills/team-advisor/SKILL.md +15 -11
  55. package/system-prompts/software-engineer.xml +7 -6
  56. package/hooks/validators/test_verify_paths.py +0 -32
  57. 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"