Skip to content

feat(git): Grant git access as branch rules and keep pushes to them - #188

Open
vaind wants to merge 15 commits into
mainfrom
ivan/git-grant-rules
Open

vaind wants to merge 15 commits into
mainfrom
ivan/git-grant-rules

Conversation

@vaind

@vaind vaind commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

A sandbox holds one git grant at a time, and a grant names one operation and its repositories. A pod that fetches and pushes can't get both, and a push grant can update any ref, including the default branch, tags and deletions, since GitServer relays pushes unread.

GrantV2 holds a list of rules, each a repository, an operation and the branches it may touch.

  • Fetch rules set branches or any_object, one required, so leaving out branches never reads as full access. Fetch rules listing branches stay refused until GitServer can keep a fetch to them.
  • Push rules set branches or full-match branch_patterns, and create_only allows only new branches. An update passes if any rule allows it, so a create_only pattern can sit next to the branch the pod already made.
  • Unknown fields and versions are refused, so later fields only need verifiers to ship first.

GitServer reads a push's ref updates before the pack and refuses the whole push unless a rule allows every one of them. Anything it doesn't recognise refuses the push too: no body, push options, a signature, or a capability git doesn't send. authorize_request refuses a GrantV2 push outright, since only GitServer reads its ref updates. git reads the answer only after sending the whole push, so the server reads the pack to the end and answers with a status report, which git shows as ! [remote rejected] main -> main (reason), where a 403 shows only "RPC failed".

Grant is now GrantV1, with Grant kept as an alias. It still signs and verifies, with pushes relayed unread, so signers can switch later, and it's marked deprecated for type checkers.

For reviewers:

  • GitServer doesn't know the default branch, so keeping it out of push rules, by name or pattern, is the signer's job.
  • Patterns run against branch names the client chooses, so signers must avoid patterns that backtrack catastrophically.
  • verify_grant now returns GrantV1 | GrantV2, so code that reads operation or repositories from its result needs to narrow first. Neither Seer nor Sentry does.

Open question:

  1. Major release instead - the feature flag that guards Sentry's git endpoint hasn't been enabled in production, so no version 1 grant is in use yet. This could ship as a major release in which the server no longer accepts version 1 grants and GrantV1 goes away, rather than a minor release that keeps it working while deprecated. Reviewers, please weigh in.
Example: a pod's grant once it has pushed its first branch

The pod may fetch main and its own branch, start new branches under seer/, and keep pushing to seer/fix-login, which it already created. The fetch rule shows the intended shape, which GitServer refuses until it can keep a fetch to listed branches. Until then, Seer's checkout uses a fetch rule with "any_object": true before any agent runs.

{
  "organization_id": 1,
  "referrer": "seer.coding_job",
  "rules": [
    {"operation": "fetch", "repository": 42, "branches": ["main", "seer/fix-login"]},
    {"operation": "push", "repository": 42, "branch_patterns": ["seer/.+"], "create_only": true},
    {"operation": "push", "repository": 42, "branches": ["seer/fix-login"]}
  ],
  "version": 2,
  "exp": 1794243666,
  "iat": 1794242766,
  "aud": "scm-git-grant"
}

Under this grant, git shows each refused push like this:

$ git push origin HEAD:main
 ! [remote rejected] HEAD -> main (the git grant does not cover this branch)
$ git push origin HEAD:seer/other    # seer/other already exists
 ! [remote rejected] HEAD -> seer/other (the git grant allows only creating this branch)
$ git push origin :seer/fix-login
 ! [remote rejected] seer/fix-login (the git grant does not allow deleting a branch)

Changelog Entry

  • Add GrantV2, a git grant made of repository, operation and branch rules, and keep pushes under it to their rules' branches. Rename Grant to GrantV1, keeping Grant as an alias, and deprecate it.

🤖 Generated with Claude Code

vaind and others added 2 commits November 9, 2026 17:53
A sandbox holds one grant at a time, and a grant names one operation and a
list of repositories, so a pod that both fetches and pushes can't get both,
and nothing limits either to branches.

