# Security model - **The bastion dials out.** Every lane is a reverse connection from this machine to the host. The only ports open on an outside interface are the tunnels' WireGuard UDP ports: one for each host paired over a tunnel, on every IPv4 and IPv6 address, at a port the system picks when that tunnel comes up. WireGuard drops every packet on one that does not come from the paired host's key; the only reply it ever sends anyone else is a cookie under a flood of handshakes, and only to a sender that already knows that tunnel's public key. Every other port the bastion opens binds loopback, except the optional memory-mirror forwarder, which also binds the replica container's private gateway address, with every accept source-checked. - **Inside the tunnel, only gated listeners.** The host can connect to up to three inference forwards (local ollama, an OpenAI-compatible server, a PII sterilizer), each a proxy to a service you run, on loopback by default and elsewhere only if you point it there by environment variable; and to one port per server in the service slot (18100 to 18199), joined to that server's socket; and to one port per coder run (18200 to 18299), joined to that run's control socket. Every connection is checked when it is accepted: a forward needs the pairing to hold the `inference` capability family, a service slot port needs the `services` family and a reach grant for that server naming that host, and a coder run port needs the `harness` family, the run to be that host's and within its deadline, and the connection to come from that host's own tunnel address. Nothing else in the tunnel accepts a connection. - **A Host on this machine gets the same walls.** Its lanes go over its lane socket with mutual TLS against the CA pinned at pairing, the pairing's name checked and the bearer on every lane; a pairing with no bearer opens none. A coder run it owns is a socket in its own runs directory (0660, group `privatae-host`, else root), checked at every accept as a run port is, and removed at run-down and at the watchdog's stop. The runs directory must be one only root can change (root's, writable by nobody else, under directories that are root's and not writable by others unless sticky), or no socket is made in it. On an anchored machine its run-up needs the standing coder grant like any pairing's. - **A Host with no platform reaches your model servers by name, never by address.** A pairing with a Host on this machine, or in a VM on it, has no tunnel and so no forwards. Its model servers ride the serve lane instead: the bastion keeps two workers parked on the Host's lane, and the Host hands one a connection that names a service, `ollama`, `llm` or `pii`. The bastion dials the address set on this machine for that service (`OLLAMA_ADDR`, `PRIVATAE_LLM_ADDR`, `PRIVATAE_PII_ADDR`), and only when every address it names is this machine: loopback, an address on its interfaces, or one the kernel delivers here, which includes a container on a network made on this machine. A server on another computer, an address or URL sent in place of a service, and a name the lane does not carry are each refused with a reason before anything is dialled. Each connection needs the `inference` family and goes through the forwards' own filter: the model scope, `/api/pull`, `/api/delete` and the other store paths refused without `models` (on an anchored machine, always), and the model lists rewritten. One pairing holds at most 16 streams here, and the 17th is refused as busy. A stream with no byte either way for 10 minutes is closed; a response's length is not capped. Each job is written to the pairing's audit log. Such a pairing's heartbeat lists what the lane serves (`lane_serves`) and advertises `embed`, `llm-openai` and `pii` only for those, and it never advertises the service slots, or coder runs over TCP, which do not run there. Before this, it advertised all of them with nothing serving them. Model management for such a pairing is held to the same rule: the beat's model-swap probe, `model:list`, `model:pull` with its probes, and `model:drop` reach `OLLAMA_ADDR` only when it is this machine, at the addresses checked; otherwise each is refused with the reason before anything is dialled or an approval is read, and the bastion prints a line in its output saying which address is not used and what to set. A target set by name is dialled at each address it was checked for, in turn, as a plain dial of the name is, so `localhost` reaches a server listening on 127.0.0.1 only when the name answers `::1` first; no other address is dialled. A tunnel pairing keeps `OLLAMA_ADDR` wherever it points, as its forwards do. A worker whose job is refused (malformed, refused or busy) waits a second before it parks again, so a Host that refuses or misuses every job cannot make this machine dial in a loop. The egress lane's workers do the same: one that took a job parks again at once, and one whose job was refused waits a second. - **Scoped tunnel.** The WireGuard peer's only allowed address is the paired host's single tunnel address, so the tunnel can reach nothing else. A pairing that offers anything wider is refused, and a pairing file that holds one brings no tunnel up. - **No MCP endpoint.** Nothing on this machine drives the host. - **Grants are verified here.** What the host may do arrives signed. The verify key is pinned on first use, and a deny always beats an allow. - **Fixed argv.** Every management op runs a fixed command line. File ops are dedicated Go handlers with no shell. Text extraction runs a named local tool from a fixed template. - **Approval on the machine, once enrolled.** With a second factor enrolled here, folder grants, move plans, model pulls and root sessions each need a code from this machine, bound to that exact request, single use. Without one, grants and move plans rest on the signed grant alone, and model pulls and root sessions are refused. - **On an anchored machine, a write needs an approval verified and spent here.** It comes from the second factor enrolled here or from your pinned approval device, is bound to the op and a digest of its arguments, and is used once; with neither enrolled, those writes are refused. The exceptions take no fresh approval: standing work under a place you set up at the machine (backup fetch and backup push, the host's context store), a backup restore request (approved on its own, below), runs under op terms you approved earlier, a root command under the grant you gave here with the code, the anchor host's signed pairing records, revokes, and a self-update to a newer version we signed. Repeated refused writes close a pairing's write lanes for a while. - **Host-written programs run sandboxed first.** An op runs only under signed terms you approved, as a dry run in a sandbox with no network, and never changes anything outside its declared file effects. A one-time apply lands only the change list you saw; a run under a standing grant is held to the grant's bounds. - **A standing grant is held here to its window and rate, and dies with the key that approved it.** The machine reads the window on its own clock and counts the rate from the runs it logged in the grant's record in `ops.json`, which it seals with a key derived from its own encryption key. A record edited by hand or by a program without that key, or copied from another machine, holds no grant. The seal does not stop the bastion's own account: it can read the key, so it can write a new record or put back an older sealed copy with fewer runs logged and more uses left. That account is root on a networked install, and root on the machine is outside what this model defends. A grant taken back while the machine is offline stops at the Host at once, which routes no run through it; the machine keeps its own record until the drop is sent again or the grant expires (30 days at most). The heartbeat does not yet carry the grants the Host still holds, which would let the machine drop the rest itself as it does for coder grants. `ops drop --all` at the machine takes every one back there. - **On your own machine, an action op reaches the internet, never your network.** On a machine no fleet has anchored, an action op's destination is never a private, ULA, CGNAT or reserved address, also inside a NAT64 or 6to4 address: one written as an address is refused when the op arrives (`op_sync`), and a name is refused at apply if any address it resolves to is one. A verb's fetch (`tree.checkout`'s repository, `k8s.set_image`'s ancestry check), which runs with the bastion's root outside the sandbox, is held to the same rule: its host is resolved once, every address checked, and git held to that address (git 2.37 or newer); on an anchored machine that fetch is still never this machine, its loopback, a link-local address or a cloud's metadata service. An image `k8s.set_image` puts live is pulled by the machine's container runtime, by name, outside the bastion's walls, so on your own machine its registry is judged where the bastion takes the image (a program's result, and the verb) and refused on your network or this machine; the runtime's own later lookup is not the bastion's to hold. Another computer of yours is reached through a bastion on that computer. An anchored machine keeps private destinations, since a fleet's ship steps reach its own private control planes. (Before this, a local machine held an op whose destination was on your LAN, and its apply reached it.) - **A server in the service slot has no network.** The host's tunnel is its only way in, and it cannot reach this machine, your network or the internet. - **Egress for someone else reaches the public internet, and nothing on this machine or your network that you did not name.** A coder run's proxy, always, and the egress lane on an anchored machine refuse loopback, link-local (the cloud metadata address included), private, CGNAT and reserved addresses, the deprecated IPv4-compatible (`::/96`), IPv4-translated (`::ffff:0:0/96`) and site-local (`fec0::/10`) IPv6 forms, and any address this machine takes as its own: one on its interfaces (a public one included), and on Linux any address the kernel's routing table delivers here (a `local`, broadcast, anycast or multicast route, such as an AnyIP range on `lo`), asked of the kernel per connection, and on Linux any address the kernel routes straight onto a link that stays on this machine: a container's veth, or a bridge whose every port is a veth, a tap or a dummy, which is the bridge network Docker, Podman, LXD and libvirt each make for their containers and VMs. A bridge with a network card, a bond or a VLAN among its ports is the LAN, a VPN or a tunnel to other machines leaves, and so does a route through a gateway, which sends the packet on. A link an IPv4 or IPv6 default route of the main table goes out of is never inside: it is this machine's way out, so a bastion that is itself in a container dials the network on its own veth, its LAN included. The IPv4 address inside an IPv4-mapped, NAT64 or 6to4 address is checked too, every address a name resolves to is checked, and the address checked is the one dialled. When the machine cannot tell, it refuses. A coder run reaches a host you named in `coders set --allow-lan`, and skips every one of these refusals for it, so a name or address of this machine you list there is reached too; the anchored egress lane has no such exception. The anchored lane's refusal of this machine's own non-private addresses is new in 0.50.0; before it, that lane refused only the internal ranges. On a local (unanchored) machine the egress lane refuses this machine too: `localhost` names, loopback and unspecified addresses (`0.0.0.0/8` and `::` included), link-local addresses (the cloud metadata address included), the rest of `64:ff9b::/32`, the three deprecated IPv6 forms, and any address this machine takes as its own, checked as above and also inside an IPv4-mapped, NAT64 or 6to4 address. It refuses the network around this machine as well, as an anchored machine's lane does: a private, ULA, CGNAT or reserved address, by address or by any address a name resolves to. Your own machines are reached through their approved lanes, never through egress. (Before this, a local machine's lane kept your LAN: such an address that was not this machine's was dialled.) A container or VM on a network made on this machine is this machine, not the LAN; on macOS and Windows, which have no route lookup here, such an address is dialled. Every address a name resolves to is checked there too, and only those addresses are dialled, in order, the next as soon as one fails or none has answered within 300 ms. This is new in 0.55.0; before it, a local machine's egress lane vetted no destination, so a host could reach this machine's own services through it, ollama's `/api/pull` among them, whatever the `models` switch said. The backup lanes (`backup_fetch` and `backup_push`, anchored or not) apply the same check of this machine's own addresses, to every address the URL's name resolves to, to each redirect a fetch follows and to the address dialled; this too is new in 0.50.0, and before it they refused only loopback, private, link-local, multicast, unspecified, CGNAT and ULA addresses. Also since 0.50.0 they classify an address as a coder run's proxy does: the IPv4 address inside a NAT64 `64:ff9b::/96` or 6to4 address is judged, and IPv4-compatible and IPv4-translated addresses, the rest of `64:ff9b::/32`, site-local `fec0::/10`, `0.0.0.0/8`, `198.18.0.0/15` and `240.0.0.0/4` are refused. Not covered: a public address that reaches this machine only through a 1:1 NAT in front of it, which is on no interface and in no local route. The bastion does not learn that address. - **A terminal is checked here and logged.** On a local (unanchored) machine, a terminal opens when the host asks, with no approval. No signed grant is needed, but one that is sent is verified, and a deny in it refuses. On an anchored machine, a host other than the anchor needs the `session` family in its pairing grant, and each terminal needs an approval from your pinned approval device or the second factor enrolled here, bound to that one session and used once. A root terminal, on either kind of machine, needs the bastion to run as root, a second factor enrolled here, and a code entered on this machine for that session; an approval device cannot approve it. Each session, and each refusal of a well-formed request, is written to the audit log on this machine when the pairing has a grant store; a malformed request is refused with no audit line. - **Root commands run only under a grant given here with the code.** Only the anchor host can hold it. It names this machine, ends when you said, is revoked from this console, and needs the bastion to run as root. A revoke or an expiry also stops a command already running, within 2 seconds. Every command and its output is written to a transcript here and to the audit log; refusals, malformed commands and setup failures are audited too. - **Root only where there is no one else to ask.** Claiming ownership or the anchor requires root because those are machine-wide facts. - **On macOS and Windows, a networked bastion is the system's service.** It runs as root under launchd, or as LocalSystem under the Windows service manager, starts at boot and is restarted when it stops, so the signed-in user cannot stop it. It is registered only from a program that only root or an administrator can replace. Beside it, `pair`, `run` and `status` from an account that does not administer the machine are refused, so this binary will not start a second bastion in that account. An older or modified copy is not bound by that refusal; what keeps the service in place is that the signed-in user does not administer the machine. - **Observation changes nothing on the machine.** Observe ops read and report. No input injection lives in the bastion. The observe ops that send traffic are `observe:net`, an outbound probe to a host you name; `observe:k8s`, a read-only query of a cluster API; and the three monitoring ops (`observe:alerts`, `observe:metrics`, `observe:logs`), read-only HTTP against an Alertmanager/Prometheus/Loki on the box's own network — private addresses only, re-checked at the socket so neither a redirect nor a DNS answer can walk a query out of that neighbourhood; and `observe:cert`, a TLS handshake with a listener on this machine, at loopback or an address this machine holds, never one another machine could. - **A backup's key goes to a host only when you approve it here.** A host's request to restore files a request and gets nothing until you approve it on this machine; the key sealed to the host then opens that one backup and no other, and this bastion's key never leaves this machine. Thinning the backup folder removes only backup files this bastion recorded fetching, still as they arrived, judged by arrival here. A host sends from this machine only backup files in its own folder or a folder you shared with it. - **No delete op on shared stores.** The one write lane is a move plan you approved, and it archives rather than deletes. - **A shared folder serves only what its grant leaves in.** A host's list, stat, read and move of a shared folder are each held to the grant's excludes, by the name the path opens and wherever a link in it leads. A pattern of one name matches it at any depth; a pattern with a `/`, such as `.config/gcloud`, matches that run of folders at any depth. The local search agent's folder, `.agent`, holds the plaintext of every file the agent read, and no path into one is served or moved, whatever the grant excludes; the bastion still writes its own report of a move plan there. - **Verified releases.** The self-updater checks the release manifest's signature against the release keys compiled into it, then checks the binary's SHA-256 against that manifest before anything is swapped. The agent download is checked the same way, and an installed agent is never replaced with an older one. The release key never reaches the machine that builds and publishes: each release waits until the key holder signs it on their own computer. The installer checks the SHA-256; see [SECURITY.md](../SECURITY.md) for what that does and does not prove. - **No secrets cached for git.** The credential helper fetches a token from the host on every auth and stores nothing itself, and `git-setup` resets the forge's helper chain before adding it. With git 2.46 or later, git's own store and cache helpers keep nothing, wherever they are configured; a helper that records everything it is given still receives the token. A git older than 2.46 gets the username and password, and for it only helpers configured before `git-setup`'s lines are kept out. ## How each claim is tested [claims.md](claims.md) covers each point above and some further claims not listed here. For each it names the test that fails if the claim stops being true, and says where there is none. To report a security issue, please do not open a public issue. See [SECURITY.md](../SECURITY.md).