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
@@ -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; the `/code` skill prepends strict mode for a session: no `Any`/`cast()`, immutable TypedDicts with `_encode_*`/`_decode_*` + `require_*` validation, per-module `_test_hooks.py` DI, 100% statement + branch coverage, zero mocks). 👥 **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` and `skills/code/SKILL.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.
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(tree: ast.Module, referenced_names: set[str]) -> list[str]:
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) >= MAX_TEST_LAYOUT_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(tree, _referenced_names(tree))
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(text: str) -> list[tuple[str, str]]:
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 are exempt and never
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
- all_matches = find_banned_terms(prose_text)
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
- from pathlib import Path
7
-
8
- _hooks_dir = str(Path(__file__).resolve().parent.parent)
9
- if _hooks_dir not in sys.path:
10
- sys.path.insert(0, _hooks_dir)
11
-
12
- from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
13
-
14
- SENSITIVE_PATTERNS = [
15
- ".env",
16
- ".env.*",
17
- "*.env",
18
- "*.pem",
19
- "*.key",
20
- "*.p12",
21
- "*.pfx",
22
- "credentials.json",
23
- "secrets.json",
24
- "id_rsa",
25
- "id_ed25519",
26
- "package-lock.json",
27
- "yarn.lock",
28
- "Pipfile.lock",
29
- "poetry.lock",
30
- "pnpm-lock.yaml",
31
- "composer.lock",
32
- ]
33
-
34
- WRITE_EDIT_TOOLS = {"Write", "Edit"}
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
- for each_pattern in SENSITIVE_PATTERNS:
40
- if fnmatch.fnmatch(filename, each_pattern):
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 WRITE_EDIT_TOOLS:
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
- deny_reason = f"BLOCKED: Sensitive file '{os.path.basename(file_path)}' (pattern: '{matched_pattern}'). Edit manually outside Claude Code."
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 is_ephemeral_script_path # noqa: E402
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 _is_inside_dotclaude_segment(file_path) or is_ephemeral_script_path(file_path):
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"