Holds risky commands (prod deploys, force pushes, destructive git/db ops) for confirmation with a context report.

Stops risky Bash commands Claude is about to run and asks you first. The command waits in a Prod Guard pane (or above the prompt, where no pane fits) with a short report, until you press Proceed (1) or Cancel (2).
| Kind | Commands |
|---|---|
| Vercel | vercel … --prod, vercel promote / rollback, vercel env add/rm/update, vercel rm, vercel domains/dns/project rm |
| Git | force push, push to main/master (also a plain git push while on main), git reset --hard, git clean -f |
| Packages | npm / pnpm / yarn / bun publish |
| Databases | prisma migrate reset/deploy, prisma db push, drizzle-kit push/migrate |
| Files and infra | rm -rf, terraform apply/destroy, kubectl delete/apply |
Folder, branch, last commit and uncommitted changes; for a push, the commits that would go out; for reset --hard, the files it would discard; for git clean, the paths it would delete; for Vercel, the linked project from .vercel/project.json.
claude -p) refuses risky commands, since nobody can confirm them.Cancel tells Claude you did not approve, and to ask you before trying again.
/plugin install prod-guard --marketplace marcinlukasik/mods
claude plugin validate ./prod-guard
claude plugin test ./prod-guardhooks/register.tsx 179 lines1// Prod Guard: holds risky Bash commands until the person presses Proceed or Cancel.
2import { atom, read, update } from 'claude-code'
3import type { Register } from 'claude-code'
4
5import type { Held } from '../types'
6
7const PANE = 'prod-guard'
8const held = atom({ plugin: 'prod-guard', key: 'held' } as const, null)
9
10type Rule = { risk: string; test: RegExp }
11
12const RULES: Rule[] = [
13 { risk: 'Vercel production deploy', test: /\bvercel\b[^;&|]*\s--prod(uction)?\b/ },
14 { risk: 'Vercel promote / rollback', test: /\bvercel\s+(promote|rollback)\b/ },
15 { risk: 'Vercel env change', test: /\bvercel\s+env\s+(rm|remove|add|update)\b/ },
16 { risk: 'Vercel remove', test: /\bvercel\s+((rm|remove)\b|(domains|dns|project|projects)\s+(rm|remove)\b)/ },
17 { risk: 'Force push', test: /\bgit\s+push\b[^;&|]*\s(-f\b|--force)/ },
18 { risk: 'Push to main', test: /\bgit\s+push\b[^;&|]*\s(origin\s+)?(HEAD:)?(main|master)\b/ },
19 { risk: 'git reset --hard', test: /\bgit\s+reset\b[^;&|]*--hard\b/ },
20 { risk: 'git clean', test: /\bgit\s+clean\s+-[a-zA-Z]*f/ },
21 { risk: 'Package publish', test: /\b(npm|pnpm|yarn|bun)\s+publish\b/ },
22 { risk: 'Database migration', test: /\b(prisma\s+(migrate\s+(reset|deploy)|db\s+push)|drizzle-kit\s+(push|migrate))\b/ },
23 { risk: 'rm -rf', test: /\brm\s+(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r|-r\s+-f|-f\s+-r)\b/ },
24 { risk: 'Infra change', test: /\b(terraform\s+(apply|destroy)|kubectl\s+(delete|apply))\b/ },
25]
26
27const PLAIN_PUSH = /\bgit\s+push\b/
28
29// The waiting hook polls this, not $.state: every $.state.get of one dispatch reads one moment.
30let decided: Held['decision'] = null
31
32export const register: Register = on => {
33 on('session.start', async ($, e, next) => {
34 const result = await next(e)
35 await update($, held, () => null)
36 return result
37 })
38
39 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
40 const command = String(e.command ?? '')
41 let risk = classify(command)
42
43 // A plain `git push` is only risky when the current branch is main/master.
44 if (risk === null && PLAIN_PUSH.test(command)) {
45 const branch = await git($, ['rev-parse', '--abbrev-ref', 'HEAD'])
46 if (branch === 'main' || branch === 'master') risk = `Push to ${branch}`
47 }
48 if (risk === null) return next(e)
49
50 // The desktop app runs the session through the SDK (isInteractive false), so ask
51 // whether anything draws at all: empty only in a plain -p run, where no one can press.
52 if ((await $.session.surfaces()).length === 0) {
53 return { deny: `Prod Guard: "${risk}" needs a person to confirm, and this session has no screen to ask on.` }
54 }
55
56 const details = await measure($, command, risk)
57 decided = null
58 await update($, held, () => ({ command, risk, details, decision: null, where: 'pane' }))
59
60 const opened = await $.ui.open({ id: PANE, title: `Prod Guard: ${risk}`, focus: true })
61 if (!opened.isPlaced) {
62 await update($, held, cur => (cur ? { ...cur, where: 'band' } : cur))
63 }
64
65 while (decided === null && !next.signal.aborted) {
66 // Time spent inside a $ call does not count against the hook's budget.
67 await $.process.run(['sleep', '0.25'])
68 }
69 const decision = decided
70 decided = null
71
72 await update($, held, () => null)
73 await $.ui.close({ id: PANE })
74
75 if (decision === 'proceed') return next(e)
76 return { deny: `Prod Guard held this command (${risk}) and the user did not approve it. Ask the user before trying again.` }
77 }).catch(async ($, e) => {
78 // Fail closed: if the guard itself breaks on a risky command, refuse rather than run it.
79 await update($, held, () => null)
80 return { deny: 'Prod Guard failed while checking this command, so it was not run. Ask the user to run it themselves.' }
81 })
82
83 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
84 const pending = await read($, held)
85 if (pending === null) return next(e)
86 return report($, e, pending, 'magenta')
87 })
88
89 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
90 const pending = await read($, held)
91 if (pending === null || pending.where !== 'band' || e.props.hasSurvey) return next(e)
92 return report($, e, pending, 'yellow')
93 })
94}
95
96function classify(command: string): string | null {
97 return RULES.find(rule => rule.test.test(command))?.risk ?? null
98}
99
100async function git($: any, args: string[]): Promise<string | null> {
101 try {
102 const { exitCode, stdout } = await $.process.run(['git', ...args], { timeoutMs: 5000 })
103 return exitCode === 0 ? stdout.trim() : null
104 } catch {
105 return null
106 }
107}
108
109async function measure($: any, command: string, risk: string): Promise<string[]> {
110 const lines: string[] = []
111 const cwd = await $.session.cwd()
112 lines.push(`dir ${cwd}`)
113
114 const branch = await git($, ['rev-parse', '--abbrev-ref', 'HEAD'])
115 if (branch !== null) {
116 lines.push(`branch ${branch}`)
117 const last = await git($, ['log', '-1', '--format=%h %s (%cr)'])
118 if (last) lines.push(`HEAD ${last}`)
119 const dirty = (await git($, ['status', '--porcelain'])) ?? ''
120 const changed = dirty.split('\n').filter(Boolean)
121 lines.push(`changes ${changed.length === 0 ? 'clean' : `${changed.length} uncommitted`}`)
122
123 if (/push/i.test(risk)) {
124 const ahead = await git($, ['log', '@{u}..HEAD', '--oneline'])
125 if (ahead) {
126 const commits = ahead.split('\n')
127 lines.push(`pushing ${commits.length} commit(s):`)
128 commits.slice(0, 5).forEach(c => lines.push(` ${c}`))
129 }
130 }
131 if (risk === 'git reset --hard' && changed.length > 0) {
132 lines.push('would discard:')
133 changed.slice(0, 8).forEach(c => lines.push(` ${c}`))
134 }
135 if (risk === 'git clean') {
136 const dry = await git($, ['clean', '-n', '-d'])
137 if (dry) {
138 const files = dry.split('\n')
139 lines.push(`would delete ${files.length} path(s):`)
140 files.slice(0, 8).forEach(f => lines.push(` ${f.replace(/^Would remove /, '')}`))
141 }
142 }
143 }
144
145 if (/vercel/i.test(risk)) {
146 try {
147 const project = JSON.parse(await $.fs.read('.vercel/project.json'))
148 lines.push(`vercel project ${project.projectName ?? project.projectId}`)
149 } catch {
150 lines.push('vercel no .vercel/project.json here')
151 }
152 }
153 return lines
154}
155
156function report($: any, e: any, pending: Held, color: string) {
157 const { Box, Text, Button } = $.ui.resolve(e)
158 const decide = (decision: 'proceed' | 'cancel') => () => {
159 decided = decision
160 return update($, held, cur => (cur ? { ...cur, decision } : cur))
161 }
162
163 return (
164 <Box flexDirection="column" borderStyle="round" borderColor={color} paddingX={1}>
165 <Text color={color} bold>
166 ⚠ Prod Guard: {pending.risk}
167 </Text>
168 <Text>$ {pending.command}</Text>
169 {pending.details.map(line => (
170 <Text dimColor>{line}</Text>
171 ))}
172 <Box flexDirection="row" gap={2} marginTop={1}>
173 <Button key="proceed" label="Proceed" hotkey="1" onPress={decide('proceed')} />
174 <Button key="cancel" label="Cancel" hotkey="2" onPress={decide('cancel')} />
175 </Box>
176 </Box>
177 )
178}
179types/index.d.ts 14 lines1export type Held = {
2 command: string
3 risk: string
4 details: string[]
5 decision: 'proceed' | 'cancel' | null
6 where: 'pane' | 'band'
7}
8
9declare module 'claude-code' {
10 interface PluginState {
11 'prod-guard': { held: Held | null }
12 }
13}
14