SLOPSHOPPER

cofre-chaves

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

newpaneguardcommandprompt
v0.1.0no licenseupdated 2026-10-12cfelipemoreira/claude-code-mods/mods/cofre-chaves
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cofre-chaves
│ ┃ Cofre de chaves ✕ › fix the failing auth test and add an audit log call │ ┃ Nome: OPENAI_API_KEY │ ┃ ⏺ Read(src/auth.ts) │ ┃ Token: cole aqui ⏎ salvar ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Salvas no .env ⎿ Added 2 lines, removed 1 line │ ┃ • DATABASE_URL ⏺ Bash(cat .env) │ ┃ • STRIPE_SECRET_KEY ⎿ Denied by cofre-chaves: Comando bloqueado: ele toca num a │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /chave │ ⎿ cofre-chaves: Cofre de chaves aberto. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Cofre de chaves
Nome: OPENAI_API_KEY Token: cole aqui ⏎ salvar Salvas no .env • DATABASE_URL • STRIPE_SECRET_KEY
README

claude-code-mods

Mods que eu mesmo criei para o Claude Code (interface e comportamento: painéis, comandos, hooks). Fonte completa de cada um.

ModVersãoO que fazPasta
assumption-session0.1.0Lista as suposições que o Claude faz enquanto constrói, num painel onde você aprova ou corrige. Abra com /suposicoes.mods/assumption-session
cofre-chaves0.1.0Guarda chaves no .env pelo /chave e impede que elas passem pelo chat, pelas ferramentas ou pela saída delasmods/cofre-chaves
comandos-mod0.1.0/comandosmod abre um painel lateral com todos os seus mods e o comando de cada um.mods/comandos-mod
plain-answers0.1.0Faz o Claude responder em linguagem simples, sem jargão. Liga/desliga com /simples.mods/plain-answers
prompt-enhance0.1.0Melhora 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-dashboard0.1.0Painel lateral com todas as sessões ativas: trabalhando, concluída ou esperando resposta. Abra com /sessoes.mods/session-dashboard

Como usar

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.

Observações

  • Os arquivos de tipagem gerados (.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.

Mods de terceiros que também uso

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)
Source 2 files
hooks/register.tsx 237 lines
1import { 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}
237
hooks/lib.ts 273 lines
1// 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