Documentation

Last updated: 2026-09-12

Can You Accept a Git Pull Request Without Giving Someone Write Access?

Pull requests look simple when a forge is doing the work for you.

A contributor creates an account, forks a repository, pushes a branch, and clicks a button. The forge connects all of those pieces and presents the result as a pull request.

When implementing pull requests in Everlock, I had a slightly different starting point.

Everlock doesn't need the usual account-and-fork model. A repository is already a Git repository, and Git already has mechanisms for transferring commits and naming them with refs.

So the question became:

What is the smallest safe write permission an untrusted Git client needs in order to contribute a change?

That turns out to be a much more interesting question than simply adding a "pull request" feature.

"Allow anonymous pushes" is obviously wrong

The naive version is easy.

Let an anonymous client push commits to the repository, then somehow mark those commits as a proposed change.

The problem is the first half of that sentence.

A normal Git push is a powerful operation. Depending on the server's authorization rules, it can create branches, update existing branches, delete refs, or rewrite history.

A contributor who wants to propose a three-line documentation change clearly shouldn't receive that authority.

Protecting main helps, but it still leaves a strangely broad permission model:

anonymous contributor
        |
        v
     can push
        |
   +----+----+
   |         |
branches   pull requests

What I actually wanted was something narrower:

anonymous contributor
        |
        v
 can submit proposal
        |
        v
 pull-request ref only

The contributor doesn't need general repository write access.

They need permission to introduce one proposed state that a maintainer can inspect.

A pull request doesn't actually need a branch

This is where Git's internal model becomes useful.

Git doesn't fundamentally have a special concept called a pull request.

It has objects and refs.

A commit is an object. A branch is simply a ref pointing at a commit:

refs/heads/main

Another branch might be:

refs/heads/fix-photo-import

Nothing prevents an application from creating another namespace:

refs/pulls/123/head

From Git's perspective, that ref isn't particularly special.

From Everlock's perspective, it can have very different semantics.

A normal branch means:

This is repository state maintained by someone with write access.

A pull-request ref means:

This is untrusted proposed repository state awaiting review.

Both can point at perfectly normal Git commits.

The distinction comes from the ref namespace and the permissions attached to it.

Git refs are already namespaces

This suggests a much smaller authorization rule.

Instead of granting an anonymous user permission to push to the repository, grant permission to create or update only refs belonging to the contribution namespace.

Conceptually:

refs/heads/*       DENY
refs/tags/*        DENY
refs/pulls/*       ALLOW

The exact policy can become more restrictive still. A contributor might only be allowed to create a new pull-request ref, rather than modify arbitrary existing ones.

That changes the meaning of "write access."

The contributor can send data to the repository, but they cannot change the repository state that normal users consume.

This is much closer to what a pull request actually needs.

But refs aren't the whole security boundary

Unfortunately, restricting refs doesn't make anonymous Git pushes safe by itself.

A push contains more than a ref update.

It also contains Git objects.

If the server accepts an anonymous contribution, it is accepting attacker-controlled bytes from the network.

That creates a second security boundary:

                  anonymous push
                        |
              +---------+---------+
              |                   |
         ref update          Git objects
              |                   |
        authorization          resource
                              protection

The ref side asks:

What repository state is this client allowed to name or modify?

The object side asks:

What data are we willing to accept and retain?

Those need different protections.

An attacker doesn't have to touch main

Imagine a server that perfectly protects every maintained branch.

An anonymous user still might be able to submit thousands of pull requests.

Or upload enormous packs.

Or construct pathological object graphs designed to consume excessive CPU, memory, or disk while being processed.

Even completely valid Git objects consume storage.

If every anonymous contribution lives forever, the pull-request mechanism becomes an unauthenticated storage API.

The attack doesn't need to modify a trusted branch to be successful.

It can simply fill the disk.

That means an anonymous contribution system needs limits around the data itself: pack size, object size, number of proposals, processing cost, retention, and eventually garbage collection.

The exact limits are policy decisions.

The important part is recognizing that branch protection and resource protection solve different problems.

Receiving data and publishing data are different permissions

There is another subtle distinction.

Suppose somebody submits a pull request containing a binary.

The server has to receive that binary if the maintainer is going to review the proposed commit.

That doesn't necessarily mean the server should immediately make the binary available through every normal repository interface.

Otherwise a public Everlock instance could accidentally become a convenient file host:

upload arbitrary object
        |
        v
create pull-request ref
        |
        v
trusted server publishes object

The repository hasn't been compromised in the traditional sense. main is untouched.

But the server is now distributing attacker-controlled content from its own domain.

This is why I find the distinction between receiving, reviewing, and publishing useful.

A contribution system may need all three states:

received -> reviewable -> trusted/published

A forge UI tends to hide these boundaries because they are wrapped in accounts, forks, permissions, databases, and application logic.

When implementing the mechanism directly on top of Git, they become much harder to ignore.

The permission should describe the operation

This also changed how I thought about authorization in Everlock.

A generic permission such as:

write

is convenient, but it says very little about what somebody is actually allowed to do.

A contribution is not the same operation as maintaining a repository.

So Everlock can represent that distinction explicitly.

For example, a contribution-oriented grant can allow pull-request refs without allowing normal branch writes.

The important idea isn't the particular name of the permission.

It is that authorization should follow the semantic operation rather than collapsing every mutation into "write."

That gives us a much clearer model:

read repository
maintain repository
submit contribution
review contribution

Those capabilities may overlap for a maintainer, but they don't have to overlap for everyone else.

This gets especially interesting without accounts

Most web forges start their security model with identity.

Who is this person?

Which account owns the fork?

Which organizations do they belong to?

What permissions have been assigned to that account?

Those are reasonable questions for a forge.

But they aren't necessarily the only place to start.

For an anonymous Everlock contribution, I don't actually need to know who somebody is before deciding what operation I am willing to accept.

I can instead begin with the capability:

This client may submit a bounded proposal, but may not modify maintained repository state.

Identity can still be useful. Authentication can allow higher limits, attribution, notifications, or additional permissions.

But it isn't required to define the fundamental shape of a contribution.

That is an interesting inversion.

Instead of:

identity -> role -> repository permission -> operation

the starting point can be:

operation -> narrowly scoped capability

For anonymous collaboration, that can be a much better fit.

Git already gives us most of the primitive

The part I like about this design is how little new machinery the actual contribution needs.

The proposed change is still made of normal Git commits.

It can still be fetched with Git.

It can still be inspected with normal Git tooling.

It can still be merged using Git.

The pull request doesn't have to become a second representation of the change stored in an application database.

Everlock mainly has to add semantics around an existing Git primitive:

objects + ref + policy

The objects contain the proposed history.

The ref gives that history a stable name.

The policy determines who may create it, how long it may exist, how large it may be, and when it becomes trusted repository state.

That's enough to build surprisingly far before needing the machinery associated with a traditional forge.

Contribution is not write access

The original problem sounded like an authorization problem:

How can an anonymous user write to a Git repository safely?

I think that question is slightly wrong.

An anonymous contributor shouldn't have repository write access at all.

They should have contribution access.

Git refs make that distinction practical because proposed state can live in its own namespace without changing maintained branches.

But the ref namespace is only one part of the boundary. The server is still accepting untrusted Git objects, so resource limits, retention, object exposure, and publication rules matter just as much as branch protection.

That leads to a model I find much more useful:

contribution != repository write access

A pull request is permission to propose a new state.

Merging it is permission to make that state authoritative.

Those are different operations.

Git already gives us the primitives to keep them separate.

updates git pull-requests permissions design