SLOPSHOPPER

prod-guard

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

panebandguardprocess
v0.2.0MITupdated 2026-10-17marcinlukasik/mods/prod-guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prod-guard
│ ┃ Prod Guard: Force push ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ ⚠ Prod Guard: Force push │ ⏺ Read(src/auth.ts) │ ┃ │ $ rm -rf build && git push --force │ ⎿ Read 6 lines │ ┃ │ origin main │ ⏺ Update(src/auth.ts) │ ┃ │ dir /work/app │ ⎿ Added 2 lines, removed 1 line │ ┃ │ branch feat/auth-refresh │ ⏺ Bash(rm -rf build && git push --force origin main) │ ┃ │ HEAD a1b2c3d fix: refresh expired │ ⎿ Denied by prod-guard: Prod Guard failed while checking th │ ┃ │ tokens (2 minutes ago) 9f8e7d6 feat: add │ │ ┃ │ audit log (3 hours ago) │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ changes 2 uncommitted │ │ ┃ │ pushing 2 commit(s): │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ a1b2c3d fix: refresh expired │ │ ┃ │ tokens (2 minutes ago) │ │ ┃ │ 9f8e7d6 feat: add audit log (3 │ │ ┃ │ hours ago) │ │ ┃ │ │ │ ┃ │ [ Proceed ] [ Cancel ] │ │ ┃ ╰──────────────────────────────────────────╯ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Prod Guard: Force push · while holding a tool call
╭──────────────────────────────────────────────────────────╮ │ ⚠ Prod Guard: Force push │ │ $ rm -rf build && git push --force origin main │ │ dir /work/app │ │ branch feat/auth-refresh │ │ HEAD a1b2c3d fix: refresh expired tokens (2 minutes │ │ ago) 9f8e7d6 feat: add audit log (3 hours ago) │ │ changes 2 uncommitted │ │ pushing 2 commit(s): │ │ a1b2c3d fix: refresh expired tokens (2 minutes │ │ ago) │ │ 9f8e7d6 feat: add audit log (3 hours ago) │ │ │ │ [ Proceed ] [ Cancel ] │ ╰──────────────────────────────────────────────────────────╯
README

prod-guard

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).

What it holds

KindCommands
Vercelvercel … --prod, vercel promote / rollback, vercel env add/rm/update, vercel rm, vercel domains/dns/project rm
Gitforce push, push to main/master (also a plain git push while on main), git reset --hard, git clean -f
Packagesnpm / pnpm / yarn / bun publish
Databasesprisma migrate reset/deploy, prisma db push, drizzle-kit push/migrate
Files and infrarm -rf, terraform apply/destroy, kubectl delete/apply

The report

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.

Fails closed

  • A session with no screen (claude -p) refuses risky commands, since nobody can confirm them.
  • If the guard itself fails on a risky command, the command is refused, not run.

Cancel tells Claude you did not approve, and to ask you before trying again.

Install

/plugin install prod-guard --marketplace marcinlukasik/mods

Develop

claude plugin validate ./prod-guard
claude plugin test ./prod-guard
Source 2 files
hooks/register.tsx 179 lines
1// 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}
179
types/index.d.ts 14 lines
1export 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