# 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 ```