krishpunjabi.com ~/skills

← all commands

/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).

  • author Krish
  • 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 --plugin it reads agent_type from the hook input and enforces only for implementer/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_type match: 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>; bare git push / git push <remote> HEAD when the current branch is readable and not protected.
    • Any other push option (--tags, --repo, -o, --receive-pack, …), a refs/… 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).
  • gh is read-only except gh 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, gist and codespace (all), run rerun/cancel/delete, release/secret/variable/workflow/cache/ruleset writes, 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 5 is edit), 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 api is GET only: a non-GET method, or -f/-F/--input without -X GET (they imply POST), blocks; gh api graphql allows only a literal read query (no mutation).
  • curl/wget/iwr/irm to any github.com host (github.com, api.github.com, uploads.github.com) or /api/v3/: GET/HEAD only; any URL naming git-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 or git-* 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|delete and hub pr|issue|release|gist writes (hub is read as git too), git town ship|sync|propose, gt/graphite submit|ss|merge|sync|land, ghstack (bare = submit) land|submit, Sapling sl push|pr|ghstack (Bash only; PowerShell sl is Set-Location), spr/git spr land|update|diff|merge, git machete traverse|github|gitlab, jj git push, arc land|diff, and gh-pages (bin or npx gh-pages) on every run.
    • hub status, gt log, sl log, git town status, git machete status, hub pr list pass.
    • 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/upstream or 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, xargs feeding a shell or interpreter, PowerShell (expr) arguments (git push origin ('ma'+'in')), [Diagnostics.Process]::Start/ProcessStartInfo naming 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, coproc and trap '…' are unwrapped and the inner command read as usual, so uv run pytest and uv run git push origin krish/x pass.
  • a shell or ./source reading its script from /dev/stdin, /dev/fd/N or /proc/self/fd/N is a stdin shell (bash /dev/stdin <<< 'git push …').
  • write-then-run: a command that writes a file (>/>>, tee, PowerShell Set-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 … EOF and git commit -F - <<EOF stay allowed.
  • a shell, iex or interpreter reading a pipe needs every producer upstream (incl. { …; } and ( … ) groups) to be a literal it reads: echo/Write-Output, printf without % or \x/\NNN escapes, echo without -e escapes, a cat here-doc, a PowerShell string.
    • Anything else (rev, tr, sed, printf '\x67it …', Get-Content x | iex) blocks; echo hi | sh passes.
    • cat x.sh | bash reads like bash x.sh.
  • 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, ${…}/#{…}, \x escapes, split() blocks; subprocess.run(['pytest','-q']) and console.log('a'+'b') pass.
  • curl -K/--config and wget -i/--input-file/-e/--execute/--config read options or URLs from a file: blocked when the command names GitHub or gives no URL.
  • PowerShell/.NET: Net.WebClient .Upload*, HttpClient Put/Post/Patch/Delete/Send, WebRequest, in a command naming git/gh/github.com; Add-Type, -ComObject, [Activator]::CreateInstance with 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 … | sh installers).
  • inline code that spawns a process and builds strings (subprocess.check_output([...]).decode() in python -c or python - <<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 with shlex (POSIX).
  • It recurses into:
    • bash/sh/zsh -c
    • pwsh/powershell -Command and -EncodedCommand
    • cmd [/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 … or Start-Process git "push …" (and saps/start)
    • $(...) and backticks
    • echo … | sh / bash <<EOF / bash <<< '…' (a shell reading stdin, incl. busybox sh)
    • find -exec/-execdir/-ok … ; and watch ….
  • 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 and git.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's git config alias.*, and a git config alias.x … written earlier in the command) and shell aliases defined in the command (Bash alias g=git, PowerShell Set-Alias/New-Alias/sal/nal), expanding them on use.
  • It reads --git-dir/--work-tree and GIT_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/x and /cygdrive/c/x map to C:\x on Windows), and PowerShell Set-Location/sl/chdir/pushd/Push-Location (-Path/-LiteralPath); for a PowerShell tool 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/wsl inside a PowerShell call as POSIX, pwsh -Command inside a Bash call as PowerShell (the outer mode is restored after).
  • It tracks git checkout/switch <branch> (incl. -t origin/main → main, and main --), git symbolic-ref HEAD refs/heads/<b> and git branch -m <b> earlier in a chain as the new current branch.

Blocked:

  • git push with --force* (incl. --force-with-lease=…, --force-if-includes), a short-flag group containing f or d (-f, -uf, -d), --mirror, --delete, --all/--branches, --prune
  • a refspec starting with + (force, any branch), a :branch delete refspec, a wildcard refspec, or one whose destination is protected: main, HEAD:main, x:refs/heads/main, refs/heads/main, DWIM HEAD: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) or HEAD/$(git branch --show-current) as refspec while the current branch is protected — or while it can't be determined (unreadable cd/-C path, detached HEAD, git checkout/switch to 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=…, the GIT_CONFIG_PARAMETERS/GIT_CONFIG_COUNT+GIT_CONFIG_KEY_n/VALUE_n env forms, (also set via env, export or PowerShell $env:)
  • a persisted git config write (any scope, --add/--replace-all/--file, or git config set) of push.default matching|upstream|tracking, remote.*.push, remote.*.mirror true, branch.*.merge naming a protected or unreadable branch, include.path, an unreadable key, or a rename-section into remote.*/branch.*: it would redirect a later, separate git 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, $c run 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_TREE repo 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 names git push/gh pr merge/gh api … merge or 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 to api.github.com
  • curl/wget/Invoke-WebRequest/iwr/Invoke-RestMethod/irm writes (non-GET method, or -d/--data*/-F/--json/-T/--post-data/-Body/-InFile) to any github.com host (or /api/v3/), and any request to a git-receive-pack URL
  • a refspec it can't read ($BRANCH, $(…), xargs- or find {}-supplied): spell the branch name out
  • any gh command outside the allowlist above, and any non-GET gh api call; the older targeted rules still apply and give specific reasons: gh api on pulls/N/merge or merges (any method), writes (-X/--method non-GET, or -f/-F fields) to git/refs/heads/<protected> or branches/<protected>/protection (endpoint // and /./ collapsed first, so refs/heads//main and branches/./main/protection count; a ref with a .. segment or %-escape is unreadable, so a write to it is blocked), a write to repos/o/r/contents/<path> unless an explicit non-protected branch= field is given (no branch= commits to the default branch, and --input <file> or -F branch=@file hides the branch, so all are blocked; -f/-F/--field/--raw-field, attached -fbranch=…, and --field=branch=… are all read), DELETE repos/o/r, and graphql mergePullRequest/ref/repo-delete mutations
  • git branch -D/-d/--delete <protected>
  • an unparseable command (unbalanced quotes) that mentions push alongside 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/x
  • git push -u origin HEAD on a feature branch
  • git commit -m "fix main push"
  • git log main..HEAD
  • gh pr view 5
  • gh api repos/o/r/pulls/5
  • gh api repos/o/r/contents/x (GET)
  • gh api graphql -f query='query{…}'
  • gh pr create … --body "$(cat <<'EOF' … EOF)"
  • gh issue view/list
  • gh pr checks/diff
  • gh run view/list
  • gh project item-list
  • git stash push
  • git config --get/get/--list/list
  • git config branch.krish/x.merge refs/heads/krish/x
  • git -c push.default=matching status
  • eval "$(ssh-agent -s)"
  • PowerShell & $env:ComSpec /c dir
  • & $exe --version
  • cd C:\x; git status
  • Git Bash cd /c/Users/me/feat && git push -u origin HEAD on a feature branch
  • python scripts/x.py
  • python -c "print(1)"
  • node -e "console.log(1)"
  • GET curl https://api.github.com/repos/o/r/pulls
  • base64 -d x > out.bin
  • echo git push origin main is allowed on purpose: the words are arguments to echo, nothing runs them (piped into sh, 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, py for 3.8+ (stdin from /dev/null, so the hook JSON stays unread) and execs 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 x would 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 python3 stub 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-slim and python:3.8-slim (dash; push to main → 2, feature push → 0) and debian:bookworm-slim (no Python → 2).
  • Windows needs Git Bash. Without it, Claude Code falls back to PowerShell, which can't parse the sh command; 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, then sh/bash on PATH (WSL's System32\bash.exe doesn't count), then bash.exe in the Git for Windows install; install-skills.sh: command -v sh) and exit non-zero if it's missing.
  • -NoShCheck / --no-sh-check installs 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 Python requests script 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 --plugin agent 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)
Blocked: gh pr merge is a write; guarded agents get read-only gh + pr create; card moves, comments and labels belong to the caller.
Bash(git push origin main) (+1 line)
Blocked: push to protected branch 'main' is not allowed.

Sample workflows

Source

Use cases & tips from the community