Mod: confirms only dangerous Bash commands (data loss, irreversible) and writes to sensitive files; optional strictness and noisy-output cap

Mod. Asks before commands that lose work or cannot be undone, and before writes to sensitive files. Ordinary work, including git merge and pushing to main, is never flagged.
Dangerous (default level)
rm -r aimed at /, home, ./.., a glob, or an absolute path outside /tmp (so rm -rf node_modules is fine)--force, -f; --force-with-lease is fine), remote deletesgit reset --hard, git clean -f, git branch -D, git restore ., git checkout -- .curl | sh, chmod -R 777, shred, mkfs, dd if=DROP/TRUNCATE TABLE/bare DELETE FROM, terraform destroy, kubectl delete namespace|node|pv|--all, docker system prune, npm publishSensitive writes: Write/Edit on .env* (not .env.example), SSH/AWS/GnuPG files, credentials, .npmrc, .git/config|hooks, /etc.
Options:
level: dangerous (default) or careful, which also asks about pushes to main/master, sudo, find -delete, xargs rm, chmod -R, truncate, terraform apply, any kubectl delete.guardWrites (true), extraRisky (a regex of your own), capNoisy (false; offers to cap cat, ls -R, test runs and logs at capLines, 200).If the question can't be asked (non-interactive), dangerous commands are blocked. The patterns are heuristics, not a sandbox.
hooks/register.ts 171 lines1import { atom, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Approval } from '../types'
5
6// Dangerous: loses work or data, or cannot be taken back. Always asks.
7const DANGEROUS = new RegExp(
8 [
9 String.raw`\bgit\s+push\b(?=[^;&|]*(?:--force(?!-with-lease)|--delete|\s-f\b|\s:\S))`,
10 String.raw`\bgit\s+reset\s+--hard`,
11 String.raw`\bgit\s+clean\s+-[a-z]*f`,
12 String.raw`\bgit\s+(?:restore\s+\.|checkout\s+--\s+\.)`,
13 String.raw`\b(?:curl|wget)\b[^|]*\|\s*(?:sudo\s+)?(?:ba|z)?sh\b`,
14 String.raw`\bchmod\s+-R\s+0?777\b`,
15 String.raw`\bshred\b`,
16 String.raw`\bdrop\s+(?:table|database)\b|\btruncate\s+table\b`,
17 String.raw`\bdelete\s+from\s+\w+\s*(?:;|$)`,
18 String.raw`\bterraform\s+destroy\b|\bkubectl\s+delete\s+(?:ns|namespace|node|pv|--all)\b|\bdocker\s+system\s+prune\b`,
19 String.raw`\bnpm\s+publish\b|\bmkfs\b|\bdd\s+if=`,
20 ].join('|'),
21 'i',
22)
23// Careful level adds things that are often fine but worth a look.
24const CAREFUL = new RegExp(
25 [
26 String.raw`\bgit\s+push\b(?:\s+\S+)?\s+(?:main|master)\b`,
27 String.raw`\bgit\s+stash\s+(?:drop|clear)\b`,
28 String.raw`\bfind\b.*(?:\s-delete\b|-exec\s+rm\b)`,
29 String.raw`\bxargs\s+(?:-\S+\s+)*rm\b`,
30 String.raw`\bsudo\b`,
31 String.raw`\bchmod\s+-R\b|\bchown\s+-R\b`,
32 String.raw`\btruncate\b`,
33 String.raw`\bterraform\s+apply\b|\bkubectl\s+delete\b`,
34 ].join('|'),
35 'i',
36)
37const BRANCH_D = /\bgit\s+branch\s+-D\b/
38
39// Even /lgtm yolo never waves these through: wiping the machine, a disk or a database.
40const CATASTROPHIC = new RegExp(
41 [
42 String.raw`\brm\s+(?:-\S+\s+)*(?:-\S*[rR]\S*\s+)(?:-\S+\s+)*(?:'|")?(?:/|/\*|~|~/|\$HOME/?|\$\{HOME\}/?)(?:'|")?(?:\s|$|;|&|\|)`,
43 String.raw`\bmkfs\b`,
44 String.raw`\bdd\s+[^;&|]*\bof=/dev/`,
45 String.raw`\bdrop\s+database\b`,
46 ].join('|'),
47 'i',
48)
49export const isCatastrophic = (cmd: string): boolean => CATASTROPHIC.test(cmd)
50
51const stripQuotes = (t: string) => t.replace(/^['"]|['"]$/g, '')
52
53const isDangerTarget = (raw: string): boolean => {
54 const t = stripQuotes(raw)
55 if (t === '/' || t === '/*' || t === '~' || t.startsWith('~/') || /^\$\{?HOME\}?/.test(t)) return true
56 if (t === '.' || t === '..' || t === '*' || t === './*' || t === '../*') return true
57 return t.startsWith('/') && !/^\/(?:tmp|var\/tmp)\//.test(t)
58}
59
60/** `rm -r…` is dangerous only when it targets /, home, the cwd or a parent, a glob, or an absolute path outside /tmp. */
61const dangerousRm = (cmd: string): boolean => {
62 for (const m of cmd.matchAll(/\brm\s+([^;&|\n]*)/g)) {
63 const tokens = m[1].split(/\s+/).filter(Boolean)
64 const flags = tokens.filter(t => t.startsWith('-'))
65 const recursive = flags.some(f => f === '--recursive' || /^-[a-zA-Z]*[rR]/.test(f))
66 if (recursive && tokens.filter(t => !t.startsWith('-')).some(isDangerTarget)) return true
67 }
68 return false
69}
70const NOISY_PARTS = [
71 /^\s*cat\s/,
72 /^\s*ls\s+-\w*R/,
73 /^\s*find\s+(\/|\.\s*$)/,
74 /^\s*git\s+log\s*$/,
75 /^\s*npm\s+(test|run\s+test)/,
76 /^\s*go\s+test\s+\.\.\./,
77 /^\s*pytest\s*$/,
78 /^\s*(docker|kubectl)\s+logs\b/,
79 /^\s*journalctl\b/,
80]
81const isNoisy = (cmd: string): boolean => NOISY_PARTS.some(re => re.test(cmd))
82const CAPPED = /\||\bhead\b|\btail\b|\s>\s|\s-n\s?\d|--oneline|-maxdepth|--tail|--since/
83const SENSITIVE = new RegExp(
84 [
85 String.raw`(?:^|\/)(?:\.env(?!\.(?:example|sample|template)$)(?:\.[\w.-]+)?|\.npmrc|\.pypirc|\.netrc|id_(?:rsa|ed25519|ecdsa)(?:\.pub)?|credentials(?:\.json)?)$`,
86 String.raw`(?:^|\/)(?:\.aws|\.ssh|\.gnupg)\/`,
87 String.raw`(?:^|\/)\.git\/(?:config|hooks\/)`,
88 String.raw`^\/etc\/`,
89 ].join('|'),
90 'i',
91)
92
93export type Level = 'dangerous' | 'careful'
94
95export const classify = (cmd: string, extra?: RegExp, level: Level = 'dangerous'): 'risky' | 'noisy' | 'ok' => {
96 const risky = DANGEROUS.test(cmd) || BRANCH_D.test(cmd) || dangerousRm(cmd) || (level === 'careful' && CAREFUL.test(cmd)) || extra?.test(cmd)
97 if (risky) return 'risky'
98 return isNoisy(cmd) && !CAPPED.test(cmd) ? 'noisy' : 'ok'
99}
100
101export const isSensitivePath = (p: string): boolean => SENSITIVE.test(p)
102
103const approvals = atom({ plugin: 'are-you-sure-bro', key: 'approvals' } as const, [])
104
105/**
106 * Is this pre-approved by /lgtm (looks-good-to-me)? Reads the gate it publishes, records the approval in this
107 * plugin's own state (the gate plugin counts and lists it), and answers false on any doubt so the caller asks.
108 */
109const gateApproves = async ($: any, kind: 'danger' | 'sensitive', detail: string): Promise<boolean> => {
110 try {
111 const g = (await $.state.get({ plugin: 'looks-good-to-me', key: 'gate' })).value
112 if (g?.phase !== 'active' || !g.allow?.[kind]) return false
113 const at = await $.clock.now()
114 await update($, approvals, (l: Approval[]) => [...l, { at, kind, detail: detail.replace(/\s+/g, ' ').slice(0, 200) }].slice(-200))
115 return true
116 } catch {
117 return false
118 }
119}
120
121const safeRegex = (src: unknown): RegExp | undefined => {
122 if (typeof src !== 'string' || !src.trim()) return undefined
123 try {
124 return new RegExp(src, 'i')
125 } catch {
126 return undefined
127 }
128}
129
130const ask = async ($: { ui: { ask: (q: string, o: string[]) => Promise<string> } }, q: string, opts: string[]) => {
131 try {
132 return await $.ui.ask(q, opts)
133 } catch {
134 return undefined
135 }
136}
137
138export const register: Register = (on, options) => {
139 const extra = safeRegex(options.extraRisky)
140 const level: Level = options.level === 'careful' ? 'careful' : 'dangerous'
141 const cap = typeof options.capLines === 'number' && options.capLines > 0 ? Math.floor(options.capLines) : 200
142
143 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
144 const cmd = e.command
145 const kind = classify(cmd, extra, level)
146
147 if (kind === 'risky') {
148 if (!isCatastrophic(cmd) && (await gateApproves($, 'danger', cmd))) return next(e)
149 const pick = await ask($, `Risky command: ${cmd.slice(0, 120)}. Run?`, ['Run', 'Block'])
150 if (pick !== 'Run') return { deny: 'are-you-sure-bro: user blocked this command' }
151 return next(e)
152 }
153
154 if (kind === 'noisy' && options.capNoisy === true) {
155 const pick = await ask($, `Noisy output likely: ${cmd.slice(0, 100)}`, [`Cap at ${cap} lines`, 'Run as is'])
156 if (pick?.startsWith('Cap')) return next({ ...e, command: `(${cmd}) 2>&1 | head -${cap}` })
157 }
158
159 return next(e)
160 })
161
162 for (const tool of ['Write', 'Edit'] as const) {
163 on('tool.call', { tool }, async ($, e, next) => {
164 if (options.guardWrites === false || !isSensitivePath(e.file_path)) return next(e)
165 if (await gateApproves($, 'sensitive', e.file_path)) return next(e)
166 const pick = await ask($, `${tool} on a sensitive file: ${e.file_path}. Go ahead?`, ['Go ahead', 'Block'])
167 return pick === 'Go ahead' ? next(e) : { deny: 'are-you-sure-bro: user blocked this write' }
168 })
169 }
170}
171types/index.d.ts 17 lines1export type Approval = { at: number; kind: string; detail: string }
2export type Matrix = {
3 pick: boolean
4 plan: { medium: boolean; high: boolean; ship: boolean; delete: boolean }
5 danger: boolean
6 sensitive: boolean
7}
8export type GateState = { mode: string | null; phase: 'off' | 'armed' | 'active'; since: number; allow: Matrix | null }
9
10declare module 'claude-code' {
11 interface PluginState {
12 'are-you-sure-bro': { approvals: Approval[] }
13 // Published by looks-good-to-me; only read here.
14 'looks-good-to-me': { gate: GateState }
15 }
16}
17