Repository navigation
chore: block forbidden agent git commands with a PreToolUse hook - #1158
Conversation
One guard script runs before every Bash call in Claude Code and Codex. It blocks gh pr merge, src:dst and + refspecs, force pushes, pushes to main and commits on main, and names the command to run instead.
|
Review round 1 (Codex Every finding was reproduced by feeding the hook JSON directly. Exit 0 means the command was allowed; nothing was actually pushed or merged.
The reviewer confirmed both harness payload contracts against the Claude docs and the Codex source. Both graphify hooks are unchanged. |
A command that mentions a git write or PR merge must be one simple command; the guard refuses other shapes instead of parsing them. Pushes resolve the effective remote, push.default, mirror, remote push refspecs and upstream. Short option clusters and long-option prefixes parse like git; gh api allows GET on the merge endpoint; a malformed Bash payload blocks.
The text check undoes quotes, backslashes and %-escapes before matching, and treats $'...' next to git/gh as a write. Unquoted globs are refused. The merge-endpoint match decodes the path and ignores a query or fragment. rg leaves the inert list because --pre runs a program.
A glob or variable can spell git or the verb (/usr/bin/gi? push, $G push, git $VERB), which the text check could not read. Such commands now take the simple-shape path, which refuses them.
Fixed in The guard no longer tries to parse bash. If a command's text mentions
Table: 127 commands × 2 payload shapes (Claude, Codex): 56 allow, 198 block, identical verdicts for both shapes, plus the malformed-payload, other-tool and switch-then-commit tests. All pass. Mutation check: I disabled each rule and parsing step one at a time. At |
|
Review round 2 (Codex All round 1 repros are closed: 40 commands in both formats plus 11 malformed payloads. The table passes: 127 commands × 2 formats. New findings, each reproduced. The pushes ran only between temporary local repositories, and the GraphQL capture went only to a fake server on this machine.
The false blocks are as designed: 10 of a 38-command sample of everyday reads were refused (variables, globs, and pipes next to |
A Bash payload with a missing, empty or non-string cwd, or a non-string or empty tool_name, now exits 2 instead of falling back to the process directory.
… git guard
A write command may end in exactly 2>&1 or 2>/dev/null; any other output redirection is refused instead of guessing file-descriptor numbers. The text check drops backslash-newline pairs, so a continuation inside git or a verb still counts, and a brace or parenthesis now counts as an expansion (git pu{s..s}h, git p@(u)sh).
… guard git -C paths go to git's own metadata queries unnormalized, so a symlink followed by .. resolves the way git resolves it. cd refuses option operands, any ~ operand, and, when CDPATH is set, an operand that is neither absolute nor ./ or ../ prefixed.
A push with no remote named falls back to a sole configured remote before origin, as git does. The matching refspec ':' blocks, remote.<name>.mirror uses git's boolean rules (anything but an explicit false blocks), and HEAD is read as its full ref so a tag named main cannot hide the branch.
The endpoint is parsed as a URL and its path decoded, so https://api.github.com/graphql and /graphql get the GraphQL merge check and a REST merge path matches with or without a query, fragment or host.
Without them a missing directory blocked the rows too, so removing the operand refusal stayed green.
All nine are fixed or documented. Head is now
Table: 154 commands × 2 payload shapes: 64 allow, 244 block, with identical verdicts. Also passing: 23 malformed payloads, another tool's payload, a Mutation check on the round 2 checks: 16 of 16 mutations turned the table red. The cd operand mutation first stayed green, because a missing directory also blocked those rows;
|
|
Review round 3 (Codex Every round 2 repro is closed, each in both payload formats, and the reviewer confirmed with independent local pushes that the fixtures exercise the real risk. Finding 9 (remote symbolic refs) stays as a documented limit. No new write bypass was found.
Accepted cost, not a fix request: when |
Git reflog and upstream syntax, gh placeholders and quoted jq, format or GraphQL text were refused as possible writes. Brace and extglob spellings of a verb are now a documented limit; backslash-newline handling stays.
Fixed in
Table: 161 commands × 2 payload shapes: 82 allow, 240 block, with identical verdicts. The malformed-payload, other-tool, |
|
Round 3 fix verified at
The review loop stops here. Rounds 1–3 confirmed product defects, and every one is fixed or documented. Round 3's only finding was a regression from the round 2 fix, and it is now reverted. |
Closes #1157
tools/agent-guard/git_guard.pyruns before every Bash call in Claude Code and Codex. It blocks the git and gh commands that the rules already forbid agents to run, and tells the agent what to run instead.How it decides
%-escapes, then asks whether the text mentionsgit … push|commit,gh … merge|alias,mergePullRequestorAutoMerge. Backslash-newline pairs are removed first, so a continuation inside a word still counts. A$, backtick or glob next to git, gh or a write verb also counts, because it can spell the rest (/usr/bin/gi? push,git $VERB,$'\x70ush'). Braces and parentheses do not count, so@{u},HEAD@{1}, gh's{owner}placeholders and quoted jq or GraphQL text pass. If none of these match, the command passes untouched. Almost every non-git command takes this path.cd <dir> &&. Newlines,;,||,|,&, comments, subshells, braces, if/for/while, heredocs,$(…), backticks, variables, unquoted globs, env assignments and wrappers (env, command, timeout, sudo, bash -c) are refused, not parsed. The message names the allowed forms (git push -u origin <branch>,cd <dir> && git commit -m "…",git commit -F <file>for a long message). Output redirections are refused too, except a trailing2>&1or2>/dev/null.cd <dir> &&is followed as bash's defaultcdwould (..by text). An operand that starts with-or~is refused, and so is a bare name whileCDPATHis set (use./nameor an absolute path).git -C <path>is passed to git's own lookups unchanged, so git resolves symlinks itself.gh pr merge; a non-GETgh apicall to…/pulls/<n>/merge; a literalmergePullRequest/…AutoMergeGraphQL query;gh alias set/importsrc:dstrefspec (HEAD:main,:branch)git branch --show-current, thengit push -u origin <that-branch>--force,-f,+refspec,--mirror, including git's abbreviations (--mir)--all,--branches,--prunemain(named,HEAD/@on main, or plaingit pushon main)git switch -c <type>/<topic>branch.<b>.pushRemote,remote.pushDefault,branch.<b>.remote, the sole remote, thenorigin):push.default=matching;remote.<r>.mirrorset to anything but an explicit false; aremote.<r>.pushrefspec that forces, is the matching refspec:, uses a wildcard or targets main; push.default upstream/tracking with an upstream of maingit -c alias.*|push.*|remote.*|branch.*and unknown git global optionsgit commitwithrefs/heads/mainchecked out (read as the full ref, so a tag namedmaincannot hide it)git switch -c <type>/<topic>firstcommand, a missing, empty or non-stringcwd, or a non-string or emptytool_nameIt allows
git push,git push -u origin <current-branch>,--force-with-lease, configured push refspecs to another branch (Paseo PR checkouts useremote.<r>.push = HEAD:refs/heads/<branch>),gh apiGET on the merge endpoint (any form: path, full URL, query string), and quoted text inecho/grep.What it does not see
gh landafter an earliergh alias set land 'pr merge').-F query=@q.graphql).merge,rebase,cherry-pickorpull. Onlygit commitis checked.git pu{s..s}h,git p@(u)sh). Contrived; counting{and(as expansions refused common reads (git log @{u}..HEAD,gh api repos/{owner}/{repo}/…), so the table keeps these two as known allows.refs/heads/livepointing at main). The guard does no remote lookups.exec_commandcalls whoseworkdirdiffers from the sessioncwd. Codex leavesworkdirout of the hook payload, so the branch is read in the sessioncwd.Harness payloads
Both harnesses send one JSON object on stdin with the shell string in
tool_input.commandand the session directory incwd. Both block on exit 2 and pass stderr to the agent.claude -p --settingswith a hook that writes stdin to a file):{"cwd":"/tmp/gg-capture","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"cd sub && git status --short","description":"…"},…}. Exit 2 behavior is in the hooks reference: "Exit with code 2 to block the action … The message that comes with the block is your hook's stderr."--dangerously-bypass-hook-trustplus a temporary hook entry, and the maintainer declined that run. Incodex-rs/core/src/tools/handlers/unified_exec/exec_command.rs,pre_tool_use_payloadsendstool_input: {"command": args.cmd}withtool_nameBash, andhook_runtime.rsbuildsPreToolUseRequestwithcwd,turn_id,modelandtool_use_id. The Codex hooks docs say: "You can also use exit code2and write the blocking reason tostderr."Notes for review
/hooks. The graphify entry keeps its index and hash.git add -A && git commit …) and heredoc commit messages are now refused. The agent re-runs the write as its own call, or writes a long message to a file and uses-F. A git or gh read that uses a variable (git -C "$WT" status) is also refused; that false block is deliberate.classifier-self-testjob (noneeds, never gated) with the runner's Python 3 and git.Tests run locally
python3 tools/agent-guard/git_guard_test.py: 161 commands, each fed in both payload shapes, plus 23 malformed payloads, another tool's payload, aCDPATHcase, and a realgit switch -cfollowed by a separategit commit. All pass.067a42d8: 47 of 48 mutations turned the table red; the survivor is the git-config read-failure check, which a broken config can never reach because the branch lookup fails first.149ca647's wider text check: 7 of 7 red. Round 2 checks at50f9459e: 16 of 16 red (payload fields, redirect suffix,>and bare&, backslash-newline, braces (removed again in09cea059),cdoperands andCDPATH, raw-C, full-ref HEAD, sole-remote fallback, mirror boolean,:refspec, endpoint parsing, GraphQL path). At09cea059, putting{and(back turns 18 subtests red, so the new read rows hold the regression.dotnet testfiltered toTenancyDocsFreshnessTestsandRenameAccountDocsTests(19 passed), plus theImagePinandCommittedSchemaDocs_CarryNoEnvironmentSpecificContenttracked-file guards (3 passed).Proof at
09cea059One blocked command and one allowed push, fed through each harness's configured hook command exactly as written in
.claude/settings.jsonand.codex/hooks.json, with that harness's payload shape (bash -c <hook command>, cwd = worktree,CLAUDE_PROJECT_DIR= worktree).claude, hook command
"$CLAUDE_PROJECT_DIR"/tools/agent-guard/git_guard.pycodex, hook command
"$(git rev-parse --show-toplevel)"/tools/agent-guard/git_guard.pyclaude, hook command
"$CLAUDE_PROJECT_DIR"/tools/agent-guard/git_guard.pycodex, hook command
"$(git rev-parse --show-toplevel)"/tools/agent-guard/git_guard.pyTable test output at
09cea059: 161 commands x 2 payload shapes: {'allow': 82, 'block': 240}, identical verdicts for both shapes'git push''git push -u origin feat/x''git push -u origin HEAD''git push --force-with-lease''git push --force-w origin feat/x''git push -o ci.skip origin feat/x''git push paseo''git push 2>&1''git push origin feat/x 2>/dev/null'"git commit -m 'a > b && c'""git commit -m 'wip'"'git commit -m "subject\n\nbody line"''cd feature && git commit -m x''git -C feature push''git status && git log --oneline -3''git fetch origin main:main'"git log --grep 'push' --oneline"'gh pr create -R mforce/cluckwork --title x''gh pr view 12 --json state''gh api repos/mforce/cluckwork/pulls/1158/merge''gh api -X GET repos/mforce/cluckwork/pulls/1158/merge''gh api -XGET repos/mforce/cluckwork/pulls/1158/merge'"gh api 'repos/mforce/cluckwork/pulls/1158/merge?x=1'""gh api https://api.github.com/graphql -f query='query { viewer { login } }'""echo 'git push --force origin HEAD:main'""echo ';' git push --force""grep -rn 'gh pr merge' docs"'ls -la | wc -l''ls *.py | wc -l''git rev-parse @{upstream}''git log @{u}..HEAD --oneline''git show HEAD@{1}''gh api repos/{owner}/{repo}/pulls/1158''git log --format="%h %s (%cr)" | head -20'"gh pr view 1158 --json number,title --jq '{number, title}' | cat"'gh api graphql -f query='query { repository(owner:"mforce", name:"cluckwork") { name } }' | cat'"gh api 'repos/mforce/cluckwork/pulls?state=open' --jq '.[].number'"'gh pr merge 12 --squash''gh -R mforce/cluckwork pr merge 12''gh api -X PUT repos/mforce/cluckwork/pulls/12/merge''gh api --method=PUT repos/mforce/cluckwork/pulls/12/merge''gh api -XPUT repos/mforce/cluckwork/pulls/12/merge''gh api repos/mforce/cluckwork/pulls/12/merge -f merge_method=squash''gh api graphql -f query='mutation { mergePullRequest(input: {pullRequestId: "PR_x"}) { clientMutationId } }'''gh api https://api.github.com/graphql -f query='mutation { mergePullRequest(input: {pullRequestId: "PR_x"}) { clientMutationId } }''"gh api /graphql -f query='mutation { mergePullRequest(input: {}) { clientMutationId } }'""gh alias set land 'pr merge'"'gh alias set land "pr merge" && gh land 1158''gh alias import aliases.yml'"gh api graphql -f query='mutation { enablePullRequestAutoMerge(input: {}) { clientMutationId } }'"'git push origin HEAD:main''git push origin feat/x:feat/y''git push origin :old-branch''git push --force''git push -f origin feat/x''git push -uf origin feat/x''git push origin +feat/x''git push --mirror''git push --all origin''git push --mir origin''git push --al origin''git push --pru origin''git push --forc origin''git push -ofoo origin HEAD:main''git push origin main''git push origin refs/heads/main''git push --delete origin main''git push origin @''git push''git push -u origin HEAD''git push origin 2>/dev/null''git commit -m x''cd .. && git commit -m x''git -C .. commit -m x''cd new-dir && git push''cd - && git commit -m x''cd -- && git commit -m x''cd -P feature && git commit -m x''cd "~" && git commit -m x''cd ~ && git commit -m x''git -C link/.. commit -m x''git -C missing-dir commit -m x''git commit -m x''git push''git push -u origin feat/t''git push origin feat/t''git push --force-with-lease origin feat/t''git -c remote.origin.push=refs/heads/feat/x:refs/heads/main push -u origin feat/x''git -c remote.origin.push=+refs/heads/feat/x:refs/heads/feat/x push -u origin feat/x''git -c push.default=matching push''git -c remote.origin.mirror=true push''git -c alias.p=push p''git --config-env=push.default=MODE push''git -c remote.origin.pushurl=../other.git push''git push backup''git push mirror''git push''git push''git push matchall''git push mirror2''git push nomirror''git commit -m x''git push''git status &&\ngit push origin HEAD:main''git status;\ngh pr merge 1158''git status\n\ngh pr merge 1158''git status # inspect first\ngit push --force''if true; then git push origin HEAD:main; fi''for branch in main; do git push origin main; done''{ git push --force; }'"bash <<'EOF'\ngh pr merge 1158\nEOF"'echo "$(git push --force)"''echogit push --force'"git commit -F - <<'EOF'\nsubject\nEOF"'git switch main && git commit -m x''git checkout main && git push''git switch -c feat/new && git commit -m x''(cd feature && git status); git commit -m x''cd feature | cat; git commit -m x''false && cd feature; git commit -m x''git add -A && git commit -m x''git push 2>&1 | tail -5''/usr/bin/env git push --force''command -- gh pr merge 1158''command -p git push --force''timeout --signal TERM 60 git push --force''env -C .. git commit -m x''GIT_DIR=../.git git commit -m x'"bash -lc 'git push origin feat/x'"'git push origin $BRANCH''git push origin \\nHEAD:main''git push \\n origin''git push # note''git status & git commit -m x''git commit -m "unclosed''git pu""sh origin HEAD:main'"g'i't push --force"'git p\ush --force'"git $'\x70ush' --force""gh pr mer''ge 12"'git push origin ma?n'"gh api -X PUT 'repos/mforce/cluckwork/pulls/12/merge?x=1'""gh api -X PUT 'repos/mforce/cluckwork/pulls/12/merge#x'"'gh api -X PUT repos/mforce/cluckwork/pulls/12/merg%65''gh api -X PUT https://api.github.com/repos/mforce/cluckwork/pulls/12/merge/''gh api -X PATCH repos/mforce/cluckwork/pulls/12 -f state=open --jq .mergeable'"rg --pre ./run.sh 'git push' ."'/usr/bin/gi? push --force''git pu\\nsh --force''g\\nit push --force''gh pr mer\\nge 1158''git com\\nmit -m x''git push 2 > /tmp/push.log main''git push origin 1158 > /tmp/push.log''git push origin feat/x >> /tmp/push.log''git push origin feat/x &> /tmp/push.log''$G push --force''git $VERB --force''git -C "$WT" status''git pu{s..s}h origin HEAD:main''git p@(u)sh --force'