Stops Claude from writing API keys, tokens, private keys and database passwords into files.

Stops Claude from writing secrets into files. When a Write, Edit, MultiEdit or NotebookEdit would put a real-looking credential into a file, or a Bash command would echo, printf, tee or heredoc one into a file, the call is refused. Claude is told what it found and on which line, and to read the value from an environment variable instead, with the value kept in a gitignored .env. The secret itself is never repeated back.
🔒 secret-guard blocked a GitHub token in config.ts
It catches:
AKIA…, ASIA…)sk-ant-…), OpenAI keys (sk-proj-…, legacy sk-…), and other long sk-… keysghp_, gho_, ghu_, ghs_, ghr_, github_pat_)xoxb-, xoxp-, …) and Slack webhook URLssk_live_, rk_live_)AIza…)-----BEGIN … PRIVATE KEY----- with a key body)postgres://, mysql://, mongodb+srv://, redis://, amqp://)It lets these through:
xxxx, your-key-here, <api-key>, ${VAR}, process.env.X, AKIA…EXAMPLE, low-variety strings.localhost or a docker service name, documentation hosts like example.com, and default passwords like postgres:postgres..env, .env.local, .env.production and .dev.vars, which is where the deny message tells Claude to put secrets. .env.example, .env.sample and .env.template are still guarded, because those get committed.CLAUDE.md and AGENTS.md get extra-strict checks. In those files any credential-named assignment with a random-looking value (api_key: "q8Zr…") is blocked too. The deny reason also tells Claude why: these files are often committed and public, and in a public sample of 25,784 CLAUDE.md files, 275 contained real-looking secrets.
Run /secret-guard to see how many secrets it has blocked so far, broken down by type.
claude --plugin-dir ./secret-guard
or add this repo as a marketplace and install it with /plugin.
None. The rules live in hooks/detect.ts. Each one is a regex for a format a real service issues, followed by placeholder checks.
A tool.call hook reads the text a file tool is about to write, or the files a Bash command redirects into, and runs the detector on it. If anything new turns up, it answers { deny } so the tool never runs. The running count is kept in $.store.
hooks/register.ts 110 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import {
4 bashWriteTargets,
5 type Finding,
6 isEnvFile,
7 isPublicInstructions,
8 newSecrets,
9 withArticle,
10} from './detect'
11
12type Tally = { total: number; byType: Record<string, number> }
13
14/** What a file tool is about to write: the new text, and the text it replaces. */
15export type ProposedWrite = { path: string; after: string; before: string }
16
17const COMMAND = 'secret-guard'
18const PUBLIC_NOTE =
19 'CLAUDE.md and AGENTS.md are often committed and public: in a public sample of 25,784 CLAUDE.md files, 275 contained real-looking secrets. Keep secret values out of them entirely and name the environment variable instead.'
20
21/** The text a file tool call would write, or undefined for any other call. */
22export function proposedWrite(tool: string, args: Record<string, unknown>): ProposedWrite | undefined {
23 const text = (value: unknown) => (typeof value === 'string' ? value : '')
24 const edits = Array.isArray(args.edits) ? (args.edits as Record<string, unknown>[]) : []
25 switch (tool) {
26 case 'Write':
27 return { path: text(args.file_path), after: text(args.content), before: '' }
28 case 'Edit':
29 return { path: text(args.file_path), after: text(args.new_string), before: text(args.old_string) }
30 case 'MultiEdit':
31 return {
32 path: text(args.file_path),
33 after: edits.map(edit => text(edit.new_string)).join('\n'),
34 before: edits.map(edit => text(edit.old_string)).join('\n'),
35 }
36 case 'NotebookEdit':
37 return { path: text(args.notebook_path), after: text(args.new_source), before: '' }
38 }
39 return undefined
40}
41
42/** The deny reason: what was found and where, what to do instead; never the secret. */
43export function denyReason(findings: Finding[], where: string, isPublic: boolean): string {
44 const what = findings.map(f => `${withArticle(f.type)} (line ${f.line})`).join(', ')
45 const env = findings[0]?.env ?? 'API_KEY'
46 return [
47 `secret-guard blocked this: it would write ${what} into ${where}.`,
48 `Don't put secret values in files. Put the value in .env (and make sure .env is listed in .gitignore), read it from an environment variable such as ${env}, and use a placeholder like \${${env}} where the file needs to show it.`,
49 isPublic ? PUBLIC_NOTE : '',
50 ]
51 .filter(Boolean)
52 .join(' ')
53}
54
55async function block($: EngineInterface, findings: Finding[], where: string, isPublic: boolean) {
56 const tally = ((await $.store.get('blocked')) as Tally | undefined) ?? { total: 0, byType: {} }
57 for (const finding of findings) {
58 tally.total += 1
59 tally.byType[finding.type] = (tally.byType[finding.type] ?? 0) + 1
60 }
61 await $.store.set('blocked', tally)
62 const short = where.split(/[\\/]/).at(-1) ?? where
63 $.ui.toast(`🔒 secret-guard blocked ${withArticle(findings[0]!.type)} in ${short}`)
64 return { deny: denyReason(findings, where, isPublic) }
65}
66
67export const register: Register = on => {
68 on('session.start', async ($, e, next) => {
69 await $.command.register({ name: COMMAND, description: 'How many secrets secret-guard has kept out of files' })
70 return next(e)
71 })
72
73 on('command.run', { command: COMMAND }, async $ => {
74 const tally = (await $.store.get('blocked')) as Tally | undefined
75 if (!tally || tally.total === 0) return { text: "🔒 secret-guard hasn't had to block any secrets yet." }
76 const kinds = Object.entries(tally.byType).map(([type, n]) => `${n} × ${type}`)
77 return { text: `🔒 secret-guard has blocked ${tally.total} secret${tally.total === 1 ? '' : 's'} so far: ${kinds.join(', ')}.` }
78 })
79
80 on('tool.call', async ($, e, next) => {
81 const tool = String(e.tool)
82 const args = e as Record<string, unknown>
83
84 if (tool === 'Bash' && typeof args.command === 'string') {
85 const targets = bashWriteTargets(args.command).filter(target => !isEnvFile(target))
86 if (targets.length === 0) return next(e)
87 const isPublic = targets.some(isPublicInstructions)
88 const findings = newSecrets(args.command, '', { strict: isPublic })
89 return findings.length === 0 ? next(e) : block($, findings, `${targets.join(', ')} (via Bash)`, isPublic)
90 }
91
92 const write = proposedWrite(tool, args)
93 if (write === undefined || isEnvFile(write.path)) return next(e)
94
95 const isPublic = isPublicInstructions(write.path)
96 let findings = newSecrets(write.after, write.before, { strict: isPublic })
97 if (findings.length === 0) return next(e)
98
99 // Secrets the file already holds are not this write's doing.
100 const existing = await $.fs.read(write.path).catch(() => '')
101 findings = findings.filter(finding => !existing.includes(finding.value))
102 if (findings.length === 0) return next(e)
103
104 // Report Edit lines as lines of the file, not of the snippet.
105 const at = tool === 'Edit' ? existing.indexOf(write.before) : -1
106 const offset = at > 0 ? existing.slice(0, at).split('\n').length - 1 : 0
107 return block($, findings.map(f => ({ ...f, line: f.line + offset })), write.path, isPublic)
108 })
109}
110hooks/detect.ts 140 lines1// High-precision secret detection: each rule matches a credential format that
2// real services issue, then obvious placeholders are dropped. Better to miss a
3// homemade token than to block a README that shows `sk-your-key-here`.
4
5export type Finding = {
6 /** What kind of secret, as the deny reason names it ("GitHub token"). */
7 type: string
8 /** The environment variable the reason suggests instead. */
9 env: string
10 /** 1-based line in the text scanned. */
11 line: number
12 /** The secret itself: compared, never shown. */
13 value: string
14}
15
16type Rule = {
17 type: string
18 env: string
19 pattern: RegExp
20 /** The part of the match that is the secret (default: all of it). */
21 secret?: (match: RegExpExecArray) => string
22 /** Extra checks on a match that is not a placeholder. */
23 isReal?: (match: RegExpExecArray) => boolean
24}
25
26const LOCAL_HOST = /^(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|host\.docker\.internal|[^.]+)$/i
27const DOC_HOST = /(^|\.)(example\.(com|org|net)|example|test|invalid|localhost)$/i
28const DEFAULT_PASSWORD = /^(password|passwd|pass|pw|pwd|secret|root|admin|user|guest|test|postgres|mysql|mongo|redis|changeme)$/i
29
30const RULES: Rule[] = [
31 { type: 'AWS access key', env: 'AWS_ACCESS_KEY_ID', pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
32 { type: 'Anthropic API key', env: 'ANTHROPIC_API_KEY', pattern: /\bsk-ant-[a-z]+\d*-[A-Za-z0-9_-]{32,}/g },
33 {
34 type: 'OpenAI API key',
35 env: 'OPENAI_API_KEY',
36 pattern: /\bsk-(?:proj|svcacct|admin)-[A-Za-z0-9_-]{32,}|\bsk-[A-Za-z0-9]{20}T3BlbkFJ[A-Za-z0-9]{20}\b/g,
37 },
38 { type: 'API key (sk-…)', env: 'API_KEY', pattern: /\bsk-[A-Za-z0-9]{32,}\b/g },
39 {
40 type: 'GitHub token',
41 env: 'GITHUB_TOKEN',
42 pattern: /\bgh[pousr]_[A-Za-z0-9]{36}\b|\bgithub_pat_[A-Za-z0-9]{22}_[A-Za-z0-9]{59}\b/g,
43 },
44 { type: 'Slack token', env: 'SLACK_TOKEN', pattern: /\bxox[baprs]-\d{6,}-[A-Za-z0-9-]{10,}/g },
45 {
46 type: 'Slack webhook URL',
47 env: 'SLACK_WEBHOOK_URL',
48 pattern: /https:\/\/hooks\.slack\.com\/services\/T[A-Z0-9]{6,}\/B[A-Z0-9]{6,}\/[A-Za-z0-9]{20,}/g,
49 },
50 { type: 'Stripe live key', env: 'STRIPE_SECRET_KEY', pattern: /\b(?:sk|rk)_live_[A-Za-z0-9]{20,}\b/g },
51 { type: 'Google API key', env: 'GOOGLE_API_KEY', pattern: /\bAIza[0-9A-Za-z_-]{35}(?![0-9A-Za-z_-])/g },
52 {
53 type: 'private key',
54 env: 'PRIVATE_KEY_PATH',
55 // a header alone is documentation; a real key has a base64 body (after
56 // real newlines, or `\n` escapes inside a JSON string)
57 pattern: /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?-----(?:\\n|\s)+(?:[\w-]+:[^\n]*(?:\\n|\s)+)*[A-Za-z0-9+/]{40,}/g,
58 },
59 {
60 type: 'database URL with a password',
61 env: 'DATABASE_URL',
62 pattern: /\b(?:postgres(?:ql)?|mysql|mariadb|mongodb(?:\+srv)?|rediss?|amqps?):\/\/[^\s:@/'"`]+:([^\s@/'"`]+)@([^\s/:'"`?,]+)/g,
63 secret: match => match[1]!,
64 isReal: match =>
65 !DEFAULT_PASSWORD.test(match[1]!) &&
66 !/^[$%]/.test(match[1]!) &&
67 !LOCAL_HOST.test(match[2]!) &&
68 !DOC_HOST.test(match[2]!),
69 },
70]
71
72// Stricter, for files that tend to be public: any credential-named assignment
73// with a random-looking value.
74const ASSIGNMENT: Rule = {
75 type: 'credential-looking value',
76 env: 'API_KEY',
77 pattern:
78 /\b(?:api[_-]?key|secret(?:[_-]?key)?|access[_-]?token|auth[_-]?token|token|password|passwd)["']?\s*[:=]\s*["'`]?([A-Za-z0-9_\-+/=.]{16,})/gi,
79 secret: match => match[1]!,
80 isReal: match => /[A-Za-z]/.test(match[1]!) && /\d/.test(match[1]!) && new Set(match[1]).size >= 10,
81}
82
83const PLACEHOLDER =
84 /x{4,}|example|your|placeholder|dummy|fake|sample|changeme|redacted|replace|insert|<|>|\$\{|\{\{|\*{3,}|\.\.\.|…|0{8,}|1234567|abcdefg/i
85
86/** True for a value no one would issue: `sk-xxxx`, `<your-key>`, `${API_KEY}`, `AKIA…EXAMPLE`. */
87export function isPlaceholder(value: string): boolean {
88 return PLACEHOLDER.test(value) || new Set(value).size < 8
89}
90
91export function findSecrets(text: string, options: { strict?: boolean } = {}): Finding[] {
92 const rules = options.strict ? [...RULES, ASSIGNMENT] : RULES
93 const found: (Finding & { start: number; end: number })[] = []
94
95 for (const rule of rules) {
96 for (const match of text.matchAll(rule.pattern)) {
97 const start = match.index
98 const end = start + match[0].length
99 const value = rule.secret ? rule.secret(match) : match[0]
100 if (found.some(f => start < f.end && f.start < end)) continue // an earlier rule has it
101 if (isPlaceholder(value) || (rule.isReal && !rule.isReal(match))) continue
102 const line = text.slice(0, start).split('\n').length
103 found.push({ type: rule.type, env: rule.env, line, value, start, end })
104 }
105 }
106 return found.sort((a, b) => a.start - b.start).map(({ start, end, ...finding }) => finding)
107}
108
109/** Secrets in `after` that `before` does not already hold: what a write adds. */
110export function newSecrets(after: string, before: string, options: { strict?: boolean } = {}): Finding[] {
111 return findSecrets(after, options).filter(finding => !before.includes(finding.value))
112}
113
114const basename = (path: string) => path.split(/[\\/]/).at(-1) ?? path
115
116/** `.env`, `.env.local`, `.dev.vars`: where secrets belong. Not `.env.example`. */
117export function isEnvFile(path: string): boolean {
118 const name = basename(path.replace(/^["']|["']$/g, ''))
119 if (name === '.dev.vars') return true
120 return /^\.env(\.[\w.-]+)?$/.test(name) && !/\.(example|sample|template|dist|defaults?)$/i.test(name)
121}
122
123/** CLAUDE.md and AGENTS.md: instruction files that are often committed and public. */
124export function isPublicInstructions(path: string): boolean {
125 return /^(CLAUDE|AGENTS)\.md$/i.test(basename(path))
126}
127
128/** The files a shell command writes to with `>`, `>>` or `tee`. */
129export function bashWriteTargets(command: string): string[] {
130 const targets: string[] = []
131 for (const match of command.matchAll(/(?:^|[^<>&\d])\d?>>?\|?\s*([^\s;&|<>()]+)/g)) targets.push(match[1]!)
132 for (const match of command.matchAll(/\btee\s+(?:-\w+\s+)*([^\s;&|<>()]+)/g)) targets.push(match[1]!)
133 return targets
134 .map(target => target.replace(/^["']|["']$/g, ''))
135 .filter(target => !/^\/dev\/(null|stdout|stderr|tty)$/.test(target))
136}
137
138/** "a GitHub token", "an AWS access key". */
139export const withArticle = (type: string) => `${/^[AEIOU]/i.test(type) ? 'an' : 'a'} ${type}`
140