Refuses git pushes to main/master, force pushes, tag creation and GitHub releases unless the person allows them

Refuses risky git and release commands (pushes to main/master, force pushes, tags, GitHub releases) until you allow them.
Bash call before it runs and denies it with an explanation to Claude, plus a toast for you.git push to main or master (explicit refspec, HEAD:main, or a bare git push while on that branch);-f, --force*, +refspec);git push --mirror / --all;--tags, --follow-tags, tag <name>, refs/tags/...);git tag (listing options such as -l, --list, --contains are allowed);gh release create, edit and delete.git -C <dir> and a preceding cd <dir> are used to find the branch of a bare push; rtk prefixes and env assignments are skipped.| Command | Effect |
|---|---|
/main-guard status | Show whether the guard is on. |
/main-guard off | Allow everything for this session; the status line shows main-guard: OFF. |
/main-guard on | Turn the guard back on. |
claude --plugin-dir /path/to/ModsTools/mods/main-guard
&&, ||, ;, | and newlines. It does not see through bash -c "...", $(...), eval, aliases or scripts, nor quoted text containing ; or |.claude plugin validate mods/main-guard
claude plugin test mods/main-guard # 40 testshooks/register.ts 49 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { check, mentionsPush, pushDir } from './rules'
5
6const isOff = atom({ plugin: 'main-guard', key: 'isOff' } as const, false)
7
8async function currentBranch($: EngineInterface, dir: string | undefined): Promise<string | undefined> {
9 const ran = await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], dir === undefined ? undefined : { cwd: dir })
10
11 return ran.exitCode === 0 ? ran.stdout.trim() : undefined
12}
13
14export const register: Register = on => {
15 on('session.start', async ($, e, next) => {
16 await $.command.register({
17 name: 'main-guard',
18 description: 'main-guard on|off|status: allow or refuse pushes to main, force pushes, tags and releases',
19 })
20
21 return next(e)
22 })
23
24 on('command.run', { command: 'main-guard' }, async ($, e) => {
25 const arg = e.args.trim()
26 if (arg === 'off' || arg === 'on') {
27 await update($, isOff, () => arg === 'off')
28 $.ui.status(arg === 'off' ? 'main-guard: OFF' : undefined)
29 }
30 const off = await read($, isOff)
31
32 return { text: `main-guard is ${off ? 'OFF for this session' : 'ON'}.` }
33 })
34
35 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
36 if (await read($, isOff)) return next(e)
37 const branch = mentionsPush(e.command) ? await currentBranch($, pushDir(e.command)) : undefined
38 const reason = check(e.command, branch)
39 if (reason === null) return next(e)
40 $.ui.toast(`main-guard blocked: ${reason}`)
41
42 return {
43 deny:
44 `main-guard: refused (${reason}). The project rules forbid this without the user's explicit approval. ` +
45 'Ask the user first; they can run it themselves with "! <command>" or allow it for this session with /main-guard off.',
46 }
47 })
48}
49hooks/rules.ts 96 lines1const PROTECTED = new Set(['main', 'master'])
2
3// `git tag` options that only read tags.
4const TAG_READS = new Set(['-l', '--list', '-n', '--contains', '--no-contains', '--points-at', '--merged', '--no-merged', '-v', '--verify', '--column', '--sort'])
5
6// Each simple command of a shell line as words, leading env assignments and `rtk` dropped.
7export function segments(command: string): string[][] {
8 return command
9 .split(/&&|\|\||[;|\n]/)
10 .map(part => {
11 const words = part.trim().split(/\s+/).filter(Boolean)
12 let i = 0
13 while (words[i] !== undefined && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]!)) i++
14 if (words[i] === 'rtk') i++
15
16 return words.slice(i)
17 })
18 .filter(words => words.length > 0)
19}
20
21// The words after `git` and its global options, or null when it is not git.
22function gitArgs(words: string[]): string[] | null {
23 if (words[0] !== 'git') return null
24 let i = 1
25 while (words[i]?.startsWith('-')) i += words[i] === '-C' || words[i] === '-c' ? 2 : 1
26
27 return words.slice(i)
28}
29
30const branchOf = (ref: string) => ref.replace(/^\+/, '').replace(/^refs\/heads\//, '')
31
32function checkPush(args: string[], branch: string | undefined): string | null {
33 const flags = args.filter(a => a.startsWith('-'))
34 const positional = args.filter(a => !a.startsWith('-'))
35 if (flags.some(f => f === '-f' || f.startsWith('--force'))) return 'force push'
36 if (flags.some(f => f === '--mirror' || f === '--all')) return 'pushing every branch'
37 if (flags.some(f => f === '--tags' || f === '--follow-tags')) return 'pushing tags'
38
39 const refspecs = positional.slice(1)
40 if (refspecs.length === 0) {
41 return branch !== undefined && PROTECTED.has(branch) ? `pushing ${branch}` : null
42 }
43 for (const refspec of refspecs) {
44 if (refspec.startsWith('+')) return 'force push'
45 if (refspec === 'tag' || refspec.startsWith('refs/tags/')) return 'pushing tags'
46 const target = branchOf(refspec.includes(':') ? refspec.split(':').pop()! : refspec)
47 const resolved = target === 'HEAD' ? branch : target
48 if (resolved !== undefined && PROTECTED.has(resolved)) return `pushing to ${resolved}`
49 }
50
51 return null
52}
53
54function checkTag(args: string[]): string | null {
55 const isRead = args.length === 0 || args.some(a => TAG_READS.has(a) || /^-n\d+$/.test(a) || a.startsWith('--sort=') || a.startsWith('--format='))
56
57 return isRead ? null : 'creating or deleting a tag'
58}
59
60/**
61 * Why the command must not run without approval, or null when it may.
62 * `branch` is the current git branch, needed for a bare `git push`.
63 */
64export function check(command: string, branch: string | undefined): string | null {
65 for (const words of segments(command)) {
66 if (words[0] === 'gh' && words[1] === 'release' && ['create', 'delete', 'edit'].includes(words[2] ?? '')) {
67 return 'creating or changing a GitHub release'
68 }
69 const args = gitArgs(words)
70 if (args === null) continue
71 const reason = args[0] === 'push' ? checkPush(args.slice(1), branch) : args[0] === 'tag' ? checkTag(args.slice(1)) : null
72 if (reason !== null) return reason
73 }
74
75 return null
76}
77
78export const mentionsPush = (command: string) => /\bpush\b/.test(command)
79
80/**
81 * The folder the first `git push` of the line runs in: its `git -C` folder,
82 * else the last `cd` before it; undefined for the session's own folder.
83 */
84export function pushDir(command: string): string | undefined {
85 let dir: string | undefined
86 for (const words of segments(command)) {
87 if (words[0] === 'cd' && words[1] !== undefined) dir = words[1]
88 if (gitArgs(words)?.[0] !== 'push') continue
89 const at = words.indexOf('-C')
90
91 return at > 0 && at < words.indexOf('push') ? words[at + 1] : dir
92 }
93
94 return undefined
95}
96types/index.d.ts 8 lines1export type GuardSwitch = boolean
2
3declare module 'claude-code' {
4 interface PluginState {
5 'main-guard': { isOff: GuardSwitch }
6 }
7}
8