Guarda chaves no .env pelo /chave e impede que elas passem pelo chat, pelas ferramentas ou pela saída delas

Mods que eu mesmo criei para o Claude Code (interface e comportamento: painéis, comandos, hooks). Fonte completa de cada um.
| Mod | Versão | O que faz | Pasta |
|---|---|---|---|
assumption-session | 0.1.0 | Lista as suposições que o Claude faz enquanto constrói, num painel onde você aprova ou corrige. Abra com /suposicoes. | mods/assumption-session |
cofre-chaves | 0.1.0 | Guarda chaves no .env pelo /chave e impede que elas passem pelo chat, pelas ferramentas ou pela saída delas | mods/cofre-chaves |
comandos-mod | 0.1.0 | /comandosmod abre um painel lateral com todos os seus mods e o comando de cada um. | mods/comandos-mod |
plain-answers | 0.1.0 | Faz o Claude responder em linguagem simples, sem jargão. Liga/desliga com /simples. | mods/plain-answers |
prompt-enhance | 0.1.0 | Melhora prompts vagos usando as instruções do projeto e as skills, troca o texto na caixa e só envia quando você clicar em Enviar. | mods/prompt-enhance |
session-dashboard | 0.1.0 | Painel lateral com todas as sessões ativas: trabalhando, concluída ou esperando resposta. Abra com /sessoes. | mods/session-dashboard |
Cada pasta é um plugin do Claude Code (.claude-plugin/plugin.json, hooks/). Para carregar um mod, aponte para a pasta dele pela variável CLAUDE_CODE_PLUGIN_DIRS no settings.json (bloco env), conforme a documentação do Claude Code. Os mods são em português.
Antes de usar, leia o código de cada um. Os mods rodam hooks com acesso aos arquivos do seu projeto.
.claude-plugin/types) não foram incluídos. Para compilar, rode o tsc no mod (há um tsconfig.json em cada um).cofre-chaves não guarda chaves neste repo: o código só contém os padrões usados para detectar chaves e testes com valores fictícios.Não copiei o código destes. Estão listados no repositório claude-code-plugins, com a fonte e o commit exato:
skins (hellosverre/claude-skins)mods-manager (ptpmediabr/mods-manager)usage-weather (ptpmediabr/usage-weather)ideas-shelf (ptpmediabr/ideas-shelf)side-chat (ptpmediabr/side-chat)hooks/register.tsx 237 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, ToolCallInput } from 'claude-code'
3
4import {
5 applyMaskedEdit,
6 bashTouchesSecret,
7 ensureGitignore,
8 findEnvValue,
9 hideBlocks,
10 looksLikeKey,
11 looksLikeToken,
12 maskBlocks,
13 maskable,
14 parseEnv,
15 sanitizeName,
16 touchesSecret,
17 upsertEnv,
18} from './lib'
19
20const PANE = 'cofre-chaves'
21const nome = atom({ plugin: 'cofre-chaves', key: 'nome' } as const, '')
22const tokenLen = atom({ plugin: 'cofre-chaves', key: 'tokenLen' } as const, 0)
23const aviso = atom({ plugin: 'cofre-chaves', key: 'aviso' } as const, '')
24const nomes = atom({ plugin: 'cofre-chaves', key: 'nomes' } as const, [])
25
26const GUIDANCE =
27 'Chaves ficam no .env, salvas pelo usuário com /chave. Nunca peça para colar no chat; ' +
28 'referencie pelo nome. Scripts carregam o .env sozinhos (ex.: dotenv).'
29
30// O valor real do token vive só aqui: nunca num atom, nunca na UI.
31let token = ''
32
33async function loadEnv($: EngineInterface): Promise<Map<string, string>> {
34 try {
35 return parseEnv(await $.fs.read(`${await $.session.root()}/.env`))
36 } catch {
37 return new Map()
38 }
39}
40
41async function refreshNames($: EngineInterface) {
42 const names = [...(await loadEnv($)).keys()]
43 await update($, nomes, () => names)
44}
45
46async function readOr($: EngineInterface, path: string): Promise<string> {
47 try {
48 return await $.fs.read(path)
49 } catch {
50 return ''
51 }
52}
53
54async function saveKey($: EngineInterface) {
55 const name = await read($, nome)
56 if (name === '' || /^[0-9]/.test(name)) {
57 await update($, aviso, () => 'Dê um nome que comece com letra ou _ (ex.: OPENAI_API_KEY).')
58 return
59 }
60 if (token === '') {
61 await update($, aviso, () => 'O token está vazio.')
62 return
63 }
64
65 const root = await $.session.root()
66 await $.fs.write(`${root}/.env`, upsertEnv(await readOr($, `${root}/.env`), name, token))
67 token = ''
68
69 let note = ''
70 if (await $.fs.exists(`${root}/.git`)) {
71 const ignored = ensureGitignore(await readOr($, `${root}/.gitignore`))
72 if (ignored !== null) {
73 await $.fs.write(`${root}/.gitignore`, ignored)
74 note = ' (.env adicionado ao .gitignore)'
75 }
76 }
77
78 await update($, tokenLen, () => 0)
79 await update($, nome, () => '')
80 await update($, aviso, () => `${name} salvo no .env${note}.`)
81 await refreshNames($)
82}
83
84async function onNameInput($: EngineInterface, value: string) {
85 if (looksLikeToken(value)) {
86 token = value.trim()
87 await update($, tokenLen, () => token.length)
88 await update($, nome, () => '')
89 await update($, aviso, () => 'Isso parece um token: movi para o campo Token. Agora dê um nome.')
90 return
91 }
92 await update($, nome, () => sanitizeName(value))
93}
94
95async function onTokenInput($: EngineInterface, shown: string) {
96 token = applyMaskedEdit(token, shown)
97 await update($, tokenLen, () => token.length)
98}
99
100/** Por que a chamada toca num arquivo secreto, ou null. */
101function blockedReason(e: ToolCallInput): string | null {
102 const tool = String(e.tool)
103 const args = e as unknown as Record<string, unknown>
104 const str = (k: string) => (typeof args[k] === 'string' ? (args[k] as string) : '')
105
106 if (tool === 'Read' || tool === 'Edit' || tool === 'Write') {
107 if (touchesSecret(str('file_path'))) return `${tool} bloqueado: ${str('file_path')} é um arquivo secreto.`
108 }
109 if (tool === 'Grep') {
110 for (const k of ['path', 'glob']) if (str(k) !== '' && touchesSecret(str(k))) return `Grep bloqueado: ${str(k)} alcança arquivo secreto.`
111 }
112 if (tool === 'Bash' && bashTouchesSecret(str('command'))) {
113 return 'Comando bloqueado: ele toca num arquivo secreto. Carregar o .env (source .env, --env-file) é permitido.'
114 }
115
116 return null
117}
118
119export const register: Register = on => {
120 on('session.start', async ($, e, next) => {
121 await $.command.register({ name: 'chave', description: 'Salva uma chave no .env do projeto sem passar pelo chat' })
122 await refreshNames($)
123
124 return next(e)
125 })
126
127 on('command.run', { command: 'chave' }, async $ => {
128 await refreshNames($)
129 await update($, aviso, () => '')
130 await $.ui.open({ id: PANE, title: 'Cofre de chaves', focus: true, closeOnEscape: true })
131
132 return { text: 'Cofre de chaves aberto.' }
133 })
134
135 // 2. arquivos secretos
136 on('tool.call', ($, e, next) => {
137 const why = blockedReason(e)
138 if (why !== null) return { deny: `${why} Use a variável pelo nome; scripts carregam o .env sozinhos.` }
139
140 return next(e)
141 }).catch(($, e, next) => (next.called ? next(e) : { deny: 'cofre-chaves não conseguiu verificar esta chamada.' }))
142
143 // 3. chave colada no prompt
144 on('prompt.submit', async ($, e, next) => {
145 const kind = looksLikeKey(e.text)
146 const envName = kind === null ? findEnvValue(e.text, await loadEnv($)) : null
147 if (kind !== null || envName !== null) {
148 const what = kind ?? `valor de ${envName} no .env`
149
150 return { drop: `Esse prompt contém uma chave (${what}). Não cole chaves no chat: use /chave.` }
151 }
152
153 return next(e)
154 }).catch(($, e, next) => (next.called ? next(e) : { drop: 'cofre-chaves não conseguiu verificar o prompt: use /chave para chaves.' }))
155
156 // 4. saída de ferramenta: valores do .env viram [NOME]
157 on('session.append', async ($, e, next) => {
158 if (e.door !== 'tool-result' && e.door !== 'tool-message') return next(e)
159 const env = await loadEnv($)
160 if (maskable(env).length === 0) return next(e)
161
162 return next({ ...e, message: { ...e.message, content: maskBlocks(e.message.content, env) } })
163 }).catch(($, e, next) =>
164 e.door === 'tool-result' || e.door === 'tool-message'
165 ? next({ ...e, message: { ...e.message, content: hideBlocks(e.message.content) } })
166 : next(e),
167 )
168
169 // 5. o modelo sabe onde ficam as chaves
170 on('prompt.compose', async ($, e, next) => {
171 const composed = await next(e)
172 const names = [...(await loadEnv($)).keys()]
173 const list = names.length > 0 ? `Variáveis salvas: ${names.join(', ')}.` : 'Nenhuma variável salva ainda.'
174
175 return {
176 ...composed,
177 sections: [...composed.sections, { id: 'cofre-chaves:chaves', text: `${GUIDANCE}\n${list}`, scope: 'session' as const }],
178 }
179 })
180
181 // 1. o painel
182 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
183 const name = await read($, nome)
184 const len = await read($, tokenLen)
185 const note = await read($, aviso)
186 const saved = await read($, nomes)
187
188 if (e.surface === 'mobile') {
189 const { Text } = $.ui.resolve(e)
190 return <Text>Abra o /chave no terminal ou no desktop para digitar a chave.</Text>
191 }
192 const { Box, Input, Text } = $.ui.resolve(e)
193
194 return (
195 <Box flexDirection="column" gap={1}>
196 <Input
197 key="nome"
198 label="Nome"
199 placeholder="OPENAI_API_KEY"
200 value={name}
201 autoFocus
202 onInput={value => void onNameInput($, value)}
203 onSubmit={async value => {
204 await onNameInput($, value)
205 if (token !== '') await saveKey($)
206 }}
207 />
208 <Input
209 key="token"
210 label="Token"
211 placeholder="cole aqui"
212 value={'•'.repeat(len)}
213 submitLabel="salvar"
214 onInput={value => void onTokenInput($, value)}
215 onSubmit={async value => {
216 await onTokenInput($, value)
217 await saveKey($)
218 }}
219 />
220 {note !== '' && (
221 <Text color="warning">
222 {note}
223 </Text>
224 )}
225 <Box flexDirection="column">
226 <Text bold>Salvas no .env</Text>
227 {saved.length === 0 ? (
228 <Text dimColor>nenhuma ainda</Text>
229 ) : (
230 saved.map(n => <Text>• {n}</Text>)
231 )}
232 </Box>
233 </Box>
234 )
235 })
236}
237hooks/lib.ts 273 lines1// Regras puras do cofre: sem `$`, testáveis isoladamente.
2
3// ---------- arquivos secretos ----------
4
5const SAFE_ENV_SUFFIXES = ['.example', '.sample', '.template']
6const SECRET_EXTENSIONS = ['.pem', '.key', '.p12', '.pfx']
7const SSH_KEYS = ['id_rsa', 'id_ed25519', 'id_ecdsa']
8
9/** Amostras usadas para saber se um glob alcança algum arquivo secreto. */
10const SECRET_SAMPLES = ['.env', '.env.local', '.env.production', 'cert.pem', 'server.key', 'cert.p12', 'cert.pfx', ...SSH_KEYS]
11
12function basename(path: string): string {
13 const parts = path.replace(/\/+$/, '').split('/')
14
15 return parts[parts.length - 1] ?? ''
16}
17
18/** .env, .env.* (menos .example/.sample/.template), *.pem, *.key, *.p12, *.pfx e chaves SSH. */
19export function isSecretPath(path: string): boolean {
20 const name = basename(path.trim())
21 if (name === '.env') return true
22 if (name.startsWith('.env.')) return !SAFE_ENV_SUFFIXES.some(s => name.endsWith(s))
23 if (SSH_KEYS.includes(name)) return true
24
25 return SECRET_EXTENSIONS.some(ext => name.length > ext.length && name.toLowerCase().endsWith(ext))
26}
27
28function globToRegExp(glob: string): RegExp {
29 let out = ''
30 for (let i = 0; i < glob.length; i++) {
31 const c = glob[i]!
32 if (c === '*') out += '[^/]*'
33 else if (c === '?') out += '[^/]'
34 else if (c === '{') out += '('
35 else if (c === '}') out += ')'
36 else if (c === ',') out += '|'
37 else if (c === '[') {
38 const end = glob.indexOf(']', i)
39 if (end === -1) out += '\\['
40 else {
41 out += `[${glob.slice(i + 1, end).replace(/^!/, '^')}]`
42 i = end
43 }
44 } else out += c.replace(/[.+^$()|\\]/g, '\\$&')
45 }
46
47 return new RegExp(`^${out}$`)
48}
49
50/** Um caminho ou glob ("*.pem", ".env*", "**\/.env") que alcança arquivo secreto. */
51export function touchesSecret(pathOrGlob: string): boolean {
52 if (isSecretPath(pathOrGlob)) return true
53 const name = basename(pathOrGlob.trim())
54 if (!/[*?[{]/.test(name)) return false
55 try {
56 // Só curingas ("*", "**", "?*") não nomeiam nada; e sem ponto inicial o shell não alcança arquivos ocultos.
57 if (name.replace(/[*?[\]{},!\\]/g, '') === '') return false
58 const re = globToRegExp(name)
59 const reachesHidden = name.startsWith('.')
60
61 return SECRET_SAMPLES.some(sample => (reachesHidden || !sample.startsWith('.')) && re.test(sample))
62 } catch {
63 return false
64 }
65}
66
67const ENV_FILE = String.raw`(?:"[^"]*"|'[^']*'|[^\s;&|()<>]+)`
68/** Carregar o .env é permitido: `source .env`, `. .env`, `--env-file .env`. */
69const ALLOWED_LOADS = [
70 new RegExp(String.raw`(^|[;&|(\s])(?:source|\.)\s+(${ENV_FILE})`, 'g'),
71 new RegExp(String.raw`()--env-file(?:=|\s+)(${ENV_FILE})`, 'g'),
72]
73
74function isEnvFile(path: string): boolean {
75 const name = basename(path.replace(/^["']|["']$/g, ''))
76
77 return name === '.env' || name.startsWith('.env.')
78}
79
80/** Um comando de shell que lê, copia, escreve ou lista um arquivo secreto. */
81export function bashTouchesSecret(command: string): boolean {
82 let rest = command
83 for (const re of ALLOWED_LOADS) {
84 rest = rest.replace(re, (match, lead: string, file: string) => (isEnvFile(file) ? `${lead} ` : match))
85 }
86 const tokens = rest.split(/[\s;&|()<>`"'=]+/).filter(Boolean)
87
88 return tokens.some(touchesSecret)
89}
90
91// ---------- chaves no texto ----------
92
93const KEY_PATTERNS: readonly [string, RegExp][] = [
94 ['chave da Anthropic', /sk-ant-[A-Za-z0-9_-]{20,}/],
95 ['chave da OpenAI', /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}/],
96 ['token do GitHub', /\bgh[pousr]_[A-Za-z0-9]{20,}/],
97 ['token do GitHub', /\bgithub_pat_[A-Za-z0-9_]{20,}/],
98 ['token da Meta', /\bEAA[A-Za-z0-9]{50,}/],
99 ['chave da AWS', /\bAKIA[0-9A-Z]{16}\b/],
100 ['chave do Google', /\bAIza[0-9A-Za-z_-]{35}/],
101 ['token do Slack', /\bxox[baprs]-[A-Za-z0-9-]{10,}/],
102 ['JWT', /\beyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\./],
103 ['chave privada', /-----BEGIN (?:[A-Z]+ )*PRIVATE KEY-----/],
104]
105
106/** O tipo da primeira chave reconhecida no texto, ou null. */
107export function looksLikeKey(text: string): string | null {
108 for (const [kind, re] of KEY_PATTERNS) if (re.test(text)) return kind
109
110 return null
111}
112
113/** Texto colado no campo Nome que na verdade é um token. */
114export function looksLikeToken(text: string): boolean {
115 const t = text.trim()
116
117 return looksLikeKey(t) !== null || t.length > 40 || /^\d{6,}$/.test(t)
118}
119
120/** Nome de variável: maiúsculas, só A-Z, 0-9 e _. */
121export function sanitizeName(text: string): string {
122 return text.toUpperCase().replace(/[^A-Z0-9_]/g, '')
123}
124
125// ---------- .env ----------
126
127const LINE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/
128
129function unquote(raw: string): string {
130 const v = raw.trim()
131 if (v.startsWith("'")) {
132 const end = v.indexOf("'", 1)
133
134 return end === -1 ? v.slice(1) : v.slice(1, end)
135 }
136 if (v.startsWith('"')) {
137 let out = ''
138 for (let i = 1; i < v.length; i++) {
139 const c = v[i]!
140 if (c === '\\' && i + 1 < v.length) {
141 const n = v[++i]!
142 out += n === 'n' ? '\n' : n
143 } else if (c === '"') break
144 else out += c
145 }
146
147 return out
148 }
149
150 return v.replace(/\s+#.*$/, '').trim()
151}
152
153/** NOME → valor, na ordem do arquivo; a última definição vence. */
154export function parseEnv(text: string): Map<string, string> {
155 const env = new Map<string, string>()
156 for (const line of text.split(/\r?\n/)) {
157 if (/^\s*#/.test(line)) continue
158 const m = LINE.exec(line)
159 if (m) env.set(m[1]!, unquote(m[2]!))
160 }
161
162 return env
163}
164
165/** Aspas simples quando o valor tem espaço, #, aspas, \ ou $. */
166export function formatEnvValue(value: string): string {
167 if (!/[\s#'"\\$]/.test(value)) return value
168 if (!value.includes("'")) return `'${value}'`
169
170 return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`
171}
172
173/** Troca a linha NOME=... ou acrescenta uma no fim. */
174export function upsertEnv(text: string, name: string, value: string): string {
175 const line = `${name}=${formatEnvValue(value)}`
176 const lines = text === '' ? [] : text.replace(/\r?\n$/, '').split(/\r?\n/)
177 let found = false
178 const out = lines.map(l => {
179 const m = LINE.exec(l)
180 if (m && m[1] === name && !/^\s*#/.test(l)) {
181 found = true
182
183 return line
184 }
185
186 return l
187 })
188 if (!found) out.push(line)
189
190 return `${out.join('\n')}\n`
191}
192
193/** Garante a linha .env no .gitignore; null quando já está lá. */
194export function ensureGitignore(text: string): string | null {
195 const has = text.split(/\r?\n/).some(l => ['.env', '/.env', '.env*', '/.env*', '*.env'].includes(l.trim()))
196 if (has) return null
197 const base = text === '' || text.endsWith('\n') ? text : `${text}\n`
198
199 return `${base}.env\n`
200}
201
202/** Valores do .env com 8 caracteres ou mais, os mais longos primeiro. */
203export function maskable(env: Map<string, string>): [string, string][] {
204 return [...env.entries()].filter(([, v]) => v.length >= 8).sort((a, b) => b[1].length - a[1].length)
205}
206
207/** Troca cada valor do .env (8+ caracteres) por [NOME]. */
208export function maskText(text: string, env: Map<string, string>): string {
209 let out = text
210 for (const [name, value] of maskable(env)) out = out.split(value).join(`[${name}]`)
211
212 return out
213}
214
215/** O nome da variável do .env cujo valor aparece no texto, ou null. */
216export function findEnvValue(text: string, env: Map<string, string>): string | null {
217 for (const [name, value] of maskable(env)) if (text.includes(value)) return name
218
219 return null
220}
221
222/**
223 * O campo Token mostra só bolinhas: cada edição chega como as bolinhas que
224 * sobraram mais o que foi digitado ou colado. Devolve o valor real novo.
225 */
226export function applyMaskedEdit(real: string, shown: string): string {
227 let kept = 0
228 while (kept < shown.length && shown[kept] === '•') kept++
229 kept = Math.min(kept, real.length)
230
231 return real.slice(0, kept) + shown.slice(kept).replace(/•/g, '')
232}
233
234type Block = { type: string; [field: string]: unknown }
235
236function mapToolResult(block: Block, fn: (text: string) => string): Block {
237 if (typeof block.content === 'string') return { ...block, content: fn(block.content) }
238 if (Array.isArray(block.content)) {
239 return {
240 ...block,
241 content: (block.content as Block[]).map(inner =>
242 inner.type === 'text' && typeof inner.text === 'string' ? { ...inner, text: fn(inner.text) } : inner,
243 ),
244 }
245 }
246
247 return block
248}
249
250/** Os blocos de uma saída de ferramenta com os valores do .env trocados por [NOME]. */
251export function maskBlocks(content: readonly Block[], env: Map<string, string>): Block[] {
252 const fn = (text: string) => maskText(text, env)
253
254 return content.map(block => {
255 if (block.type === 'text' && typeof block.text === 'string') return { ...block, text: fn(block.text) }
256 if (block.type === 'tool_result') return mapToolResult(block, fn)
257
258 return block
259 })
260}
261
262/** Quando o mascaramento falha, a saída inteira some em vez de vazar. */
263export function hideBlocks(content: readonly Block[]): Block[] {
264 const hidden = () => '[saída ocultada pelo cofre-chaves]'
265
266 return content.map(block => {
267 if (block.type === 'text') return { ...block, text: hidden() }
268 if (block.type === 'tool_result') return { ...block, content: hidden() }
269
270 return block
271 })
272}
273