Skip to content

Security: rozsival/devbox

docs/security.md

πŸ›‘οΈ Security Model

This container exists to run agents with bypassed permissions, where the worst case is a lost project tree, not a lost host. The threat model is an agent (or dependency) executing arbitrary code inside the devbox.

Related: Architecture Β· Secrets Β· Docker Β· Networking


🧱 Boundaries

Boundary Enforced by
No host filesystem access Only ${DEVBOX_DATA_DIR} mounted, at /home/dev (see accepted limit 5)
No host root Docker daemon /var/run/docker.sock not mounted; reachable daemon is rootless
No privilege escalation user: ${HOST_UID}:${HOST_GID}, cap_drop: [ALL], no-new-privileges:true
No public network exposure ${BIND_ADDR}:${DEVBOX_SSH_PORT}:2222 β€” Tailnet address only
No host services devbox-docker-firewall: the devbox bridge reaches only the project daemon's ports; dev's uid and its subordinate uids β€” the daemon, its containers, --network host ones included β€” no host address, loopback included, but the DNS stub
No password auth PubkeyAuthentication yes, PasswordAuthentication no, UsePAM no
No private keys at rest Devbox holds no SSH private key; AllowAgentForwarding yes only lets ssh -A devbox borrow the laptop's forwarded 1Password agent for one connection

πŸ‘€ No root process at runtime

The image ends as USER dev; sshd runs unprivileged, only ever authenticating the user it already is β€” with UsePAM no and pubkey-only auth, needing neither /etc/shadow nor setuid.

This is the isolation that matters: user namespaces aren't configured on the workstation, so container root would be host UID 0 in a runtime escape. Running as the dedicated dev account (UID 1001) means an escape lands as a host user with no password, no sudo, no files outside /home/dev.

Verify:

ssh <workstation> 'cd ~/devbox && docker compose exec -T devbox ps -o user= -p 1'   # dev
ssh <workstation> 'cd ~/devbox && ./bin/devbox logs | grep "Server listening"'      # no "must be run as root"

πŸŽ’ What the container holds

Authority is enumerated, not ambient: each credential is scoped, separately revocable, separately attributable.

Purpose Credential Reach
Agent git (clone, pull, push, commit) per-repository GitHub App installation token, else a fine-grained PAT App: one repo, 1h. PAT: its named repos, contents: write
Manual git, incl. signing (you) the laptop's 1Password agent, forwarded per connection (ssh -A devbox) same as your laptop; the container stores no private key
Agent gh on one repository (PRs, issues, runs) that repository's GitHub App installation token, else the PAT below App: one repo, 1h (cached ≀30 min on tmpfs)
Dashboards, CI, issues, account-wide gh one fine-grained PAT per GitHub account named repos, scoped per token
LLM inference per-project GCP service-account key one dev project, predict-only
Project secrets that project's .env one project

Notably absent: any 1Password account (op isn't installed), any GitHub private key, any Google user credential. See Secrets.

πŸ” What an agent inside the devbox can reach

Can: the whole /home/dev tree β€” every identity's public keys (useless without the laptop's forwarded agent), every identity's gh token, each configured App's private key, every project's .env and GCP key β€” plus the internet, the LAN (Tailnet peers included, at their LAN addresses) and the rootless project Docker daemon (Docker).

Cannot: the host filesystem outside the data dir and world-readable paths, the host's root Docker daemon, the host's own services (its sshd included), other Tailnet machines over the overlay (accepted limits 1 and 8 name the routes that remain), the devbox container's own lifecycle, root inside the container, any unpublished port, and any 1Password vault.

The consequence: an agent with shell access can push through the same App token or PAT git/gh already resolve, and read each configured App's private key, every identity's PAT, and every project's .env and GCP key directly. Your own GitHub push authority stays out of reach β€” no private key to steal β€” unless an ssh -A devbox connection is open (accepted limit 2).

Important

Treat a compromise as "revoke every identity's PAT and App key, plus the service-account key," not "rebuild a laptop."

⚠️ Accepted limits

