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

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:
main, master, production, release* by default)NODE_ENV, RAILS_ENV, APP_ENV or ENVIRONMENT is prod / production.prod marker file sits in the working directory or the repo rootOn 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:
| command | on a protected branch |
|---|---|
git push --force / -f / --force-with-lease / +refspec | denied |
git push --delete / :branch, git push --mirror | denied |
git reset --hard | denied |
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.
claude --plugin-dir ./main-guard
or add this repo as a marketplace and install it with /plugin.
| option | default | what it does |
|---|---|---|
protectedBranches | main, master, production, release* | Comma-separated; * matches anything, so release* covers release/2.1. |
confirmPushes | true | Off: plain pushes to a protected branch are refused outright instead of asking. |
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.
hooks/register.tsx 120 lines1import { 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}
120hooks/guard.ts 314 lines1// 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}
314types/index.d.ts 9 lines1/** 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