Refuses Write, Edit and Bash calls that contain secret-looking values (API keys, tokens, private keys)

Refuses file writes and commands that contain secret-looking values, so credentials never land in your code or shell history.
Write, the new_string of Edit, and the command of Bash before they run.sk-ant-...), OpenAI keys (sk-...), GitHub tokens (ghp_/gho_/ghs_, github_pat_), AWS access key ids (AKIA...), Slack tokens (xox[baprs]-), Google API keys (AIza...), and PEM private keys with a key body.example, your, placeholder, redacted, dummy, fake, sample, changeme or xxxx, values with few distinct characters, and token-style values with no mix of letters and digits.| Command | Effect |
|---|---|
/secret-shield status | Show whether the shield is on. |
/secret-shield off | Allow secret-looking values for this session; status line shows secret-shield: OFF. |
/secret-shield on | Turn it back on. |
claude --plugin-dir /path/to/ModsTools/mods/secret-shield
/secret-shield off after asking).NotebookEdit, MultiEdit-style tools, and the old_string of Edit.bash -c, $(...), eval, aliases or scripts. A seatbelt, not a security boundary.claude plugin validate mods/secret-shield
claude plugin test mods/secret-shield # 34 testshooks/register.ts 57 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { findSecret } from './rules'
5
6const isOff = atom({ plugin: 'secret-shield', key: 'isOff' } as const, false)
7
8async function refusal($: EngineInterface, field: string, text: string): Promise<string | null> {
9 if (await read($, isOff)) return null
10 const found = findSecret(text)
11 if (found === null) return null
12 $.ui.toast(`secret-shield blocked a ${found.kind} (${found.preview})`)
13
14 return (
15 `secret-shield: refused, the ${field} contains what looks like a ${found.kind} (${found.preview}). ` +
16 'Never write secrets into files or commands: read them from an environment variable or a secret store, or use a placeholder. ' +
17 'If this is a false positive, ask the user; they can allow it for this session with /secret-shield off.'
18 )
19}
20
21export const register: Register = on => {
22 on('session.start', async ($, e, next) => {
23 await $.command.register({
24 name: 'secret-shield',
25 description: 'secret-shield on|off|status: refuse or allow secret-looking values in Write, Edit and Bash',
26 })
27
28 return next(e)
29 })
30
31 on('command.run', { command: 'secret-shield' }, async ($, e) => {
32 const arg = e.args.trim()
33 if (arg === 'off' || arg === 'on') {
34 await update($, isOff, () => arg === 'off')
35 $.ui.status(arg === 'off' ? 'secret-shield: OFF' : undefined)
36 }
37 const off = await read($, isOff)
38
39 return { text: `secret-shield is ${off ? 'OFF for this session' : 'ON'}.` }
40 })
41
42 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
43 const deny = await refusal($, 'file content', e.content)
44 return deny === null ? next(e) : { deny }
45 })
46
47 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
48 const deny = await refusal($, 'new text', e.new_string)
49 return deny === null ? next(e) : { deny }
50 })
51
52 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
53 const deny = await refusal($, 'command', e.command)
54 return deny === null ? next(e) : { deny }
55 })
56}
57hooks/rules.ts 39 lines1export type Finding = { kind: string; preview: string }
2
3// Order matters: Anthropic before the generic sk- of OpenAI.
4const PATTERNS: { kind: string; re: RegExp; mixed?: boolean }[] = [
5 { kind: 'Anthropic API key', re: /(?<![\w-])sk-ant-[A-Za-z0-9_-]{20,}/g, mixed: true },
6 { kind: 'OpenAI API key', re: /(?<![\w-])sk-(?!ant-)[A-Za-z0-9_-]{20,}/g, mixed: true },
7 { kind: 'GitHub token', re: /(?<![\w-])gh[pos]_[A-Za-z0-9]{20,}/g, mixed: true },
8 { kind: 'GitHub token', re: /(?<![\w-])github_pat_[A-Za-z0-9_]{22,}/g, mixed: true },
9 { kind: 'AWS access key id', re: /(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}(?![A-Za-z0-9])/g },
10 { kind: 'Slack token', re: /(?<![\w-])xox[baprs]-[A-Za-z0-9-]{10,}/g, mixed: true },
11 { kind: 'Google API key', re: /(?<![\w-])AIza[0-9A-Za-z_-]{35}/g },
12 { kind: 'private key', re: /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----(?:\s|\\[rn])*[A-Za-z0-9+/=]{20,}/g },
13]
14
15const PLACEHOLDER_WORDS = /example|x{4}|your|placeholder|redacted|dummy|fake|sample|changeme/i
16
17// Placeholders: doc words, too few distinct characters, or (for random tokens) no digit/letter mix.
18function isPlaceholder(token: string, mixed: boolean): boolean {
19 const body = token.replace(/^(sk-ant-|sk-|gh[pos]_|github_pat_|AKIA|xox[baprs]-|AIza)/, '')
20 if (token.startsWith('-----BEGIN')) return false
21 if (PLACEHOLDER_WORDS.test(body)) return true
22 if (new Set(body.replace(/[-_]/g, '').toLowerCase()).size < 6) return true
23
24 return mixed && !(/[0-9]/.test(body) && /[A-Za-z]/.test(body))
25}
26
27export const mask = (secret: string) => `${secret.slice(0, 4)}****`
28
29/** The first secret-looking value in the text, or null when it is clean. */
30export function findSecret(text: string): Finding | null {
31 for (const { kind, re, mixed } of PATTERNS) {
32 for (const m of text.matchAll(re)) {
33 if (!isPlaceholder(m[0], mixed === true)) return { kind, preview: mask(m[0]) }
34 }
35 }
36
37 return null
38}
39types/index.d.ts 8 lines1export type ShieldSwitch = boolean
2
3declare module 'claude-code' {
4 interface PluginState {
5 'secret-shield': { isOff: ShieldSwitch }
6 }
7}
8