These are known and deliberate, not gaps to be closed later:

  1. No egress filtering beyond the Tailnet overlay. Outbound internet is unrestricted β€” the box needs it β€” and so is the LAN: the boundary cuts only the overlay (tailscale0 and Tailscale's address ranges), so a Tailnet peer stays reachable at its LAN or public address like any other machine. An agent reading a poisoned issue or README can send whatever it holds anywhere on the internet: IAM and token scoping limit what it can reach, not send. The container is a containment boundary for authority, not confidentiality β€” assume anything inside can leave. A Tailscale exit node on the workstation removes the internet too: its traffic leaves through tailscale0, which the boundary drops for the devbox and the project daemon.
  2. A forwarded agent is open to the whole devbox while it lasts. ssh -A devbox exposes the 1Password agent socket for the connection's lifetime, and the socket belongs to dev: every process in the container can use it, not just the shell you forwarded it into. Only agent git is fenced (HTTPS, through the omp/claude launchers, which also start each session without SSH_AUTH_SOCK β€” a default, since the socket stays findable under ~/.ssh/agent/ (OpenSSH β‰₯ 10.1)). That directory is on the bind mount, so any project container that bind-mounts /home/dev, and dev on the host, reach the socket as well. And 1Password approves per key and per application, not per use β€” a key your terminal app is already approved for (by this connection, or by anything since 1Password last locked) signs without a prompt. What a key can reach is therefore the limit: devbox.pub opens only the devbox and is never registered on GitHub, and Host <workstation> uses the default identity's key rather than devbox.pub. That narrows, not closes: a key used through ssh -A devbox stays approved for the terminal app until 1Password locks, so it can sign from the devbox too β€” what keeps the devbox off the workstation's sshd is devbox-docker-firewall. What a forwarded agent still reaches is your GitHub push authority, for the connection's lifetime. Plain herdr panes, ./bin/devbox shell and a bare ssh devbox never forward one.
  3. No isolation between projects. One container, one dev user, one bind mount: an agent in project A can read project B's .env and GCP key. Cloning something less trusted is where per-project containers or separate users stop being over-engineering.
  4. Secrets are plaintext at rest. .env files, gh tokens, key files and the Claude Code login (~/.claude/.credentials.json) are unencrypted on the workstation's disk, readable by the host user. Workstation disk encryption and host account hygiene are part of this security model, not separate.
  5. The project Docker daemon widens reach to the dev account. Anything in the container can start a container through that socket, including one bind-mounting a host path β€” but only as unprivileged dev: world-readable host files to read, only dev-owned files to write, never host root, the root daemon, or your home directory (750, untraversable by dev). The price of docker compose up inside the box β€” why the daemon gets its own dedicated account. dev's systemd user manager reads its units and environment.d from root-owned /etc/devbox-docker, which closes the path the devbox writes directly β€” files in the bind mount. It doesn't close the manager: a project container can bind-mount /run/user/1001 (its bus, systemd/private, systemd/user.control, systemd/transient) and change the daemon's environment or start units as dev in the host's network namespace. What bounds such code is the boundary's rules for dev's uid and its subordinate uids: no host address β€” loopback included β€” but systemd-resolved's stub, no Tailnet overlay, and its listening sockets answer only on loopback and docker0. The same rules cover a --network host project container, which shares the host's network namespace. Not applied: running the daemon from a root-owned system unit with no user manager.
  6. A project port is one firewall rule away from the Tailnet. Rootless Docker binds every published port on 0.0.0.0, with no way to change that β€” devbox-docker-firewall, an nftables table dropping input to that daemon's sockets outside loopback and the devbox bridge, confines them, and keeps the host's own services and the Tailnet overlay away from the devbox and its project containers. ./bin/devbox doctor fails if it is inactive or stale; removed (sudo systemctl disable --now devbox-docker-firewall && sudo nft delete table inet devbox β€” stop alone leaves the table loaded), every project port reaches the Tailnet and LAN, and the devbox reaches the host's sshd and every Tailnet peer. A container the host's root daemon puts on the default bridge shares the devbox's boundary β€” give such containers their own network. See Docker.
  7. On the laptop, an agent is only as contained as your OS user. The launchers, the gh shim and the SSH fence choose which credential an agent session uses by default β€” clearing what your shell carries (an IDE's askpass, GITHUB_TOKEN, SSH_AUTH_SOCK) and refusing every credential prompt; they cannot stop a process running as you from calling the real gh with your OAuth login, reading the keychain, or asking the 1Password agent for a key (1Password's approval prompt is then the only gate). On the devbox the same bypass gains nothing β€” no credential broader than the scoped PATs and App keys exists there. Keep autonomous or bypass-permission work against orgs you own on the devbox; on the laptop, a gh auth logout (your own gh on fine-grained PATs too) and 1Password set to approve every SSH request remove what a bypass could reach.
  8. tailscaled's LocalAPI relays to the Tailnet. /run/tailscale/tailscaled.sock is world-accessible (0666), and its dial endpoint has tailscaled, as root, open a connection for any local caller. From 1.98 it relays only Tailnet routes β€” a peer, its sshd included β€” and answers a non-Tailscale address with Dial-Self; before 1.98 it dialled any address, so the host's own services, its sshd and loopback included, were reachable too, past the boundary's uid rules. ./bin/devbox doctor host therefore fails when the running tailscaled is below 1.98 (read from tailscale version --daemon, falling back to the CLI's version when the daemon doesn't answer). The project daemon can bind-mount any host path dev reaches, so a project container can relay to a peer through that socket. The boundary stops direct connections from the devbox and its containers, not this relay. Not applied, being the host's decision: restricting /run/tailscale through a tailscaled drop-in, or hiding it from dev's user manager.

Command allowlists are a guardrail, not a control

An agent's own command allowlist β€” forbidding op, gcloud and similar β€” is a useful guardrail, not a control: a subverted agent can call the same APIs through an SDK without either binary. The boundary is what each credential can do.

home/.omp/agent/config.yml ships exactly such a guardrail for OMP, seeded when absent by bootstrap and ./bin/devbox agent install: bash.patterns that deny gh auth token|login|…, an absolute-path gh (*/bin/gh * β€” past the shim), unsetting or reassigning the launcher's exports (env -u, unset GIT_CONFIG_GLOBAL, GH_CONFIG_DIR=…; reading them stays allowed; env -i prompts instead, since this repo's own smoke tests use it), keychain reads, gh secret/variable/repo delete, --no-verify and force pushes, and prompt for op, gcloud and terraform apply.

