# Security model This is the security architecture of cplt: the threat model it addresses, the defense layers it implements, and how automated tests validate them. ## Supported agents cplt sandboxes AI coding agents. Currently that means **GitHub Copilot CLI**, **[OpenCode](https://opencode.ai/)**, **Google Gemini CLI**, **[Antigravity CLI](https://github.com/google-antigravity/antigravity-cli)**, **[Pi](https://github.com/earendil-works/pi)**, **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)**, **[goose](https://github.com/aaif-goose/goose)**, and **DeepSeek Harness** (`dsh`). All share the same core sandbox (deny-default Seatbelt/Landlock profile, env sanitization, scratch dir), with per-agent adaptations: | Property | Copilot | OpenCode | Antigravity | Pi | Claude Code | |-----------------|-------------------------------------|---------------------------------------------------------------------|----------------------------------------------|---------------------------------------------|----------------------------------------------| | Auth mechanism | GitHub token (Keychain, `GH_TOKEN`) | `/connect` device flow to `auth.json`, or API keys | Google OAuth (browser / keyring session) | API keys (Anthropic, OpenAI, Gemini, etc.) | OAuth token (Keychain / `~/.claude`) or API key | | Auth in sandbox | Environment token, one-shot gh guard cache, or native Keychain login on macOS | Copilot auth stored in data dir; third-party keys need `--pass-env` | OAuth/session data stored in `~/.gemini/*` | Keys need `--pass-env` | OAuth stored in `~/.claude`/Keychain; keys need `--pass-env` | | Config dir | `~/.copilot` (read/write; Linux adds execute, see below) | `~/.config/opencode` (read-only) | `~/.gemini/config` (read/write) | `~/.pi/agent` (read-only root; `sessions/`, `prompts/`, `themes/`, `tools/`, `tmp/` writable) | `~/.claude` + `~/.claude.json` (read/write) | | Data dir | `~/Library/Caches/copilot` | `~/.local/share/opencode` (write, no exec) | `~/.gemini/antigravity-cli` (read/write) | `~/.pi/agent/bin` (read + exec) | N/A (in config dir) | | State data dir | N/A | `~/.local/state/opencode` (write, no exec) | N/A | N/A | N/A | | Keychain access | Yes¹ | No | Yes (OAuth/keyring flow)¹ | No | Yes (macOS OAuth token storage)¹ | | SEA extraction | Yes (pre-sandbox) | No | No | No | No | | Env isolation | Exported token vars passed; resolved gh substitute or opt-in injection adds a token; `COPILOT_*` passed | suppressed (see below) | suppressed (see below) | suppressed (see below) | suppressed (see below); `DISABLE_AUTOUPDATER=1` injected | | Auto-detected | Yes (priority 1) | Yes (priority 2) | Yes (priority 3) | No (explicit only, name collision risk) | No (explicit only) | ¹ On macOS the Keychain grant is dropped when a substitute resolves (`sandbox.keychain_substitute`: on by default for Copilot and Claude Code, opt-in for the others); see [Keychain access is all-or-nothing](#keychain-access-is-all-or-nothing). For Copilot that is a nonblank exported `COPILOT_GITHUB_TOKEN`, `GH_TOKEN` or `GITHUB_TOKEN` surviving `deny.env`, or gh's stored github.com token injected into the first permitted name. A failed retrieval or detected account mismatch keeps the grant when no exported substitute resolves. For Claude Code it means `CLAUDE_CODE_OAUTH_TOKEN` is exported and not denied. Resolution does not validate the credential. ### GitHub token handling per agent | Agent | `GH_TOKEN`/`GITHUB_TOKEN` from host env | `inject_token` (deprecated²) | `gh auth token` | Real `gh` behind the gh guard | Keychain | | --- | --- | --- | --- | --- | --- | | Copilot | Passed unless denied | First of `GH_TOKEN`, `GITHUB_TOKEN`, `COPILOT_GITHUB_TOKEN` not in `deny.env`; skipped for a surviving exported token or gh-token substitute | Served once from `.gh-token` when enabled and cached; skipped for a surviving exported token, denied `GH_TOKEN`, or gh-token substitute | Surviving `GH_TOKEN`/`GITHUB_TOKEN`, native Keychain access on macOS when granted, or `.gh-exec-token` when eligible. gh ignores `COPILOT_GITHUB_TOKEN` | Conditional on macOS¹ | | Every other agent | Stripped (`--pass-env GH_TOKEN` forwards it) | Ignored | "No cached token" (with `block_auth_token = true`, the default) | `GH_TOKEN` from the exec token | Antigravity, Claude Code and goose yes¹; the rest no | ² On macOS `gh_guard.inject_token` has no effect: the exec token covers `gh`, and the Keychain substitute covers 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 with the replacement. On Linux there is no Keychain grant to drop; for Copilot the substitute reads Copilot's own Secret Service login on the host with `secret-tool` and passes it as `COPILOT_GITHUB_TOKEN` (see [Linux keyring access](docs/known-impacts.md#linux-keyring-access-depends-on-the-backend-and-agent-version)). When no lookup runs or it finds nothing, this key is the recommended handover. Native access depends on the backend and agent version; the keyring is not guaranteed closed. `cplt check` also notes `GH_TOKEN` or `GITHUB_TOKEN` in `pass_env` for agents other than Copilot, since `gh` no longer needs it there; keep it if another tool uses it. **The exec token.** With the gh guard on and a scratch directory, cplt can write `$SCRATCH/.gh-exec-token` at mode `0600` when gh's effective `hosts.yml` exists and trusted stored-token retrieval succeeds. File existence alone is not proof of login. A surviving child `GH_TOKEN`/`GITHUB_TOKEN`, a gh-token substitute, or `deny.env` naming `GH_TOKEN` suppresses this cache. macOS Copilot also skips it when no substitute resolves, because its Keychain grant stays; Linux Copilot and other agents can use it. The gh wrapper sets `GH_TOKEN` from the file on the real gh it runs, for that process only. The cache does not put the token in the agent's environment; other handover channels can. HTTPS git authentication to github.com uses the same wrapper: cplt appends `credential.https://github.com.helper = !gh auth git-credential` through `GIT_CONFIG_*`, after the user's helpers. Those helpers retain precedence; other hosts are unchanged. A host with only an exported token and no `hosts.yml` gets no exec cache; non-Copilot agents need `--pass-env GH_TOKEN` to receive that exported token. `__CPLT_GH_TOKEN_DIR` points the guard to the actual scratch directory even if the user passes a different `TMPDIR`. The file remains for the session, is removed with the scratch directory on exit, and an orphaned cache is cleaned by a subsequent scratch-enabled launch. **The limit.** A sandboxed agent can get the host user's full gh token, unless `proxy.inject_github_token` is on (see below), in which case it gets a placeholder. It can read `$TMPDIR/.gh-exec-token`, or run `gh auth git-credential get`, which the gh guard allows because HTTPS push needs it. The gh guard limits which `gh` commands the wrapper runs. It does not limit what the token can do once the agent has it, for example with `curl` against the GitHub API. A narrower token for the sandbox is planned ([#731](https://github.com/navikt/cplt/issues/731)). **Retrieval is not authentication.** Doctor and launch use the same trusted-directory gh lookup and `gh auth token --hostname github.com`, with `COPILOT_GITHUB_TOKEN`, `GH_TOKEN` and `GITHUB_TOKEN` removed from that subprocess. The child has null stdin/stderr, a 5-second exit timeout and a 500 ms stdout-EOF grace. A nonempty result proves only retrieval from gh's stored credential source, not token validity, entitlement, selected Copilot account or long-session renewal. Doctor does not print the token. A Keychain grant or discovered native module is not proof of a stored login. **OpenCode** also gets that token as its GitHub Copilot login when its `auth.json` has no `github-copilot` entry. cplt sets `OPENCODE_AUTH_CONTENT`, which OpenCode reads instead of `auth.json`: your other providers copied from the file, plus a `github-copilot` entry holding the gh token. So for OpenCode the token is in the agent's environment, not only in the exec token file. An existing `github-copilot` entry (a `/connect` login or Enterprise), or an `auth.json` cplt cannot parse, leaves OpenCode alone. A normal run does not rewrite `auth.json`; `/connect` inside the sandbox does, and writes the gh token in as the `github-copilot` entry, which the next session then uses. `deny.env` naming `GH_TOKEN` or `OPENCODE_AUTH_CONTENT` turns this off. **GitHub MCP for OpenCode v2** ([#797](https://github.com/navikt/cplt/issues/797)). When the user's own OpenCode config (the config dir, or `OPENCODE_CONFIG_CONTENT` when it reaches the sandbox) names `https://api.githubcopilot.com/mcp` or `/mcp/readonly` in `mcp.servers` (or the legacy `mcp.`), cplt serves an endpoint at a random 128-bit path on its existing proxy port and points the entry at it with `"oauth": false` through `OPENCODE_CONFIG_CONTENT`. Project config never enables it, since the agent can write it. No sandbox rule is added: on Linux a Landlock port rule cannot be limited to loopback, so a new port would be reachable on every host. The path is compared in constant time, and the proxy's connection limit, head size cap and head deadline apply. The endpoint holds the gh token, retrieved the same way as above, and does not put it in the sandbox. Every request goes to `https://api.githubcopilot.com/mcp/readonly` with `X-MCP-Readonly: true` and `X-MCP-Lockdown: true` forced. The entry's or agent's `Authorization`, `Cookie` and `X-MCP-*` headers are dropped (`X-MCP-Toolsets` passes), and a header with any control byte other than tab is refused. The upstream leg runs through the system `curl` from a trusted directory. The token and request go in on stdin rather than in argv, `-q` skips `~/.curlrc`, and `SSLKEYLOGFILE` is removed. The agent therefore gets read-only GitHub MCP tools with the user's identity and lockdown on, not the token. This channel does not change the [limit](#github-token-handling-per-agent) above: the token is still reachable through the exec token, and through `OPENCODE_AUTH_CONTENT` where that handover applies. `sandbox.github_mcp_proxy = false`, `--no-github-mcp-proxy`, or `deny.env` naming `GH_TOKEN` or `OPENCODE_CONFIG_CONTENT` turns it off. **Brokered local MCP servers** ([#824](https://github.com/navikt/cplt/issues/824), macOS, and Linux under Bubblewrap ([#834](https://github.com/navikt/cplt/issues/834)); the agent's sandbox then has the D-Bus and systemd sockets masked, and the server child gets its own sandbox, described below). A local MCP server with a broker spec in `~/.config/cplt/mcp/` runs on the host as a child of cplt, not in the sandbox, so it can use its own host login (for `nais`, the keychain) without the sandbox getting that grant. Specs are read only from the user config dir, never from the repository. A symlinked spec is refused, and so is a spec whose file, or any entry on the way to it, the session's final sandbox policy makes writable (tool caches, agent dirs, the shared git dir and every `allow.write`, file grants included). `cplt mcp status` applies the same check; run outside a project (from `~`, say) it checks against an empty stand-in project and reports `"project": null`. cplt refuses a spec that names an unknown credential mechanism, runs anything other than the mechanism's fixed command (for `nais-keychain`, `nais alpha mcp serve`; the `none` mechanism has no fixed command and no probe, so it runs any command in the trusted dirs, the same trust as `allow.exec`), names a command outside the trusted dirs, holds a secret value or a credential placeholder, or allows a `write` or `external` tool without `writeConsent` (a `host-exec` tool never). The child starts in `/`, never the agent-writable project (so `python3 -m` cannot import planted code), with a cleared environment (`HOME`, a trusted-dirs `PATH`, `TMPDIR`, `LANG`, an empty `NO_PROXY`, and `HTTPS_PROXY`/`HTTP_PROXY` pointing at its own CONNECT-only proxy on 127.0.0.1). That proxy allows only the spec's `egressHosts` (exact or subdomain), port 443 only, refuses names that resolve to loopback, private or link-local addresses, and logs each refusal as DENIED. The agent's allowlist is not changed. On macOS the child runs under its own Seatbelt profile ([#849](https://github.com/navikt/cplt/issues/849)): outbound TCP reaches only that proxy port, so a program that ignores the proxy variables gets `EPERM`; UDP is denied; writes go only to its temp dir and its mechanism's config dir (for `nais-keychain`, `~/Library/Application Support/nais`, plus `~/Library/Keychains/login.keychain-db` and its `.sb-*` temp siblings, which Security.framework rewrites in the caller's process on a token refresh); reads, the keychain through securityd, and name lookups stay open; no listener and no unix-socket connects. Mach lookups are limited to the services a real nais run was measured to use ([#911](https://github.com/navikt/cplt/issues/911)): `com.apple.SecurityServer` (securityd, for the keychain), plus the system services `bsd.sb` allows. An XPC service that makes requests for its caller, such as `nsurlsessiond`, is therefore out of reach. What remains: securityd and the `bsd.sb` services still act on the child's behalf, so this contains a cooperating server like nais, not an arbitrary hostile binary. On both systems the credential probe of `cplt mcp status` runs in the server's own sandbox and egress proxy ([#930](https://github.com/navikt/cplt/issues/930)), since nais reads `config.yaml` from the dir the server can write and a planted value there could otherwise make the probe run gcloud on the host. On Linux the child runs under Bubblewrap with its own network namespace ([#849](https://github.com/navikt/cplt/issues/849)): its only way out is the egress proxy, bound in as a unix socket and relayed to `127.0.0.1:` inside the namespace by a cplt relay process, so a direct connect gets `ENETUNREACH`; the root is read-only; `HOME` is an empty read-only tmpfs with only the mechanism's own entries bound back (for `nais-keychain`, the writable `~/.config/nais` under `XDG_CONFIG_HOME`, and nothing else), so sockets in `HOME` such as Docker Desktop's `~/.docker/desktop/docker.sock` are gone; writes go only to a private `/tmp` and that config dir; `/run`, the runtime dir and the Nix daemon's socket dirs are private, and bwrap 0.8 and later adds `--disable-userns` (`cplt doctor` warns below 0.8). A `keychain` mechanism gets a session bus filtered by `xdg-dbus-proxy --filter --talk=org.freedesktop.secrets` (resolved from the trusted dirs, living as long as the child), so it can reach the Secret Service and nothing else on the bus: not `systemd --user`, and no D-Bus activation of other services. The host bus itself is never bound in. Without xdg-dbus-proxy or a host bus the child gets no bus (`cplt doctor` warns). Other mechanisms get no bus. Without bwrap, or if the namespace cannot be built, the child does not start. The broker log is the `--proxy-log` file: every `tools/call` leaves one line there with the server id, tool, outcome (ALLOWED, DENIED with the reason, FAIL with the integer error code, or CANCELLED) and duration, never arguments or results; the terminal shows only DENIED lines and failures. The child's stderr goes to the broker log, at most 64 lines of 512 bytes, token-shaped runs redacted. The sandbox may connect only to the broker's socket, in a per-session directory outside every grant. The agent can only pick a declared server id. The broker parses every JSON-RPC line and forwards its own re-serialization, so the server sees exactly what was checked. It drops client notifications other than `initialized`, `cancelled` and `roots/list_changed` (an id-less `tools/call` never reaches the server), refuses a request whose id is already in flight, filters `result.tools` in every response, refuses batches and protocol versions other than 2025-06-18 and 2025-11-25, lists and allows only `tools.allow`, refuses `execute_graphql` arguments with a key that case-folds to `query`, and documents with a mutation, a subscription or more than one operation (fragments included, anything unparsable refused), refuses keys in the envelope, `params` and `arguments` that case-fold together or to a checked field (Go's `encoding/json` matches field names that way), caps in-flight requests at 8 (a client `notifications/cancelled` frees its slot and the late response is dropped; a request unanswered after 120 s gets a JSON-RPC timeout error, the server a cancel, and the slot is freed), drops server-initiated messages, and serves at most 32 connections at once (socket clients and egress tunnels), closing extra ones before any thread starts; a handshake that stalls ends after 10 s. Rejected-spec paths and reasons are escaped before they reach the terminal. Residual risk: the child runs trusted code on arguments the agent chooses, and can read what the user can (and send it to its `egressHosts`); on Linux it can reach unix sockets outside `HOME`, `/tmp` and `/run` (under `/var`, say), and read copies of `HOME` elsewhere in the root (a btrfs snapshot, a second bind mount): hiding `HOME` is a denylist, and a keychain server can read every Secret Service item the user's session has unlocked, not only its own: with gnome-keyring, every item in the login collection, including other apps' passwords and tokens, plus whatever gnome-keyring's internal interface allows over the same bus name; with KeePassXC's Secret Service integration, every entry in the groups it exposes. **Proxy-side token injection** ([#820](https://github.com/navikt/cplt/issues/820), `proxy.inject_github_token`: on in configs written by `cplt config init`, off in configs without the key). The sandbox gets a placeholder with the real token's prefix and length in every channel above. For `api.github.com`, `github.com`, `uploads.github.com` and `*.githubcopilot.com` on 443, after every proxy policy gate has passed, the proxy terminates TLS with a leaf from a per-session CA whose key exists only in cplt's memory and is name-constrained to `github.com` and `githubcopilot.com`. It restores the real token only in `Authorization` (`Bearer`, `token`, `Basic` password), never on the GitHub MCP paths or `TRACE`, and forces one request per connection so later bytes cannot be rewritten. The upstream leg trusts the system roots plus CAs from the user's env, held in memory; the bundles in the scratch dir are agent-writable and only affect what the agent trusts. This removes the long-lived token from the sandbox but not its use: the agent can still send requests through the proxy that carry it. `hosts.yml` remains readable. A setup failure refuses the launch; it never falls back to the real token. A token without a known GitHub prefix (`gh?_`, `github_pat_`) cannot get a placeholder and is passed as is. A token in the environment is readable: `block_auth_token` blocks `gh auth token`, not `echo $GH_TOKEN`. The direct-`curl` bypass in [gh guard security boundaries](docs/gh-guard.md#security-boundaries) applies to any agent that can read the token. ### Rules that apply to every non-Copilot agent - **Copilot env vars are suppressed.** `GH_TOKEN`, `GITHUB_TOKEN`, `COPILOT_GITHUB_TOKEN`, and every `COPILOT_*` variable are stripped for OpenCode, Gemini, Antigravity, Pi, Claude Code, goose, and DeepSeek Harness. OpenCode's Copilot provider uses its own auth file or the host gh login, and `gh` still authenticates through the gh guard; see [GitHub token handling per agent](#github-token-handling-per-agent). - **Third-party API keys are opt-in.** `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `OPENROUTER_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, `CLAUDE_CODE_OAUTH_TOKEN` (from `claude setup-token`), and the Bedrock/Vertex routing vars (`CLAUDE_CODE_USE_BEDROCK`, `AWS_BEARER_TOKEN_BEDROCK`, `CLAUDE_CODE_USE_VERTEX`, `ANTHROPIC_VERTEX_PROJECT_ID`, `GOOGLE_CLOUD_PROJECT`) are never passed through by default. You have to name each one with `--pass-env`. That keeps credentials from reaching a sandboxed process by accident. - **Host-persistence guard on the agent's own writable grants.** Most agents get their global config dir mounted read/write — and for some, more than that: the shell also gets the fish *data* dir, Claude a top-level file (`~/.claude.json`), OpenCode its data, state and cache dirs. Some files in those grants auto-execute the next time the agent runs *outside* the sandbox. goose is the exception: its config dir is read-only, because it rewrites `config.yaml` by rename, which needs directory write and would hand back the whole vector. Those paths are write-denied (`Agent::host_persistence_denies`): | Agent | Denied inside the agent's writable grants | | --- | --- | | Claude Code | `settings.json`, `statusline.sh`, `plugins/` | | Pi | `settings.json`, `trust.json`, `extensions/`, `npm/`, `git/`, `skills/` | | Antigravity | `config/hooks.json`, `config/mcp_config.json`, `antigravity-cli/bin/` | | Copilot | `settings.json`, `hooks/`, `mcp-config.json`, `lsp-config.json`, `extensions/`, `installed-plugins/`, `pkg/`, `skills/` | | Shell (fish) | `config.fish`, `conf.d/`, `functions/`, `completions/`, `vendor_conf.d/`, `vendor_functions.d/`, `vendor_completions.d/` | | goose | — (its whole config dir is read-only; see below) | | DeepSeek Harness | `cordis.patch.yml` (home-level overlay; `profiles/` stays writable so DSH can rewrite its boot include-root) | | OpenCode | — (skills live in its read-only config dir; see below) | **Hook and status-line scripts outside `~/.claude` ([#702](https://github.com/navikt/cplt/pull/702), [#705](https://github.com/navikt/cplt/issues/705)).** cplt reads the user-level `settings.json` (never `settings.local.json` or a project's `.claude/settings*.json`, which the agent can write) and grants read + execute on the one file each hook or `statusLine` command runs: the command itself, or the script an interpreter (`python3`, `node`, `bash`, `uv run`, ...) is given. Other arguments, such as a log file, are not granted. The granted file is write-protected inside the sandbox. Files in a credential directory, under the Claude config root, or under another writable grant are skipped, and a hook inside an `allow.write` tree stays exec-denied. A script that imports sibling files needs `allow.read` on its directory. On Linux the grant applies only when Bubblewrap is active, because only its read-only bind keeps `settings.json` out of the agent's reach. With Landlock alone the whole Claude config root is writable, so cplt drops the grant, warns, and the hook may fail. On Linux, cplt creates a missing `~/.claude/settings.json` (or its `CLAUDE_CONFIG_DIR` equivalent) as `{}` before every launch, with or without Bubblewrap, so Bubblewrap can bind it read-only; an existing file is never overwritten. On hosts that run Claude without Bubblewrap (Landlock only), the agent can edit `settings.json` during that session, and a later Bubblewrap launch trusts whatever hook paths it names; only scripts outside writable trees and credential directories get read + exec. **Hook and MCP scripts outside `~/.copilot` (macOS only, [#704](https://github.com/navikt/cplt/issues/704)).** cplt reads `~/.copilot/settings.json`, `hooks/*.json` and `mcp-config.json` (never a project's hook or MCP files, which the agent can write) and grants read + execute on the one file each hook command (`bash`, `command`, or `exec` + `args`) or MCP server `command` + `args` runs: the command itself, or the script an interpreter is given. All three are write-denied inside the sandbox, so the agent cannot add entries. A symlinked hook file or `hooks/` directory is not read. The other rules match the Claude Code grant: no other arguments, the granted file is write-protected, and credential paths and paths under `~/.copilot` are skipped. Linux is unchanged for now. For both: no grant is made when the configured script path, or any symlink crossed resolving it, lies in a tree the agent can write (project, `--repo-dir`, `allow.write`, a writable agent dir), since the agent could repoint that link at any file. Two of these go beyond "the file that names the code". Pi's `npm/` and `git/` hold the code of packages `pi install` already fetched: denying only the settings file stops a *new* entry being added while leaving the code an existing entry already points at editable in place, which loads on the next host run. Antigravity's `bin/` holds `agentapi` and `webm_encoder`, which it executes on the host. `skills/` is a third kind and the newest ([navikt/copilot#858](https://github.com/navikt/copilot/issues/858)). Nothing in it fires at launch. A skill is the instructions the model is offered in the *next* session, and the way a skill does work is by naming a script for the agent to run — `bash "$SKILLS_DIR//