Keeps API keys out of the conversation: /chave writes them to .env, reads of secret files show only names, outputs are masked, pasted keys are held

Function-hook mods for Claude Code: shell safety, secret guarding, session telemetry, context panels, and workflow tools. Each mod is an isolated plugin with its own tests, and you enable only the ones you want.
| Mod | Description |
|---|---|
guarda | Intercepts risky Bash commands (rm -rf, git reset --hard, force push, new dependencies) and asks for confirmation explaining the impact. |
lixeira | Sends rm / Remove-Item targets to the Windows Recycle Bin instead of deleting them permanently. |
cofre | Keeps API keys out of the conversation: /chave stores them in .env, secret file reads display only variable names, outputs are masked, and pasted keys are quarantined. |
atomic-commit-guard | Blocks commits that mix top-level directories, violate Conventional Commits, or lack a Co-Authored-By trailer. |
universal-audit-log | A hook on * that logs every event (tool calls, prompts, turns, fs/http/process calls from other plugins) with secret redaction. |
| Mod | Description |
|---|---|
context-monitor | Context weather and token usage above the prompt, replay and activity panels, plain-language explanation before each edit, and ai-memory notes. |
cache-frio | Confirms before submitting a prompt to a large conversation whose prompt cache has gone cold. |
session-telemetry | Timestamp on each response, TTFT and tok/s per step, and visual notice whenever the responding model switches. |
session-time-machine | /timemachine: renders the session as a timeline of prompts, tool calls, and turn completions. |
turn-inspector | Side pane displaying the session turn-by-turn: every model request and what it carried. |
clean-view | Collapses each successful tool call into a single concise line (Edit file +12 −3); errors and in-flight calls retain the full block. |
webfetch-cache | Serves repeated WebFetch requests (identical URL and prompt) directly from the session cache. |
reflexo | Detects corrections and constraints in prompts, offering to save them as rules in CLAUDE.md. |
| Mod | Description |
|---|---|
agent-flow | Live diagram of agent flow: main loop context, subagent tree, and context passed/returned by each agent. |
agent-radar | One live line per running subagent above the prompt; /radar opens a pane with messages from each. |
delegation-dock | Status line indicator showing in-flight delegations (agy/codex/ollama) and alerting when one returns empty. |
phase-tracker | Tracks and displays the current development phase (Spec → Red Test → Surgical Code → Verification). |
next-prompts | Band with 3 next-step suggestions generated by a free Ollama cloud model; click one to draft. |
git-sidebar | Lazygit-style side pane: worktrees, local branches, branch switching, and worktree checkout. |
wait-quiz | Quizzes a flashcard in a band above the prompt while Claude is working. |
meus-mods | Central hub: quick-access bar above the prompt and /mods command displaying the status of each mod. |
Requires Claude Code with function-hook mods support.
git clone https://github.com/Covre81/claude-code-mods.git ~/.claude/mods
Enable the mods by listing their absolute paths in CLAUDE_CODE_PLUGIN_DIRS inside ~/.claude/settings.json. On Windows, paths are separated by ;:
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "C:/Users/me/.claude/mods/guarda;C:/Users/me/.claude/mods/cofre;C:/Users/me/.claude/mods/session-telemetry"
}
}
Order matters: the last plugin in the list sits closest to the engine. For instance, lixeira should come after guarda so guarda inspects the original rm -rf first.
Several mods include a command to toggle them on or off without restarting (/webfetch-cache on|off, /radar-toggle, /telemetria…); /mods displays the status of all mods.
Each mod follows the same structure:
<mod>/
.claude-plugin/plugin.json manifest
.claude-plugin/types/ engine types (git-ignored, see below)
hooks/hooks.json hooks registered by the mod
hooks/register.ts(x) code
hooks/*.test.ts(x) tests
Engine types are not committed to the repository. To typecheck and test a mod, copy .claude-plugin/types from an existing mod or generate them with your Claude Code instance.
Tests run directly within Claude Code, not via vitest/jest:
claude plugin test guarda # single mod
for d in */hooks/hooks.json; do claude plugin test "${d%%/*}"; done # all mods
Commits follow Conventional Commits, scoped to one mod per commit (enforced by atomic-commit-guard).
Six mods (agent-flow, git-sidebar, session-time-machine, turn-inspector, universal-audit-log, webfetch-cache) originate from davila7/claude-code-templates, and agent-radar originates from hamzafer/claude-code-mods, both under the MIT License and adapted here. Copyright notices are preserved in THIRD_PARTY_NOTICES.md.
hooks/register.tsx 137 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { detect, ensureGitignore, envNames, envValues, isEnvFile, isSecretFile, MASK, maskDeep, maskText, secretReadTarget, upsertEnv } from './secrets'
4
5type Call = Record<string, unknown> & { tool?: unknown }
6const str = (v: unknown) => (typeof v === 'string' ? v : '')
7
8/** Values of the session folder's .env files, plus the one being read. ponytail: reread per call, no cache; .env files are tiny. */
9async function secretValues($: EngineInterface, extra?: string): Promise<string[]> {
10 const local = (await $.fs.list().catch(() => [])).filter(f => f.kind !== 'dir' && isEnvFile(f.name)).map(f => f.name)
11 const paths = extra && !local.includes(extra) ? [...local, extra] : local
12 const texts = await Promise.all(paths.map(p => $.fs.read(p).catch(() => '')))
13 return texts.flatMap(t => envValues(String(t)))
14}
15
16const EDITORS = ['Edit', 'Write', 'MultiEdit']
17
18/** Why this call must not run, or null. A Read of .env runs and gets masked instead. */
19async function refusal($: EngineInterface, call: Call): Promise<string | null> {
20 const tool = str(call.tool)
21 if (EDITORS.includes(tool)) {
22 const edits = Array.isArray(call.edits) ? (call.edits as Call[]) : []
23 const written = [str(call.content), str(call.new_string), ...edits.map(x => str(x.new_string))]
24 const file = str(call.file_path)
25 return isSecretFile(file) && written.some(w => w.includes(MASK))
26 ? `Gravação em ${file} negada: o texto contém ${MASK}, que é a máscara do cofre, não o valor real, e sobrescreveria a chave. Use /chave NOME para gravar o valor.`
27 : null
28 }
29 const target =
30 tool === 'Read' ? str(call.file_path) : tool === 'Grep' ? ([str(call.path), str(call.glob)].find(isSecretFile) ?? '') : (secretReadTarget(str(call.command)) ?? '')
31 if (!target || !isSecretFile(target)) return null
32 if (tool === 'Read' && isEnvFile(target)) return null
33 const names = isEnvFile(target) ? envNames(String(await $.fs.read(target).catch(() => ''))) : []
34 return `Arquivo de segredo (${target}): conteúdo oculto pelo cofre.${names.length ? ` Variáveis definidas: ${names.join(', ')}.` : ''} Use os nomes; os valores ficam só no disco.`
35}
36
37const PANE = 'cofre'
38const NAME = /^[A-Z_][A-Z0-9_]*$/
39const WAIT_MS = 10 * 60 * 1000
40
41// The value the Input hands back; null while waiting, '' when cancelled. One /chave at a time.
42let pending: { name: string; value: string | null } | null = null
43
44async function save($: EngineInterface, name: string, value: string): Promise<string> {
45 // Only a file that is not listed counts as empty; a listed file that fails to read aborts before any write, so a lock or EACCES can't wipe the user's other secrets.
46 const listed = new Set((await $.fs.list()).filter(f => f.kind !== 'dir').map(f => f.name))
47 const readOrEmpty = async (file: string) => (listed.has(file) ? String(await $.fs.read(file)) : '')
48 let ignore: string, env: string
49 try {
50 ;[ignore, env] = [await readOrEmpty('.gitignore'), await readOrEmpty('.env')]
51 } catch (err) {
52 return `🔐 ${name} não gravada: não consegui ler o arquivo existente (${err instanceof Error ? err.message : 'erro de leitura'}).`
53 }
54 const ignored = ensureGitignore(ignore)
55 // .gitignore first: a .env must never exist on disk un-ignored, not even for a moment.
56 if (ignored !== ignore) await $.fs.write('.gitignore', ignored)
57 await $.fs.write('.env', upsertEnv(env, name, value))
58 return `🔐 ${name} gravada no .env${ignored !== ignore ? ' · .gitignore atualizado' : ''}`
59}
60
61export const register: Register = on => {
62 on('tool.call', { tool: /^(Read|Grep|Bash|PowerShell|Edit|Write|MultiEdit)$/ }, async ($, e, next) => {
63 const call = e as unknown as Call
64 const why = await refusal($, call)
65 if (why) return { deny: why }
66 const r = await next(e)
67 if (r.deny !== undefined) return r
68 const tool = str(call.tool)
69 const extra = tool === 'Read' && isEnvFile(str(call.file_path)) ? str(call.file_path) : EDITORS.includes(tool) ? str(call.file_path) : undefined
70 const values = await secretValues($, extra || undefined)
71 const text = r.text ?? ''
72 const textClean = maskText(text, values) === text
73 if (r.result === undefined) return textClean ? r : { deny: 'saída retida pelo cofre: continha segredo' }
74 const masked = maskDeep(r.result, values)
75 // A hook's own result carries no `text`/`ref`: spreading r would hand the model the raw text core rendered.
76 return textClean && JSON.stringify(masked) === JSON.stringify(r.result) ? r : ({ result: masked, context: r.context, isError: r.isError } as typeof r)
77 }).catch(() => ({ deny: 'O cofre falhou ao conferir esta chamada e reteve o resultado por segurança.' }))
78
79 on('prompt.mention', ($, e, next) => (isSecretFile(e.path) ? { deny: 'arquivo de segredo: o cofre não anexa o conteúdo' } : next(e))).catch(() => ({
80 deny: 'o cofre falhou ao conferir a menção',
81 }))
82
83 on('prompt.submit', ($, e, next) => {
84 if (/^#ok\b/.test(e.text)) return next({ ...e, text: e.text.replace(/^#ok\s*/, '') })
85 const hit = detect(e.text)[0]
86 if (!hit) return next(e)
87 return { drop: `🔐 Isso parece uma chave ${hit.kind} (${hit.sample}). Use /chave NOME para gravar no .env sem passar pelo chat. Para enviar mesmo assim, comece a mensagem com #ok` }
88 }).catch(($, e, next) => (next.called ? next(e) : { drop: 'o cofre falhou ao conferir o prompt' })) // fail closed: a key must not reach the model unchecked
89
90 on('session.start', async ($, e, next) => {
91 await $.command.register({ name: 'chave', description: 'Grava uma chave de API no .env sem ela passar pelo chat: /chave NOME' })
92 return next(e)
93 })
94
95 on('command.run', { command: 'chave' }, async ($, e) => {
96 const name = e.args.trim()
97 if (!NAME.test(name)) return { text: 'Uso: /chave NOME (maiúsculas, números e _), ex.: /chave OPENAI_API_KEY' }
98 if (pending) return { text: 'Já tem um /chave aberto.' }
99 const p: { name: string; value: string | null } = { name, value: null }
100 pending = p
101 try {
102 if (!(await $.ui.open({ id: PANE, title: 'Cofre', focus: true, closeOnEscape: true, rows: 6 })).isPlaced) return { text: 'Terminal estreito demais para o campo do cofre.' }
103 const startedAt = await $.clock.now()
104 while (p.value === null) {
105 if ((await $.clock.now()) - startedAt > WAIT_MS) return { text: `${name} não gravada: ninguém respondeu em 10 minutos.` }
106 await $.clock.sleep(250)
107 }
108 return { text: p.value ? await save($, name, p.value) : `${name} não gravada.` }
109 } finally {
110 pending = null
111 await $.ui.close({ id: PANE }).catch(() => undefined)
112 }
113 })
114
115 on('ui.close', ($, e, next) => {
116 if (e.id === PANE && pending && pending.value === null) pending.value = ''
117 return next(e)
118 })
119
120 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
121 const p = pending
122 if (e.requestId !== PANE || !p || e.surface !== 'terminal') return next(e)
123 const { Box, Input, Text } = $.ui.resolve(e)
124 return (
125 <Box flexDirection="column" borderStyle="round" borderColor="green" paddingX={1}>
126 <Text bold color="green">
127 🔐 Cofre · {p.name}
128 </Text>
129 <Input key="valor" label="Cole o valor e tecle Enter" autoFocus submitLabel="gravar" onSubmit={v => void (p.value ??= v.trim())} />
130 <Text dimColor wrap="wrap">
131 Vai para o .env desta pasta e não entra na conversa com o Claude. Esc cancela.
132 </Text>
133 </Box>
134 )
135 })
136}
137hooks/secrets.ts 99 lines1// Pure rules of the cofre: what a secret looks like, which files hold one, and how to hide it.
2
3export type Hit = { kind: string; sample: string }
4
5// ponytail: fixed provider prefixes, no entropy scoring (false positives on hashes and UUIDs); add a row when a new provider shows up.
6const PATTERNS: readonly (readonly [string, RegExp])[] = [
7 ['anthropic', /(?<![\w-])sk-ant-[\w-]{20,}/g],
8 ['openai', /(?<![\w-])sk-(?!ant-)(?:proj-)?[\w-]{20,}/g],
9 ['google', /AIza[\w-]{35}/g],
10 ['jwt', /eyJ[\w-]{10,}\.eyJ[\w-]{10,}\.[\w-]{10,}/g],
11 ['github', /ghp_[A-Za-z0-9]{36}|github_pat_\w{22,}/g],
12 ['aws', /AKIA[0-9A-Z]{16}/g],
13 // The whole block when its END is there, so masking hides the key body, not just the header.
14 ['private-key', /-----BEGIN [A-Z ]*PRIVATE KEY-----(?:[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----)?/g],
15]
16
17export function detect(text: string): Hit[] {
18 return PATTERNS.flatMap(([kind, re]) => [...text.matchAll(re)].map(m => ({ kind, sample: `${m[0].slice(0, 6)}…` })))
19}
20
21const baseName = (path: string) => (path.replace(/["']/g, '').split(/[\\/]/).pop() ?? '').toLowerCase()
22
23export const isEnvFile = (path: string) => /^\.env(\..+)?$/.test(baseName(path)) && !/\.(example|sample|template)$/.test(baseName(path))
24
25export function isSecretFile(path: string): boolean {
26 const base = baseName(path)
27 if (isEnvFile(path)) return true
28 if (base.endsWith('.pub')) return false
29 return /\.(pem|key)$/.test(base) || /^credentials.*\.json$/.test(base) || /^id_(rsa|ed25519)/.test(base)
30}
31
32export const MASK = '••••'
33
34const ENV_LINE = /^\s*(?:export\s+)?([A-Za-z_]\w*)\s*=\s*(.*)$/
35
36function unquote(raw: string): string {
37 const v = raw.trim()
38 // Read up to the closing quote and ignore what follows, so `"x" # note` still yields x.
39 const dq = /^"((?:[^"\\]|\\.)*)"/.exec(v)
40 if (dq) return dq[1]!.replace(/\\(["\\])/g, '$1')
41 const sq = /^'([^']*)'/.exec(v)
42 if (sq) return sq[1]!
43 return v.replace(/\s+#.*$/, '')
44}
45
46const envEntries = (text: string) =>
47 text.split(/\r?\n/).flatMap(line => {
48 const m = ENV_LINE.exec(line)
49 return m ? [{ name: m[1]!, value: unquote(m[2]!) }] : []
50 })
51
52export const envNames = (text: string) => envEntries(text).map(e => e.name)
53
54/** Values worth masking: 8+ chars, so PORT=3000 or DEBUG=true stay readable. */
55export const envValues = (text: string) => envEntries(text).map(e => e.value).filter(v => v.length >= 8)
56
57export function maskText(text: string, values: readonly string[]): string {
58 let out = text
59 for (const v of [...values].sort((a, b) => b.length - a.length)) out = out.split(v).join(MASK)
60 for (const [, re] of PATTERNS) out = out.replace(re, MASK)
61 return out
62}
63
64/** Masks every string inside a tool's result and keeps its shape, so core's schema check still passes. */
65export function maskDeep<T>(value: T, values: readonly string[]): T {
66 if (typeof value === 'string') return maskText(value, values) as T
67 if (Array.isArray(value)) return value.map(v => maskDeep(v, values)) as T
68 if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, maskDeep(v, values)])) as T
69 return value
70}
71
72const READERS = new Set(['cat', 'type', 'get-content', 'gc', 'more', 'less', 'head', 'tail', 'source', '.'])
73
74/** The secret file a shell command reads, or null. */
75export function secretReadTarget(command: string): string | null {
76 // ponytail: plain token split, not the guarda's classify; the output mask is the real net.
77 const tokens = command
78 .split(/[\s;|&()<>]+/)
79 .map(t => t.replace(/^["']|["']$/g, ''))
80 .filter(Boolean)
81 if (!tokens.some(t => READERS.has(t.toLowerCase()))) return null
82 return tokens.find(isSecretFile) ?? null
83}
84
85/** `.env` text with NAME set to value; double quotes when the value has a space, #, quote or backslash. */
86export function upsertEnv(env: string, name: string, value: string): string {
87 const line = `${name}=${/[\s#"'\\]/.test(value) ? `"${value.replace(/[\\"]/g, c => `\\${c}`)}"` : value}`
88 const re = new RegExp(`^\\s*(?:export\\s+)?${name}\\s*=.*$`, 'm')
89 if (re.test(env)) return env.replace(re, () => line)
90 return `${env}${env && !env.endsWith('\n') ? '\n' : ''}${line}\n`
91}
92
93const COVERS_ENV = new Set(['.env', '/.env', '.env*', '/.env*', '*.env'])
94
95export function ensureGitignore(text: string): string {
96 if (text.split(/\r?\n/).some(l => COVERS_ENV.has(l.trim()))) return text
97 return `${text}${text && !text.endsWith('\n') ? '\n' : ''}.env\n`
98}
99