Denies writing secret files, committing secrets, and pushing to main or force pushing.

Stops secrets being written, committed or pushed.
Refuses to write .env, .pem, SSH keys and credentials files, refuses commands that contain API keys or high-entropy tokens, blocks git add/commit of staged secrets, and blocks pushes to main/master and force pushes. .env.example-style files are allowed.
/plugin marketplace add cloudcruncher/claude-mod-garage
/plugin install secret-guard@mod-garage
Run /reload-plugins (or restart Claude Code) afterwards. To try it from a local clone without installing: claude --plugin-dir ./dev-mods/secret-guard
Illustrative output.
Model tries to write .env
secret-guard: refusing to write .env. .env is an environment file that usually holds secrets. Use a .env.example with placeholder values, or ask the user to edit it.
Model tries to push
secret-guard: git push blocked: pushing to main is not allowed.
hooks/register.ts 76 lines1import type { Register, EngineInterface } from 'claude-code'
2import {
3 addedLines, findSecret, gitOps, isBroadStage, pushDenyReason, secretPathReason, type GitOp,
4} from './lib.ts'
5
6type Engine = EngineInterface
7
8async function git($: Engine, args: string[]): Promise<string> {
9 const r = await $.process.run(['git', ...args], { timeoutMs: 15000 })
10 return r.exitCode === 0 ? r.stdout : ''
11}
12
13/** Looks at what a git add or commit would record; returns a deny reason or undefined. */
14async function stagedProblem($: Engine, op: GitOp): Promise<string | undefined> {
15 const chunks: string[] = []
16 if (op.kind === 'commit') {
17 chunks.push(addedLines(await git($, ['diff', '--cached', '--no-color'])))
18 if (isBroadStage(op)) chunks.push(addedLines(await git($, ['diff', '--no-color'])))
19 } else {
20 // git add: scan the working tree changes it could pick up
21 chunks.push(addedLines(await git($, ['diff', '--no-color'])))
22 const status = await git($, ['status', '--porcelain', '--untracked-files=all'])
23 const files = status.split('\n').filter(l => l.length > 3).map(l => l.slice(3).replace(/^"|"$/g, ''))
24 const named = op.args.filter(a => !a.startsWith('-'))
25 const wanted = isBroadStage(op) ? files : files.filter(f => named.some(n => f === n || f.startsWith(`${n.replace(/\/$/, '')}/`)))
26 for (const f of wanted.slice(0, 50)) {
27 const why = secretPathReason(f)
28 if (why) return `git add would stage ${f}: ${why}`
29 if (status.includes(`?? ${f}`)) {
30 const text = await $.fs.read(f).catch(() => '')
31 if (typeof text === 'string') chunks.push(text)
32 }
33 }
34 const stagedNames = (await git($, ['diff', '--cached', '--name-only'])).split('\n').filter(Boolean)
35 for (const f of stagedNames) {
36 const why = secretPathReason(f)
37 if (why) return `${f} is already staged: ${why}`
38 }
39 }
40 const hit = findSecret(chunks.join('\n'))
41 return hit ? `content to be recorded looks like a secret (${hit})` : undefined
42}
43
44export const register: Register = on => {
45 for (const tool of ['Write', 'Edit', 'MultiEdit', 'NotebookEdit'] as const) {
46 on('tool.call', { tool }, ($, e, next) => {
47 const path = tool === 'NotebookEdit' ? (e as { notebook_path?: string }).notebook_path : (e as { file_path?: string }).file_path
48 const why = path ? secretPathReason(path) : undefined
49 return why
50 ? { deny: `secret-guard: refusing to write ${path}. ${why}. Use a .env.example with placeholder values, or ask the user to edit it.` }
51 : next(e)
52 }).catch(($, e, next) => (next.called ? next(e) : { deny: 'secret-guard: the guard failed, so the write was blocked.' }))
53 }
54
55 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
56 const ops = gitOps(e.command)
57 if (ops.length === 0) return next(e)
58
59 const inline = findSecret(e.command)
60 if (inline && ops.some(o => o.kind !== 'push')) {
61 return { deny: `secret-guard: the command itself contains what looks like a secret (${inline}). Remove it and use an environment variable.` }
62 }
63 for (const op of ops) {
64 if (op.kind === 'push') {
65 const branch = (await git($, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim() || undefined
66 const why = pushDenyReason(op.args, branch)
67 if (why) return { deny: `secret-guard: git push blocked: ${why}.` }
68 } else {
69 const why = await stagedProblem($, op)
70 if (why) return { deny: `secret-guard: git ${op.kind} blocked: ${why}. Remove the secret, rotate it if it was ever real, and retry.` }
71 }
72 }
73 return next(e)
74 }).catch(($, e, next) => (next.called ? next(e) : { deny: 'secret-guard: the guard failed, so the git command was blocked.' }))
75}
76hooks/lib.ts 132 lines1// Pure pattern logic for secret-guard.
2
3const EXAMPLE_SUFFIX = /\.(example|sample|template|dist|defaults?)$/i
4
5function baseName(path: string): string {
6 return path.split(/[\\/]/).pop() ?? path
7}
8
9/** Returns why a path is a secret file, or undefined when it is fine to write. */
10export function secretPathReason(path: string): string | undefined {
11 const name = baseName(path)
12 const lower = name.toLowerCase()
13 if (EXAMPLE_SUFFIX.test(lower)) return undefined
14 if (lower === '.env' || lower.startsWith('.env.')) return `${name} is an environment file that usually holds secrets`
15 if (lower.endsWith('.pem')) return `${name} is a PEM key or certificate file`
16 if (/\.(p12|pfx|jks|keystore)$/.test(lower)) return `${name} is a key store`
17 if (/^id_[a-z0-9_]+$/.test(lower)) return `${name} looks like an SSH private key`
18 if (/^(\.?credentials|\.git-credentials)(\..*)?$/.test(lower) || lower === 'credentials.json') {
19 return `${name} is a credentials file`
20 }
21 return undefined
22}
23
24export function shannonEntropy(s: string): number {
25 if (s.length === 0) return 0
26 const counts = new Map<string, number>()
27 for (const ch of s) counts.set(ch, (counts.get(ch) ?? 0) + 1)
28 let h = 0
29 for (const c of counts.values()) {
30 const p = c / s.length
31 h -= p * Math.log2(p)
32 }
33 return h
34}
35
36const FIXED_PATTERNS: { label: string; re: RegExp }[] = [
37 { label: 'AWS access key id', re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/ },
38 { label: 'GitHub token', re: /\bgh[pousr]_[A-Za-z0-9]{30,}\b/ },
39 { label: 'GitHub fine-grained token', re: /\bgithub_pat_[A-Za-z0-9_]{20,}\b/ },
40 { label: 'Anthropic API key', re: /\bsk-ant-[A-Za-z0-9_-]{16,}/ },
41 { label: 'API secret key (sk-)', re: /\bsk-(?:proj-)?[A-Za-z0-9_-]{32,}/ },
42 { label: 'private key block', re: /-----BEGIN (?:[A-Z]+ )?PRIVATE KEY(?: BLOCK)?-----/ },
43 { label: 'Slack token', re: /\bxox[abprs]-[A-Za-z0-9-]{10,}/ },
44]
45
46const ASSIGNMENT =
47 /\b[\w.-]*(?:secret|token|passw(?:or)?d|api[_-]?key|access[_-]?key|private[_-]?key|auth)[\w.-]*\s*[:=]\s*["']?([A-Za-z0-9+/_\-=.]{20,})["']?/i
48
49const PLACEHOLDER = /(example|placeholder|changeme|your[_-]|<.*>|xxxx|\$\{|\$\(|process\.env|dummy|redacted)/i
50
51/** Returns a label for the first secret-looking thing in the text, or undefined. */
52export function findSecret(text: string): string | undefined {
53 for (const { label, re } of FIXED_PATTERNS) if (re.test(text)) return label
54 for (const line of text.split('\n')) {
55 const m = ASSIGNMENT.exec(line)
56 const value = m?.[1]
57 if (value === undefined) continue
58 if (PLACEHOLDER.test(line)) continue
59 if (shannonEntropy(value) >= 3.8) return 'high-entropy token in an assignment'
60 }
61 return undefined
62}
63
64/** The added lines of a unified diff, without the leading plus. */
65export function addedLines(diff: string): string {
66 return diff
67 .split('\n')
68 .filter(l => l.startsWith('+') && !l.startsWith('+++'))
69 .map(l => l.slice(1))
70 .join('\n')
71}
72
73export type GitOp = { kind: 'commit' | 'add' | 'push'; args: string[] }
74
75/** Same-length copy with heredoc bodies and quoted text blanked, so words inside them are not read as commands. */
76function maskLiterals(command: string): string {
77 const blank = (t: string): string => t.replace(/[^\n]/g, '_')
78 return command
79 .replace(/(<<-?\s*(["']?)(\w+)\2[^\n]*\n)([\s\S]*?)(\n\s*\3(?=\n|$))/g, (_m, head: string, _q, _id, body: string, tail: string) => head + blank(body) + tail)
80 .replace(/"(?:[^"\\]|\\.)*"|'[^']*'/g, m => m[0] + blank(m.slice(1, -1)) + m[m.length - 1])
81}
82
83/** Finds git commit/add/push invocations in a shell command line. */
84export function gitOps(command: string): GitOp[] {
85 const ops: GitOp[] = []
86 const masked = maskLiterals(command)
87 let offset = 0
88 for (const segment of masked.split(/&&|\|\||;|\n/)) {
89 const start = masked.indexOf(segment, offset)
90 offset = start + segment.length
91 // git must be the command itself, not a word inside an argument
92 const m = /^\s*(?:\w+=\S*\s+)*git\s+(?:-C\s+\S+\s+|-c\s+\S+\s+)*(commit|add|push)\b(.*)$/s.exec(segment)
93 if (!m) continue
94 const rest = command.slice(start + segment.length - (m[2] ?? '').length, start + segment.length)
95 const args = rest.match(/"[^"]*"|'[^']*'|\S+/g) ?? []
96 ops.push({ kind: m[1] as GitOp['kind'], args: args.map(a => a.replace(/^["']|["']$/g, '')) })
97 }
98 return ops
99}
100
101const PROTECTED_BRANCH = /^(?:main|master)$/
102
103/** Returns a deny reason for a git push, or undefined when it is allowed. */
104export function pushDenyReason(args: string[], currentBranch?: string): string | undefined {
105 const isForce = args.some(
106 a => a === '-f' || /^--force(?:-with-lease|-if-includes)?(?:=.*)?$/.test(a) || /^\+\S/.test(a) ||
107 (/^-[a-zA-Z]+$/.test(a) && a.includes('f')),
108 )
109 if (isForce) return 'force pushes are not allowed here; push normally or ask the user to do it'
110 const positional = args.filter(a => !a.startsWith('-'))
111 const refspecs = positional.slice(1) // first positional is the remote
112 const targets = refspecs.map(r => (r.includes(':') ? (r.split(':').pop() ?? '') : r))
113 if (targets.some(t => PROTECTED_BRANCH.test(t.replace(/^refs\/heads\//, '')))) {
114 return 'pushing to main or master is blocked; push a branch and open a pull request'
115 }
116 if (refspecs.length === 0 && currentBranch !== undefined && PROTECTED_BRANCH.test(currentBranch)) {
117 return `you are on ${currentBranch}; pushing to main or master is blocked`
118 }
119 if (args.includes('--all') || args.includes('--mirror')) {
120 return 'pushing all refs would include main or master; push a named branch instead'
121 }
122 return undefined
123}
124
125/** True when a `git add` or `git commit` could pick up files beyond those it names. */
126export function isBroadStage(op: GitOp): boolean {
127 if (op.kind === 'commit') {
128 return op.args.some(a => a === '-a' || a === '--all' || /^-[a-zA-Z]*a[a-zA-Z]*$/.test(a))
129 }
130 return op.args.some(a => ['.', '-A', '--all', '-u', '--update', ':/'].includes(a))
131}
132