Skip to content

feat: sandboxed agents create git worktrees in their own folder, and tests can use Docker by opt-in (#448) - #459

Open
saucam wants to merge 9 commits into
mainfrom
feat/sandbox-worktrees-docker
Open

saucam wants to merge 9 commits into
mainfrom
feat/sandbox-worktrees-docker

Conversation

@saucam

@saucam saucam commented Nov 7, 2026

Copy link
Copy Markdown
Collaborator

Summary

Two everyday workflows that the OS sandbox (#454) broke now work:

  • Agents can create git worktrees. Each sandboxed session can add, commit in and remove worktrees in its own folder.
  • Tests that start containers can use Docker, once you opt in.

Both keep the sandbox's guarantees, and the limits that remain are stated plainly.

Why

A real session hit both:

  • Its agent never branches in the main checkout, because other agents share it. So it runs git worktree add, which failed with "Operation not permitted" under .git/worktrees/.
  • Its tests start MySQL and Postgres through testcontainers. That needs the Docker socket, which the sandbox hides because Docker access is root-equivalent.

Worktrees: one folder per session

  • Where a session may create worktrees. Only under its own folder of the repository's worktree locations: <repo>-worktrees/<session-folder>/…, plus any sandbox.worktreeRoots entries, scoped per repository. Codeoid tells the agent the path when the session starts.
    • The folder name is stable across resume and daemon restarts.
    • A fork gets its own.
    • Inside it, git worktree add, commit, rebase, remove and prune all work, in every mode.
  • What stays read-only: everything else there. That covers your own worktrees, other sessions' folders, codeoid's fork, loop and --worktree checkouts, and any folder created later by anyone else.
  • Continuing another session's worktree: start a session in it. It's then that session's workspace, and writable.
  • Git's bookkeeping (.git/worktrees/) stays protected:
    • entries that exist when the sandbox is built are read-only and pinned;
    • every entry that isn't the session's own is checked against git's own layout when the sandbox is built and at the end of every turn;
    • a tampered entry is restored and announced on the session (session.worktree_entry_restored in the audit log).
  • Nothing is created inside your checkout. The session's sibling folder is removed again when it's empty.
  • sandbox.worktreeRoots is validated after resolving. It refuses your home, /, and any dir whose contents run outside the sandbox (~/.config, ~/.local, systemd units, LaunchAgents, codeoid's dirs).
  • Adding or removing a worktree never rebuilds another session's sandbox. Earlier drafts of this change did that, and it killed Claude sessions' CLIs.
  • Documented limit: between a takeover of an entry created while the session runs and that turn's end, git in that checkout could follow the planted entry. The end-of-turn check restores it.

Docker access: opt-in

  • How to turn it on: sandbox.docker.socket, off by default. It's in Settings → Sandbox, CODEOID_SANDBOX_DOCKER_SOCKET, per session /sandbox auto docker, codeoid new … --docker-socket, and the new-session dialog.
    • Turning it on is an owner-confirmed widening, with the same authority rules as turning the sandbox off.
    • The session shows "sandboxed, Docker access", because Docker access can reach the whole machine.
  • What a command gets:
    • the runtime's socket, read-only, so it can't remove or replace it;
    • DOCKER_HOST set to that socket. The user's own (rootless) runtime is preferred over the root daemon's.
  • What stays hidden: registry logins (~/.docker/config.json).
  • Per provider:
    • bubblewrap and Seatbelt expose the socket;
    • the Docker provider mounts it into the container;
    • on Claude's Linux sandbox, the CLI only allows unix sockets all-or-nothing. So with Docker access on, the host's /tmp and other shared socket dirs are hidden, and the runtime's socket is the only one reachable.

Also tightened

  • Root-equivalent sockets hidden in every sandbox: libvirt, LXD, Incus, containerd, k3s and the system bus. They were reachable before.
  • With the network off or an allowlist, local services' sockets are hidden too: Postgres, MySQL and Redis, whose peer authentication needs no password.

Verification

  • Reviews: two deep review rounds, each with three sandboxed reviewers. The findings changed the design to per-session folders, then to the end-of-turn integrity check. Everything they found is fixed, and each fix has a regression test confirmed to fail without it.
  • Fresh install on CI's Bun 1.3.13: typecheck and lint are clean. The full suite has 5,792 pass and 0 fail, and all 16 e2e specs pass against a fresh web build. Web tests show the same 39 environment failures as main.
  • Real bubblewrap: create, commit and remove in the session's folder works; writing elsewhere, into other sessions' folders or into existing entries fails; a takeover is restored; planted sockets are hidden or exposed as configured.
  • Real Docker: 13 of 13 pass.
  • Not verified: macOS (Seatbelt rules for both features). They're added to the checklist in docs/sandbox.md.

🤖 Implementation context (for agents / maintainers)
  • src/daemon/sandbox/policy.ts: worktree locations and the per-session folder; worktreeRoots validation and per-repo scoping; dockerSocketPaths (order: DOCKER_HOST, the user's runtime, then system); hidden runtime and service sockets (HOST_SOCKET_DIRS); git layout pinning.
  • The worktree integrity check and restore, run at sandbox build, turn end and destroy, with session.worktree_entry_restored. See the round-11 fix commit.
  • Providers: bwrap.ts, seatbelt.ts, docker.ts, claude-native.ts (allowAllUnixSockets with Docker access, plus the narrowing).
  • Surfaces: SessionSandboxInfo.dockerSocket and limits lines, core sandboxBadge, web SandboxChip / NewSessionModal / settings, CLI --docker-socket, /sandbox … docker.
  • Docs: docs/sandbox.md ("Git worktrees inside the sandbox", "Docker access"), CHANGELOG, CLAUDE.md.

🤖 Generated with Claude Code

saucam and others added 9 commits November 7, 2026 00:40
…pository (#448)

Agents branch in a worktree of their own (`git worktree add <path> -b
<branch>`), but inside the sandbox git couldn't make the admin dir under
the read-only `<common>/worktrees/`, and the checkout path was outside the
workspace.

- `<common>/worktrees/` is writable, bound at its own path (it can't be
  renamed away and rebuilt).
- The repository's worktree locations are writable: the sibling
  `<repo>-worktrees/` (codeoid's own convention), `<repo>/.worktrees/`, and
  `sandbox.worktreeRoots` (`CODEOID_SANDBOX_WORKTREE_ROOTS`, Settings >
  Sandbox). Never `/`, the home, one holding the repository or inside its
  git dir or a hidden path, one another user owns, or one that is (or is
  reached through) a link a command could have planted.
- Every worktree that isn't the session's own stays held: its admin dir
  whole (not rewritten, renamed, removed or pruned), its `.git` file, and
  its whole checkout when that is in a location (`remove --force` deletes
  nothing of it). The session's own: appeared since its sandbox first
  looked, checked out where its commands can write, not made by codeoid
  (forks and loops register theirs).
- Codex gets the locations as writable roots; the end-of-turn warning reads
  a worktree as git makes it as nothing changed.

Tested with real bubblewrap (add, commit, rebase, list, remove, prune; each
protection) and the real Claude CLI's sandbox.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ntainers (#448)

The sandbox hides container runtime sockets on purpose (root on the
machine), so testcontainers suites couldn't run. Docker access lets the
owner opt a session in; it is off by default.

- `sandbox.docker.socket` (`CODEOID_SANDBOX_DOCKER_SOCKET`, Settings >
  Sandbox) daemon-wide; per session `dockerSocket` in the sandbox setting:
  `codeoid new … --docker-socket`, the new-session dialog's box,
  `/sandbox <provider> … docker` (`nodocker`), `codeoid sandbox set …
  --[no-]docker-socket`, routines and loops `--docker-socket`.
- Commands reach the host's runtime sockets that exist (`$DOCKER_HOST`'s,
  Docker's, rootless Docker's, Podman's; Docker Desktop's on macOS), each
  at its own path, read-only, with `DOCKER_HOST` set; never the client's
  credentials (`~/.docker` stays hidden). bwrap binds them, Seatbelt allows
  them last, the Docker provider mounts the runtime's socket at
  /var/run/docker.sock with its group, Claude's own sandbox allows them
  (`allowAllUnixSockets` on Linux, where its seccomp refuses unix sockets
  otherwise). The CLI's git shim never gets them.
- Turning it on is a widening (`widens`): the owner-only confirm on the
  session, session:approve and an undelegated token at create, fork,
  routine and loop; daemon-wide it needs settings:write, undelegated.
  Autonomous on such a session is the owner's own call.
- Said plainly: the chip "sandboxed, Docker access" (warn, "Docker access
  can reach the whole machine"), `codeoid ls`, and every approval card.

Tested with a planted listening socket under real bubblewrap and the real
Claude CLI's sandbox (no Docker daemon is reachable here).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… in their own folder, worktreeRoots are validated and scoped per repository, and the end-of-turn warning covers the agent's worktrees

The worktree locations were writable whole, and the session's spec was
built once (Claude native: at its first query, for the session's life), so
a session could write a fork's, a loop's or another session's checkout made
later (its .claude settings, its .git file, its admin dir), and a shared
worktreeRoots dir exposed other repositories' worktrees.

- Each location (`<repo>-worktrees/`, `<repo>/.worktrees/`,
  `<root>/<repo>/` for a worktree root) holds one folder per session
  (`sessionFolderName`: its name and short id, found again by the id after a
  rename or a restart); only that folder is writable, the rest of a location
  read-only (later arrivals included), a location inside the workspace held
  whole. The agent is told where in its system prompt (every backend).
- Every admin dir not checked out in the session's folder is held; the
  session's spec is built again when an admin dir appears or goes (a Claude
  query is rebuilt at its next turn).
- sandbox.worktreeRoots: refused at config load, settings write and env
  (`/`, the home or a dir holding it, a hidden dir of the home, ~/Library,
  codeoid's config dir, a system dir, a relative root climbing out); a
  missing root is made only beside the repository, else said on the limits
  line.
- The end-of-turn look names a checkout in the session's folder whose .git
  names anything but its own admin dir (or a planted .git dir), and an
  admin dir that changed; a new plain one or a removed one isn't news.
- On Claude's own sandbox a location isn't shown read-only (its CLI lays
  readable binds over writable ones, which would make the folder read-only).

Tested with real bubblewrap (own folder add/commit/remove, elsewhere
refused, other sessions', the user's and codeoid's checkouts read-only,
later arrivals, admin dirs, shared roots, the warning) and the real Claude
CLI's sandbox.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…kets, the system bus and (with Docker access on Claude's Linux sandbox) the host's /tmp are out of a sandboxed command's reach

- Container and VM runtime sockets hidden in every sandbox, Docker access
  showing only the runtime's own: libvirt (`/run/libvirt`,
  `$XDG_RUNTIME_DIR/libvirt`), LXD, Incus, dockerd's `/run/docker`,
  cri-dockerd, k0s, microk8s, firecracker-containerd, balena, besides
  those already listed. The system D-Bus and screen's dir are hidden too.
- Claude's Linux sandbox can only allow every unix socket
  (`allowAllUnixSockets`), so with Docker access the host's /tmp and
  /var/tmp are denied there (tmux, X11, ssh-agent, IDE sockets); what stays
  reachable is said on the limits line and in the owner's confirm.
- A runtime socket the daemon's user can't connect to isn't counted:
  Docker access then says so and opens nothing up.

Checked on the real Claude CLI (a tmux-style socket planted in the host's
/tmp is out of reach, the runtime's is reached, its network proxy still
works) and real bubblewrap (a planted libvirt socket stays hidden with
Docker access on; the host's own libvirt socket is unreachable).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… services' sockets (Postgres, MySQL, Redis) are out of a sandboxed command's reach

A filesystem socket isn't the network: --unshare-net doesn't stop a command
connecting to /run/postgresql's, which lets it in by who the user is (peer
auth). policy.ts `localServiceSocketPaths` (the system's database socket
dirs, and those found by name in /tmp, /private/tmp, /var/tmp) become
SandboxSpec.serviceSockets when the network the session asked for isn't on;
bubblewrap, Seatbelt and Docker hide them, Claude's own sandbox denies them
(the /tmp ones are behind its socket block or the denied /tmp), and the host
file tools' bounds count them hidden. With the network on, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… <repo>/.worktrees is no longer a worktree location

Every sandboxed session in a repository used to make an empty `.worktrees/`
in the user's checkout. The sibling `<repo>-worktrees/` (and configured
roots) are now the only locations; a `.worktrees/` the user already has is
held read-only whole for a session working in that checkout, and gives no
session a folder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…udged where it leads, so ../.config of a repository in the home is refused

worktreeRootProblem ran only on absolute and `~` roots; a relative one was
resolved against the main checkout unchecked (`../.config` of `~/proj` gave
a writable `~/.config/proj/<folder>`). worktreeLocationDirs now judges it
again once resolved (links and all) by the same list, refuses one inside
the main checkout, and compares without regard to case on macOS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lds a session's sandbox, and a worktree's admin dir changed inside it is restored at the turn's end and said

The spec was rebuilt whenever admin dir names changed, which rebuilt a
running Claude query and killed its background tasks. Yet an admin dir made
while a sandbox ran could still be taken over for good and unseen: its
gitdir pointed into the session's folder (the next spec then took it for
the session's own), or the dir replaced by a link to a planted git dir.

The spec is now built once (and at a natural rebuild). worktree-entries.ts
`WorktreeEntryGuard` checks every admin dir against git's layout when the
spec is built, at every turn's end and at destroy:
- not a link (one is removed: git there stops instead of following it);
- commondir is the repository's git dir;
- no config, hooks/ or info/ (but sparse-checkout);
- config.worktree empty or the user's;
- gitdir names a checkout that names it back, the one last seen.
It restores what is certain: the gitdir from the back-link found by
scanning, never from the changed file. What it takes out is moved aside,
never deleted, and so is a duplicate a `git worktree repair` would adopt
(one already adopted is undone). What can't be restored with certainty is
said and left.

The session shows a warning line (sandbox.worktree_entry) and audits
session.worktree_entry_restored / _unrestored. An entry is the session's own
only when it made it (first seen in its folder, claimed by no other
checkout), recorded per inode, so a reused name is judged afresh. Empty
worktree folders, and an emptied <repo>-worktrees/, are removed at a turn's
end with nothing running, and made again at the next turn's start.
plainWorktree treats a linked admin dir as custom.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ket, and the socket tests count only what they planted

DOCKER_HOST named the root daemon's /var/run/docker.sock when the user's
rootless one existed too; the user's runtime comes first now (as Docker
Desktop's already does on macOS). The tests asserted no other runtime socket
exists, which fails on any machine with Docker, CI's runners included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

2 participants