SLOPSHOPPER

cofre

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

newpaneguardcommandprompt
v0.1.0MITupdated 2026-10-19Covre81/claude-code-mods/cofre
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cofre
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(cat .env) ⎿ Denied by cofre: Arquivo de segredo (.env): conteúdo oculto pelo cofre. Variáveis definidas: DATABASE_URL, ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /chave ⎿ cofre: Uso: /chave NOME (maiúsculas, números e _), ex.: /chave OPENAI_API_KEY ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-code-mods

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.

Mods

Security

ModDescription
guardaIntercepts risky Bash commands (rm -rf, git reset --hard, force push, new dependencies) and asks for confirmation explaining the impact.
lixeiraSends rm / Remove-Item targets to the Windows Recycle Bin instead of deleting them permanently.
cofreKeeps 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-guardBlocks commits that mix top-level directories, violate Conventional Commits, or lack a Co-Authored-By trailer.
universal-audit-logA hook on * that logs every event (tool calls, prompts, turns, fs/http/process calls from other plugins) with secret redaction.

Session & Context

ModDescription
context-monitorContext weather and token usage above the prompt, replay and activity panels, plain-language explanation before each edit, and ai-memory notes.
cache-frioConfirms before submitting a prompt to a large conversation whose prompt cache has gone cold.
session-telemetryTimestamp 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-inspectorSide pane displaying the session turn-by-turn: every model request and what it carried.
clean-viewCollapses each successful tool call into a single concise line (Edit file +12 −3); errors and in-flight calls retain the full block.
webfetch-cacheServes repeated WebFetch requests (identical URL and prompt) directly from the session cache.
reflexoDetects corrections and constraints in prompts, offering to save them as rules in CLAUDE.md.

Agents & Workflow

ModDescription
agent-flowLive diagram of agent flow: main loop context, subagent tree, and context passed/returned by each agent.
agent-radarOne live line per running subagent above the prompt; /radar opens a pane with messages from each.
delegation-dockStatus line indicator showing in-flight delegations (agy/codex/ollama) and alerting when one returns empty.
phase-trackerTracks and displays the current development phase (Spec → Red Test → Surgical Code → Verification).
next-promptsBand with 3 next-step suggestions generated by a free Ollama cloud model; click one to draft.
git-sidebarLazygit-style side pane: worktrees, local branches, branch switching, and worktree checkout.
wait-quizQuizzes a flashcard in a band above the prompt while Claude is working.
meus-modsCentral hub: quick-access bar above the prompt and /mods command displaying the status of each mod.

Installation

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.

Development

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).

Credits

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.

License

MIT

Source 2 files
hooks/register.tsx 137 lines
1import 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}
137
hooks/secrets.ts 99 lines
1// 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