SLOPSHOPPER

main-guard

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

newguardcommandtoaststatusprocess
v0.1.0MITupdated 2026-10-05vincentlauriat/ModsTools/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 ╭────────────────────────────────╮ │ main-guard │ ⏺ Read(src/auth.ts) │ main-guard blocked: force push │ ⎿ 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: refused (force push). The project rules forbid this without the user's e ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /main-guard ⎿ main-guard: main-guard is ON. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

main-guard

Refuses risky git and release commands (pushes to main/master, force pushes, tags, GitHub releases) until you allow them.

What it does

  • Inspects every Bash call before it runs and denies it with an explanation to Claude, plus a toast for you.
  • Refused:
  • git push to main or master (explicit refspec, HEAD:main, or a bare git push while on that branch);
  • force pushes (-f, --force*, +refspec);
  • git push --mirror / --all;
  • pushing tags (--tags, --follow-tags, tag <name>, refs/tags/...);
  • creating or deleting tags with 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.

Commands

CommandEffect
/main-guard statusShow whether the guard is on.
/main-guard offAllow everything for this session; the status line shows main-guard: OFF.
/main-guard onTurn the guard back on.

Install

claude --plugin-dir /path/to/ModsTools/mods/main-guard

Limits

  • The command line is read word by word, split on &&, ||, ;, | and newlines. It does not see through bash -c "...", $(...), eval, aliases or scripts, nor quoted text containing ; or |.
  • This is a seatbelt against accidental pushes, not a security boundary.
  • The off switch lasts for the session (stored as a session atom).

Develop

claude plugin validate mods/main-guard
claude plugin test mods/main-guard   # 40 tests
Source 3 files
hooks/register.ts 49 lines
1import { 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}
49
hooks/rules.ts 96 lines
1const 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}
96
types/index.d.ts 8 lines
1export type GuardSwitch = boolean
2
3declare module 'claude-code' {
4  interface PluginState {
5    'main-guard': { isOff: GuardSwitch }
6  }
7}
8