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
| 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 |
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"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.
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."
These are known and deliberate, not gaps to be closed later:
- 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 (
tailscale0and 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 throughtailscale0, which the boundary drops for the devbox and the project daemon. - A forwarded agent is open to the whole devbox while it lasts.
ssh -A devboxexposes the 1Password agent socket for the connection's lifetime, and the socket belongs todev: every process in the container can use it, not just the shell you forwarded it into. Only agent git is fenced (HTTPS, through theomp/claudelaunchers, which also start each session withoutSSH_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, anddevon 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.pubopens only the devbox and is never registered on GitHub, andHost <workstation>uses the default identity's key rather thandevbox.pub. That narrows, not closes: a key used throughssh -A devboxstays 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 isdevbox-docker-firewall. What a forwarded agent still reaches is your GitHub push authority, for the connection's lifetime. Plainherdrpanes,./bin/devbox shelland a baressh devboxnever forward one. - No isolation between projects. One container, one
devuser, one bind mount: an agent in project A can read project B's.envand GCP key. Cloning something less trusted is where per-project containers or separate users stop being over-engineering. - Secrets are plaintext at rest.
.envfiles,ghtokens, 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. - The project Docker daemon widens reach to the
devaccount. Anything in the container can start a container through that socket, including one bind-mounting a host path β but only as unprivilegeddev: world-readable host files to read, onlydev-owned files to write, never host root, the root daemon, or your home directory (750, untraversable bydev). The price ofdocker compose upinside the box β why the daemon gets its own dedicated account. dev's systemd user manager reads its units andenvironment.dfrom 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 asdevin 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 anddocker0. The same rules cover a--network hostproject container, which shares the host's network namespace. Not applied: running the daemon from a root-owned system unit with no user manager. - 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 doctorfails if it is inactive or stale; removed (sudo systemctl disable --now devbox-docker-firewall && sudo nft delete table inet devboxβstopalone 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. - On the laptop, an agent is only as contained as your OS user. The launchers, the
ghshim 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 realghwith 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, agh auth logout(your ownghon fine-grained PATs too) and 1Password set to approve every SSH request remove what a bypass could reach. tailscaled's LocalAPI relays to the Tailnet./run/tailscale/tailscaled.sockis world-accessible (0666), and itsdialendpoint hastailscaled, 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 hosttherefore fails when the runningtailscaledis below 1.98 (read fromtailscale version --daemon, falling back to the CLI's version when the daemon doesn't answer). The project daemon can bind-mount any host pathdevreaches, 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/tailscalethrough atailscaleddrop-in, or hiding it from dev's user manager.
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.
| 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 |
- The Tailnet is the perimeter. Any Tailnet device with an authorized key can reach the devbox β no second factor on the SSH port.
authorized_keystrusts a GitHub account. Every key on theDEVBOX_GITHUB_USERaccount can log in β the same trust model the workstation's host sshd uses. Narrow it: clearDEVBOX_GITHUB_USER, list keys explicitly inDEVBOX_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 thatdevcan't write. It opens nothing on the host:docker setupputsDenyUsers devin the host's sshd (/etc/ssh/sshd_config.d/devbox-docker.conf), since/home/dev/.sshis that account's too and a locked password doesn't stop a key login underUsePAM yes.- The
devhost user owns the data, and root can read everything. The bind mount belongs to the dedicateddevaccount, which also owns the project Docker daemon; your host account reaches it only viasudo. The container protects the host from the agent, not the files from its owner.
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.
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.
Yes: sshd never changes user. Dropping CHOWN/SETUID/SETGID is exactly what a root-launched sshd would have needed.
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.
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.
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.
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.