SLOPSHOPPER

push-guard

Blocks git push and gh pr create until you confirm the commits, their authors and the diff size, with a side pane of the last check

newpaneguardcommandprocess
v0.1.0MITupdated 2026-10-10kk5190/claude-code-mods/push-guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · push-guard
│ ┃ Push guard ✕ › fix the failing auth test and add an audit log call │ ┃ Command: rm -rf build && git push --force │ ┃ origin main ⏺ Read(src/auth.ts) │ ┃ Could not read the commits. ⎿ Read 6 lines │ ┃ Outcome: refused, the command did not run ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(rm -rf build && git push --force origin main) │ ⎿ Denied by push-guard: push-guard: could not read the comm │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /push-guard │ ⎿ push-guard: Push guard pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Push guard
Command: rm -rf build && git push --force origin main Could not read the commits. Outcome: refused, the command did not run
README

push-guard

A Claude Code mod that stops git push and gh pr create until you confirm.

When Claude runs one of those commands, a dialog shows:

  • the command
  • the commits not yet on the upstream branch, and the diff size
  • an Author check failed list for any commit whose author isn't kk5190

Choose Continue to run it, or Cancel to refuse. If nobody answers, the command is refused.

The side pane shows the last check: the command, the commits, any author warning, and the outcome (waiting, continued, cancelled, or refused). It opens when a session starts. Reopen it with /push-guard.

Limits

  • It matches on the command text. A push hidden behind an alias, a script, or a sub-shell won't be caught.
  • The commit list comes from the upstream branch. With no upstream set, the dialog says it could not list the commits.

Install

/plugin install push-guard --marketplace kk5190/claude-code-mods

Develop

claude --plugin-dir .          # run a session with this folder loaded; edits hot-reload
claude plugin validate .
claude plugin test .

License

MIT. See LICENSE.

Source 3 files
hooks/register.tsx 86 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Check, Outcome } from '../types'
5import { authorWarnings, isPublishCommand, paneLines, parseCommits, summarize } from './guard'
6
7const PANE = 'push-guard'
8const TITLE = 'Push guard'
9
10const LOG = ['git', 'log', '--format=%an%x09%ae%x09%s', '@{u}..HEAD']
11const STAT = ['git', 'diff', '--shortstat', '@{u}..HEAD']
12
13const lastCheck = atom({ plugin: 'push-guard', key: 'lastCheck' } as const, null)
14
15export const register: Register = on => {
16  on('session.start', async ($, e, next) => {
17    await $.command.register({ name: 'push-guard', description: 'Show the push guard pane' })
18    void $.ui.open({ id: PANE, title: TITLE })
19
20    return next(e)
21  })
22
23  on('command.run', { command: 'push-guard' }, async $ => {
24    await $.ui.open({ id: PANE, title: TITLE })
25
26    return { text: 'Push guard pane opened.' }
27  })
28
29  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
30    if (!isPublishCommand(e.command)) return next(e)
31
32    const settle = (check: Check, outcome: Outcome) => update($, lastCheck, () => ({ ...check, outcome }))
33
34    let check: Check
35    let question: string
36    try {
37      const log = await $.process.run(LOG)
38      const stat = await $.process.run(STAT)
39      const commits = log.exitCode === 0 ? parseCommits(log.stdout) : []
40      const body =
41        log.exitCode === 0 ? summarize(commits, stat.stdout) : 'Could not list the commits (no upstream branch?).'
42      const warnings = authorWarnings(commits)
43      const warn = warnings.length > 0 ? `\nAuthor check failed:\n  ${warnings.join('\n  ')}` : ''
44      question = `Run: ${e.command}\n${body}${warn}`
45      check = { command: e.command, lines: body.split('\n'), warnings, outcome: 'checking' }
46    } catch {
47      await update($, lastCheck, () => ({
48        command: e.command,
49        lines: ['Could not read the commits.'],
50        warnings: [],
51        outcome: 'refused',
52      }))
53      return { deny: 'push-guard: could not read the commits, so the command was not run' }
54    }
55
56    await update($, lastCheck, () => check)
57
58    try {
59      const answer = await $.ui.ask(question, ['Continue', 'Cancel'])
60      if (answer !== 'Continue') {
61        await settle(check, 'cancelled')
62        return { deny: 'push-guard: cancelled' }
63      }
64    } catch {
65      await settle(check, 'refused')
66      return { deny: 'push-guard: no answer, so the command was not run' }
67    }
68
69    await settle(check, 'continued')
70    return next(e)
71  }).catch(($, e, next) => (next.called ? next(e) : { deny: 'push-guard: its check failed, so the command was not run' }))
72
73  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
74    const { Box, Text } = $.ui.resolve(e)
75    const lines = paneLines(await read($, lastCheck))
76
77    return (
78      <Box flexDirection="column" paddingX={1}>
79        {lines.map((line, i) => (
80          <Text key={i}>{line}</Text>
81        ))}
82      </Box>
83    )
84  })
85}
86
hooks/guard.ts 55 lines
1// Pure logic for the push guard. No engine imports, so it runs under plain tests.
2
3import type { Check, Outcome } from '../types'
4
5export type Commit = { name: string; email: string; subject: string }
6
7// The author the guard expects on every commit about to be published.
8const EXPECTED_AUTHOR = 'kk5190'
9
10// Matches anywhere in the command, so a chained `git add . && git push` is caught too.
11// Over-matching a quoted string is harmless: the guard asks instead of running.
12const PUBLISH = /\bgit\s+push\b|\bgh\s+pr\s+create\b/
13
14export const isPublishCommand = (command: string): boolean => PUBLISH.test(command)
15
16// Input is `git log --format=%an%x09%ae%x09%s`, one commit per line.
17export const parseCommits = (stdout: string): Commit[] =>
18  stdout
19    .split('\n')
20    .filter(line => line.length > 0)
21    .map(line => {
22      const [name, email, subject] = line.split('\t')
23      return { name, email, subject }
24    })
25
26const isExpected = ({ name, email }: Commit) =>
27  name === EXPECTED_AUTHOR || email.toLowerCase().includes(EXPECTED_AUTHOR)
28
29export const authorWarnings = (commits: Commit[]): string[] =>
30  commits.filter(c => !isExpected(c)).map(c => `${c.name} <${c.email}>: ${c.subject}`)
31
32export const summarize = (commits: Commit[], shortstat: string): string => {
33  const count = `${commits.length} commit${commits.length === 1 ? '' : 's'}`
34  const lines = commits.map(c => `  ${c.subject}`)
35  return [count, ...lines, shortstat.trim()].filter(Boolean).join('\n')
36}
37
38const OUTCOME: Record<Outcome, string> = {
39  checking: 'waiting for your answer',
40  continued: 'continued',
41  cancelled: 'cancelled',
42  refused: 'refused, the command did not run',
43}
44
45// The pane's lines for the last check, one string per row.
46export const paneLines = (check: Check | null): string[] => {
47  if (check === null) return ['No publish command checked yet.']
48  return [
49    `Command: ${check.command}`,
50    ...check.lines,
51    ...check.warnings.map(w => `Author check failed: ${w}`),
52    `Outcome: ${OUTCOME[check.outcome]}`,
53  ]
54}
55
types/index.d.ts 11 lines
1export type Outcome = 'checking' | 'continued' | 'cancelled' | 'refused'
2
3// The last publish command the guard looked at, as the pane shows it.
4export type Check = { command: string; lines: string[]; warnings: string[]; outcome: Outcome }
5
6declare module 'claude-code' {
7  interface PluginState {
8    'push-guard': { lastCheck: Check | null }
9  }
10}
11