Scoping GitHub access to the workspace
The github grant injects your GitHub token for github.com and api.github.com. A token from gh auth token can usually push to every repository you can. github.scope: workspace limits what a run can change to the repositories checked out in its workspace:
grants:
- github
github:
scope: workspace
A push to any other repository fails:
$ git push https://github.com/acme/payments.git main
remote: Moat: request blocked by Keep policy.
remote: Host: github.com
remote: Push blocked: this run can only push to majorcontext/keep, majorcontext/moat. To add a repository, the user runs `moat github allow OWNER/REPO` on the host.
fatal: unable to access 'https://github.com/acme/payments.git/': The requested URL returned error: 403
The proxy enforces the scope with a Keep policy on each request that carries the token, so it applies to git, gh, and any other client in the container.
How repositories are found
When a run starts, moat scans the workspace on the host, before the container is created:
- the workspace itself, nested clones (a workspace that holds several repositories), linked git worktrees, and checked-out submodules, up to four directories deep
- every remote’s
urlandpushurlthat points atgithub.com, over HTTPS or SSH
Moat reads .git/config files directly and never runs git in the workspace, since a modified repository could run hooks on the host. It skips dependency and build directories (node_modules, vendor, .venv, target, .build, .terraform, _deps, and similar), where package managers leave checkouts of third-party code.
Skipping a directory can only narrow access. A repository that discovery misses gets the same access as any other repository.
Approvals
The first run with github.scope: workspace approves every repository it finds and records them:
GitHub: this run can only push to these repositories (recorded for future runs):
majorcontext/keep ./keep (origin)
majorcontext/moat ./ (origin)
Other repositories: read-only
On later runs, a repository that was not approved before is pending. That covers a clone the agent made, or a remote it re-pointed. On a terminal, moat asks before the run starts:
New since the last approved run:
acme/payments ./scratch/payments (origin)
Allow this run to push to acme/payments? It was found in ./scratch/payments (origin). [y/N]:
Without a terminal (or with --no-prompt), pending repositories stay read-only and moat prints the command that approves them.
Approvals are stored in ~/.moat/github-repos.json, keyed by the project’s path. The file is outside every workspace, so an agent cannot add approvals by editing files in its workspace and waiting for the next run.
The scope stays on
Once a project has run with github.scope: workspace, it stays scoped even if moat.yaml stops asking for it. An agent can edit moat.yaml, so removing the setting is not enough to turn the scope off. Later runs print a note and keep enforcing it, using the other_repos setting from the last run that asked for the scope. To turn it off, run moat github forget --all in the project.
A run records the project only after its container and audit store are created, so a run that fails before starting (for example, because of an incompatible network.keep_policy) does not lock the project.
Managing a project’s repositories
moat github commands act on the current project: the nearest directory at or above the current one that has recorded approvals, or else the nearest one that contains moat.yaml.
$ moat github
Project: /Users/me/code/moat
Scope: workspace (since 2026-11-02)
Other repositories: read-only
REPOSITORY STATUS FOUND IN
acme/payments pending ./scratch/payments (origin)
majorcontext/keep approved ./keep (origin)
majorcontext/moat approved ./ (origin)
Pending repositories are read-only. Approve one with: moat github allow OWNER/REPO
| Command | Effect |
|---|---|
moat github allow OWNER/REPO... | Approve repositories. An approved repository is writable whether or not it is checked out in the workspace. |
moat github forget OWNER/REPO... | Revoke repositories. The project stays scoped. |
moat github forget --all | Delete the project’s approvals and its lock. |
moat github policy | Print the Keep rule file runs use, with the approved repositories filled in. |
To list a repository in moat.yaml instead, use extra_repos. It is approved the same way as a discovered repository: on the first run, or at the prompt afterwards.
github:
scope: workspace
extra_repos:
- majorcontext/docs
What is enforced
| Request | Approved repositories | Other repositories |
|---|---|---|
git push (git-receive-pack) | allowed | denied |
git fetch / clone, LFS download | allowed | allowed with other_repos: read; denied with none |
| LFS upload | allowed | denied |
REST GET under /repos/{owner}/{repo} | allowed | allowed with read; denied with none |
REST writes under /repos/{owner}/{repo} and /repositories/{id} | allowed | denied |
REST writes outside /repos (SSH keys, gists, new repositories, organization settings) | denied | denied |
| Forking or generating a repository | denied | denied |
| GraphQL queries | allowed | allowed |
GraphQL mutations with input.repositoryId or input.branch.repositoryNameWithOwner | allowed | denied |
| GraphQL mutations on issues and pull requests that name their target only by node ID: comments, reactions, labels, assignees, edits, open/closed/draft state, non-approving reviews | allowed | allowed |
| Approving a pull request through GraphQL | denied | denied |
Any other GraphQL mutation that does not name a repository: code changes (mergePullRequest, updateRef, enablePullRequestAutoMerge, …), branch protection, rulesets, deletions, and mutations GitHub adds later | denied | denied |
gh pr create, gh issue create, gh pr view, and gh api repos/OWNER/REPO/... work for approved repositories. gh pr merge uses the mergePullRequest mutation, which identifies the pull request by node ID only, so it is denied. Merge through REST instead:
gh api -X PUT repos/OWNER/REPO/pulls/NUMBER/merge
Moat looks up each approved repository’s numeric ID and GraphQL node IDs with your token on each run, and caches them, so the policy can recognize requests that address a repository by ID. If GitHub is unavailable, the run uses cached IDs and warns that repositories without cached IDs may have GraphQL mutations blocked.
Other repositories: read or none
github:
scope: workspace
other_repos: none # default: read
read (the default) lets the agent fetch and read other repositories, which public dependencies fetched from github.com need. none also denies git fetches, REST reads, and code search for repositories outside the workspace.
none is not a confidentiality boundary. A GraphQL query can name any repository the token can see, and the policy allows all queries. For a hard read boundary, grant a fine-grained token limited to the repositories the agent needs.
Configurations that are refused
The scope only holds if every path to GitHub with your credentials passes through the policy. A run with github.scope: workspace fails before the container starts if it also has:
| Configuration | Why | Fix |
|---|---|---|
any ssh: grant | git push over SSH does not pass through the HTTPS policy, and moat’s SSH agent cannot limit a key to one destination host: a key granted for ssh:gitlab.com also signs a connection to github.com | Remove SSH grants and use https://github.com/... remotes |
an mcp: server with auth.grant: github | Remote MCP servers are not covered by the policy | Use the gh CLI in the container |
a Copilot run (moat copilot) | Copilot reaches GitHub through Copilot API hosts the policy does not cover | Run without the scope |
A separate token supplied through env, -e, a secrets: resolver, or the environment of a local MCP server is outside the scope of the injected github grant. The agent or MCP tool can use that token independently. Moat warns about recognized token variable names in top-level configuration and -e; it cannot detect every credential in resolver output or local MCP environments.
Limits
Each generated Keep def is limited to 2,048 characters. The combined legacy and next-generation node-ID list can reach that limit at roughly 30 repositories, depending on ID lengths; long names can hit a different def limit sooner. If startup reports this limit, narrow the workspace or remove unneeded checkouts and extra_repos entries. This is a size limit, not a fixed repository count.
- GraphQL mutations by node ID. Mutations that identify their target only by a pull request, issue, or comment node ID cannot be traced to a repository without a lookup. A fixed list of discussion mutations (comments, reactions, labels, assignees, edits, non-approving reviews) is allowed for every repository, so
gh pr commentandgh pr editkeep working. A comment on another repository’s pull request is therefore not blocked. Every other such mutation is denied. - Workflows in approved repositories. The agent can push changes to
.github/workflows/in an approved repository. Those workflows run on GitHub with that repository’s secrets. - Network policy. The scope governs requests that carry the github grant’s token. Combine it with
network.policy: strictto limit everything else the container can reach.
GraphQL transferIssue is denied even when its destination is approved, because the issue’s source repository cannot be verified from that input.
After upgrading, run moat proxy restart. Registered policies are saved with the run and recompiled after restart. A run with missing or invalid required policy data is not restored. Legacy GitHub registrations without a policy snapshot must be recreated. Older daemon binaries cannot restore policy-bearing tokens; downgrading the daemon can require recreating those runs.
Combining with network.keep_policy
The scope adds its rules to the proxy’s http Keep scope. A network.keep_policy is merged into the same rule file, so it must use mode: enforce. It must also leave on_error and case_sensitive at their defaults, and pin every request-body rule to an operation. An unpinned body rule would make the proxy read every request body, including git push pack data, which is not JSON. Only deny and log actions are supported in the merged policy. Mutating actions could change what the scope rules see. User defs must not collide with identifiers in the generated GitHub rules. Moat reports any of these conflicts when the run starts.
Upgrading
The scope needs a proxy daemon that reads request bodies only for the requests a body rule’s operation matches. If the running daemon is older, the run fails with a message to run moat proxy restart.