Asks the user before destructive Bash commands run: rm -rf, git reset --hard, git clean -f, discarding all changes, SQL DROP/TRUNCATE, find -delete, mkfs, dd…

Makes destructive Bash commands ask for your confirmation instead of running silently.
Bash call through the permission system (tool.check) and turns it into an "ask" prompt when it matches. A command already denied stays denied.&&, ||, ;, |, &, newlines):rm with both recursive and force flags, unless every target is under /tmp/ or /private/tmp/ (no target also asks, e.g. xargs rm -rf);git reset --hard, git clean -f (not with -n/--dry-run), git checkout ., git restore . (not when only --staged);find ... -delete, and find -exec/-execdir running a destructive command;mkfs*, dd of=/dev/...;DROP TABLE|DATABASE and TRUNCATE when a SQL client (psql, sqlite3, mysql, mariadb, duckdb, sqlcmd) appears in the command.sudo, env, rtk, nohup, time, nice, xargs, sh/bash/zsh, eval, command.| Command | Effect |
|---|---|
/rm-guard status | Show whether the guard is on. |
/rm-guard off | Stop asking for this session; status line shows rm-guard: OFF. |
/rm-guard on | Turn it back on. |
claude --plugin-dir /path/to/ModsTools/mods/rm-guard
$(...), aliases, scripts or bash -c with complex quoting. A seatbelt, not a security boundary.auto/bypassPermissions mode the mode settles the question.rm -r without -f is allowed (it still prompts on write-protected files).claude plugin validate mods/rm-guard
claude plugin test mods/rm-guard # 86 testshooks/register.ts 42 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { check } from './rules'
5
6const isOff = atom({ plugin: 'rm-guard', key: 'isOff' } as const, false)
7
8export const register: Register = on => {
9 on('session.start', async ($, e, next) => {
10 await $.command.register({
11 name: 'rm-guard',
12 description: 'rm-guard on|off|status: ask before destructive commands (rm -rf, git reset --hard, DROP TABLE…)',
13 })
14
15 return next(e)
16 })
17
18 on('command.run', { command: 'rm-guard' }, async ($, e) => {
19 const arg = e.args.trim()
20 if (arg === 'off' || arg === 'on') {
21 await update($, isOff, () => arg === 'off')
22 $.ui.status(arg === 'off' ? 'rm-guard: OFF' : undefined)
23 }
24 const off = await read($, isOff)
25
26 return { text: `rm-guard is ${off ? 'OFF for this session' : 'ON'}.` }
27 })
28
29 on('tool.check', { tool: 'Bash' }, async ($, e, next) => {
30 const verdict = await next(e)
31 const command = (e.input as { command?: unknown }).command
32 if (verdict.decision === 'deny' || typeof command !== 'string' || (await read($, isOff))) return verdict
33 const reason = check(command)
34 if (reason === null) return verdict
35
36 return {
37 decision: 'ask',
38 reason: `rm-guard: destructive command (${reason}). Confirm only if you meant it. If refused, ask the user: they can run it with "! <command>" or stop the checks with /rm-guard off.`,
39 }
40 })
41}
42hooks/rules.ts 132 lines1// Wrappers that run the command after them; the value is their options that take an argument.
2const WRAPPERS: Record<string, Set<string>> = {
3 rtk: new Set(),
4 sudo: new Set(['-u', '-g', '-C', '-D', '-h', '-p', '-U']),
5 env: new Set(['-u', '-C', '-S']),
6 command: new Set(),
7 nohup: new Set(),
8 time: new Set(),
9 nice: new Set(['-n']),
10 xargs: new Set(['-I', '-n', '-P', '-L', '-d', '-E', '-s', '-a']),
11 sh: new Set(),
12 bash: new Set(),
13 zsh: new Set(),
14 eval: new Set(),
15}
16
17const SQL_CLIENT = /\b(psql|sqlite3|mysql|mariadb|duckdb|sqlcmd)\b/
18
19const unquote = (word: string) => word.replace(/^['"]+|['"]+$/g, '')
20
21const isAssignment = (word: string) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(word)
22
23// The command a segment runs, wrappers, their options and env assignments dropped.
24function strip(words: string[]): string[] {
25 let i = 0
26 for (;;) {
27 while (words[i] !== undefined && isAssignment(words[i]!)) i++
28 const takesArg = WRAPPERS[words[i] ?? '']
29 if (takesArg === undefined) break
30 i++
31 while (words[i]?.startsWith('-')) i += takesArg.has(words[i]!) ? 2 : 1
32 }
33
34 return words.slice(i)
35}
36
37// Each simple command of a shell line as unquoted words, wrappers dropped.
38export function segments(command: string): string[][] {
39 return command
40 .split(/&&|\|\||[;|&\n]/)
41 .map(part => strip(part.trim().split(/\s+/).filter(Boolean).map(unquote)))
42 .filter(words => words.length > 0)
43}
44
45const baseName = (word: string) => word.slice(word.lastIndexOf('/') + 1)
46
47const isScratch = (target: string) =>
48 (target.startsWith('/tmp/') || target.startsWith('/private/tmp/')) && !target.split('/').includes('..')
49
50// `rm` with both recursive and force, unless every target is under /tmp/ or /private/tmp/.
51// `rm -r` without force is allowed: it still prompts on write-protected files.
52// No target asks too: the targets may come from stdin (`xargs rm -rf`).
53function checkRm(args: string[]): string | null {
54 let isRecursive = false
55 let isForce = false
56 const targets: string[] = []
57 let isOptions = true
58 for (const arg of args) {
59 if (isOptions && arg === '--') isOptions = false
60 else if (isOptions && arg.startsWith('--')) {
61 if (arg === '--recursive') isRecursive = true
62 if (arg === '--force') isForce = true
63 } else if (isOptions && /^-[A-Za-z]+$/.test(arg)) {
64 if (/[rR]/.test(arg)) isRecursive = true
65 if (arg.includes('f')) isForce = true
66 } else targets.push(arg)
67 }
68 if (!isRecursive || !isForce) return null
69
70 return targets.length > 0 && targets.every(isScratch) ? null : `rm -rf ${targets.join(' ')}`.trim()
71}
72
73// The words after `git` and its global options, or null when it is not git.
74function gitArgs(words: string[]): string[] | null {
75 if (words[0] !== 'git') return null
76 let i = 1
77 while (words[i]?.startsWith('-')) i += words[i] === '-C' || words[i] === '-c' ? 2 : 1
78
79 return words.slice(i)
80}
81
82const shortHas = (args: string[], letter: string) => args.some(a => /^-[A-Za-z]+$/.test(a) && a.includes(letter))
83
84function checkGit(args: string[]): string | null {
85 const [sub, ...rest] = args
86 if (sub === 'reset' && rest.includes('--hard')) return 'git reset --hard'
87 if (sub === 'clean') {
88 const isForce = shortHas(rest, 'f') || rest.includes('--force')
89 const isDryRun = shortHas(rest, 'n') || rest.includes('--dry-run')
90
91 return isForce && !isDryRun ? 'git clean -f' : null
92 }
93 if (sub === 'checkout' && rest.includes('.')) return 'git checkout . (discards every change)'
94 if (sub === 'restore' && rest.includes('.')) {
95 const isStagedOnly = (rest.includes('--staged') || shortHas(rest, 'S')) && !(rest.includes('--worktree') || shortHas(rest, 'W'))
96
97 return isStagedOnly ? null : 'git restore . (discards every change)'
98 }
99
100 return null
101}
102
103function checkWords(words: string[]): string | null {
104 const name = baseName(words[0] ?? '')
105 const args = words.slice(1)
106 if (name === 'rm') return checkRm(args)
107 if (/^mkfs(\.|$)/.test(name)) return 'mkfs'
108 if (name === 'dd' && args.some(a => a.startsWith('of=/dev/'))) return 'dd onto a device'
109 if (name === 'find') {
110 if (args.includes('-delete')) return 'find -delete'
111 const exec = args.findIndex(a => a === '-exec' || a === '-execdir')
112 if (exec >= 0) return checkWords(strip(args.slice(exec + 1)))
113 }
114 const git = gitArgs(words)
115
116 return git === null ? null : checkGit(git)
117}
118
119/** Why the command is destructive and needs the user's confirmation, or null when it is not. */
120export function check(command: string): string | null {
121 if (SQL_CLIENT.test(command)) {
122 if (/\bdrop\s+(table|database)\b/i.test(command)) return 'SQL DROP'
123 if (/\btruncate\s+(table\s+)?[A-Za-z_"`]/i.test(command)) return 'SQL TRUNCATE'
124 }
125 for (const words of segments(command)) {
126 const reason = checkWords(words)
127 if (reason !== null) return reason
128 }
129
130 return null
131}
132types/index.d.ts 8 lines1export type RmGuardSwitch = boolean
2
3declare module 'claude-code' {
4 interface PluginState {
5 'rm-guard': { isOff: RmGuardSwitch }
6 }
7}
8