SLOPSHOPPER

main-guard

A red band when you are on main or in prod, and no force-push, reset --hard or unconfirmed push to a protected branch

newbandguardprocesstimer
v0.1.0MITupdated 2026-10-04ShriD5/claude-mods/main-guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · main-guard
› 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 main-guard: main-guard: Force-pushing main rewrites history other people build on, so it is bloc ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

main-guard

Puts a red band above the prompt whenever you are somewhere you can hurt production, and stops Claude from doing the irreversible git things there.

 ⚠ on main · NODE_ENV=production   force-push and reset --hard are blocked

The band comes up when any of these is true:

  • the current git branch is protected (main, master, production, release* by default)
  • NODE_ENV, RAILS_ENV, APP_ENV or ENVIRONMENT is prod / production
  • a .prod marker file sits in the working directory or the repo root

On every Bash call Claude makes, main-guard reads the git commands out of it (through &&, pipes, git -C dir, env prefixes and bash -c "...") and:

commandon a protected branch
git push --force / -f / --force-with-lease / +refspecdenied
git push --delete / :branch, git push --mirrordenied
git reset --harddenied
plain git push (to main, HEAD:main, or while on main)asks you first: "Push straight to main?"

A denied call never reaches the shell; Claude gets the reason and a better route (push a branch, open a PR, git switch -c rescue before resetting). If there is nobody to ask (a -p run), the plain push is denied with the same advice.

Install

claude --plugin-dir ./main-guard

or add this repo as a marketplace and install it with /plugin.

Options

optiondefaultwhat it does
protectedBranchesmain, master, production, release*Comma-separated; * matches anything, so release* covers release/2.1.
confirmPushestrueOff: plain pushes to a protected branch are refused outright instead of asking.

How it works

A tool.call hook on Bash parses the command and answers { deny } or asks with $.ui.ask before calling next; the band is an AbovePrompt render of state refreshed on session.start, after every Bash call and every 15 s on $.clock.every.

This is a seatbelt for an agent, not a security boundary: a command that builds a git call at run time (eval, a script file) is not read.

