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.

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.
| Kind | Examples |
|---|---|
| Secrets in code | AWS, GitHub, Anthropic, OpenAI, Stripe, Slack, Google keys · private key blocks · JWTs · DB URLs with passwords · hardcoded api_key = "…" |
| Crypto | Wallet 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/ |
| Size | Warns 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.
/plugin marketplace add choaticpixels/HauntedClaudeMod
/plugin install commit-guard@claude-mods
/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.commitguardignore file in the repo (one per line, # for comments).commit-guard:ignore in a comment on the offending line.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.
hooks/register.tsx 285 lines1import { 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}
285types/index.d.ts 16 lines1export 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