Stops Claude from reading .env files, private keys and credentials.

Stops Claude from reading .env files, private keys and credentials.
When Claude tries to read a secret-looking file, the call is refused and Claude is told to ask you for what it needs. A toast names the file, and a status entry counts the blocks in this session:
Bash(cat config/.env.production)
secret-guard: config/.env.production looks like a secret; ask the person to share what is needed.
toast ⊘ blocked Bash: config/.env.production
status secret-guard · 1 blocked
If the check itself fails, the call is refused too.
.env and .env.*, except .env.example, .env.sample and .env.template*.pem and *.keyid_rsa* and id_ed25519*, public keys included (allow *.pub to let them through).npmrc, .pypirc, credentials.json and .aws/credentials.ssh/ folderMatching ignores case and treats \ and / alike. It looks at the file name and at every folder in the path, so C:\Users\me\.ssh\config, ~/.aws/credentials and C:\repo\.env::$DATA are all caught.
path and glob) on such a path. MultiEdit and NotebookRead too, on builds that have them.cat, less, head, tail, grep (also grep -f .env), sed, awk, base64, xxd, cp, mv, scp, rsync, tar, curl, source, python -c, Get-Content, type and [IO.File]::ReadAllText(...).< .env, bash -c "...", cmd /c ..., ssh host '...', eval "...", $(...), find ... -exec cat, ... | xargs cat, xargs -a .env, kubectl exec pod -- cat and git show HEAD:.env. A wildcard such as .env* is tried against typical secret names.sudo, env, timeout and xargs, after -exec or --, or as a git subcommand. Text that only mentions a file passes: git commit -m "Move .env loading into config", a here-document body, a single-quoted string.echo X=1 >> .env, cp .env.example .env), listing (ls -la .env) and a grep pattern (grep -rn ".env" src) are not reads, so they pass.| Option | Type | Default | What it does |
|---|---|---|---|
extraPatterns | string | "" | Comma-separated globs that also count as secrets, e.g. .envrc, .secret, config/prod/.yml. |
allow | string | "" | Comma-separated globs that always pass, checked first, e.g. fixtures/**, *.pub. |
In a glob, * stays within a folder, ** crosses folders and ? is one character. A glob without / matches the file name or any folder name. A glob with / matches the end of the path.
Set them with /config, or in settings under pluginConfigs["secret-guard@mod-showroom"].options.
/plugin install secret-guard --marketplace KROU4/claude-code-mod-showroomclaude plugin install secret-guard --marketplace KROU4/claude-code-mod-showroomKROU4/claude-code-mod-showroom → Add plugindocker exec <container> ... without --, or inside the $(...) of an unquoted here-document.MIT. See LICENSE.
hooks/register.ts 38 lines1import { atom, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { findSecret, rulesFrom } from './secrets'
5
6const blocked = atom({ plugin: 'secret-guard', key: 'blocked' } as const, 0)
7
8const denial = (path: string) =>
9 `secret-guard: ${path} looks like a secret; ask the person to share what is needed.`
10const FAILED = 'secret-guard: its check failed, so the call was refused.'
11
12export const register: Register = (on, options) => {
13 const rules = rulesFrom(options)
14
15 on('tool.call', async ($, e, next) => {
16 const secret = findSecret(e, rules)
17 if (secret === undefined) return next(e)
18
19 const count = await update($, blocked, n => n + 1)
20 $.ui.status(`secret-guard · ${count} blocked`)
21 $.ui.toast(`⊘ blocked ${String(e.tool)}: ${secret}`)
22
23 return { deny: denial(secret) }
24 }).catch(async ($, e, next) => {
25 if (next.called) return next(e)
26 let secret: string | undefined
27 try {
28 secret = findSecret(e, rules)
29 } catch {
30 return { deny: FAILED }
31 }
32 if (secret !== undefined) return { deny: denial(secret) }
33
34 // Re-entry (raised beneath a call of ours): judged here on `e` alone. Any other failure fails closed.
35 return next.error.kind === 're-entry' ? next(e) : { deny: FAILED }
36 })
37}
38hooks/secrets.ts 374 lines1// Which tool calls reach secret-looking files. Pure functions, no `$`.
2
3import type { PluginOptions } from 'claude-code'
4
5export type Glob = { matches: (path: string, segments: readonly string[]) => boolean; sample: string }
6export type Rules = { extra: readonly Glob[]; allow: readonly Glob[] }
7
8const ALLOWED_ENV = new Set(['.env.example', '.env.sample', '.env.template'])
9const SECRET_NAMES = new Set(['.npmrc', '.pypirc', 'credentials.json'])
10// What a wildcard in a shell word is tried against: `cat .env*`, `cat *.pem`.
11const SAMPLES = ['.env', '.env.local', 'id_rsa', 'id_ed25519', 'cert.pem', 'server.key', '.npmrc', '.pypirc', 'credentials.json', 'credentials']
12
13// Tools whose arguments name a file; others are compared by name only.
14const FILE_FIELDS = new Map<string, readonly string[]>([
15 ['Read', ['file_path']],
16 ['Edit', ['file_path']],
17 ['Write', ['file_path']],
18 ['MultiEdit', ['file_path']],
19 ['NotebookEdit', ['notebook_path']],
20 ['NotebookRead', ['notebook_path']],
21 ['Grep', ['path', 'glob']],
22])
23// Tools whose `command` a shell runs, with that shell's escape character.
24const SHELL_ESCAPES = new Map([['Bash', '\\'], ['Monitor', '\\'], ['PowerShell', '`']])
25
26// Commands that read (or copy away) the files they name.
27const READERS = new Set([
28 'cat', 'tac', 'nl', 'less', 'more', 'most', 'bat', 'batcat', 'view', 'vi', 'vim', 'nvim', 'ex', 'ed', 'nano', 'emacs',
29 'head', 'tail', 'grep', 'egrep', 'fgrep', 'rg', 'ag', 'ack', 'sed', 'awk', 'gawk', 'mawk', 'cut', 'sort', 'uniq',
30 'paste', 'join', 'comm', 'diff', 'cmp', 'column', 'fold', 'fmt', 'pr', 'rev', 'expand', 'strings', 'od', 'xxd',
31 'hexdump', 'hd', 'base64', 'base32', 'basenc', 'uuencode', 'cp', 'mv', 'ln', 'install', 'scp', 'rsync', 'sftp',
32 'tar', 'zip', '7z', 'gzip', 'bzip2', 'xz', 'zstd', 'zcat', 'dd', 'jq', 'yq', 'openssl', 'curl', 'wget', 'source', '.',
33 'python', 'python3', 'node', 'deno', 'bun', 'ruby', 'perl', 'php', 'lua',
34 'get-content', 'gc', 'type', 'select-string', 'sls', 'copy-item', 'copy', 'cpi', 'move-item', 'move', 'mi',
35 'format-hex', 'fhx', 'import-csv', 'findstr', 'xcopy', 'certutil',
36])
37const GREPS = new Set(['grep', 'egrep', 'fgrep', 'rg', 'ag', 'ack', 'select-string', 'sls', 'findstr'])
38const PATTERN_FLAGS = new Set(['-e', '--regexp', '-pattern'])
39// Flags that hand a grep its patterns or files, so its first positional is no pattern.
40const FILE_FLAGS = new Set(['-f', '--file', '-path', '-literalpath'])
41const COPIERS = new Set(['cp', 'mv', 'ln', 'install', 'scp', 'rsync', 'copy-item', 'copy', 'cpi', 'move-item', 'move', 'mi', 'xcopy'])
42const DESTINATION_FLAGS = new Set(['-t', '--target-directory', '-destination'])
43const GIT_READS = new Set(['show', 'diff', 'log', 'blame', 'cat-file', 'grep', 'archive'])
44const GIT_VALUED = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace'])
45// Words after which a command starts again: `find -exec`, `fd -x`, `kubectl exec pod --`.
46const EXEC_FLAGS = new Set(['-exec', '-execdir', '-ok', '-okdir', '-x', '--exec', '--'])
47// Flags whose following words are a command line: `bash -c`, `pwsh -Command`, `cmd /c`.
48const RUN_FLAGS = /^(-c|--?command|\/c|\/k)$/i
49
50// Words that run the command after them. `valued` flags take the next word; `operands` leading words are no command.
51type Wrapper = { valued?: readonly string[]; operands?: number }
52const WRAPPERS = new Map<string, Wrapper>([
53 ['sudo', { valued: ['-u', '-g', '-h', '-p', '-C', '-D', '-r', '-t', '-U'] }],
54 ['doas', { valued: ['-u', '-C'] }],
55 ['env', { valued: ['-u', '-C', '--unset', '--chdir'] }],
56 ['exec', { valued: ['-a'] }],
57 ['time', { valued: ['-f', '-o', '--format', '--output'] }],
58 ['nice', { valued: ['-n', '--adjustment'] }],
59 ['timeout', { valued: ['-s', '-k', '--signal', '--kill-after'], operands: 1 }],
60 ['watch', { valued: ['-n', '--interval'] }],
61 ['xargs', { valued: ['-a', '--arg-file', '-d', '--delimiter', '-E', '-I', '-L', '-n', '--max-args', '-P', '--max-procs', '-s', '--max-chars'] }],
62 ['ssh', { valued: ['-b', '-c', '-D', '-E', '-e', '-F', '-I', '-i', '-J', '-L', '-l', '-m', '-O', '-o', '-p', '-Q', '-R', '-S', '-W', '-w'], operands: 1 }],
63 ...['command', 'builtin', 'nohup', 'eval', 'iex', 'invoke-expression', 'if', 'then', 'else', 'elif', 'while', 'until', 'do', '!'].map(
64 name => [name, {}] as const,
65 ),
66])
67
68/** Lower case, forward slashes, no quotes, no `host:`, `HEAD:` or drive prefix, no `::$DATA` stream. */
69export function normalise(raw: string): string {
70 const path = raw.trim().replace(/^["']+|["']+$/g, '').replace(/\\/g, '/').toLowerCase().replace(/::\$data\b/g, '')
71 const colon = path.indexOf(':')
72
73 return colon > 0 && !path.slice(0, colon).includes('/') ? path.slice(colon + 1) : path
74}
75
76// A colon left inside a path parts a ref from its file: `origin/main:.env`.
77const segmentsOf = (path: string) => path.split(/[/:]/).filter(part => part !== '' && part !== '.')
78const hasWildcard = (text: string) => /[*?]/.test(text)
79// Short flags are case-sensitive (`-f` is not `-F`); PowerShell's long ones are not.
80const isFlag = (flags: ReadonlySet<string>, arg = '') => flags.has(arg.length > 2 ? arg.toLowerCase() : arg)
81
82function isBuiltinSecret(segments: readonly string[]): boolean {
83 const name = segments.at(-1) ?? ''
84 if (segments.includes('.ssh')) return true
85 if (name === 'credentials' && segments.at(-2) === '.aws') return true
86 if (name === '.env' || name.startsWith('.env.')) return !ALLOWED_ENV.has(name)
87
88 return SECRET_NAMES.has(name) || name.endsWith('.pem') || name.endsWith('.key') || name.startsWith('id_rsa') || name.startsWith('id_ed25519')
89}
90
91function globSource(glob: string): string {
92 return glob.replace(/\*\*\/|\*\*|\*|\?|[.+^${}()|[\]\\]/g, token =>
93 token === '**/' ? '(?:.*/)?' : token === '**' ? '.*' : token === '*' ? '[^/]*' : token === '?' ? '[^/]' : `\\${token}`,
94 )
95}
96
97/** A glob without `/` matches the file name or any folder; with `/`, the end of the path. */
98export function compileGlob(pattern: string): Glob | undefined {
99 const glob = normalise(pattern).replace(/^\.\//, '')
100 if (segmentsOf(glob).length === 0) return undefined
101 const sample = (segmentsOf(glob).at(-1) ?? '').replace(/[*?]+/g, 'x')
102
103 if (!glob.includes('/')) {
104 const re = new RegExp(`^${globSource(glob)}$`)
105 return { matches: (_, segments) => segments.some(segment => re.test(segment)), sample }
106 }
107 const re = new RegExp(`(?:^|/)${globSource(glob.replace(/^\/+/, ''))}$`)
108
109 return { matches: path => re.test(path), sample }
110}
111
112export function rulesFrom(options: PluginOptions): Rules {
113 const globs = (value: unknown) =>
114 (typeof value === 'string' ? value.split(',') : []).flatMap(part => compileGlob(part.trim()) ?? [])
115
116 return { extra: globs(options.extraPatterns), allow: globs(options.allow) }
117}
118
119export function isSecretPath(raw: string, rules: Rules): boolean {
120 const path = normalise(raw)
121 const segments = segmentsOf(path)
122 if (segments.length === 0 || rules.allow.some(glob => glob.matches(path, segments))) return false
123
124 return isBuiltinSecret(segments) || rules.extra.some(glob => glob.matches(path, segments))
125}
126
127/** A path, or a shell glob tried against typical secret names: `.env*`, `~/.ssh/*`. */
128export function isSecretWord(word: string, rules: Rules): boolean {
129 const path = normalise(word)
130 const slash = path.lastIndexOf('/')
131 const folder = path.slice(0, slash + 1)
132 const name = path.slice(slash + 1)
133 if (!hasWildcard(name)) return isSecretPath(path, rules)
134 if (/^[*?]*$/.test(name)) return isSecretPath(`${folder}x`, rules)
135 const re = new RegExp(`^${globSource(name)}$`)
136 const samples = [...SAMPLES, ...rules.extra.map(glob => glob.sample)]
137
138 return samples.some(sample => re.test(sample) && isSecretPath(folder + sample, rules))
139}
140
141/**
142 * One simple command. `nested`: its words that run a command line inside double
143 * quotes (`"$(cat .env)"`). `call`: it ended on `(` or `$(`, whose content is its argument.
144 */
145type Segment = { words: string[]; inputs: string[]; nested: string[]; call: boolean }
146
147/**
148 * Splits a command line into simple commands at `; & | ( ) $(`, newlines and
149 * standalone braces, keeping each one's words (quotes removed) and the files `<` reads.
150 * Here-document bodies are text and are skipped.
151 * `escape` is the shell's escape character: `\` for sh, a backtick for PowerShell.
152 */
153function parse(command: string, escape: string): Segment[] {
154 const segments: Segment[] = []
155 const empty = (): Segment => ({ words: [], inputs: [], nested: [], call: false })
156 let current = empty()
157 let word = ''
158 let hasWord = false
159 let runs = false
160 let quote = ''
161 let redirect: 'in' | 'out' | 'heredoc' | undefined
162 const heredocs: string[] = []
163
164 const endWord = () => {
165 if (!hasWord) return
166 if (redirect === 'in') current.inputs.push(word)
167 else if (redirect === 'heredoc') heredocs.push(word)
168 else if (redirect === undefined) current.words.push(word)
169 if (runs) current.nested.push(word)
170 redirect = undefined
171 word = ''
172 hasWord = false
173 runs = false
174 }
175 const endSegment = (call = false) => {
176 endWord()
177 redirect = undefined
178 current.call = call
179 if (current.words.length > 0 || current.inputs.length > 0 || current.nested.length > 0) segments.push(current)
180 current = empty()
181 }
182 // Index of the newline that ends a here-document begun after `from`, or the end.
183 const bodyEnd = (from: number, delimiter: string) => {
184 let start = from + 1
185 for (;;) {
186 const end = command.indexOf('\n', start)
187 if (end === -1) return command.length
188 if (command.slice(start, end).replace(/^\t+/, '') === delimiter) return end
189 start = end + 1
190 }
191 }
192
193 for (let i = 0; i < command.length; i++) {
194 const c = command[i] ?? ''
195 const following = command[i + 1] ?? ''
196 if (c === escape && (quote === '"' || quote === '') && /["'`$\\\s;&|<>()]/.test(following)) {
197 word += following
198 hasWord = true
199 i++
200 } else if (quote !== '') {
201 if (c === quote) quote = ''
202 else word += c
203 // Inside double quotes `$(...)` and backticks still run; single quotes keep text literal.
204 runs ||= quote === '"' && ((c === '$' && following === '(') || (c === '`' && escape !== '`'))
205 } else if (c === '"' || c === "'") {
206 quote = c
207 hasWord = true
208 } else if (c === '#' && !hasWord) {
209 while (i + 1 < command.length && command[i + 1] !== '\n') i++
210 } else if (c === '(' || (c === '$' && following === '(')) {
211 endSegment(true)
212 if (c === '$') i++
213 } else if (c === '\n' || ';&|)'.includes(c) || (c === '`' && escape !== '`')) {
214 endSegment()
215 if (c === '\n') for (const delimiter of heredocs.splice(0)) i = bodyEnd(i, delimiter)
216 } else if ((c === '{' || c === '}') && !hasWord && /^$|[\s;&|)]/.test(following)) {
217 // `{ cat .env; }` and PowerShell's `{ gc .env }` hold commands; `{}` and `{a,b}` stay words.
218 endSegment()
219 } else if (/\s/.test(c)) {
220 endWord()
221 } else if (c === '<' || c === '>') {
222 // `<` reads a file, `<<` opens a here-document, `<<<` is a here-string, `>` writes.
223 endWord()
224 let last = i
225 while (command[last + 1] === c) last++
226 const count = last - i + 1
227 redirect = c === '>' || count > 2 ? 'out' : count === 2 ? 'heredoc' : 'in'
228 i = command[last + 1] === '&' || (redirect === 'heredoc' && command[last + 1] === '-') ? last + 1 : last
229 } else {
230 word += c
231 hasWord = true
232 }
233 }
234 endSegment()
235
236 return segments
237}
238
239const commandName = (word: string) => (word.split(/[\\/]/).at(-1) ?? '').toLowerCase().replace(/\.exe$/, '')
240
241/** Where the command starts at or after `from`: past `NAME=value`, keywords and wrappers such as `sudo -u root`. */
242function commandStart(words: readonly string[], from = 0): number {
243 let i = from
244 for (;;) {
245 const word = words[i] ?? ''
246 const wrapper = WRAPPERS.get(commandName(word))
247 if (/^[A-Za-z_]\w*=/.test(word)) {
248 i++
249 continue
250 }
251 if (wrapper === undefined) return i
252 let operands = wrapper.operands ?? 0
253 i++
254 while (i < words.length) {
255 const flag = words[i] ?? ''
256 if (flag.startsWith('-')) i += wrapper.valued?.includes(flag) ? 2 : 1
257 else if (operands > 0) {
258 operands--
259 i++
260 } else break
261 }
262 }
263}
264
265/** The word that reads files: the command, a git subcommand, or what `-exec` runs; -1 if none. */
266function readerAt(words: readonly string[]): number {
267 const starts = [0, ...words.flatMap((word, i) => (EXEC_FLAGS.has(word) ? [i + 1] : []))]
268
269 for (const start of starts) {
270 let at = commandStart(words, start)
271 const name = commandName(words[at] ?? '')
272 if (READERS.has(name)) return at
273 if (name !== 'git') continue
274 at++
275 while ((words[at] ?? '').startsWith('-')) at += GIT_VALUED.has(words[at] ?? '') ? 2 : 1
276 if (GIT_READS.has((words[at] ?? '').toLowerCase())) return at
277 }
278
279 return -1
280}
281
282/** The arguments of a reading command that may name what it reads. */
283function readArgs(name: string, args: readonly string[]): string[] {
284 const positional = args.flatMap((arg, i) => (arg.startsWith('-') ? [] : [i]))
285 const after = (flags: ReadonlySet<string>) => args.flatMap((arg, i) => (isFlag(flags, arg) ? [i + 1] : []))
286 // A grep's pattern is no file (`grep -rn ".env" src`); a copy's destination is written (`cp .env.example .env`).
287 const patterns = after(PATTERN_FLAGS)
288 const destinations = after(DESTINATION_FLAGS)
289 const grepSkip = patterns.length > 0 || args.some(arg => isFlag(FILE_FLAGS, arg)) ? patterns : positional.slice(0, 1)
290 const copySkip = destinations.length > 0 ? destinations : positional.slice(-1)
291 const skipped = new Set([
292 ...(GREPS.has(name) ? grepSkip : []),
293 ...(COPIERS.has(name) && positional.length > 1 ? copySkip : []),
294 ])
295
296 return args.filter((_, i) => !skipped.has(i))
297}
298
299/** Words of one simple command that may name a file it reads. */
300function readTargets(words: readonly string[], previous: readonly string[]): string[] {
301 const prefix = words.slice(0, commandStart(words))
302 // `xargs -a .env echo` reads its argument file, whatever it runs.
303 const argFiles = prefix.some(word => commandName(word) === 'xargs')
304 ? prefix.filter((word, i) => /^(-a|--arg-file)$/.test(prefix[i - 1] ?? '') || word.startsWith('--arg-file='))
305 : []
306 const at = readerAt(words)
307 if (at === -1) return argFiles
308 const before = words.slice(0, at)
309 const args = readArgs(commandName(words[at] ?? ''), words.slice(at + 1))
310 // `find . -name .env -exec cat {} +` and `... | xargs cat` read what came before.
311 const isExec = before.some(word => EXEC_FLAGS.has(word))
312 const isXargs = before.some(word => commandName(word) === 'xargs')
313
314 return [...argFiles, ...args, ...(isExec ? before : []), ...(isXargs ? previous : [])]
315}
316
317/** A reader whose argument is the call that follows: `Get-Content (Join-Path ...)`, `[IO.File]::ReadAllText('.env')`. */
318function readsCall(segment: Segment | undefined): boolean {
319 if (segment?.call !== true) return false
320 const last = segment.words.at(-1) ?? ''
321 const at = readerAt(segment.words)
322 if (/io\.file\]::(read|open|copy|move)/i.test(last)) return true
323
324 return at !== -1 && (at === segment.words.length - 1 || (last.startsWith('-') && !isFlag(PATTERN_FLAGS, last)))
325}
326
327// Pieces of a word that may be a path: `open('.env')`, `--env-file=.env`, `-F f=@id_rsa`, `{a,.env}`; `${HOME}` stays whole.
328const pieces = (word: string) => word.match(/(?:\$\{[^}]*\}|[^\s"'`()[\]{},;=@<>|&])+/g) ?? []
329
330/** The first secret-looking path a shell command reads, as written; undefined if none. */
331export function shellSecret(command: string, rules: Rules, escape = '\\', depth = 0): string | undefined {
332 const segments = parse(command, escape)
333
334 for (const [index, segment] of segments.entries()) {
335 const { words, inputs, nested } = segment
336 const previous = segments[index - 1]
337 const targets = [...inputs, ...readTargets(words, previous?.words ?? []), ...(readsCall(previous) ? words : [])]
338 const hit = targets.flatMap(pieces).find(piece => isSecretWord(piece, rules))
339 if (hit !== undefined) return hit
340
341 // Command lines a shell runs: `bash -c "cat .env"`, `cmd /c type .env`, `ssh host 'head .env'`, `echo "$(cat .env)"`.
342 const program = words[commandStart(words)] ?? ''
343 const lines = [
344 ...words.flatMap((_, i) => (RUN_FLAGS.test(words[i - 1] ?? '') ? [words.slice(i).join(' ')] : [])),
345 ...(/\s/.test(program) ? [program] : []),
346 ...nested,
347 ]
348 for (const line of depth < 3 ? lines : []) {
349 const inner = shellSecret(line, rules, escape, depth + 1)
350 if (inner !== undefined) return inner
351 }
352 }
353
354 return undefined
355}
356
357/** The secret-looking path a tool call would touch, as written; undefined if none. */
358export function findSecret(e: Readonly<Record<string, unknown>>, rules: Rules): string | undefined {
359 const tool = String(e.tool)
360 const text = (key: string) => {
361 const value = e[key]
362 return typeof value === 'string' ? value : undefined
363 }
364
365 const escape = SHELL_ESCAPES.get(tool)
366 if (escape !== undefined) {
367 // A Monitor on a WebSocket has no command and reads no file.
368 const command = text('command')
369 return command === undefined ? undefined : shellSecret(command, rules, escape)
370 }
371
372 return (FILE_FIELDS.get(tool) ?? []).map(text).find(path => path !== undefined && isSecretWord(path, rules))
373}
374types/index.d.ts 9 lines1/** How many tool calls secret-guard refused this session. */
2export type SecretGuardBlocked = number
3
4declare module 'claude-code' {
5 interface PluginState {
6 'secret-guard': { blocked: number }
7 }
8}
9