Repository navigation
Authenticate the sandbox's host bridges - #3
Merged
Merged
Conversation
The sandbox's host bridges are socat listeners on 127.0.0.1, which any local process or user, host-network container or other sandbox VM can reach, with nothing asked before relaying an SSH or GPG agent. bridges.BridgeServer listens on a fresh 127.0.0.1 port (port 0, kept bound, so nothing can take it in between) and drops every connection whose first line is not this launch's token, compared in constant time, followed by the name of a service it serves. Services run on daemon threads of the launcher, so they end when it does. unix_relay is the first service: a two-way relay to an agent socket. Not wired in yet; the launcher switches to it in a later commit.
Ports the two host handler scripts to bridge services, with two changes in behavior: - clipboard replies with a status line before the bytes, "OK" or "ERR <reason>", so the in-VM wl-paste shim can fail like wl-paste does (an empty clipboard, for one) instead of exiting 0 with no output. The argument allowlist is unchanged. - browser_open parses the URL instead of prefix-matching it: https only, host exactly clau.de or claude.ai, path /chrome or below it, and no port or credentials. https://claude.ai/chromebook-ad no longer passes.
Ports browser_bridge_host.py to a bridge service. The framing, newest-socket selection, private-dir check and service-worker keepalive work as before, with two changes: - The native-host dir is named after the uid's user name, as claude names it, instead of getpass.getuser(), which reads $USER first and could pick a different dir. - The relay is a small class instead of closures in main() at complexity 19, and the keepalive and the swallowing of its replies are now tested.
The launcher now serves every bridge from one bridges.BridgeServer in its own process instead of starting one host socat per agent, clipboard and browser bridge. Each connection must open with the launch's random token and a service name, so other local users, containers on the host network and other sandbox VMs can no longer use the agents, clipboard or browser through the loopback ports. The VM gets the port with -e and the token through the secret env file. The entrypoint presents each forwarded socket with socat and a new bridge-connect helper that sends the handshake; the wl-paste and google-chrome shims send it themselves. The host no longer needs socat, and the three host handler scripts are gone.
Passing the bridge port and token pushed _build_sandbox_argv to 101 lines, past the limit in AGENTS.md, and nothing caught it: ruff checks complexity but not length. claude's own session flags (skip-permissions, --chrome, the briefing) move to _sandbox_claude_args, and a test now fails for any function in the package over 100 lines.
claude on the host runs the hooks and statusline command in the
profile's settings.json, and the VM sees the profile dir read-write. The
VM got a copy mounted over the file only when there was a sudo deny to
strip; otherwise it wrote the real file. It now always gets a per-launch
copy.
A missing settings.json is created as {} so there is a file to mount
over, and a symlinked one is never read through: the VM could have aimed
it at another profile's credentials, so the VM gets {} and a warning
instead. Failing to write the copy now stops the launch.
The work tree and its git dir are mounted read-write, so the VM could set core.fsmonitor or an alias in .git/config, or plant a hook, and the host's next git command in the repo would run it, a shell prompt's git status included. Each git dir's config, the repo's and its submodules', is now mounted read-only over itself, and hooks/ is replaced by a throwaway copy refreshed from the host at each launch, with links copied as links. krun refuses a rename onto a file mount, so git commands that save config (push -u, branch -u, remote add) cannot save it in the VM. The push itself still works; the sandbox briefing tells the agent.
claude loads hooks from .claude/settings.local.json at the work tree root and at the launch dir, and git ignores the file, so a change made in the VM would run on the host unnoticed. Where the file exists, the VM now gets a per-launch copy, never read through a link.
The VM gets the global statusline script read-only but can replace the profile's link to it, and claude runs the statusline on every host launch. A host launch of a profile that is marked for the sandbox, or has been launched in one, now warns unless statusline.sh is absent or is the link to ~/.claude/statusline.sh that add creates.
The profile's .claude.json stays writable from the VM, and claude on the host starts the MCP servers it lists. Supervised launches now record the MCP servers, the .mcp.json approvals and which .claude/settings.local.json files exist before the VM starts, and list what the session added or changed once it ends. Only names are printed, never commands or env, which carry tokens. The names come from the VM, so non-printable characters are replaced and rich markup is escaped before printing, as is the link target that _linked_mounts prints.
The security section said the agent could change git config, hooks, settings.json and .claude/ settings that the host later runs. It now lists what is protected (read-only git config, throwaway copies of the hooks and settings files, the statusline check, the post-session MCP report) and what still needs reviewing.
The override decides every launch, but `sandbox <name>` and `list` only read the .sandbox marker, so with it set they could report a microVM for a profile that launches on the host. Both now say when the override is set and what it does, and `sandbox --on/--off` says so when the override contradicts the new setting. They also read the marker the way the launch does: a dangling marker link counts as sandboxed. `sandbox --on` replaces a linked marker instead of touching, and so creating, the file the link points at.
The sandbox's copy of the profile's settings.json drops deny rules such as Bash(sudo *), which would otherwise block the VM's own scoped sudo (deny wins even under --dangerously-skip-permissions). Shared settings from shared-settings.json reach claude through --settings and kept them, so a shared sudo deny still blocked sudo dnf and sudo podman in the VM. Sandbox launches now drop the same denies from the shared settings; host launches keep them.
Both were mounted straight from the host with :z, which relabels the user's own files for containers (known_hosts loses its ssh_home_t label) out from under the host's tools and restorecon. The VM now gets copies in the profile's state dir, refreshed every launch, and :z relabels those, as the launcher already does for the claude binary.
When the host has custom CA anchors, the entrypoint ran update-ca-trust extract on every boot, which takes about 7 s in the VM. The host's own update-ca-trust has already merged those anchors with its system roots into /etc/pki/ca-trust/extracted, so the launcher now mounts that directory read-only at the same path, alongside the anchors, and the entrypoint no longer extracts. Boot to exit fell from 13.7 s to 4.6 s on a host with six anchors, with every anchor still trusted. Anchors without an extracted directory warn and mount neither.
Each host update of claude copied the 244 MiB native binary into the data dir for the sandbox. On Python 3.13, shutil.copy writes every block of it, and snapshots then keep each old version pinned. The copy now asks the kernel to share the binary's blocks (FICLONE), which is instant and takes no space on btrfs or XFS, and falls back to a plain copy elsewhere or across filesystems.
podman run passes the host's http_proxy, https_proxy, no_proxy and the like into the container by default, so a proxy URL's credentials reached the VM although the launcher forwards nothing else from the host environment. Sandbox launches now pass --http-proxy=false; a proxy the VM needs can be forwarded with CLAUDE_PROFILE_SANDBOX_FORWARD_ENV.
The digest-pinned FROM line and the printf that writes storage.conf ran past 100 characters. The digest moves into an ARG ahead of FROM, and printf takes one argument per line as the sudoers step does. The build resolves the same base digest and writes a byte-identical storage.conf.
The security section described what the VM can change but not which credentials it can read or use. It now lists what every launch hands the VM (the profile's Claude login, .env values, MCP tokens in .claude.json and ancestor .mcp.json files, a copy of ~/.gitconfig), what each opt-in setting adds, and what is not mounted.
This was referenced Oct 9, 2026
Three tests looked for a word that the warning only prints inside a long temp path. rich wraps such a path mid-word at its 80-column width, so the word was split whenever the temp dir's length put a line break inside it: CI's /tmp/pytest-of-runner paths did, locally they missed. The assertions now ignore whitespace, as the other path checks do, and pass at each of 41 temp-dir lengths.
Sandbox fixes: launch status, shared denies, CA trust, copies
Protect host-trusted files from the sandbox
# Conflicts: # .pre-commit-config.yaml
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This branch now also holds #4 (protect host-trusted files from the sandbox) and #5 (sandbox fixes: launch status, shared denies, CA trust, copies), merged into it, so merging this lands all three. Their descriptions cover those changes; the one below covers the bridge server.
Serves every host bridge the sandbox uses from one authenticated server in the launcher, in place of a host
socatper bridge.What it does
bridges.py): supervised sandbox launches run aBridgeServerinside the launcher, one TCP listener on127.0.0.1(an ephemeral port) that the VM reaches through pasta's--map-host-loopback. Every connection must open with<token> <service>: a random token made for that launch, compared withhmac.compare_digest, and a service the launch enabled. A wrong token, an unknown service or a client silent for 5 s is dropped without a reply, so other local users, containers on the host network and other sandbox VMs can't use the agents, clipboard or browser through the port.ssh-Nandgpgrelay to the host's agent sockets.clipboardruns only read-onlywl-pasteinvocations and answers with a status line.openopens only Claude'sclau.de/claude.ai/chromepages (https, no credentials, no port) in the host's Chrome.chromerelays claude's native-messaging frames to the newest live native host and keeps Chrome's service worker awake during idle gaps.-eand the token through the existing secret env file, never on podman's argv.socat UNIX-LISTEN …,perm=0600and a newbridge-connecthelper that sends the handshake; thewl-pasteandgoogle-chromeshims send it themselves.wl-pasteexits non-zero when the host refuses orwl-pastefails there, aswl-pastewould.socatrequirement,_free_tcp_portand the three host handler scripts._build_sandbox_argvmoves claude's session flags into_sandbox_claude_argsto stay within the 100-line limit, and a test now fails for any function in the package over 100 lines.The sandbox image needs a rebuild (
claude-profile build): the entrypoint and shims changed, andbridge-connectis new.Test plan
prek run --all-filespasses (ruff, ruff format, ty, 355 tests, shellcheck, gitleaks)ssh-add -land a raw agent request work throughbridge-connect; a wrong token and an unknown service get no reply;wl-pasterefuses an unknown flag and lists types;google-chromerefuses a non-Claude URL; forwarded sockets are 0600 in 0700 dirs; the gpg-agent greeting arrives through the bridge