← Docs
boxery is built for one trusted owner running untrusted-ish agents: the agents are yours, but you don't want a confused or prompt-injected agent walking off with your credentials or wrecking your laptop. It's not built for hostile multi-tenant use.
| ✅ The internet | Through libvirt's NAT network (or Lima's user network) |
| ✅ DNS and DHCP | From the host's network (libvirt's dnsmasq on the bridge address) |
| ✅ LLM providers | Only the ones you grant (llm:chatgpt, …), through the host proxy, with a per-VM token |
| ✅ What you grant | ssh-agent, env vars, files: only while granted |
🔒 http:/github grant secrets |
never: the host proxy injects them per request; the VM can use but not read them |
| ❌ Your real LLM keys / OAuth tokens | They stay on the host |
| ❌ Your laptop | No inbound path. ssh goes laptop → host → VM. With Lima the laptop is the host: on macOS a guest reaches the Mac's loopback (127.0.0.1) only on the proxy port. See limits. |
| ❌ The host's sshd and other services | libvirt and Cloud Hypervisor guests can't open connections to the host: only DHCP and DNS reach it. Lima guests can reach the Mac's non-loopback addresses (see limits). |
| ❌ Other VMs | libvirt, Cloud Hypervisor: dropped on the host, and a guest can't take another's MAC or IP (ARP, IP and MAC spoofing are filtered) |
| ❌ Your LAN, Tailscale, cloud metadata | libvirt, Cloud Hypervisor: everything in 0/8, 10/8, 100.64/10 (CGNAT, Tailscale), 127/8, 169.254/16 (link-local, 169.254.169.254), 172.16/12, 192.168/16 and 224/3 (multicast) is dropped, as is IPv6. Lima: see limits |
Inside the VM, everything listens on localhost: the desktop (noVNC on 127.0.0.1:6080), Chrome's CDP port, and the proxy forwarder. You reach the desktop only through an ssh tunnel (boxery desktop).
The desktop also needs a password. websockify (noVNC) doesn't check the Origin header. While a boxery desktop tunnel is open, any process on your machine can reach it, and so can a web page in a browser that allows WebSockets to localhost. The VNC server therefore uses TigerVNC's VncAuth and never runs without a password. The password is 8 random letters and digits (VncAuth reads only 8 characters). It lives in /run/user/UID/boxery-vnc/passwd: tmpfs, mode 0600, owned by the agent user, never on the VM's disk. boxery-vnc.service makes one before it starts the server, so a new VM is locked even if nobody opens its desktop. Each boxery desktop sets a new password and passes it to noVNC in the URL fragment, which isn't sent to the server or written to its logs (details). Computer use (vm_screenshot, vm_click, …, vm_view) talks to the X display directly over ssh, not to VNC, so the password doesn't affect it.
Every ssh connection to a VM checks the VM's ssh host key: yours (boxery ssh, run, the agent tools, grant pushes, the ssh-agent forward) and the host's own (key and token installs, lease setup). boxery generates each VM's ed25519 host key on the host, puts it in the VM's seed (sshd presents only that key) and records the public half in ~/.config/boxery/host-keys/NAME. Controllers fetch it from the host when they connect (KnownHostsCommand … boxery-host known-hosts %H in the generated ssh_config), and ssh runs with StrictHostKeyChecking yes. So a guest that answers on another VM's address gets nothing: not that VM's pushed grants, its ssh-agent forward or its proxy token.
-
A new identity gets a new key.
new,forkandnew --fromeach get a fresh key.revertand pool resets keep the VM's own key, because its images carry it.rmremoves the record. -
boat.dev VMs keep the platform's host key. boat reports it through its API, and boxery re-pins it whenever the VM may have moved to another machine (start, reimage).
-
Re-pinning. A VM made before boxery pinned keys, or whose record was lost, is refused with "Host key verification failed". Re-pin it:
boxery pin NAME # reads the key through a channel the guest network can't spoof: # libvirt's guest agent, or Lima/exe/boat's loopback endpoint on the host boxery pin NAME --from-guest # if that isn't available: trust what NAME's ssh endpoint answers, once
boxery start NAMEalso pins such a VM automatically through those channels.--from-guestis the trust-on-first-use fallback: use it only when nothing else on the VM network could be impersonating NAME. With libvirt's guest isolation (#69) no other guest can. -
Older hosts. A controller talking to a host from before this change keeps its old, unverified
ssh_configand warns once a day until you runboxery upgrade-host. After that, the next command rewrites the config to verify keys. -
Out of scope. The hop from your laptop to the host is your own ssh to it and uses your
known_hosts. exe.dev's and boat.dev's admin channels pin those platforms' keys separately (backends).
Guests run without swap, with core dumps disabled. Pushed grant files are 0400 and live on tmpfs.
VMs run agents that need LLM access, but they never get a real key. A VM gets no LLM access at all until you grant it a provider: boxery grant VM llm:chatgpt (or llm:openai, llm:anthropic, llm:ollama). See Grants.
VM: pi/opencode ──► 127.0.0.1:8088 ──vsock──► host: boxery-proxy ──► chatgpt.com / OpenAI / Anthropic / ollama
(per-VM token) checks token + llm: grant, swaps in real credential
boxery-host newwrites the agent configs into the VM. They only say where to send requests; they don't grant anything:~/.pi/agent/models.jsonoverridesopenai-codex.baseUrlwithhttp://127.0.0.1:8088/chatgpt/backend-api. ItsapiKeyis an unsigned fake JWT whose payload carries{"boxery":"<vm>:<secret>"}. It has to be JWT-shaped because pi derives the ChatGPT account id from the JWT. Ollama models are listed too.~/.config/opencode/opencode.jsongets aboxeryOpenAI-compatible provider pointing at/ollama/v1.
- In the guest,
boxery-llm.serviceforwards127.0.0.1:8088to the host: over vsock (CID 2) on libvirt, or the user-net gateway on Lima. The host needs no listening port on the VM network. Lima maps that gateway (192.168.5.2) to the host's loopback, so on macOS boxery starts each Lima instance under a sandbox that lets it reach only the proxy port there (limits). - On the host,
boxery-vsock.servicehands the connection toboxery-proxyon127.0.0.1:8088. A named host profile has its own pair on its own port (boxery-vsock-NAME→boxery-proxy-NAME). - The proxy checks
<vm>:<secret>against~/.config/boxery/tokens.json, then checks that the VM holds anllm:grant for that provider (listed in~/.config/boxery/edge/<vm>.json, re-read on every request). No grant means 403, before any credential is attached. It then strips the VM's auth and injects the real credential: the host pi's ChatGPT/Codex OAuth (refreshed automatically) or keys fromproxy.env. It also drops the VM'sopenai-organizationandopenai-projectheaders, so a VM can't bill another organization or project the host's key belongs to. Onlyproxy.envsets them (OPENAI_ORG_ID,OPENAI_PROJECT_ID). Routes are allowlisted and size-limited. WebSocket upgrades are refused, so pi uses SSE. boxery revoke VM llm:Xtakes effect on the next request.boxery rmand pool lease resets revoke the token and all grants.boxery proxy-logshows which VM called which provider.
boxery runners once (local runners) and boxery-host runners (host runners) turn pool VMs into ephemeral self-hosted runners (GitHub Actions runners). A workflow job runs arbitrary code from the repository, and from whoever can change its workflows, as root in the runner VM.
- Private repositories only. Never attach these runners to a public repository, or to an organization runner group that public repositories can use. On a public repository, a pull request from a fork can run its own workflow code on your runners, and so on your hardware and network. GitHub's own advice is the same: self-hosted runners are for private repositories.
- One job per VM, then a reset. Each runner is registered with a just-in-time config that takes exactly one job. When it exits, its lease is released and the pool resets the VM to
NAME@boxery-clean, so a job can't leave anything behind for the next one: no files, processes, credentials or caches. A VM whose reset fails is quarantined (broken), as for any pool lease. - The same network as any pool VM, and no grants. A job reaches the internet and nothing on the host, other VMs, your LAN or tailnet (above; Lima's limits apply to Lima hosts). It has no LLM access and no
http:/env:/file:grants: the runner manager grants nothing, and the VM's per-lease proxy token is useless without a grant. Secrets a job needs come from GitHub (Actions secrets), as on any runner. - Local runners: no GitHub credential on the host.
boxery runners onceregisters the runner with theghlogin of the machine it runs on (gh api, from that machine). The host gets only the runner's JIT config, on the stdin ofboxery-host runners __once(over the controller's ssh connection; never a command line, an environment variable or a file on the host), and from there it goes to the guest as below. The controller deletes the registration when it is done. If the controller vanishes, the host stops the runner and releases the VM: at once when the connection closes, or after 2 minutes without a heartbeat. The registration then stays, offline and unusable without that VM's JIT config, until GitHub removes it. Local runners lease asgithub-runner-localand are namedboxery-local-…, so the host's runner manager never adopts, releases or deregisters them. - The JIT config is the only GitHub credential in the guest. Host runners' GitHub token (a fine-grained token that can manage runners: repository "Administration" or organization "Self-hosted runners", read and write) stays in
~/.config/boxery/runners.env(0600; the manager refuses a file others can read). The manager sends it only in theAuthorizationheader to the GitHub API it was configured with, and never logs it or a JIT config. The JIT config travels over ssh stdin (never a command line), sits on the guest's tmpfs until the runner reads it, and registers one runner for one job; the runner's own credentials then live on the VM's disk until the reset removes them. Anything in the guest can read the JIT config and, with it, impersonate that one runner, which can only take the job GitHub would have given it anyway. - The runner image is pinned. The
github-runnerlayer installs one actions/runner release, checked against its SHA-256 pinned in the layer, and the runner runs with self-update off: what runs is what the image was built with. Rebuild runner VMs after bumping the pin. - A job sees what the VM's clean image holds. Whatever you install into a runner VM before
pool add(tools, caches, repositories) is visible to every job. Put no credentials there.
- Guest network isolation on libvirt. VMs share libvirt's
defaultNAT network (one bridge), and every guest's interface carries the nwfilterboxery-guest-isolation. libvirt applies it on the host whenever the domain starts, so root in the guest can't lift it, and it is part of the domain's configuration, so forks, reverts, lease resets and host reboots keep it. The guest's address is the one the host's DHCP server leased it (CTRL_IP_LEARNING=dhcp, answers only from the bridge address count); traffic from any other MAC or IP, and ARP claiming one, is dropped. To the host it may send DHCP and DNS only, and from outside it accepts replies and ssh from the host. The private ranges listed above are dropped, which is also what keeps guests apart (they share192.168.122.0/24). The policy is the cloud-hypervisor backend's (one routed TAP per VM and nftables), expressed as libvirt nwfilter rules, so it needs no root and no host firewall of boxery's own. What it doesn't cover:- VMs made before this release stay on the open network until their next cold start;
init(andupgrade) adds the filter to their configuration,boxery stop NAME && boxery start NAMEapplies it. BOXERY_NET_ISOLATION=offinhost.envturns it off for VMs that must reach LAN services (install). Their guests then reach the host's sshd and services on the bridge address, each other and every network the host routes to.- Public addresses of the host itself (a cloud VM's public IP) aren't in a blocked range, so a guest reaches services the host exposes there, as anyone on the internet can.
- DNS answers come from the host's resolver. A guest can resolve internal names; it just can't connect to private addresses.
- VMs made before this release stay on the open network until their next cold start;
- exe.dev: the account key and integrations. Anyone with the exe.dev account's ssh key has root in every VM through exe.dev's edge, so treat it like host root (
BOXERY_EXE_KEYgives boxery a key of its own). exe.dev integrations give a VM credentials exe.dev holds, outside boxery's proxy and grants. boxery allows them (they're the account owner's choice) and warns atinit,new,startand reimages about each one that reaches a VM, exceptreflectionandnotify, which hold none. A guest in a VM an integration reaches can use its credentials with no grant, confirmation or per-VM revoke; detach it to rule that out (exe backend). New accounts attachllmto every VM. - Lima guests and the host's loopback. Lima NATs the guest's gateway,
192.168.5.2, to the host's127.0.0.1. Left alone, that hands every guest each loopback service on the Mac: Ollama's admin API (/api/pull,/api/delete, bypassing the proxy's allowlist), the HTTP MCP server, dev databases (#67). On macOS, boxery therefore starts every Lima instance undersandbox-execwith a Seatbelt profile. Lima's host agent, which carries the guest's network, and the qemu it spawns may then open loopback connections only to the proxy port and to the instance's own ssh port, which leads back to the guest's own sshd. Everything else on127.0.0.1(any 127/8 address, TCP and UDP) is refused. The Mac's kernel enforces it, so root in the guest can't lift it. What it doesn't cover:- Services listening on the Mac's other addresses (
0.0.0.0, the LAN or Tailscale IP) stay reachable from guests, as from your LAN. Guest traffic leaves through the Mac's own routing, so guests also reach your LAN and tailnet. - Instances started outside boxery (a plain
limactl start boxery-NAME) and VMs that were already running when you upgraded aren't confined. Restart them withboxery stop NAME && boxery start NAME. - Lima on Linux has no Seatbelt. Its guests still reach the host's loopback; use libvirt or Cloud Hypervisor there.
- A host-side HTTP proxy or DNS resolver on
127.0.0.1is out of reach for Lima guests too. Confined qemu instances use slirp's DNS forwarder (192.168.5.3) instead of Lima's host resolver, which listens on loopback.
- Services listening on the Mac's other addresses (
- Proxy spend. A guest with root can read its own token and spend through every provider it's been granted. That's the same blast radius as handing it an API key, except the token is revocable per VM and logged. Per-VM budgets are planned.
- Grant front-ends require confirmation by default.
vm_grantadds a grant only after you approve a boxery-generated client elicitation or pi dialog. The CLI asks on/dev/tty(not stdin), defaults to No, and refuses without a controlling terminal. This coversgrant,agent-forward on, creation-time grants andfork --grants; re-applying existing grants and revocation never prompt. Both front-ends call shared operations; tool approval does not invoke the CLI or use a prompt-bypass flag. It can't ask forhttp:grants with a--cmd, files in~/.ssh,~/.gnupg,~/.pior boxery's own config (the same setvm_copy --copy-rootrefuses), orenv:BOXERY_*variables such as the HTTP MCP token (Agents can ask). A requested file is stored by its real path, and an apply skips any granted file that has since been re-pointed through a symlink into one of those directories. The CLI enforces the same deny-directory andBOXERY_*rules. A client hook or setting that auto-accepts MCP elicitations would also auto-accept these. Headless automation may explicitly putGRANT_CONFIRM=offin a host profile'sconfig: this turns off CLI grant confirmation protection for that profile, not MCP/pi dialogs or validation. No flag or environment variable skips CLI confirmation. This is friction, not a security boundary: an agent running as the user can fake a terminal (script,expect, tmux), change the profile or call internal operations directly. - HTTP MCP runs on the control plane.
boxery-mcp --httpruns next to the credentials it protects, so itsvm_copyrefuseslocal:endpoints unless you start it with--copy-root DIR, which confines them toDIRand still refuses boxery's config,~/.ssh,~/.gnupgand~/.pi(details). Anyone with the bearer token can drive every VM on that host; keep it on the tailnet behind--allow-user. - Pushed grants are readable.
env:/file:values (and the ssh-agent while connected) can be used or read by whatever runs in the VM. Grant dev-scoped credentials only, and preferhttp:grants, whose values stay on the host. - Desktops from older bases have no VNC password. The password is part of the desktop base image. VMs made from a base built before it have an open VNC server, and so do their snapshots and images: while a
boxery desktoptunnel is open, any local process or web page can drive that desktop, including its logged-in browser.boxery desktopwarns when it opens one. Rebuild the base (boxery build-base) and create those VMs again (details). - Desktop password limits. VncAuth is old (a DES challenge-response, 8 characters at most). It stops processes and pages on your machine that would guess, but not anything that can read your browser's history: the URL is saved there, and its password works until the next
boxery desktopor a reboot of the VM replaces it. TigerVNC blocks an address for a while after 5 failed logins. Every login through the tunnel comes from127.0.0.1, so a local process that keeps guessing can also lock you out for a while. - Guest patches are as fresh as the VM. Every VM gets the security updates available when its disk was made: the base build (desktop) or its first boot (headless) runs
apt-get full-upgrade. After that, only Ubuntu's unattended-upgrades patches it, and a new kernel runs from the next restart. Snapshots, images and pool clean states keep what they were captured with.boxery lsmarks a VMoutdatedwhen it was built from other image layers than the host's, more than 30 days ago, or when it waits for a reboot, andunknownwhen it predates build records (details);snapsandimagesshow the same for images.boxery patch NAME(or--all) runs the VM's layers again with the host's current files: the updates, a restart if they ask for one, and the software the layers install outside apt (playwright-coreand its Chromium on arm64, node 24, the pi and opencode CLIs npm installed). For a pool VM it also captures the clean state again. What it doesn't cover:- Snapshots and images aren't patched. A VM made or reverted from one starts with its old software; patch it afterwards, or take the snapshot again.
- The desktop base isn't patched: VMs cloned from it start with its software until patched. Rebuild it (
boxery build-base) to make new desktops current. - Staleness is a record, not a scan. It says what boxery installed and when, read when the host last reached the VM (
ls --refreshreads it again). Packages someone updated by hand aren't counted, and a VM that unattended-upgrades kept patched still shows its build's age. - Kept software. devtools keeps what the starting image ships: on exeuntu (exe.dev),
patchleaves the image's own node, and an agent CLI that isn't npm's global install of its package, as they are (apt packages still update). - The arm64 browser is Playwright's Chromium build, which only changes when
playwright-corereleases a new one, and can lag Chrome's security fixes by weeks.
- One host. No multi-host scheduling yet.
Found a security issue? Please report it privately through GitHub security advisories rather than a public issue.