Skip to content

Latest commit

 

History

History
235 lines (176 loc) · 12.7 KB

security.md

File metadata and controls

235 lines (176 loc) · 12.7 KB
title Security model
description What can reach what, what stops it, and what is not defended against.
sidebar_order 22

Security model

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
A hosted portal's sign-in a sealed cookie in your browser whatever you can
The portal's OAuth App secret and session key Worker secrets sign people in

On your machine, no credential is held by roster. The CLI uses your own gh, and the portal asks gh for its token once, holds it in memory, and writes it nowhere.

A hosted portal holds each person's sign-in, and nothing else. See the hosted portal.

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.

The review gate

"Nothing goes out unread" is enforced by GitHub, not by the prompt. An App with contents: write on a product repo can push to its default branch, and with pull_requests: write it can merge its own PR, unless a rule on the branch says otherwise.

The rule: the default branch of every product repo requires a pull request with at least one approving review, and no staff App is on the list of who may bypass it. A ruleset or classic branch protection both count.

It is off unless you turn it on, with review_gate: true in org.yaml. Without it, staff are told to leave merging to you, and on Pip they always have, but GitHub does not stop them. Most orgs keep product repos private on the Free plan, where GitHub cannot enforce the rule.

With it on, roster doctor reads it for every role: product repo in org.yaml and every repo a staff member works_in, as review-gate. It fails when nothing requires a PR or a staff App can bypass the rule, and warns when a PR is required with no approval, since then whoever opened it can merge it.

With it on, roster hire --apply adds a ruleset named roster: review before merge to each product repo that does not already require an approving review, and leaves anything stricter alone. Pass --no-review-gate to skip it. The ruleset lets repository admins bypass it only by merging a pull request, so your own merge is still the approval (GitHub will not let you approve your own PR) and an App, which is never an admin, cannot merge at all.

On GitHub Free, private repos can't have this rule. GitHub only enforces rulesets and branch protection on private repos for paid plans. To use the gate there, make the product repo public or move the org to GitHub Team.

To set it by hand: repo Settings -> Rules -> Rulesets -> New branch ruleset, target the default branch, tick Require a pull request before merging with one required approval, and keep the staff Apps off the bypass list.

Permissions a new App asks for

contents: write     commit and push
issues: write       open, comment, close, label, pin
pull_requests: write
metadata: read      always required

Deliberately 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. A hosted portal does not have it, nor hiring, retiring or doctor.

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.

On your machine, 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.

These paths take attacker-controlled input, and each is constrained rather than sanitised:

  • /api/file resolves the path and refuses anything outside the org's repositories.
  • /api/diff requires the directory to be a known staff repo and the sha to look like a sha.
  • /api/doc requires the page to be one the listing offered.
  • /api/docasset requires the screenshot to be one sitting in docs/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.

A save says what text the edit started from, and is refused if the file has moved on GitHub since, so the portal never overwrites somebody else's change without saying so.

The hosted portal

The same portal on Cloudflare Workers, behind Sign in with GitHub.

Who gets in. The people in org.yaml's humans:, signed in through an OAuth App the org creates. Membership is checked with their own token at sign-in, which also proves they can read the ops repo, and again every few minutes. Anybody else is turned away and their token revoked.

What it holds. Each person's GitHub token, their login and an expiry, sealed with AES-GCM under the Worker's SESSION_KEY and set as an HttpOnly, Secure, SameSite=Lax cookie. There is no database. A cookie that was tampered with, or sealed under another key, does not open. Rotating SESSION_KEY signs everybody out. The OAuth App's client secret is a Worker secret and is used only to finish a sign-in and to revoke a token.

What a write needs. A POST, the x-roster header, an Origin that is the portal's own, and a signed-in session. SameSite=Lax keeps the cookie off cross-site posts as well.

What it can do if somebody reaches it. Without a sign-in, nothing but show the sign-in page and the page's own scripts. With one, only what that person's GitHub account can already do: every read and write goes to GitHub with their token, so GitHub's own permissions are the boundary. It cannot set up an org, create an App, write a secret, hire or retire.

What it shares between people. The copy it holds of the org's repositories is shared by everyone signed in, as is the cache. Everyone signed in is one of the org's humans, so this is the org shown to its own people; a human whose GitHub account cannot read one of the brains can still see that brain in the portal. Their unread marks and anything they do are their own.

Claude, through MCP. /mcp takes a bearer token rather than the cookie. The portal is its own OAuth 2.1 server for it, with PKCE and dynamic client registration, in front of GitHub's sign-in: the same check against humans:, then a page the person has to press Allow on. Nothing is stored for this either. The client's registration, the code, and the access and refresh tokens are each sealed under SESSION_KEY with a kind of their own, so a refresh token or a code cannot be used as an access token, and none of them opens the portal as a cookie. An access token lasts an hour and a refresh token thirty days. Each connection signs in to GitHub separately, so signing out of the portal does not disconnect Claude; revoking the OAuth App on GitHub disconnects both, and rotating SESSION_KEY disconnects every connector. A code is not single use, since there is no store to remember it in, but it lasts five minutes and is useless without the PKCE verifier only the client holds. The tools cannot merge, approve, hire or change the org.

The OAuth scopes. repo, notifications and workflow. A GitHub App would be narrower, but its tokens cannot read notifications, and unread in the portal is GitHub's own unread, kept in step with the website and your phone. workflow is for routines: a routine's time is a line in the daily caller under .github/workflows/, and GitHub refuses any change there from a token without it. gh on your own machine already has it.

Connectors

A connector's key is a GitHub secret every brain repo can read, encrypted to GitHub's key by the portal or the CLI before it leaves your machine. It reaches a run because the callers pass their secrets on with secrets: inherit. Inside the run, one step reads them (connectors.mjs) and writes only this staff member's connectors' keys into the agent's MCP config, in the runner's temp directory. No other step, and no agent, sees the rest.

The agent can read that config, so treat a key as given to the staff members who have it, and give it the least it needs: a read-only key is a limit no prompt can argue with.

A signed-in connector's refresh token stays on the hosted portal, sealed under its session key. A run gets a short-lived access token, and only after proving with GitHub's OIDC token that it is a job of this org's session.yaml; a token minted for any other audience, workflow or org is refused.

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 offers to open the request on that person's own private tracker as well, carrying the pull request and the hunk. It writes as you, with your own token, so no credential lives on the public repo and nothing is dispatched across a boundary.

If you add a workflow to a product repo that bridges this automatically, 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-ops as production.