Skip to content
Network tiers

Network tiers

This page explains how devvm confines a VM to its proxy on each tier, what the proxy does with each connection, and how your secrets get into the VM’s requests without the VM holding them. Network tiers covers choosing a tier, the allowlists and their rules, the secrets and the logs as you use them.

Starting a VM on its tier

A VM’s state records its tier in its net section, as "tier": open, internet, allowlist or none (network tiers). A section without a tier reads as allowlist, and one with a tier devvm doesn’t know can’t be read, so devvm refuses the VM rather than take it as open. The tier decides the VM’s confinement (confinementFor): on every tier but open, the VM starts confined to its proxy (backend.NetProxy); an open VM starts on its backend’s own network (backend.NetDirect) with its proxy’s port and password in its confinement, so its guest’s proxy settings name the proxy all the same (Confinement.GuestProxy). A running VM keeps the confinement it started with until its next start.

When devvm starts a VM on a confined tier, it takes these steps in order:

  1. It readies the VM’s own CA and the next one (the VM’s CA), and on macOS it keeps them out of Time Machine.
  2. It writes the VM’s secrets as the VM’s proxy sees them.
  3. It starts the VM’s proxy on a free port on the host’s loopback, and waits for the proxy to say it’s listening.
  4. It starts the VM confined, with the VM’s proxy settings pointing at the proxy.
  5. It runs the self-check before the VM’s setup and before its shell.
  6. It sets the VM up, as the VM’s setup describes. The setup puts the VM’s CA and the next one in the VM, points the tools that don’t read the environment at the proxy, and writes the VM’s placeholders and its agents’ logins and settings with your keys taken out.

An open VM’s start takes the same steps, but it starts the VM unconfined and runs the open self-check. Its proxy has the VM’s CAs and its secrets as any VM’s does, and applies no rules. Its setup installs both CAs in the VM, points the same tools at the proxy, and writes the same placeholders and copies of the agents’ logins and settings, so an open VM holds no real secret either. The VM reaches its proxy directly, through the HTTPS_PROXY that names it (Lima), and a request that goes around the proxy carries only the placeholder.

VMs that can’t be confined

A VM whose backend can’t confine it to its proxy (egress-proxy) can only be open: devvm refuses to make it on any other tier, unasked, in one line that names the backend and --network open (net_tier_backend), and the registry’s check refuses devvm network with a confined tier.

The proxy

One proxy runs for each VM. It runs on the host as devvm _proxy, a hidden command, and listens on a loopback port. The VM reaches nothing else over its network, so every connection the VM makes goes through its proxy. The one other way out of the VM is the links you add to it, which this machine carries over ssh to the ports your other VMs publish, so they don’t use the VM’s network. The proxy is a second entrance to those links, for the VM’s HTTP clients (links through the proxy).

Other programs on the host can also reach that port, so the port alone doesn’t identify the VM. Every request must carry the VM’s password in Proxy-Authorization, with the user devvm, or it gets 407. Each VM has its own random password in its proxy settings. The proxy reads the password from its config, a file only you can read, never from its arguments, which other users can see, and it refuses to start without one (devvm _proxy). The VM’s settings also get the password through files (Lima).

