Repositories
A grant on a repository host with a token on it is a grant to push anywhere on the repository, main included. So a repository is given together with the branches that go with it, and the proxy reads every push before it passes one.
Four words, three grants
│ › /repo acme/website │ │ This plane holds no GitHub token. Make a fine-grained one at │ │ https://github.com/settings/personal-access-tokens/new with │ │ Contents: read and write on acme/website — and Pull requests: │ │ read and write, if it should open them — then paste it here: │ │ /repo github_pat_… │ │ │ │ It is kept here, spent by the proxy, and never given to an │ │ agent. │ │ │ │ › /repo … │ │ scout holds https://github.com/acme/website. It can clone it │ │ and push to scout/*; a push to any other branch is refused │ │ before it leaves, and it can open pull requests. The token │ │ stays here, never in the sandbox. │
What the plane derives from acme/website is three grants, none of them written by hand: github.com under /acme/website, with the push scope on it and the token attached as basic auth, which is the clone and the push; api.github.com under /repos/acme/website for GET, which is reading; and under /repos/acme/website/pulls for POST and PATCH as well, which is opening a pull request. Two API grants rather than one because the API is where a branch scope could be walked around — a PUT on contents/ commits to any branch, a PATCH on git/refs/ moves any ref, a POST on merges merges into main. Opening a PR is the one write it gets; merging one is a PUT it is not given.
The token is the plane's, one for every repository given here, kept beside the provider keys as GITHUB_TOKEN — exporting it in the plane's environment works too, and what is pasted at a console wins. A fine-grained token answers only for the repositories it was given, so give it the ones you will hand out and nothing else: it is the belt under the proxy's scope, not a second copy of it. The repository answers under its name with .git on the end as well as without, because the agent pastes whichever the clone box showed it.
Its own lane, unless you say otherwise
Left unsaid, the branches are the agent's own name and anything under it — scout/* — so nothing lands on main because nobody said which branches. /repo acme/website fix/* docs names them instead. A pattern is shaped like a refspec: a bare name is a branch, * stands for anything, slashes included, and * on its own is anywhere at all. Saying /repo again for a repository already held changes what it may push rather than adding a row.
│ $ git push origin main │ │ remote: squad: main is not granted to this agent; push scout/* here │ │ To https://github.com/acme/website │ │ ! [remote rejected] main -> main (not granted: push scout/* here) │ │ error: failed to push some refs to 'https://github.com/acme/website' │
The branch is in the head of the push itself: one line per ref, closed by a flush, and the packfile after. The proxy reads to the flush before it opens anything upstream, and if a ref is not on the list nothing goes — not the packfile, not the other refs pushed with it. The refusal is not a 403, because a 403 reaches git as RPC failed; HTTP 403, which is what a wrong token looks like, and an agent reading that goes and checks its token. What git prints word for word is the server's report, so the proxy declines the way a pre-receive hook does, naming the branch it refused and the ones it has. The audit log carries the refs of every push, allowed or not.
Said every turn, after the house rules
A grant nobody mentions is a grant found by trial — cloning under the wrong name and meeting a 403, or pushing to main and meeting a refusal it did not know was coming. So the plane tells the agent what it holds at the start of every turn, the way it says the house rules: the URL, where under its workspace the checkout goes, and the branches it may push. Its commits carry its own name, and git in the sandbox never stops to ask for a password, since the credential is put on by the proxy and a prompt would be a turn hung on a question nobody answers.
│ › /repo │ │ scout holds: │ │ https://github.com/acme/website push scout/* from here │ │ https://github.com/acme/api push fix/* docs from the file │ │ │ │ /repo drop <owner/name> takes one back; /repo <owner/name> │ │ <branch>… changes what it may push. │
An agent may ask /repo to see what it holds, and may not ask to hold one: holding a repository spends your token on it, and a token an agent is holding is one it got from something it read. The refusal says which line is yours to type, and never prints the token.
A force push looks like a push
The proxy sees the refs and not the history behind them, so a force push to a branch the agent may push passes, and so does deleting one. Nothing about main changes hands either way, and that is the claim: the branches you named are the branches it can touch. The second belt is GitHub's, a ruleset on main that refuses direct pushes, with a bypass for you — worth having because the token is the plane's and lives outside the sandbox, where the proxy is not what guards it.
The same four words go in deploy/config.yaml, under the agent, and what the file declares the console leaves to the file:
agents:
- id: scout
repos:
- repo: acme/website
- repo: acme/api
push: [fix/*, docs]
Reach is what a grant is and where the credential sits, and this is one shape of grant with a scope the method could not carry.