Holds risky Bash commands (rm -rf, git reset --hard, force push, new dependencies) and asks first, explaining the impact in plain PT-BR

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 458 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Report } from '../types'
5
6// Visual pattern from anthropics/claude-code-playground mods/blast-radius @569c528 (Apache-2.0),
7// rewritten for Windows: only `git` is spawned, never bash (on PATH it can be WSL's), and rm is measured with $.fs.
8
9const PANE = 'guarda'
10const LIST_MAX = 8
11const NET_MS = 2500
12
13const held = atom({ plugin: 'guarda', key: 'held' } as const, null)
14
15type Dir = string | null
16export type Risk =
17 | { kind: 'rm'; label: string; targets: string[]; dir: Dir }
18 | { kind: 'git-reset' | 'git-clean' | 'git-push-force' | 'git-checkout'; label: string; args: string[]; dir: Dir }
19 | { kind: 'deps'; label: string; manager: 'npm' | 'pip'; packages: string[]; dir: Dir }
20 | { kind: 'secret-commit'; label: string; args: string[]; dir: Dir }
21
22const EXPLAIN: Record<Risk['kind'], string> = {
23 rm: 'Apaga arquivos ou pastas. Com o mod lixeira ligado, o rm simples manda para a Lixeira do Windows; /usr/bin/rm, command/env/exec rm e xargs rm apagam de vez, sem Ctrl+Z.',
24 'git-reset': 'Joga fora tudo que foi alterado e ainda não virou commit. O git não guarda cópia disso.',
25 'git-clean': 'Apaga arquivos que o git nunca registrou (os novos). Como não estão no histórico, somem de vez.',
26 'git-push-force': 'Reescreve o histórico no GitHub. Commits que só existem lá podem desaparecer.',
27 'git-checkout': 'Descarta as alterações ainda não commitadas de todos os arquivos.',
28 'secret-commit': 'Coloca um arquivo de segredo no histórico do git. Mesmo apagado depois, continua nos commits antigos.',
29 deps: 'Instala código de terceiros no projeto. Ele passa a rodar na sua máquina e dentro do app: vale ver se é conhecido e mantido.',
30}
31
32// ---- What counts as risky (pure, tested) ---------------------------------
33
34function tokenize(text: string): string[] {
35 const words: string[] = []
36 const re = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g
37 for (let m = re.exec(text); m; m = re.exec(text)) words.push(m[1] ?? m[2] ?? m[3] ?? '')
38 return words
39}
40
41const isAbs = (p: string) => /^([A-Za-z]:[\\/]|[\\/]|~)/.test(p)
42/** Git Bash spells `C:\Dev` as `/c/Dev`; the engine's fs wants `C:/Dev`. */
43const toWin = (p: string) => p.replace(/^\/([a-zA-Z])(?=\/|$)/, (_, d: string) => `${d.toUpperCase()}:`)
44const joinDir = (dir: Dir, arg: string | undefined): string => (!arg || arg === '~' ? '~' : isAbs(arg) || !dir ? arg : `${dir}/${arg}`)
45
46const PREFIXES = new Set(['sudo', 'command', 'exec', 'env', 'nohup', 'time', 'then', 'do', 'else', '!'])
47const NODE_ADD: Record<string, readonly string[]> = { npm: ['install', 'i', 'add'], pnpm: ['add', 'install', 'i'], yarn: ['add'], bun: ['add', 'install', 'i'] }
48const PIP_VALUE_FLAGS = new Set(['-r', '--requirement', '-e', '--editable', '-c', '--constraint', '-i', '--index-url', '--extra-index-url', '-t', '--target'])
49
50/** Each simple command of a shell line with the folder it runs in (after any cd). */
51function* commands(command: string): Generator<{ cmd: string; args: string[]; dir: Dir }> {
52 let dir: Dir = null
53 for (const seg of command.split(/&&|\|\||;|\||\n/)) {
54 const words = tokenize(seg.trim().replace(/^[({]+\s*/, '').replace(/\s*[)}]+$/, ''))
55 while (words.length && (PREFIXES.has(words[0]!) || /^[A-Za-z_]\w*=/.test(words[0]!))) words.shift()
56 const [first, ...args] = words
57 if (!first) continue
58 const cmd = first.replace(/^\\/, '').replace(/\.exe$/i, '')
59 if (cmd === 'cd') {
60 dir = joinDir(dir, args[0])
61 continue
62 }
63 yield { cmd, args, dir }
64 }
65}
66
67/** The first risky segment of a shell command, or null. A bare `npm install` / `pip install -r` adds nothing new and passes. */
68export function classify(command: string): Risk | null {
69 for (const { cmd, args, dir } of commands(command)) {
70 const risk = classifyCmd(cmd, args, dir)
71 if (risk) return risk
72 }
73 return null
74}
75
76function gitParts(args: string[], dir: Dir) {
77 let i = 0
78 let gitDir = dir
79 while (i < args.length && args[i]!.startsWith('-')) {
80 if (args[i] === '-C') gitDir = joinDir(gitDir, args[i + 1])
81 i += args[i] === '-C' || args[i] === '-c' ? 2 : 1
82 }
83 return { sub: args[i], rest: args.slice(i + 1), gitDir }
84}
85
86type Candidate = Extract<Risk, { kind: 'secret-commit' }>
87
88/** Every git add/commit of the line that may carry a secret file; only git itself can tell, so the hook asks it before holding. */
89export function commitCandidates(command: string): Candidate[] {
90 const found: Candidate[] = []
91 for (const { cmd, args, dir } of commands(command)) {
92 if (cmd !== 'git') continue
93 const { sub, rest, gitDir } = gitParts(args, dir)
94 if (sub === 'add' || sub === 'commit') found.push({ kind: 'secret-commit', label: `git ${sub}`, args: rest, dir: gitDir })
95 }
96 return found
97}
98
99export const commitCandidate = (command: string): Candidate | null => commitCandidates(command)[0] ?? null
100
101function classifyCmd(cmd: string, args: string[], dir: Dir): Risk | null {
102 if (cmd === 'rm' || cmd.endsWith('/rm')) {
103 const flags = args.filter(a => a.startsWith('-'))
104 const short = flags.filter(f => /^-[^-]/.test(f)).join('')
105 const isRecursive = flags.includes('--recursive') || /[rR]/.test(short)
106 const isForce = flags.includes('--force') || short.includes('f')
107 return isRecursive || isForce ? { kind: 'rm', label: `rm ${flags.join(' ')}`, targets: args.filter(a => !a.startsWith('-')), dir } : null
108 }
109 if (cmd === 'git') {
110 const { sub, rest, gitDir } = gitParts(args, dir)
111 const short = rest.filter(a => /^-[^-]/.test(a)).join('')
112 if (sub === 'reset' && rest.includes('--hard')) return { kind: 'git-reset', label: 'git reset --hard', args: rest, dir: gitDir }
113 // Without -f git refuses to clean (clean.requireForce), and -n is the dry run.
114 if (sub === 'clean' && (short.includes('f') || rest.includes('--force')) && !short.includes('n') && !rest.includes('--dry-run'))
115 return { kind: 'git-clean', label: 'git clean', args: rest, dir: gitDir }
116 if (sub === 'push' && rest.some(a => a === '--force' || a === '-f' || a.startsWith('--force-with-lease') || a.startsWith('+')))
117 return { kind: 'git-push-force', label: 'git push --force', args: rest, dir: gitDir }
118 const isStagedOnly = sub === 'restore' && rest.includes('--staged') && !rest.includes('--worktree') && !rest.includes('-W')
119 if ((sub === 'checkout' || sub === 'restore') && rest.includes('.') && !isStagedOnly)
120 return { kind: 'git-checkout', label: `git ${sub} .`, args: rest, dir: gitDir }
121 return null
122 }
123 const adds = NODE_ADD[cmd]
124 if (adds || (cmd === 'npx' && args[0] === 'expo')) {
125 const [sub, ...rest] = adds ? args : args.slice(1)
126 if (!sub || !(adds ?? ['install']).includes(sub)) return null
127 const packages = rest.filter(a => !a.startsWith('-'))
128 return packages.length ? { kind: 'deps', label: `${adds ? cmd : 'npx expo'} ${sub}`, manager: 'npm', packages, dir } : null
129 }
130 const pip =
131 cmd === 'pip' || cmd === 'pip3'
132 ? args
133 : /^(python3?|py)$/.test(cmd) && args[0] === '-m' && args[1] === 'pip'
134 ? args.slice(2)
135 : cmd === 'uv' && args[0] === 'pip'
136 ? args.slice(1)
137 : cmd === 'uv' && args[0] === 'add'
138 ? ['install', ...args.slice(1)]
139 : null
140 if (pip?.[0] !== 'install') return null
141 const packages: string[] = []
142 const rest = pip.slice(1)
143 for (let k = 0; k < rest.length; k++) {
144 const a = rest[k]!
145 if (PIP_VALUE_FLAGS.has(a)) k++
146 else if (!a.startsWith('-') && !/^[./\\~]/.test(a) && !/^[A-Za-z]:/.test(a)) packages.push(a)
147 }
148 return packages.length ? { kind: 'deps', label: 'pip install', manager: 'pip', packages, dir } : null
149}
150
151/** Registry name of a package spec, or null for a git/URL/file source. */
152export function packageName(manager: 'npm' | 'pip', spec: string): string | null {
153 if (manager === 'pip') return /^[A-Za-z0-9][\w.-]*/.exec(spec)?.[0] ?? null
154 const name = spec.replace(/(?<=.)@.*$/, '')
155 return /^(@[\w.-]+\/)?[\w.-]+$/.test(name) ? name : null
156}
157
158// ---- Measuring ------------------------------------------------------------
159
160function size(bytes: number): string {
161 if (bytes < 1024) return `${bytes} B`
162 const units = ['KB', 'MB', 'GB']
163 let n = bytes / 1024
164 let i = 0
165 while (n >= 1024 && i < units.length - 1) {
166 n /= 1024
167 i++
168 }
169 return `${n.toFixed(n < 10 ? 1 : 0)} ${units[i]}`
170}
171
172const short = (n: number) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(n))
173const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`
174
175type Measured = Pick<Report, 'summary' | 'lines' | 'note'>
176
177/** Never resolves past NET_MS: $.http.fetch has no timeout of its own. */
178function within<T>($: EngineInterface, p: Promise<T>): Promise<T | null> {
179 return Promise.race([p.catch(() => null), $.clock.sleep(NET_MS).then(() => null)])
180}
181
182async function resolveDir($: EngineInterface, base: string, dir: Dir): Promise<string | null> {
183 if (dir === null) return base
184 if (dir.startsWith('~')) return null
185 const path = isAbs(dir) ? toWin(dir) : `${base}/${dir}`
186 const st = await $.fs.stat(path).catch(() => undefined)
187 return st?.kind === 'dir' ? path : null
188}
189
190async function git($: EngineInterface, cwd: string, args: string[]) {
191 return $.process.run(['git', ...args], { cwd, timeoutMs: 15000 })
192}
193
194// ponytail: copy of cofre/hooks/secrets.ts isSecretFile; plugins load in isolation, a ../../cofre import leaves the plugin root.
195function isSecretFile(path: string): boolean {
196 const base = (path.replace(/["']/g, '').split(/[\\/]/).pop() ?? '').toLowerCase()
197 if (/^\.env(\..+)?$/.test(base)) return !/\.(example|sample|template)$/.test(base)
198 if (base.endsWith('.pub')) return false
199 return /\.(pem|key)$/.test(base) || /^credentials.*\.json$/.test(base) || /^id_(rsa|ed25519)/.test(base)
200}
201
202/** Secret files this add/commit would put in history: `git add -n` for add, the index (+ worktree with -a) for commit. */
203async function stagedSecrets($: EngineInterface, risk: Extract<Risk, { kind: 'secret-commit' }>, cwd: string): Promise<string[]> {
204 const out = async (args: string[]) => (await git($, cwd, args)).stdout.split('\n')
205 const files =
206 risk.label === 'git add'
207 ? (await out(['add', '-n', ...risk.args])).map(l => /^add '(.+)'$/.exec(l.trim())?.[1] ?? '')
208 : [
209 ...(await out(['diff', '--cached', '--name-only', '--diff-filter=d'])),
210 ...(risk.args.includes('--all') || risk.args.some(a => /^-[^-]*a/.test(a)) ? await out(['diff', '--name-only', '--diff-filter=d']) : []),
211 ]
212 return [...new Set(files.map(f => f.trim()).filter(f => f && isSecretFile(f)))]
213}
214
215async function measureRm($: EngineInterface, targets: string[], cwd: string): Promise<Measured> {
216 const lines: string[] = []
217 let found = 0
218 let bytes = 0
219 for (const t of targets.slice(0, LIST_MAX)) {
220 if (/[*?[]/.test(t) || t.startsWith('~') || t.includes('$')) {
221 lines.push(`${t} (padrão ou variável: não consegui medir)`)
222 continue
223 }
224 const path = isAbs(t) ? toWin(t) : `${cwd}/${t}`
225 const st = await $.fs.stat(path).catch(() => undefined)
226 if (!st) {
227 lines.push(`${t} (não existe)`)
228 continue
229 }
230 found++
231 if (st.kind === 'dir') lines.push(`${t}/ pasta com ${(await $.fs.list(path).catch(() => [])).length} itens dentro`)
232 else {
233 bytes += st.size
234 lines.push(`${t} ${size(st.size)}`)
235 }
236 }
237 if (targets.length > LIST_MAX) lines.push(`+ ${targets.length - LIST_MAX} outros caminhos`)
238 return {
239 summary: targets.length === 0 ? 'rm sem caminho' : found === 0 ? 'apagar nada: nenhum caminho medido existe' : `apagar ${plural(found, 'caminho', 'caminhos')}${bytes ? ` (~${size(bytes)} em arquivos)` : ''}`,
240 lines,
241 note: 'Pastas são apagadas com tudo dentro, inclusive subpastas.',
242 }
243}
244
245async function measureGit($: EngineInterface, risk: Extract<Risk, { args: string[] }>, cwd: string): Promise<Measured> {
246 if (risk.kind === 'git-clean') {
247 const flags = risk.args.filter(a => a.startsWith('-')).map(a => (/^-[^-]/.test(a) ? a.replace(/[fiq]/g, '') : a)).filter(a => a !== '-' && a !== '--force')
248 const paths = risk.args.filter(a => !a.startsWith('-'))
249 const run = await git($, cwd, ['clean', '-n', ...flags, '--', ...paths])
250 if (run.exitCode !== 0) return { summary: 'git clean (não consegui simular)', lines: [], note: run.stderr.trim().slice(0, 200) }
251 const gone = run.stdout.split('\n').filter(l => l.startsWith('Would remove ')).map(l => l.slice(13))
252 return {
253 summary: gone.length ? `apagar ${plural(gone.length, 'arquivo novo', 'arquivos novos')}` : 'apagar nada: não há arquivos novos',
254 lines: [...gone.slice(0, LIST_MAX), ...(gone.length > LIST_MAX ? [`+ ${gone.length - LIST_MAX} outros`] : [])],
255 note: 'Simulado com git clean -n. Esses arquivos não estão no git: não dá pra recuperar.',
256 }
257 }
258 if (risk.kind === 'git-push-force') {
259 const [remote = 'origin', spec = ''] = risk.args.filter(a => !a.startsWith('-'))
260 const head = (await git($, cwd, ['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim()
261 const [src, dst] = spec.replace(/^\+/, '').split(':')
262 const branch = (dst ?? src) && (dst ?? src) !== 'HEAD' ? (dst ?? src)!.replace(/^refs\/heads\//, '') : head
263 const ref = `${remote}/${branch}`
264 if ((await git($, cwd, ['rev-parse', '--verify', '--quiet', ref])).exitCode !== 0)
265 return { summary: `force push para ${ref}`, lines: [], note: `Não tenho cópia local de ${ref}, então não sei que commits sumiriam. Rode git fetch antes.` }
266 const dropped = (await git($, cwd, ['log', '--oneline', '--no-decorate', `${src && src !== 'HEAD' ? src : 'HEAD'}..${ref}`])).stdout.split('\n').filter(Boolean)
267 return {
268 summary: dropped.length ? `force push para ${ref}: some com ${plural(dropped.length, 'commit', 'commits')}` : `force push para ${ref}: nenhum commit some`,
269 lines: dropped.slice(0, LIST_MAX),
270 note: `Commits que existem em ${ref} e não na sua cópia, desde o último fetch.`,
271 }
272 }
273 const status = await git($, cwd, ['status', '--porcelain'])
274 if (status.exitCode !== 0) return { summary: `${risk.label} (aqui não é um repositório git?)`, lines: [], note: status.stderr.trim().slice(0, 200) }
275 const rows = status.stdout.split('\n').filter(l => l.length > 3 && !l.startsWith('??'))
276 // reset --hard drops staged and unstaged changes; checkout/restore . only the unstaged ones.
277 const lost = risk.kind === 'git-reset' ? rows : rows.filter(l => l[1] !== ' ')
278 const stat = (await git($, cwd, ['diff', '--shortstat', risk.kind === 'git-reset' ? 'HEAD' : '--'])).stdout.trim()
279 return {
280 summary: lost.length ? `descartar alterações em ${plural(lost.length, 'arquivo', 'arquivos')}` : 'descartar nada: não há alterações pendentes',
281 lines: [...lost.slice(0, LIST_MAX).map(l => `${l.slice(0, 2)} ${l.slice(3)}`), ...(lost.length > LIST_MAX ? [`+ ${lost.length - LIST_MAX} outros`] : [])],
282 note: stat ? `${stat}. Alterações não commitadas não voltam.` : 'De git status.',
283 }
284}
285
286async function lookup($: EngineInterface, manager: 'npm' | 'pip', spec: string): Promise<string> {
287 const name = packageName(manager, spec)
288 if (!name) return `${spec} fonte fora do registro (git, URL ou arquivo): confira de onde vem`
289 const url = manager === 'npm' ? `https://registry.npmjs.org/${name}/latest` : `https://pypi.org/pypi/${encodeURIComponent(name)}/json`
290 const res = await within($, $.http.fetch(url))
291 if (!res) return `${name} (registro não respondeu a tempo)`
292 if (res.status === 404) return `⚠ ${name} NÃO existe no registro: erro de digitação ou pacote falso?`
293 if (!res.ok) return `${name} (registro respondeu ${res.status})`
294 try {
295 if (manager === 'pip') {
296 const { info } = JSON.parse(res.text) as { info: { version?: string; license?: string; summary?: string } }
297 return [name, info.version, info.license?.slice(0, 20), info.summary].filter(Boolean).join(' · ')
298 }
299 const j = JSON.parse(res.text) as { version?: string; license?: string; description?: string }
300 const dl = await within($, $.http.fetch(`https://api.npmjs.org/downloads/point/last-week/${name}`))
301 const downloads = dl?.ok ? (JSON.parse(dl.text) as { downloads?: number }).downloads : undefined
302 return [name, j.version, j.license, downloads === undefined ? undefined : `${short(downloads)} downloads/semana`, j.description].filter(Boolean).join(' · ')
303 } catch {
304 return `${name} (resposta do registro ilegível)`
305 }
306}
307
308async function measure($: EngineInterface, risk: Risk, cwd: string): Promise<Measured> {
309 try {
310 if (risk.kind === 'secret-commit') {
311 const files = await stagedSecrets($, risk, cwd)
312 return {
313 summary: `colocar ${plural(files.length, 'arquivo de segredo', 'arquivos de segredo')} no git`,
314 lines: files.slice(0, LIST_MAX).map(f => `⚠ ${f}`),
315 note: 'Tire do commit com git restore --staged <arquivo> e confira se o .gitignore cobre o arquivo.',
316 }
317 }
318 if (risk.kind === 'rm') return await measureRm($, risk.targets, cwd)
319 if (risk.kind === 'deps') {
320 const lines = await Promise.all(risk.packages.slice(0, 3).map(p => lookup($, risk.manager, p)))
321 const isUnknown = lines.some(l => l.startsWith('⚠'))
322 return {
323 summary: `adicionar ${plural(risk.packages.length, 'pacote novo', 'pacotes novos')}${isUnknown ? ' (um deles não existe!)' : ''}`,
324 lines,
325 note: 'Poucos downloads, sem licença ou nome parecido com um famoso = desconfie. Prefira o que o projeto já usa.',
326 }
327 }
328 return await measureGit($, risk, cwd)
329 } catch (error) {
330 return { summary: `${risk.label} (não consegui medir)`, lines: [], note: String(error).slice(0, 200) }
331 }
332}
333
334// ---- Holding the call -----------------------------------------------------
335
336// One hold at a time, Bash calls in a turn run in order.
337let isHolding = false
338
339type Verdict = { isOk: boolean; why: string; summary: string }
340
341async function hold($: EngineInterface, command: string, risk: Risk, signal: AbortSignal): Promise<Verdict> {
342 const stop = (why: string, summary = risk.label): Verdict => ({ isOk: false, why, summary })
343 while (isHolding) {
344 if (signal.aborted) return stop('o turno foi interrompido')
345 await $.clock.sleep(250, { signal }).catch(() => undefined)
346 }
347 isHolding = true
348 let summary = risk.label
349 let isPlaced = false
350 try {
351 const cwd = await resolveDir($, toWin(await $.session.cwd()), risk.dir)
352 const measured: Measured =
353 cwd === null ? { summary: `${risk.label} em ${risk.dir}`, lines: [], note: `Não achei a pasta ${risk.dir}, então não consegui medir o impacto.` } : await measure($, risk, cwd)
354 const report: Report = { command, label: risk.label, explain: EXPLAIN[risk.kind], ...measured }
355 summary = report.summary
356 await update($, held, () => report)
357 // The pane only shows the details; it does not take focus, the dialog below does.
358 isPlaced = (await $.ui.open({ id: PANE, title: 'Guarda', rows: Math.min(24, 9 + report.lines.length) })).isPlaced
359 // The answer comes from the engine's dialog, not from polling: the hook budget
360 // (10 s) stops while a `$` call is in flight but runs through `$.clock.sleep`,
361 // so a polled wait was cut at 10 s and the command ran unanswered (fail open).
362 // Typed "Other" text or a dismissal is a no.
363 const answer = await $.ui.ask(`⚠ ${risk.label}: ${report.explain} Vai ${report.summary}. Executar mesmo assim?`, {
364 header: 'Guarda',
365 options: ['Cancelar', 'Executar'],
366 })
367 if (signal.aborted) return stop('o turno foi interrompido', summary)
368 return answer === 'Executar' ? { isOk: true, why: '', summary } : stop('o usuário escolheu Cancelar', summary)
369 } catch {
370 // Includes `-p` runs, where nobody can be asked: a risky command never runs unseen.
371 return stop('o guarda não conseguiu perguntar ao usuário', summary)
372 } finally {
373 if (isPlaced) await $.ui.close({ id: PANE }).catch(() => undefined)
374 await update($, held, () => null).catch(() => undefined)
375 isHolding = false
376 }
377}
378
379export const register: Register = on => {
380 // PowerShell runs the same git/npm/pip lines, so it goes through the same classifier.
381 on('tool.call', { tool: ['Bash', 'PowerShell'] }, async ($, e, next) => {
382 const command = String(e.command ?? '')
383 let risks: Risk[] = []
384 try {
385 risks = [...commitCandidates(command), classify(command)].filter((r): r is Risk => r !== null)
386 } catch {
387 risks = [] // a classifier bug must never block every shell call
388 }
389 // Every add/commit is a candidate; hold only those where git lists a secret file. A git failure lets it pass, as a classifier bug does.
390 const base = risks.some(r => r.kind === 'secret-commit') ? toWin(await $.session.cwd().catch(() => '')) : ''
391 const kept: Risk[] = []
392 for (const r of risks) {
393 if (r.kind !== 'secret-commit') {
394 kept.push(r)
395 continue
396 }
397 if (!base) continue // no cwd: cannot ask git, but classify risks stay held
398 const cwd = await resolveDir($, base, r.dir).catch(() => null)
399 const found = cwd === null ? [] : await stagedSecrets($, r, cwd).catch(() => [])
400 if (found.length) kept.push(r)
401 }
402 risks = kept
403 for (const risk of risks) {
404 const verdict = await hold($, command, risk, next.signal)
405 if (!verdict.isOk)
406 return { deny: `O guarda segurou este comando e não o executou: ${verdict.why}. Ele iria ${verdict.summary}. Não tente de novo a menos que o usuário peça.` }
407 }
408 if (risks.length) $.ui.toast('🛡 guarda: executando')
409 return next(e)
410 }).catch(($, e, next) =>
411 // fail closed: a throw or an overrun before the command ran must not let it through
412 next.called ? next(e) : { deny: 'O guarda falhou ao conferir este comando e não o executou. Não tente de novo a menos que o usuário peça.' },
413 )
414
415 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
416 if (e.requestId !== PANE) return next(e)
417 const r = await read($, held)
418 if (!r) return next(e)
419 const { Box, Text } = $.ui.resolve(e)
420 return (
421 <Box flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1}>
422 <Text bold color="yellow">
423 🛡 Guarda · {r.label}
424 </Text>
425 <Text wrap="truncate-end">
426 <Text dimColor>Comando </Text>
427 <Text bold>{r.command}</Text>
428 </Text>
429 <Text wrap="wrap">
430 <Text dimColor>O que é </Text>
431 {r.explain}
432 </Text>
433 <Text>
434 <Text dimColor>Vai </Text>
435 <Text bold color="red">
436 {r.summary}
437 </Text>
438 </Text>
439 <Box flexDirection="column" marginTop={1}>
440 {r.lines.map((l, i) => (
441 <Text key={`l${i}`} color={l.startsWith('⚠') ? 'red' : 'cyan'} wrap="truncate-end">
442 {` ${l}`}
443 </Text>
444 ))}
445 </Box>
446 {r.note ? (
447 <Text dimColor italic wrap="wrap">
448 {r.note}
449 </Text>
450 ) : null}
451 <Box marginTop={1}>
452 <Text dimColor>Responda na pergunta do Guarda: Executar ou Cancelar</Text>
453 </Box>
454 </Box>
455 )
456 })
457}
458types/index.d.ts 15 lines1export type Report = {
2 command: string
3 label: string
4 explain: string
5 summary: string
6 lines: string[]
7 note?: string
8}
9
10declare module 'claude-code' {
11 interface PluginState {
12 guarda: { held: Report | null }
13 }
14}
15