For each connection, the proxy does the following:

  • It sends a request for one of the VM’s own links to that link before any rule, and passes its bytes on as they are (links through the proxy).
  • It checks CONNECT host:port and absolute-form requests (GET http://host/…) against the VM’s policy files, and then tunnels or forwards the allowed ones untouched.
  • It takes only host names, made of letters, digits, -, _ and ., and addresses. It lowercases a name and drops one trailing dot, and the name that’s left may have no empty label, so evil.test.., evil..test and .evil.test get 400 too, because a company proxy that drops trailing dots would otherwise reach a host that a deny of evil.test never matched. Anything else gets 400 before the policy or the log sees it.
  • It resolves names itself and dials the addresses it checked. It refuses the host’s own addresses and internal ones unless a rule names the address. Internal addresses are loopback, private, link-local, multicast, unspecified, CGNAT 100.64.0.0/10 and the other ranges that aren’t the public internet, and a NAT64 address 64:ff9b::a.b.c.d counts as its IPv4 address.
  • It requires a TLS ClientHello in a tunnel to name the CONNECT host in its SNI. The proxy doesn’t see the Host inside the encrypted traffic, so a CDN that routes by it can reach other sites it serves.
  • For a host with a bound secret, it replaces the placeholder in one header with the real value, as how the proxy puts them in describes. Everything else passes unread.
  • It carries at most 1024 CONNECT tunnels at once for the VM, as described below.
  • It logs every decision (log files).

A CONNECT tunnel meets the proxy’s checks in this order:

    flowchart TD
  conn["CONNECT host:port from the VM"] --> pw["the VM's password?<br>no: 407"]
  pw --> name["a host name or an address?<br>no: 400"]
  name --> room["fewer than 1024 tunnels open?<br>no: 503"]
  room --> link{{"one of the VM's links?"}}
  link -- "yes" --> linked["the link's socket,<br>tunneled untouched"]
  link -- "no" --> allow["on the allowlist, and not denied?<br>no: 403"]
  allow --> floor["public and not this machine's,<br>or an address a rule names?<br>no: 403"]
  floor --> bound{{"a bound host, on port 443?"}}
  bound -- "no" --> sni["its TLS SNI names<br>the CONNECT host?<br>no: closed"]
  sni -- "yes, or not TLS" --> tunnel["tunneled untouched"]
  bound -- "yes" --> intercept["intercepted, with<br>the VM's CA"]
  intercept --> named["each request names<br>the bound host?<br>no: 421"]
  named --> swap["the placeholder swapped<br>for the real value"]
  

A tunnel that the proxy doesn’t intercept carries the bytes both ways as they are. When the VM finishes sending, the proxy closes its side of the connection to the far end, so the far end sees the end of what the VM sent and can still answer. The tunnel ends when the far end’s bytes end, or when the proxy can’t pass them on because the VM’s connection has gone, and then the proxy closes both connections. In a tunnel to a host the policy allows, the far end’s bytes reach the VM at once, so a protocol whose server speaks first, such as SMTP, gets its greeting before the VM sends anything. The VM’s bytes wait until the proxy has checked them: when the first one starts a TLS record, the proxy reads the whole ClientHello and checks its SNI before any of it reaches the far end, and any other first byte passes at once.

The proxy carries at most 1024 CONNECT tunnels for the VM at once, counting tunnels to links and intercepted ones too, from the request until the tunnel ends. That’s four times the 256 connections a browser such as Chrome keeps open at most, and many times what a package manager opens, such as npm’s 15 sockets or uv’s 50 downloads, so several installs and builds at once fit. Each tunnel holds two of the proxy’s file descriptors, so the limit keeps a VM that opens tunnels without end from taking them all. A CONNECT past the limit gets 503, which a client may retry, and the reason 1024 tunnels open already, logged as a refused block, so neither the VM’s prompt nor devvm network log suggests an allow rule. The proxy’s own log, proxy.log, says when the VM reaches the limit, once until a tunnel ends. Plain HTTP requests in absolute form and idle kept-alive connections don’t count toward the limit (attack vectors).

Links through the proxy

A VM’s NO_PROXY names only the VM itself, so an HTTP client that uses the VM’s proxy settings sends a request for a link, such as http://db:5432/, to the proxy. A running VM can’t take a new environment, so the proxy carries those requests itself, and a link added while the VM runs works for every HTTP client at once.

devvm _links writes every link the VM has, carried or not, to a list, links.json in the VM’s run folder, ~/.local/share/devvm/vms/<hash>/run/, mode 600 in a folder of mode 700, whenever they change or the file is gone. Each entry has the link’s names (NAME and NAME.devvm.internal), its address and port in the VM, its target, and its socket on this machine. The list stays when devvm _links ends, and devvm removes it with the VM, so a link that isn’t carried, which has no socket, is refused rather than asked of the rules. devvm puts the list’s path in the proxy’s config as links, and its run folder, where the links’ sockets are, as link_sockets. The proxy checks the list for every request and reads it again when it has changed, as it does the policy files. A list it can’t read has no links, which its log says once. The list must be a regular file, not a symbolic link, that only you can write, and at most 1 MiB.

The proxy checks a request’s host against the list after the VM’s password and before any rule, as it does for devvm.proxy. It lowercases the host and drops one trailing dot, and then a host and port match only an entry that names them exactly, so DB.:5432 matches db:5432, while db..:5432, db.devvm:5432 and 127.77.0.1:5432 don’t. The request names a host and a port, never a socket. An entry counts only when its socket is the one that devvm _links names for this VM’s link at that address and port, in the run folder of devvm’s data folder, so the proxy never connects to another VM’s link or to any other socket, whatever the list holds. Its address must also be in 127.77.0.0/16. Any other name under devvm.internal is refused with 403 and logged as a refused block, whatever the rules allow, so the rules, the host’s resolver and a company proxy never see one. A host and port on no entry are otherwise the policy’s, as before.

For a match, the proxy connects to the link’s socket, which is the same socket that the link’s remote forward reaches, so devvm _links checks the connection as it checks every other (each connection), and the 256-connection cap counts both. A CONNECT is tunneled as it is, with no SNI check and no interception, and an absolute-form request, such as GET http://db:5432/, goes to the socket in origin form, without Proxy-Authorization or Proxy-Connection, over a new connection for each request, which ends when the client gives up the request. A kept-alive connection to the proxy that asks for a link and then for another host has each request handled on its own. HTTPS in absolute form gets 400, because CONNECT carries it. The proxy puts no secret into a link’s connection, even when a secret’s hosts would match the link’s name, and its rules don’t apply, so devvm network deny db doesn’t stop the link db.

The proxy logs nothing for a link’s connection that it passes on, because devvm _links logs it as link, once. When the socket doesn’t answer, because the link was removed since the proxy read the list or devvm _links isn’t running, the proxy answers 502 and logs link_refused with the reason not carried. A socket whose backlog is full refuses a connection, so the proxy tries again for under a second.

Through a company proxy

When devvm starts a VM’s proxy, it reads HTTPS_PROXY, HTTP_PROXY and NO_PROXY from its own environment, each in capitals first and then in lowercase, as Go’s clients do. When its environment sets neither HTTPS_PROXY nor HTTP_PROXY, it reads config.toml’s [proxy] table instead, its https, http and no-proxy, and never a mix of the two. It writes them into the proxy’s config, proxy.json in the VM’s run folder, ~/.local/share/devvm/vms/<hash>/run/, as upstream, so a password in the environment’s URL is in a file only you can read and in no process’s arguments. devvm checks the settings first: a proxy URL is http://user:password@host:port, or host:port alone, and a proxy without a port is on port 80. devvm refuses an https:// or socks5:// proxy (upstream_proxy_scheme) and a setting it can’t read (upstream_proxy_invalid), so a VM doesn’t start until you change it.

A config.toml URL never holds a password (proxy_password_in_config, as it’s read), because people copy that file between machines and keep it with their dotfiles. When its URL names a user, upstream gets "secret": "proxy", and the proxy looks the proxy secret up as it starts, through the same lookup as the secrets it puts in, the Keychain or ~/.local/share/devvm/secrets/proxy, and uses it as the password of each URL that names a user and none of its own. So proxy.json never holds that password. A proxy whose lookup fails doesn’t start, and devvm checks that the secret is saved before it makes a new VM, starts a VM’s proxy, or builds an image or the shared tools (proxy_password_missing). Each of these checks is settings.CompanyProxy.Upstream.

devvm’s own requests on this machine go through the same company proxy. They are the download of its own mise, the list of Ubuntu’s cloud images, and the registries --from asks for digests. When the environment sets the proxy, their clients use Go’s own environment proxy. When config.toml sets it, settings.CompanyProxy.Route is their transport’s proxy. At the first request, it looks the proxy secret up and puts it in each URL that names a user and none of its own. It routes each request as a VM’s proxy does: HTTPS_PROXY’s for HTTPS, HTTP_PROXY’s for plain HTTP, and directly for NO_PROXY’s hosts, localhost and loopback addresses. Go sends the user and password only to the company proxy, in Proxy-Authorization. A missing password fails the request without a retry. The mise that devvm runs on this machine inherits devvm’s environment, so it uses the environment’s proxy as it is. With config.toml’s proxy, devvm adds HTTPS_PROXY, HTTP_PROXY and NO_PROXY to mise’s environment, in capitals and in lowercase, with the password in each URL that names a user. mise takes a proxy’s password only in its URL, so its environment is the one place it can go. It’s never in mise’s arguments or in a file.

A VM’s proxy refreshes Codex’s and opencode’s OAuth logins through the company proxy it sends the VM’s requests through, its upstream, whether that came from the environment or from config.toml. As it starts, it makes that upstream’s route for its own requests, with the proxy secret it looked up as the password, as proxy.Upstream.WithPassword and proxy.Upstream.Route do for devvm’s own requests. It gives the route to each lookup in the lookup’s context (proxy.WithRoute), and agentlogin sets it as the refresh’s transport proxy (proxy.RouteOf). A VM’s proxy without an upstream sends the refresh directly, as it does the VM’s requests.

An open VM’s proxy goes through the company proxy as a confined VM’s does. Its guest gets no proxy settings of devvm’s environment: Lima’s propagateProxyEnv is off for every VM whose guest names devvm’s proxy, so neither the shell’s nor config.toml’s password reaches the guest.

devvm warns (upstream_proxy_gone) when the config the VM’s proxy last started with has an upstream and the one it starts it with now has none, as happens when a shell without the settings starts it.

The proxy decides each request as it does without a company proxy, by the host name the VM asked for, and only then picks the way out:

  • A host that NO_PROXY names, localhost, a name under .localhost and a loopback or unspecified address go directly, through the checks above.
  • A tunnel, and the connection the proxy makes to a bound host to put a secret in, go through HTTPS_PROXY’s proxy. The proxy sends CONNECT host:port with the name the VM asked for, and Proxy-Authorization: Basic from the URL’s user and password, and it reads the answer. Anything but 200 refuses the VM’s request, naming the company proxy by its host and port. Over that tunnel, the proxy’s TLS connection to a bound host checks the server’s certificate against the system’s roots and the CAs in your CA folder, as it does directly.
  • A plain HTTP request goes to HTTP_PROXY’s proxy in absolute form, with its Proxy-Authorization in place of the VM’s. The company proxy’s address in the request has no password, so no error the proxy logs holds one.
  • Without HTTPS_PROXY or HTTP_PROXY, those requests go directly.

NO_PROXY is a comma-separated list, which the proxy reads as Go’s clients read it. In it, * matches every host, and an address or a CIDR matches addresses. A name matches itself and its subdomains, while a name with a leading . or *. matches only its subdomains. Any entry can end in a port, which limits it to that port.

The company proxy resolves the names it’s sent, so the proxy can’t hold it to the addresses it checked, and the floor changes for those requests. An address in the VM’s request is checked as before, and a host whose last label is all digits or starts with 0x, such as 127.1, gets 400, because a company proxy may read it as an address. For a name, the proxy asks this machine’s resolver, for at most three seconds, and refuses the request when any answer is internal or denied, unless a rule names the address. A name this machine can’t resolve, which is common behind a company proxy, goes to the company proxy unchecked when a rule names it, as itself or under *.domain. When only ** allows it, the proxy refuses it with 403, and the event log says that this machine can’t resolve it and only ** allows it. The proxy reads at most 64 KiB of the company proxy’s answer to CONNECT. The design note explains what that leaves open, which Attack vectors lists, and how builds reach the company proxy through a proxy of their own.

The event log’s upstream field names the company proxy, by host and port, for each tunnel and request that went through it, and devvm network log adds through host:port to the line. The proxy’s own log names both company proxies by host and port when it starts. Neither holds the user or the password.

How long a proxy runs

The proxy exits when the VM’s Lima hostagent does, which it learns from the hostagent’s pid file, the VM’s lifeline (lifeline in its config). The proxy starts before the VM, so it waits for the lifeline to name a process that runs. What the file held when the proxy started is an earlier start’s, and the proxy waits past it, but when a new start writes the file and its process is already gone, the proxy exits. Once it follows a start, it runs while the file holds what it held then and that process runs, so a VM started again gets a new proxy. If the hostagent never appears, the proxy exits 15 minutes after it started, and devvm stop stops it at once.

A VM that starts again gets a new proxy on the same port. A running VM also gets a new proxy when the config devvm would start it with differs from the one it started with, which devvm tells by the SHA-256 of each (proxy.Config.Sum). The config holds everything that, changed, starts the proxy again: the VM’s list of policies, the VM’s CA and the next one, by the SHA-256 of each certificate, so a CA that was replaced counts, the SHA-256 of devvm’s binary, so a proxy from another devvm binary counts, the CAs in your CA folder, the company proxy settings in devvm’s environment and the shared Nix cache’s cap. A config that’s gone, or that the proxy wouldn’t read, counts as one that differs. To start a new proxy, devvm stops the old one with SIGTERM, and with SIGKILL when it’s still running two seconds later. The old proxy counts as stopped only once its process is gone, not once its arguments can no longer be read, because an ending process loses its memory before its files, and for that moment its port still looks taken by another program, which would fail the launch with proxy_port_taken. SIGKILL goes only to a process whose arguments devvm can still read as the proxy’s, so a proxy still ending gets none, and neither does another process given its pid since, even one whose arguments devvm can’t read. If it still runs two seconds after that, devvm stops with proxy_stop_failed and starts nothing, because the old proxy would go on with its old settings.

devvm counts a proxy as started only once the proxy says it’s listening on the VM’s port and is still running, because a program that took the port first would otherwise get the VM’s traffic. The proxy says so in a status file of its own in the VM’s run folder (vms/<hash>/run/proxy.status), which devvm names to it when it starts it and removes before each start. The file holds the proxy’s pid and the address it listens on, so a status that an earlier proxy left, or that another program wrote, never counts. While devvm waits, the proxy counts as running as long as its pid file still names it, it hasn’t ended, and its arguments are the VM’s proxy’s, _proxy -config with the VM’s proxy.json, or still devvm’s own, or can’t be read yet. On Linux, a process that has just started shows devvm’s own arguments for a moment, then none, because the kernel lets devvm go on as the new program begins to replace devvm’s copy, and shows the program’s arguments only once it has loaded. If the proxy doesn’t start, devvm stops and names the proxy’s log (proxy_start_failed). When a stopped VM’s port was taken by another program, devvm chooses a new free port. A running VM’s backend holds the port it started with, so when a running VM’s proxy is gone and another program took its port, devvm can’t start the proxy again (proxy_port_taken). devvm then stops the VM (net_proxy_down_stopped), and it does the same when the proxy fails to start for any other reason, because a confined VM never runs on without its proxy. The next devvm starts both. An interrupt during the start of a confined VM stops the VM and its proxy too (interrupted_start, a cleanup that starter.start registers around the backend’s Start), because a backend can leave the VM running while its start still waits for it: Lima’s hostagent keeps running the VM after limactl start ends, and devvm records the confined start only once limactl start returns. Without the stop, the VM would run with no record of its confined start, so devvm ls would show it as unenforced and its next launch would offer to restart it.

Policy files

On the allowlist tier, a VM’s proxy reads these policies, in this order, as one list. Allowlists describes what they allow and the rules they hold. The other tiers read fewer, as netpolicy.Policies lists them for each:

  • internet: one built-in policy, network/internet.policy, which holds **:*, every host and address on any port, then secrets.policy, then your files below, whose denies alone apply. The proxy’s config lists those files in deny_policies, and the proxy drops their allows as it reads them, keeping their denies and a broken deny. ** lifts no floor, so internal addresses and this machine’s stay refused.
  • none: the built-in policies of the VM’s agents (shared-tools/claude.policy, codex.policy, opencode.policy) and no other built-in one, then secrets.policy, then your files as denies alone. The VM’s links come before every rule, as on every tier.
  • open: no policy at all. The proxy’s config has "open": true, which allows every host and address on every port, internal ones and this machine’s included, and a config with open and any policy is refused. The floor doesn’t apply, and a name that the host can’t resolve goes to the company proxy unjudged, as it does for a build.
PolicyRead for
layers/base.policy (built in)every VM
shared-tools/<name>.policy (built in)each shared tool in the VM’s image
layers/<name>.policy (built in)each built-in layer in the VM’s image
~/.config/devvm/layers/<name>.policyeach of your layers in the VM’s image
~/.config/devvm/policyevery VM
~/.local/share/devvm/vms/<hash>/policythat VM only
~/.local/share/devvm/vms/<hash>/run/secrets.policyevery VM, which devvm writes with the hosts of the VM’s secrets and logins and of its agents’ token endpoints

The built-in policies are paths in devvm’s own files, assets/ in the repo, which are built into the binary, and no copy of them is ever on disk. netpolicy.Policies lists both kinds for a VM: the built-in ones go in the proxy’s config as names in builtin_policies, and your files as paths in policies. The proxy reads the built-in ones from the binary once, as it starts, ahead of your files in the list, and never again, so only an upgrade of devvm changes them, and an upgrade starts every VM’s proxy again because the binary changed. A built-in policy that’s missing, or that holds a line that isn’t a rule, is a bug in devvm, so the proxy refuses to start rather than run with fewer rules than devvm meant, and devvm stops the VM as for any proxy that doesn’t start. A missing file of yours, on the other hand, has no rules. devvm network reads the built-in policies from the binary too, and it labels each with its name (# devvm: layers/base.policy, and "builtin" with --json) where it shows your files’ paths. Both lists are in the config that a proxy started with, so a change to either starts the proxy again.

The proxy, devvm network and devvm doctor read every policy with one parser, proxy.ParsePolicy, which returns each line that holds more than a comment, with its number and whether it’s a rule. A line is a rule only when proxy.ValidRule takes it, which is exactly what devvm network allow and deny take. Each time the proxy reads a file, it logs each line that isn’t a rule, with its file, number and text, quoted, to its own log, proxy.log in the VM’s logs/ folder. It skips such a line, unless the line starts with ! (proxy.BrokenDeny). A broken deny fails closed: while any of the VM’s policy files has one, the proxy’s policy denies every name and every address, and each request gets 403 and a refused block whose reason names the file and the line, so neither the VM’s prompt nor devvm network log suggests an allow rule. The VM’s links and the proxy’s own answers at devvm.proxy come before the rules, so they still work, and tunnels already open stay open, as they do for any new deny. Fixing the line lifts it at the proxy’s next read of the file, which is the next connection. The proxy reads a file again when its mtime, size or inode changes, or when it comes back after it was missing, whatever its mtime. It also reads a file again at every connection while its mtime is less than 2 seconds old, because some filesystems keep mtimes only to the second or two, so a second write within that time could leave the stamp as it was. A change of a file’s mode or owner alone, with chmod or chown, changes none of the three, so the proxy doesn’t see it until the file’s next write. The same goes for the bindings file and the links list, so one that you make readable or writable by others after the proxy read it keeps its last contents until it changes, and the proxy refuses it then. A file the proxy refused is read again at the next connection, so fixing its mode works at once. An error other than a missing file keeps its last rules and its broken deny, which the proxy’s log says once. The proxy, devvm network and devvm doctor read a policy file only when it’s a regular file, through a symbolic link if it’s one, of at most 1 MiB (proxy.ReadRegular). They open it without blocking and check the file they opened, so a FIFO or a link to /dev/zero in a policy file’s place can’t hang them, and each refuses it with a named error, proxy.ErrNotRegular or proxy.ErrTooLong. A leading byte-order mark isn’t part of the first line. netpolicy.PolicyWarnings warns about each line that isn’t a rule, with policy_broken_deny for a broken deny and policy_not_rule for the others. netpolicy.Proxy.Prepare uses it for the VM’s policy files at each launch of a confined VM, devvm network allow, deny and rm for the file they change, and devvm doctor for the files you edit: ~/.config/devvm/policy, your layers’ and each VM’s own. devvm network also marks the line, and --json lists it in not_rules. A unit test parses every policy file that devvm ships and fails on any line that isn’t a rule.

Each change that devvm network allow, deny or rm makes rewrites the file whole, through a link if the file is one, so a proxy never reads half of it.

The VM’s setup

On every launch, the VM’s setup does the following:

  • It installs the VM’s own CA and the next one into the VM’s trust store, as devvm-proxy.crt and devvm-proxy-next.crt, so the proxy’s certificates for hosts with a bound secret verify, in this VM only. It never gets their keys.
  • It points the programs that ignore the proxy variables at the proxy. Those are apt, sudo (which keeps the variables), every ssh connection, Docker’s daemon and containers with the docker layer, and Nix’s daemon with the nix layer. Under systemd, each daemon gets a drop-in, devvm-proxy.conf, that holds the VM’s own proxy address and nothing else, and without systemd, Docker’s settings in /etc/conf.d/docker get a block instead, and Nix’s daemon gets /etc/conf.d/nix-daemon. Every VM has a proxy, an open one’s included, so every VM gets them, and the setup restarts a daemon whose settings changed. An ssh connection gets a ProxyCommand that tunnels through the proxy, and the proxy checks its host and port against the allowlist.
  • It writes devvm-local-proxy, a proxy on the VM’s loopback that adds the VM’s password to each request, for browsers, which ask for a proxy’s password rather than take it from the proxy variables. The desktop layer starts it and points the desktop’s session at it (the local proxy).
  • With the nix layer, it points Nix at the shared Nix cache first: /etc/nix/devvm-cache.conf, which /etc/nix/nix.conf includes with !include, sets substituters = http://devvm.proxy/nix https://cache.nixos.org. Every VM gets that file, an open one’s included, and the setup restarts Nix’s daemon when the file changed.
  • It sets NO_PROXY and no_proxy to the VM itself, localhost,127.0.0.1,::1, in the daemons’ settings above and in Docker’s client settings (~/.docker/config.json), as the VM’s backend does in the environment it sets at each start. The VM’s links aren’t in it, because HTTP clients reach them through the proxy, so a change of links changes no daemon’s settings and restarts nothing.
  • It puts the VM’s placeholders in ~/.config/devvm/env, which login shells read, as CLAUDE_CODE_OAUTH_TOKEN and GH_TOKEN. When github is bound, it also sets up git’s credential helper for GitHub over HTTPS.
  • It adds devvm-why, the note before each prompt, and the notes for Claude Code and Codex (inside the VM). devvm-why prints the proxy’s /blocked, and when that list is empty, the proxy’s answer adds that a DNS or connection error for an allowed host comes from a tool that skipped the proxy, with a link to troubleshooting. The notes, a block between <!-- devvm-proxy --> markers in ~/.claude/CLAUDE.md and ~/.codex/AGENTS.md, say to run devvm-why after a connection error or a 403 from the proxy, and that such an error while devvm-why shows nothing means the tool skipped the proxy and needs its own proxy setting or $HTTPS_PROXY. The setup writes them only when the VM has a proxy address, and replaces the block on every launch.

Each placeholder ends in a random value of the VM’s own, its salt, which isn’t its password.

The shared Nix cache

VMs with the nix layer share their downloads from Nix’s binary cache, so each package comes from cache.nixos.org once per machine instead of once per VM. Each VM keeps its own Nix store and daemon, and only the host writes the cache, so no VM can change what another VM gets. The design note says why it’s a cache that the host fills rather than a shared store.

In the VM. devvm starts the VM’s proxy with nix_cache set to ~/.local/share/devvm/cache/nix-cache in its config when the VM’s image has the nix layer, and the VM’s setup puts http://devvm.proxy/nix before https://cache.nixos.org in Nix’s substituters (the VM’s setup). It leaves trusted-public-keys as Nix has it, which is cache.nixos.org’s key alone. Nix’s daemon reaches devvm.proxy through its own proxy settings, so its requests are absolute-form GETs with the VM’s password, and the proxy answers them itself, as it does /blocked and /ca.pem, and never forwards them. When the cache answers with anything but a file, Nix goes on to cache.nixos.org, through the proxy as any other download.

What a VM can ask for. The proxy takes only GET and HEAD requests with no query, for three kinds of path under /nix/. These are nix-cache-info, a narinfo named <hash>.narinfo with a 32-character hash in Nix’s base 32, and a NAR named nar/<hash>.nar with a 52-character hash and no suffix or .xz, .zst or .bz2. Anything else gets 400. The proxy makes up nix-cache-info itself, with priority 30, so Nix asks the cache before cache.nixos.org, whose priority is 40. It serves the other files from the folder, or fetches them from https://cache.nixos.org when the VM’s policy allows cache.nixos.org:443. That’s the only host it fetches from, and its fetches go through the same address floor and company proxy as the VM’s own requests. It follows no redirect, and nothing of the VM’s request goes with it, so nothing a VM sends is ever stored.

What the host checks. Before it keeps a narinfo, the proxy checks that its StorePath is the path the VM asked for, and that cache.nixos.org-1, whose Ed25519 key devvm pins, signed its fingerprint, 1;StorePath;NarHash;NarSize;References. The signature doesn’t cover the NAR’s URL, so the proxy also checks that the URL is named by the narinfo’s own NarHash or FileHash, and no narinfo can put a file in another NAR’s place. It fetches a NAR only after it checked a narinfo that names it, never for a HEAD, and it keeps the NAR only when its SHA-256 and size are that narinfo’s FileHash and FileSize. The host can’t check a NAR’s NarHash, which is the hash of the archive before compression, because the proxy uses Go’s standard library alone, and that has no xz or zstd. Each VM’s Nix checks the signature and the NarHash again, so a narinfo with an added reference, or a corrupted NAR, fails there too. A file that fails a check, a not-found and an error all get 404, and the proxy keeps nothing of them, so Nix goes to cache.nixos.org itself.

The folder and its cap. The proxy writes each file whole into a temporary file in the folder, then renames it into place, mode 600 in folders of 700. The cache’s cap is the nix-cache.size retention setting, 10 GiB when it’s off, which devvm puts in the proxy’s config as nix_cache_max when the proxy starts. The cap counts the disk each file takes, in whole blocks, and a NAR being fetched counts at its full size from the start. When a new file would take the cache past its cap, the proxy deletes the least recently used files, down to nine tenths of the cap, and when the NARs being fetched leave no room, it refuses the fetch. One proxy fetches each NAR once, however many requests ask for it, and at most four at once. A hit marks a file used by setting its modification time. Every VM’s proxy and devvm gc take one lock on the folder before they change it, so the cap holds while many VMs fill it, and a running total in the folder saves counting it for each file. devvm gc and the daily retention run trim the cache to its cap too, and devvm gc --nix-cache empties it.

Logs. Each request is a nix-cache event in the VM’s own log, with its path and what came of it: hit, fetched, or why it wasn’t found or was refused. devvm network log shows these as cache.

Open VMs have their proxy too, so they share the cache. An open proxy allows cache.nixos.org as it allows every host.

What stays open. What one VM can learn of another through the cache, and how it can push other VMs’ files out, is under Attack vectors. Private caches, such as Cachix or one behind a netrc, are never shared, because only root in a VM can add a substituter, and Nix reaches it directly through the proxy’s allowlist.

A company’s CA

The CA certificates in ~/.config/devvm/ca (a company’s CA) reach every project VM, whatever its tier, and the VM’s proxy. Builds trust them too, as Images and caching describes.

Reading them. trust.Read reads the folder’s *.pem and *.crt files at each launch, each build and each proxy start, and nothing outside the folder. It checks the folder and each of those files with os.Lstat and follows no link: a folder that is a link fails with ca_folder_link, and a file that is one, dangling or not, with ca_link. A link into a project folder would otherwise let that project’s VM write the CA that every VM, build and proxy trusts. A file or folder that it can’t read fails with ca_unreadable or ca_folder_unreadable, which name it and carry the error. It decodes every PEM block and refuses a block that isn’t CERTIFICATE (ca_not_certificate), a certificate that crypto/x509 can’t parse (ca_invalid) and a certificate without CA basic constraints (ca_not_ca). It also refuses a file with no certificate in it. It tells a private key apart by its PEM type, not by the file’s name, and a refusal fails the whole launch or build. What leaves the host is each certificate encoded again from its parsed bytes, once each, so the text around a block never does.

Installing them. assets/guest/ca.sh DIR runs as root and makes devvm’s CAs in /usr/local/share/ca-certificates exactly DIR’s devvm-*.crt files: the VM’s own CA and its next one, devvm-proxy.crt and devvm-proxy-next.crt, and the user’s devvm-extra-N.crt. When anything changed, it runs update-ca-certificates --fresh, so a CA that went leaves neither the bundle nor its hashed link. update-ca-certificates tells the hooks in /etc/ca-certificates/update.d only about the CAs it links, never about a file that was deleted, so ca.sh tells them itself. It runs each hook that run-parts would run, with a -/etc/ssl/certs/NAME.pem line on its input for each CA it removed, as Debian’s update-ca-certificates does for a CA it deselects. That way Java’s cacerts (ca-certificates-java’s jks-keystore hook) and Mono’s store drop a removed CA, in a project VM and in an image whose layer installed Java. A hook that rebuilds its store from the bundle ignores that input. A hook that fails stops nothing, and ca.sh says so in the run log. It also writes them all to /etc/ssl/devvm.pem, for Node. In a system without update-ca-certificates, such as a container image before its ca-certificates package, it adds them to /etc/ssl/certs/ca-certificates.crt itself. It keeps the bundle as it was in /var/lib/devvm-ca, or a mark that there was none, and puts it back once they go, and a package that arrives in between makes the bundle again from the same folder. Run without DIR, ca.sh removes every one of devvm’s files. Its unit tests run it in a folder that stands for / (DEVVM_CA_ROOT).

Project VMs. The setup that devvm sends at each launch carries ca.sh and the certificates in ca/, and sync.sh runs it, so a change applies at the next launch and a running VM needs no restart. Every project VM starts with trust.Env in its environment, whatever its tier and whether there are CAs or not, so adding a CA changes nothing that its backend sets at a start. On Lima, the clone that makes the VM sets the environment too (VMSpec.Env), and the VM’s first start adds its proxy’s address to it in the one edit before its first boot. The variables are NODE_EXTRA_CA_CERTS=/etc/ssl/devvm.pem, which is empty when there are none (Node warns only about a missing file), SSL_CERT_FILE, REQUESTS_CA_BUNDLE and PIP_CERT, set to the system’s bundle, and UV_SYSTEM_CERTS=1.

The proxy. netpolicy.Proxy.Prepare reads the folder too, and writes the bundle into the proxy’s config, proxy.json in the VM’s run folder, as upstream_cas, which the proxy adds to the system’s roots for its own TLS connections. Each secret lookup’s context carries those roots (proxy.WithRoots), and an agent’s OAuth refresh on the host trusts them (proxy.RootsOf, set on its client with fetch.Client.Trust), so it goes through a company proxy that inspects TLS as the VM’s own requests do. devvm’s other host requests, such as registry digests, Ubuntu’s image check and shared-tool downloads, use the host’s own store, as the host’s other programs do. A VM can’t change that file or the folder, so the proxy’s roots grow only by what you put there. A proxy whose bundle differs from the folder’s starts again, as one whose CA was replaced does.

End-to-end checks (tests/e2e): in project, a VM trusts a test CA from its next launch (openssl verify against the system’s bundle, and curl reaching an HTTPS server on the host that the CA signed), has the variables, fails its launch on a file that isn’t a certificate, and trusts the CA no more once the folder is emptied; in tools, Node fetches from that server.

Secrets

Secrets lists each secret with its hosts and header. This section describes where devvm keeps them and how the proxy puts them into requests.

Where secrets are saved

On macOS, devvm saves a secret in the Keychain, under the service devvm and the account claude or github. It saves it through security -i, giving the value on its input, so the value is in no process’s arguments. DEVVM_SECRET_SERVICE names another service, made of letters, digits, ., _ and - only. On other systems, devvm saves a secret in ~/.local/share/devvm/secrets/<name>, a file only you can read.

How the proxy puts them in

A secret’s hosts join the VM’s allowlist on port 443, whether a value is saved or not.

For each secret that has a value, devvm writes the VM’s bindings to secrets.json in the VM’s run folder, ~/.local/share/devvm/vms/<hash>/run/, as one JSON object of "version": 1, whole and mode 600. A binding holds the secret’s name, its kind, its hosts, header and placeholder, and never its value. The kind is secret for one that devvm keeps and login for one of an agent’s logins, with the agent whose login file holds it, so where a value comes from never depends on its name. The proxy reads the file only when it’s a regular file, not a symbolic link, that only you can read or write, and a file it can’t read that way has no bindings until devvm writes it again. For a bound host, the proxy intercepts :443 tunnels instead of passing them through. It terminates TLS with a certificate from the VM’s own CA. Each certificate it makes for a host lasts a day, but never past the CA’s own end or before its start, because a client checks the whole chain. The proxy keeps the certificates of the 1024 hosts it used most recently and makes a new one for any other, so a binding with a wildcard host such as *.example.com, which needs a certificate for each name the VM asks for, can’t grow the proxy’s memory without end. It then replaces the placeholder in the binding’s header with the real value, also inside Basic credentials, and forwards the request over a new TLS connection. Absolute-form https:// requests get the same. Each request must name the bound host, and one that names another host gets 421. The proxy checks the bindings file for every request and reads it again when it has changed, so removing a binding applies to tunnels that are already open.

The proxy is devvm itself, so it looks a value up in its own process, from the Keychain, devvm’s secrets folder or the agent’s login file, by the binding’s kind. It runs no other devvm for it, no devvm command prints a secret, and no value is in any process’s arguments. On macOS it asks the Keychain through security find-generic-password, whose output is the value. A lookup takes at most 20 seconds, and the proxy makes one at a time, so two requests never refresh a login at once. The proxy caches values for 5 minutes, or 30 seconds for agents’ logins, and never logs them. A lookup that fails puts nothing in, and the proxy’s own log says why, without the value, such as codex_refresh_failed when refreshing a Codex login failed. It refuses with 403 the GitHub API calls that would make a credential of their own with the GitHub token, which are adding an account or deploy key, registering a runner and adding a webhook, whether the repo or organization is named or given by its ID. Every HTTPS request it forwards is checked, inside an intercepted tunnel and in absolute form alike, before it goes anywhere.

The VM’s CA

Each VM has a CA of its own, whatever its tier, which only its proxy signs with and only that VM trusts, so a copy of one VM’s key can impersonate sites to that VM alone, and no VM’s proxy can make a certificate that another VM accepts. A CA is ECDSA P-256, valid for 30 days from an hour before it’s made, and it names no host. Its common name holds its serial number, so no two CAs share a name. devvm keeps the VM’s CAs in the VM’s run folder, ~/.local/share/devvm/vms/<hash>/run/ca/: the current CA as ca.pem and its key ca.key, and the next CA as next.pem and its key next.key. The folder and both keys are yours alone (modes 700 and 600), devvm writes each file whole, a key before its certificate, and on macOS it keeps the folder out of Time Machine. The keys never leave this machine: the VM gets the two certificates and nothing else. The proxy reads each certificate and key only when it’s a regular file, not a symbolic link, and refuses a key that anybody but you may read or write.

At each launch, trust.InitCA readies both CAs before the proxy starts, holding the folder’s lock, lock, so two launches of one VM at once take turns. The current CA stays while it has over a week left. When it has less, or there’s none, the next CA takes its place if it has started and has over a week left, and otherwise devvm makes a new current CA. The next CA starts a week and an hour before the current one ends, and lasts 30 days too. When it doesn’t follow the current one that way, by ending after it and starting by the time it’s replaced, devvm makes it again. A key without its certificate, or a CA that lasts longer than 30 days, is made again too. Each launch’s setup installs both certificates in the VM, so the VM trusts the next CA weeks before the proxy uses it.

The proxy holds both CAs, and it signs with the next one from an hour after that CA’s start, which is a week before the current one ends and the moment from which the next launch would make it the current one. The hour lets a VM whose clock is behind see that the CA has started. The switch needs no launch and no new proxy, because the VM already trusts the next CA, so a VM that you don’t open again keeps verifying the proxy’s certificates until the next CA ends, which is at least 30 days less an hour after its last launch. A proxy keeps the CAs it started with, because the VM trusts no others, and a launch that replaced either one starts the proxy again with the new ones, as how long a proxy runs describes. /ca.pem at devvm.proxy serves both certificates.

A VM that runs past its next CA’s end without a launch can’t verify the proxy’s certificates any more. From then on, its proxy answers each CONNECT to a bound host’s :443 with 403 before any tunnel opens, and it logs a refused block with the reason devvm's CA has ended: open the VM again (devvm in its folder) for a new one, so neither the VM’s prompt nor devvm network log suggests an allow rule. Absolute-form https:// requests involve no certificate for the VM, so they go on getting their secrets. The VM’s other connections work until you open it again. Running devvm in the VM’s folder then makes a new CA and a next one and starts the proxy again with them, which ends the connections open through the old one, and the launch’s setup installs both in the VM.

The CAs live as long as the VM’s folder. A remake keeps the folder, so the VM keeps its CAs, and so does a change of tier. devvm rm, devvm gc --orphans, and devvm deleting a VM whose creation it interrupted or whose folder an earlier VM of the project left, delete the folder, and the CAs and their keys with it.

Unit tests: TestInitCA, TestInitCASwitch and TestInitCAReplaced (internal/trust) check the dates, the modes, the switch and each CA that’s made again; TestInitCATogether and TestInitCACancelled check that launches at once take turns on the lock; TestPrepareOpen (internal/netpolicy) checks that an open VM’s proxy has the VM’s CAs, salt and secrets file, and that a VM keeps its CAs as its tier changes; TestNextCASigns checks that a client trusting both CAs verifies the proxy’s certificates before the switch and after it, with the same proxy; TestLoadCA checks that the proxy refuses a key others may read and a symbolic link; TestServersApart checks that two proxies’ certificates come from their own CAs. CLI tests (network.txtar, sync.txtar, layout.txtar): each VM’s CA files and modes, two VMs’ CAs differ, the setup sends both certificates and no key, an open VM’s too, and a VM turned off and on again keeps its CAs. End-to-end checks (tests/e2e, network): a second confined VM, whose CAs differ from the first’s; each VM trusts its own two and none of the other’s; no key is anywhere in the VM; each proxy serves its own CAs at /ca.pem, and its certificates verify with its own VM’s CAs and not with the other’s; devvm rm deletes a VM’s CA with its folder.

Agents’ logins and settings

Agents’ login files also stay on the host, in devvm’s agent store, which devvm agent import fills, and so do logins made in a VM (devvm’s agent store). devvm never reads an agent’s own login file in the home folder for a VM. When a VM’s image has the agent and the store has a login file for it, the login’s bindings join the VM’s, and their hosts join its allowlist on port 443.

Codex’s login is ~/.local/share/devvm/logins/codex/.codex/auth.json and opencode’s is ~/.local/share/devvm/logins/opencode/.local/share/opencode/auth.json, the store’s copies of ~/.codex/auth.json and ~/.local/share/opencode/auth.json. The VM’s copy holds placeholders, shaped like a JWT where the tools read claims, and the bindings put the store’s API key or access token into requests for the provider’s hosts:

  • For Codex, those are OpenAI’s hosts, api.openai.com, and chatgpt.com for a ChatGPT login.
  • For opencode, they’re the hosts of Anthropic, OpenAI and OpenRouter keys, and of OpenAI and GitHub Copilot logins, but not Copilot Enterprise.

The store keeps opencode’s login in the auth.json format of opencode 1: one entry per provider, {"type":"api","key"} or {"type":"oauth","access","refresh","expires"} with its expiry in milliseconds. opencode 2 keeps its logins in the credential table of its SQLite database, ~/.local/share/opencode/opencode.db, and imports auth.json into it once, in the migration that makes the table, so a VM’s opencode gets the VM’s copy when it first runs there. A VM’s placeholders don’t change when the host refreshes a login, so the database’s copies keep working. After that, the store’s opencode logins reach the database through opencode auth import, as auth export entries with the IDs cred_devvm_<provider> and the logins’ placeholders (agentlogin.OpencodeImport): each API key that the proxy puts in, and the OpenAI login from one of opencode’s Codex logins. The VM’s setup sends these entries on every launch, and devvm agent login opencode --api-key --provider PROVIDER sends them at once, both on the input of devvm’s guest script opencode.sh, never in a command’s arguments. The script keeps the last entries it got in ~/.local/state/devvm/opencode-import.json in the VM. When opencode has made its database and the new entries differ from the record, or the database no longer holds one of their IDs because it was made again, such as after you delete it or a history import replaces it, it logs out of devvm’s entries from the last ones with opencode auth logout, because opencode auth import skips an ID it already has, and then imports the new ones and makes each of them its provider’s active credential with opencode auth switch, so a ChatGPT login for another account or a key saved from another VM reaches every VM at its next launch. Credentials that you added in the VM yourself have other IDs, so they stay in the database, and opencode auth switch makes one of them active again. Before opencode has made its database, the script does nothing and records nothing, because opencode 2.2.5 doesn’t always import auth.json as it makes the database, so the first launch after opencode has run imports every entry. The script imports each entry on its own, so an entry that opencode refuses doesn’t keep the others out, and devvm agent login names the providers that opencode refused. A failed import leaves the record as it was, so the next launch tries again. The script and its record live in the VM rather than in the VM’s state on the host, because only the VM knows whether its database exists and what it last imported, and a VM that is set up again or has its home copied keeps both together.

Each VM’s placeholders end in its own random value, so no other VM’s placeholder gets its secrets. devvm reads a login file only when it’s a regular file, so a FIFO or a device in its place can’t hold devvm. devvm refreshes OAuth logins on the host, one lookup at a time, and writes them back to the store’s file, so a VM never holds a refresh token. A refresh that has started finishes and is written back even when the request that needed it ends, because OpenAI may already have replaced the refresh token. When a refresh fails, or the store’s login no longer has what a binding names, the proxy puts nothing in, and its own log says to log in again with the agent and import the login again (codex_refresh_failed, opencode_refresh_failed, codex_no_key, codex_no_chatgpt), or names the login file it couldn’t read (login_read_failed). devvm copies logins it doesn’t know as they are, and warns you (codex_login_kept, opencode_login_kept). An opencode OAuth login that devvm doesn’t bind, such as Copilot Enterprise’s or one for a provider devvm doesn’t know, is the exception: its access and refresh tokens become devvm-redacted in the VM’s copy, so opencode in the VM can’t use that login, and devvm warns you (opencode_login_withheld). If devvm can’t make the VM’s copy of a login, it stops the VM’s setup (login_copy_failed).

Coding agents lists which files each agent shares.

Logins made in a VM

A login that an agent makes inside a VM goes to devvm’s store too, and the VM gets only placeholders for it. The VM’s proxy does this at the agent’s OAuth token endpoint, the one request of a login whose reply holds its tokens. Codex and opencode both log in to OpenAI at auth.openai.com/oauth/token, whether the code comes from a browser’s redirect or from a device code typed into a browser elsewhere. For opencode 2, those are its Codex logins, the methods chatgpt-browser and chatgpt-headless. Its Sign in with ChatGPT, chatgpt-token-sharing, registers a client of its own and gets its tokens from auth.openai.com/api/accounts/oauth/token, then checks the id_token’s signature against OpenAI’s keys, which a placeholder can’t pass, so devvm doesn’t capture that endpoint, and its replies get 502, as the next paragraph says. Every VM’s proxy config lists that endpoint in captures, for Codex and for opencode, whatever the VM’s image holds, because a program in the VM could install either agent itself. The endpoint’s host joins the VM’s secrets.policy on port 443 only when its image has one of them, so that agent can log in on every tier. The proxy intercepts the host’s :443 tunnels as it does a bound host’s, with a certificate from the VM’s CA, and forwards every request to it. Only a POST to the endpoint’s path is captured, and the proxy compares the path once it’s cleaned, without its path parameters and in any case, so a request for /oauth//token, /OAuth/Token/ or /oauth/token;x is captured too. A captured request goes to the endpoint’s own path, without a query, whatever the VM’s spelling of it. Any other successful reply from that host that names an access_token, a refresh_token or an id_token anywhere gets 502, whatever its type, so no other path there, such as another token endpoint’s, hands the VM a login. The proxy passes only a web page and its parts unread, which are HTML, CSS, JavaScript, images and fonts, and it refuses a reply it can’t read for that, one that’s encoded or longer than 1 MiB, and an upgrade (101). No informational reply (1xx) from the host reaches the VM, because its headers could hold a cookie.

The proxy reads a captured request’s body whole, up to 64 KiB, and refuses one that names a field twice, which devvm and the endpoint could read apart. It drops the VM’s Accept-Encoding header from every request to the host, so each reply comes as it is, or as gzip that Go’s transport undoes. The request goes on unchanged, with two exceptions. After a code’s login, Codex exchanges the login’s id_token for an API key, with a token-exchange grant that asks for openai-api-key. The VM’s request carries the VM’s placeholder of the id_token, so the proxy puts the store’s id_token in its place, and only when the placeholder is that VM’s for the store’s current login. An error reply to that request reaches the VM with its OAuth error code alone, such as {"error":"invalid_grant"}, in case the endpoint’s message quotes the id_token. A refresh whose refresh token is the VM’s own, devvm-proxy-managed, or for opencode its access token’s placeholder, goes nowhere, and the VM gets the store’s login as placeholders again, because the host refreshes the login itself. devvm refuses any other grant than a code’s, a refresh’s or Codex’s exchange before the request leaves the host, because it couldn’t read the reply. The proxy reads at most 8 requests and replies to and from the host whole at once.

A successful reply never reaches the VM as it is. The proxy reads it whole, up to 1 MiB, and agentlogin.File.Capture saves its tokens in the store’s login file, in the agent’s own format, under the file’s lock, so a refresh on the host never runs at the same time. Codex’s goes in tokens of its auth.json, with the account from the id_token, and the exchange’s API key in OPENAI_API_KEY. opencode’s goes in the openai entry of its auth.json, as an OAuth login with its expiry in milliseconds and its account. A code’s reply replaces the login, and for Codex the API key of the login before it too. A refresh’s reply adds to the login, so the store keeps only the newest refresh token. Then devvm writes the VM’s bindings again, so its proxy puts the new login into requests at once. When that write fails, the login is saved still, the VM gets its reply, its proxy’s own log says why, and the VM’s next launch writes its bindings.

The VM gets a reply with placeholders in place of the tokens. Its access token’s placeholder is the one the store’s login binds, a JWT with the login’s account claims, its API key’s is the one its binding names, and its refresh token is devvm-proxy-managed, which is no token. The reply keeps only the token type and scope, says that the access token lasts until 2099, and holds nothing else of the endpoint’s reply, so the agent in the VM never refreshes the login itself. Its headers are only Content-Type, Cache-Control, Pragma and Date, so no cookie of the endpoint’s reaches the VM. A reply that’s too long, encoded in another way, or not a login devvm reads, such as one without an access token, gets 502 instead, logged as a refused block, and devvm saves nothing of it. An error reply holds no tokens, so it reaches the VM as it is, with the same headers alone. The event log gets a login event for each login saved, which names the agent, and the proxy’s own log says why a capture failed, without a value.

A login is the agent’s that the VM’s image has, of the two. In a VM with both, or with neither, the request’s User-Agent says whose login it is, through agentlogin.Pick. opencode runs on Bun, so a request whose User-Agent starts with Bun/ or opencode/ is opencode’s, and any other is Codex’s.

Claude Code logs in at platform.claude.com/v1/oauth/token, and older releases at console.anthropic.com/v1/oauth/token, so every VM’s proxy config lists both in captures for Claude Code, and their hosts join the secrets.policy of a VM whose image has it. Anthropic doesn’t document these requests: devvm reads them from Claude Code 2.1’s bundle, so a release that changes them can break the capture, and the network end-to-end section drives the real Claude Code’s login against a fake endpoint to catch that. Claude Code sends JSON with its public client ID, 9d1c250a-e61b-44d9-88ed-5944d1962f5e, and PKCE, without a client secret. A code’s request holds grant_type, code, redirect_uri, client_id, code_verifier and state, and a refresh’s grant_type, refresh_token, client_id and scope. The reply holds access_token, refresh_token, expires_in and scope, with the account and the organization.

claude setup-token sends a code’s request too, with expires_in of a year added, and asks only for the user:inference scope, so agentlogin.SetupToken tells it apart by that field. Its reply’s access token, which must start with sk-ant-oat01-, becomes the claude secret (secrets.Store.Save), and the VM gets the secret’s placeholder in its place, which claude setup-token then prints. Any other code’s login goes in the store’s .claude/.credentials.json, as Claude Code keeps it on Linux: claudeAiOauth with accessToken, refreshToken, expiresAt in Unix milliseconds and scopes. A new login whose scope lacks user:inference gets 502, because it can’t use the model: that’s an Anthropic Console login, which Claude Code would use to make an API key at api.anthropic.com/api/oauth/claude_cli/create_api_key with the access token the VM never gets. A login or a refresh whose scope has org:create_api_key gets 502 too, whatever else it has, because the proxy puts the login into every request to api.anthropic.com, so a VM could make an API key with it and get the key itself. The access token must start with sk-ant-oat01- and hold no space, quote, backslash or control character, in a capture’s reply and in the host’s refresh’s. Only a VM whose image has Claude Code captures a Claude Code login, because a capture replaces the login every VM with Claude Code uses, so another VM’s gets 502 and devvm saves nothing. The VM’s reply holds sk-ant-oat01-devvm-proxy-managed-oauth-<salt> as its access token and devvm-proxy-managed as its refresh token, so the VM’s ~/.claude/.credentials.json, which Claude Code writes from it, holds no token. A refresh with devvm-proxy-managed goes nowhere, and the VM gets the placeholders again.

devvm keeps one Claude Code login. Saving the claude secret, captured or with devvm secret set claude, deletes the store’s .credentials.json, and capturing an OAuth login deletes the saved claude secret, so the newest login wins. Both happen under the lock of the store’s logins/claude folder, so two at once never leave neither, and a saved token that can’t be deleted is a warning in the proxy’s log, as the login is saved. A CLAUDE_CODE_OAUTH_TOKEN in devvm’s environment wins over both, as it does in Claude Code (secrets.ClaudeOf(store).Login). A launch gives the VM CLAUDE_CODE_OAUTH_TOKEN with its placeholder when there’s a token, and otherwise the VM’s copy of .credentials.json, with that placeholder, no refresh token and an expiry of 2099 (agentlogin.File.VMCopy), never both. The VM’s bindings hold both logins’ placeholders, the claude secret’s and claude-oauth, each putting a value into Authorization for api.anthropic.com, whenever devvm keeps either login, and secrets.Store.Get gives each the login devvm keeps now. So a VM that started with the login that the newest replaced keeps working until its next launch gives it the new one. Claude Code sends the anthropic-beta header that names oauth-2025-04-20 itself, so the proxy only swaps the placeholder.

The host refreshes the OAuth login when its access token is due, as Codex’s is: a JSON refresh to platform.claude.com/v1/oauth/token with Claude Code’s client ID and the login’s scopes, through the company proxy the VM’s proxy goes through, written back under the file’s lock (agentlogin.File.Get). When the refresh fails, the proxy’s log says to run devvm agent login claude again (claude_refresh_failed).

devvm agent login claude runs claude auth login --claudeai in the VM, or claude setup-token with --setup-token, without the VM’s CLAUDE_CODE_OAUTH_TOKEN. Claude Code shows the address that comes back to platform.claude.com/oauth/code/callback, which shows the code to paste, so devvm forwards no port. Once the store has a new login of the kind asked for (secrets.ClaudeOf(store).Sums), devvm sets the VM up again, so its environment and files hold the login devvm keeps now.

Any VM with the agent can replace the store’s login, because the store keeps one login for each agent. A VM you don’t trust can log your other VMs in to an account of its choosing, and their requests then go to that account. Claude Code’s long-lived tokens have the same shape whatever asks for one, so a token that Claude Code mints in a VM for another use, such as claude /install-github-app’s, becomes devvm’s Claude Code login too, and what the VM gets is its placeholder, which works only through its proxy. A VM with Claude Code can also use the login, through its proxy, for whatever else its scopes allow at api.anthropic.com, beyond the model, such as reading the account’s profile, and devvm doesn’t limit the paths it goes to. Neither does devvm guard Anthropic’s other hosts: if one of them also answered /v1/oauth/token, a VM could exchange its code there and keep the login’s tokens, though none is known to. On the open tier, a program in the VM can go around its proxy, and then it gets the login’s tokens itself and devvm saves nothing.

Unit tests: TestCaptureLogin, TestCapturePaths, TestCaptureEarlyHintsAnswerAndSaved, TestCaptureErrorPasses and TestCaptureRefuses (internal/proxy) check what goes out and what the VM gets, its headers included, which paths are captured and the one they go to, that tokens from another path are refused, that no 1xx reaches the VM, an answered request and a login saved without its bindings, that an error passes, with its code alone after a real value went out, and that what can’t be read is refused; TestCodexCapture, TestOpencodeCapture, TestClaudeCapture, TestClaudeSetupToken, TestClaudeOAuthGet, TestCaptureRefuses and TestEndpointsAndPick (internal/agentlogin) check each agent’s file, the VM’s placeholders and copies, the exchange, the VM’s own refresh, a Console login refused, setup-token’s request told apart, the host’s refresh of Claude Code’s login and repeated fields; TestOneClaudeLogin, TestClaudeBindings and TestClaudeOAuthVMCopy (internal/secrets) check that the newest Claude Code login replaces the other, the environment’s token wins, both bindings get the login devvm keeps, and the VM’s copy holds placeholders; TestCapturer (internal/netpolicy) checks that every VM’s config has the endpoints on every tier, which agent a capture is, and that it writes the VM’s bindings. End-to-end checks (tests/e2e, network): a fake token endpoint behind a company proxy of the run’s own, which the VM reaches through its proxy; the store gets the real tokens and the API key, the VM’s replies hold placeholders and no refresh token, no log holds a value, and the VM’s next launch gets the login as placeholders. For Claude Code, the same company proxy fakes Claude’s token endpoint and Anthropic’s API: claude setup-token’s request makes the claude secret and the VM gets its placeholder, the real Claude Code’s claude auth login through devvm agent login claude saves the login in the store and leaves placeholders alone in the VM, the API gets the store’s token for the VM’s placeholders, and a login that’s due makes the host refresh it at Claude’s endpoint through the company proxy.

MCP servers

On each launch, devvm merges the settings and MCP servers from the store’s .claude.json, ~/.local/share/devvm/logins/claude/.claude.json, into the VM’s own ~/.claude.json:

  • Each of your servers replaces the VM’s server of that name whole.
  • devvm removes the servers it copied before that you no longer have.
  • Servers added in the VM stay.

The copy holds only your settings, onboarding and account state, and MCP servers. Everything else stays out, such as primaryApiKey, customApiKeyResponses and your per-project entries, which devvm agent import already leaves out of the store, and the VM keeps its own value of each. So the VM keeps its own per-project entries, such as trust and allowed tools, and yours never go in. If devvm can’t read the store’s .claude.json, it stops the VM’s setup (login_copy_failed).

On every tier, an HTTPS server’s headers become bindings for its host, which must be a DNS name on port 443. devvm replaces these with devvm-redacted: env values, plain-HTTP servers’ headers, user info and secret-looking values in URLs, and secret-named or key-shaped arguments. A server’s command and headersHelper, which are shell commands, lose the same words as other text in settings files (keys in settings files).

Keys in settings files

devvm also takes the keys out of the VM’s copies of the agents’ settings files, which are ~/.claude/settings.json, ~/.codex/config.toml and opencode’s .json, .jsonc and .toml files. Those are JSON, JSONC or TOML, and devvm parses each file whole and writes the VM’s copy from what it parsed, so a JSONC file’s copy is JSON and a TOML file’s copy has no comments. devvm replaces these with devvm-redacted, and names what it replaced (config_redacted):

  • Every value under a key that looks like a secret’s (*key*, *token*, *secret*, *password*, Authorization), and every string in a list or a table under such a key, but key bindings, such as opencode’s keybinds.
  • Any string that starts like a known key or token, such as sk-, ghp_ or eyJ, wherever it is.
  • Every value in an env or headers table, whatever its name: env, environment, headers, http_headers, and Codex’s shell_environment_policy.set. A number of up to five digits, true and false stay, because they’re switches and ports. In Claude Code’s own env, the top-level one of settings.json, its configuration variables stay too: ANTHROPIC_MODEL, ANTHROPIC_SMALL_FAST_MODEL, the ANTHROPIC_DEFAULT_*_MODEL and ANTHROPIC_CUSTOM_MODEL_OPTION families, CLAUDE_CODE_*, DISABLE_*, MAX_* and *_TIMEOUT_MS. A variable whose name has KEY, TOKEN as a word, SECRET, PASS, AUTH, CREDENTIAL, COOKIE or HEADER in it is redacted all the same, such as CLAUDE_CODE_OAUTH_TOKEN, and so is its value when it’s a URL.
  • In a URL anywhere in the file, a string’s whole value or a part of other text, its user info and its secret-looking query values and path segments.
  • In every list, the arguments that MCP servers’ arguments in ~/.claude.json lose: the value after a secret-named flag, such as --api-key X, the value of --token=X, and any that looks like a key.
  • In any other string, such as a hook’s command or an argument that holds a header, each word that starts like a known key, and the credential after Bearer or Basic, quoted or not, when it has 16 or more characters, with a digit, +, / or = among them, which words don’t have, and isn’t a path. devvm replaces only those words, so curl -H 'x-api-key: sk-…' URL becomes curl -H 'x-api-key: devvm-redacted' URL, and a reference such as Bearer ${GITHUB_TOKEN} stays. MCP servers’ arguments, command and headersHelper in ~/.claude.json lose the same words.

Values that only name where a secret is stay as they are. Those are an environment variable’s name (env_key = "OPENAI_API_KEY"), {env:X}, ${X} and a path. devvm refuses a file that doesn’t parse as its format, instead of copying any of it unread, so the VM doesn’t get that file (config_not_copied). The VM doesn’t get opencode’s other files in ~/.config/opencode either, such as a .env, because devvm can’t take secrets out of them, apart from its Markdown files, such as AGENTS.md, which hold instructions for the agent (config_not_copied). An MCP server or a provider that needs one of those keys in the VM has to get it another way.

Saving the copies back

devvm agent save reads the VM’s copies back, and they hold devvm-redacted and the proxy’s placeholders where devvm’s agent store has your secrets (saving a VM’s settings). devvm parses the VM’s file whole, and when it holds a placeholder, devvm also makes the copy the VM got from the store’s file again: the same redaction for a settings file, and for ~/.claude.json the copy with the VM’s own salted placeholders. Each placeholder gets the store’s value at the same path, but only when the copy had the same placeholder there, and when the table the placeholder is in and the table above that one, below the file’s top level, are the same as in the copy. Those tables say where the secret goes, such as an MCP server’s url beside its headers, a provider’s baseURL beside its apiKey and an env table’s base URL beside its key, so a VM can’t keep a placeholder and point it at another host. A placeholder that fails the check leaves the whole file out of the save (agent_save_placeholder), and so does a placeholder in a file without a format devvm parses, such as a skill. A file with placeholders put back is written as devvm writes the VM’s copies, as JSON or as TOML without comments, and a file without placeholders is saved as the VM has it.

What stays in the VM’s copies

Taking secrets out of a file works only where devvm can tell a secret from the rest, so some go into the VM on every tier, and keys devvm can’t tell apart lists them among what devvm leaves open:

  • The agents’ mounted folders. devvm mounts Claude Code’s skills, agents, commands, output-styles and plugins, Codex’s prompts and skills, and opencode’s subfolders whole and read-only, so every file in them reaches the VM, a .env in ~/.config/opencode/plugin included. So does a plugin marketplace’s git config in ~/.claude/plugins/marketplaces, which holds a password or a token when you added a private marketplace with one in its URL. These folders hold code that the agent runs, such as plugins, hooks and the scripts that skills call, so a copy with only settings and Markdown, as opencode’s config folder’s files get, would break them. A copy without files whose names look secret, such as .env or *.pem, would miss a key in any other file, and it would need a copy of each folder, which can be large, on every launch, while a mount shows the changes to devvm’s agent store at once. So devvm mounts them as they are, and the coding agents page says to keep secrets out of them.
  • Free text of an unknown shape. A key in other text, such as a hook’s command, stays when it neither starts like a known key nor follows Bearer or Basic, because devvm can’t tell it from a word.
  • Logins devvm can’t bind. The entries of Codex’s auth.json that devvm doesn’t know, opencode’s API keys for providers other than Anthropic, OpenAI and OpenRouter, and opencode’s well-known logins ("type": "wellknown", which an organization’s own config sets up) go into the VM’s copy as they are, because devvm doesn’t know which hosts they’re for, so the proxy couldn’t put them in. A local model server’s key is one of these. devvm names each (codex_login_kept, opencode_login_kept).

The self-check

A confined tier holds only while the VM runs on the process that devvm started confined. A process can’t leave its sandbox, but limactl start starts a new Lima process, unconfined. So on every launch, and whenever an export or devvm rm’s check of a --clone VM needs the VM running, whether devvm started the VM or found it running, devvm checks two things, from the host:

  1. That the VM still runs on the process devvm started. The VM can’t fake this check.
  2. That a direct connection from the VM to the internet fails. The VM answers this check, so it catches only the honest case.

devvm records the result in the VM’s state, as ok or unenforced in the net section’s made.check. When the check fails, what devvm does depends on why:

  • The VM wasn’t started confined, because it was started outside devvm or has been running since before devvm network moved it from open to a confined tier. In a terminal, devvm offers to restart the VM confined (net_unenforced_restart), and if you decline, it warns you and goes on (net_unenforced). Without a terminal, or if the VM is still unenforced after the restart, devvm stops the VM and ends without opening a shell (net_unenforced_stop). The next devvm starts the VM confined.
  • The VM was started confined, yet it gets out, because it reaches the internet through something devvm didn’t set up, such as a second network. A restart wouldn’t help, so devvm stops the VM and ends (net_direct_route).

This is the check, and what devvm does when it fails:

    flowchart TD
  launch["every launch"] --> proc["on the process<br>devvm started?"]
  proc -- "yes" --> direct["a direct connection<br>from the VM fails?"]
  direct -- "yes" --> ok["check: ok"]
  proc -- "no" --> un["check: unenforced"]
  direct -- "no" --> un
  un --> confined["started confined?"]
  confined -- "yes: another<br>way out" --> route["stops the VM<br>and ends"]
  confined -- "no" --> term["a terminal?"]
  term -- "no" --> stop["stops the VM, no shell;<br>the next devvm starts<br>it confined"]
  term -- "yes" --> offer["restart it confined?"]
  offer -- "no" --> warn["warns, goes on"]
  offer -- "yes" --> again["enforced now?"]
  again -- "yes" --> ok2["check: ok"]
  again -- "no" --> stop
  

An open VM isn’t confined, so its self-check is another one: a command in the VM asks its proxy for http://devvm.proxy/blocked through the proxy that the VM’s /etc/environment names, the variable’s name in the command and never the password. When the proxy doesn’t answer, devvm warns you (net_proxy_unreachable) and goes on, records no result, and the VM keeps running.

devvm ls and devvm network show such a VM’s tier as unenforced. That’s a VM whose last self-check failed, or a running VM that isn’t on the process devvm started confined, which devvm checks on the host each time.

On Lima, “the process devvm started” is the VM’s Lima process, which devvm starts confined.

Log files

The proxy writes every decision (tunnel, intercept, forward, blocked, or nix-cache for a request to the shared Nix cache) as a JSON line in network.jsonl in the VM’s logs folder, ~/.local/share/devvm/vms/<hash>/logs/. A line holds the time, the VM, the host and port, the method and path when the proxy sees them, the rule or the reason, and the names of the secrets it put in, never their values.

The proxy’s own lines, such as what it loaded and what failed upstream, go to proxy.log next to it. An upstream error stays there, because it can name addresses that the VM shouldn’t learn. So when a name doesn’t resolve, or a tunnel can’t connect, the VM’s 403, its http://devvm.proxy/blocked and the event log give only the reason can't resolve it; see proxy.log on the host or connect failed; see proxy.log on the host. The error itself, which can name this machine’s DNS server or the company proxy’s address and what it answered, goes to proxy.log alone. A name that resolves to an internal address gets the reason it resolves to an internal address; allow the address itself to reach it, which tells the VM that much and not the address (attack vectors). Only you can read the VM’s logs folder and its run folder, and devvm’s run logs never hold a VM’s password. The VM chose the hosts and paths, so devvm network log drops control characters in them.

The event log also holds the VM’s links’ connections, which devvm _links writes in the same format: link for a connection it carried, and link_refused, link_target_stopped or link_limit for one it refused, with the link’s name as the host and the target VM in target. An open VM’s event log holds its proxy’s events and its links’, but nothing it reached directly.

The proxy is the only program that writes proxy.log, and the event log has devvm _links as its only other writer. The proxy rotates each log before a line would take it past 10 MiB, and devvm _links writes the event log with the proxy’s own code, so it rotates the log by the same rule and a VM that runs for months keeps at most about 20 MiB of each. A line never spans two files, and lines from the proxy’s connections at the same time are written one after another, so a rotation loses none and splits none. Before each line, each writer checks that the file it has open is still network.jsonl, and when the other writer has rotated it, the writer opens the new network.jsonl and writes there, so its lines go on reaching the file that devvm network log --follow follows. Each writer takes the log’s size from the file, so it counts the other writer’s lines too. A writer rotates the log while it holds a lock on the file and checks the name again first, so when both reach the limit at once, only one renames the file and the other writes to the new one. A rotation renames network.jsonl to network.jsonl.1, which replaces the one that was there, and starts a new network.jsonl. proxy.log is also the proxy process’s output, where its last error goes when it stops, so the proxy copies it to proxy.log.1 and empties it instead, and that error still reaches the current file. Every log file and its .1 file is readable only by you (mode 600).

devvm network log reads network.jsonl.1 before network.jsonl, so the history it shows and the blocks that devvm network log --blocked counts carry on across a rotation. devvm network log --follow reads the renamed file to its end and then follows the new one from its start. devvm rm deletes the .1 files with the rest of the VM’s logs.

devvm _proxy

devvm runs the proxy as itself, devvm _proxy -config FILE, and nothing else is in its arguments. Its flags take one dash or two, and its errors end in one line, The VM's proxy failed: … (proxy_failed).

The config is one JSON file, proxy.Config, which a VM’s proxy and a build’s proxy both run with. For a VM it’s proxy.json in the VM’s run folder, ~/.local/share/devvm/vms/<hash>/run/, and for a build it’s proxy.json in the build proxy’s folder. devvm writes it whole, mode 600, before each start. It holds the VM’s password and the company proxy’s credentials from the environment, though never config.toml’s password, which the proxy looks up itself, so it’s never in a process’s arguments, which other users can see. The proxy reads it once, as it starts, and refuses to start unless it’s a regular file, not a symbolic link, owned by the user the proxy runs as and readable and writable by nobody else, at most 1 MiB, of "version": 1, with no field the proxy doesn’t know. It also refuses a config without a password, one whose listen isn’t a loopback address and port, one with ca and no ca_key or the other way around, and one with a nix_cache_max below 0. A config of another version is never read, so a proxy and the devvm that started it always agree on what each field means.

FieldWhat
version1
listenthe loopback address and port to listen on, port 0 for any free one
vmthe VM’s name, for the log
passwordthe VM’s password, which every request carries in Proxy-Authorization with the user devvm; required
builtin_policiesthe built-in policies, paths in devvm’s own files, read from the binary once as the proxy starts, ahead of policies; a missing one refuses to start
policiesyour policy files, in order, each read again when it changes; a missing one has no rules
logthe JSON-lines event log (none: standard output), rotated past 10 MiB
proxy_logthe file that the proxy’s output goes to, which is its own log, rotated past 10 MiB by copying
lifelinethe hostagent’s pid file: exit once it no longer holds what it held when the proxy first saw its process running, or that process is gone
ca, ca_keythe VM’s own CA, for interception: its certificate and key
ca_next, ca_next_keywith ca: the VM’s next CA, which signs from an hour after its start
secretsthe VM’s bindings file, read again when it changes; the proxy looks each binding’s value up in its own process
upstream_casthe CAs in your CA folder (PEM), which the proxy’s own TLS connections trust besides the system’s
upstreamthe company proxy: https, http and no_proxy, as HTTPS_PROXY, HTTP_PROXY and NO_PROXY or config.toml’s [proxy] had them, and secret, the secret the proxy looks up as the password of a URL that names a user and none of its own; allowed hosts are reached through it
upstream_unresolvedwith upstream: names this machine can’t resolve go there even when only ** allows them, which builds’ proxies get
nix_cacheserve the shared Nix cache at http://devvm.proxy/nix/ from this folder, which it makes, only yours (mode 700)
nix_cache_maxwith nix_cache: the cache’s cap, in bytes (none: 10 GiB)
linksthe VM’s links’ list, which devvm _links writes: reach each link through its socket, before any rule (links through the proxy); a missing list has no links
link_socketswith links: the folder of the links’ sockets, devvm’s run folder; without it, no link is reached
devvm_sha256, ca_sha256, ca_next_sha256the SHA-256 of devvm’s binary, of the VM’s CA certificate and of its next CA’s that the proxy started with; the proxy doesn’t read them, and they’re there so that a new binary or a new CA makes another config (how long a proxy runs)

-config is the proxy’s only flag, and running the proxy is its only mode. What only this machine does for a VM’s proxy runs inside devvm itself, never through the proxy’s arguments. devvm makes each VM’s CAs with trust.InitCA, and internal/agentlogin makes the VM’s copies of agents’ logins and settings files, their bindings and their values.

Last updated on