Skip to content
Agentic Control Plane

How to stop Claude Code from force-pushing

David Crowe David Crowe · · 9 min read
claude-code git-safety permissions hooks
Share X HN LinkedIn

Add three deny rules to .claude/settings.json and Claude Code refuses the common force-push spellings:

{
  "permissions": {
    "deny": [
      "Bash(git push *--force*)",
      "Bash(git push -f*)",
      "Bash(git push * -f*)"
    ]
  }
}

By the matching rules in Claude Code’s docs, that covers git push --force origin main, git push -f and git push origin main -f. We derived these from the docs and have not run them in a live session, so try one in a scratch repo before you rely on it. It does not cover git push origin +main, a combined flag such as -uf, a global option before push such as git -C dir push --force, a force-push inside a script, or one behind a git alias. The hook below catches the first three. Nothing on the machine catches the last two, which is why the final step is on the server.

How Claude Code matches a push rule

Rules live in permissions.deny, permissions.ask and permissions.allow. Put them in .claude/settings.json to share them with the repo, or in ~/.claude/settings.json to apply them to every project. Claude Code checks deny first, then ask, then allow, and the first match wins (permissions docs).

Three things about how a Bash rule matches, all from those docs:

  • * stands in for any text, and the rule is matched against the whole command.
  • Compound commands are split on &&, ||, ; and |, and each part is checked. cd app && git push --force is still caught.
  • The first rule above also matches --force-with-lease, because the text contains --force. If you want lease pushes to go through, use the hook and drop that rule.

If you would rather be asked than refused, use "ask": ["Bash(git push *)"]. Every push then prompts, plain ones included.

In auto mode the classifier already blocks a force-push by default (permission modes). The rules above are for the other modes, and for not depending on a classifier.

A hook that catches more

A PreToolUse hook gets the command as JSON on stdin before it runs. Exit code 2 blocks the call and sends stderr to the model. Save this as ~/.claude/hooks/no-force-push.mjs:

#!/usr/bin/env node
let raw = "";
process.stdin.on("data", (d) => (raw += d));
process.stdin.on("end", () => {
  let cmd = "";
  try { cmd = String(JSON.parse(raw).tool_input?.command ?? ""); } catch {}
  const force = /\bgit\b[^|;&\n]*\bpush\b[^|;&\n]*\s(--force(?!-with-lease|-if-includes)\b|-[a-zA-Z]*f[a-zA-Z]*(\s|$)|\+\S+)/;
  if (force.test(cmd)) {
    console.error("Blocked: this is a force-push. Stop and ask the human to run it.");
    process.exit(2);
  }
});

Register it in ~/.claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "node \"$HOME/.claude/hooks/no-force-push.mjs\"", "onFailure": "block" }
        ]
      }
    ]
  }
}

onFailure: "block" needs Claude Code 2.1.295 or later. Without it a hook that crashes or times out lets the call run (hooks docs).

What gets past the force-push hook

We ran the hook script against twelve command strings. This is its output:

BLOCKED  git push --force origin main
BLOCKED  git push -f
BLOCKED  git push origin +main
BLOCKED  cd app && git push -uf origin fix
BLOCKED  git -C . push --force
ran      git push --force-with-lease
ran      git push origin main
ran      bash scripts/release.sh
ran      git pf
ran      G=git; $G push --force
ran      git push --mirror
ran      git push origin :main

--force-with-lease and a plain push are meant to run. The ones that got through are bash scripts/release.sh, the pf alias and G=git; $G push --force. The hook reads command text, and none of them says “push –force” in it. The deny rules miss the same three, and Claude Code’s docs say as much: a Bash rule “isn’t a security boundary around the program.”

--mirror and :main rewrite or delete remote refs without a force flag. Add them to the pattern if you want them blocked.

The fix for all of these is on the server. Turn on branch protection for main and block force pushes there. The agent cannot edit that from your terminal. It only covers the branches you protect.

What ACP adds

ACP is the same kind of hook, maintained for you, and it keeps one log and one set of rules for Claude Code, Codex and Cursor. Its force-push check matches --force, -f and a +refspec, leaves --force-with-lease alone, and also reads the command inside sh -c '...'. git push --mirror and a delete of a protected branch, such as git push origin :main, are held as well. It reads command text like the hook above, so a script file or a git alias gets past it too.

Two things differ from the hand-written hook. A force-push is held and you get a prompt in the session, so you can say yes when a force-push is what you want. And every tool call is written to a log, so you can see what ran while you were away. In an unattended run the hold is a block that you approve on the console or from the email.

Frequently asked questions

How do I stop Claude Code from force pushing?

Add deny rules for the force flags to permissions.deny in .claude/settings.json, for example Bash(git push –force), Bash(git push -f) and Bash(git push * -f). Deny rules are checked before ask and allow rules. For the +refspec spelling and for a clearer message to the model, add a PreToolUse hook that exits with code 2.

Does a Claude Code deny rule catch every force-push?

No. Claude Code’s docs say a Bash rule matches the command text Claude writes and is not a security boundary around the program. A force-push inside a script, behind a git alias, or through sh -c does not match a git push rule.

Can Claude Code ask me before a force-push instead of blocking it?

Yes. Put Bash(git push *) in permissions.ask and every push prompts. A PreToolUse hook can also return permissionDecision ask for the force-push case only.

Does branch protection make this unnecessary?

Branch protection on the server is the control the agent cannot edit, so turn it on for main. It only covers the branches you protect. A force-push over your own feature branch still goes through.

Share X HN LinkedIn
Get the next data drop
What agents actually cost, new tool-surface captures, and the occasional incident post-mortem — sent when we publish something worth your inbox, not on a schedule. Unsubscribe anytime.
Share: Twitter LinkedIn
Related posts

← back to blog