Protect the Default Branch from Direct Agent Commits
Stops the agent from committing straight to main or master. Changes have to land on another branch and go through a pull request, where a person reviews them.
On this page
What it does
Stops the agent from committing straight to main or master. Changes have to land on
another branch and go through a pull request, where a person reviews them.
create_or_update_file,push_files, anddelete_fileare denied when theirbranchargument ismainormaster(case-insensitive, with an optionalrefs/heads/prefix).- The same calls are denied when
branchis missing or empty. GitHub's contents API writes to the repository's default branch when no branch is given, so an omitted branch is treated as a commit to the default branch. - Commits to any other branch, and every other tool (reads, branch creation, PRs), pass through.
Compliance alignment
- SOC 2 CC8.1 — supports change management: changes reach the production branch only through a reviewed pull request.
- SOX ITGC (change management) — supports segregation between authoring a change and promoting it.
Why ingress
A commit is permanent once it reaches GitHub, and a push to the default branch can trigger deploys immediately. The target branch is in the request, so ingress can stop it before anything is written.
Configuration
protected_branches lists the branch names treated as protected. Add develop,
release, or trunk if your team uses them as the default or deploy branch.
Tool name matching
Tools are matched by suffix (create_or_update_file, push_files, delete_file), so the
policy works whatever name your gateway gives the GitHub server, and covers both the
official github/github-mcp-server and the archived community server, which use the same
names for these three tools. Confirm the exact names your gateway sends with the
dump-input debug technique before relying on it.
Examples
Allowed
{
"input": {
"action": "tool_pre_invoke",
"resource": { "name": "github-push_files", "type": "tool" },
"payload": { "args": { "owner": "acme", "repo": "app", "branch": "fix/payments-timeout",
"message": "Revert timeout", "files": [ { "path": "src/client.ts", "content": "..." } ] } }
}
}
allow = true, no reason.
Denied
{
"input": {
"action": "tool_pre_invoke",
"resource": { "name": "github-create_or_update_file", "type": "tool" },
"payload": { "args": { "owner": "acme", "repo": "app", "branch": "main",
"path": "src/client.ts", "message": "hotfix", "content": "..." } }
}
}
allow = false, reason = "Blocked: agents can't commit directly to the main or master branch. Push to a new branch and open a pull request instead.".
Composition
Pairs naturally with require-human-approval-merge:
this policy forces changes into a PR, that one keeps the merge with a person. Together they
mean nothing an agent writes reaches the default branch without review.
Known limitations
- The default branch name is not looked up. The policy can't see each repository's
configured default branch. A repo whose default is
trunkordevelopis unprotected until you add that name toprotected_branches. - Other write paths are out of scope.
merge_pull_request(handled byrequire-human-approval-merge),update_pull_request_branch, and Git operations made outside the MCP server are not covered. - Not a substitute for GitHub branch protection. Keep branch protection rules on in GitHub too; this policy covers the agent path only.
Compliance note. This policy supports alignment with the cited framework controls on the MCP path only. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it.
Policy source (Rego)
package github.ingress.protect_default_branch
# Allow by default; deny commit-writing tools that target a protected branch.
default allow := true
# Branch names treated as the default / deploy branch. Extend for your repos.
protected_branches := {"main", "master"}
_tool := lower(object.get(input.resource, "name", ""))
_args := object.get(object.get(input, "payload", {}), "args", {})
# Tools that write a commit to a named branch.
_commit_tool_suffixes := {"create_or_update_file", "push_files", "delete_file"}
_is_commit_tool if {
some suffix in _commit_tool_suffixes
endswith(_tool, suffix)
}
# Normalize the requested branch: trim, lowercase, drop a refs/heads/ prefix.
_branch := b if {
raw := object.get(_args, "branch", "")
is_string(raw)
b := trim_prefix(lower(trim_space(raw)), "refs/heads/")
}
# Missing, non-string, or empty branch: GitHub writes to the default branch.
_targets_default if not _branch
_targets_default if _branch == ""
_targets_protected if _targets_default
_targets_protected if protected_branches[_branch]
allow := false if {
_is_commit_tool
_targets_protected
}
reason := "Blocked: agents can't commit directly to the main or master branch. Push to a new branch and open a pull request instead." if {
_is_commit_tool
_targets_protected
} Canonical source: policy.md on GitHub · raw · raw on this site (.md)