A coding agent that can commit can usually push. One that can push can usually force-push. And a force-push to a shared branch is one of the few agent mistakes that destroys other people's work. This post sets up git push --force guardrails for AI agents in layers, from the server inward, with tested examples and the bypasses each layer misses.
Why agents force-push
Not malice — momentum. The agent rebases to tidy history, the push is rejected as non-fast-forward, and the most common fix in its training data is git push --force. Or a README in a fork suggests "reset to upstream and force push". The command is plausible, which is exactly the problem.
Layer 1: protect the branch on the server
This is the only layer the agent's machine cannot bypass.
-
GitHub / GitLab / Bitbucket: enable branch protection or rulesets on
main, release branches and anything shared. Block force pushes and deletions, require pull requests, and make sure the agent's identity is not on any bypass list. -
Self-hosted bare repositories: two
git configsettings on the server repo do the job:
git -C /srv/git/project.git config receive.denyNonFastForwards true
git -C /srv/git/project.git config receive.denyDeletes true
Tested against a local bare repository, even a client that skips its own hooks gets refused:
$ git push --no-verify --force origin HEAD:main
! [remote rejected] HEAD -> main (non-fast-forward)
error: failed to push some refs to '/tmp/gp/remote.git'
If you only do one thing from this article, do this one.
Layer 2: give the agent a narrower identity
Agents frequently run with the developer's own credentials — which may include admin rights and a branch-protection bypass. Give agent workflows their own credential instead:
- A fine-grained token limited to the repositories the task needs, with no admin permissions.
- Or a deploy key with write access to one repository, combined with branch rules that the key cannot bypass.
- Prefer having the agent push to its own branch prefix (
agent/*) and open a pull request, rather than pushing to shared branches at all.
Layer 3: a client-side pre-push hook
A pre-push hook receives one line per ref being pushed: <local ref> <local sha> <remote ref> <remote sha>. A push is a force-push exactly when the remote tip is not an ancestor of what you are pushing. That check catches every spelling — --force, -f, --force-with-lease, +refspec — because it looks at the effect, not the flags.
#!/bin/sh
# .git/hooks/pre-push — refuse non-fast-forward pushes and deletions of protected branches.
zero=0000000000000000000000000000000000000000
protected='refs/heads/main refs/heads/master refs/heads/release'
while read local_ref local_sha remote_ref remote_sha; do
case " $protected " in
*" $remote_ref "*) ;;
*) continue ;;
esac
if [ "$local_sha" = "$zero" ]; then
echo "pre-push: refusing to delete $remote_ref" >&2
exit 1
fi
[ "$remote_sha" = "$zero" ] && continue
if ! git merge-base --is-ancestor "$remote_sha" "$local_sha" 2>/dev/null; then
echo "pre-push: non-fast-forward push to $remote_ref refused (force push)" >&2
exit 1
fi
done
exit 0
Tested:
$ git push --force origin HEAD:main
pre-push: non-fast-forward push to refs/heads/main refused (force push)
$ git push origin +HEAD:main
pre-push: non-fast-forward push to refs/heads/main refused (force push)
The catch: git push --no-verify --force skips the hook entirely, and in my test it went straight through as a forced update. Client hooks are a seatbelt for honest mistakes, not a control against a determined (or injected) agent. That's why Layer 1 exists.
Layer 4: agent-level and policy rules
Most agents let you deny command patterns. In Claude Code, for instance:
{
"permissions": {
"deny": ["Bash(git push --force *)", "Bash(git push -f *)", "Bash(git reset --hard *)"],
"ask": ["Bash(git push *)"]
}
}
Pattern rules match the string the agent submits, so they need care. To see exactly where string rules stop, I tested this policy in the Cirvix DSL, where command = "…" is a contains match:
deny:
name = deny-force-push
tool = shell.exec
command = "git push --force"
deny:
name = deny-force-push-short
tool = shell.exec
command = "git push -f"
deny:
name = deny-hard-reset
tool = shell.exec
command = "git reset --hard"
require_approval:
name = hold-any-push
tool = shell.exec
command = "git push"
approvers = developer
allow:
name = allow-safe-shell
tool = shell.exec
risk <= MEDIUM
cirvix policy test results (package 0.3.0):
✓ long flag git push --force origin main → deny (deny-force-push)
✓ lease git push --force-with-lease origin f → deny (deny-force-push)
✓ short flag git push -f origin main → deny (deny-force-push-short)
✓ flag after remote git push origin main -f → require_approval (hold-any-push)
✓ plus refspec git push origin +main → require_approval (hold-any-push)
✓ normal push git push origin feature/login → require_approval (hold-any-push)
✓ status git status → allow (allow-safe-shell)
7/7 PASSED
The two interesting rows are git push origin main -f and git push origin +main. Neither contains the denied substrings, so string rules alone would have missed them. They were caught only because every push is held for a person. That is the design lesson: deny the spellings you know, and put a hold on the whole class so the spellings you didn't think of land in front of a human instead of running.
Note also that --force-with-lease is denied here because it contains --force. Lease is safer than a bare force, but on a shared branch it still rewrites history; if you want to allow it on agent-owned branches, do that with a narrower rule.
Recommended setup
| Layer | Stops | Bypassed by |
|---|---|---|
Branch protection / receive.denyNonFastForwards
|
All force-pushes to protected branches | An identity with bypass rights |
| Scoped agent credentials | Pushes outside the task's repos | Agent using your personal credentials |
| Pre-push hook | Honest force-push mistakes, any spelling | --no-verify |
| Agent deny/ask rules and policy | Known spellings; holds the rest | Unrouted commands, missing class-level hold |
Where Cirvix fits
Cirvix AgentControl is an open-source policy engine for AI agent tool calls. It evaluates governed calls — Claude Code's built-in Bash tool via its PreToolUse hook, MCP calls via its gateway, or SDK-wrapped tools — and returns permit, hold or deny before execution, with deny as the default. The repository's policies/default.policy already includes rules for force-push and hard reset, plus a hold for shell commands it can't classify as safe.
npm install -g @cirvix_ai/agent-control
cirvix policy test --policy git.policy
It only governs calls routed through it, and it sits on the agent's machine — which is why server-side branch protection stays at the top of the list.
Repo: https://github.com/CIRVIX/agent-control
Site: https://cirvix.com
Top comments (0)