Asks the user before Claude reads secret files (.env, private keys, .netrc, .npmrc, .aws/credentials, keychains…) with Read, Grep, Glob or Bash

Asks for confirmation before Claude reads a secret file, judged by file name.
Read, Grep, Glob and Bash calls; a match turns the verdict into an ask (permission prompt) with the reason. A call already denied stays denied..env and .env.* (except .example, .sample, .template), *.pem, *.key, *.p12, keychains (*.keychain*), SSH private keys (id_rsa, id_ed25519, id_ecdsa, id_dsa, not .pub), .netrc, .npmrc, .pypirc, .aws/credentials.Read checks file_path; Grep/Glob check path and the glob when it names a secret file directly (**/.env, *.pem), not a broad one (**/*).Bash: readers such as cat, head, tail, less, source, ., grep, rg, awk, sed, cp, scp, rsync, openssl, base64, xargs, open, including < file and --flag=file; wrappers like sudo, env, rtk are looked through. Also security find-generic-password|find-internet-password with -w or -g.| Command | Effect |
|---|---|
/env-protect status | Show whether checks are on (default when no argument) |
/env-protect off | Disable for this session (status line shows env-protect: OFF) |
/env-protect on | Re-enable |
claude --plugin-dir /path/to/ModsTools/mods/env-protect
grep -r KEY ., paths built from variables or $(...), scripts that read the file themselves, symlinks and renamed copies are not seen.auto / bypassPermissions mode the mode settles the question.claude plugin validate mods/env-protect
claude plugin test mods/env-protect # 76 testshooks/register.ts 57 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { checkBash, checkFileTool } from './rules'
5
6const isOff = atom({ plugin: 'env-protect', key: 'isOff' } as const, false)
7
8async function escalate<T extends { decision: string }>(
9 $: EngineInterface,
10 verdict: T,
11 why: string | null,
12): Promise<T | { decision: 'ask'; reason: string }> {
13 if (why === null || verdict.decision === 'deny' || (await read($, isOff))) return verdict
14
15 return {
16 decision: 'ask',
17 reason: `env-protect: this reads a secret file (${why}). Confirm only if the user expects it. If refused, ask the user: they can paste what is needed or stop the checks with /env-protect off.`,
18 }
19}
20
21export const register: Register = on => {
22 on('session.start', async ($, e, next) => {
23 await $.command.register({
24 name: 'env-protect',
25 description: 'env-protect on|off|status: ask before reading secret files (.env, private keys, .netrc…)',
26 })
27
28 return next(e)
29 })
30
31 on('command.run', { command: 'env-protect' }, 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' ? 'env-protect: OFF' : undefined)
36 }
37 const off = await read($, isOff)
38
39 return { text: `env-protect is ${off ? 'OFF for this session' : 'ON'}.` }
40 })
41
42 for (const tool of ['Read', 'Grep', 'Glob'] as const) {
43 on('tool.check', { tool }, async ($, e, next) => {
44 const verdict = await next(e)
45
46 return escalate($, verdict, checkFileTool(tool, (e.input ?? {}) as Record<string, unknown>))
47 })
48 }
49
50 on('tool.check', { tool: 'Bash' }, async ($, e, next) => {
51 const verdict = await next(e)
52 const command = (e.input as { command?: unknown }).command
53
54 return escalate($, verdict, typeof command === 'string' ? checkBash(command) : null)
55 })
56}
57hooks/rules.ts 104 lines1// Word-level parsing, like rm-guard: no shell grammar. Not covered: `grep -r KEY .` (no file named),
2// variables or command substitutions that build a path, scripts that read the file themselves,
3// `dotenv`-style loaders, and a file reached through a symlink or a renamed copy.
4
5const SAFE_ENV_SUFFIXES = new Set(['example', 'sample', 'template'])
6
7const baseName = (word: string) => word.slice(word.lastIndexOf('/') + 1)
8
9/** True when the path names a secret file (private key, credentials, .env…), judged by its name alone. */
10export function isSecretPath(path: string): boolean {
11 const name = baseName(path.replace(/\/+$/, '')).toLowerCase()
12 if (name === '') return false
13 if (name === '.env' || name.startsWith('.env.')) {
14 return !SAFE_ENV_SUFFIXES.has(name.slice(name.lastIndexOf('.') + 1))
15 }
16 if (name.startsWith('.env') && /[*?[]/.test(name)) return true
17 if (/\.(pem|key|p12)$/.test(name) || name.includes('.keychain')) return true
18 if (/^id_(rsa|ed25519|ecdsa|dsa)(_.*)?$/.test(name) && !name.endsWith('.pub')) return true
19 if (name === '.netrc' || name === '.npmrc' || name === '.pypirc') return true
20
21 return name === 'credentials' && path.split('/').includes('.aws')
22}
23
24// Commands that read or copy the files named in their arguments.
25const READERS = new Set([
26 'cat', 'less', 'more', 'head', 'tail', 'bat', 'nl', 'tac', 'xxd', 'hexdump', 'od', 'strings', 'base64',
27 'source', '.', 'grep', 'egrep', 'fgrep', 'rg', 'ag', 'awk', 'sed', 'cut', 'sort', 'uniq', 'diff',
28 'cp', 'scp', 'rsync', 'openssl', 'ssh-keygen', 'gpg', 'xargs', 'open',
29])
30
31const WRAPPERS: Record<string, Set<string>> = {
32 sudo: new Set(['-u', '-g', '-C', '-D', '-h', '-p', '-U']),
33 env: new Set(['-u', '-C', '-S']),
34 command: new Set(),
35 nohup: new Set(),
36 time: new Set(),
37 nice: new Set(['-n']),
38 rtk: new Set(),
39}
40
41const unquote = (word: string) => word.replace(/^['"]+|['"]+$/g, '')
42
43const isAssignment = (word: string) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(word)
44
45function strip(words: string[]): string[] {
46 let i = 0
47 for (;;) {
48 while (words[i] !== undefined && isAssignment(words[i]!)) i++
49 const takesArg = WRAPPERS[words[i] ?? '']
50 if (takesArg === undefined) break
51 i++
52 while (words[i]?.startsWith('-')) i += takesArg.has(words[i]!) ? 2 : 1
53 }
54
55 return words.slice(i)
56}
57
58/** Each simple command of a shell line as unquoted words, wrappers dropped; `$(…)`, backticks and parentheses split too. */
59export function segments(command: string): string[][] {
60 return command
61 .split(/&&|\|\||\$\(|[;|&\n`()]/)
62 .map(part => strip(part.trim().split(/\s+/).filter(Boolean).map(unquote)))
63 .filter(words => words.length > 0)
64}
65
66// `--flag=path` and `<path` count as the path.
67const operand = (word: string) => word.replace(/^<+/, '').replace(/^--[A-Za-z-]+=/, '')
68
69function checkWords(words: string[]): string | null {
70 const name = baseName(words[0] ?? '')
71 const args = words.slice(1)
72 if (name === 'security' && /^find-(generic|internet)-password$/.test(args[0] ?? '')) {
73 if (args.some(a => /^-[A-Za-z]*[wg][A-Za-z]*$/.test(a))) return 'keychain password'
74 }
75 if (!READERS.has(name)) return null
76 const secret = args.map(unquote).map(operand).find(isSecretPath)
77
78 return secret === undefined ? null : `${name} ${secret}`
79}
80
81/** Why the Bash command reads a secret file, or null when it does not. */
82export function checkBash(command: string): string | null {
83 for (const words of segments(command)) {
84 const reason = checkWords(words)
85 if (reason !== null) return reason
86 }
87
88 return null
89}
90
91// A glob that names a secret file directly (`**/.env`, `*.pem`), not a broad one (`**/*`).
92const isSecretGlob = (glob: string) => isSecretPath(glob) || /(^|\/)\.env\*?$|\*\.(pem|key|p12)$/.test(glob)
93
94/** Why a Read/Grep/Glob call targets a secret file, or null. */
95export function checkFileTool(tool: string, input: Record<string, unknown>): string | null {
96 const str = (key: string) => (typeof input[key] === 'string' ? (input[key] as string) : '')
97 if (tool === 'Read') return isSecretPath(str('file_path')) ? str('file_path') : null
98 const path = str('path')
99 if (path !== '' && isSecretPath(path)) return path
100 const glob = tool === 'Glob' ? str('pattern') : str('glob')
101
102 return glob !== '' && isSecretGlob(glob) ? glob : null
103}
104types/index.d.ts 8 lines1export type EnvProtectSwitch = boolean
2
3declare module 'claude-code' {
4 interface PluginState {
5 'env-protect': { isOff: EnvProtectSwitch }
6 }
7}
8