Stops before Claude writes API keys, tokens, private keys or a non-ignored .env into files, git add or commits. Exceptions for test keys and example files…

Stops before Claude writes API keys, tokens, private keys or a non-ignored .env into files, git add or commits. Exceptions for test keys and example files: /secret-allow.
Part of the Mod Box, which bundles it with other mods and one window to switch and configure them all. This is the same mod on its own.
In a terminal Claude Code session (2.1.287 or newer):
/plugin install secret-guard --marketplace LupusMalusDeviant/claude-mods
| Command | What it does |
|---|---|
/secret-allow | exceptions: last finds and both allow lists |
Set them in /config (or when installing).
| Option | Default | What it does |
|---|---|---|
mode | block | block: refuse the action. warn: only warn and let it through. |
placeholders | on | on: values with EXAMPLE, DUMMY, FAKE, PLACEHOLDER, REDACTED, SAMPLE, xxxxxx … are no secret (this covers the AWS documentation keys). |
language | en | Language of the texts this mod shows and writes. |
MIT, see LICENSE.
hooks/entry.ts 9 lines1// Entry of the standalone Secret Guard: the Mod Box code, registered on its own.
2import type { Register } from 'claude-code'
3
4import { register as inner } from '../mods/secret-guard/hooks/register'
5
6export const register: Register = (on, options) => {
7 ;(inner as any)(on, options)
8}
9mods/secret-guard/hooks/register.tsx 411 lines1// Secret Guard: the counterpart of Blast Radius for secrets. Stops before Claude
2// - writes an API key, token or private key into a file (Write, Edit, MultiEdit, NotebookEdit),
3// - writes or `git add`s a .env, .pem, id_rsa & co. that git does not ignore,
4// - commits while a secret sits in the staged changes,
5// - redirects a secret from the command line into a file.
6// Mode "block" refuses the action (Claude gets the reason), "warn" only reports.
7//
8// Exceptions, so test keys and example files pass:
9// - project: .secretguard-allow in the project root (can be committed, applies to the team),
10// - global: in the box's store (all projects).
11// An entry is a path in gitignore style (tests/fixtures/**, *.example.ts) or a value as
12// sha256:<hex>, so no secret ever stands in clear text in an exception list. Placeholders
13// (EXAMPLE, DUMMY, FAKE, xxxx …) pass on their own (option); that covers the documentation keys
14// of AWS too, which all contain "EXAMPLE".
15// Only the person changes the exceptions (window /secret-allow); Claude writing the list is stopped.
16import { atom, read, update } from 'claude-code'
17import type { EngineInterface, Register } from 'claude-code'
18
19import type { BlockedFind } from '../types'
20import { PATTERNS, isPlaceholder } from './patterns'
21
22type Hit = { what: string; where: string; path: string | null; value: string | null; hard?: boolean }
23
24const blocked = atom({ plugin: 'secret-guard', key: 'blocked' } as const, [] as BlockedFind[])
25const projectLines = atom({ plugin: 'secret-guard', key: 'project' } as const, [] as string[])
26const globalLines = atom({ plugin: 'secret-guard', key: 'global' } as const, [] as string[])
27
28export const ALLOW_FILE = '.secretguard-allow'
29const STORE_GLOBAL = 'secret-allow-global'
30const PANE = 'mod-box-secret-allow'
31const MAX_BLOCKED = 20
32
33// Display language and options (module level: the pane and the checks read them).
34let lang: 'de' | 'en' = 'en'
35const t = (de: string, en: string) => (lang === 'en' ? en : de)
36let placeholders = true
37let isWindows = false
38
39// still exported here for the tests and older imports
40export { PATTERNS, isPlaceholder }
41
42// Files that hold secrets; templates (.example, .sample, .template, .dist) are fine.
43export const SECRET_FILE = /(^|[\\/])(\.env(\.[\w-]+)?|[^\\/]*\.(pem|key|p12|pfx|keystore|jks)|id_(rsa|dsa|ecdsa|ed25519)|credentials(\.json)?|\.npmrc|\.pypirc|\.netrc)$/i
44const TEMPLATE = /\.(example|sample|template|dist)$/i
45
46export function findSecrets(text: string, where: string, path: string | null = null): Hit[] {
47 const hits: Hit[] = []
48 for (const [what, re] of PATTERNS) {
49 for (const m of text.matchAll(re)) hits.push({ what, where, path, value: m[0] })
50 }
51 return hits
52}
53
54export function isSecretFile(file: string): boolean {
55 return SECRET_FILE.test(file) && !TEMPLATE.test(file)
56}
57
58// Paths of a `git add …`; "." and "-A" count as "everything".
59export function gitAddPaths(command: string): string[] | 'all' | null {
60 const m = /(?:^|[;&|]\s*)git\s+(?:-C\s+\S+\s+)?add\s+([^;&|]*)/.exec(command)
61 if (!m) return null
62 const words = (m[1] ?? '').trim().split(/\s+/).filter(Boolean)
63 if (words.some(w => w === '.' || w === '-A' || w === '--all' || w === '*')) return 'all'
64 return words.filter(w => !w.startsWith('-'))
65}
66
67// ---------------------------------------------------------------- exceptions
68
69/** An exception line: a path pattern, or sha256:<hex>. Comments after # are dropped. */
70export function parseAllow(lines: string[]): { globs: string[]; hashes: Set<string> } {
71 const globs: string[] = []
72 const hashes = new Set<string>()
73 for (const raw of lines) {
74 const line = raw.replace(/\s+#.*$/, '').replace(/^#.*$/, '').trim()
75 if (!line) continue
76 const h = /^sha256:([0-9a-f]{64})$/i.exec(line)
77 if (h) hashes.add(h[1]!.toLowerCase())
78 else globs.push(line)
79 }
80 return { globs, hashes }
81}
82
83/** gitignore-like matching: no slash = any folder level; a slash = from the project root; ** spans folders. */
84export function globMatch(glob: string, path: string, ignoreCase = false): boolean {
85 let g = glob.trim().replace(/\\/g, '/')
86 if (!g) return false
87 const dirOnly = g.endsWith('/')
88 if (dirOnly) g = g.slice(0, -1)
89 const anchored = g.startsWith('/') || g.includes('/')
90 g = g.replace(/^\//, '')
91 let re = ''
92 for (let i = 0; i < g.length; i++) {
93 const c = g[i]!
94 if (c === '*' && g[i + 1] === '*') {
95 re += '.*'
96 i++
97 if (g[i + 1] === '/') i++
98 } else if (c === '*') re += '[^/]*'
99 else if (c === '?') re += '[^/]'
100 else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&')
101 }
102 const body = anchored ? `^${re}` : `(^|.*/)${re}`
103 return new RegExp(`${body}(/.*)?$`, ignoreCase ? 'i' : '').test(path.replace(/\\/g, '/').replace(/^\.\//, ''))
104}
105
106export async function sha256(text: string): Promise<string> {
107 const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
108 return [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, '0')).join('')
109}
110
111async function projectRoot($: EngineInterface): Promise<string> {
112 return (await $.session.cwd()).replace(/\\/g, '/').replace(/\/$/, '')
113}
114
115function relative(root: string, file: string): string {
116 const f = file.replace(/\\/g, '/')
117 const r = root + '/'
118 if (isWindows ? f.toLowerCase().startsWith(r.toLowerCase()) : f.startsWith(r)) return f.slice(r.length)
119 return f.replace(/^\.\//, '')
120}
121
122let projectCache: { path: string; mtime: number; lines: string[] } | null = null
123
124async function loadProjectLines($: EngineInterface): Promise<string[]> {
125 const file = `${await projectRoot($)}/${ALLOW_FILE}`
126 const st = await $.fs.stat(file).catch(() => null)
127 if (!st) {
128 projectCache = { path: file, mtime: 0, lines: [] }
129 return []
130 }
131 if (projectCache && projectCache.path === file && projectCache.mtime === st.mtimeMs) return projectCache.lines
132 const lines = String(await $.fs.read(file).catch(() => '')).split(/\r?\n/)
133 projectCache = { path: file, mtime: st.mtimeMs, lines }
134 return lines
135}
136
137async function loadGlobalLines($: EngineInterface): Promise<string[]> {
138 const saved = await $.store.get(STORE_GLOBAL).catch(() => null)
139 return Array.isArray(saved) ? saved.filter((x): x is string => typeof x === 'string') : []
140}
141
142/** Drops the hits an exception covers. */
143async function applyExceptions($: EngineInterface, hits: Hit[]): Promise<Hit[]> {
144 if (hits.length === 0) return hits
145 const all = parseAllow([...(await loadProjectLines($)), ...(await loadGlobalLines($))])
146 const out: Hit[] = []
147 for (const h of hits) {
148 if (h.hard) {
149 out.push(h)
150 continue
151 }
152 if (h.path && all.globs.some(g => globMatch(g, h.path!, isWindows))) continue
153 if (h.value) {
154 if (placeholders && isPlaceholder(h.value)) continue
155 if (all.hashes.has(await sha256(h.value))) continue
156 }
157 out.push(h)
158 }
159 return out
160}
161
162// ---------------------------------------------------------------- the guard
163
164export const register: Register = (on, options) => {
165 const mode = options.mode === 'warn' ? 'warn' : 'block'
166 lang = options.language === 'de' ? 'de' : 'en'
167 placeholders = options.placeholders !== 'off'
168
169 on('session.start', {}, async ($, e, next) => {
170 const r = await next(e)
171 isWindows = (await $.env.get('OS')) === 'Windows_NT'
172 await $.command.register({ name: 'secret-allow', description: t('Secret-Wächter: Ausnahmen verwalten', 'Secret Guard: manage exceptions') })
173 return r
174 })
175
176 on('command.run', { command: 'secret-allow' }, async $ => {
177 await refreshLists($)
178 await $.ui.open({ id: PANE, title: t('Secret-Wächter · Ausnahmen', 'Secret Guard · exceptions'), focus: true, closeOnEscape: true })
179 return { text: t('Ausnahmen des Secret-Wächters geöffnet.', 'Secret Guard exceptions opened.') }
180 })
181
182 on('tool.call', {}, async ($, e, next) => {
183 const found = await check($, e as unknown as Record<string, unknown>)
184 const hits = dedupe(await applyExceptions($, found))
185 if (hits.length === 0) return next(e)
186 const list = hits.map(h => `${h.what} (${h.where})`).join('; ')
187 const isHard = hits.some(h => h.hard)
188 await remember($, hits, mode === 'block' || isHard)
189 if (mode === 'warn' && !isHard) {
190 $.ui.toast(`${t('Secret-Wächter', 'Secret Guard')}: ${list}`)
191 return next(e)
192 }
193 $.ui.toast(`${t('Secret-Wächter hat angehalten', 'Secret Guard stopped')}: ${list} · /secret-allow`)
194 return {
195 deny: isHard
196 ? `Secret Guard stopped this: ${list}. The exception list ${ALLOW_FILE} is changed only by the user, through the Mod Box window (/secret-allow). Do not try to change it another way.`
197 : `Secret Guard stopped this action: ${list}. Secrets do not belong in files in the repository or in commits. ` +
198 'Use an environment variable or a file that is not versioned (in .gitignore), and write a placeholder instead. ' +
199 `If this is a known test key or an example file, the user can allow it with /secret-allow; do not edit ${ALLOW_FILE} yourself. ` +
200 'Do not retry it another way unless the user explicitly asks.',
201 }
202 })
203 // No .catch: if the check itself fails (no git, say), the action goes on rather than blocking every edit.
204
205 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => drawPane($, e))
206}
207
208async function check($: EngineInterface, e: Record<string, unknown>): Promise<Hit[]> {
209 const tool = String(e.tool)
210 const root = await projectRoot($)
211 const file = typeof e.file_path === 'string' ? e.file_path : typeof e.notebook_path === 'string' ? e.notebook_path : ''
212 if (tool === 'Write' || tool === 'Edit' || tool === 'MultiEdit' || tool === 'NotebookEdit') {
213 const rel = relative(root, file)
214 const where = file.split(/[\\/]/).pop() || file
215 // The exception list belongs to the person.
216 if (where === ALLOW_FILE) return [{ what: t('Änderung der Ausnahmeliste', 'change of the exception list'), where, path: rel, value: null, hard: true }]
217 const texts: string[] = []
218 if (typeof e.content === 'string') texts.push(e.content)
219 if (typeof e.new_string === 'string') texts.push(e.new_string)
220 if (typeof e.new_source === 'string') texts.push(e.new_source)
221 if (Array.isArray(e.edits)) for (const x of e.edits) if (x && typeof x.new_string === 'string') texts.push(x.new_string)
222 const hits = texts.flatMap(text => findSecrets(text, where, rel))
223 // A .env may hold secrets when git ignores it.
224 if (isSecretFile(file)) {
225 if (await isIgnored($, file)) return []
226 hits.push({ what: t('Geheimnis-Datei, die git nicht ignoriert', 'secret file that git does not ignore'), where, path: rel, value: null })
227 }
228 return hits
229 }
230 if (tool === 'Bash' && typeof e.command === 'string') {
231 const cmd = e.command
232 const hits: Hit[] = []
233 if (cmd.includes(ALLOW_FILE) && /(>|\btee\b|\bsed\s+-i|\brm\b|\bmv\b|\bcp\b|Set-Content|Add-Content|Out-File|Remove-Item|\btruncate\b)/.test(cmd)) {
234 hits.push({ what: t('Änderung der Ausnahmeliste', 'change of the exception list'), where: ALLOW_FILE, path: ALLOW_FILE, value: null, hard: true })
235 }
236 const add = gitAddPaths(cmd)
237 if (add && add !== 'all') for (const p of add) if (isSecretFile(p)) hits.push({ what: t('git add einer Geheimnis-Datei', 'git add of a secret file'), where: p, path: relative(root, p), value: null })
238 if (add === 'all') {
239 // What "git add ." would take: changed and new files that are not ignored.
240 const r = await $.process.run(['git', 'status', '--porcelain', '--untracked-files=all']).catch(() => null)
241 for (const line of r?.exitCode === 0 ? r.stdout.split('\n') : []) {
242 const p = line.slice(3).trim().replace(/^"|"$/g, '')
243 if (p && isSecretFile(p)) hits.push({ what: t('git add . würde eine Geheimnis-Datei aufnehmen', 'git add . would stage a secret file'), where: p, path: p, value: null })
244 }
245 }
246 if (/(?:^|[;&|]\s*)git\s+(?:-C\s+\S+\s+)?commit\b/.test(cmd)) {
247 const staged = await $.process.run(['git', 'diff', '--cached', '--name-only']).catch(() => null)
248 for (const p of staged?.exitCode === 0 ? staged.stdout.split('\n').filter(Boolean) : []) {
249 if (isSecretFile(p)) hits.push({ what: t('Commit mit einer Geheimnis-Datei', 'commit with a secret file'), where: p, path: p, value: null })
250 }
251 const diff = await $.process.run(['git', 'diff', '--cached', '-U0', '--no-color']).catch(() => null)
252 if (diff?.exitCode === 0) hits.push(...secretsInDiff(diff.stdout))
253 }
254 // A secret on the command line that is redirected into a file (echo KEY > file).
255 const target = /(?:>>?|\btee\s+(?:-a\s+)?)\s*(["']?)([^\s"';&|]+)\1/.exec(cmd)
256 if (target) hits.push(...findSecrets(cmd, t('Umleitung in eine Datei', 'redirect into a file'), relative(root, target[2]!)))
257 return hits
258 }
259 return []
260}
261
262/** Added lines of a staged diff, per file, so path exceptions apply. */
263export function secretsInDiff(diff: string): Hit[] {
264 const hits: Hit[] = []
265 let path: string | null = null
266 let added: string[] = []
267 const flush = () => {
268 if (path && added.length) hits.push(...findSecrets(added.join('\n'), path, path))
269 added = []
270 }
271 for (const line of diff.split('\n')) {
272 if (line.startsWith('+++ ')) {
273 flush()
274 path = line.slice(4).replace(/^b\//, '').trim()
275 if (path === '/dev/null') path = null
276 } else if (line.startsWith('+') && !line.startsWith('+++')) added.push(line.slice(1))
277 }
278 flush()
279 return hits
280}
281
282async function isIgnored($: EngineInterface, file: string): Promise<boolean> {
283 const r = await $.process.run(['git', 'check-ignore', '-q', file]).catch(() => null)
284 return r?.exitCode === 0
285}
286
287function dedupe(hits: Hit[]): Hit[] {
288 const seen = new Set<string>()
289 return hits.filter(h => {
290 const k = `${h.what}|${h.where}|${h.value ?? ''}`
291 if (seen.has(k)) return false
292 seen.add(k)
293 return true
294 })
295}
296
297// Keeps the last finds for the window (hash only, never the value).
298async function remember($: EngineInterface, hits: Hit[], stopped: boolean) {
299 const now = await $.clock.now()
300 const finds: BlockedFind[] = []
301 for (const h of hits) {
302 if (h.hard) continue
303 finds.push({ at: now, what: h.what, where: h.where, path: h.path, hash: h.value ? await sha256(h.value) : null, stopped })
304 }
305 if (finds.length) await update($, blocked, list => [...finds, ...list].slice(0, MAX_BLOCKED)).catch(() => {})
306}
307
308// ---------------------------------------------------------------- the exceptions window
309
310async function refreshLists($: EngineInterface) {
311 projectCache = null
312 const lines = await loadProjectLines($)
313 await update($, projectLines, () => lines.filter(l => l.trim() !== ''))
314 const g = await loadGlobalLines($)
315 await update($, globalLines, () => g)
316}
317
318async function addEntry($: EngineInterface, scope: 'project' | 'global', entry: string) {
319 const line = entry.trim()
320 if (!line) return
321 if (scope === 'global') {
322 const list = await loadGlobalLines($)
323 if (!list.includes(line)) await $.store.set(STORE_GLOBAL, [...list, line])
324 } else {
325 const file = `${await projectRoot($)}/${ALLOW_FILE}`
326 const existing = await $.fs.read(file).then(String).catch(() => '')
327 const header = existing ? '' : `# ${t('Ausnahmen für den Secret-Wächter der Mod Box: Pfade (gitignore-Stil) oder sha256:<hash> eines Werts.', 'Exceptions for the Mod Box Secret Guard: paths (gitignore style) or sha256:<hash> of a value.')}\n`
328 const lines = existing.split(/\r?\n/)
329 if (!lines.some(l => l.replace(/\s+#.*$/, '').trim() === line.replace(/\s+#.*$/, '').trim())) {
330 await $.fs.write(file, (existing ? existing.replace(/\s*$/, '\n') : header) + line + '\n')
331 }
332 }
333 $.ui.toast(t(`Ausnahme hinzugefügt (${scope === 'global' ? 'global' : ALLOW_FILE}).`, `Exception added (${scope === 'global' ? 'global' : ALLOW_FILE}).`))
334 await refreshLists($)
335}
336
337async function removeEntry($: EngineInterface, scope: 'project' | 'global', entry: string) {
338 if (scope === 'global') {
339 await $.store.set(STORE_GLOBAL, (await loadGlobalLines($)).filter(l => l !== entry))
340 } else {
341 const file = `${await projectRoot($)}/${ALLOW_FILE}`
342 const existing = await $.fs.read(file).then(String).catch(() => '')
343 await $.fs.write(file, existing.split(/\r?\n/).filter(l => l !== entry).join('\n'))
344 }
345 await refreshLists($)
346}
347
348async function drawPane($: EngineInterface, e: any) {
349 const { Box, Text, Button, Input } = $.ui.resolve(e) as any
350 const finds = await read($, blocked)
351 const proj = await read($, projectLines)
352 const glob = await read($, globalLines)
353 const when = (ms: number) => new Date(ms).toLocaleTimeString(lang === 'en' ? 'en-GB' : 'de-DE', { hour: '2-digit', minute: '2-digit' })
354 const entryRow = (scope: 'project' | 'global', line: string, i: number) =>
355 line.trim().startsWith('#') ? (
356 <Text key={`${scope}-c-${i}`} dimColor>{` ${line}`}</Text>
357 ) : (
358 <Box key={`${scope}-${i}`} flexDirection="row">
359 <Text>{` ${line.length > 90 ? line.slice(0, 89) + '…' : line}`}</Text>
360 <Text>{' '}</Text>
361 <Button key={`sa-rm-${scope}-${i}`} label={t('Entfernen', 'Remove')} plain onPress={() => void removeEntry($, scope, line)} />
362 </Box>
363 )
364 return (
365 <Box flexDirection="column" paddingX={1}>
366 <Text dimColor wrap="wrap">
367 {t(
368 `Ausnahmen lassen bekannte Test-Schlüssel und Beispieldateien durch. Einträge: ein Pfad im gitignore-Stil (tests/fixtures/**, *.example.ts) oder sha256:<hash> eines Werts – Schlüssel stehen nie im Klartext in der Liste. Projekt-Ausnahmen liegen in ${ALLOW_FILE} (kann ins Repo), globale gelten überall. Claude kann die Liste nicht ändern.`,
369 `Exceptions let known test keys and example files pass. Entries: a path in gitignore style (tests/fixtures/**, *.example.ts) or sha256:<hash> of a value – keys never stand in clear text in the list. Project exceptions live in ${ALLOW_FILE} (can be committed), global ones apply everywhere. Claude cannot change the list.`,
370 )}
371 </Text>
372
373 <Box flexDirection="column" marginTop={1}>
374 <Text bold>{t('Zuletzt gefunden', 'Last finds')}</Text>
375 {finds.length === 0 ? <Text dimColor>{t(' Noch nichts.', ' Nothing yet.')}</Text> : null}
376 {finds.map((f, i) => (
377 <Box key={`sa-f-${i}`} flexDirection="column">
378 <Text>
379 <Text color={f.stopped ? 'red' : 'yellow'}>{` ${f.stopped ? '✘' : '⚠'} ${f.what}`}</Text>
380 <Text dimColor>{` ${f.path ?? f.where} · ${when(f.at)}`}</Text>
381 </Text>
382 <Box flexDirection="row" paddingLeft={4}>
383 {f.path ? <Button key={`sa-fp-${i}`} label={t('Datei erlauben (Projekt)', 'Allow file (project)')} plain onPress={() => void addEntry($, 'project', f.path!)} /> : null}
384 {f.path && f.hash ? <Text>{' '}</Text> : null}
385 {f.hash ? <Button key={`sa-fv-${i}`} label={t('Wert erlauben (Projekt)', 'Allow value (project)')} plain onPress={() => void addEntry($, 'project', `sha256:${f.hash} # ${f.what} ${f.path ?? f.where}`)} /> : null}
386 {f.hash ? <Text>{' '}</Text> : null}
387 {f.hash ? <Button key={`sa-fg-${i}`} label={t('Wert erlauben (global)', 'Allow value (global)')} plain onPress={() => void addEntry($, 'global', `sha256:${f.hash} # ${f.what}`)} /> : null}
388 </Box>
389 </Box>
390 ))}
391 </Box>
392
393 <Box flexDirection="column" marginTop={1}>
394 <Text bold>{t(`Projekt (${ALLOW_FILE})`, `Project (${ALLOW_FILE})`)}</Text>
395 {proj.filter(l => !l.trim().startsWith('#')).length === 0 ? <Text dimColor>{t(' keine', ' none')}</Text> : null}
396 {proj.map((l, i) => entryRow('project', l, i))}
397 {Input ? <Input key="sa-add-project" label={t(' Neu: ', ' New: ')} placeholder="tests/fixtures/**" submitLabel={t('Hinzufügen', 'Add')} onSubmit={(v: string) => void addEntry($, 'project', v)} /> : null}
398 </Box>
399
400 <Box flexDirection="column" marginTop={1}>
401 <Text bold>{t('Global (alle Projekte)', 'Global (all projects)')}</Text>
402 {glob.length === 0 ? <Text dimColor>{t(' keine', ' none')}</Text> : null}
403 {glob.map((l, i) => entryRow('global', l, i))}
404 {Input ? <Input key="sa-add-global" label={t(' Neu: ', ' New: ')} placeholder="*.example.env" submitLabel={t('Hinzufügen', 'Add')} onSubmit={(v: string) => void addEntry($, 'global', v)} /> : null}
405 </Box>
406
407 <Text dimColor>{placeholders ? t('Platzhalter (EXAMPLE, DUMMY, FAKE, xxxx …) werden automatisch durchgelassen.', 'Placeholders (EXAMPLE, DUMMY, FAKE, xxxx …) pass automatically.') : t('Platzhalter-Erkennung ist aus.', 'Placeholder detection is off.')}</Text>
408 </Box>
409 )
410}
411mods/secret-guard/hooks/patterns.ts 27 lines1// The patterns of known secrets, on their own: Screen Mask uses them too, and a standalone
2// export of it then carries only these, not Secret Guard's hooks and state.
3
4// Patterns of known secrets. Kept narrow, so examples and placeholders pass.
5export const PATTERNS: Array<[string, RegExp]> = [
6 ['Anthropic-API-Key', /sk-ant-[A-Za-z0-9_-]{20,}/g],
7 ['OpenAI-API-Key', /\bsk-(?!ant-)(?:proj-)?[A-Za-z0-9_-]{32,}/g],
8 ['GitHub-Token', /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{60,})/g],
9 ['GitLab-Token', /\bglpat-[A-Za-z0-9_-]{20,}/g],
10 ['AWS-Zugangsschlüssel', /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g],
11 ['Google-API-Key', /\bAIza[0-9A-Za-z_-]{35}\b/g],
12 ['Slack-Token', /\bxox[abprs]-[A-Za-z0-9-]{10,}/g],
13 ['Stripe-Live-Key', /\b(?:sk|rk)_live_[A-Za-z0-9]{20,}/g],
14 ['Hugging-Face-Token', /\bhf_[A-Za-z0-9]{30,}/g],
15 ['privater Schlüssel', /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY(?: BLOCK)?-----/g],
16 ['JWT', /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g],
17 [
18 'credentials in an assignment',
19 /\b(?:api[_-]?key|secret|token|passw(?:or)?d|client[_-]?secret|access[_-]?key)\b["']?\s*[:=]\s*["'](?![^"']*(?:example|beispiel|changeme|placeholder|your[_-]|xxx|<|\$\{|\{\{))[A-Za-z0-9+/_\-.=]{20,}["']/gi,
20 ],
21]
22
23/** Placeholders and documentation examples: they look like keys but are none. */
24export function isPlaceholder(value: string): boolean {
25 return /example|dummy|fake|placeholder|redacted|sample|x{6,}|0{10,}|\*{4,}|your[_-]?(?:api|key|token|secret)/i.test(value)
26}
27mods/secret-guard/types/contract.d.ts 22 lines1/** A find the guard stopped (or warned about); the value itself is never kept, only its hash. */
2export type BlockedFind = {
3 at: number
4 what: string
5 where: string
6 /** Path relative to the project, when the find belongs to a file. */
7 path: string | null
8 /** sha256 of the found value, when there is one (key patterns; not for secret files). */
9 hash: string | null
10 stopped: boolean
11}
12
13declare module 'claude-code' {
14 interface PluginState {
15 'secret-guard': {
16 blocked: BlockedFind[]
17 project: string[]
18 global: string[]
19 }
20 }
21}
22