Source 3 files
hooks/register.tsx 120 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { GuardState } from '../types'
5import { bandText, branchList, decide, DEFAULT_BRANCHES, gitCalls, isProtected } from './guard'
6
7const guard = atom({ plugin: 'main-guard', key: 'guard' } as const, null)
8
9const PROD = /^prod(uction)?$/i
10const REFRESH_MS = 15_000
11
12async function git($: EngineInterface, args: string[], dir?: string): Promise<string | null> {
13  const argv = dir === undefined ? ['git', ...args] : ['git', '-C', dir, ...args]
14  const ran = await $.process.run(argv, { timeoutMs: 5_000 }).catch(() => undefined)
15
16  return ran?.exitCode === 0 ? ran.stdout.trim() : null
17}
18
19const branchOf = ($: EngineInterface, dir?: string) => git($, ['symbolic-ref', '--short', '-q', 'HEAD'], dir)
20
21/** The first production-looking env var, as NAME=value. */
22async function prodEnv($: EngineInterface): Promise<string | undefined> {
23  const vars: [string, string | undefined][] = [
24    ['NODE_ENV', await $.env.get('NODE_ENV')],
25    ['RAILS_ENV', await $.env.get('RAILS_ENV')],
26    ['APP_ENV', await $.env.get('APP_ENV')],
27    ['ENVIRONMENT', await $.env.get('ENVIRONMENT')],
28  ]
29  const hit = vars.find(([, value]) => value !== undefined && PROD.test(value))
30
31  return hit === undefined ? undefined : `${hit[0]}=${hit[1]}`
32}
33
34/** Works out why the band should be up and stores it; a no-op when nothing changed. */
35async function refresh($: EngineInterface, patterns: readonly string[]) {
36  const cwd = await $.session.cwd()
37  const branch = await branchOf($)
38  const root = (await git($, ['rev-parse', '--show-toplevel'])) ?? cwd
39  const env = await prodEnv($)
40  const hasMarker = (await $.fs.exists(`${cwd}/.prod`)) || (root !== cwd && (await $.fs.exists(`${root}/.prod`)))
41  const reasons = [
42    ...(isProtected(branch, patterns) ? [`on ${branch}`] : []),
43    ...(env === undefined ? [] : [env]),
44    ...(hasMarker ? ['.prod'] : []),
45  ]
46  const fresh: GuardState = { branch, reasons }
47  const now = await read($, guard)
48
49  if (JSON.stringify(now) !== JSON.stringify(fresh)) {
50    await update($, guard, () => fresh)
51  }
52}
53
54export const register: Register = (on, options) => {
55  const patterns = branchList(String(options.protectedBranches ?? DEFAULT_BRANCHES))
56  const confirmPushes = options.confirmPushes !== false
57
58  on('session.start', async ($, e, next) => {
59    await refresh($, patterns)
60    $.clock.every(REFRESH_MS, () => void refresh($, patterns).catch(() => undefined))
61
62    return next(e)
63  })
64
65  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
66    for (const call of gitCalls(e.command)) {
67      const decision = decide(call, await branchOf($, call.dir), patterns)
68
69      if (decision.kind === 'deny') {
70        return { deny: decision.reason }
71      }
72
73      if (decision.kind === 'confirm') {
74        const stop = `main-guard: a direct push to ${decision.branch} needs your OK. Push a feature branch and open a PR, or run the push yourself.`
75
76        if (!confirmPushes) {
77          return { deny: stop }
78        }
79
80        const answer = await $.ui
81          .ask(`Push straight to ${decision.branch}?`, { options: [`Push to ${decision.branch}`, 'Cancel'], header: 'main-guard' })
82          .catch(() => undefined)
83
84        if (answer !== `Push to ${decision.branch}`) {
85          return { deny: answer === undefined ? stop : `main-guard: the push to ${decision.branch} was cancelled by the user.` }
86        }
87      }
88    }
89
90    const ran = await next(e)
91    void refresh($, patterns).catch(() => undefined)
92
93    return ran
94  })
95
96  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
97    const state = await read($, guard)
98
99    if (e.props.hasSurvey || state === null || state.reasons.length === 0) {
100      return next(e)
101    }
102
103    const { Box, Text } = $.ui.resolve(e)
104    const isOnBranch = state.reasons.some(reason => reason.startsWith('on '))
105
106    return (
107      <Box width={e.props.bodyColumns}>
108        <Text backgroundColor="red" color="white" bold wrap="truncate">
109          {` ${bandText(state.reasons)} `}
110        </Text>
111        {isOnBranch && (
112          <Text dimColor wrap="truncate">
113            {'  force-push and reset --hard are blocked'}
114          </Text>
115        )}
116      </Box>
117    )
118  })
119}
120
hooks/guard.ts 314 lines
1// Pure logic for main-guard: reading git calls out of a shell command and judging them.
2
3export const DEFAULT_BRANCHES = 'main, master, production, release*'
4
5/** "main, master release/*" -> ["main", "master", "release/*"] */
6export function branchList(text: string): string[] {
7  return text
8    .split(/[\s,]+/)
9    .map(item => item.trim())
10    .filter(Boolean)
11}
12
13/** Glob match with `*` as "anything", so `release*` covers `release/2.1` and `release-candidate`. */
14export function isProtected(branch: string | null, patterns: readonly string[]): boolean {
15  if (branch === null || branch === '') {
16    return false
17  }
18
19  return patterns.some(pattern => {
20    const source = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')
21
22    return new RegExp(`^${source}$`).test(branch)
23  })
24}
25
26const SEPARATORS = new Set(['&&', '||', ';', '|', '&', '\n', '(', ')', '|&'])
27
28/** Splits a shell command into simple commands, each a list of words with quotes removed. */
29export function simpleCommands(command: string): string[][] {
30  const commands: string[][] = []
31  let words: string[] = []
32  let word = ''
33  let hasWord = false
34  let i = 0
35
36  const endWord = () => {
37    if (hasWord) {
38      words.push(word)
39    }
40
41    word = ''
42    hasWord = false
43  }
44  const endCommand = () => {
45    endWord()
46
47    if (words.length > 0) {
48      commands.push(words)
49    }
50
51    words = []
52  }
53
54  while (i < command.length) {
55    const char = command[i] ?? ''
56    const pair = command.slice(i, i + 2)
57
58    if (char === '\\' && i + 1 < command.length) {
59      if (command[i + 1] !== '\n') {
60        word += command[i + 1]
61        hasWord = true
62      }
63
64      i += 2
65    } else if (char === "'") {
66      const end = command.indexOf("'", i + 1)
67      const stop = end === -1 ? command.length : end
68      word += command.slice(i + 1, stop)
69      hasWord = true
70      i = stop + 1
71    } else if (char === '"') {
72      i += 1
73
74      while (i < command.length && command[i] !== '"') {
75        if (command[i] === '\\' && i + 1 < command.length && '"\\$`'.includes(command[i + 1] ?? '')) {
76          i += 1
77        }
78
79        word += command[i]
80        i += 1
81      }
82
83      hasWord = true
84      i += 1
85    } else if (char === '#' && !hasWord) {
86      while (i < command.length && command[i] !== '\n') {
87        i += 1
88      }
89    } else if (SEPARATORS.has(pair)) {
90      endCommand()
91      i += 2
92    } else if (SEPARATORS.has(char)) {
93      endCommand()
94      i += 1
95    } else if (char === ' ' || char === '\t') {
96      endWord()
97      i += 1
98    } else {
99      word += char
100      hasWord = true
101      i += 1
102    }
103  }
104
105  endCommand()
106
107  return commands
108}
109
110export type GitCall = {
111  /** The `-C <dir>` the call runs in, when it names one. */
112  dir: string | undefined
113  sub: string
114  args: string[]
115}
116
117const WRAPPERS = new Set(['sudo', 'env', 'command', 'time', 'nohup', 'exec', 'builtin'])
118const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash'])
119const GIT_VALUE_OPTIONS = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path', '--config-env'])
120
121/** Every git call in a command, `bash -c "..."` bodies included. */
122export function gitCalls(command: string, depth = 0): GitCall[] {
123  const calls: GitCall[] = []
124
125  for (const words of simpleCommands(command)) {
126    let at = 0
127
128    while (at < words.length && (WRAPPERS.has(words[at] ?? '') || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[at] ?? '') || (words[at - 1] === 'sudo' && words[at]?.startsWith('-')))) {
129      at += 1
130    }
131
132    const program = (words[at] ?? '').split('/').pop() ?? ''
133
134    if (SHELLS.has(program) && depth < 2) {
135      const flag = words.findIndex((word, index) => index > at && /^-[a-z]*c[a-z]*$/.test(word))
136      const body = flag === -1 ? undefined : words[flag + 1]
137
138      if (body !== undefined) {
139        calls.push(...gitCalls(body, depth + 1))
140      }
141
142      continue
143    }
144
145    if (program !== 'git') {
146      continue
147    }
148
149    let dir: string | undefined
150    at += 1
151
152    while (at < words.length && (words[at] ?? '').startsWith('-')) {
153      const option = words[at] ?? ''
154
155      if (option === '-C') {
156        dir = words[at + 1]
157      }
158
159      at += GIT_VALUE_OPTIONS.has(option) ? 2 : 1
160    }
161
162    if (at < words.length) {
163      calls.push({ dir, sub: words[at] ?? '', args: words.slice(at + 1) })
164    }
165  }
166
167  return calls
168}
169
170export type Push = {
171  isForce: boolean
172  isDelete: boolean
173  isMirror: boolean
174  isAll: boolean
175  /** Branch names the push writes; `null` stands for the current branch. */
176  targets: (string | null)[]
177}
178
179const PUSH_VALUE_OPTIONS = new Set(['-o', '--push-option', '--repo', '--receive-pack', '--exec'])
180
181export function readPush(args: readonly string[]): Push {
182  const push: Push = { isForce: false, isDelete: false, isMirror: false, isAll: false, targets: [] }
183  const positional: string[] = []
184  let isTagsOnly = false
185
186  for (let i = 0; i < args.length; i += 1) {
187    const arg = args[i] ?? ''
188
189    if (arg === '--') {
190      positional.push(...args.slice(i + 1))
191      break
192    } else if (arg === '--force' || arg === '--force-with-lease' || arg.startsWith('--force-with-lease=')) {
193      push.isForce = true
194    } else if (arg === '--delete') {
195      push.isDelete = true
196    } else if (arg === '--mirror') {
197      push.isMirror = true
198    } else if (arg === '--all' || arg === '--branches') {
199      push.isAll = true
200    } else if (arg === '--tags') {
201      isTagsOnly = true
202    } else if (PUSH_VALUE_OPTIONS.has(arg)) {
203      i += 1
204    } else if (arg.startsWith('--')) {
205      continue
206    } else if (arg.startsWith('-') && arg.length > 1) {
207      // A cluster of short flags: -fu, -uf, -d. `-o` takes the rest or the next word.
208      const flags = arg.slice(1)
209
210      for (let at = 0; at < flags.length; at += 1) {
211        if (flags[at] === 'f') {
212          push.isForce = true
213        } else if (flags[at] === 'd') {
214          push.isDelete = true
215        } else if (flags[at] === 'o') {
216          i += at === flags.length - 1 ? 1 : 0
217
218          break
219        }
220      }
221    } else if (/^\d*[<>]/.test(arg) || arg.startsWith('&>')) {
222      // A redirection (2>, >log) the splitter left as a word: it names no branch.
223      continue
224    } else {
225      positional.push(arg)
226    }
227  }
228
229  const refspecs = positional.slice(1)
230
231  if (refspecs.length === 0) {
232    push.targets = push.isAll || push.isMirror || isTagsOnly ? [] : [null]
233
234    return push
235  }
236
237  for (const refspec of refspecs) {
238    const isForced = refspec.startsWith('+')
239    const spec = isForced ? refspec.slice(1) : refspec
240    const colon = spec.indexOf(':')
241    const source = colon === -1 ? spec : spec.slice(0, colon)
242    const destination = colon === -1 ? spec : spec.slice(colon + 1)
243
244    if (destination.startsWith('refs/tags/') || (colon === -1 && source.startsWith('refs/tags/'))) {
245      continue
246    }
247
248    if (isForced) {
249      push.isForce = true
250    }
251
252    if (colon !== -1 && source === '') {
253      push.isDelete = true
254    }
255
256    push.targets.push(destination === 'HEAD' || destination === '@' ? null : destination.replace(/^refs\/heads\//, ''))
257  }
258
259  return push
260}
261
262export type Decision =
263  | { kind: 'pass' }
264  | { kind: 'deny'; reason: string }
265  | { kind: 'confirm'; branch: string }
266
267/**
268 * What to do about one git call, given the branch it runs on.
269 *
270 * Force pushes, deletes and `reset --hard` on a protected branch are refused;
271 * a plain push to one needs the person to say yes.
272 */
273export function decide(call: GitCall, current: string | null, patterns: readonly string[]): Decision {
274  if (call.sub === 'reset' && call.args.includes('--hard') && isProtected(current, patterns)) {
275    return {
276      kind: 'deny',
277      reason: `main-guard: \`git reset --hard\` on ${current} throws away commits and uncommitted work. Branch off first (\`git switch -c rescue\`) or \`git stash\`, then reset there.`,
278    }
279  }
280
281  if (call.sub !== 'push') {
282    return { kind: 'pass' }
283  }
284
285  const push = readPush(call.args)
286
287  if (push.isMirror) {
288    return { kind: 'deny', reason: 'main-guard: `git push --mirror` overwrites every branch on the remote, protected ones included. Push the branches you mean by name.' }
289  }
290
291  const targets = push.targets.map(target => target ?? current)
292  const hit = targets.find(target => isProtected(target, patterns)) ?? (push.isAll ? patterns.find(pattern => !pattern.includes('*')) : undefined)
293
294  if (hit === undefined || hit === null) {
295    return { kind: 'pass' }
296  }
297
298  if (push.isForce || push.isDelete) {
299    const what = push.isDelete ? 'Deleting' : 'Force-pushing'
300
301    return {
302      kind: 'deny',
303      reason: `main-guard: ${what} ${hit} rewrites history other people build on, so it is blocked here. Push to a feature branch and open a PR; if you really mean it, run it yourself outside Claude.`,
304    }
305  }
306
307  return { kind: 'confirm', branch: hit }
308}
309
310/** The band's text: "⚠ on main · NODE_ENV=production · .prod". */
311export function bandText(reasons: readonly string[]): string {
312  return `⚠ ${reasons.join(' · ')}`
313}
314
types/index.d.ts 9 lines
1/** Why the band is up: the protected branch, a production env var, a .prod marker. Empty: no band. */
2export type GuardState = { branch: string | null; reasons: string[] }
3
4declare module 'claude-code' {
5  interface PluginState {
6    'main-guard': { guard: GuardState | null }
7  }
8}
9