DEV Community

Umang Kumar
Umang Kumar

Posted on

git push --force Guardrails for AI Agents: Server, Client, and Policy Layers That Actually Hold

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 config settings 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
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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 *)"]
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)