SLOPSHOPPER

secret-guard

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

newguardtoastprocess
v0.2.0MITupdated 2026-10-08tksunw/secret-guard
A shopper browsing a rack in a slop shop
README

secret-guard

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:

  • Commit: staged files and staged added lines. When the same command also runs git add, or the commit uses -a, it widens the check to tracked working-tree changes and untracked files that are not gitignored.
  • Push: every commit on 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.
  • Unignored secret files: a commit that is otherwise clean raises a toast naming any untracked secret file the repo does not ignore.

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.

False positives

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.

Limits

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.

Install

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".

Update

git -C ~/REPOS/secret-guard pull

A running session reloads the mod when the folder changes.

Develop

claude plugin validate .
claude plugin test .
Source 1 files
hooks/register.ts 292 lines
1import 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