Blocks git commit and git push from Claude when the change carries a secret file or a credential-shaped line, and flags secret files that are not gitignored

A Claude Code mod that stops Claude from committing or pushing secrets.
It hooks the Bash tool. When a command runs git commit or git push (directly, through rtk, after a cd, or with git -C), the mod inspects what that command would publish and refuses the call if it finds a secret. Claude gets the file, line and pattern in the refusal, with the fix.
What it checks:
git add, or the commit uses -a, it widens the check to tracked working-tree changes and untracked files that are not gitignored.HEAD that no remote-tracking branch holds yet (git log HEAD --not --remotes), so a secret committed earlier and deleted later is still caught before it leaves the machine.Secret files by name: .env and .env.* (not .example, .sample, .template, .dist), .apikeys*, *.key, *.pfx, *.p12, *.jks, *.keystore, *.kdbx, *.ppk, *.tfstate, terraform.tfvars, SSH private keys, Oracle wallets (cwallet.sso, ewallet.p12), credentials, .netrc, .pgpass, .git-credentials.
Secret lines by content: private key blocks, AWS access keys, GitHub, Anthropic, OpenAI and Slack tokens, Azure storage and shared access keys, SAS signatures, JWTs, Password= in connection strings, credentials in URLs, and password|secret|api_key|client_secret|token = "<literal>" assignments. Placeholders and lookups (${var.x}, lookup(...), os.environ, changeme, <...>) are skipped.
Add a path glob to .secret-guard-allow at the repo root, one per line, # for comments. * matches within a path segment, ** across segments.
# Public test fixtures
tests/fixtures/*.key
Only you edit this file. The mod refuses Claude's Edit and Write calls on it. It also refuses any shell line that would write to it, move it or delete it: redirects, tee, sed -i, cp/mv/rm/touch, git checkout/restore, the PowerShell *-Content/*-Item cmdlets, and Python or Node file writes. Reads and plain mentions pass, including cd repo && cat .secret-guard-allow and a heredoc that only names the file. The check guards against Claude editing the list by mistake. It is not proof against deliberate obfuscation.
It guards Claude's tool calls only. Commits you make in your own shell are not checked; pair it with a git pre-commit hook such as gitleaks if you want that too. The command parsing follows cd, &&, ;, | and git -C; it is not a full shell parser.
Clone this repo and point Claude Code at it, either through the mods folder:
git clone <repo-url> ~/REPOS/secret-guard
ln -s ~/REPOS/secret-guard ~/.claude/mods/secret-guard
with ~/.claude/settings.json holding "env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods" }, or for one session with claude --plugin-dir ~/REPOS/secret-guard.
On Windows, use New-Item -ItemType SymbolicLink -Path "$env:USERPROFILE\.claude\mods\secret-guard" -Target "$env:USERPROFILE\REPOS\secret-guard".
git -C ~/REPOS/secret-guard pull
A running session reloads the mod when the folder changes.
claude plugin validate .
claude plugin test .hooks/register.ts 292 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// Paths that hold secrets by their name alone. Binary formats (pfx, p12, wallets) cannot be read for
4// content, so their name is the only signal. Public certificates (.pem, .crt) are left to the content
5// scan, which catches the private key block when one is in them.
6const SECRET_NAME =
7 /(^|\/)(\.env(\.[^/]+)?|\.apikeys[^/]*|\.env\.[^/]+|[^/]+\.(key|pfx|p12|jks|keystore|kdbx|ppk|tfstate|tfstate\.backup)|id_(rsa|dsa|ecdsa|ed25519)|cwallet\.sso|ewallet\.p12|credentials(\.json)?|\.netrc|\.pgpass|\.git-credentials|terraform\.tfvars)$/i
8const TEMPLATE_NAME = /\.(example|sample|template|dist)$/i
9
10// Patterns read on the lines a commit or push adds. Each is specific enough that a hit is worth a stop.
11const SECRET_LINE: [string, RegExp][] = [
12 ['private key', /-----BEGIN (?:[A-Z]+ )?PRIVATE KEY-----/],
13 ['AWS access key', /\bAKIA[0-9A-Z]{16}\b/],
14 ['GitHub token', /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{40,})\b/],
15 ['Anthropic key', /\bsk-ant-[A-Za-z0-9_-]{20,}/],
16 ['OpenAI key', /\bsk-(?:proj-)?[A-Za-z0-9]{32,}\b/],
17 ['Slack token', /\bxox[abprs]-[A-Za-z0-9-]{10,}/],
18 ['Azure storage key', /AccountKey=[A-Za-z0-9+/]{40,}={0,2}/],
19 ['Azure shared access key', /SharedAccessKey=[A-Za-z0-9+/]{30,}={0,2}/],
20 ['SAS signature', /[?&]sig=[A-Za-z0-9%+/]{30,}/],
21 ['JWT', /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/],
22 ['connection string password', /\b(?:Password|Pwd)=[^;'"\s]{6,}/i],
23 ['URL credentials', /\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:[^\s:/@]{6,}@/i],
24 [
25 'assigned secret',
26 /\b(?:password|passwd|secret|api[_-]?key|apikey|access[_-]?key|client[_-]?secret|auth[_-]?token|private[_-]?key)\b["']?\s*[:=]\s*["']?(?![$<{(%]|\bnull\b|\btrue\b|\bfalse\b)[^\s"'`,;)]{8,}/i,
27 ],
28]
29// Values on an "assigned secret" hit that are placeholders, not secrets.
30const PLACEHOLDER = /changeme|change-me|example|placeholder|your[_-]|redacted|x{6,}|\*{4,}|<[^>]*>|\.\.\.|lookup|getenv|environ|process\.env|\$env:|os\.environ/i
31
32const ALLOW_FILE = '.secret-guard-allow'
33// Shell forms that write, move or delete the allow list. Checked per line, so a heredoc that only
34// mentions the file (a note, a README) passes, and so does any read (`cd repo && cat ...`).
35// Guards against Claude editing the list, not against deliberate obfuscation.
36const AL = String.raw`\.secret-guard-allow\b`
37const WRITES_ALLOW: RegExp[] = [
38 new RegExp(String.raw`>{1,2}\|?\s*["']?[^\s"'<>|;&]*${AL}`), // redirect into it
39 new RegExp(String.raw`\btee\b[^|;&\n]*${AL}`),
40 new RegExp(String.raw`\b(?:sed|perl|ruby)\b[^|;&\n]*\s-[a-zA-Z]*i[^|;&\n]*${AL}`),
41 new RegExp(String.raw`\b(?:cp|mv|rm|ln|touch|truncate|install|dd|shred|unlink|chmod|chown)\b[^|;&\n]*${AL}`),
42 new RegExp(String.raw`\bgit\b[^|;&\n]*\b(?:checkout|restore|rm|mv|apply|stash)\b[^|;&\n]*${AL}`),
43 new RegExp(String.raw`\b(?:Set-Content|Add-Content|Clear-Content|Out-File|New-Item|Remove-Item|Copy-Item|Move-Item|Rename-Item)\b[^|;&\n]*${AL}`, 'i'),
44 new RegExp(String.raw`${AL}["'\x60]?\s*,\s*["'\x60]?[wax]`), // open('.secret-guard-allow', 'w')
45 new RegExp(String.raw`(?:write_text|write_bytes|writeFile|writeFileSync|appendFile|WriteAllText|AppendAllText)\b[^\n]*${AL}`),
46 new RegExp(String.raw`${AL}[^\n]*\.(?:write_text|write_bytes|unlink|rename|replace)\(`),
47]
48
49export function writesAllowFile(command: string): boolean {
50 return command.split('\n').some(line => line.includes(ALLOW_FILE) && WRITES_ALLOW.some(r => r.test(line)))
51}
52
53type Git = { dir: string; sub: string; args: string[] }
54type Finding = { file: string; line?: number; what: string; shown?: string }
55
56export const register: Register = on => {
57 // The allow list is Tim's to edit. Claude reading it is fine; Claude writing it would defeat the guard.
58 on('tool.call', { tool: 'Edit' }, ($, e, next) => (isAllowFile(e.file_path) ? { deny: allowDeny($.plugin.name) } : next(e)))
59 on('tool.call', { tool: 'Write' }, ($, e, next) => (isAllowFile(e.file_path) ? { deny: allowDeny($.plugin.name) } : next(e)))
60
61 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
62 if (writesAllowFile(e.command)) return { deny: allowDeny($.plugin.name) }
63
64 const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? ''
65 const calls = gitCalls(e.command, await $.session.cwd(), home)
66 const adds = calls.some(c => c.sub === 'add')
67
68 for (const call of calls) {
69 if (call.sub !== 'commit' && call.sub !== 'push') continue
70 const root = await gitText($, call.dir, ['rev-parse', '--show-toplevel'])
71 if (root === undefined) continue
72 const allow = await readAllow($, root.trim())
73
74 const found =
75 call.sub === 'commit'
76 ? await scanCommit($, root.trim(), adds || call.args.some(a => a === '-a' || a === '--all' || /^-[a-zA-Z]*a/.test(a)))
77 : await scanPush($, root.trim())
78 const blocking = found.filter(f => !allowed(allow, f.file))
79
80 const loose = (await untrackedSecrets($, root.trim())).filter(f => !allowed(allow, f))
81 if (loose.length > 0 && blocking.length === 0) {
82 $.ui.toast(`secret-guard: not gitignored in ${baseName(root.trim())}: ${loose.slice(0, 3).join(', ')}${loose.length > 3 ? ' …' : ''}`)
83 }
84
85 if (blocking.length > 0) return { deny: denyText($.plugin.name, call.sub, blocking, loose) }
86 }
87
88 return next(e)
89 })
90}
91
92// --- what a command runs ---------------------------------------------------------------------
93
94// Each git invocation in a shell command, with the directory it runs in. Follows `cd` between
95// segments and `git -C`. Good enough for the commands Claude writes; not a shell parser.
96export function gitCalls(command: string, cwd: string, home: string): Git[] {
97 const calls: Git[] = []
98 let dir = cwd
99 for (const raw of command.split(/&&|\|\||;|\n|\|/)) {
100 const words = tokens(raw)
101 while (words.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0])) words.shift()
102 if (words[0] === 'rtk') words.shift()
103 if (words[0] === 'cd' || words[0] === 'pushd' || words[0] === 'Set-Location') {
104 if (words[1] !== undefined) dir = resolve(dir, words[1], home)
105 continue
106 }
107 if (words[0] !== 'git') continue
108 let at = dir
109 let i = 1
110 while (i < words.length && words[i].startsWith('-')) {
111 if (words[i] === '-C' && words[i + 1] !== undefined) {
112 at = resolve(at, words[i + 1], home)
113 i += 2
114 } else if (words[i] === '-c' || words[i] === '--git-dir' || words[i] === '--work-tree') i += 2
115 else i += 1
116 }
117 if (words[i] !== undefined) calls.push({ dir: at, sub: words[i], args: words.slice(i + 1) })
118 }
119 return calls
120}
121
122function tokens(text: string): string[] {
123 return [...text.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)].map(m => m[1] ?? m[2] ?? m[3])
124}
125
126function resolve(from: string, to: string, home: string): string {
127 const path = to.replace(/^~(?=$|[\\/])/, home)
128 if (/^([A-Za-z]:)?[\\/]/.test(path)) return path
129 const parts = from.replace(/\\/g, '/').split('/')
130 for (const part of path.replace(/\\/g, '/').split('/')) {
131 if (part === '..') parts.pop()
132 else if (part !== '.' && part !== '') parts.push(part)
133 }
134 return parts.join('/') || '/'
135}
136
137// --- scans -----------------------------------------------------------------------------------
138
139async function scanCommit($: EngineInterface, root: string, wide: boolean): Promise<Finding[]> {
140 const names = lines(await gitText($, root, ['diff', '--cached', '--name-only', '--diff-filter=ACMR']))
141 const patches = [(await gitText($, root, ['diff', '--cached', '-U0', '--no-color', '--no-ext-diff'])) ?? '']
142 if (wide) {
143 names.push(...lines(await gitText($, root, ['diff', '--name-only', '--diff-filter=ACMR'])))
144 patches.push((await gitText($, root, ['diff', '-U0', '--no-color', '--no-ext-diff'])) ?? '')
145 for (const file of lines(await gitText($, root, ['ls-files', '--others', '--exclude-standard']))) {
146 names.push(file)
147 const text = await readText($, `${root}/${file}`)
148 if (text !== undefined) patches.push(asPatch(file, text))
149 }
150 }
151 return [...byName(names), ...byContent(patches.join('\n'))]
152}
153
154async function scanPush($: EngineInterface, root: string): Promise<Finding[]> {
155 // Commits on HEAD that no remote-tracking branch holds yet: what this push would publish.
156 const names = lines(await gitText($, root, ['log', '--format=', '--name-only', '--diff-filter=ACMR', 'HEAD', '--not', '--remotes']))
157 const patch = (await gitText($, root, ['log', '--format=', '-p', '-U0', '--no-color', '--no-ext-diff', 'HEAD', '--not', '--remotes'])) ?? ''
158 return [...byName(names), ...byContent(patch)]
159}
160
161async function untrackedSecrets($: EngineInterface, root: string): Promise<string[]> {
162 return lines(await gitText($, root, ['ls-files', '--others', '--exclude-standard'])).filter(isSecretName)
163}
164
165export function isSecretName(path: string): boolean {
166 return SECRET_NAME.test(path) && !TEMPLATE_NAME.test(path)
167}
168
169function byName(names: string[]): Finding[] {
170 return [...new Set(names)].filter(isSecretName).map(file => ({ file, what: 'secret file by name' }))
171}
172
173// Reads a unified diff and reports each added line that matches a secret pattern.
174export function byContent(patch: string): Finding[] {
175 const found: Finding[] = []
176 let file = ''
177 let line = 0
178 for (const text of patch.split('\n')) {
179 if (text.startsWith('+++ ')) {
180 file = text.slice(4).replace(/^b\//, '')
181 continue
182 }
183 const hunk = /^@@ -\S+ \+(\d+)/.exec(text)
184 if (hunk) {
185 line = Number(hunk[1])
186 continue
187 }
188 if (!text.startsWith('+')) continue
189 const added = text.slice(1)
190 for (const [what, pattern] of SECRET_LINE) {
191 const hit = pattern.exec(added)
192 if (hit === null) continue
193 if (what === 'assigned secret' && PLACEHOLDER.test(hit[0])) continue
194 found.push({ file, line, what, shown: mask(hit[0]) })
195 break
196 }
197 line += 1
198 }
199 return found
200}
201
202function asPatch(file: string, text: string): string {
203 return [`+++ b/${file}`, '@@ -0,0 +1 @@', ...text.split('\n').map(l => `+${l}`)].join('\n')
204}
205
206function mask(text: string): string {
207 return text.length <= 10 ? `${text.slice(0, 3)}…` : `${text.slice(0, 10)}…(${text.length} chars)`
208}
209
210// --- the allow list --------------------------------------------------------------------------
211
212// One path glob per line (`*` within a segment, `**` across), relative to the repo root. `#` comments.
213async function readAllow($: EngineInterface, root: string): Promise<RegExp[]> {
214 const text = await readText($, `${root}/${ALLOW_FILE}`)
215 if (text === undefined) return []
216 return text
217 .split('\n')
218 .map(l => l.replace(/#.*/, '').trim())
219 .filter(l => l !== '')
220 .map(glob)
221}
222
223export function glob(pattern: string): RegExp {
224 const body = pattern
225 .replace(/^\.?\//, '')
226 .replace(/[.+^${}()|[\]\\]/g, '\\$&')
227 .replace(/\*\*/g, '\u0000')
228 .replace(/\*/g, '[^/]*')
229 .replace(/\?/g, '[^/]')
230 .replace(/\u0000/g, '.*')
231 return new RegExp(`^${body}$`)
232}
233
234function allowed(allow: RegExp[], file: string): boolean {
235 return allow.some(r => r.test(file))
236}
237
238function isAllowFile(path: string): boolean {
239 return path.replace(/\\/g, '/').endsWith(`/${ALLOW_FILE}`) || path === ALLOW_FILE
240}
241
242// --- text ------------------------------------------------------------------------------------
243
244function denyText(plugin: string, sub: string, found: Finding[], loose: string[]): string {
245 const rows = found
246 .slice(0, 15)
247 .map(f => ` ${f.file}${f.line ? `:${f.line}` : ''} ${f.what}${f.shown ? ` ${f.shown}` : ''}`)
248 const more = found.length > 15 ? [` …and ${found.length - 15} more`] : []
249 const unignored = loose.length > 0 ? [``, `Not gitignored: ${loose.join(', ')}`] : []
250 return [
251 `${plugin}: git ${sub} blocked, it would publish what looks like a secret:`,
252 ...rows,
253 ...more,
254 ...unignored,
255 ``,
256 sub === 'push'
257 ? `These are in local commits not yet pushed. Remove the secret from history (rewrite the unpushed commits), add the file to .gitignore, and rotate the credential if it was ever pushed elsewhere.`
258 : `Unstage it (git restore --staged <file>, or git rm --cached <file> if already tracked), add it to .gitignore, then commit again.`,
259 `If a hit is a false positive, ask Tim to add its path to ${ALLOW_FILE} at the repo root. Do not edit that file yourself.`,
260 ].join('\n')
261}
262
263function allowDeny(plugin: string): string {
264 return `${plugin}: ${ALLOW_FILE} is edited by Tim only. Tell Tim which path to add and why.`
265}
266
267function lines(text: string | undefined): string[] {
268 return (text ?? '').split('\n').map(l => l.trim()).filter(l => l !== '')
269}
270
271function baseName(path: string): string {
272 return path.replace(/\\/g, '/').split('/').pop() ?? path
273}
274
275async function gitText($: EngineInterface, dir: string, args: string[]): Promise<string | undefined> {
276 try {
277 const ran = await $.process.run(['git', ...args], { cwd: dir, timeoutMs: 20_000 })
278 return ran.exitCode === 0 ? ran.stdout : undefined
279 } catch {
280 return undefined
281 }
282}
283
284async function readText($: EngineInterface, path: string): Promise<string | undefined> {
285 try {
286 const text = await $.fs.read(path)
287 return typeof text === 'string' && !text.includes('\u0000') ? text : undefined
288 } catch {
289 return undefined
290 }
291}
292