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 where the App is installed (push) | per-repository GitHub App installation token, one App per identity | one repo, 1h |
| Agent git elsewhere (clone, fetch only) | the identity's fine-grained PAT, read-only | its named repos, read-only |
| 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 read-only fine-grained PAT per GitHub account | named repos, read-only |
| 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 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. None of that
writes as you: every PAT is read-only, so a stolen one reads what it names and nothing more, and every write an agent
makes carries an App's bot. 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/claude/codexlaunchers, 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 and Codex logins (~/.claude/.credentials.json,~/.codex/auth.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. Sessions an app starts sit outside the launchers: the Claude desktop app's Bash commands are fenced by aSessionStarthook, but the Codex desktop app's sessions are not fenced at all. Codex's nearest counterpart,shell_environment_policyin~/.codex/config.toml, could set the git exports for every command but cannot put theghshim ahead on thePATH(it takes literal values), and the file is the desktop app's own, so applying it would need a TOML merge - not done. Those sessions still read~/.codex/AGENTS.md(the laptop context, which tells an agent with an emptyDEVBOX_ENVto stop before any git push orghwrite) and load the rules file, but those are instructions and a guardrail, not a fence β only a launcher-startedcodexis fenced. 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, Vercel/Neon project deletion, API DELETEs and
token minting, and prompt for op, gcloud, terraform apply and every Vercel/Neon write, deploy, login or
database-password read (Toolchain).
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.
home/.claude/settings.json is the Claude Code counterpart: permissions.deny and permissions.ask rules, written
Bash(<pattern>), that mirror the OMP list with the same deny/prompt split (bare op/gcloud/vercel and
*vercel token folded into their Bash(β¦ *) or Bash(*vercel) rule). Seeded or merged by
bootstrap and ./bin/devbox agent install when ~/.claude/settings.json has no permissions.deny, re-applied by
./bin/devbox sync claude; merge, not copy, because other tools write that file (Toolchain).
Claude Code differs from OMP in four ways that matter here:
- It splits compound commands (
&&,;,|, subshells,$(...)) and appliesdenyandaskto every subcommand, strips wrappers (timeout,nice,command, β¦) before matching, and matches past leading environment assignments.true && GH_CONFIG_DIR=/tmp/x true,command gh auth tokenand/opt/homebrew/bin/gh --versionare all denied. denyholds even underbypassPermissions.- Permission arrays merge across settings scopes (user, project, local), so a repository's own
.claude/settings.jsoncannot drop these rules the way a project'sbash.patternsreplaces OMP's. - Nothing in the preset corresponds to OMP's
secrets.enabledobfuscation.
The limits are otherwise the same for OMP and Claude Code: rules match command text, so an interpreter one-liner or a script file walks past them β accepted limit 7 still applies on the laptop.
home/.codex/rules/devbox.rules is the Codex counterpart: execpolicy rules (Starlark prefix_rule, decision
forbidden or prompt) for the same categories as the other two lists β the human gh login, an absolute-path gh,
env -u and unset of the launcher's exports, the keychain, op, gh repo delete/secret/variable and API
DELETEs, gh workflow run, gcloud and terraform apply/import (prompt), terraform destroy, force pushes,
--no-verify and docker system prune/volume rm. It is a file of our own, so nothing is seeded or merged: bootstrap
and ./bin/devbox agent install rewrite ~/.codex/rules/devbox.rules on every run, and there is no sync codex. Codex
loads every *.rules file in ~/.codex/rules and the strictest match wins (forbidden over prompt over allow), so an
"always allow" click, which Codex writes to default.rules, never lifts a forbidden. Codex's rules are coarser than
OMP's globs:
- A rule matches only the start of the command's argument list. A flag later in the line (
git push origin main --force), a leading assignment (GIT_CONFIG_GLOBAL=x git push,export GH_CONFIG_DIR=x) or abash -lcscript that is not a plain chain of words falls past it; a pinned runner (npx vercel@39) does too. - So every Vercel and Neon invocation prompts, reads included, and only project deletion and token or API-key minting are forbidden, where OMP and Claude Code gate those CLIs verb by verb.
The sandbox is a separate matter. On the devbox container/codex-config.toml, linked by the Dockerfile to
/etc/codex/config.toml (Codex's lowest-precedence config layer, root-owned and outside the bind mount), sets
sandbox_mode = "danger-full-access". Codex's Linux sandbox is bubblewrap, which needs a user namespace; Docker's
default seccomp profile refuses one without CAP_SYS_ADMIN, and this container runs cap_drop: [ALL] with
no-new-privileges. Left at its default every sandboxed command fails and Codex asks to rerun it unsandboxed. The
container is the boundary β OpenAI's own advice for a container that is β and Codex stands on the same footing as OMP and
Claude Code here, which also run commands unsandboxed. Approvals keep their default, so the rules above still prompt and
forbid. Your own ~/.codex/config.toml or a -c/--sandbox flag overrides the layer; it is a default, not a guardrail.
Codex's shared background server (0.157+) runs every attached session under the environment of whatever started it β the
desktop app, a bare ~/.local/bin/codex β so attaching would drop the fence. codex-launcher therefore passes
--no-daemon on both machines, which keeps its sessions off a server even when one is running, and on the devbox
bootstrap turns daemon_auto_start off in ~/.codex/config.toml so none is started
(Toolchain). The laptop keeps Codex's own macOS Seatbelt sandbox defaults:
nothing writes a config there.
| 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 write only through each identity's own GitHub App, scoped to specific repositories and
permissions, and gh otherwise uses a read-only 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.
Not any more: the PAT writes nothing. Every agent write goes through an installed GitHub App, which mints a token scoped to exactly one repository for one hour; a repository without the App is read-only to agents. Install the App on the repositories agents should change 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.