Writing and git rules: replaces em dashes in prose, and asks before git commit --amend, an unformatted push, or personal info in notes and commits.

A private reference collection of public Claude Code mods, with a security-oriented analysis.
mods/Nothing here is installed or executed. All code is by its upstream authors under their own licences (MIT, one Apache-2.0); each folder keeps its original LICENSE.
hooks/register.ts 182 lines1// rulebook-guard: enforces writing and git rules kept in CLAUDE.md and memory.
2// 1. No em dashes in prose: notes, docs, commit messages, PR text, Slack posts.
3// 2. New commits, not `git commit --amend`.
4// 3. Changed files formatted (ruff format, Prettier) before `git push`.
5// 4. No personal info (emails, phone numbers) in notes, memory or commits without asking.
6import type { EngineInterface, Register } from 'claude-code'
7
8const PROSE_FILE = /\.(md|mdx|markdown|txt)$/i
9const PROSE_COMMAND = /\bgh\s+(pr|issue)\s+(create|comment|edit|review)\b/
10// `git`, then any global options (`-c k=v`, `-C dir`, `--no-pager`), then the subcommand.
11const GIT = String.raw`\bgit(?:\s+(?:-[cC]\s+\S+|--?[\w-]+(?:=\S+)?))*\s+`
12const GIT_COMMIT = new RegExp(`${GIT}commit\\b`)
13const GIT_AMEND = new RegExp(`${GIT}commit\\b[^|;&]*--amend\\b`)
14const GIT_PUSH = new RegExp(`${GIT}push\\b`)
15const SLACK_POST = /^mcp__claude_ai_Slack__slack_(send_message|send_message_draft|schedule_message)$/
16const EMAIL = /[\w.+-]+@[\w-]+\.[a-z]{2,}/i
17const PHONE = /\+\d{1,3}[\s-]?\d{2,4}[\s-]?\d{2,4}[\s-]?\d{2,4}/
18const PRETTIER_FILE = /\.(js|jsx|ts|tsx|mjs|cjs|json|css|scss|md|mdx|ya?ml|html)$/
19
20export const register: Register = on => {
21 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
22 if (await isPrivatePath($, e.file_path)) {
23 const found = pii(e.content)
24 if (found && !(await allow($, `This write to ${short(e.file_path)} contains what looks like ${found}.`))) {
25 return { deny: `rulebook-guard: the user blocked writing ${found} to ${e.file_path}. Personal info needs the user's approval first. Write the outcome instead of the details.` }
26 }
27 }
28 if (PROSE_FILE.test(e.file_path) && e.content.includes('—')) {
29 $.ui.toast(`rulebook-guard: replaced ${count(e.content)} em dash(es) in ${short(e.file_path)}`)
30 return next({ ...e, content: undash(e.content) })
31 }
32 return next(e)
33 })
34
35 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
36 if (await isPrivatePath($, e.file_path)) {
37 const found = pii(e.new_string)
38 if (found && !(await allow($, `This edit to ${short(e.file_path)} adds what looks like ${found}.`))) {
39 return { deny: `rulebook-guard: the user blocked writing ${found} to ${e.file_path}. Personal info needs the user's approval first. Write the outcome instead of the details.` }
40 }
41 }
42 if (PROSE_FILE.test(e.file_path) && e.new_string.includes('—')) {
43 $.ui.toast(`rulebook-guard: replaced ${count(e.new_string)} em dash(es) in ${short(e.file_path)}`)
44 return next({ ...e, new_string: undash(e.new_string) })
45 }
46 return next(e)
47 })
48
49 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
50 let command = e.command
51 const shell = bare(command) // what the shell runs, without quoted text or heredoc bodies
52
53 if (GIT_AMEND.test(shell)) {
54 if (!(await allow($, 'This is `git commit --amend`. Your rule: make a new commit instead.'))) {
55 return { deny: 'rulebook-guard: the user blocked `git commit --amend`. Their rule is new commits, not amend. Make a new commit instead.' }
56 }
57 }
58
59 if (GIT_COMMIT.test(shell)) {
60 const found = pii(command)
61 if (found && !(await allow($, `This commit message contains what looks like ${found}.`))) {
62 return { deny: `rulebook-guard: the user blocked a commit message with ${found}. Personal info needs the user's approval first.` }
63 }
64 }
65
66 if (GIT_PUSH.test(shell)) {
67 const problems = await unformatted($, cdOf(command))
68 if (problems.length > 0 && !(await allow($, `Not formatted yet: ${problems.join('; ')}. Your rule: format before push.`))) {
69 return { deny: `rulebook-guard: the user blocked the push because these are not formatted: ${problems.join('; ')}. Run the formatter(s), commit, then push.` }
70 }
71 }
72
73 if ((GIT_COMMIT.test(shell) || PROSE_COMMAND.test(shell)) && command.includes('—')) {
74 $.ui.toast(`rulebook-guard: replaced ${count(command)} em dash(es) in the ${GIT_COMMIT.test(shell) ? 'commit message' : 'PR text'}`)
75 command = undash(command)
76 }
77
78 return next(command === e.command ? e : { ...e, command })
79 })
80
81 // Slack posts: no em dashes either.
82 on('tool.call', async ($, e, next) => {
83 if (!SLACK_POST.test(e.tool)) return next(e)
84 const args = e as unknown as { message?: unknown }
85 if (typeof args.message !== 'string' || !args.message.includes('—')) return next(e)
86 $.ui.toast(`rulebook-guard: replaced ${count(args.message)} em dash(es) in the Slack message`)
87 return next({ ...e, message: undash(args.message) } as typeof e)
88 })
89}
90
91// The command as the shell reads it: heredoc bodies and quoted strings blanked, so text
92// that merely mentions a command (a script, a commit message) is not taken for one.
93export function bare(command: string) {
94 return command
95 .replace(/<<-?\s*(['"]?)(\w+)\1[^\n]*\n[\s\S]*?\n\s*\2\s*(\n|$)/g, ' ')
96 .replace(/'[^']*'/g, "''")
97 .replace(/"(?:[^"\\]|\\.)*"/g, '""')
98}
99
100// "a — b" and "a—b" both read "a, b".
101export function undash(text: string) {
102 return text.replace(/\s*—\s*/g, ', ')
103}
104
105function count(text: string) {
106 return text.split('—').length - 1
107}
108
109// What kind of personal info the text seems to hold, by category only; never the value.
110export function pii(text: string): string | null {
111 if (EMAIL.test(text)) return 'an email address'
112 if (PHONE.test(text)) return 'a phone number'
113 return null
114}
115
116// Notes, auto-memory and CLAUDE.md files: where the PII rule applies.
117async function isPrivatePath($: EngineInterface, path: string) {
118 const home = (await $.env.get('HOME')) ?? ''
119 return (
120 (home !== '' && path.startsWith(`${home}/notes/`)) ||
121 /\/(\.claude|\.claude-work)\/projects\/[^/]+\/memory\//.test(path) ||
122 /(^|\/)CLAUDE\.md$/.test(path)
123 )
124}
125
126// Asks the person; anything but "Allow once" (or no one to ask) blocks.
127async function allow($: EngineInterface, why: string) {
128 try {
129 const answer = await $.ui.ask(`rulebook-guard: ${why} Allow it anyway?`, {
130 options: ['Block it', 'Allow once'],
131 header: 'Rulebook',
132 })
133 return answer === 'Allow once'
134 } catch {
135 return false
136 }
137}
138
139// The directory a leading `cd x &&` moves to, if any.
140export function cdOf(command: string): string | undefined {
141 return /^\s*cd\s+("[^"]+"|'[^']+'|\S+)\s*&&/.exec(command)?.[1]?.replace(/^["']|["']$/g, '')
142}
143
144// Changed files the repo's own formatters would change, as short reasons.
145async function unformatted($: EngineInterface, cwd: string | undefined): Promise<string[]> {
146 const init = cwd ? { cwd } : undefined
147 const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], init).catch(() => null)
148 if (!top || top.exitCode !== 0) return []
149 const root = top.stdout.trim()
150 const at = { cwd: root, timeoutMs: 60_000 }
151
152 let diff = await $.process.run(['git', 'diff', '--name-only', '--diff-filter=ACMR', '@{u}...HEAD'], at)
153 if (diff.exitCode !== 0) diff = await $.process.run(['git', 'diff', '--name-only', '--diff-filter=ACMR', 'origin/main...HEAD'], at)
154 if (diff.exitCode !== 0) return []
155 const files = diff.stdout.split('\n').filter(Boolean)
156 const problems: string[] = []
157
158 const py = files.filter(f => f.endsWith('.py'))
159 if (py.length > 0) {
160 const ruff = await $.process.run(['ruff', 'format', '--check', ...py], at).catch(() => null)
161 if (ruff && ruff.exitCode === 1) {
162 const bad = ruff.stdout.split('\n').filter(l => l.startsWith('Would reformat:')).map(l => l.slice(16).trim())
163 problems.push(`ruff format: ${bad.join(', ') || 'some Python files'}`)
164 }
165 }
166
167 const web = files.filter(f => PRETTIER_FILE.test(f))
168 const hasPrettier = await $.fs.exists(`${root}/node_modules/.bin/prettier`).catch(() => false)
169 if (web.length > 0 && hasPrettier) {
170 const prettier = await $.process.run([`${root}/node_modules/.bin/prettier`, '--check', ...web], at).catch(() => null)
171 if (prettier && prettier.exitCode === 1) {
172 const bad = `${prettier.stdout}\n${prettier.stderr}`.split('\n').filter(l => l.startsWith('[warn] ') && !l.includes('Code style issues')).map(l => l.slice(7).trim())
173 problems.push(`prettier: ${bad.join(', ') || 'some files'}`)
174 }
175 }
176 return problems
177}
178
179function short(path: string) {
180 return path.split('/').slice(-2).join('/')
181}
182