# gh guard
The gh guard stops a sandboxed agent from running destructive GitHub
operations through the `gh` CLI. It intercepts every `gh` invocation inside
the sandbox and checks it against a command-level policy before handing off
to the real binary.
Its sibling, the git guard, blocks `git push`, `git request-pull`, and
`git send-pack`. That one has its own doc, [git-guard.md](git-guard.md).
## Configuration
Both guards are **enabled by default**, and both block. The gh guard blocks
across its whole policy table; the git guard blocks pushes to the default
branch, and lets feature branches through.
```bash
# gh guard — on by default, in block mode
cplt config set gh_guard.enabled false # opt out entirely
cplt config set gh_guard.mode block # block | warn | audit (default block)
cplt config set gh_guard.scope_check true # enforce repo-scoping on write commands
cplt config set gh_guard.block_auth_token true # deny "gh auth token" exfiltration
cplt config set gh_guard.unknown_command block # block unrecognized gh commands
# git guard — on by default, in block mode, scoped to the default branch
cplt config set git_guard.mode block # block | warn | audit (default block)
cplt config set git_guard.enabled false # opt out entirely
cplt config set git_guard.prevent_push true # block git push/request-pull
```
`--preset strict` widens the git guard from the default branch to every push;
`--preset permissive` and `--preset full-trust` turn both guards off. Every refusal prints the flag and
the config key that undoes it.
Both guards take a `mode`. `block` prints the message and exits non-zero.
`warn` prints the same message behind `⚠️ WARNING (would block):` and runs the
command anyway. `audit` prints it behind `[audit] gh-gate: would block:` (or
`git-gate`) and runs it. Use `warn` or `audit` to find out what a rollout would
break before you enforce it.
For per-repo enforcement, committed to version control:
```bash
cplt config set --repo sandbox.gh_proxy true
cplt config set --repo sandbox.git_push_prevention true
```
CLI flags (override for a single run)
```bash
cplt --gh-guard # enable gh guard
cplt --no-gh-guard # disable gh guard
cplt --git-guard # enable git push prevention
cplt --no-git-guard # disable git push prevention
cplt --allow-api-write # allow gh api write operations to current repo (overrides config)
cplt --no-allow-api-write # deny gh api write operations (overrides config)
```
Advanced: full TOML reference
```toml
# ~/.config/cplt/config.toml
[gh_guard]
enabled = true # blocks destructive gh operations
mode = "block" # block | warn | audit
scope_check = true # enforce repo-scoping on write commands
block_auth_token = true # deny "gh auth token" exfiltration
inject_token = false # no effect on macOS; Linux Copilot may need it
unknown_command = "block" # block|allow unrecognized gh commands
allow_api_write = false # allow gh api write (POST/PUT/PATCH) to current repo (opt-in)
allow_pr_merge = false # allow gh pr merge into a ruleset-protected branch (opt-in)
hosts = ["github.com"] # GitHub hosts whose remotes are scoped; global config only
[git_guard]
enabled = true # intercept git push, request-pull, send-pack
mode = "block" # block | warn | audit (default block)
prevent_push = true # treat push/request-pull as violations
prevent_force_push = true # block force push, even where a plain push is allowed
```
### GitHub Enterprise hosts
By default the guard scopes only `github.com` remotes. A checkout cloned from
another host, such as `https://example.ghe.com/owner/repo`, gets no scope, and
scope-checked commands are refused. Add the host to `gh_guard.hosts` in your
global config:
```bash
cplt config set gh_guard.hosts github.com,example.ghe.com
```
List `github.com` too if you still use it. Entries are bare hostnames: no
`https://`, path, port or wildcard. `cplt config set` refuses anything else.
The key is global-only. `cplt config set --local` refuses it, and a local or
`.cplt.toml` file cannot set it.
With a host added:
- remotes on it join the scope as `host/owner/repo`, and `GH_REPO` and
`GH_HOST` are pinned to that repository and host (with the key set,
`GH_HOST` is pinned for github.com repositories too);
- `--hostname` may name any listed host, once: a repeated `--hostname` is
refused;
- `gh api` paths are checked on the host they go to: `--hostname`, else
github.com. Name the host with `--hostname` for a `/repos/` call on it.
Absolute `https://` endpoints are still limited to `https://api.github.com`;
- git's HTTPS credential helper is routed through the guard for every listed
host;
- the cached github.com token is not handed to a command aimed at another
listed host.
`gh api graphql` and `allow_pr_merge` still work on github.com only. On other
hosts they are refused.
The guard does not open the network. The host must also be reachable through
the proxy: under strict networking, add it to `allowed_domains`. cplt does
not add it for you.
### Backward compatibility
The old flat config still works:
```toml
[sandbox]
gh_proxy = true # maps to [gh_guard] enabled=true with defaults
git_push_prevention = true # maps to [git_guard] enabled=true with defaults
```
### Policy is baked at launch
cplt bakes the policy flags, the absolute paths to the real `gh` and trusted
`git` binaries, and the verified repository scope into the wrapper script when
the sandbox starts. The `gh-gate` subcommand receives `--repo-scope`,
`--real-git`, `--scope-check`, `--block-auth-token`, `--unknown-command=block`,
and `--allow-api-write`/`--no-allow-api-write` as CLI flags. Runtime working
directories, nested repositories, Git configuration, and `PATH` cannot redefine
the approved scope; the cwd is only checked as evidence for an implicit target.
## How it works
```
Agent calls gh → wrapper script (in PATH) → cplt gh-gate → policy check
├─ Allow → exec real gh
├─ ScopeCheck → verify repo → exec or block
└─ Block → print error, exit 1
```
1. At sandbox launch (when scratch directory is enabled), cplt writes a small wrapper to `{scratch}/bin/gh`
2. `{scratch}/bin` is prepended to PATH, shadowing the real `gh`
3. The wrapper calls `cplt gh-gate --real-gh /path/to/gh --real-git /path/to/git --repo-scope owner/repo -- `
4. cplt evaluates the command against the policy table
5. If allowed, `exec()` replaces the process with the real `gh`, so there is no runtime overhead
6. If blocked, it prints an error explaining why and exits non-zero
> **Note:** Wrappers require the scratch directory. If you run with `--no-scratch-dir`,
> the gh/git guards will be inactive. `cplt doctor` then shows `push main` and
> `pr merge` as ALLOWED on its `guard:` lines.
## Policy tiers
| Tier | Behavior | Examples |
|------|----------|----------|
| **Allow** | Read-only; repo-scoped reads resolve against the scope set, and an unverifiable cwd is pinned to the only member rather than refused (pinned to nothing when the set has several) | `pr list`, `issue view`, `run list`, `search` |
| **ScopeCheck** | Permitted only for a repository in the scope set: the startup repo plus every `--repo-dir` root with a GitHub origin. Implicit targets must resolve to one of them from cwd | `pr create`, `issue comment`, `pr close` |
| **Block** | Never permitted | `repo delete`, `pr merge`, `release create`, `workflow run` |
| **Unknown** | Not in the policy table, blocked by default | anything GitHub adds to `gh` after the table was last updated |
Merging, deleting, and releasing need human judgment, which is why they sit in
the Block tier rather than being scope-checked. `gh api` is the one command with
its own rules, described below.
Two entries surprise people. `gh repo clone` is blocked, because cloning
another repository pulls code into the sandbox from outside the enforced scope.
Plain `git clone` is not affected, since it is on the git guard's allow list.
And `gh auth status` is allowed while `gh auth status --show-token` is blocked.
The policy table classifies `auth status` as a read, so the token flag is
intercepted separately, in every spelling (`--show-token`, `--show-token=true`,
`-t`, and bundled clusters such as `-at`).
"Always permitted" in the Allow tier means permitted for a repository in the
scope set. A repo-scoped read invoked from a repository *outside* it is blocked
rather than silently answered from the startup repo (#213). The repo-scoped groups are
`pr`, `issue`, `run`, `workflow`, `release`, `label`, `cache`, `secret`,
`variable`, `repo` and `ruleset`; commands that do not resolve a repository from
the cwd (`auth`, `search`, `gist`, `org`, `project`, `config`, `extension`,
`attestation`, `copilot`, ssh/gpg key listings, and the owner-scoped `repo list`
/ `repo gitignore` / `repo license`) stay usable from any directory.
## Scope checking
When a command is classified as `ScopeCheck`, cplt verifies that the command
targets a repository in the scope captured at sandbox startup:
1. At sandbox startup, reads `remote.origin.url` with the trusted Git binary, from the project root and from every repository named with `--repo-dir` / `sandbox.repo_dirs`. A named root whose origin is not a GitHub URL is warned about and left out of the set
2. Ignores global/system config, config includes, and inherited `GIT_*` variables
3. Bakes the verified `owner/repo` set and trusted Git path into the wrapper; unavailable scope remains fail-closed
4. If the command has `-R`/`--repo`, checks it for membership in that set
5. Otherwise, resolves the invocation cwd with the trusted Git binary and requires that repo to be a member
6. Preserves explicit `/repos/owner/repo/...` API endpoint checks without requiring cwd matching
7. Rejects a conflicting `--hostname` or fully qualified non-GitHub API endpoint
8. Sets `GH_REPO=github.com/owner/repo` and clears `GH_HOST` before executing `gh`
The scope is a set: the launch repository plus every repository named with
`--repo-dir` / `sandbox.repo_dirs` whose origin is a GitHub URL. `GH_REPO` is
pinned to whichever member the command matched, never to the launch repository
when another member was the target.
Repo-scoped `Allow` commands get the same cwd check (step 5) when their target
is implicit, so a read invoked from a repository outside the set is blocked
instead of silently answered from a member. Allow-tier commands that resolve no
repository from the cwd are unaffected and stay usable from any directory.
Unlike `ScopeCheck`, an *unverifiable* cwd is not fatal for a read. What happens
then depends on the size of the set: with a single repository in scope the
command is pinned to it, which is the safe target; with more than one there is
no safe default, so cplt pins nothing and lets `gh` resolve the target itself
rather than choosing a repository on the command's behalf.
## Command classifications
### Always allowed (reads)
```
gh pr list/view/status/diff/checks
gh issue list/view/status
gh repo view/list/set-default/gitignore/license
gh run list/view/download/watch
gh workflow list/view
gh release list/view/download/verify
gh search *
gh auth status # but not with --show-token or -t, see below
gh auth git-credential # git credential helper, required for HTTPS push
gh config list/get/clear-cache
gh secret list
gh variable list/get
gh label list
gh cache list
gh ssh-key list
gh gpg-key list
gh org list
gh copilot *
gh ruleset *
gh attestation *
```
### Scope-checked (current-repo writes)
```
gh pr create/comment/edit/review/ready/close/reopen/checkout/update-branch
gh issue create/comment/edit/close/reopen/develop
gh label create/edit
```
### Blocked (destructive/out-of-scope)
```
gh repo clone/create/delete/archive/fork/rename/edit/sync/deploy-key
gh pr merge/revert/lock/unlock
gh issue delete/transfer/lock/unlock/pin/unpin
gh release create/edit/delete/upload
gh secret set/delete
gh variable set/delete
gh workflow run/enable/disable
gh run rerun/cancel/delete
gh auth login/logout/refresh/setup-git/switch
gh auth token (served from one-time cache, see below)
gh auth status --show-token / -t (plain gh auth status stays allowed)
gh gist clone, gh label clone
gh config set
gh extension install/remove/upgrade/exec
gh gist create/edit/delete
gh ssh-key add/delete
gh gpg-key add/delete
gh label delete/clone
gh cache delete
gh codespace *
gh project * (except list/view/field-list/item-list)
```
## `gh auth token` and token isolation
`gh auth token` gets a one-time-read mechanism that hands Copilot its auth token
without exposing it to subprocesses:
1. Before launching the sandbox, cplt runs `gh auth token` (outside the sandbox)
2. Writes the token to a file in the scratch dir (`{scratch}/.gh-token`, mode 0600)
3. On Copilot's first call to `gh auth token`, the wrapper reads and returns the
cached token, then **immediately deletes the file**
4. Any subsequent call by tools or MCP servers gets "no cached token available"
A `GH_TOKEN` environment variable would be inherited by every tool, MCP server,
and subprocess the agent spawns. The file gives it to the first reader only,
which is Copilot at startup. Copilot then caches it in memory, so one read is
enough. The file exists for well under a second, the token never appears as an
environment variable, and after the delete no subprocess can get it back via
`gh auth token`.
This path applies only:
- to the Copilot agent (other agents have their own auth mechanisms)
- when `block_auth_token = true` (default)
- when `GH_TOKEN`/`GITHUB_TOKEN` is not already set in the environment
- when the repo's `[deny] env` does not list `GH_TOKEN`
That last one suppresses this token handover, not every credential source:
the cache is suppressed as well as the environment variable.
The guarded `gh auth token` returns nothing and no
`.gh-token` file is written. Denying only `GITHUB_TOKEN` or
`COPILOT_GITHUB_TOKEN` is a narrower statement and leaves this channel open.
It falls back gracefully if `gh` is not installed or not authenticated.
On Linux, host authentication and token handover remain recommended when
native Secret Service login fails. Stripping `DBUS_SESSION_BUS_ADDRESS` affects
discovery, not API access: keyring-file denies do not block D-Bus credential
reads. Native login depends on the backend and agent version; a vault prompt
alone does not establish the cause. Use `inject_token` or a manually supplied,
session-only token rather than plaintext storage. See [Linux keyring access](known-impacts.md#linux-keyring-access-depends-on-the-backend-and-agent-version).
### Copilot asks for `/login` on Linux
`/login` cannot complete from inside the sandbox: there is no browser, and
the device-code URL fails too. Sign in on the host with `gh auth login`, then
run `cplt config set gh_guard.inject_token true --force`. The token is then
visible to every process in the sandbox.
cplt prints this fix once at launch when `gh_guard` is on, the scratch dir is
on, `quiet` is off, `sandbox.keychain_substitute` is not `false`, and no token
is reachable. If `~/.copilot/config.json` records a login, the line is hedged:
cplt could not pass that login to the sandbox, and it names the reason when it
knows it. With `inject_token` already on but gh not logged in on the host, cplt
says to run `gh auth login`.
`inject_token` requires `gh_guard.enabled`: the injection is part of installing
the gh wrapper, which the guard owns. With the guard off it does nothing, and
on Linux cplt says so at launch rather than leaving a key that reads as true and
has no effect. On macOS the key has no effect either way, and cplt prints the
no-op line below.
On macOS `inject_token` has no effect. `gh` gets its token from the exec token,
and Copilot signs in through the Keychain substitute, which needs
`sandbox.keychain_substitute = true` (the default for Copilot). cplt still
accepts the key and never refuses a launch over it; while it is `true`, cplt
prints one line at launch and `cplt check` lists it. Remove it. On Linux the substitute does nothing, so keep the key set to `true` for the case above, or anywhere you still want the token in the environment.
`cplt config set` refuses it without `--force`, and `cplt config show` marks it
`⚠ DANGEROUS`, because the token is then inherited by every process in the
sandbox — and readable from any of them, through `/proc//environ` on Linux
or `ps -E` on macOS:
```toml
[gh_guard]
inject_token = true # injects GH_TOKEN env var (visible to all subprocesses)
```
A token in the environment, exported or injected, is readable: `block_auth_token`
blocks `gh auth token` and `--show-token`, not `echo $GH_TOKEN`. See the
[per-agent table](../SECURITY.md#github-token-handling-per-agent).
### How `gh` itself authenticates
Inside the sandbox `gh` usually cannot read its token: it is in the Keychain, which most agents do not get, and token variables are stripped for every agent except Copilot. So with the gh guard on and `gh` logged in on the host, cplt writes the token at launch to `$TMPDIR/.gh-exec-token` (mode `0600`), the exec token. Copilot on macOS is skipped when it has the Keychain or the substitute's `GH_TOKEN`. When the wrapper runs an approved command, it sets `GH_TOKEN` from that file on the real `gh` process only. The agent's environment never gets it, but whatever the real `gh` starts inherits it: `GH_PAGER`, `BROWSER` and `EDITOR` programs, extensions under `unknown_command = "allow"`, and git from `gh pr checkout`. That is the same trust level as the readable file.
HTTPS `git push` goes through the wrapper too. `gh auth setup-git` writes the credential helper with an absolute path (for example `!/opt/homebrew/bin/gh auth git-credential`), which makes git run the real `gh` directly. So when the wrapper is installed, cplt appends `credential.https://github.com.helper = !gh auth git-credential` for the sandbox through `GIT_CONFIG_COUNT`/`GIT_CONFIG_KEY_n`/`GIT_CONFIG_VALUE_n`. Git runs the helper through `sh`, so it resolves to the wrapper on `PATH`. Git tries helpers in order: your own (osxkeychain, Git Credential Manager, `store`, the absolute-path `gh`) run first, and when none returns a credential, for example because the absolute-path `gh` cannot reach the Keychain, git falls through to the wrapper. Existing `GIT_CONFIG_*` entries are kept and the new one appended. Helpers for other hosts are unchanged. No file is written; your git config is untouched.
An explicit `GH_TOKEN` or `GITHUB_TOKEN` in the environment wins, and `deny.env` naming `GH_TOKEN` turns the file off. The file is not deleted, since every `gh` call reads it. Like `.gh-token`, it is not a boundary: the agent runs as the same user and can read it.
A host that authenticates `gh` only through a `GH_TOKEN` environment variable, without `gh auth login`, has no `hosts.yml`, so no file is written. Use `--pass-env GH_TOKEN` there.
## `gh api` handling
`gh api` gives raw API access, so it is classified per request rather than per
subcommand.
| Condition | Default | With `allow_api_write = true` | Reason |
|-----------|---------|-------------------------------|--------|
| No method flag (implicit GET) | ScopeCheck | ScopeCheck | Read operation, must target current repo |
| `-X GET` | ScopeCheck | ScopeCheck | Explicit read, must target current repo |
| `-X POST/PUT/PATCH` | Block | ScopeCheck | Write operation, opt-in required |
| `-X DELETE` | Block | Block | Destructive, always blocked |
| `-f`, `-F`, or `--input` present | Block | ScopeCheck | Input implies write, opt-in required |
| `graphql` endpoint | See [below](#gh-api-graphql) | See [below](#gh-api-graphql) | Read-only queries of allowlisted pull request and review-thread fields on a repository in scope, and three review-thread mutations with verified targets. Everything else is blocked |
| Write to `repos/{o}/{r}/pulls/{n}/merge` or `repos/{o}/{r}/merges` | Block | Block | A merge must go through `gh pr merge` and its `allow_pr_merge` check |
Note that `allow_api_write = true` scope-checks writes rather than freeing them.
Cross-repo writes are still denied.
**Breaking change:** before `allow_pr_merge` existed, `allow_api_write = true`
let `gh api -X PUT repos/{o}/{r}/pulls/{n}/merge` (and `POST repos/{o}/{r}/merges`)
through. Those writes are now refused for everyone, whether or not
`allow_pr_merge` is on, because a REST merge skips the `allow_pr_merge` check
and gh's own refusal to merge a blocked pull request without `--admin`. Merge
with `gh pr merge` instead. A GET on the merge endpoint, which reports whether
a pull request is merged, still passes.
This does not make `allow_api_write` safe for the base branch. It still allows
other writes that move it: a contents `PUT` with `branch=main`, a `PATCH` to
`git/refs/heads/main`, `merge-upstream`. Only the repository's rulesets stop
those; the shim does not.
**Enable in config:**
```toml
[gh_guard]
enabled = true
allow_api_write = true
```
**Or for a single run:**
```bash
cplt --allow-api-write -- -p "post review comment replies"
```
> **Note:** `allow_api_write` has no effect on `gh api graphql`. What GraphQL allows is
> fixed, and described in [`gh api graphql`](#gh-api-graphql).
### API scope enforcement
GET requests are repo-scoped. Only endpoints matching
`/repos/{current-owner}/{current-repo}/...` are allowed, which blocks:
- `/orgs/.../audit-log`, org audit logs, which contain employee PII
- `/orgs/.../members`, org membership enumeration
- `/orgs/.../teams`, team structure
- `/user/repos`, private repos across all orgs
- `/users/.../repos`, other users' repo lists
Agents that need current-repo data (issues, PRs, actions, commits) work normally.
If an agent needs org-level access, the human should run those commands outside the sandbox.
### `gh api graphql`
Some review actions exist only in GraphQL: which review threads are still open
(`isResolved`), and resolving one. So `gh api graphql` is allowed in two narrow
shapes and refused in every other.
**Read-only queries of review threads in a repository in scope.** Every root
field must be `repository(owner: …, name: …)` for a repository in the scope
set, or `__typename`. `viewer`, `search`, `node`, `organization` and the rest of
the root are refused. Below the root, each field must be on an allowlist for the
type it is selected on (`FIELDS` in `src/gh_graphql.rs`):
| On | Fields |
|----|--------|
| `Repository` | `pullRequest(number:)`, `pullRequests(first, last, after, before, states, orderBy, labels, headRefName, baseRefName)` |
| `PullRequest` | `id`, `number`, `title`, `state`, `url`, `isDraft`, `headRefName`, `baseRefName`, `reviewThreads(first, after)` |
| `PullRequestReviewThread` | `id`, `isResolved`, `isOutdated`, `path`, `line`, `originalLine`, `startLine`, `diffSide`, `comments(first, after)` |
| `PullRequestReviewComment` | `id`, `body`, `url`, `createdAt`, `path`, `author { login }` |
| each connection | `nodes`, `edges { cursor node }`, `totalCount`, `pageInfo { hasNextPage hasPreviousPage endCursor startCursor }` |
| mutation payloads | `thread { id isResolved }`, `comment { id url }`, `clientMutationId` |
| any type | `__typename` |
Any other field or argument is refused, and so is every fragment (named,
spread or inline).
```bash
gh api graphql -f query='{
repository(owner: "navikt", name: "cplt") {
pullRequest(number: 414) {
reviewThreads(first: 100) { nodes { id isResolved path } }
}
}
}'
```
**Three review-thread mutations**: `resolveReviewThread`,
`unresolveReviewThread` and `addPullRequestReviewThreadReply`. Each takes an
`input: { … }` object literal. Before `gh` runs, the gate looks up every node ID
the mutation names (`threadId`, `pullRequestReviewThreadId`,
`pullRequestReviewId`) with a read-only `node(id:)` query, and requires the node
to be of the expected type and to belong to a `https://github.com/` repository
in the scope set. If a lookup fails, returns another type, or names another
repository, the command is refused.
```bash
gh api graphql -f query='mutation($id: ID!) {
resolveReviewThread(input: {threadId: $id}) { thread { isResolved } }
}' -f id=PRRT_kwDO...
```
These are allowed without `allow_api_write`, like `gh pr comment` and
`gh pr review`, which also write to the pull request in scope. They are refused
when `scope_check` is off, because then nothing holds a GraphQL request to a
repository.
#### How the request is checked
The gate parses the GraphQL document itself with a strict parser, and refuses
anything it cannot parse. It does not match text. Beyond what is described
above, it refuses:
- more than one operation in a document (so `operationName` never picks an
unchecked one), and every `subscription`
- every fragment: definitions, spreads and inline fragments, wherever they
appear. An alias never counts: in `resolveReviewThread: deleteIssue(…)` the
field is `deleteIssue`
- any field or argument not in the allowlist above for its parent type
- a duplicated argument or input field (`threadId: "a", threadId: "b"`), so the
guard and the server cannot read different values
- names and IDs it cannot know the value of: a string with escapes, a block
string, an unbound variable, a variable bound by `-F` to a number, boolean or
null, and `input: $var` in place of an object literal
- a variable named `operationName` or `endCursor` may never supply a name or
ID: `gh` sends the first outside `variables`, and `--paginate` overwrites the
second with a value from the response
- the ways `gh` sends something other than the arguments the guard saw:
`--input`, `-H`, `-p`, `-X` other than `POST`, `-F key=@file`,
`{owner}`/`{repo}`/`{branch}`/`:owner` placeholders in `-F` values (a branch
name can contain GraphQL punctuation), nested `key[sub]=` fields, attached
short flags such as `-fquery=…`, and a `--hostname` other than `github.com`
- the endpoint spelled any way but `graphql` (`/graphql`, a full URL,
`graphql?x`): those stay blocked as before
#### Threat model
What an agent can do with this:
- read the allowlisted pull request, review-thread and comment fields of a
repository in scope, including comment bodies and authors' logins
- resolve, unresolve and reply to review threads on pull requests in a
repository in scope
What it can't do through the guard:
- run any other mutation, on any repository
- write to a review thread outside the scope set
- start a query at another repository, the viewer, an organization, a search,
or an arbitrary node
- follow an edge out of the repository: owners, users, other repositories, fork
parents, cross-references, file contents (`object`) and the viewer's
repositories are not on the allowlist
Where it stops:
- **The allowlist depends on GitHub's schema.** Each allowlisted field is a
scalar or leads only to another allowlisted type. If GitHub changed one of
these fields to return something else, the guard would not notice.
- **The lookup runs in the sandbox, as the agent.** The gate calls the real
`gh` with the agent's environment, minus `GH_HOST` and `GH_REPO`, just as it
runs the approved command. That keeps both on the same host, but it stops a
cooperative or confused agent, not a hostile one. A hostile agent can already
call the real `gh` directly (see
[What it does NOT protect against](#what-it-does-not-protect-against)).
- **Check and use are separate calls.** A node's repository does not change
after the lookup, because review threads and reviews do not move between
repositories. But the lookup and the mutation are two requests.
`cplt check exec gh api graphql …` reports the static verdict. For a mutation
it says the command is allowed if the targets are in scope, because the lookup
only happens when the command runs.
### Why `gh api` is stricter than other commands
Higher-level commands like `gh repo view`, `gh repo list`, and `gh issue list` are
allowed cross-repo. They output curated, read-only data rather than raw API
responses with PII, agents commonly need them (checking dependencies,
understanding upstream issues), and they do not expose sensitive org internals
such as audit logs, SSO identities, or member emails.
Raw `gh api` can reach any REST endpoint, including exactly that sensitive
org and user data, so it gets the tighter rule.
## Error messages
Every block starts with `⚠️ BLOCKED by sandbox:`, followed by the reason and a
line asking the agent to note it for the human operator and carry on. The
opening lines:
```
⚠️ BLOCKED by sandbox: 'gh repo delete' is not allowed in this environment.
⚠️ BLOCKED by sandbox: 'gh pr create' targets 'other/repo' which is outside the current repo 'navikt/cplt'.
⚠️ BLOCKED by sandbox: 'gh newthing' is not recognized by the policy table.
⚠️ BLOCKED by sandbox: 'gh pr create' cannot verify target repository scope.
⚠️ BLOCKED by sandbox: 'gh api' targets an endpoint outside 'https://api.github.com'.
⚠️ BLOCKED by sandbox: 'gh issue list' targets GitHub host 'ghe.example.com', outside the approved host 'github.com'.
⚠️ BLOCKED by sandbox: revealing the GitHub token is not allowed in this environment.
```
The full message bodies live in `src/gh_proxy.rs`.
## Git push prevention
cplt installs a git wrapper alongside the gh wrapper. It blocks `git push`,
`git request-pull`, `git send-pack`, and `git subtree push` while letting local
git work through. Under the default `standard` preset only pushes to the
default branch are blocked (`protect_default_branch_only`), so an agent can
push a feature branch; under `strict` every push is blocked and a human has to
push. It also blocks
anything that would retarget the `origin` remote, since that is where the gh
guard reads the enforced scope from, and it refuses git subcommands it does not
recognize while push prevention is on. Blocked commands, the
`protect_default_branch_only` mode, branch and refspec parsing, and the git-side
defense-in-depth layers are all covered in [git-guard.md](git-guard.md).
## Maintenance
The policy table lives in `src/gh_proxy.rs`. Commands GitHub adds to the `gh` CLI
are blocked automatically until someone classifies them, which is the safe
default but means the table needs periodic updates.
### Adding new commands
1. Check `gh help ` to understand what it does
2. Add entries to the `POLICY` table in `src/gh_proxy.rs`
3. Add tests to the `#[cfg(test)]` module
4. Run `cargo test --lib gh_proxy`
### Detecting new commands
To find commands that exist in `gh` but aren't in the policy table:
```bash
gh help --all 2>&1 | grep "^ " | awk '{print $1}' | sort -u
```
Compare against the commands in `POLICY`.
## Security boundaries
What the gh/git guard stops, and what it does not.
### What it protects against
| Threat | How it's stopped |
|--------|-----------------|
| Agent merges a PR without human review | `gh pr merge` is in the Block tier. With `allow_pr_merge = true` it is allowed only for the account's own PR into a branch where a ruleset the account cannot bypass requires an approving review that a new push dismisses; `--admin` is always refused. Status checks alone do not count. The merge runs pinned to the checked PR number and head commit (`--match-head-commit`), so a bare merge cannot switch PRs after the check |
| Agent deletes a repository | `gh repo delete` is in the Block tier |
| Agent creates releases or uploads artifacts | `gh release create/upload` blocked |
| Agent triggers CI workflows | `gh workflow run` blocked |
| Agent pushes directly to main/master | `git push` blocked (or only default branch blocked with `protect_default_branch_only`) |
| Agent force-pushes and rewrites history | `--force`/`--force-with-lease` detection on push |
| Agent exfiltrates `gh auth token` value | `gh auth token` blocked; token served via one-time-read file (deleted after first use). Env var injection only with `inject_token=true`, or for Copilot on macOS through the Keychain substitute (`sandbox.keychain_substitute`, on by default for Copilot), which trades the Keychain grant for the token in `GH_TOKEN`, so `block_auth_token` no longer keeps it out of the environment. Best-effort, not a same-UID boundary ([SECURITY.md](../SECURITY.md#honest-gaps)): `gh auth git-credential get` is allowed so HTTPS push works, and it prints `password=` wherever the agent still has a token source |
| Agent modifies secrets/variables | `gh secret set/delete`, `gh variable set/delete` blocked |
| Agent installs malicious gh extensions | `gh extension install/remove` blocked |
| Agent operates on other repositories | `-R other/repo` checked via ScopeCheck |
| Agent uses `gh api` POST to mutate state | Presence of `-f`, `-F`, `--input`, or non-GET method is blocked by default; opt-in with `allow_api_write = true` (scope-checked to current repo) |
| Agent uses `gh api graphql` to read outside the repository | Queries must start at a repository in scope and select only allowlisted pull request and review-thread fields; fragments are refused. See [`gh api graphql`](#gh-api-graphql) |
| Agent uses `gh api graphql` for mutations | Only `resolveReviewThread`, `unresolveReviewThread` and `addPullRequestReviewThreadReply` are allowed, after a lookup shows every target is in a repository in scope. The document is parsed, and anything unparseable is refused. See [`gh api graphql`](#gh-api-graphql) |
### What it does NOT protect against
| Gap | Explanation | Mitigation |
|-----|-------------|------------|
| **Data exfiltration via `gh api` GET** | The agent can `gh api /repos/owner/repo/contents/secret.yml` to read and then exfiltrate via network | Use network proxy domain filtering (`--blocked-domains`) |
| **Direct `curl` with `GH_TOKEN`** | Agent can `curl -H "Authorization: token $GH_TOKEN" https://api.github.com/...` bypassing the gh wrapper entirely | `GH_TOKEN` is only injected for Copilot; other agents get it only on the real `gh` process, or by reading the exec token at `$TMPDIR/.gh-exec-token`; network proxy logs all outbound connections |
| **Wrapper bypass via real binary path** | Agent can `cat $(which gh)` to discover the real `gh` path in the wrapper script and call it directly | The wrapper uses `exec` so the real path is in the script; Seatbelt blocks writes to the scratch bin dir, but the path stays readable |
| **Agent edits `.github/workflows/`** | Agent can write CI configs that run on push, so destructive actions happen in CI, not locally | Code review (git diff). Under `standard` a feature-branch push is allowed and can trigger `on: push` workflows; only `strict`, which blocks every push, keeps them from running |
| **Agent creates commits on main locally** | `git commit` on the main branch is allowed, being a local operation | The push guard prevents pushing those commits to the default branch |
| **`git send-pack` plumbing variants** | Covered, `send-pack` is in the block list | Already handled |
| **Agent reads project source code** | Project dir is fully readable (agent's core function) | Network domain filtering to prevent exfiltration |
| **Heuristic parser confusion** | Extremely unusual flag combinations might confuse the parser | Fail-closed: unknown states are blocked, and the e2e guard suite covers bypass variants |
| **New `gh` commands added upstream** | Unknown commands get `Decision::Unknown` and are blocked by default | Periodic policy table updates; `unknown_command = "block"` is default |
### Where the guard sits
| Layer | What it does | Can it be bypassed? |
|-------|--------------|---------------------|
| 1. Kernel sandbox (Seatbelt/Landlock) | Blocks credential file reads, exec from tmp, localhost | No, not by any user-space code |
| 2. Environment sanitization | Only safe env vars pass through; the agent never sees `AWS_SECRET_KEY`, `NPM_TOKEN`, and friends | No |
| 3. gh/git command guard (this feature) | Prevents destructive GitHub/git operations by shadowing the real binary in `PATH`; policy is baked at launch so the agent can't modify it | Yes, if the agent discovers the real binary |
| 4. Network proxy (domain filtering) | Blocks known exfiltration domains, logs all outbound CONNECT requests | Partly, see the gaps above |
| 5. AGENTS.md instructions | Tells the agent not to push/merge/delete | Yes, compliance only, nothing enforces it |
The gh/git guard is Layer 3. It sits between kernel enforcement, which cannot be
bypassed, and instructions, which cannot be enforced. Treat it as a strong
deterrent that stops compliant agents and catches accidental destructive
operations, not as a hard boundary against a determined adversary who already
has code execution.
The hard boundary is Layer 1. Credentials, SSH keys, and secret files are
physically inaccessible no matter what the agent does.
## Testing
The command guards are covered by `tests/e2e_guards.rs` and the `#[cfg(test)]` module in `src/gh_proxy.rs`, covering:
- Command parsing: plain, with flags, with `-R`, and API commands
- Policy evaluation across all three tiers, wildcard groups, and default-deny
- Scope checking: matching, non-matching, case-insensitive, `.git` suffix
- API endpoint path extraction and cross-repo detection
- Input flag bypass attempts (`--field=value`, `-fvalue`, `--input=-`)
- GraphQL: parsing, the query and mutation allowlists, smuggling attempts, target verification
- Force-push flag detection, including the `--force-with-lease=ref` form
- URL parsing: HTTPS, SSH shorthand, SSH URL, non-GitHub
- Wrapper script generation for both gh and git
- Git push blocking with flags like `-c key=value`, and git read/local-write operations passing through
- `protect_default_branch_only` branch and refspec parsing
- Full sandbox integration, wrapper through gate to real binary
Run tests:
```bash
cargo test --lib gh_proxy # unit tests (parser, policy, helpers)
cargo test --test e2e_guards # E2E tests (full binary pipeline)
mise run check # full suite
```