Warning

deny holds even under yolo, but the rules match command text in the bash tool only β€” not eval, not a script file β€” and a project's own bash.patterns replaces the list (settings arrays never merge), so such a repository has to carry the rules it wants itself.

🚫 Deliberate boundaries

Boundary Rationale
No host root Docker socket Mounting /var/run/docker.sock would hand the sandbox host root, voiding the container's point. Projects needing containers get a sibling rootless daemon owned by a dedicated unprivileged host user, reached through DOCKER_HOST β€” see Docker for why nesting one needs the container's CAP_SETUID back
No root process at runtime See No root process at runtime
No 1Password in the container A live op session is readable by any agent in that shell, turning a one-project leak into every vault the account can read β€” while project secrets sit in a plaintext .env regardless, since the app must read them. Secrets render on the laptop, then copy in
No Google user credential gcloud auth application-default login writes a non-expiring refresh token for your whole Google identity; projects get a service-account key scoped to their own GCP project instead

🀝 Trust assumptions

  1. The Tailnet is the perimeter. Any Tailnet device with an authorized key can reach the devbox β€” no second factor on the SSH port.
  2. authorized_keys trusts a GitHub account. Every key on the DEVBOX_GITHUB_USER account can log in β€” the same trust model the workstation's host sshd uses. Narrow it: clear DEVBOX_GITHUB_USER, list keys explicitly in DEVBOX_EXTRA_AUTHORIZED_KEYS. The file itself is the bind mount's, so an agent can add a key to it: that key opens the devbox β€” from the Tailnet only β€” until the next container start rebuilds the file. Without root at runtime there is no file sshd reads that dev can't write. It opens nothing on the host: docker setup puts DenyUsers dev in the host's sshd (/etc/ssh/sshd_config.d/devbox-docker.conf), since /home/dev/.ssh is that account's too and a locked password doesn't stop a key login under UsePAM yes.
  3. The dev host user owns the data, and root can read everything. The bind mount belongs to the dedicated dev account, which also owns the project Docker daemon; your host account reaches it only via sudo. The container protects the host from the agent, not the files from its owner.

❓ FAQ

Why not gVisor, Firecracker, or a VM?

Overkill for the actual risk (a hostile dependency, not a targeted attacker), and it breaks herdr's attach model. The cheap, high-value controls β€” non-root, no socket, dropped capabilities, address-scoped port β€” are already in place.

Should I enable user namespace remapping on the host?

It would let the container run as root safely, but nothing here needs container root, and userns-remap breaks the UID-matched bind mount that makes the data dir inspectable from the host β€” not worth it.

Is cap_drop: ALL compatible with sshd?

Yes: sshd never changes user. Dropping CHOWN/SETUID/SETGID is exactly what a root-launched sshd would have needed.

What if unprivileged sshd stops working after an OpenSSH update?

Fix the unprivileged path β€” it's the whole design. cap_add and a root-launched sshd are off the table per AGENTS.md rule 4; ./bin/devbox shell still gets you in while sshd is broken, so there's no lock-out risk to trade the boundary away for.

Can I give an agent a narrower key?

Already the default: agents commit through each identity's own GitHub App where one is installed, scoped to specific repositories and permissions, and gh uses a fine-grained PAT rather than an account-wide OAuth token. The forwarded 1Password agent stays broad for manual work because it's yours β€” see accepted limit 2.

Would per-repo deploy keys be tighter than the fine-grained PAT?

Yes β€” but the primary path is already that tight: an installed GitHub App mints a token scoped to exactly one repository for one hour. Deploy keys matter only for the fallback case β€” repos without the App installed β€” where the fine-grained PAT trades the same breadth as any multi-repo PAT: it pushes everywhere granted contents: write, readable by any agent, not just by you at a prompt. Install the App on repos that matter instead of adding a deploy key.

How do I revoke access from a lost laptop?

The SSH keys are 1Password items, not files, so the laptop carries no key β€” but remove the Devbox Laptop key from DEVBOX_EXTRA_AUTHORIZED_KEYS anyway (devbox.pub is never registered on GitHub), restart the container (authorized_keys rebuilds on every start), and drop the default identity's key from the workstation's own ~/.ssh/authorized_keys. What the laptop does hold in plaintext, if ./bin/devbox agent install ran there, is the agent's own credentials: one GH_TOKEN_<SLUG> per identity in ~/.config/devbox/secrets.env, and each configured identity's App pem under its own app directory. Revoke every identity's PAT, rotate every App private key β€” same list as a container compromise above.

There aren't any published security advisories