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
package/docs/CODE_RULES.md
CHANGED
|
@@ -94,4 +94,4 @@ If you already have the data, don't fetch it again.
|
|
|
94
94
|
|
|
95
95
|
## 11. ENFORCEMENT SURFACES
|
|
96
96
|
|
|
97
|
-
⚡ **Hooks** block pattern-matchable violations at Write/Edit time. 🤖 **Prompt context** carries judgment principles (SRP, Right-Sized Engineering, conservative-action, BDD discovery, docstring-prose-matches-implementation
|
|
97
|
+
⚡ **Hooks** block pattern-matchable violations at Write/Edit time. 🤖 **Prompt context** carries judgment principles (SRP, Right-Sized Engineering, conservative-action, BDD discovery, docstring-prose-matches-implementation). 👥 **Audit rubrics** (`/check`, `packages/claude-dev-env/audit-rubrics/` categories A–Q) cover cross-file architectural concerns. Rules with documented-but-pending hook coverage live in `~/.claude/rules/*.md`; each names its own promotion path. The docstring-prose standard (free-form enumerations match the body) lives in `packages/claude-dev-env/rules/docstring-prose-matches-implementation.md`, enforced via Category O6 audit. The diagram-first docstring standard (a summary line, then a `::` example or doctest, then a couple of short prose lines) lives in `packages/claude-dev-env/rules/plain-illustrative-docstrings.md`, enforced by the `check_docstring_runon_sentence` and `check_docstring_prose_wall_without_illustration` backstop hooks and Category O9 audit.
|
|
@@ -158,6 +158,7 @@ from code_rules_shared import ( # noqa: E402
|
|
|
158
158
|
is_ephemeral_script_path,
|
|
159
159
|
is_hook_infrastructure,
|
|
160
160
|
is_test_file,
|
|
161
|
+
is_under_session_scratchpad,
|
|
161
162
|
)
|
|
162
163
|
from code_rules_string_magic import ( # noqa: E402
|
|
163
164
|
check_inline_literal_collections,
|
|
@@ -1104,6 +1105,9 @@ def main(all_arguments: list[str]) -> None:
|
|
|
1104
1105
|
tool_input = pretooluse_payload.get("tool_input", {})
|
|
1105
1106
|
file_path = tool_input.get("file_path", "")
|
|
1106
1107
|
|
|
1108
|
+
if is_under_session_scratchpad(file_path, pretooluse_payload):
|
|
1109
|
+
sys.exit(0)
|
|
1110
|
+
|
|
1107
1111
|
runs_full_verdict = _is_validated_target(file_path)
|
|
1108
1112
|
if not runs_full_verdict and not _is_hook_infrastructure_python_target(file_path):
|
|
1109
1113
|
sys.exit(0)
|
|
@@ -4,6 +4,7 @@ import ast
|
|
|
4
4
|
import difflib
|
|
5
5
|
import os
|
|
6
6
|
import sys
|
|
7
|
+
import tempfile
|
|
7
8
|
from collections.abc import Collection, Iterator
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
|
|
@@ -29,6 +30,13 @@ from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
|
29
30
|
LEADING_DRIVE_LETTER_PATTERN,
|
|
30
31
|
STRICT_TEST_FILE_BASENAME_PATTERN,
|
|
31
32
|
)
|
|
33
|
+
from hooks_constants.harness_scratchpad_constants import ( # noqa: E402
|
|
34
|
+
HARNESS_SCRATCHPAD_LEAF_DIRECTORY_NAME,
|
|
35
|
+
HARNESS_SCRATCHPAD_PATH_SEPARATOR_REPLACEMENT,
|
|
36
|
+
HARNESS_SCRATCHPAD_USER_DIRECTORY_PREFIX,
|
|
37
|
+
HOOK_PAYLOAD_SESSION_ID_KEY,
|
|
38
|
+
HOOK_PAYLOAD_WORKING_DIRECTORY_KEY,
|
|
39
|
+
)
|
|
32
40
|
from hooks_constants.unused_module_import_constants import ( # noqa: E402
|
|
33
41
|
TYPE_CHECKING_IDENTIFIER,
|
|
34
42
|
)
|
|
@@ -267,6 +275,80 @@ def is_ephemeral_script_path(file_path: str) -> bool:
|
|
|
267
275
|
return False
|
|
268
276
|
|
|
269
277
|
|
|
278
|
+
def _resolve_session_scratchpad_root(hook_payload: dict) -> str | None:
|
|
279
|
+
"""Rebuild the harness session scratchpad directory from a PreToolUse payload.
|
|
280
|
+
|
|
281
|
+
::
|
|
282
|
+
|
|
283
|
+
payload.cwd = /home/user/project payload.session_id = 5f2c...
|
|
284
|
+
| |
|
|
285
|
+
<tempdir>/claude-<uid>/-home-user-project/5f2c.../scratchpad
|
|
286
|
+
| |
|
|
287
|
+
os.getuid() cwd with each "/" turned to "-"
|
|
288
|
+
|
|
289
|
+
No environment variable carries this path, so it is rebuilt from the three
|
|
290
|
+
signals a hook can read: the POSIX user id and the ``cwd`` and ``session_id``
|
|
291
|
+
fields the harness puts in every PreToolUse payload. The rebuilt directory
|
|
292
|
+
is returned only when it exists on disk, so a wrong guess or a non-POSIX
|
|
293
|
+
platform yields None and the gates keep full enforcement.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
hook_payload: The PreToolUse payload carrying ``cwd`` and ``session_id``.
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
The scratchpad directory path when it exists on disk, else None.
|
|
300
|
+
"""
|
|
301
|
+
get_user_id = getattr(os, "getuid", None)
|
|
302
|
+
if get_user_id is None:
|
|
303
|
+
return None
|
|
304
|
+
session_id = hook_payload.get(HOOK_PAYLOAD_SESSION_ID_KEY, "")
|
|
305
|
+
working_directory = hook_payload.get(HOOK_PAYLOAD_WORKING_DIRECTORY_KEY, "")
|
|
306
|
+
if not isinstance(session_id, str) or not session_id:
|
|
307
|
+
return None
|
|
308
|
+
if not isinstance(working_directory, str) or not working_directory:
|
|
309
|
+
return None
|
|
310
|
+
mangled_working_directory = working_directory.replace("\\", "/").replace(
|
|
311
|
+
"/", HARNESS_SCRATCHPAD_PATH_SEPARATOR_REPLACEMENT
|
|
312
|
+
)
|
|
313
|
+
user_directory_name = f"{HARNESS_SCRATCHPAD_USER_DIRECTORY_PREFIX}{get_user_id()}"
|
|
314
|
+
scratchpad_root = os.path.join(
|
|
315
|
+
tempfile.gettempdir(),
|
|
316
|
+
user_directory_name,
|
|
317
|
+
mangled_working_directory,
|
|
318
|
+
session_id,
|
|
319
|
+
HARNESS_SCRATCHPAD_LEAF_DIRECTORY_NAME,
|
|
320
|
+
)
|
|
321
|
+
if not os.path.isdir(scratchpad_root):
|
|
322
|
+
return None
|
|
323
|
+
return scratchpad_root
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def is_under_session_scratchpad(file_path: str, hook_payload: dict) -> bool:
|
|
327
|
+
"""Return True when file_path resolves under the harness session scratchpad.
|
|
328
|
+
|
|
329
|
+
One-off scripts written to the session scratchpad are throwaway tooling
|
|
330
|
+
outside every repo, so the TDD and CODE_RULES gates skip them. Both
|
|
331
|
+
file_path and the rebuilt scratchpad root are resolved through the real
|
|
332
|
+
filesystem (symlinks followed) before the containment test, so a symlink
|
|
333
|
+
into the scratchpad exempts and a symlink out of it does not.
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
file_path: The path the write targets.
|
|
337
|
+
hook_payload: The PreToolUse payload carrying ``cwd`` and ``session_id``.
|
|
338
|
+
|
|
339
|
+
Returns:
|
|
340
|
+
True when file_path's real path sits at or under the session scratchpad.
|
|
341
|
+
"""
|
|
342
|
+
if not file_path:
|
|
343
|
+
return False
|
|
344
|
+
scratchpad_root = _resolve_session_scratchpad_root(hook_payload)
|
|
345
|
+
if scratchpad_root is None:
|
|
346
|
+
return False
|
|
347
|
+
real_target = os.path.realpath(file_path)
|
|
348
|
+
real_root = os.path.realpath(scratchpad_root)
|
|
349
|
+
return real_target == real_root or real_target.startswith(real_root + os.sep)
|
|
350
|
+
|
|
351
|
+
|
|
270
352
|
def is_migration_file(file_path: str) -> bool:
|
|
271
353
|
"""Check if file is a Django migration (must be self-contained)."""
|
|
272
354
|
path_lower = file_path.lower().replace("\\", "/")
|
|
@@ -133,7 +133,11 @@ def _referenced_names(tree: ast.Module) -> set[str]:
|
|
|
133
133
|
}
|
|
134
134
|
return load_names | literal_values
|
|
135
135
|
|
|
136
|
-
def _dead_constant_messages(
|
|
136
|
+
def _dead_constant_messages(
|
|
137
|
+
tree: ast.Module,
|
|
138
|
+
referenced_names: set[str],
|
|
139
|
+
maximum_issues: int,
|
|
140
|
+
) -> list[str]:
|
|
137
141
|
"""Return one message per module constant absent from the referenced set."""
|
|
138
142
|
issues: list[str] = []
|
|
139
143
|
for each_name, each_line in _module_constant_targets(tree):
|
|
@@ -142,7 +146,7 @@ def _dead_constant_messages(tree: ast.Module, referenced_names: set[str]) -> lis
|
|
|
142
146
|
issues.append(
|
|
143
147
|
f"Line {each_line}: constant {each_name!r} - {DEAD_TEST_CONSTANT_GUIDANCE}"
|
|
144
148
|
)
|
|
145
|
-
if len(issues) >=
|
|
149
|
+
if len(issues) >= maximum_issues:
|
|
146
150
|
break
|
|
147
151
|
return issues
|
|
148
152
|
|
|
@@ -174,7 +178,9 @@ def check_dead_test_module_constant(content: str, file_path: str) -> list[str]:
|
|
|
174
178
|
tree = _parse_module(content)
|
|
175
179
|
if tree is None:
|
|
176
180
|
return []
|
|
177
|
-
return _dead_constant_messages(
|
|
181
|
+
return _dead_constant_messages(
|
|
182
|
+
tree, _referenced_names(tree), MAX_TEST_LAYOUT_ISSUES
|
|
183
|
+
)
|
|
178
184
|
|
|
179
185
|
def _node_names_fixture(node: ast.AST) -> bool:
|
|
180
186
|
"""Return whether one decorator sub-node spells the pytest fixture marker."""
|
|
@@ -26,10 +26,14 @@ from hooks_constants.plain_language_blocker_constants import ( # noqa: E402
|
|
|
26
26
|
ALL_WRITE_EDIT_TOOL_NAMES,
|
|
27
27
|
ASK_USER_QUESTION_TOOL_NAME,
|
|
28
28
|
BLOCKQUOTE_LINE_PATTERN,
|
|
29
|
+
DOT_CLAUDE_DIRECTORY_NAME,
|
|
29
30
|
FENCED_CODE_BLOCK_PATTERN,
|
|
30
31
|
FILE_PATH_PATTERN,
|
|
31
32
|
INLINE_CODE_PATTERN,
|
|
32
33
|
MARKDOWN_EXTENSION,
|
|
34
|
+
PROJECT_ALLOWLIST_FILENAME,
|
|
35
|
+
PROJECT_ROOT_WALK_LIMIT,
|
|
36
|
+
REPOSITORY_MARKER_NAME,
|
|
33
37
|
URL_PATTERN,
|
|
34
38
|
USER_FACING_PLAIN_LANGUAGE_NOTICE,
|
|
35
39
|
)
|
|
@@ -49,13 +53,23 @@ def strip_non_prose_regions(text: str) -> str:
|
|
|
49
53
|
return without_paths
|
|
50
54
|
|
|
51
55
|
|
|
52
|
-
def find_banned_terms(
|
|
56
|
+
def find_banned_terms(
|
|
57
|
+
text: str, all_allowlisted_terms: frozenset[str] = frozenset()
|
|
58
|
+
) -> list[tuple[str, str]]:
|
|
53
59
|
"""Return each (matched term, suggested replacement) found in the prose.
|
|
54
60
|
|
|
55
61
|
Each term appears at most once, in first-seen order. Matching is
|
|
56
62
|
case-insensitive and respects word boundaries; multi-word phrases match as
|
|
57
|
-
whole units. Terms in the software-term allowlist
|
|
58
|
-
flagged.
|
|
63
|
+
whole units. Terms in the software-term allowlist and terms in the
|
|
64
|
+
caller-supplied per-project allowlist are exempt and never flagged.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
text: The prose to scan.
|
|
68
|
+
all_allowlisted_terms: Lowercased project-vocabulary terms to exempt, on top
|
|
69
|
+
of the built-in software-term allowlist.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
The (matched term, suggested replacement) pairs, in first-seen order.
|
|
59
73
|
"""
|
|
60
74
|
prose_text = strip_non_prose_regions(text)
|
|
61
75
|
all_matches: list[tuple[str, str]] = []
|
|
@@ -69,11 +83,128 @@ def find_banned_terms(text: str) -> list[tuple[str, str]]:
|
|
|
69
83
|
continue
|
|
70
84
|
if normalized_term in ALL_SOFTWARE_TERMS:
|
|
71
85
|
continue
|
|
86
|
+
if normalized_term in all_allowlisted_terms:
|
|
87
|
+
continue
|
|
72
88
|
seen_terms.add(normalized_term)
|
|
73
89
|
all_matches.append((normalized_term, each_replacement))
|
|
74
90
|
return all_matches
|
|
75
91
|
|
|
76
92
|
|
|
93
|
+
def _allowlist_start_directory(
|
|
94
|
+
tool_name: str, tool_input: dict, payload_by_key: dict[str, object]
|
|
95
|
+
) -> Path | None:
|
|
96
|
+
"""Return the directory to begin the project-allowlist search from.
|
|
97
|
+
|
|
98
|
+
A Write/Edit/MultiEdit on a file starts at that file's parent directory; an
|
|
99
|
+
AskUserQuestion (or a write with no path) starts at the session working
|
|
100
|
+
directory the payload carries.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
tool_name: The intercepted tool's name.
|
|
104
|
+
tool_input: The intercepted tool's input payload.
|
|
105
|
+
payload_by_key: The full PreToolUse payload carrying ``cwd``.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
The starting directory, or None when neither a file path nor a working
|
|
109
|
+
directory is available.
|
|
110
|
+
"""
|
|
111
|
+
if tool_name in ALL_WRITE_EDIT_TOOL_NAMES:
|
|
112
|
+
file_path = tool_input.get("file_path", "")
|
|
113
|
+
if isinstance(file_path, str) and file_path:
|
|
114
|
+
return Path(file_path).parent
|
|
115
|
+
working_directory = payload_by_key.get("cwd", "")
|
|
116
|
+
if isinstance(working_directory, str) and working_directory:
|
|
117
|
+
return Path(working_directory)
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _find_project_allowlist_file(start_directory: Path) -> Path | None:
|
|
122
|
+
"""Walk ancestors from start_directory for a repo-scoped project allowlist file.
|
|
123
|
+
|
|
124
|
+
::
|
|
125
|
+
|
|
126
|
+
parent/ .claude/allow -> ignored (above the repo root)
|
|
127
|
+
parent/repo/ <- .git .claude/allow -> applied (repo root reached)
|
|
128
|
+
parent/repo/pkg/ .claude/allow -> applied (inside the repo tree)
|
|
129
|
+
|
|
130
|
+
The allowlist must live inside the repository so it is reviewed like any
|
|
131
|
+
other committed config. The walk checks each directory for the allowlist,
|
|
132
|
+
then for the ``.git`` repository marker; it accepts an allowlist at or below
|
|
133
|
+
the first ``.git``-bearing directory, stops at that repository root, and
|
|
134
|
+
never ascends past it. When no ``.git`` marker appears within the walk
|
|
135
|
+
limit, the directory is not inside a repository and no allowlist applies, so
|
|
136
|
+
a global file such as ``~/.claude/plain-language-allow.json`` never loosens
|
|
137
|
+
the gate for every project.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
start_directory: The directory to begin the upward walk from.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
The nearest in-repository ``.claude/plain-language-allow.json`` at or
|
|
144
|
+
below the repository root, or None when the walk finds no repository
|
|
145
|
+
root or no allowlist within it.
|
|
146
|
+
"""
|
|
147
|
+
nearest_allowlist: Path | None = None
|
|
148
|
+
current_directory = start_directory
|
|
149
|
+
for _ in range(PROJECT_ROOT_WALK_LIMIT):
|
|
150
|
+
candidate = current_directory / DOT_CLAUDE_DIRECTORY_NAME / PROJECT_ALLOWLIST_FILENAME
|
|
151
|
+
if nearest_allowlist is None and candidate.is_file():
|
|
152
|
+
nearest_allowlist = candidate
|
|
153
|
+
if (current_directory / REPOSITORY_MARKER_NAME).exists():
|
|
154
|
+
return nearest_allowlist
|
|
155
|
+
if current_directory.parent == current_directory:
|
|
156
|
+
break
|
|
157
|
+
current_directory = current_directory.parent
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _parse_project_allowlist_file(allowlist_path: Path) -> frozenset[str]:
|
|
162
|
+
"""Read a JSON array of allowlist words into a lowercased term set.
|
|
163
|
+
|
|
164
|
+
Malformed JSON, an unreadable file, or any non-list shape yields an empty
|
|
165
|
+
set, so the hook falls back to the standard check rather than crashing.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
allowlist_path: The allowlist file to read.
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
The lowercased string entries, or an empty set on any read/parse fault.
|
|
172
|
+
"""
|
|
173
|
+
try:
|
|
174
|
+
raw_text = allowlist_path.read_text(encoding="utf-8")
|
|
175
|
+
parsed_entries = json.loads(raw_text)
|
|
176
|
+
except (OSError, ValueError):
|
|
177
|
+
return frozenset()
|
|
178
|
+
if not isinstance(parsed_entries, list):
|
|
179
|
+
return frozenset()
|
|
180
|
+
return frozenset(
|
|
181
|
+
each_entry.lower() for each_entry in parsed_entries if isinstance(each_entry, str)
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _load_project_allowlist(
|
|
186
|
+
tool_name: str, tool_input: dict, payload_by_key: dict[str, object]
|
|
187
|
+
) -> frozenset[str]:
|
|
188
|
+
"""Load the per-project domain-vocabulary allowlist for the write's project.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
tool_name: The intercepted tool's name.
|
|
192
|
+
tool_input: The intercepted tool's input payload.
|
|
193
|
+
payload_by_key: The full PreToolUse payload carrying ``cwd``.
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
The lowercased allowlist terms for the project the write targets, or an
|
|
197
|
+
empty set when no allowlist applies.
|
|
198
|
+
"""
|
|
199
|
+
start_directory = _allowlist_start_directory(tool_name, tool_input, payload_by_key)
|
|
200
|
+
if start_directory is None:
|
|
201
|
+
return frozenset()
|
|
202
|
+
allowlist_path = _find_project_allowlist_file(start_directory)
|
|
203
|
+
if allowlist_path is None:
|
|
204
|
+
return frozenset()
|
|
205
|
+
return _parse_project_allowlist_file(allowlist_path)
|
|
206
|
+
|
|
207
|
+
|
|
77
208
|
def build_block_reason(all_matches: list[tuple[str, str]]) -> str:
|
|
78
209
|
"""Return a deny reason naming each flagged term and its plain replacement."""
|
|
79
210
|
swap_phrases = ", ".join(
|
|
@@ -197,7 +328,10 @@ def evaluate(payload_by_key: dict[str, object]) -> str | None:
|
|
|
197
328
|
if not prose_text:
|
|
198
329
|
return None
|
|
199
330
|
|
|
200
|
-
|
|
331
|
+
all_allowlisted_terms = _load_project_allowlist(
|
|
332
|
+
raw_tool_name, raw_tool_input, payload_by_key
|
|
333
|
+
)
|
|
334
|
+
all_matches = find_banned_terms(prose_text, all_allowlisted_terms)
|
|
201
335
|
if not all_matches:
|
|
202
336
|
return None
|
|
203
337
|
|
|
@@ -1,48 +1,129 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
+
"""Deny a Write or Edit whose target filename names a secret or a lock file.
|
|
3
|
+
|
|
4
|
+
::
|
|
5
|
+
|
|
6
|
+
.env flag: a live secrets file
|
|
7
|
+
.env.local flag: a live per-machine secrets file
|
|
8
|
+
credentials.json flag: a live credential file
|
|
9
|
+
id_ed25519 flag: a live private key
|
|
10
|
+
package-lock.json flag: regenerated by its package manager, never hand-edited
|
|
11
|
+
|
|
12
|
+
.env.example ok: a placeholders-only template, committed on purpose
|
|
13
|
+
.env.sample ok: the same shape, .sample spelling
|
|
14
|
+
.env.template ok: the same shape, .template spelling
|
|
15
|
+
main.py ok: ordinary source
|
|
16
|
+
|
|
17
|
+
A basename whose FINAL suffix is ``.example``, ``.sample``, or ``.template``
|
|
18
|
+
names a committed template, so the filename guard steps aside for it. The
|
|
19
|
+
suffix has to come last: ``.env.example.bak`` is a copy of a live secrets
|
|
20
|
+
file, not a template, and stays denied.
|
|
21
|
+
|
|
22
|
+
The guard over a write's contents stays in force: the ``pii_prevention_blocker``
|
|
23
|
+
hook scans what a write carries, so a template holding a real secret or a real
|
|
24
|
+
email address is denied on its contents. The two hooks are layered, and this
|
|
25
|
+
one rules on the filename alone.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
2
30
|
import fnmatch
|
|
3
31
|
import json
|
|
4
32
|
import os
|
|
5
33
|
import sys
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
"
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
34
|
+
|
|
35
|
+
import _path_setup # noqa: F401
|
|
36
|
+
|
|
37
|
+
from hooks_constants.hook_block_logger import log_hook_block
|
|
38
|
+
from hooks_constants.sensitive_file_protector_constants import (
|
|
39
|
+
ALL_SENSITIVE_PATTERNS,
|
|
40
|
+
ALL_TEMPLATE_SUFFIXES,
|
|
41
|
+
ALL_WRITE_EDIT_TOOLS,
|
|
42
|
+
DENY_DECISION,
|
|
43
|
+
DENY_REASON_TEMPLATE,
|
|
44
|
+
HOOK_EVENT_NAME,
|
|
45
|
+
HOOK_SCRIPT_NAME,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def is_template_filename(filename: str) -> bool:
|
|
50
|
+
"""Report whether a basename names a placeholders-only committed template.
|
|
51
|
+
|
|
52
|
+
::
|
|
53
|
+
|
|
54
|
+
.env.example -> True
|
|
55
|
+
.ENV.Template -> True
|
|
56
|
+
.env.example.bak -> False
|
|
57
|
+
.env.local -> False
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
filename: The basename of the file a Write or Edit targets.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
True when the basename's final suffix is a template suffix.
|
|
64
|
+
"""
|
|
65
|
+
return filename.lower().endswith(ALL_TEMPLATE_SUFFIXES)
|
|
35
66
|
|
|
36
67
|
|
|
37
68
|
def is_sensitive_file(file_path: str) -> str | None:
|
|
69
|
+
"""Return the sensitive pattern a path's basename matches, or None.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
file_path: The path the Write or Edit targets.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
The matched pattern, or None when the basename names a template or
|
|
76
|
+
matches no pattern.
|
|
77
|
+
"""
|
|
38
78
|
filename = os.path.basename(file_path)
|
|
39
|
-
|
|
40
|
-
|
|
79
|
+
if is_template_filename(filename):
|
|
80
|
+
return None
|
|
81
|
+
for each_pattern in ALL_SENSITIVE_PATTERNS:
|
|
82
|
+
if fnmatch.fnmatch(filename.lower(), each_pattern.lower()):
|
|
41
83
|
return each_pattern
|
|
42
84
|
return None
|
|
43
85
|
|
|
44
86
|
|
|
87
|
+
def build_deny_response(deny_reason: str) -> dict[str, dict[str, str]]:
|
|
88
|
+
"""Wrap a deny reason in the PreToolUse decision payload Claude Code reads.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
deny_reason: The operator-facing sentence explaining the denial.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
The hook decision dictionary carrying the deny permission decision.
|
|
95
|
+
"""
|
|
96
|
+
return {
|
|
97
|
+
"hookSpecificOutput": {
|
|
98
|
+
"hookEventName": HOOK_EVENT_NAME,
|
|
99
|
+
"permissionDecision": DENY_DECISION,
|
|
100
|
+
"permissionDecisionReason": deny_reason,
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def deny_write(file_path: str, matched_pattern: str) -> None:
|
|
106
|
+
"""Record the block in the hook-blocks log and emit the deny decision.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
file_path: The path the Write or Edit targets.
|
|
110
|
+
matched_pattern: The sensitive pattern the basename matched.
|
|
111
|
+
"""
|
|
112
|
+
deny_reason = DENY_REASON_TEMPLATE.format(
|
|
113
|
+
filename=os.path.basename(file_path),
|
|
114
|
+
matched_pattern=matched_pattern,
|
|
115
|
+
)
|
|
116
|
+
log_hook_block(
|
|
117
|
+
calling_hook_name=HOOK_SCRIPT_NAME,
|
|
118
|
+
hook_event=HOOK_EVENT_NAME,
|
|
119
|
+
block_reason=deny_reason,
|
|
120
|
+
offending_input_preview=file_path,
|
|
121
|
+
)
|
|
122
|
+
sys.stdout.write(json.dumps(build_deny_response(deny_reason)))
|
|
123
|
+
|
|
124
|
+
|
|
45
125
|
def main() -> None:
|
|
126
|
+
"""Deny the write when the stdin payload targets a sensitive filename."""
|
|
46
127
|
try:
|
|
47
128
|
hook_input = json.load(sys.stdin)
|
|
48
129
|
except json.JSONDecodeError:
|
|
@@ -51,7 +132,7 @@ def main() -> None:
|
|
|
51
132
|
tool_name = hook_input.get("tool_name", "")
|
|
52
133
|
tool_input = hook_input.get("tool_input", {})
|
|
53
134
|
|
|
54
|
-
if tool_name not in
|
|
135
|
+
if tool_name not in ALL_WRITE_EDIT_TOOLS:
|
|
55
136
|
sys.exit(0)
|
|
56
137
|
|
|
57
138
|
file_path = tool_input.get("file_path", "")
|
|
@@ -59,23 +140,8 @@ def main() -> None:
|
|
|
59
140
|
sys.exit(0)
|
|
60
141
|
|
|
61
142
|
matched_pattern = is_sensitive_file(file_path)
|
|
62
|
-
|
|
63
143
|
if matched_pattern is not None:
|
|
64
|
-
|
|
65
|
-
deny_response = {
|
|
66
|
-
"hookSpecificOutput": {
|
|
67
|
-
"hookEventName": "PreToolUse",
|
|
68
|
-
"permissionDecision": "deny",
|
|
69
|
-
"permissionDecisionReason": deny_reason,
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
log_hook_block(
|
|
73
|
-
calling_hook_name="sensitive_file_protector.py",
|
|
74
|
-
hook_event="PreToolUse",
|
|
75
|
-
block_reason=deny_reason,
|
|
76
|
-
offending_input_preview=file_path,
|
|
77
|
-
)
|
|
78
|
-
print(json.dumps(deny_response))
|
|
144
|
+
deny_write(file_path, matched_pattern)
|
|
79
145
|
|
|
80
146
|
sys.exit(0)
|
|
81
147
|
|
|
@@ -22,7 +22,10 @@ if _hooks_root_path_string not in sys.path:
|
|
|
22
22
|
if _blocking_directory_path_string not in sys.path:
|
|
23
23
|
sys.path.insert(0, _blocking_directory_path_string)
|
|
24
24
|
|
|
25
|
-
from code_rules_shared import
|
|
25
|
+
from code_rules_shared import ( # noqa: E402
|
|
26
|
+
is_ephemeral_script_path,
|
|
27
|
+
is_under_session_scratchpad,
|
|
28
|
+
)
|
|
26
29
|
|
|
27
30
|
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
28
31
|
from hooks_constants.messages import USER_FACING_TDD_NOTICE # noqa: E402
|
|
@@ -592,7 +595,11 @@ def main() -> None:
|
|
|
592
595
|
if not file_path:
|
|
593
596
|
sys.exit(0)
|
|
594
597
|
|
|
595
|
-
if
|
|
598
|
+
if (
|
|
599
|
+
is_under_session_scratchpad(file_path, input_data)
|
|
600
|
+
or _is_inside_dotclaude_segment(file_path)
|
|
601
|
+
or is_ephemeral_script_path(file_path)
|
|
602
|
+
):
|
|
596
603
|
sys.exit(0)
|
|
597
604
|
|
|
598
605
|
path = Path(file_path)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Tests for the session-scratchpad exemption wired into code_rules_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
|
+
ENFORCER_PATH = Path(__file__).parent / "code_rules_enforcer.py"
|
|
15
|
+
FIXED_USER_ID = 6070
|
|
16
|
+
WORKING_DIRECTORY = "/home/user/project"
|
|
17
|
+
SESSION_ID = "enforcer-session-654"
|
|
18
|
+
VIOLATING_CONTENT = "def compute():\n data = 42\n return data\n"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _load_enforcer() -> ModuleType:
|
|
22
|
+
module_spec = importlib.util.spec_from_file_location(
|
|
23
|
+
"code_rules_enforcer_scratchpad_under_test", 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
|
+
_ENFORCER = _load_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": VIOLATING_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
|
+
_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_write_is_exempt_from_code_rules(
|
|
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"
|