Refuses git pushes, force pushes, rebases, amends, hard resets, `git add -A` and doc commits the person didn't ask for this turn, and strips Claude's co-author…

Stops Claude from running git operations you didn't ask for in your latest prompt. It exists because of complaints like "stop committing the docs", "just push, why rebase", "commit all, I said all" and "don't add yourself as co-author".
git push runs only if your latest prompt says push, ship or deploy. A force push needs "force". A rebase (including pull --rebase), commit --amend, reset --hard, clean -f and checkout -- ./restore . each need their own words.git add -A/./--all and git commit -a need "all" or "semua". Otherwise Claude is told to stage explicit paths.git commit checks the index, plus anything the same command line stages first. If it would commit docs/**, plans/ or specs/ Markdown, ADRs, *handover*, CLAUDE.md or AGENTS.md, it is refused unless the prompt mentions docs, readme or ADR, or names the file.git status still run.Co-Authored-By trailer and the "Generated with Claude Code" line from commit and PR messages. It also empties the engine's commit and PR attribution text. This stays on even while the guard is paused./git-guard off pauses the guard for the session. /git-guard on turns it back on.Your latest prompt is the last one you typed or sent from your phone. Scripts (claude -p), notifications and other sessions don't count. Negations are read only roughly: "why rebase" and "don't push" don't count as asking, but other phrasings can.
The guard fails closed: if its own check breaks, the git command is refused (other commands still run). It reads the command text only, so aliases, scripts and bash -c get past it. It is a safety net, not a permission system.
hooks/register.tsx 102 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { ASK, addedPaths, asks, fromRoot, gitCalls, isDocPath, refusal, stripAttribution } from './rules'
5import type { GitCall } from './rules'
6
7const lastPrompt = atom({ plugin: 'git-guard', key: 'prompt' } as const, '')
8const paused = atom({ plugin: 'git-guard', key: 'paused' } as const, false)
9
10const GIT = /\bgit\b/
11
12async function absDir($: EngineInterface, dir: string | null): Promise<string | undefined> {
13 if (dir === null) return undefined
14 if (dir === '~' || dir.startsWith('~/')) return `${(await $.env.get('HOME')) ?? ''}${dir.slice(1)}`
15 return dir
16}
17
18async function gitLines($: EngineInterface, cwd: string | undefined, args: string[]): Promise<string[]> {
19 const run = await $.process.run(['git', ...args], { cwd, timeoutMs: 8000 })
20 if (run.exitCode !== 0) throw new Error(`git ${args[0]} failed: ${run.stderr.trim().slice(0, 160)}`)
21 return run.stdout.split('\n').filter(l => l !== '')
22}
23
24/** Repo-root paths the commit at `at` would record: the index plus what the line stages first. */
25async function committedPaths($: EngineInterface, calls: GitCall[], at: number): Promise<string[]> {
26 const cwd = await absDir($, calls[at]!.dir)
27 const probe = await $.process.run(['git', 'rev-parse', '--show-prefix'], { cwd, timeoutMs: 8000 })
28 if (probe.exitCode !== 0) return [] // not a repo: the commit fails on its own
29 const prefix = probe.stdout.trim()
30 const { paths, broad, all } = addedPaths(calls, at)
31 const found = new Set(await gitLines($, cwd, ['diff', '--cached', '--name-only']))
32 for (const p of paths) found.add(fromRoot(prefix, p))
33 if (broad || all) for (const p of await gitLines($, cwd, ['diff', '--name-only', 'HEAD'])) found.add(p)
34 if (broad) for (const p of await gitLines($, cwd, ['ls-files', '--others', '--exclude-standard', '--full-name'])) found.add(p)
35 return [...found]
36}
37
38export const register: Register = on => {
39 on('session.start', async ($, e, next) => {
40 await $.command.register({
41 name: 'git-guard',
42 description: 'Pause (off) or resume (on) git-guard for this session',
43 argumentHint: 'on|off',
44 })
45 return next(e)
46 })
47
48 on('command.run', { command: 'git-guard' }, async ($, e) => {
49 const arg = e.args.trim().toLowerCase()
50 if (arg === 'off' || arg === 'on') {
51 await update($, paused, () => arg === 'off')
52 return { text: arg === 'off' ? 'git-guard paused for this session.' : 'git-guard is on.' }
53 }
54 return { text: `git-guard is ${(await read($, paused)) ? 'paused' : 'on'}. Use /git-guard off or /git-guard on.` }
55 })
56
57 // What the person asked this turn. Their own Enter or their phone; not a
58 // script, a notification or another session.
59 on('prompt.submit', async ($, e, next) => {
60 if (e.origin.kind === 'composer' || e.origin.kind === 'bridge') {
61 await update($, lastPrompt, () => e.text)
62 }
63 return next(e)
64 })
65
66 on('attribution.text', ($, e, next) => (e.kind === 'commit' || e.kind === 'pr' ? { text: '' } : next(e)))
67
68 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
69 const command = stripAttribution(e.command)
70 const pass = () => next(command === e.command ? e : { ...e, command })
71 if (!GIT.test(command) || (await read($, paused))) return pass()
72
73 const prompt = await read($, lastPrompt)
74 const calls = gitCalls(command)
75 for (const call of calls) {
76 const why = refusal(call, prompt)
77 if (why !== null) return { deny: `git-guard: ${why}` }
78 }
79
80 const at = calls.findIndex(c => c.sub === 'commit')
81 if (at >= 0 && !asks(prompt, ASK.docs)) {
82 const named = prompt.toLowerCase()
83 const docs = (await committedPaths($, calls, at))
84 .filter(isDocPath)
85 .filter(p => !named.includes((p.split('/').pop() ?? p).toLowerCase()))
86 if (docs.length > 0) {
87 const list = docs.slice(0, 15).join(', ') + (docs.length > 15 ? `, +${docs.length - 15} more` : '')
88 return {
89 deny: `git-guard: this commit would include docs/plans/notes the user didn't ask to commit: ${list}. Leave them out (\`git restore --staged <path>\`) and commit the rest; mention them to the user instead.`,
90 }
91 }
92 }
93 return pass()
94 }).catch(($, e, next) => {
95 // Fail closed for git (Jev: fail_closed 0.83); anything else goes on.
96 if (next.called) return next(e)
97 return e.tool === 'Bash' && GIT.test(e.command)
98 ? { deny: 'git-guard: its check failed, so this git command was not run. Retry, or the user can run /git-guard off.' }
99 : next(e)
100 })
101}
102hooks/rules.ts 198 lines1import { commands, joinDir, program } from './shell'
2
3export type GitCall = {
4 sub: string
5 args: string[]
6 /** Where git runs: the line's `cd`s, then `-C`; null = the session folder. */
7 dir: string | null
8}
9
10/** Every `git <sub>` on the line, in order. */
11export function gitCalls(line: string): GitCall[] {
12 const out: GitCall[] = []
13 for (const { words, dir } of commands(line)) {
14 if (program(words[0]) !== 'git') continue
15 let at = dir
16 let i = 1
17 while (i < words.length && words[i]!.startsWith('-')) {
18 const opt = words[i]!
19 if (opt === '-C') {
20 at = joinDir(at, words[i + 1])
21 i += 2
22 } else if (opt === '-c' || opt === '--git-dir' || opt === '--work-tree' || opt === '--namespace') {
23 i += 2
24 } else {
25 i += 1
26 }
27 }
28 const sub = words[i]
29 if (sub !== undefined) out.push({ sub, args: words.slice(i + 1), dir: at })
30 }
31 return out
32}
33
34// ---- What the prompt asked for ----------------------------------------------
35
36const NEGATION = /\b(don'?t|dont|do not|never|no|not|stop|why|jangan|gak|ga|nggak|tidak|tanpa|without)\b/i
37
38/**
39 * True when the prompt names the thing and the few words before it, in the same
40 * clause, don't negate it: "just push" yes, "why rebase" and "don't push" no.
41 */
42export function asks(prompt: string, pattern: RegExp): boolean {
43 const global = new RegExp(pattern.source, 'gi')
44 for (const m of prompt.matchAll(global)) {
45 const clause = prompt.slice(0, m.index).split(/[.,!?;:\n]/).pop() ?? ''
46 const before = clause.trim().split(/\s+/).slice(-3).join(' ')
47 if (!NEGATION.test(before)) return true
48 }
49 return false
50}
51
52export const ASK = {
53 push: /\b(push|ship|deploy)\w*/,
54 force: /\bforce|\bpush\s+-f\b/,
55 rebase: /\brebas\w*/,
56 amend: /\bamend\w*/,
57 resetHard: /\breset\s+(--)?hard\b|\bhard[- ]reset\b/,
58 clean: /\bgit\s+clean\b|\buntracked\b/,
59 discard: /\bdiscard\w*|\bbuang\w*|\bgit\s+(checkout|restore)\s+(--\s+)?\./,
60 all: /\ball\b|\bsemua\b/,
61 docs: /\b(docs?|documentation|readme|adrs?|dokumen\w*)\b/,
62}
63
64/** "don't DO any git command", "jangan pakai git": no git writes this turn. */
65const NO_GIT = /\b(don'?t|dont|do not|never|jangan)\s+((do|run|use|touch|make|execute|any|pakai|pake|jalankan|lakukan)\s+){0,2}git\b/i
66
67const READ_ONLY = new Set([
68 'status', 'diff', 'log', 'show', 'blame', 'rev-parse', 'ls-files', 'ls-tree', 'grep', 'describe',
69 'shortlog', 'cat-file', 'merge-base', 'help', 'version', 'reflog', 'rev-list', 'whatchanged',
70])
71
72function readsOnly({ sub, args }: GitCall): boolean {
73 if (READ_ONLY.has(sub)) return sub !== 'reflog' || args[0] === undefined || args[0] === 'show'
74 if (sub === 'branch') return args.every(a => /^(-v+|-a|-r|--list|--show-current|--contains|--merged|--no-merged)$/.test(a))
75 if (sub === 'remote') return args.length === 0 || args[0] === '-v' || args[0] === 'show' || args[0] === 'get-url'
76 if (sub === 'stash') return args[0] === 'list' || args[0] === 'show'
77 if (sub === 'config') return args.some(a => a === '--get' || a === '--list' || a === '-l' || a === '--get-all')
78 if (sub === 'worktree') return args[0] === 'list'
79 return false
80}
81
82const short = (args: readonly string[], letter: string) =>
83 args.some(a => new RegExp(`^-[a-zA-Z]*${letter}[a-zA-Z]*$`).test(a))
84
85/** Why this call is refused given the prompt, or null when it may run. */
86export function refusal(call: GitCall, prompt: string): string | null {
87 const { sub, args } = call
88 const named = `git ${[sub, ...args].join(' ')}`.slice(0, 120)
89 if (NO_GIT.test(prompt) && !readsOnly(call)) {
90 return `\`${named}\` refused: the user said not to run git commands this turn. Do not run git; tell the user what you would run.`
91 }
92 if (sub === 'push') {
93 const isForce = args.some(a => a === '--force' || a.startsWith('--force-with-lease') || a === '--force-if-includes' || /^\+/.test(a)) || short(args, 'f')
94 if (isForce && !asks(prompt, ASK.force)) {
95 return `\`${named}\` refused: a force push needs the user to ask for it this turn ("force push"). Ask the user instead.`
96 }
97 if (!asks(prompt, ASK.push)) {
98 return `\`${named}\` refused: the user didn't ask to push this turn. Stop after committing and say it's ready to push.`
99 }
100 }
101 if (sub === 'rebase' || (sub === 'pull' && args.some(a => a === '--rebase' || a.startsWith('--rebase=') || a === '-r'))) {
102 if (!asks(prompt, ASK.rebase)) {
103 return `\`${named}\` refused: the user didn't ask for a rebase this turn. Don't rebase; if a push is rejected, report it and ask.`
104 }
105 }
106 if (sub === 'commit' && args.includes('--amend') && !asks(prompt, ASK.amend)) {
107 return `\`${named}\` refused: the user didn't ask to amend this turn. Make a new commit instead.`
108 }
109 if (sub === 'reset' && args.includes('--hard') && !asks(prompt, ASK.resetHard)) {
110 return `\`${named}\` refused: \`git reset --hard\` throws away work and the user didn't ask for it this turn.`
111 }
112 if (sub === 'clean' && (args.includes('--force') || short(args, 'f')) && !asks(prompt, ASK.clean)) {
113 return `\`${named}\` refused: \`git clean -f\` deletes untracked files and the user didn't ask for it this turn.`
114 }
115 const dropsAll = (sub === 'checkout' && args.includes('.')) ||
116 (sub === 'restore' && args.includes('.') && !(args.includes('--staged') && !args.includes('--worktree')))
117 if (dropsAll && !asks(prompt, ASK.discard)) {
118 return `\`${named}\` refused: it discards every uncommitted change and the user didn't ask for that this turn.`
119 }
120 if (isBroadAdd(call) && !asks(prompt, ASK.all)) {
121 return `\`${named}\` refused: the user didn't say to commit "all". Stage explicit paths (\`git add <file> ...\`) for the files you changed for this task.`
122 }
123 return null
124}
125
126/** `git add -A/--all/./:/`, `git commit -a`: stages everything. */
127export function isBroadAdd({ sub, args }: GitCall): boolean {
128 if (sub === 'add') return args.some(a => a === '-A' || a === '--all' || a === '.' || a === ':/' || a === '*' || /^-[a-zA-Z]*A[a-zA-Z]*$/.test(a))
129 if (sub === 'commit') return args.includes('--all') || short(args, 'a')
130 return false
131}
132
133// ---- Docs that shouldn't ride along in a commit -----------------------------
134
135const DOC_EXT = /\.(md|mdx|txt|rst|adoc)$/i
136
137/** A path (from the repo root) that looks like a plan, spec, handover, ADR or agent note. */
138export function isDocPath(path: string): boolean {
139 const p = path.replace(/^\.\//, '')
140 const base = p.split('/').pop() ?? p
141 if (/^docs?\//i.test(p)) return true
142 if (/(^|\/)(plans|specs)\//i.test(p) && DOC_EXT.test(p)) return true
143 if (/(^|\/)(adrs?|decisions)\//i.test(p) && DOC_EXT.test(p)) return true
144 if (/^adr[-_ ]?\d+/i.test(base)) return true
145 if (/handover/i.test(base)) return true
146 return /^(CLAUDE|AGENTS)(\.local)?\.md$/i.test(base)
147}
148
149/** Paths this commit's docs check should look at besides the index. */
150export function addedPaths(calls: readonly GitCall[], commitAt: number): { paths: string[]; broad: boolean; all: boolean } {
151 const paths: string[] = []
152 let broad = false
153 const VALUED = new Set(['-m', '--message', '-F', '--file', '-C', '-c', '--author', '--date', '-t', '--template', '--fixup', '--squash', '--reuse-message', '--reedit-message', '--pathspec-from-file'])
154 calls.forEach((call, i) => {
155 if (i > commitAt) return
156 if (call.sub === 'add' && i < commitAt) {
157 if (isBroadAdd(call)) broad = true
158 else paths.push(...call.args.filter(a => !a.startsWith('-')))
159 }
160 if (i === commitAt) {
161 for (let k = 0; k < call.args.length; k += 1) {
162 const a = call.args[k]!
163 if (VALUED.has(a)) k += 1
164 else if (!a.startsWith('-')) paths.push(a)
165 }
166 }
167 })
168 const commit = calls[commitAt]
169 return { paths, broad, all: commit !== undefined && isBroadAdd(commit) }
170}
171
172/** `prefix` + `path`, with `.` and `..` folded: a repo-root path. */
173export function fromRoot(prefix: string, path: string): string {
174 const parts: string[] = []
175 for (const part of `${prefix}${path}`.split('/')) {
176 if (part === '' || part === '.') continue
177 if (part === '..') parts.pop()
178 else parts.push(part)
179 }
180 return parts.join('/')
181}
182
183// ---- Attribution --------------------------------------------------------------
184
185/**
186 * The command with Claude's Co-Authored-By trailer and "Generated with Claude
187 * Code" line taken out of a commit or PR message. Quotes are left in place.
188 */
189export function stripAttribution(command: string): string {
190 if (!/\bgit\b[\s\S]*\bcommit\b|\bgh\s+pr\s+(create|edit)\b/.test(command)) return command
191 const out = command
192 .replace(/[ \t]+-m[ \t]*(["'])\s*Co-Authored-By:[^"'\n]*(claude|anthropic)[^"'\n]*\1/gi, '')
193 .replace(/[ \t]+-m[ \t]*(["'])\s*(🤖\s*)?Generated with \[?Claude Code[^"'\n]*\1/gi, '')
194 .replace(/^[ \t]*Co-Authored-By:[^\n"']*(claude|anthropic)[^\n"']*/gim, '')
195 .replace(/^[ \t]*(🤖[ \t]*)?Generated with \[?Claude Code[^\n"']*/gim, '')
196 return out === command ? command : out.replace(/\n{3,}/g, '\n\n')
197}
198hooks/shell.ts 189 lines1// A shell command line read well enough to find the commands in it: quotes,
2// escapes, separators (&& || ; | & newline), ( ) subshells and `cd`. Heredoc
3// bodies are cut out first; $( ... ) stays inside its word. Not a full shell.
4
5export type Command = {
6 /** The words, unquoted, with redirects, VAR=value, sudo and the like removed. */
7 words: string[]
8 /** Where a `cd`/`pushd` earlier on the line moved to; null = the session folder. */
9 dir: string | null
10}
11
12const HEREDOC = /<<-?[ \t]*(['"]?)([A-Za-z_]\w*)\1([^\n]*)\n([\s\S]*?)\n[ \t]*\2[ \t]*(?=\n|$)/g
13
14/** The command with heredoc bodies removed, and the bodies. */
15export function stripHeredocs(input: string): { text: string; bodies: string[] } {
16 const bodies: string[] = []
17 const text = input.replace(HEREDOC, (_m, q: string, tag: string, rest: string, body: string) => {
18 bodies.push(body)
19 return `<<${q}${tag}${q}${rest}`
20 })
21 return { text, bodies }
22}
23
24type Raw = { words: string[]; depth: number }
25
26function split(input: string): Raw[] {
27 const out: Raw[] = []
28 let words: string[] = []
29 let word = ''
30 let inWord = false
31 let depth = 0
32 let segDepth = 0
33 const endWord = () => {
34 if (inWord) words.push(word)
35 word = ''
36 inWord = false
37 }
38 const endSeg = () => {
39 endWord()
40 if (words.length > 0) out.push({ words, depth: segDepth })
41 words = []
42 segDepth = depth
43 }
44 for (let i = 0; i < input.length; i += 1) {
45 const c = input[i]!
46 if (c === '\\') {
47 if (i + 1 < input.length && input[i + 1] !== '\n') {
48 word += input[i + 1]
49 inWord = true
50 }
51 i += 1
52 } else if (c === "'") {
53 const j = input.indexOf("'", i + 1)
54 const end = j < 0 ? input.length : j
55 word += input.slice(i + 1, end)
56 inWord = true
57 i = end
58 } else if (c === '"') {
59 let j = i + 1
60 while (j < input.length && input[j] !== '"') {
61 if (input[j] === '\\' && j + 1 < input.length && '"\\$`'.includes(input[j + 1]!)) {
62 word += input[j + 1]
63 j += 2
64 } else {
65 word += input[j]
66 j += 1
67 }
68 }
69 inWord = true
70 i = j
71 } else if (c === '$' && input[i + 1] === '(') {
72 let j = i + 2
73 let d = 1
74 while (j < input.length && d > 0) {
75 if (input[j] === '(') d += 1
76 else if (input[j] === ')') d -= 1
77 j += 1
78 }
79 word += input.slice(i, j)
80 inWord = true
81 i = j - 1
82 } else if (c === '#' && !inWord) {
83 while (i + 1 < input.length && input[i + 1] !== '\n') i += 1
84 } else if (c === ' ' || c === '\t') {
85 endWord()
86 } else if (c === '\n' || c === ';') {
87 endSeg()
88 } else if (c === '&' && (input[i - 1] === '>' || input[i + 1] === '>')) {
89 word += c
90 inWord = true
91 } else if (c === '&' || c === '|') {
92 if (input[i + 1] === c) i += 1
93 endSeg()
94 } else if (c === '(' && !inWord) {
95 endSeg()
96 depth += 1
97 segDepth = depth
98 } else if (c === ')') {
99 endSeg()
100 depth = Math.max(0, depth - 1)
101 segDepth = depth
102 } else {
103 word += c
104 inWord = true
105 }
106 }
107 endSeg()
108 return out
109}
110
111const PREFIXES = new Set(['command', 'exec', 'env', 'nohup', 'time', 'then', 'do', 'else', '!', 'builtin'])
112const SUDO_VALUE = new Set(['-u', '-g', '-C', '-D', '-h', '-p', '-r', '-t', '-T', '-U'])
113const REDIRECT = /^(\d*|&)(>>?|<<?-?|<)/
114
115function normalize(input: readonly string[]): string[] {
116 const words: string[] = []
117 for (let i = 0; i < input.length; i += 1) {
118 const w = input[i]!
119 const m = REDIRECT.exec(w)
120 if (m) {
121 if (m[0] === w) i += 1 // `> file`: drop the target too
122 continue
123 }
124 words.push(w)
125 }
126 for (;;) {
127 const first = words[0]
128 if (first === undefined) break
129 if (/^[A-Za-z_]\w*=/.test(first) || PREFIXES.has(first)) {
130 words.shift()
131 } else if (first === 'sudo' || first === 'doas') {
132 words.shift()
133 while (words[0]?.startsWith('-')) {
134 const opt = words.shift()!
135 if (SUDO_VALUE.has(opt)) words.shift()
136 }
137 } else if (first === 'nice') {
138 words.shift()
139 if (words[0] === '-n') words.splice(0, 2)
140 else if (/^-\d+$/.test(words[0] ?? '')) words.shift()
141 } else {
142 break
143 }
144 }
145 return words
146}
147
148export function joinDir(dir: string | null, arg: string | undefined): string {
149 if (arg === undefined || arg === '~' || arg.startsWith('/') || arg.startsWith('~/')) return arg ?? '~'
150 return dir === null ? arg : `${dir}/${arg}`
151}
152
153/** Every simple command on the line, in order, each with the folder it runs in. */
154export function commands(line: string): Command[] {
155 const raws = split(stripHeredocs(line).text)
156 const out: Command[] = []
157 let dir: string | null = null
158 let depth = 0
159 const scopes: (string | null)[] = []
160 const pushed: (string | null)[] = []
161 for (const raw of raws) {
162 while (depth < raw.depth) {
163 scopes.push(dir)
164 depth += 1
165 }
166 while (depth > raw.depth) {
167 dir = scopes.pop() ?? null
168 depth -= 1
169 }
170 const words = normalize(raw.words)
171 const [cmd, arg] = words
172 if (cmd === 'cd') {
173 dir = arg === '-' ? '-' : joinDir(dir, arg)
174 } else if (cmd === 'pushd') {
175 pushed.push(dir)
176 dir = joinDir(dir, arg)
177 } else if (cmd === 'popd') {
178 dir = pushed.length > 0 ? (pushed.pop() ?? null) : '-'
179 }
180 if (words.length > 0) out.push({ words, dir })
181 }
182 return out
183}
184
185/** The program's bare name: `/usr/bin/git` and `\git` are `git`. */
186export function program(word: string | undefined): string {
187 return (word ?? '').replace(/^\\/, '').replace(/^.*\//, '')
188}
189types/index.d.ts 8 lines1export type GitGuardPrompt = string
2
3declare module 'claude-code' {
4 interface PluginState {
5 'git-guard': { prompt: GitGuardPrompt; paused: boolean }
6 }
7}
8