Asks before every git push the model runs, in Claude Code's own question dialog: a click confirms that one command, anything else refuses it. Optional one-shot…

Asks before every git push the model runs. The question is Claude Code's own AskUserQuestion dialog: the full command, the repository, the branch and its upstream, how many commits go, and a warning for a force push or a protected branch (release, master, main). A click on «Пушить» lets that one call run. «Отмена», a typed answer, Esc or a session with no one to ask (claude -p) refuse it, and the model reads why.
Version: 0.1.0
/plugin install push-gate --marketplace IvanLutsenko/awac-ai-agent-plugins
Requires Claude Code with function hooks (mods), 2.1.287 or later. This is early access: the hook API can change between releases.
git and push. The rule is coarse on purpose: it catches bash -c "git push", ( git push ), /usr/bin/git push, env git push, a quoted -C path and a push on the second line. A commit message with the word "push" in it costs one extra dialog.glab … --push and gh pr create, which push on their own.claude, codex or pi started with their guards off (--safe-mode, --bare, --dangerously-…).Alone, the mod refuses every push the person did not confirm. It is not a wall, though: a push from a script file, a git hook, an API call or another agent never passes through it. For a guard that also holds when mods are off (--safe-mode) or the mod is not installed, deny every push in a PreToolUse hook in settings.json:
if printf '%s' "$CMD" | grep -qw git && printf '%s' "$CMD" | grep -qw push; then
deny 'Push only through the push-gate dialog.'
fi
The mod lifts that deny for the one call the person confirmed: tool.check runs after PreToolUse and may allow what a PreToolUse hook from user settings denied (not one from managed settings). The approval lives in the mod's memory, so there is no file the model could write to forge it. The cost: a confirmed push skips the auto mode classifier; the click is the decision.
The server is the real boundary: protect the branches that matter (no force push, no deletion) on GitHub or GitLab.
claude plugin test plugins/push-gatehooks/register.ts 93 lines1import type { EngineInterface, Register } from 'claude-code'
2
3const PUSH = 'Пушить'
4const MAX_SHOWN = 2000
5const PROTECTED = /\b(release|master|main)\b/
6
7// Errs toward asking. A push is `git` and `push` as words anywhere in the line
8// (bash -c, subshells, /usr/bin/git, env prefixes, quoted -C paths, newlines);
9// also the tools that push on their own and child agents started with their
10// guards off. A commit message with "push" in it costs one extra dialog.
11export function needsConfirm(command: string): boolean {
12 return (
13 (/\bgit\b/.test(command) && /\bpush\b/.test(command)) ||
14 /\bglab\b[^\n]*--push\b/.test(command) ||
15 /\bgh\s+pr\s+create\b/.test(command) ||
16 /\b(claude|codex|pi)\b[^\n]*--(safe-mode|bare|dangerously-[\w-]+)/.test(command)
17 )
18}
19
20export function warnings(command: string, branch?: string): string[] {
21 const marks: string[] = []
22 if (/(^|\s)(--force|--force-with-lease|-[a-zA-Z]*f[a-zA-Z]*)(\s|=|$)|\s\+\S/.test(command)) marks.push('FORCE')
23 if (PROTECTED.test(command) || (branch !== undefined && PROTECTED.test(branch))) marks.push('защищённая ветка')
24 return marks
25}
26
27// Where the push runs: git's -C path when given, else the session's directory.
28export function repoDir(command: string): string | undefined {
29 const m = command.match(/\bgit\s+-C\s+(?:"([^"]+)"|'([^']+)'|(\S+))/)
30 return m === null ? undefined : (m[1] ?? m[2] ?? m[3])
31}
32
33async function git($: EngineInterface, cwd: string, ...args: string[]): Promise<string | undefined> {
34 try {
35 const run = await $.process.run(['git', ...args], { cwd, timeoutMs: 3000 })
36 return run.exitCode === 0 ? run.stdout.trim() : undefined
37 } catch {
38 return undefined
39 }
40}
41
42async function question($: EngineInterface, command: string): Promise<string> {
43 const cwd = repoDir(command) ?? (await $.session.cwd())
44 const branch = await git($, cwd, 'rev-parse', '--abbrev-ref', 'HEAD')
45 const upstream = await git($, cwd, 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}')
46 const ahead = upstream === undefined ? undefined : await git($, cwd, 'rev-list', '--count', '@{u}..HEAD')
47 const marks = warnings(command, branch)
48
49 return [
50 'Пушу?',
51 command,
52 '',
53 `Репо: ${cwd}`,
54 `Ветка: ${branch ?? '?'} → ${upstream ?? 'нет upstream'}${ahead === undefined ? '' : ` (коммитов впереди: ${ahead})`}`,
55 ...(marks.length > 0 ? [`ВНИМАНИЕ: ${marks.join(' · ')}`] : []),
56 ].join('\n')
57}
58
59// The mod asks in tool.call and, on the click, lifts the deny of a PreToolUse
60// guard for that one call in tool.check (which runs after PreToolUse). The
61// approval lives in this module's memory, out of the model's reach: there is
62// no file to forge. A confirmed push skips the auto mode classifier; the
63// click is the decision.
64export const register: Register = on => {
65 const approved = new Set<string>()
66
67 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
68 if (!needsConfirm(e.command)) return next(e)
69 if (e.command.length > MAX_SHOWN) {
70 return { deny: `push-gate: the command is longer than ${MAX_SHOWN} characters and cannot be shown whole; split it.` }
71 }
72
73 const answer = await $.ui.ask(await question($, e.command), { options: [PUSH, 'Отмена'], header: 'push-gate' })
74 if (answer !== PUSH) return { deny: 'The person declined this push in the push-gate dialog.' }
75
76 approved.add(e.tool_use_id)
77 try {
78 return await next(e)
79 } finally {
80 approved.delete(e.tool_use_id)
81 }
82 }).catch(($, e, next) =>
83 next.called ? next(e) : { deny: 'push-gate could not ask the person; push refused.' },
84 )
85
86 // ponytail: no .catch — a failure here only fails to lift a deny, which is safe.
87 on('tool.check', { tool: 'Bash' }, ($, e, next) =>
88 e.tool_use_id !== undefined && approved.has(e.tool_use_id)
89 ? { decision: 'allow', reason: 'confirmed in the push-gate dialog' }
90 : next(e),
91 )
92}
93