SLOPSHOPPER

guarda

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

newpaneguardtoastprocessnetwork
v0.1.0MITupdated 2026-10-19Covre81/claude-code-mods/guarda
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · guarda
› 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(rm -rf build && git push --force origin main) ⎿ Denied by guarda: O guarda segurou este comando e não o executou: o usuário escolheu Cancelar. Ele iria ap ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? 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 458 lines
1import { 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}
458
types/index.d.ts 15 lines
1export 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