GrantV2 holds a list of rules, each a repository, an operation and the
branches it may touch. A fetch rule sets any_object instead of branches to
read everything, so a rule that leaves its branches out is refused rather
than read as a full read. Grant, version 1, still signs and verifies, and
its pushes are relayed unread, so signers can switch once verifiers read
both.

GitServer reads a push's ref updates before the pack and refuses the whole
push if one updates an unlisted branch, deletes a branch, updates another
ref or carries push options. git reads the answer only after sending the
whole push, so the server reads the pack to the end, then answers with a
status report that git shows as rejected refs with their reasons. Fetch
rules that list branches are refused until the server can keep a fetch to
them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…anches

Seer can let the pod push in two ways: add each branch to the grant before
the pod pushes it, or let the pod start branches under a prefix and add
each one once it exists. The second needs a rule that matches names it
doesn't know yet and can't move a branch someone else made.

A push rule now sets branch_patterns, full-match regular expressions, in
place of branches, and create_only allows only updates whose old id is all
zeros, which the provider refuses for a branch that exists. Several rules
may name one repository and operation, and an update passes if any allows
it, so a create_only pattern sits next to the branch the pod already made.

Signing a version 1 Grant now warns, and the class is marked deprecated
for type checkers. Verifying one stays quiet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vaind
vaind requested a review from a team as a code owner November 9, 2026 17:57
vaind and others added 5 commits November 9, 2026 19:09
Grant is deprecated, so the README, which reads as the module's docs,
points new callers at GrantV2 alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…uietly

git_malformed_request_body matches rpc_malformed_request_body and says
which part of the request was wrong. Signing a version 1 Grant no longer
warns at runtime, since the class's deprecation already shows in type
checkers and IDEs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Next to GrantV2 the version 1 grant reads clearer with its version in the
name. Grant stays an alias of it, so code that imports Grant keeps working.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A push that updates a ref no rule allows is the grant not allowing that
operation, so it reuses the existing code, and its detail names the ref
and the reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Nov 9, 2026

Copy link
Copy Markdown

CW-3072

It only ever held GrantV1's version, which the class's own field already
holds, so GrantV1 stops reading it and the constant goes when GrantV1 does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
vaind and others added 7 commits November 9, 2026 19:40
…alias

The rules belong to the version 2 grant, so their names say so, and the union is spelled out where it's used rather than exported as a type of its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GitServer checked a push only when it found push rules and a body, so a
push under a GrantV2 with neither would have gone through unread. It now
checks every GrantV2 push, refusing one with no body, no rule, a capability
git doesn't send, or data after an empty command list. Public
authorize_request refuses a GrantV2 push outright, since only GitServer
reads the ref updates.

Signed, compressed and malformed pushes are read to the end like refused
ones, and a signed push gets its reason on side band 3. A refusal's detail
shows at most 200 characters of the client's ref. bin/git-server passes a
body that ends at Content-Length, as GitServer expects, and checks that the
provider refuses a forged create of an existing branch, which is what keeps
a create_only rule from moving one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
receive-pack reads capabilities after a NUL on every ref update, so one on
a later update would have slipped past the allowlist. A push is now refused
unless it looks exactly as git sends it: capabilities on the first update
only, branch names git takes, object ids of the push's object format, and
each ref updated once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An adversarial review of the GrantV2 push path. Each test fails while the
check lets its input through: capability values git wouldn't send, a
refusal report and detail that grow with a client-chosen capability name,
control characters from non-branch refs in the refusal detail, a long
branch name that pins a worker in a rule's pattern, and push headers that
reach the provider unchecked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each version's rules read on their own, and the version 1 checks stay as they were before rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A rule's pattern ran on a branch name of up to 64 KiB, so a backtracking
pattern could pin a worker. Capability values, refs outside refs/heads/ and
push headers went unchecked, so control bytes reached logs and values git
never sends reached the provider.

A push is refused unless every ref is a name git takes, with branch names
at most 255 bytes, its capabilities are printable ASCII, and its
Content-Encoding, Content-Type, Accept and Git-Protocol values, under any
spelling, are ones git sends. An unknown capability's name is cut short in
the reason each refused ref repeats.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant