SLOPSHOPPER

rulebook-guard

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.

newguardtoastprocess
A shopper browsing a rack in a slop shop
README

claude-code-mods-collection

A private reference collection of public Claude Code mods, with a security-oriented analysis.

  • ANALYSIS.md: what each mod can reach, which to install, which to avoid
  • SOURCES.md: upstream repo and pinned commit for every folder in 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.

Source 1 files
hooks/register.ts 182 lines
1// 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