SLOPSHOPPER

commit-guard

Blocks git commit and git push when the change contains secrets, private keys, .env files, node_modules or giant files, before they leave your machine.

newbandguardcommandtoastprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · commit-guard
› fix the failing auth test and add an audit log call ╭──────────────────────────────────────────╮ │ commit-guard │ ⏺ Read(src/auth.ts) │ 🛡️ Commit Guard: 2 files checked, │ ⎿ Read 6 lines │ clean. │ ⏺ Update(src/auth.ts) ╰──────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /commit-guard ⎿ commit-guard: 🛡️ Commit Guard is ON · 0 commits blocked all time ⎿ commit-guard: Last check: clean (push, 2 files) ⎿ commit-guard: ⎿ commit-guard: /commit-guard scan · allow-once · on · off ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

🛡️ Commit Guard

Claude should never be able to publish your secrets.

When Claude runs git commit or git push, Commit Guard checks the change first and blocks it if it contains something you'd regret sharing. Claude is told exactly what was found and how to fix it, so it can self-correct without you.

What it catches

KindExamples
Secrets in codeAWS, GitHub, Anthropic, OpenAI, Stripe, Slack, Google keys · private key blocks · JWTs · DB URLs with passwords · hardcoded api_key = "…"
CryptoWallet private keys (hex and base58), seed phrases, keypair/wallet JSON files
Files that should never be committed.env* (not .env.example), *.pem/*.key, id_rsa, credentials.json, .npmrc, node_modules/, .venv/
SizeWarns over 5 MB, blocks over 95 MB (GitHub rejects 100 MB)

It understands git add -A && git commit, commit -am, git -C dir …, and scans unpushed commits on git push. Placeholders (your_api_key, process.env.X, example) are ignored.

Install

/plugin marketplace add choaticpixels/HauntedClaudeMod
/plugin install commit-guard@claude-mods

Commands

  • /commit-guard: status and the last check
  • /commit-guard scan: scan your working tree and unpushed commits right now
  • /commit-guard allow-once: let the next blocked commit through (false positive)
  • /commit-guard off · on

Quieting false positives

  • Add path fragments to a .commitguardignore file in the repo (one per line, # for comments).
  • Put commit-guard:ignore in a comment on the offending line.

Notes

Commit Guard is a safety net, not a guarantee: it only sees commits and pushes that Claude runs through Bash, and it looks for known patterns. Rotate any key that has ever been exposed.

Source 2 files
hooks/register.tsx 285 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Finding, Report } from '../types'
5
6type $ = EngineInterface
7
8const last = atom({ plugin: 'commit-guard', key: 'last' } as const, null as Report | null)
9const allowOnce = atom({ plugin: 'commit-guard', key: 'allowOnce' } as const, false)
10
11const BAND_MS = 25_000
12const WARN_BYTES = 5 * 1024 * 1024
13const BLOCK_BYTES = 95 * 1024 * 1024 // GitHub refuses pushes over 100 MB
14const MAX_UNTRACKED_READ = 1024 * 1024
15
16// `strict` rules are never waved through as placeholders: a real-looking key is a real key.
17const SECRET_RULES: { name: string; re: RegExp; strict?: boolean }[] = [
18  { name: 'AWS access key', re: /\bAKIA[0-9A-Z]{16}\b/, strict: true },
19  { name: 'GitHub token', re: /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{50,})\b/, strict: true },
20  { name: 'Anthropic API key', re: /\bsk-ant-[A-Za-z0-9_-]{20,}/, strict: true },
21  { name: 'OpenAI-style API key', re: /\bsk-(?:proj-)?[A-Za-z0-9_-]{32,}/, strict: true },
22  { name: 'Stripe live key', re: /\b[sr]k_live_[A-Za-z0-9]{16,}/, strict: true },
23  { name: 'Slack token', re: /\bxox[abprs]-[A-Za-z0-9-]{10,}/, strict: true },
24  { name: 'Google API key', re: /\bAIza[0-9A-Za-z_-]{35}\b/, strict: true },
25  { name: 'Private key block', re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY(?: BLOCK)?-----/, strict: true },
26  { name: 'JSON Web Token', re: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/ },
27  { name: 'Crypto private key (hex)', re: /(?:private[_-]?key|priv[_-]?key|secret[_-]?key|wallet[_-]?key|signer[_-]?key)\w*\s*[:=]\s*['"]?(?:0x)?[0-9a-fA-F]{64}\b/i, strict: true },
28  { name: 'Crypto private key (base58)', re: /(?:private[_-]?key|secret[_-]?key|keypair|wallet[_-]?key)\w*\s*[:=]\s*['"][1-9A-HJ-NP-Za-km-z]{80,90}['"]/i, strict: true },
29  { name: 'Seed phrase', re: /(?:mnemonic|seed[_-]?phrase|recovery[_-]?phrase)\w*\s*[:=]\s*['"](?:[a-z]+\s){11,23}[a-z]+['"]/i, strict: true },
30  { name: 'Database URL with password', re: /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqps?):\/\/[^\s:@/'"]+:[^\s@/'"]{3,}@/i },
31  { name: 'Hardcoded credential', re: /(?:api[_-]?key|secret|token|passw(?:or)?d|auth)\w*\s*[:=]\s*['"][A-Za-z0-9/+_\-=.]{16,}['"]/i },
32]
33
34const PLACEHOLDER = /example|placeholder|your[_-]|<[a-z_ -]+>|x{6,}|\*{4,}|dummy|changeme|fake|sample|test[_-]?(?:key|token|secret)|\$\{|process\.env|os\.environ|getenv|env\(/i
35
36const FILE_RULES: { re: RegExp; detail: string; severity: 'block' | 'warn' }[] = [
37  { re: /(^|\/)\.env(\.(?!example$|sample$|template$|dist$)[\w.-]+)?$/i, detail: 'environment file (usually holds secrets)', severity: 'block' },
38  { re: /\.(pem|key|p12|pfx|jks|keystore|ppk)$/i, detail: 'key or certificate file', severity: 'block' },
39  { re: /(^|\/)id_(rsa|dsa|ecdsa|ed25519)$/, detail: 'SSH private key', severity: 'block' },
40  { re: /(^|\/)(wallet|keystore|keypair|phantom)[\w.-]*\.json$/i, detail: 'crypto wallet / keypair file', severity: 'block' },
41  { re: /(^|\/)(credentials|secrets?)\.(json|ya?ml|toml|ini)$/i, detail: 'credentials file', severity: 'block' },
42  { re: /(^|\/)\.(npmrc|pypirc|netrc|git-credentials|pgpass)$/, detail: 'tool credentials file', severity: 'block' },
43  { re: /(^|\/)node_modules\//, detail: 'node_modules should never be committed', severity: 'block' },
44  { re: /(^|\/)(\.venv|venv|__pycache__)\//, detail: 'python environment / cache', severity: 'block' },
45  { re: /\.(sqlite3?|db|sql\.gz|dump)$/i, detail: 'database file (may hold real data)', severity: 'warn' },
46]
47
48const mask = (s: string) => (s.length <= 8 ? '•'.repeat(s.length) : `${s.slice(0, 4)}…(${s.length} chars)`)
49
50// ───────────────────────── reading the command ─────────────────────────
51
52const tokens = (seg: string) => (seg.match(/"[^"]*"|'[^']*'|\S+/g) ?? []).map(t => t.replace(/^["']|["']$/g, ''))
53
54type Plan = { allTracked: boolean; cwd?: string; push: boolean; hasAdd: boolean; commit: boolean }
55
56const plan = (command: string): Plan | null => {
57  const p: Plan = { allTracked: false, push: false, hasAdd: false, commit: false }
58  let cwd: string | undefined
59  for (const seg of command.split(/&&|\|\||;|\n/)) {
60    const t = tokens(seg.trim())
61    while (t.length && /^\w+=/.test(t[0])) t.shift()
62    if (t[0] === 'cd' && t[1] && t[1] !== '-') cwd = t[1]
63    if (t[0] !== 'git') continue
64    let i = 1
65    let gitDir: string | undefined
66    while (i < t.length && t[i].startsWith('-')) {
67      if (t[i] === '-C' && t[i + 1]) gitDir = t[i + 1]
68      i += t[i] === '-C' || t[i] === '-c' ? 2 : 1
69    }
70    const verb = t[i]
71    const rest = t.slice(i + 1)
72    if (gitDir) cwd = gitDir
73    if (verb === 'add') p.hasAdd = true
74    if (verb === 'commit') {
75      p.commit = true
76      if (rest.some(a => a === '--all' || /^-[a-zA-Z]*a[a-zA-Z]*$/.test(a))) p.allTracked = true
77    }
78    if (verb === 'push' && !rest.includes('--dry-run')) p.push = true
79  }
80  p.cwd = cwd
81  return p.commit || p.push ? p : null
82}
83
84// ───────────────────────── scanning ─────────────────────────
85
86const git = async ($: $, cwd: string | undefined, args: string[]) => {
87  const r = await $.process.run(['git', ...args], { cwd, timeoutMs: 20_000 }).catch(() => undefined)
88  return r && r.exitCode === 0 ? r.stdout : ''
89}
90
91const isIgnored = (path: string, ignores: string[]) => ignores.some(i => path.includes(i))
92
93const scanLine = (file: string, line: number, text: string, ignores: string[], out: Finding[]) => {
94  if (!file || file === '/dev/null' || isIgnored(file, ignores) || text.length > 4000 || /commit-guard:ignore/.test(text)) return
95  for (const rule of SECRET_RULES) {
96    const m = rule.re.exec(text)
97    if (!m) continue
98    if (!rule.strict && PLACEHOLDER.test(text)) continue
99    out.push({ kind: 'secret', severity: 'block', where: `${file}:${line}`, detail: `${rule.name} ${mask(m[0].replace(/^.*?[:=]\s*['"]?/, ''))}` })
100    return
101  }
102}
103
104const scanDiff = (diff: string, ignores: string[], out: Finding[]) => {
105  let file = ''
106  let line = 0
107  for (const raw of diff.split('\n')) {
108    if (raw.startsWith('+++ ')) {
109      file = raw.slice(4).replace(/^b\//, '')
110      continue
111    }
112    const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)/.exec(raw)
113    if (hunk) {
114      line = Number(hunk[1])
115      continue
116    }
117    if (!raw.startsWith('+')) continue
118    scanLine(file, line, raw.slice(1), ignores, out)
119    line++
120  }
121}
122
123const scanPaths = async ($: $, cwd: string | undefined, paths: string[], ignores: string[], out: Finding[]) => {
124  const base = cwd ? `${cwd.replace(/[\\/]+$/, '')}/` : ''
125  for (const path of paths) {
126    if (isIgnored(path, ignores)) continue
127    const rule = FILE_RULES.find(r => r.re.test(path))
128    if (rule) out.push({ kind: 'file', severity: rule.severity, where: path, detail: rule.detail })
129    const stat = await $.fs.stat(base + path).catch(() => undefined)
130    if (stat && stat.kind === 'file' && stat.size >= WARN_BYTES) {
131      const mb = (stat.size / 1048576).toFixed(1)
132      out.push({ kind: 'size', severity: stat.size >= BLOCK_BYTES ? 'block' : 'warn', where: path, detail: `${mb} MB${stat.size >= BLOCK_BYTES ? ' (GitHub rejects files over 100 MB)' : ''}` })
133    }
134  }
135}
136
137const scan = async ($: $, p: Plan): Promise<{ findings: Finding[]; checked: number }> => {
138  const cwd = p.cwd
139  const prefix = cwd ? `${cwd}/` : ''
140  const findings: Finding[] = []
141  const ignoreText = await $.fs.read(`${prefix}.commitguardignore`).catch(() => '')
142  const ignores = ignoreText.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#'))
143  const paths = new Set<string>()
144
145  if (p.commit) {
146    if (p.hasAdd) {
147      // `git add … && git commit`: the add has not run yet, so look at the whole working tree.
148      const status = await git($, cwd, ['status', '--porcelain', '-uall'])
149      const untracked: string[] = []
150      for (const l of status.split('\n').filter(Boolean)) {
151        const path = l.slice(3).replace(/^"|"$/g, '').replace(/.* -> /, '')
152        paths.add(path)
153        if (l.startsWith('??')) untracked.push(path)
154      }
155      scanDiff(await git($, cwd, ['diff', 'HEAD', '-U0', '--no-color']), ignores, findings)
156      for (const path of untracked) {
157        const stat = await $.fs.stat(`${prefix}${path}`).catch(() => undefined)
158        if (!stat || stat.kind !== 'file' || stat.size > MAX_UNTRACKED_READ) continue
159        const text = await $.fs.read(`${prefix}${path}`).catch(() => '')
160        text.split('\n').forEach((t, i) => scanLine(path, i + 1, t, ignores, findings))
161      }
162    } else {
163      const ref = p.allTracked ? ['HEAD'] : ['--cached']
164      for (const f of (await git($, cwd, ['diff', ...ref, '--name-only', '--diff-filter=ACMR'])).split('\n').filter(Boolean)) paths.add(f)
165      scanDiff(await git($, cwd, ['diff', ...ref, '-U0', '--no-color']), ignores, findings)
166    }
167  }
168
169  if (p.push) {
170    // Everything committed locally that no remote has seen yet.
171    const names = await git($, cwd, ['log', 'HEAD', '--not', '--remotes', '--name-only', '--format=', '--diff-filter=ACMR'])
172    for (const f of names.split('\n').filter(Boolean)) paths.add(f)
173    scanDiff(await git($, cwd, ['log', 'HEAD', '--not', '--remotes', '-p', '-U0', '--no-color', '--format=']), ignores, findings)
174  }
175
176  await scanPaths($, cwd, [...paths], ignores, findings)
177  const seen = new Set<string>()
178  const unique = findings.filter(f => {
179    const key = `${f.kind}|${f.where}|${f.detail}`
180    if (seen.has(key)) return false
181    seen.add(key)
182    return true
183  })
184  return { findings: unique, checked: paths.size }
185}
186
187const describe = (f: Finding) => `${f.severity === 'block' ? '•' : '◦'} ${f.kind}: ${f.detail} at ${f.where}`
188
189// ───────────────────────── the hooks ─────────────────────────
190
191export const register: Register = on => {
192  on('session.start', async ($, e, next) => {
193    await $.command.register({ name: 'commit-guard', description: 'Commit Guard: status | scan | allow-once | on | off' })
194    return next(e)
195  })
196
197  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
198    const raw = (e as { command?: unknown }).command
199    const command = typeof raw === 'string' ? raw : ''
200    if (!/\bgit\b/.test(command) || ((await $.store.get('enabled')) ?? true) === false) return next(e)
201
202    const p = plan(command)
203    if (!p) return next(e)
204
205    const now = await $.clock.now()
206    const action = [p.commit && 'commit', p.push && 'push'].filter(Boolean).join(' + ')
207    const { findings, checked } = await scan($, p)
208    const blockers = findings.filter(f => f.severity === 'block')
209    const warns = findings.filter(f => f.severity === 'warn')
210
211    if (blockers.length > 0) {
212      if (await read($, allowOnce)) {
213        await update($, allowOnce, () => false)
214        await update($, last, () => ({ at: now, verdict: 'allowed', action, checked, findings }))
215        $.ui.toast(`🛡️ Commit Guard: ${blockers.length} issue${blockers.length > 1 ? 's' : ''} overridden for this ${action}.`, { timeoutMs: 6000 })
216        return next(e)
217      }
218      await update($, last, () => ({ at: now, verdict: 'blocked', action, checked, findings }))
219      await $.store.set('blockedTotal', Number((await $.store.get('blockedTotal')) ?? 0) + 1)
220      $.ui.toast(`🛡️ Commit Guard blocked a git ${action}: ${blockers.length} problem${blockers.length > 1 ? 's' : ''} found. /commit-guard for details.`, { timeoutMs: 9000 })
221      return {
222        deny:
223          `Commit Guard blocked this git ${action}: ${blockers.length} problem${blockers.length > 1 ? 's' : ''} in the change.\n` +
224          findings.slice(0, 12).map(describe).join('\n') +
225          '\nFix: unstage or remove the file (git restore --staged <file>), add it to .gitignore, replace the secret with an environment variable, and rotate any key that was exposed. ' +
226          'Do NOT retry the same command. If you believe this is a false positive, tell the user: they can run /commit-guard allow-once, add a path fragment to .commitguardignore, or mark a line with "commit-guard:ignore".',
227      }
228    }
229
230    await update($, last, () => ({ at: now, verdict: 'clean', action, checked, findings }))
231    if (warns.length > 0) $.ui.toast(`🛡️ Commit Guard (heads-up): ${warns.map(describe).join(' · ')}`, { timeoutMs: 8000 })
232    else $.ui.toast(`🛡️ Commit Guard: ${checked} file${checked === 1 ? '' : 's'} checked, clean.`, { timeoutMs: 2500 })
233    return next(e)
234  }).catch(($, e, next) => {
235    // Fail open, but never silently: a guard that quietly stops guarding is worse than none.
236    $.ui.toast('🛡️ Commit Guard hit an error and skipped its check this time.', { timeoutMs: 6000 })
237    return next(e)
238  })
239
240  on('command.run', { command: 'commit-guard' }, async ($, e) => {
241    const arg = e.args.trim().toLowerCase()
242    if (arg === 'on' || arg === 'off') {
243      await $.store.set('enabled', arg === 'on')
244      return { text: arg === 'on' ? '🛡️ Commit Guard is ON.' : '⚠️ Commit Guard is OFF. Commits and pushes will not be checked.' }
245    }
246    if (arg === 'allow-once') {
247      await update($, allowOnce, () => true)
248      return { text: '🛡️ The next blocked commit or push will be let through, once. Tell Claude to retry.' }
249    }
250    if (arg === 'scan') {
251      const { findings, checked } = await scan($, { allTracked: true, push: true, hasAdd: true, commit: true })
252      if (findings.length === 0) return { text: `🛡️ Scanned ${checked} changed files plus unpushed commits: all clean.` }
253      return { text: `🛡️ ${findings.length} finding${findings.length > 1 ? 's' : ''}:\n${findings.map(describe).join('\n')}` }
254    }
255    const l = await read($, last)
256    const total = Number((await $.store.get('blockedTotal')) ?? 0)
257    const isOn = ((await $.store.get('enabled')) ?? true) !== false
258    return {
259      text:
260        `🛡️ Commit Guard is ${isOn ? 'ON' : 'OFF'} · ${total} commit${total === 1 ? '' : 's'} blocked all time\n` +
261        (l ? `Last check: ${l.verdict} (${l.action}, ${l.checked} files)${l.findings.length ? `\n${l.findings.map(describe).join('\n')}` : ''}` : 'No commits checked yet this session.') +
262        '\n\n/commit-guard scan · allow-once · on · off',
263    }
264  })
265
266  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
267    if (e.props.hasSurvey) return next(e)
268    const l = await read($, last)
269    const now = await $.clock.now()
270    if (!l || l.verdict === 'clean' || now - l.at > BAND_MS) return next(e)
271    const { Box, Text } = $.ui.resolve(e)
272    const blockers = l.findings.filter(f => f.severity === 'block')
273    return (
274      <Box flexDirection="column">
275        <Text color={l.verdict === 'blocked' ? 'red' : 'yellow'} bold>
276          {l.verdict === 'blocked' ? `🛡️ Commit Guard blocked git ${l.action}: ${blockers.length} problem${blockers.length > 1 ? 's' : ''}` : `🛡️ Commit Guard override used for git ${l.action}`}
277        </Text>
278        {l.findings.slice(0, 3).map(f => (
279          <Text key={`${f.kind}|${f.where}|${f.detail}`} dimColor>{'  ' + describe(f)}</Text>
280        ))}
281      </Box>
282    )
283  })
284}
285
types/index.d.ts 16 lines
1export type Finding = { kind: 'secret' | 'file' | 'size'; severity: 'block' | 'warn'; where: string; detail: string }
2
3export type Report = {
4  at: number
5  verdict: 'clean' | 'blocked' | 'allowed'
6  action: string
7  checked: number
8  findings: Finding[]
9}
10
11declare module 'claude-code' {
12  interface PluginState {
13    'commit-guard': { last: Report | null; allowOnce: boolean }
14  }
15}
16