/guard-git
/guard-git hooks
PreToolUse hook that stops the implementer and manager agents from pushing to protected branches, force pushing, merging PRs or writing other remote state. PreToolUse hook that stops the implementer and manager agents from pushing to protected branches, force pushing, merging PRs or writing other remote state. It runs on Bash|PowerShell, is wired in their frontmatter, and exits 2 (blocks, reason on stderr) or 0 (allows).
- invocation PreToolUse hook
- source agents/hooks/guard-git.py
- tested used daily
- suggested model · effort haiku · low
Install
Comes with every agent install and is wired in the agents' own frontmatter; this copies it on its own, from a clone of the repo.
./scripts/install-skills.sh --skill guard-git # bash, Git Bash, macOS, Linux
.\scripts\install-skills.ps1 -Skill guard-git # Windows PowerShell
What it does
PreToolUse hook that stops the implementer and manager agents from pushing to protected branches, force pushing, merging PRs or writing other remote state. It runs on Bash|PowerShell, is wired in their frontmatter, and exits 2 (blocks, reason on stderr) or 0 (allows).
As a plugin. Claude Code ignores hooks frontmatter in plugin agents, so the plugin wires the same command once, plugin-wide, in .claude-plugin/plugin.json, with --plugin.
- That hook fires in every session. With
--pluginit readsagent_typefrom the hook input and enforces only forimplementer/manager(bare or<plugin>:-prefixed); the main session and other agents pass through (exit 0). - Without Python 3.8+ the plugin command can't run guard-git. It then greps the hook input for the same
agent_typematch: those two agents are blocked (exit 2) and everything else passes (exit 0). A missing Python never locks your own session's Bash.
It is a guardrail, not a sandbox. It stops an agent's honest mistakes and the obvious workarounds.
- It can't see inside script files, shell profile aliases/functions, or code built at runtime.
- Turn on GitHub branch protection on your default branch (require a PR, required checks, no force-push, no deletion): that is the server-side backstop for anything a local hook can't read.
Fail-closed allowlist (#174). After the parsing below, every piece of the command that could push, merge, delete or write remote state must match a known-safe form, or the command is blocked:
git push [-u|--set-upstream|-v|-q|-n|--dry-run|--porcelain|--no-verify] <remote> <branch>[:<same branch>]with a literal, non-protected branch name;HEAD:<branch>; baregit push/git push <remote> HEADwhen the current branch is readable and not protected.- Any other push option (
--tags,--repo,-o,--receive-pack, …), arefs/…or reflog source, a different source branch, or a remote it can't read blocks. - A tag push blocks too:
git push origin tag v1, a name that is a local tag but not a local branch, one tagged earlier in the command (git tag x && git push origin x), or a version-like name (v1.0,3.1) with no local branch of that name (a tag fetched in the same command isn't visible yet).
- Any other push option (
ghis read-only exceptgh pr create(#191; any repo/head/base/title/body flags):pr create/new/view/list/checks/diff/status/checkout,issue view/list/status,repo view/list/clone,release view/list/download,workflow view/list,run view/list/watch/download,label list,project view/list/field-list/item-list,cache list,ruleset view/list/check,secret list,variable list/get,org list,alias list,extension list/search/browse,auth status/token,config get/list,search,status,browse --no-browser,help,version.- Everything else blocks: every issue/label/project write,
gistandcodespace(all),run rerun/cancel/delete,release/secret/variable/workflow/cache/rulesetwrites,pr merge/close/reopen/ready/edit/comment/review/lock/revert/update-branch,repo create/fork/edit/delete/archive/rename/set-default/sync/deploy-key,alias set, and any unknown top-level command (a gh alias or extension can't be read). - The subcommand is read both literally and the way gh's parser finds it (an unknown flag before it takes the next word as its value:
gh issue --add-label view edit 5isedit), blocking if either is a write. - Card moves, issue comments and labels belong to the caller (orchestrator,
pair, verifier; none is guarded): implementer and manager list them in their handoff. gh apiis GET only: a non-GET method, or-f/-F/--inputwithout-X GET(they imply POST), blocks;gh api graphqlallows only a literal read query (nomutation).
curl/wget/iwr/irmto anygithub.comhost (github.com,api.github.com,uploads.github.com) or/api/v3/: GET/HEAD only; any URL naminggit-receive-pack(smart-HTTP push) blocks whatever the method.- local git (add, commit, branch, merge, rebase, stash, worktree, fetch, pull, checkout/switch, tag, …) stays allowed. These block:
git send-pack/http-push, and an unknown git subcommand orgit-*plugin that names push/merge/sync/delete/ship/land/submit/publish/deploy/traverse/propose (git subtree push,git ship,git-ship origin main). Commands git runs for you (rebase -x,submodule foreach,bisect run,-c key=<value>) are read like any other command. - third-party push/merge/PR tools (#202) block their remote writes, reads pass:
hub push|api|merge|fork|create|pull-request|sync|deleteandhub pr|issue|release|gistwrites (hub is read as git too),git town ship|sync|propose,gt/graphite submit|ss|merge|sync|land,ghstack(bare = submit)land|submit, Saplingsl push|pr|ghstack(Bash only; PowerShellslis Set-Location),spr/git spr land|update|diff|merge,git machete traverse|github|gitlab,jj git push,arc land|diff, andgh-pages(bin ornpx gh-pages) on every run.hub status,gt log,sl log,git town status,git machete status,hub pr listpass.- Any other program whose arguments pair a push-like verb (push/ship/land/submit/sync/merge/publish/deploy/traverse/propose) with a protected branch,
origin/upstreamor a git URL blocks (mytool ship origin main); image tools (docker push,helm,kubectl, …) are exempt.
- unreadable code that could run a push fails closed: Bash
function f { … }wrappers, a shell running<(…),script -c,xargsfeeding a shell or interpreter, PowerShell(expr)arguments (git push origin ('ma'+'in')),[Diagnostics.Process]::Start/ProcessStartInfonaming git/gh, and inline interpreter code (deno eval,node -e, …) that mentions push/merge next to a process call or a git/gh word. - backstop for runners it doesn't model. A command blocks when both are true:
- its first word isn't git/gh, a shell, an interpreter, an HTTP client, a handled wrapper or a reader (
echo,printf,grep,rg,cat,ls,test,Write-Host,Select-String, …); - its arguments name a push/merge-capable command (
git … push|send-pack|http-push|subtree|receive-pack,gh … pr merge|api|alias|repo, a gh write as classified above,api.github.com,git-receive-pack). - Examples:
parallel git push …,awk 'BEGIN{system("git push …")}',sed '1e git push …',lua -e,Rscript -e. uv run/poetry run/pipenv run,npm exec/npx(incl.-c '…'),flock(incl.-c),strace/ltrace,coprocandtrap '…'are unwrapped and the inner command read as usual, souv run pytestanduv run git push origin krish/xpass.
- its first word isn't git/gh, a shell, an interpreter, an HTTP client, a handled wrapper or a reader (
- a shell or
./sourcereading its script from/dev/stdin,/dev/fd/Nor/proc/self/fd/Nis a stdin shell (bash /dev/stdin <<< 'git push …'). - write-then-run: a command that writes a file (
>/>>,tee, PowerShellSet-Content/Add-Content/Out-File) and runs that same file (sh x.sh,./x.sh,. x.sh,pwsh -File x.ps1,python x.py) blocks when its text names a push/merge-capable command or a gh write. - PowerShell splatting into git/gh (
git @a) can't be read and blocks. - stdin fed to a command that may run it: a here-doc, here-string or pipe into anything that isn't a shell or a stdin data consumer (
cat,grep,jq,sort,tee,xargs, git/gh,python script.py, …) blocks when the fed text names a push/merge-capable command (make -f - <<EOF,just --justfile /dev/stdin <<<,at now <<<,batch,tclsh,ssh host <<EOF,… | crontab -) or comes from a transformer (rev … | at now).cat > notes.txt <<'EOF' … git push … EOFandgit commit -F - <<EOFstay allowed.
- a shell,
iexor interpreter reading a pipe needs every producer upstream (incl.{ …; }and( … )groups) to be a literal it reads:echo/Write-Output,printfwithout%or\x/\NNNescapes,echowithout-eescapes, acathere-doc, a PowerShell string.- Anything else (
rev,tr,sed,printf '\x67it …',Get-Content x | iex) blocks;echo hi | shpasses. cat x.sh | bashreads likebash x.sh.
- Anything else (
- inline interpreter code (
-c/-e/eval/stdin) that spawns or evals (subprocess,os.system,exec(,eval(,child_process,spawn,system(,__import__,getattr(, Ruby/Perl backticks) and builds strings at runtime ('g'+'it','g'.'it',join,bytes.fromhex,chr(,String.fromCharCode,decode,atob,Buffer.from,[::-1],reverse,replace(,format/f-strings,${…}/#{…},\xescapes,split() blocks;subprocess.run(['pytest','-q'])andconsole.log('a'+'b')pass. curl -K/--configandwget -i/--input-file/-e/--execute/--configread options or URLs from a file: blocked when the command names GitHub or gives no URL.- PowerShell/.NET:
Net.WebClient.Upload*,HttpClientPut/Post/Patch/Delete/Send,WebRequest, in a command naming git/gh/github.com;Add-Type,-ComObject,[Activator]::CreateInstancewith a.Run/.Exec/ShellExecute/Start/::Method(call or a git/gh/GitHub name. cmd /c:%x%and!x!expand at run time, so in a command running cmd they are unreadable (cmd /c set x=main ^& git push origin %x%blocks).
Commands that can't push or merge (pytest, uv, npm, npx, python/node scripts, file edits) are not on the risky surface and pass untouched.
Failing closed has known false positives:
- an unmodelled tool whose arguments merely mention
git push(sed -i 's/git push/…/' README.md; use the editor tool). - a shell fed by a command it can't read (
curl … | shinstallers). - inline code that spawns a process and builds strings (
subprocess.check_output([...]).decode()inpython -corpython - <<EOF; put it in a script file).
Parsing. Stdlib Python 3.8+.
- Splits the command on newlines,
;,&&,||,&,|and( ), respecting quotes and dropping# comments; tokenizes each piece withshlex(POSIX). - It recurses into:
bash/sh/zsh -cpwsh/powershell -Commandand-EncodedCommandcmd [/d] /c(dropping^escapes)wsl [-e] …eval- PowerShell
Invoke-Expression/iex(incl.'…' | iex) Invoke-Command/icm { … }& { … }. { … }& ("git") …- any
{ … }scriptblock (% { … },try { … },Start-Job { … }) Start-Process git -ArgumentList …orStart-Process git "push …"(andsaps/start)$(...)and backticksecho … | sh/bash <<EOF/bash <<< '…'(a shell reading stdin, incl.busybox sh)find -exec/-execdir/-ok … ;andwatch ….
iex (…)of a literal string concatenation ('git push ' + 'origin main') is evaluated and read.- Here-doc bodies fed to a stdin data consumer (
cat,git commit -F -,gh … --body-file -,jq) are data, so a commit message that says "push to main" is fine. - Bash brace expansion is applied to every word (
ma{i,}n→main man,{a..c}; quoted braces too, conservatively; over 64 results → unreadable). - Before the git subcommand it skips
VAR=x,env(incl.-S),command,sudo,timeout,time -p,setsid,nohup,chronic,xargs, full paths andgit.exe, and git global options (-C dir,-c k=v,--git-dir,--work-tree,--no-pager, …). - It resolves git aliases (
-c alias.p=push, the user'sgit config alias.*, and agit config alias.x …written earlier in the command) and shell aliases defined in the command (Bashalias g=git, PowerShellSet-Alias/New-Alias/sal/nal), expanding them on use. - It reads
--git-dir/--work-treeandGIT_DIR/GIT_WORK_TREE(per command,export,env, PowerShell$env:) and checks that repo's current branch for a bare push. - It follows the working directory across
cd,git -C(Git Bash/c/xand/cygdrive/c/xmap toC:\xon Windows), and PowerShellSet-Location/sl/chdir/pushd/Push-Location(-Path/-LiteralPath); for aPowerShelltool call it keeps Windows backslash paths (C:\x\repo) and treats a trailing backtick as a line continuation, not a dynamic refspec. - Each nested shell is read in its own language:
bash -c/sh/wslinside a PowerShell call as POSIX,pwsh -Commandinside a Bash call as PowerShell (the outer mode is restored after). - It tracks
git checkout/switch <branch>(incl.-t origin/main→main, andmain --),git symbolic-ref HEAD refs/heads/<b>andgit branch -m <b>earlier in a chain as the new current branch.
Blocked:
git pushwith--force*(incl.--force-with-lease=…,--force-if-includes), a short-flag group containingford(-f,-uf,-d),--mirror,--delete,--all/--branches,--prune- a refspec starting with
+(force, any branch), a:branchdelete refspec, a wildcard refspec, or one whose destination is protected:main,HEAD:main,x:refs/heads/main,refs/heads/main, DWIMHEAD:heads/main,refs/heads//main(case-insensitive); a destination of@{-1}-style or- git send-pack/git http-push(any form; not on the allowlist)- a push with no refspec (
git push,git push origin) orHEAD/$(git branch --show-current)as refspec while the current branch is protected — or while it can't be determined (unreadablecd/-Cpath, detached HEAD,git checkout/switchto an unknown or protected name earlier in the chain): a bare/implicit push fails closed - a push carrying a config override that can redirect it to another branch:
-c push.default=matching|upstream|tracking,-c remote.*.push=…,-c remote.*.mirror=true,--config-env=…, theGIT_CONFIG_PARAMETERS/GIT_CONFIG_COUNT+GIT_CONFIG_KEY_n/VALUE_nenv forms, (also set viaenv,exportor PowerShell$env:) - a persisted
git configwrite (any scope,--add/--replace-all/--file, orgit config set) ofpush.default matching|upstream|tracking,remote.*.push,remote.*.mirror true,branch.*.mergenaming a protected or unreadable branch,include.path, an unreadable key, or arename-sectionintoremote.*/branch.*: it would redirect a later, separategit push, so the write itself is blocked - code it can't read, when the command mentions push/merge/delete:
eval $c,iex $c,iex (expr)that isn't a literal concatenation,$crun as a command,& $sb,. $sb,& (expr),Invoke-Command $sb,Start-Job $sb,$sb.Invoke(),[scriptblock]::Create(…).Invoke(),InvokeScript(…)(fail closed) - an unreadable git subcommand (
git $a origin main,git $(…)), a git alias set to an unreadable value, or a bare push in a--git-dir/GIT_DIR/GIT_WORK_TREErepo that is on a protected branch or can't be read - a
git config alias.*write whose value mentions push/merge/delete (any scope) - base64/hex-decoded text (
base64 -d,xxd -r,openssl … -d,certutil -decode,[Convert]::FromBase64String) reaching a stdin shell,eval,iex,sh -c "$(…)"or inline interpreter code (fail closed, no risky word needed) - inline interpreter code (
python/python3/py -c,node -e/--eval/-p,perl -e/-E,ruby -e,php,bun,deno, or one reading stdin) that namesgit push/gh pr merge/gh api … mergeor a gh write (subprocess.run(['gh','issue','close','5'])), mentions push/merge next to a process call (os.system,subprocess,exec,child_process, backticks), or writes toapi.github.com curl/wget/Invoke-WebRequest/iwr/Invoke-RestMethod/irmwrites (non-GET method, or-d/--data*/-F/--json/-T/--post-data/-Body/-InFile) to anygithub.comhost (or/api/v3/), and any request to agit-receive-packURL- a refspec it can't read (
$BRANCH,$(…),xargs- orfind {}-supplied): spell the branch name out - any
ghcommand outside the allowlist above, and any non-GETgh apicall; the older targeted rules still apply and give specific reasons:gh apionpulls/N/mergeormerges(any method), writes (-X/--methodnon-GET, or-f/-Ffields) togit/refs/heads/<protected>orbranches/<protected>/protection(endpoint//and/./collapsed first, sorefs/heads//mainandbranches/./main/protectioncount; a ref with a..segment or%-escape is unreadable, so a write to it is blocked), a write torepos/o/r/contents/<path>unless an explicit non-protectedbranch=field is given (nobranch=commits to the default branch, and--input <file>or-F branch=@filehides the branch, so all are blocked;-f/-F/--field/--raw-field, attached-fbranch=…, and--field=branch=…are all read),DELETE repos/o/r, and graphqlmergePullRequest/ref/repo-delete mutations git branch -D/-d/--delete <protected>- an unparseable command (unbalanced quotes) that mentions
pushalongside a protected name or a force/delete marker, and any internal error while analysing a command that mentions push/merge/delete (fail closed)
Allowed (no false positives):
git push origin krish/xgit push -u origin HEADon a feature branchgit commit -m "fix main push"git log main..HEADgh pr view 5gh api repos/o/r/pulls/5gh api repos/o/r/contents/x(GET)gh api graphql -f query='query{…}'gh pr create … --body "$(cat <<'EOF' … EOF)"gh issue view/listgh pr checks/diffgh run view/listgh project item-listgit stash pushgit config --get/get/--list/listgit config branch.krish/x.merge refs/heads/krish/xgit -c push.default=matching statuseval "$(ssh-agent -s)"- PowerShell
& $env:ComSpec /c dir & $exe --versioncd C:\x; git status- Git Bash
cd /c/Users/me/feat && git push -u origin HEADon a feature branch python scripts/x.pypython -c "print(1)"node -e "console.log(1)"- GET
curl https://api.github.com/repos/o/r/pulls base64 -d x > out.binecho git push origin mainis allowed on purpose: the words are arguments toecho, nothing runs them (piped intosh, it's blocked).
Interpreter fallback. The frontmatter command: is POSIX sh (Claude Code runs hook commands with sh -c on macOS/Linux and Git Bash on Windows).
- It probes
python3,python,pyfor 3.8+ (stdin from/dev/null, so the hook JSON stays unread) andexecs the first that passes. - If none does, it prints the reason and exits 2 (the plugin command exits 2 only for guarded agents; see As a plugin).
- A naive
python3 x || python xwould be wrong: a real block (exit 2) from the first would fall through to the second, which reads empty stdin and allows. - The Windows Store
python3stub fails the probe and is skipped. - A missing hook file also exits 2 (Python's "can't open file").
- Tested with Git Bash (
sh,bash,dash),docker run python:3.12-slimandpython:3.8-slim(dash; push to main → 2, feature push → 0) anddebian:bookworm-slim(no Python → 2). - Windows needs Git Bash. Without it, Claude Code falls back to PowerShell, which can't parse the
shcommand; that non-2 exit doesn't block, so the hook fails open and guards nothing. - The install scripts check for it before copying anything (
install-skills.ps1:CLAUDE_CODE_GIT_BASH_PATH, thensh/bashonPATH(WSL'sSystem32\bash.exedoesn't count), thenbash.exein the Git for Windows install;install-skills.sh:command -v sh) and exit non-zero if it's missing. -NoShCheck/--no-sh-checkinstalls anyway, with a warning.
Known limits. The hook reads the command text the agent runs, nothing more.
It doesn't see:
- what a script file does (
./push.sh,python deploy.py) unless the same command wrote it - shell aliases or functions defined in the user's shell profile
- inline interpreter code that builds its command at runtime without a marker it knows (spawn/eval plus concatenation,
join,fromhex,[::-1], … do block) - HTTP writes whose URL hides the host (
curl -X PUT $URL) - other HTTP clients (
httpie, a Pythonrequestsscript file) - a
git config alias.*value pulled in from a file
Unquoted C:\x paths in a Bash call stay unreadable (bash eats the backslashes), so a bare push after one fails closed. GitHub branch protection is the backstop for all of these.
Protected branches: GUARD_PROTECTED_BRANCHES (comma list), default main,master.
- A block is working as intended, not something for the agent to route around.
- Self-test (every case above, current branch injected, plus stdin end-to-end runs and the
--pluginagent filter):python agents/hooks/guard-git.py --self-test.
Example
Example output when a guarded agent tries to merge its own PR, then push to main: the hook exits 2 and prints one line on stderr for each.
Bash(gh pr merge 12 --squash) (+1 line)
Bash(git push origin main) (+1 line)
Sample workflows
- Run the backlog unattended
you → lanes → you → meta-orchestrator → planner → manager → implementer → guard-git → verifier → you → switch → remind → progress-report → you
Install just this one:./scripts/install-skills.sh --workflow unattended-backlogor.\scripts\install-skills.ps1 -Workflow unattended-backlog
Use cases & tips from the community