Skip to content

Authenticate the sandbox's host bridges - #3

Merged
jdoss merged 24 commits into
masterfrom
bridge-auth
Oct 9, 2026
Merged

jdoss merged 24 commits into
masterfrom
bridge-auth

Conversation

@jdoss

@jdoss jdoss commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

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 socat per bridge.

What it does

  • One bridge server (bridges.py): supervised sandbox launches run a BridgeServer inside the launcher, one TCP listener on 127.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 with hmac.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.
  • Services: ssh-N and gpg relay to the host's agent sockets. clipboard runs only read-only wl-paste invocations and answers with a status line. open opens only Claude's clau.de/claude.ai /chrome pages (https, no credentials, no port) in the host's Chrome. chrome relays claude's native-messaging frames to the newest live native host and keeps Chrome's service worker awake during idle gaps.
  • Token handling: the VM gets the port with -e and the token through the existing secret env file, never on podman's argv.
  • Guest side: the entrypoint presents each forwarded socket with socat UNIX-LISTEN …,perm=0600 and a new bridge-connect helper that sends the handshake; the wl-paste and google-chrome shims send it themselves. wl-paste exits non-zero when the host refuses or wl-paste fails there, as wl-paste would.
  • Removed: the host socat requirement, _free_tcp_port and the three host handler scripts.
  • _build_sandbox_argv moves claude's session flags into _sandbox_claude_args to 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, and bridge-connect is new.

Test plan

  • prek run --all-files passes (ruff, ruff format, ty, 355 tests, shellcheck, gitleaks)
  • Mutation checks: breaking the server's start or close, the token's placement, each service mapping or the GPG check fails a test
  • Real krun VM: ssh-add -l and a raw agent request work through bridge-connect; a wrong token and an unknown service get no reply; wl-paste refuses an unknown flag and lists types; google-chrome refuses a non-Claude URL; forwarded sockets are 0600 in 0700 dirs; the gpg-agent greeting arrives through the bridge
  • Interactive sandbox session with SSH, GPG, clipboard and Chrome forwarding on
  • CI passes on this pull request

jdoss added 19 commits October 8, 2026 21:28
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.
jdoss and others added 5 commits October 9, 2026 09:57
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
@jdoss
jdoss merged commit 3e82daa into master Oct 9, 2026
1 check passed
@jdoss
jdoss deleted the bridge-auth branch October 9, 2026 16:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant