Stop Your GitHub MCP Agent From Force-Pushing to main
An agent is chasing a flaky CI run at 02:00. It decides the remote branch is stale, picks the wrong ref, and calls push with force: true. The branch it picked is main. GitHub’s branch protection rules will reject the call, but you still pay for the attempt: an alert lands in a security channel, a reviewer has to confirm nothing leaked, and the agent — which has no memory of the rejection beyond its current turn — will retry the same plan in five minutes.
Branch protection on the server is necessary. It is not sufficient. The cheaper place to block a force-push to a protected branch is before the call leaves the gateway, on the arguments the agent already gave you.
Why Tool-Hiding Alone Isn’t Enough
A companion piece, Secure your GitHub MCP server, covers hiding destructive tools the agent should never see — delete_repository, transfer_repository, delete_branch_protection. Hiding is the right move when a tool has no benign use for the grant. The agent never learns the tool exists, and the attack surface shrinks.
Hiding breaks down for tools the agent legitimately needs. push, create_or_update_file, and merge_pull_request are the tools an autonomous coding agent uses on every task. You cannot hide them without making the agent useless. The destructive variant of each one lives in the arguments — the target branch, the target repository, the force flag. Protection has to read those arguments and make a decision per call.
That is what Deny if is for. A condition path walks into args.<field> on the call, and one of eleven operators (eq, neq, lt, lte, gt, gte, in, not_in, exists, regex, contains) decides whether the call proceeds. The agent keeps the tool. The transport refuses the call.
Before writing rules, run tools/list against your GitHub MCP upstream once and copy the exact argument names. The official server uses branch, repo, owner, and path; community forks vary. Get the names right or your rules will never fire.
Branch-Level Deny Rules
Five rules cover the bulk of the destructive surface: two for protected-branch names, one for force-push, one for repository allowlisting, and a hide list for tools no agent should call. This example uses default: "allow" so it only constrains the listed hazards; if you prefer default-deny, list every permitted tool under tools.
{
"version": "1",
"default": "allow",
"hide": [
"delete_repository",
"transfer_repository",
"delete_branch_protection"
],
"tools": {
"push": {
"deny_if": [
{
"conditions": [
{ "path": "args.branch", "op": "in", "value": ["main", "master", "production"] }
],
"on_deny": "Direct writes to protected branches are not permitted. Open a pull request."
},
{
"conditions": [
{ "path": "args.branch", "op": "regex", "value": "^(main|master|production|release/.*)$" }
],
"on_deny": "Protected branch. Push to a feature branch and open a PR."
},
{
"conditions": [
{ "path": "args.force", "op": "eq", "value": true }
],
"on_deny": "Force pushes are not permitted through MCP."
}
]
},
"create_or_update_file": {
"deny_if": [
{
"conditions": [
{ "path": "args.branch", "op": "in", "value": ["main", "master", "production"] }
],
"on_deny": "Commits to protected branches must go through review."
},
{
"conditions": [
{ "path": "args.branch", "op": "regex", "value": "^(main|master|production|release/.*)$" }
],
"on_deny": "Protected branch. Use a feature branch."
}
]
},
"merge_pull_request": {
"deny_if": [
{
"conditions": [
{ "path": "args.repo", "op": "not_in", "value": ["acme/dashboard", "acme/sdk", "acme/website"] }
],
"on_deny": "Merges are scoped to the dashboard, sdk, and website repositories."
}
]
}
}
}
Two passes on args.branch look redundant, but they buy you something: the in rule is exact-match and cheap to audit, while the regex rule generalises to release/2026-q2. Keep both. The exact rule documents intent for the next reviewer; the regex rule covers what intent missed.
The repository rule flips direction. not_in against args.repo means everything outside the allowlist is denied, so a grant that should only touch three repositories cannot merge a PR in a fourth. This narrows the blast radius when an agent holds a token with broader scope than the work warrants.
For force-push specifically, the args.force rule denies every force push regardless of branch. Pair it with the branch rules and the agent has no path to rewrite history through MCP.
What the Proxy Log Shows
A denied call surfaces in the dashboard’s proxy log feed with the rule pointer that fired and the on_deny message that went back to the agent. For the first rule above, the entry reads something like /tools/push/deny_if/args.branch-in alongside the message “Direct writes to protected branches are not permitted.” The agent sees the message in its tool response and can adapt — open a PR instead of pushing — or escalate.
The audit value is the other half. Security review wants evidence that destructive calls were attempted and blocked. The proxy log is one surface for many upstreams: GitHub, Slack, Linear, and any other MCP server you route through PolicyLayer. The rule pointer answers which clause refused the call; the timestamp answers when; the grant label answers which issued credential made the attempt. There is no need to correlate GitHub’s audit log with a separate gateway log because the deny never reached GitHub.
Getting Started
Three steps to get the branch rules live.
First, register the GitHub MCP server as an upstream. The dashboard captures the URL and the upstream credentials. A Grant is scoped to one server route; repository boundaries should be enforced with narrow upstream credential scopes and rules like the args.repo deny above.
Second, write the deny rules. Start with the JSON above, swap acme/* for your organisation’s allowlist, and confirm the argument names by running tools/list once. If your fork uses repository instead of repo, update the condition path. The policy editor in the dashboard validates the operator-value pair before saving.
Third, test. Point your agent at a routed grant and ask it to push a trivial change to main. The call should fail with the on_deny message, and the deny should appear in the proxy log shortly after. Repeat the test for release/2026-q2 to confirm the regex rule fires. Try a merge_pull_request against a repository outside the allowlist. All three denies should land with distinct rule pointers, which is what you want — distinct pointers mean each rule is doing real work rather than shadowing another.
If a deny does not fire, the most common cause is an argument-name mismatch. Re-run tools/list and compare the field names character-for-character against your condition paths.
Why This Matters
GitHub’s branch protection rules are the last line. The gateway is the first. Catching a force-push at the transport layer is faster, cheaper, and leaves a cleaner audit trail than catching it at the API. The agent receives a structured deny it can reason about, the proxy log records the attempt, and the upstream never sees the call.
Defence in depth is the goal. The branch protection rules on GitHub stay on. The deny rules at the gateway stay on. An agent that bypasses one still meets the other. And when the next destructive tool ships — across GitHub, Slack, Linear, anywhere — the same Deny if primitive covers it without bespoke code.