SLOPSHOPPER

secret-guard

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…

newpaneguardcommandtoastprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · secret-guard
│ ┃ Secret Guard · exceptions ✕ › fix the failing auth test and add an audit log call │ ┃ Exceptions let known test keys and example │ ┃ files pass. Entries: a path in gitignore ⏺ Read(src/auth.ts) │ ┃ style (tests/fixtures/**, *.example.ts) or ⎿ Read 6 lines │ ┃ sha256:<hash> of a value – keys never ⏺ Update(src/auth.ts) │ ┃ stand in clear text in the list. Project ⎿ Added 2 lines, removed 1 line │ ┃ exceptions live in .secretguard-allow (can ⏺ Bash(bun test) │ ┃ be committed), global ones apply ⎿ 3 pass, 1 fail │ ┃ everywhere. Claude cannot change the list. │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Last finds │ ┃ Nothing yet. ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ Project (.secretguard-allow) › /secret-allow │ ┃ none ⎿ secret-guard: Secret Guard exceptions opened. │ ┃ New: : tests/fixtures/** ⏎ Add │ ┃ │ ┃ Global (all projects) │ ┃ none │ ┃ New: : *.example.env ⏎ Add │ ┃ Placeholders (EXAMPLE, DUMMY, FAKE, xxxx │ ┃ …) pass automatically. │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Secret Guard · exceptions
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 .secretguard-allow (can be committed), global ones apply everywhere. Claude cannot change the list. Last finds Nothing yet. Project (.secretguard-allow) none New: : tests/fixtures/** ⏎ Add Global (all projects) none New: : *.example.env ⏎ Add Placeholders (EXAMPLE, DUMMY, FAKE, xxxx …) pass automatically.
README

Secret Guard

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.

Install

In a terminal Claude Code session (2.1.287 or newer):

/plugin install secret-guard --marketplace LupusMalusDeviant/claude-mods

Commands

CommandWhat it does
/secret-allowexceptions: last finds and both allow lists

Options

Set them in /config (or when installing).

OptionDefaultWhat it does
modeblockblock: refuse the action. warn: only warn and let it through.
placeholdersonon: values with EXAMPLE, DUMMY, FAKE, PLACEHOLDER, REDACTED, SAMPLE, xxxxxx … are no secret (this covers the AWS documentation keys).
languageenLanguage of the texts this mod shows and writes.

License

MIT, see LICENSE.

Source 4 files
hooks/entry.ts 9 lines
1// 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}
9
mods/secret-guard/hooks/register.tsx 411 lines
1// 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}
411
mods/secret-guard/hooks/patterns.ts 27 lines
1// 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}
27
mods/secret-guard/types/contract.d.ts 22 lines
1/** 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