claude-dev-env 1.93.1 → 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 (262) 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/_shared/pr-loop/CLAUDE.md +1 -0
  7. package/_shared/pr-loop/audit-contract.md +1 -1
  8. package/_shared/pr-loop/gh-payloads.md +3 -3
  9. package/_shared/pr-loop/post-audit-thread-contract.md +51 -0
  10. package/_shared/pr-loop/scripts/README.md +2 -2
  11. package/_shared/pr-loop/scripts/fix_hookspath.py +47 -12
  12. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +0 -69
  13. package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +497 -374
  14. package/_shared/pr-loop/state-schema.md +3 -22
  15. package/bin/CLAUDE.md +11 -2
  16. package/bin/ever-shipped-skills.mjs +70 -0
  17. package/bin/install.mjs +138 -13
  18. package/bin/install.prune.test.mjs +457 -0
  19. package/bin/install.test.mjs +10 -4
  20. package/docs/CODE_RULES.md +1 -1
  21. package/hooks/blocking/CLAUDE.md +2 -0
  22. package/hooks/blocking/code_rules_enforcer.py +4 -0
  23. package/hooks/blocking/code_rules_shared.py +82 -0
  24. package/hooks/blocking/code_rules_test_layout.py +9 -3
  25. package/hooks/blocking/pii_commit_command.py +398 -0
  26. package/hooks/blocking/pii_payload_scan.py +133 -0
  27. package/hooks/blocking/pii_prevention_blocker.py +151 -449
  28. package/hooks/blocking/plain_language_blocker.py +138 -4
  29. package/hooks/blocking/sensitive_file_protector.py +114 -48
  30. package/hooks/blocking/tdd_enforcer.py +9 -2
  31. package/hooks/blocking/test_code_rules_enforcer_scratchpad.py +105 -0
  32. package/hooks/blocking/test_code_rules_shared.py +181 -0
  33. package/hooks/blocking/test_pii_prevention_blocker.py +299 -0
  34. package/hooks/blocking/test_plain_language_blocker_allowlist.py +184 -0
  35. package/hooks/blocking/test_sensitive_file_protector.py +185 -0
  36. package/hooks/blocking/test_tdd_enforcer_scratchpad.py +105 -0
  37. package/hooks/blocking/test_verdict_directory_write_blocker.py +42 -7
  38. package/hooks/hooks_constants/CLAUDE.md +4 -2
  39. package/hooks/hooks_constants/code_rules_path_utils_constants.py +0 -2
  40. package/hooks/hooks_constants/harness_scratchpad_constants.py +17 -0
  41. package/hooks/hooks_constants/local_identity.py +65 -15
  42. package/hooks/hooks_constants/pii_prevention_constants.py +16 -0
  43. package/hooks/hooks_constants/plain_language_blocker_constants.py +5 -0
  44. package/hooks/hooks_constants/pr_description_enforcer_constants.py +1 -1
  45. package/hooks/hooks_constants/sensitive_file_protector_constants.py +42 -0
  46. package/hooks/hooks_constants/test_local_identity.py +68 -0
  47. package/hooks/pyproject.toml +75 -4
  48. package/hooks/validators/CLAUDE.md +1 -1
  49. package/hooks/validators/README.md +2 -0
  50. package/hooks/validators/python_style_checks.py +114 -136
  51. package/hooks/validators/python_style_helpers.py +95 -0
  52. package/hooks/validators/test_python_style_checks.py +0 -164
  53. package/hooks/validators/test_python_style_checks_decorator_gap.py +119 -0
  54. package/hooks/validators/test_python_style_fixes.py +251 -0
  55. package/hooks/validators/test_python_style_helpers.py +125 -0
  56. package/package.json +1 -1
  57. package/rules/CLAUDE.md +1 -0
  58. package/rules/anti-corollary-tests.md +69 -0
  59. package/rules/bdd.md +1 -3
  60. package/rules/code-reviews.md +1 -1
  61. package/rules/gh-paginate.md +1 -1
  62. package/rules/plain-language.md +2 -0
  63. package/scripts/CLAUDE.md +10 -1
  64. package/scripts/Get-SessionAccount.ps1 +210 -0
  65. package/scripts/tests/CLAUDE.md +15 -1
  66. package/scripts/tests/Get-SessionAccount.Tests.ps1 +350 -0
  67. package/skills/CLAUDE.md +6 -27
  68. package/skills/anthropic-plan/SKILL.md +1 -13
  69. package/skills/autoconverge/CLAUDE.md +5 -2
  70. package/skills/autoconverge/SKILL.md +112 -387
  71. package/skills/autoconverge/reference/CLAUDE.md +4 -1
  72. package/skills/autoconverge/reference/closing-report.md +9 -2
  73. package/skills/autoconverge/reference/convergence.md +26 -22
  74. package/skills/autoconverge/reference/headless-safety.md +44 -0
  75. package/skills/autoconverge/reference/multi-pr.md +88 -0
  76. package/skills/autoconverge/reference/self-closing-loop.md +84 -0
  77. package/skills/autoconverge/reference/stop-conditions.md +21 -13
  78. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +87 -27
  79. package/skills/autoconverge/workflow/converge.contract.test.mjs +40 -10
  80. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +179 -47
  81. package/skills/autoconverge/workflow/converge.fix-progress.test.mjs +1 -1
  82. package/skills/autoconverge/workflow/converge.mjs +286 -96
  83. package/skills/bugteam/CLAUDE.md +1 -1
  84. package/skills/bugteam/CONSTRAINTS.md +3 -3
  85. package/skills/bugteam/PROMPTS.md +8 -19
  86. package/skills/bugteam/SKILL.md +13 -14
  87. package/skills/bugteam/reference/CLAUDE.md +1 -1
  88. package/skills/bugteam/reference/README.md +2 -3
  89. package/skills/bugteam/reference/audit-and-teammates.md +2 -2
  90. package/skills/bugteam/reference/copilot-gap-analysis.md +2 -0
  91. package/skills/bugteam/reference/github-pr-reviews.md +56 -38
  92. package/skills/bugteam/reference/team-setup.md +11 -11
  93. package/skills/bugteam/scripts/CLAUDE.md +6 -6
  94. package/skills/bugteam/scripts/README.md +20 -8
  95. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +13 -1463
  96. package/skills/bugteam/scripts/bugteam_fix_hookspath.py +23 -358
  97. package/skills/bugteam/scripts/bugteam_preflight.py +13 -315
  98. package/skills/bugteam/scripts/bugteam_scripts_constants/CLAUDE.md +2 -3
  99. package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +34 -1053
  100. package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +30 -347
  101. package/skills/bugteam/scripts/test_bugteam_preflight.py +30 -305
  102. package/skills/closeout/SKILL.md +153 -0
  103. package/skills/closeout/reference/handoff-prompt-template.md +72 -0
  104. package/skills/closeout/reference/issue-body-templates.md +108 -0
  105. package/skills/closeout/reference/pii-redaction-checklist.md +36 -0
  106. package/skills/copilot-finding-triage/SKILL.md +80 -28
  107. package/skills/copilot-finding-triage/reference/tier-rubric.md +70 -11
  108. package/skills/copilot-finding-triage/templates/notification.md +8 -3
  109. package/skills/everything-search/SKILL.md +1 -2
  110. package/skills/grokify/SKILL.md +58 -0
  111. package/skills/grokify/templates/handoff-template.md +92 -0
  112. package/skills/orchestrator/SKILL.md +35 -25
  113. package/skills/orchestrator-refresh/SKILL.md +15 -11
  114. package/skills/pr-converge/CLAUDE.md +1 -1
  115. package/skills/pr-converge/SKILL.md +132 -233
  116. package/skills/pr-converge/reference/convergence-gates.md +102 -66
  117. package/skills/pr-converge/reference/examples.md +37 -25
  118. package/skills/pr-converge/reference/fix-protocol.md +7 -7
  119. package/skills/pr-converge/reference/multi-pr-orchestration.md +10 -4
  120. package/skills/pr-converge/reference/per-tick.md +64 -41
  121. package/skills/pr-converge/reference/state-schema.md +27 -2
  122. package/skills/pr-converge/scripts/CLAUDE.md +8 -0
  123. package/skills/pr-converge/scripts/README.md +4 -3
  124. package/skills/pr-converge/scripts/_pr_converge_path_setup.py +23 -0
  125. package/skills/pr-converge/scripts/check_convergence.py +189 -645
  126. package/skills/pr-converge/scripts/check_convergence_gates.py +235 -0
  127. package/skills/pr-converge/scripts/check_convergence_thread_gates.py +219 -0
  128. package/skills/pr-converge/scripts/conftest.py +8 -0
  129. package/skills/pr-converge/scripts/pr_converge_scripts_constants/CLAUDE.md +1 -0
  130. package/skills/pr-converge/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +36 -0
  131. package/skills/pr-converge/scripts/test__pr_converge_path_setup.py +26 -0
  132. package/skills/pr-converge/scripts/test_check_convergence.py +73 -228
  133. package/skills/pr-converge/scripts/test_check_convergence_contract.py +321 -0
  134. package/skills/pr-converge/scripts/test_check_convergence_gates.py +68 -0
  135. package/skills/pr-converge/scripts/test_check_convergence_thread_gates.py +75 -0
  136. package/skills/pr-fix-protocol/SKILL.md +20 -20
  137. package/skills/privacy-hygiene/SKILL.md +4 -3
  138. package/skills/reviewer-gates/SKILL.md +26 -8
  139. package/skills/session-log/SKILL.md +11 -12
  140. package/skills/session-tidy/SKILL.md +40 -30
  141. package/skills/skill-builder/CLAUDE.md +13 -10
  142. package/skills/skill-builder/SKILL.md +31 -31
  143. package/skills/skill-builder/references/CLAUDE.md +10 -6
  144. package/skills/skill-builder/references/delegation-map.md +13 -4
  145. package/skills/skill-builder/references/description-field.md +113 -0
  146. package/skills/skill-builder/references/self-audit-checklist.md +21 -4
  147. package/skills/skill-builder/references/skill-modularity.md +113 -0
  148. package/skills/skill-builder/references/skill-types.md +4 -0
  149. package/skills/skill-builder/templates/CLAUDE.md +4 -4
  150. package/skills/skill-builder/templates/gap-analysis.md +25 -1
  151. package/skills/skill-builder/workflows/CLAUDE.md +6 -4
  152. package/skills/skill-builder/workflows/improve-skill.md +17 -16
  153. package/skills/skill-builder/workflows/new-skill.md +34 -10
  154. package/skills/skill-builder/workflows/polish-skill.md +59 -43
  155. package/skills/team-advisor/SKILL.md +15 -11
  156. package/skills/usage-pause/SKILL.md +10 -14
  157. package/system-prompts/software-engineer.xml +7 -6
  158. package/hooks/validators/test_verify_paths.py +0 -32
  159. package/hooks/validators/verify_paths.py +0 -57
  160. package/skills/bdd-protocol/CLAUDE.md +0 -26
  161. package/skills/bdd-protocol/SKILL.md +0 -30
  162. package/skills/bdd-protocol/references/CLAUDE.md +0 -10
  163. package/skills/bdd-protocol/references/anti-patterns.md +0 -26
  164. package/skills/bdd-protocol/references/example-mapping.md +0 -23
  165. package/skills/bg-agent/CLAUDE.md +0 -17
  166. package/skills/bg-agent/SKILL.md +0 -69
  167. package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_code_rules_gate_constants.py +0 -55
  168. package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_fix_hookspath_constants.py +0 -26
  169. package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_preflight_constants.py +0 -35
  170. package/skills/caveman/CLAUDE.md +0 -15
  171. package/skills/caveman/SKILL.md +0 -38
  172. package/skills/code/CLAUDE.md +0 -17
  173. package/skills/code/SKILL.md +0 -178
  174. package/skills/deep-research/CLAUDE.md +0 -17
  175. package/skills/deep-research/SKILL.md +0 -80
  176. package/skills/findbugs/CLAUDE.md +0 -20
  177. package/skills/findbugs/SKILL.md +0 -241
  178. package/skills/fixbugs/CLAUDE.md +0 -19
  179. package/skills/fixbugs/SKILL.md +0 -142
  180. package/skills/gh-paginate/CLAUDE.md +0 -18
  181. package/skills/gh-paginate/SKILL.md +0 -84
  182. package/skills/gotcha/CLAUDE.md +0 -33
  183. package/skills/gotcha/SKILL.md +0 -73
  184. package/skills/implement/CLAUDE.md +0 -27
  185. package/skills/implement/SKILL.md +0 -66
  186. package/skills/implement/scripts/CLAUDE.md +0 -22
  187. package/skills/implement/scripts/append_note.py +0 -133
  188. package/skills/implement/scripts/implement_scripts_constants/CLAUDE.md +0 -22
  189. package/skills/implement/scripts/implement_scripts_constants/__init__.py +0 -0
  190. package/skills/implement/scripts/implement_scripts_constants/notes_constants.py +0 -12
  191. package/skills/implement/scripts/test_append_note.py +0 -191
  192. package/skills/log-audit/CLAUDE.md +0 -20
  193. package/skills/log-audit/SKILL.md +0 -66
  194. package/skills/log-audit/reference/CLAUDE.md +0 -9
  195. package/skills/log-audit/reference/charter.md +0 -52
  196. package/skills/log-audit/scripts/CLAUDE.md +0 -27
  197. package/skills/log-audit/scripts/cluster_recurrences.py +0 -261
  198. package/skills/log-audit/scripts/collect_log_window.py +0 -199
  199. package/skills/log-audit/scripts/log_audit_constants/CLAUDE.md +0 -12
  200. package/skills/log-audit/scripts/log_audit_constants/__init__.py +0 -0
  201. package/skills/log-audit/scripts/log_audit_constants/cluster_recurrences_constants.py +0 -23
  202. package/skills/log-audit/scripts/log_audit_constants/collect_log_window_constants.py +0 -24
  203. package/skills/log-audit/scripts/log_audit_constants/mine_copilot_findings_constants.py +0 -49
  204. package/skills/log-audit/scripts/mine_copilot_findings.py +0 -302
  205. package/skills/log-audit/scripts/test_cluster_recurrences.py +0 -160
  206. package/skills/log-audit/scripts/test_collect_log_window.py +0 -111
  207. package/skills/log-audit/scripts/test_mine_copilot_findings.py +0 -126
  208. package/skills/logifix/CLAUDE.md +0 -36
  209. package/skills/logifix/SKILL.md +0 -69
  210. package/skills/logifix/scripts/CLAUDE.md +0 -16
  211. package/skills/logifix/scripts/logifix.ps1 +0 -205
  212. package/skills/monitor-open-prs/CLAUDE.md +0 -34
  213. package/skills/monitor-open-prs/SKILL.md +0 -88
  214. package/skills/monitor-open-prs/scripts/CLAUDE.md +0 -17
  215. package/skills/monitor-open-prs/scripts/discover_open_prs.py +0 -69
  216. package/skills/monitor-open-prs/scripts/test_discover_open_prs.py +0 -149
  217. package/skills/monitor-open-prs/test_skill_contract.py +0 -27
  218. package/skills/post-audit-findings/SKILL.md +0 -80
  219. package/skills/pr-consistency-audit/CLAUDE.md +0 -34
  220. package/skills/pr-consistency-audit/SKILL.md +0 -116
  221. package/skills/pr-consistency-audit/reference/CLAUDE.md +0 -16
  222. package/skills/pr-consistency-audit/reference/detection-rules.md +0 -96
  223. package/skills/pr-consistency-audit/reference/illustrations.md +0 -78
  224. package/skills/pr-review-responder/CLAUDE.md +0 -35
  225. package/skills/pr-review-responder/EXAMPLES.md +0 -590
  226. package/skills/pr-review-responder/PRINCIPLES.md +0 -533
  227. package/skills/pr-review-responder/README.md +0 -168
  228. package/skills/pr-review-responder/SKILL.md +0 -203
  229. package/skills/pr-review-responder/TESTING.md +0 -350
  230. package/skills/pr-scope-resolve/SKILL.md +0 -45
  231. package/skills/pre-compact/CLAUDE.md +0 -24
  232. package/skills/pre-compact/SKILL.md +0 -134
  233. package/skills/qbug/CLAUDE.md +0 -40
  234. package/skills/qbug/SKILL.md +0 -387
  235. package/skills/qbug/test_qbug_skill_audit_schema.py +0 -152
  236. package/skills/qbug/test_qbug_skill_post_fix_audit.py +0 -103
  237. package/skills/refine/CLAUDE.md +0 -44
  238. package/skills/refine/SKILL.md +0 -262
  239. package/skills/refine/templates/CLAUDE.md +0 -17
  240. package/skills/refine/templates/implementation-notes-template.html +0 -56
  241. package/skills/refine/templates/plan-template.md +0 -60
  242. package/skills/research-mode/CLAUDE.md +0 -35
  243. package/skills/research-mode/SKILL.md +0 -53
  244. package/skills/structure-prompt/CLAUDE.md +0 -42
  245. package/skills/structure-prompt/SKILL.md +0 -44
  246. package/skills/structure-prompt/reference/CLAUDE.md +0 -28
  247. package/skills/structure-prompt/reference/adversarial-tuning.md +0 -62
  248. package/skills/structure-prompt/reference/block-classification.md +0 -27
  249. package/skills/structure-prompt/reference/canonical-case.md +0 -48
  250. package/skills/structure-prompt/reference/citation-depth.md +0 -70
  251. package/skills/structure-prompt/reference/cleanup.md +0 -33
  252. package/skills/structure-prompt/reference/constraints.md +0 -33
  253. package/skills/structure-prompt/reference/directives.md +0 -37
  254. package/skills/structure-prompt/reference/examples.md +0 -72
  255. package/skills/structure-prompt/reference/instantiation.md +0 -51
  256. package/skills/structure-prompt/reference/output-contract.md +0 -72
  257. package/skills/structure-prompt/reference/per-category.md +0 -23
  258. package/skills/structure-prompt/reference/persona.md +0 -38
  259. package/skills/structure-prompt/reference/research.md +0 -33
  260. package/skills/structure-prompt/reference/structure.md +0 -28
  261. package/skills/verified-build/CLAUDE.md +0 -33
  262. package/skills/verified-build/SKILL.md +0 -33
@@ -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"
@@ -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