Security model
What can reach what, what stops it, and what is not defended against.
What can reach what, and what stops it.
Credentials
| Credential | Lives | Can |
|---|---|---|
| Staff App private key | a repo secret | mint a token for the trackers it is installed on |
| Public App private key | a repo secret | mint a token for the public product repo |
| Agent credential | a repo secret | spend money with your model provider |
Your gh token |
your machine | whatever you can |
No credential is held by roster. The CLI and the portal shell out to your own gh. The
framework has no service, no server it talks to, and nothing to store.
The App private key
Created by GitHub during the manifest flow and returned exactly once. roster app holds it in
memory and passes it to gh secret set on standard input.
It is never written to a file, never passed as a command-line argument, and never appears in the process table. If the secret write fails after the App is created, the key is unrecoverable and roster says so: generate a new one from the App's settings page.
What a run can reach
A session runs with two tokens and a shell. It can do anything those tokens can do, which is the point and also the boundary worth understanding.
It cannot push a change under .github/workflows/ in any repository. That is a GitHub
restriction on App tokens, not a roster policy, and it is why agents cannot modify the
workflows that constrain them.
It can only reach repos the App was installed on. Under-granting is the common misconfiguration and it fails at run time. Over-granting is the risk: an App installed on the whole organisation can reach anything in it.
Install narrowly. The Staff card's GitHub App panel prints the list it actually needs, and
so does roster app.
Permissions a new App asks for
contents: write commit and push
issues: write open, comment, close, label, pin
pull_requests: write
metadata: read always requiredDeliberately not workflows: write. One of the pre-existing Apps here declares it, which is
a good illustration of the trap: a declaration is not a grant. GET /apps/<slug> reports
what an App asked for, which says nothing about what any installation gave it.
Never verify an installation by reading the API. The only proof is a run that finished.
Setting up from the portal
The setup screen creates repositories, creates GitHub Apps and writes repository secrets. It is
the most privileged surface roster has, and it is local only: bound to 127.0.0.1, behind the
same write guard as everything else, and never something to put behind a tunnel. There is no
hosted setup and there should not be.
The App manifest hand-off runs on the portal's own port rather than a second one, which changes where the callback lands and nothing else: the one-time code is still exchanged server-side, and the private key is still held in memory and written straight to a repo secret without touching disk. A hand-off is keyed by a random state that GitHub echoes back, held in memory only, and a callback whose state is unknown is refused.
The portal
It can write to GitHub, so it is worth being precise.
It binds to 127.0.0.1. A write needs a POST, an x-roster header (which forces a CORS
preflight that fails from any other origin), and an Origin that is either absent or
localhost. Reads are served to anything that can reach the port.
Two paths take attacker-controlled input to the disk or a command line, and both are constrained rather than sanitised:
/api/fileresolves the path and refuses anything outside the workspace root./api/diffrequires the directory to be a known staff repo and the sha to look like a sha./api/docrequires the page to be one the listing offered./api/docassetrequires the screenshot to be one sitting indocs/images/, matched by name against that listing...is not a case to get wrong; it is simply a name not on it.
--host overrides the bind address and prints a warning. It exists for people who know what
they are doing on a network they control. See hosting the portal.
Trust in a prompt
Everything composed into a prompt is content you or your agents wrote: org/, the charter, the
memory index. A mention run additionally carries the text of a comment.
On a private tracker that is you. On the public product repo it is not, which is exactly why nothing in a product repo wakes an agent at all. There is no caller workflow there, by design: anyone can comment on a public pull request, a run started from a comment executes with repository secrets, and a run that prints a charter and a chain of reasoning would print it into a world-readable log.
So a mention on a public pull request is decoration. It posts, it renders as a chip, and
nothing happens, which is safe and also invisible. The portal is what makes it visible and what
gets you out of it: replying with an @handle where nothing listens says so, and Ask a staff
member opens the request on that person's own private tracker instead, carrying the pull
request and the hunk. It writes through your own gh, as you, so no credential lives on the
public repo and nothing is dispatched across a boundary.
There was once a forwarder in the product repo that bridged this automatically. It was removed; see concepts. If you reinstate one, its author gate is load-bearing. Do not relax it.
The loop guard
The mention callers gate on github.event.sender.login, the account that performed the
action, not the author of the thing acted on.
That distinction is load-bearing. The pinned status issue is opened by the human and rewritten by the agent on every run. An author check would let the agent's own edit wake another run, which would edit it again.
What is not defended against
- A compromised model provider account. The agent credential can spend money and the agent can write to your repos.
- A malicious prompt injection from a public comment, beyond the split above. A staff member reading a public PR comment is reading untrusted text.
- Someone with write access to the ops repo. They can change what every staff member is
told to do, on the next run. Treat
roster-opsas production.