Trust model
July 30, 2026 · View on GitHub
This is the engineering-facing trust model for coop — the authoritative list
of trust boundaries, taint sources, and the invariants that hold the isolation
together. The review-security agent
reads this file, and the root CLAUDE.md "Trust model" section
points here. Apply these checks whenever you build or review a change.
This complements — it does not replace — SECURITY.md, which
is the vulnerability-disclosure policy. SECURITY.md tells outsiders how to
report a problem; this document tells contributors where the boundaries are so
they don't introduce one.
The core boundary: the VM
coop's isolation boundary is the guest VM itself — a Firecracker microVM on Linux, a Lima VM (Apple Virtualization.framework) on macOS. The point of the tool is to run AI coding agents (Claude Code, Codex) with broad autonomy inside that boundary, so the guest is deliberately permissive:
- The guest user has passwordless
sudo(NOPASSWD:ALL). - Claude runs with a managed
~/.claude/settings.jsoncarryingdefaultMode: bypassPermissions; thecodex/claudelaunchers pass--dangerously-bypass-approvals-and-sandbox/--dangerously-skip-permissionsunless the user passes--ask.
This is intentional and correct: there is no privilege boundary inside the guest to protect — the whole VM is the blast radius. The security model is "anything the agent does stays in the VM." Every rule below exists to keep that true: to stop guest-authored (therefore untrusted) data from escalating across the VM boundary into host code execution, host filesystem escape, or credential exposure.
Treat the guest as untrusted from the host's point of view, even though the user launched it.
Trust zones
| Zone | Trust | Notes |
|---|---|---|
Host user + config.toml | Trusted | config.toml cmd: values run arbitrary sh -c on the host (config.rs:resolve_cmd_value). The config file is a host code-execution surface; only the owner should write it. |
| coop process (host) | Trusted | Holds/relays secrets, constructs guest commands, runs iptables/firecracker via sudo. |
| The guest VM | Untrusted | Agent-controlled. Anything it emits — file contents, paths, archive members, command output — is a taint source once it crosses back to the host. |
| GitHub API / model endpoints / DNS | External | api.github.com (PAT probe, release metadata), the model endpoint, 8.8.8.8. Reached over the network; authenticated where applicable. |
Taint sources (treat as untrusted)
- Guest filesystem content pulled to the host.
workspace.rspull/tar_pipe_pull/rsync_pullbring guest-authored file contents, filenames, and symlinks onto the host filesystem. This is the widest guest→host channel and the primary place a path-traversal or symlink escape could land. - Guest command output read by the host. e.g.
check_guest_dirtyreadsgit status --porcelainfrom the guest. Today this only gates control flow / is printed to the user — it is never fed intosh -con the host. Keep it that way. - A fetched
devcontainer.json.git_repo_devcontainer.rs/devcontainer.rsparse devcontainer JSON that may originate from a remote repo. Its values configure the guest; they must never reach a hostcmd:evaluation or host shell. - Downloaded update artifacts.
update.rstarball +SHA256SUMSfrom the release host — gated by checksum and (best-effort) Sigstore attestation. - OCI feature blobs.
devcontainer_oci.rspulls devcontainer Features from GHCR; the install snippet runs in the guest, not the host.
Secrets and how they cross into the guest
coop relays several secrets from the host into the guest: ANTHROPIC_API_KEY,
OPENAI_API_KEY, GITHUB_TOKEN/PAT, CLAUDE_CODE_OAUTH_TOKEN, arbitrary
user env_forward entries, and the VM SSH key. The invariants:
- Never on argv. Secrets ride SSH
SendEnv(env channel) or process env (backend.rs:prepare_env_forwarding,EnvForward), or are piped via stdin (SshTarget::exec_with_stdin, the git-clone credential helperbuild_clone_with_token_script,curl -H @-ingithub_pat.rs/update.rs). Never build a command line with the secret as an argument — it is visible inps//proc. Known exceptions are the macOSsecurityand 1Passwordopbackends, which take the secret on argv because their CLIs offer no stdin path; this is documented at the call sites and limited to the store step. GITHUB_TOKENdefaults to Off. It is only forwarded with an explicitgithub = auto|env|patopt-in (backend.rs:resolve_github_token). When forwarded,bootstrap_agentsrunsgh auth setup-git, which makes the token persistent guest state (a git credential helper any guest process can read). A change that forwards it by default, or makes it persistent where it wasn't, is a finding.- Secret files stay
0600, dirs0700. File-backend PATs live at<state_dir>/github-pat/<account>.txt(secret_store.rs:store_file); all managed writes go throughfs_util::atomic_write_with_mode/atomic_write_ssh, which never relax permissions. - Secrets stay out of logs.
Cmd::redacted_argredacts argv in traces;EnvForward/Secret<T>customDebugimpls keep values out of debug output. Do not log a resolved secret. - The stored token is indirected, never inlined.
config.tomlholds acmd:...retrieval command (secret_store.rs:CmdToken), not the plaintext token; coop runs it at VM-start to fetch the value. - Proxy mode keeps the model API keys out of the guest entirely (issue #411,
opt-in
[proxy]). When enabled in remote model mode,ANTHROPIC_API_KEY(Claude) and/orOPENAI_API_KEY(Codex) are not forwarded (prepare_env_forwarding'ssuppress_anthropic_key/suppress_openai_key, one per provider); the host-sidecoop-proxyholds the real credential and the guest gets only a per-instance capability token (Claude viasettings.json, Codex via thecoop_localprovider's bearerenv_key). In proxy mode Codex's~/.codex/auth.jsonis also not staged onto the guest disk (it holds a refreshable subscription token). Each credential is resolved on the host and handed tocoop-proxyover stdin, never argv or disk; a resolution failure fails the boot closed. A per-VM override (proxy_state.rs,<inst.dir>/proxy.json) selects a different host-side credential for one VM — resolution is override → default → off — without changing the proxy binary or the capability token. Seecredential-proxy.md.
SSH boundary
- coop connects to the guest with
StrictHostKeyChecking=no,UserKnownHostsFile=/dev/null,IdentitiesOnly=yes(backend.rs:SshTarget::ssh_opts,workspace.rs:ssh_config_block). This is deliberate: guest keys are ephemeral and regenerated per VM, so there is no stable host key to pin. The trade-off is that a MITM on the path to the guest is not detected — acceptable because that path is loopback / a local TAP link to a VM the host itself owns. - The guest SSH key (
<data_dir>/vm_key, ed25519, passphrase-less by design) is a VM-access credential. Do not "harden" it with a passphrase (it must be used non-interactively), but do flag any change that exposes it or copies it off the host. - Flag any change that extends the no-host-key-checking options to a non-guest host.
Network
-
Port-forwards bind to
127.0.0.1only (port_forward.rs): both the collision probe and thessh -L 127.0.0.1:<host>:127.0.0.1:<guest>spec. There are no0.0.0.0binds anywhere in the tree. A new listener must bind loopback explicitly. -
Firecracker networking (
network.rs) sets up abr0bridge + per-instance TAP, enablesip_forward, and adds aMASQUERADE+FORWARDruleset so the guest reaches the internet through the host's default interface. A change that widens guest egress or adds inbound reachability is a finding. -
rewrite_host_urlrewrites a loopback local-model endpoint to the guest-visible gateway address so the guest can reach a model server running on the host; non-loopback URLs pass through unchanged. -
The credential proxy (issue #411) binds host loopback (
127.0.0.1, it refuses an unspecified address at bind) and is exposed into the guest with a per-instancessh -Rreverse tunnel (proxy.rs:spawn_reverse_forward), so — like the port-forwards above — it never binds a non-loopback interface and is reachable by exactly one guest, identically on both backends. It is guarded by a per-instance capability token and forwards only to a fixed per-provider upstream (api.anthropic.com/api.openai.com), never a guest-supplied host — one proxy process and one tunnel per (VM, provider). Its own TLS-verifying outbound HTTPS is the intended egress; a change that lets the guest influence the upstream host, or that binds anything wider than loopback, is a finding. -
The credential proxy is jailed (issue #411, slice 3).
coop-proxyholds the real credential and terminates connections the untrusted guest originates, so it is the feature's new attack surface; the jail bounds the blast radius of a proxy exploit. On Linux the proxy self-confines with Landlock, applied incoop-proxy'smainafter its libraries load and before it binds, so every tokio worker inherits the domain. The confinement is tiered by kernel capability: all filesystem writes and all programexecare denied as a hard floor (kernel ≥5.13); on kernels ≥6.7 TCPconnectis additionally limited to:443(upstream) and:53(DNS),bindto the listener port (see the host-kernel floor below). On macOS the launcher wraps the spawn insandbox-execwith the Seatbelt profile insrc/seatbelt-proxy.sb(deny by default; allow file reads, name resolution, the loopback listener, and egress only to:443/:53). Either way the launch is fail-closed on the high-value denials: if the filesystem-write/program-execfloor cannot be established — Linux kernel <5.13, or the macOS Seatbelt profile fails to apply — the proxy exits before serving,proxy.rs's post-spawn readiness probe never connects, and the VM start aborts, so the credential-holding proxy never runs without that floor. Reads stay open (the resolver config, the dynamic linker) and writes to the already-open stderr log fd are unaffected, because both jails gate path opens / new connections, not existing descriptors.Accepted limitations, by construction (do not file these as findings; do flag a change that widens them):
-
Port-scoped, not host-scoped. Both Landlock and Seatbelt filter by port, not hostname/IP. A fully compromised proxy could still open a TCP connection to some other host on
:443; the two upstreams' identity is enforced one layer up, at the proxy's TLS verification + pinnedHost, and the guest still cannot retarget them. Host-scoped egress would need IP pinning (fragile against CDN rotation) or an L7 egress proxy — out of scope and consistent with the DNS/CDN caveat #2 already accepts. -
UDP egress is not restricted on Linux. Landlock's network rules are TCP-only, and DNS needs UDP
:53, so arbitrary UDP egress remains possible for a compromised proxy. Closing it would neednftablesowner-matching (a dedicated uid +sudo+ per-instance teardown) — deliberately deferred. -
Host-kernel floor / deprecated primitive. The Linux jail degrades by kernel version rather than all-or-nothing:
- kernel ≥6.7 — full jail: filesystem-write + program-
execdenied and TCP egress port-scoped to:443/:53; - kernel 5.13–6.6 — filesystem-write + program-
execdenied, but Landlock has no network rules, so TCP egress is not scoped (open egress). Acceptable because the network tier is already the weak, port-scoped layer and upstream identity is TLS-pinned in the proxy; - kernel <5.13 — the floor cannot be established, so the proxy fails closed and refuses to start.
sandbox-execis officially deprecated but still functional and has no CLI successor — a pragmatic, aging primitive. - kernel ≥6.7 — full jail: filesystem-write + program-
These match the design's "Cross-platform hardening" note: the Firecracker (Linux) story is the stronger one; Lima (macOS) is closable to near-parity with Seatbelt, with the stated caveats.
-
coop update trust chain
Self-update (update.rs) must preserve, in order:
- Metadata from the pinned
trailofbits/coopGitHub repo (compile-time const). normalize_tag— the version tag is validated as semver before it enters the API URL path (path-traversal guard).- Mandatory checksum. The
SHA256SUMSasset must be present (install is refused otherwise) and every downloaded tarball is verified against it (verify_sha256, constant-sizeSha256Hashcompare). - Best-effort attestation.
gh attestation verify --repo trailofbits/coop(Sigstore provenance). Skipped with a logged note ifghis absent, and skipped entirely whenCOOP_UPDATE_API_BASE_URLis overridden (test mode). So provenance is not guaranteed on hosts withoutgh— checksum is the floor. - Extraction with
tar -xzf --no-same-owner --no-same-permissions(path-escape safe), then an atomicrename-over-self.
COOP_UPDATE_API_BASE_URL redirects the update origin and disables
attestation; the checksum then only proves integrity against that server's own
SHA256SUMS, giving no provenance. Only the pinned github.com default +
attestation provide provenance. Flag any change that widens where that override
is honored, or that softens any step above.
Documented, accepted trade-offs
These are deliberate and documented in CLAUDE.md /
docs/ARCHITECTURE.md. Don't "fix" them without
understanding the rationale; do flag a change that widens them:
DOCKER_INSECURE_NO_IPTABLES_RAW=1in the guest. The Firecracker CI kernel lacksiptable_raw, so Docker 28+ can't install its raw-table "direct access filtering" rule. Without it, other hosts on the guest's LAN could route to published container ports bound to loopback — irrelevant here because the guest's only network neighbor is the host and the VM is the isolation boundary.- iptables-legacy and a static
/etc/resolv.confin the guest — both work around the minimal CI kernel (no nftables, no systemd-resolved). Guest config only; no host-side trust impact.
Stop-and-confirm checklist
Stop and get explicit human confirmation before merging a change that:
- adds an outbound URL, a network listener, or an egress/
FORWARD/NAT rule — name the boundary it crosses and what authenticates it; - forwards a new secret into the guest, or makes an existing one persistent inside the guest;
- runs a host subprocess on tainted (guest- or fetch-derived) bytes — no shell
strings; use
Cmd::arg/RemoteCommand::arg; - writes a host filesystem path derived from tainted data — validate against traversal first;
- logs or traces tainted or secret content — that channel becomes an exfiltration path;
- softens any step of the
coop updateverification chain.