From e84bb51921de99d2f52c1da6281422432b43e365 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Sat, 7 Nov 2026 00:40:58 +0800 Subject: [PATCH 1/9] feat: sandboxed commands can add and remove git worktrees of their repository (#448) Agents branch in a worktree of their own (`git worktree add -b `), but inside the sandbox git couldn't make the admin dir under the read-only `/worktrees/`, and the checkout path was outside the workspace. - `/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 `-worktrees/` (codeoid's own convention), `/.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 --- CLAUDE.md | 10 + docs/CONFIGURATION.md | 2 + docs/sandbox-providers.md | 4 +- docs/sandbox.md | 162 +++++++++-- src/config.ts | 9 +- src/daemon/git-worktree.ts | 3 + src/daemon/providers/codex/index.ts | 5 +- src/daemon/sandbox/git-watch.ts | 27 +- src/daemon/sandbox/policy.ts | 111 ++++++-- src/daemon/sandbox/session-sandbox.ts | 25 +- src/daemon/sandbox/types.ts | 7 + src/daemon/sandbox/worktrees.ts | 189 +++++++++++++ src/daemon/settings/manifest.ts | 5 + src/tests/sandbox-bwrap-real.test.ts | 33 ++- src/tests/sandbox-claude-real.test.ts | 35 ++- src/tests/sandbox-codex.test.ts | 4 + src/tests/sandbox-git-watch.test.ts | 3 +- src/tests/sandbox-home.test.ts | 41 ++- src/tests/sandbox-manager.test.ts | 6 +- src/tests/sandbox-policy.test.ts | 2 +- src/tests/sandbox-seatbelt.test.ts | 8 + src/tests/sandbox-worktrees-real.test.ts | 334 +++++++++++++++++++++++ src/tests/settings.test.ts | 12 + 23 files changed, 936 insertions(+), 101 deletions(-) create mode 100644 src/daemon/sandbox/worktrees.ts create mode 100644 src/tests/sandbox-worktrees-real.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index c7f6d7f4..fddcc04b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -390,6 +390,15 @@ past allow rules by the PreToolUse hook; `SandboxNetworkAccess` follows the session's network. `session.set_sandbox` is owner-only; widening is a confirm on the session (`widens`), never on an unattended turn. Forks and collaboration children inherit the setting. +Worktrees (sandbox/worktrees.ts, docs/sandbox.md "Git worktrees inside the +sandbox"): `/worktrees/` is writable, bound at its own path, and the +repository's worktree locations (`-worktrees/`, `.worktrees/`, +`sandbox.worktreeRoots`; `GitPaths.locations`, `SandboxSpec.worktreeLocations`) +are writable, so a command can `git worktree add/remove/prune`; every worktree +that isn't the session's own is held whole (its admin dir, its `.git` file, +its checkout when in a location). Own = appeared since `SessionSandbox`'s +first look (`worktreeBaseline`), checked out in the workspace or a location, +not codeoid's (`noteCodeoidWorktree` in git-worktree.ts). Shell commands are approved by risk (#431, docs/approvals.md): `src/daemon/shell/` parses a command line with tree-sitter-bash (WASM, vendored `tree-sitter-bash.wasm`; @@ -439,6 +448,7 @@ CODEOID_ROUTINES_ENABLED=true # scheduler + routine commands (docs/routine CODEOID_DEFAULT_EFFORT= # thinking effort for new sessions: off|minimal|low|medium|high|xhigh|max (docs/thinking-effort.md) CODEOID_SANDBOX=auto # OS sandbox for agents' commands: auto|bwrap|seatbelt|docker|off (docs/sandbox.md) CODEOID_SANDBOX_NETWORK=on # on|off|allowlist (CODEOID_SANDBOX_ALLOWED_HOSTS); hiddenPaths: CODEOID_SANDBOX_HIDDEN_PATHS +CODEOID_SANDBOX_WORKTREE_ROOTS= # more places a sandboxed command may check a worktree out (csv) # Claude auth: uses your existing `claude login` session (Pro/Max subscription). # No ANTHROPIC_API_KEY needed if you're logged in via Claude Code CLI. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index f9293e0b..c40ce906 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -113,6 +113,7 @@ CODEOID_SANDBOX_ALLOWED_HOSTS= # with allowlist: github.com,*.npmjs.org CODEOID_SANDBOX_HIDDEN_PATHS= # more paths to hide: ~/work/.env CODEOID_SANDBOX_HOME_READABLE= # replaces the home allowlist: ~/.bun,~/.cargo/bin,~/.gitconfig CODEOID_SANDBOX_DOCKER_IMAGE=node:22-bookworm-slim +CODEOID_SANDBOX_WORKTREE_ROOTS= # more places a command may check a worktree out: ~/wt,../shared-worktrees # Forks, backend switches and rewinds (docs/FEATURES.md#how-a-fork-switch-or-rewind-carries-the-conversation) CODEOID_NATIVE_CONTINUATION=true # hand Claude/Codex the conversation in their own form; false = text transcript @@ -249,6 +250,7 @@ Agents' commands run in an OS sandbox, on by default ([sandbox.md](sandbox.md)): - Every `sandbox.*` change through `settings.set` needs an undelegated token, and is audited old → new. - `bwrapPath` — the bubblewrap binary (default: on `PATH`). Claude Code's own sandbox on Linux also needs `socat`. - `docker.image` (`CODEOID_SANDBOX_DOCKER_IMAGE`) and `docker.runtime` — what a Docker sandbox runs, and `runsc` for gVisor. +- `worktreeRoots` (`CODEOID_SANDBOX_WORKTREE_ROOTS`, comma separated) — more places a sandboxed command may check a git worktree of its repository out, on top of `-worktrees/` and `/.worktrees/` (`~` expands; a relative path is the repository's main checkout's). Writable to sandboxed commands ([sandbox.md](sandbox.md#git-worktrees-inside-the-sandbox)). - Where the sandbox can't run, interactive sessions run unsandboxed and say so on every approval; autonomous sessions, routines and loops refuse to start. `codeoid sandbox providers` shows what runs here. ### One daemon per data directory diff --git a/docs/sandbox-providers.md b/docs/sandbox-providers.md index 9b84d975..7bf558a1 100644 --- a/docs/sandbox-providers.md +++ b/docs/sandbox-providers.md @@ -36,13 +36,15 @@ interface SandboxSpec { tempDir: string; // writable, private; the sandbox's /tmp where it can be privateTemp?: boolean; // a fresh temp dir per program, gone when it exits, never tempDir itself (the git shim's): // bwrap/Docker a tmpfs /tmp; Seatbelt a dir the wrapper makes in tempDir and removes - writable: string[]; // the backend's private home (when its whole process is inside); a git repository's dirs; the cache dir + writable: string[]; // the backend's private home (when its whole process is inside); a git repository's dirs (its + // worktrees/ too); the cache dir; the repository's worktree locations // (each bound at its own path: none may be renamed, as a mount point can't) hidden: string[]; // not readable at all (the user's home first, whole) reexposed: string[]; // read-only inside something hidden (the home allowlist, the backend's program and login) readOnly?: string[]; // read-only inside something writable, with what is in it (what picks a repository's config, // hooks and git dir; the project settings agent CLIs run code from); each exists gitDirs?: string[]; // the git dirs a commit writes (a backend's own sandbox's extra writable roots) + worktreeLocations?: string[]; // where a command checks a new worktree out (in writable too; a backend's own sandbox's roots) env?: Record; // set inside: the toolchains' caches in the sandbox's cache dir pinned?: string[]; // can't be renamed or removed, contents still writable (a dir between the workspace and a hidden path) containerSockets: string[]; // never reachable diff --git a/docs/sandbox.md b/docs/sandbox.md index fc91b408..0605a383 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -2,9 +2,11 @@ Every shell command an agent runs, on every backend that can be sandboxed, runs inside an operating-system sandbox. The workspace and a private temp -directory are writable; everything else is read-only; credential stores are -not there at all; container runtime sockets can't be reached; the network is a -per-session setting. +directory are writable (and, so an agent can branch in a worktree of its own, +the repository's [worktree locations](#git-worktrees-inside-the-sandbox)); +everything else is read-only; credential stores are not there at all; +container runtime sockets can't be reached; the network is a per-session +setting. The approval gate's risk levels (#431, [approvals.md](approvals.md)) read a command's text. They are guidance: three audit rounds showed that reading bash @@ -69,8 +71,8 @@ With the default policy (workspace-write): |---|---| | **Your home: hidden whole** | the sandbox starts from an empty, read-only home. A credential that isn't on any list, one created after the session started (`aws configure`, `gh auth login`), or one a program replaces by writing a new file and renaming it over the old one, is simply not there. What is shown again, read-only: the **allowlist** `sandbox.homeReadable` (below), and what the backend itself needs (its program; its private home, below). The workspace, when it is in your home, is shown writable. | | **Home allowlist** (`sandbox.homeReadable`, read-only) | by default toolchains and tool settings that hold no secret: `~/.bun`, `~/.cargo/bin`, `~/.cargo/registry`, `~/.cargo/git`, `~/.rustup`, `~/.nvm`, `~/.npm`, `~/.local/bin`, `~/.local/lib` (pip `--user` packages), `~/.local/share/pnpm`, `~/.local/share/uv`, `~/.local/share/pipx`, `~/.local/share/mise`, `~/.local/share/claude/versions` (the Claude installer's binaries), `~/.pyenv`, `~/go/pkg`, `~/go/bin`, `~/.gitconfig`, `~/.config/git/config`, `~/.config/git/ignore`, `~/.config/git/attributes`, `~/.claude/plugins` and `~/.claude/skills` (installed plugins and user skills, so a skill's script run as a command can read its own files; the CLI's settings, credentials and hooks stay hidden), and `~/.claude/projects` (every Claude session's transcript and memory, read-only, so an agent can look up what another session did; a secret once pasted into a session is in its transcript, so remove it from the list to hide them). Not listed, so hidden: `~/.npmrc`, `~/.bunfig.toml`, `~/.yarnrc.yml`, `~/.pypirc`, `~/.cargo/credentials.toml`, `~/.git-credentials`, `~/.config/git/credentials`, `~/.gradle`, `~/.m2` and everything else. A global npmrc inside an nvm install (`~/.nvm/versions/node/*/etc/npmrc`) and uv's credential store (`~/.local/share/uv/credentials`) are hidden again. A link inside a listed dir (`~/.local/bin/x`) works only where it leads into the list itself (a pipx or `uv tool` venv, the Claude installer's versions) or outside the home: what it leads to elsewhere in the home (a project of yours, with its `.env`) stays hidden, and the link dangles. List the target's dir in `sandbox.homeReadable` to show it. A link in the list (`~/.gitconfig` into a dotfiles repository) is shown at its own path and where it leads. A token written into `~/.gitconfig` itself (a URL carrying one, an `http.extraHeader`) would show: keep it in a credential helper. | -| **Writable** | the session's working directory (its worktree), a private temp dir made for the session (`$TMPDIR`, and the sandbox's `/tmp` under bubblewrap and Docker), and what a commit in the workspace's **git repository** writes: its git dir, a linked worktree's own git dir and its repository's (objects, refs, logs, the index, `HEAD`, `packed-refs` and the lock files beside them), wherever they are, a repository under the host's `/tmp` included. Nothing else. | -| **Read-only** | everything else on the machine. `rm -rf ~` fails with "Read-only file system". Inside the writable, **a repository's structure**: everything that decides which config, hooks or git dir git uses, since a hook, `core.hooksPath` or `core.fsmonitor` planted there (or a config git is pointed at) would run outside the sandbox at your next `git status`. That is, in the repository's git dir: `config`, `config.worktree`, `commondir`, `hooks/`, `info/`, `modules/` (a submodule's git dir carries hooks and config of its own), `remotes/`, `branches/` and `worktrees/` whole (every worktree's git dir, one added after the session started included; the workspace's own worktree git dir is writable over it); in a linked worktree's own git dir: `commondir`, `gitdir`, `config.worktree`; the worktree's `.git` file, and the `.git` file of every other worktree or submodule checked out inside the workspace. A **submodule checkout** as the workspace (its `.git` a file naming `/.git/modules/`, whose `core.worktree` names it back) is held the same way, its git dir being the repository's. Any other `.git` file (a `--separate-git-dir` checkout, which git's records don't name back) is held read-only, with what it names when that is inside the workspace: nothing commits from inside, and the session's sandbox limits say so. The git dirs themselves are bound at their own paths, so none can be renamed away and rebuilt (`mv .git .git-old` fails), the workspace's `.git` dir included. A plain repository at the top of the workspace gets an empty `HEAD/` dir beside its `.git`, held read-only (git doesn't see an empty dir): a `.git` made invalid (its `HEAD` garbled, `refs/` removed) would otherwise have git take the workspace's top for a bare repository planted there, with its own config. What of these isn't there yet is made first, so there is something to hold: empty `hooks/`, `info/`, `modules/`, `remotes/`, `branches/`, `worktrees/`, an empty `config.worktree` in every git dir of the repository, and a `commondir` in the repository's git dir holding `.` (git, libgit2 and gitoxide read it as that git dir itself; see Limits). An entry that would have to be held but is a link (a bind can't hold one) makes the git dir read-only whole: commits from inside then fail. And **the project settings agent CLIs run code from** (below). | +| **Writable** | the session's working directory (its worktree), a private temp dir made for the session (`$TMPDIR`, and the sandbox's `/tmp` under bubblewrap and Docker), what a commit in the workspace's **git repository** writes: its git dir, a linked worktree's own git dir and its repository's (objects, refs, logs, the index, `HEAD`, `packed-refs` and the lock files beside them), wherever they are, a repository under the host's `/tmp` included; and, so a command can add a worktree, the repository's `worktrees/` (where git makes a new worktree's git dir) and its **worktree locations**: `-worktrees/`, `/.worktrees/` and `sandbox.worktreeRoots` ([below](#git-worktrees-inside-the-sandbox)). Nothing else. | +| **Read-only** | everything else on the machine. `rm -rf ~` fails with "Read-only file system". Inside the writable, **a repository's structure**: everything that decides which config, hooks or git dir git uses, since a hook, `core.hooksPath` or `core.fsmonitor` planted there (or a config git is pointed at) would run outside the sandbox at your next `git status`. That is, in the repository's git dir: `config`, `config.worktree`, `commondir`, `hooks/`, `info/`, `modules/` (a submodule's git dir carries hooks and config of its own), `remotes/`, `branches/`; in `worktrees/` (itself writable, so a command can add a worktree, but bound at its own path: it can't be renamed away and rebuilt), **every worktree's git dir that isn't the session's own, whole** (its `commondir`, `gitdir`, `config.worktree`, HEAD and index: it can't be rewritten, renamed, removed or pruned), with its checkout's `.git` file wherever the session can write, and its whole checkout when that is in a worktree location ([below](#git-worktrees-inside-the-sandbox)); in a linked worktree's own git dir: `commondir`, `gitdir`, `config.worktree`; the worktree's `.git` file, and the `.git` file of every submodule checked out inside the workspace. A **submodule checkout** as the workspace (its `.git` a file naming `/.git/modules/`, whose `core.worktree` names it back) is held the same way, its git dir being the repository's. Any other `.git` file (a `--separate-git-dir` checkout, which git's records don't name back) is held read-only, with what it names when that is inside the workspace: nothing commits from inside, and the session's sandbox limits say so. The git dirs themselves are bound at their own paths, so none can be renamed away and rebuilt (`mv .git .git-old` fails), the workspace's `.git` dir included. A plain repository at the top of the workspace gets an empty `HEAD/` dir beside its `.git`, held read-only (git doesn't see an empty dir): a `.git` made invalid (its `HEAD` garbled, `refs/` removed) would otherwise have git take the workspace's top for a bare repository planted there, with its own config. What of these isn't there yet is made first, so there is something to hold: empty `hooks/`, `info/`, `modules/`, `remotes/`, `branches/`, `worktrees/`, an empty `config.worktree` in every git dir of the repository, and a `commondir` in the repository's git dir holding `.` (git, libgit2 and gitoxide read it as that git dir itself; see Limits). An entry that would have to be held but is a link (a bind can't hold one) makes the git dir read-only whole: commits from inside then fail. And **the project settings agent CLIs run code from** (below). | | **Project settings** (read-only) | `.claude/`, `.gemini/`, `.qwen/`, `.codex/`, `.pi/`, `.opencode/` in the workspace (and, for a session in a monorepo's package, in every dir from the package up to the repository's top: where the CLIs look), whole (made empty first where missing: git doesn't see an empty dir; not in a workspace that is your home or holds it, a temp root or `/`, where they would be the CLIs' own config dirs: there only those that exist are held), and `.mcp.json`, `opencode.json`, `opencode.jsonc` at its top, made first where missing (in the same places) holding an empty config (`{"mcpServers":{}}`, `{}`: what the CLIs read as none; your own `opencode` loads a project `opencode.json`'s MCP servers and plugins at its start) and, in a git repository, listed in its `.git/info/exclude` so `git status` and `git add -A` don't see them (an existing file is held as it is). A Claude hook, an MCP server, a pi or opencode plugin written there by one backend's command would run on the host in a later session of another, or in your own CLI. Changing them needs the sandbox lifted for that command: Claude's `dangerouslyDisableSandbox` and Codex's permission request are asked about in guarded and interactive mode and refused in autonomous mode (below); on other backends edit them yourself, or turn the session's sandbox off. A file tool that runs in the backend's own process, outside the sandbox (Claude's Write, Edit, MultiEdit and NotebookEdit, opencode's write, edit and patch) is held to the same rules at codeoid's gate in autonomous mode ([below](#file-tools-that-run-outside-the-sandbox)): writing what the sandbox holds read-only is refused there, and asked about in guarded and interactive mode. Writing the rest of **the repository's git dir** (its objects and refs, which a sandboxed commit writes, a linked worktree's, the `.git` file, another checkout's `.git` file in the workspace), or a file with more than one hard link, is asked about in guarded and interactive mode while the sandbox is on, the card saying why (audited `session.git_structure_write_ask` / `session.project_settings_write_ask`), and refused at once in autonomous mode (audited `session.autonomous_refuse`, kind `held_write`); on Claude the PreToolUse hook routes it to the gate past any allow rule. A backend whose file tools run inside the sandbox (Codex, pi, the Gemini CLI, Qwen; Claude under Docker) is held by the sandbox itself: nothing of this applies to it, a hard-linked file in `node_modules` included. | | **Hidden besides** (not readable at all) | outside the home, or in a workspace that holds it: codeoid's config dir (`~/.codeoid`: keys, accounts, endpoints, the local token) and every data dir it owns wherever it is configured (the database, transcripts and checkpoints, opencode's database and data dirs, memory's; their lock files); the agent CLIs' logins (`~/.claude/.credentials.json`, `~/.codex/auth.json`, `~/.qwen/oauth_creds.json`, `~/.gemini/oauth_creds.json` …, pi's `auth.json`) and what backends registered as credentials (opencode's key files); `~/.ssh`, `~/.aws`, `~/.azure`, `~/.config/gcloud`, `~/.kube`, `~/.docker`, `~/.gnupg`, `~/.config/gh`, `~/.config/hub`, `~/.config/glab-cli`, `~/.netrc`, `~/.git-credentials`, `~/.config/git/credentials`, `~/.local/share/keyrings`, `~/.password-store`, `~/.config/op`, `~/.vault-token`, `~/.pypirc`, `~/.cargo/credentials(.toml)`, `~/.terraform.d/credentials.tfrc.json`, `~/.claude.json` (MCP tokens), `~/.local/share/opencode/auth.json` and `mcp-auth.json`, `~/.config/github-copilot`, `~/.pgpass`, `~/.config/rclone`, `~/.s3cfg`, `~/.boto`, `~/.pulumi/credentials.json`, `~/.config/doctl`, `~/.fly`, `~/.npmrc`, `~/.bunfig.toml`, `~/.yarnrc.yml`, `~/.gradle/gradle.properties`, `~/.m2/settings.xml`, `~/Library/Keychains`; the desktop session's runtime dir (`$XDG_RUNTIME_DIR`: keyring, D-Bus, agent sockets) and `$SSH_AUTH_SOCK`'s dir; the daemon's own temp dir when it isn't `/tmp`, and always the daemon's private dir in it (`codeoid-sandbox-*`, made at its start, 0700), where it makes every temp entry of its own: sign-in homes, a check's artifacts, a loop's index, pi's bridge, the sessions' temp dirs and launchers — so one made while a session runs is hidden from it too, on Claude's own sandbox (which keeps the host's `/tmp`) as well; plus `sandbox.hiddenPaths` (which also beats the allowlist). | | **The daemon's own git** | every git codeoid runs in a repository an agent can write (a fork's worktree made and removed, a destroyed session's work snapshotted, a worktree's setup, the branch and status the UI shows, loops) runs nothing that repository's config names, sandbox or not: no hook (the hooks dir's or one defined in config, `hook..command`), fsmonitor, alternates' ref command (`core.alternateRefsCommand`), external diff, signing or signature check (gpg's and ssh's programs, `gpg.ssh.defaultKeyCommand`), editor or pager, git-protocol proxy or auto gc, `core.sshCommand` is `ssh`, and every filter, diff or merge driver and credential helper defined in the repository's own config (local or worktree, includes too) is reset, your own global or system definition put back (git lfs installed globally keeps working; a repository redefining it doesn't). Reaching a remote (a base branch fetched for a fork, a partial clone's objects) is https or ssh only. Loops' repositories are stricter still: every driver off, no system config. Checkpoints run in codeoid's own shadow repository, whose config is codeoid's. | @@ -124,7 +126,9 @@ UI's settings and `/settings`) is the default for new sessions: with `sandbox.allowedHosts` (`CODEOID_SANDBOX_ALLOWED_HOSTS`, `example.com`, `*.example.com`, `localhost`; anything else fails the config's load). `sandbox.hiddenPaths` (`CODEOID_SANDBOX_HIDDEN_PATHS`) adds paths to hide (`~` -expands). `sandbox.homeReadable` (`CODEOID_SANDBOX_HOME_READABLE`, comma +expands). `sandbox.worktreeRoots` (`CODEOID_SANDBOX_WORKTREE_ROOTS`, Settings > +Sandbox > More worktree locations) adds places a command may check a worktree +out ([below](#git-worktrees-inside-the-sandbox)). `sandbox.homeReadable` (`CODEOID_SANDBOX_HOME_READABLE`, comma separated) replaces the home allowlist: add what your tools need that holds no secret (`~/.volta`, `~/.sdkman/candidates`, `~/.config/starship.toml`), never a file with a token. `sandbox.bwrapPath` picks the bwrap binary. @@ -584,6 +588,94 @@ either way rather than depend on that. Not refused: a sub-agent's worktree isolation (the `Agent` tool's `isolation: "worktree"`), whose sub-agent runs in the CLI's process with the session's settings. +## Git worktrees inside the sandbox + +Agents branch in a worktree of their own, never in a checkout others share: +`git worktree add -b `, work and commit there, then +`git worktree remove`. Inside the sandbox that works, in every mode +(`src/daemon/sandbox/worktrees.ts`): + +- **Where**: a command may check a worktree out in the repository's **worktree + locations**, which are writable: the sibling `-worktrees/` (codeoid's + own convention for forks and loops: `
-worktrees/`), the + repository's `.worktrees/`, and each `sandbox.worktreeRoots` entry (`~` + expands; a relative one is the main checkout's: `../worktrees`). The + defaults are made empty where missing (a mount needs something to mount; git + doesn't see an empty dir), `.worktrees/` only when the workspace doesn't + already hold it; a submodule gets only the configured ones (its sibling + would be made inside its superproject). Never a location of `/`, the home or + a dir holding it, one holding the main checkout or the git dir, one inside + the git dir, one inside a hidden path, one another user owns, and never a + link or a path through one below the main checkout's parent (a command in + the main checkout could otherwise make `.worktrees` a link that hands a + later session's sandbox a writable dir anywhere); such an entry is left out. +- **git's own records**: the repository's `worktrees/` is writable, so git can + make the new worktree's admin dir, and the new branch's refs are written as + any commit's. `worktrees/` is bound at its own path: it can't be renamed + away and rebuilt. +- `git worktree add`, `list`, `remove` and `prune`, committing, rebasing and + removing in such a worktree, and in it in the session's next sandbox (a + backend restarted, the next query), all work. +- **What stays the user's** (the reason the structure is read-only: the user's + own checkouts must not be redirectable): the repository's `config`, + `config.worktree`, `hooks/`, `info/`, `modules/`, and **every worktree that + isn't the session's own**: its admin dir in `worktrees/`, whole (its + `commondir`, `gitdir`, `config.worktree`: what decides where git looks; it + can't be renamed, removed or replaced by a planted one under its name, + `git worktree prune` leaves it — it says it failed to delete it — and + `git worktree remove` can't remove it), its checkout's `.git` file, and, + when the checkout is in a worktree location, the whole checkout (so + `git worktree remove --force` deletes nothing of it). A checkout's `.git` + file outside the worktree locations and the workspace isn't writable at all. +- **The session's own worktrees** are those whose admin dir appeared after the + session's sandbox first looked at the repository, checked out where its + commands can write (the workspace, a worktree location), and not made by + codeoid itself (a fork's, a loop's: those are held like yours in every + other session's sandbox, whenever they were made). They are writable whole: + a worktree the agent creates is its own output, like the workspace (its + files, its `.git` file, its admin dir). Review it as you would the + workspace. +- **The end-of-turn warning** ([above](#what-it-guarantees-and-what-it-doesnt)) + treats a worktree as `git worktree add` makes one (its `commondir` naming + its own repository, `../..`, no worktree config) as nothing changed, so + adding one isn't news; one whose `commondir` names anything else, or that + carries a `config.worktree`, is named (its `commondir` and `gitdir`). +- **Codeoid's own sessions and loops in a worktree it made** (`--worktree`, a + fork, a loop run) are unaffected: their workspace's own git dir is held as + before, and their siblings in `-worktrees/` are other sessions' (held). + +Limits (defence in depth, as the rest of git's structure): + +- A worktree **you add yourself while a session's sandbox runs** isn't held by + that sandbox (it didn't see it): its admin dir is in the writable + `worktrees/`, so a command running then could rewrite its `commondir`. The + session's next sandbox holds it (it is checked out outside the locations, or + codeoid made it), and the end-of-turn warning names a `commondir` changed + meanwhile. Add worktrees while no session in that repository is running a + turn, or look at the warning before running git in the new one. A worktree + someone else checks out **in a worktree location** while the session runs + counts as the session's own in its next sandbox (unless codeoid made it): + the locations are the agents'. +- A checkout of yours **whose admin dir is already gone** (pruned while its + disk was unmounted) isn't known to the sandbox: a command could make an + admin dir under its name. git already treats such a checkout as broken + ("not a git repository"); repair it (`git worktree repair`) before a session + runs, or remove it. +- After the daemon restarts, a session's earlier worktrees are held like + yours (the session's look at the repository starts again): remove them + yourself, or from a session that runs outside the sandbox. +- `git worktree add -b origin/main` makes the worktree but + can't record the upstream (`branch..merge` is in the repository's + config, held read-only): git says "could not write config file". Pass + `--no-track`, or set the upstream yourself. +- Everything in a worktree location is writable to every sandboxed session of + that repository but the checkouts held above (worktrees the session didn't + make): don't keep anything else there. +- On Claude's own sandbox the rules are fixed when its query starts, as for + the rest (a worktree added during the query by someone else isn't held there + either). On Seatbelt `worktrees/` and each location are kept from being + renamed by a `literal` write denial (not verified on a Mac: see below). + ## Committed project settings **Starting a Claude session in a repository trusts its committed project @@ -675,8 +767,10 @@ to the workspace. In autonomous mode it is information only (the audit log's home's own files can't do that (the home isn't shown). - **Git**: with a repository's structure read-only, `git config …` (writing the repository's config), installing a hook, `git submodule update --init`, - `git worktree add`/`remove`, `git sparse-checkout` and editing - `.git/info/exclude` fail inside a command; run them yourself. The Claude + `git sparse-checkout`, editing `.git/info/exclude`, and removing or changing + a worktree the session didn't make fail inside a command; run them + yourself. Adding, using and removing worktrees of its own in the worktree + locations works ([above](#git-worktrees-inside-the-sandbox)). The Claude CLI's sub-agent worktree isolation works: its git may write `worktrees/` and `.claude/worktrees` ([above](#a-backend-clis-own-git)); its `EnterWorktree` is refused ([above](#the-claude-clis-own-worktrees)). @@ -690,15 +784,15 @@ to the workspace. In autonomous mode it is information only (the audit log's dir, the empty `HEAD/` at the top of the workspace) are what let bubblewrap and Docker hold these read-only (a mount needs something to mount on, and a git dir must stay writable for git's lock files); they stay after the - session (`git worktree prune` removes an empty `worktrees/`; it is made + session (`git worktree prune` can't remove `worktrees/`, which is bound + while a sandbox runs; outside, it removes an empty one and it is made again). A `.git` that is a symlink can't be held. A top-level `HEAD` that is already a file or a non-empty dir of the project isn't held (on a - case-insensitive disk, a `head` too). On Claude's own sandbox, whose - `denyWrite` wins over `allowWrite` wherever they overlap, a linked - worktree's `worktrees/` is held entry by entry around its own git dir, so a - worktree added while its query runs isn't held there (the end-of-session - warning, above, names its `commondir` if it changes); a plain repository's - `worktrees/` is held whole there too. Not held, + case-insensitive disk, a `head` too). `worktrees/` is writable (a command + may add a worktree), each worktree's git dir in it held but the session's + own, so a worktree someone else adds while a sandbox runs isn't held by + that sandbox ([above](#git-worktrees-inside-the-sandbox); the + end-of-session warning names its `commondir` if it changes). Not held, because they are the workspace's own content: a repository planted in a subdirectory of the workspace (a `.git` made in a directory that had none, an uninitialised submodule's path included) is used by your git there, and @@ -722,8 +816,10 @@ to the workspace. In autonomous mode it is information only (the audit log's repository is one codeoid doesn't widen to: above the home, a `.git` file git's records don't name back); a symlinked `.git` can't be held; a top-level `HEAD` that is already the project's isn't - held; a worktree added while a Claude query runs isn't held by Claude's - own sandbox; a `.git` file git's records don't name back is held but + held; a worktree someone else adds while a sandbox runs (a Claude query + included) isn't held by it, nor a checkout of yours whose admin dir is + already gone, nor what a worktree location holds besides others' + worktrees; a `.git` file git's records don't name back is held but unverified. Your global `safe.bareRepository=explicit` closes the bare repository fallback entirely. None of these reach the git a backend CLI runs for itself, which is contained ([above](#a-backend-clis-own-git)): @@ -840,6 +936,30 @@ works, and skip where it doesn't): and makes a worktree; with codeoid's PreToolUse refusal it makes none and the model is told why. Whether the CLI loads a worktree's committed hooks after entering it: see [The Claude CLI's own worktrees](#the-claude-clis-own-worktrees). +- Worktrees (#448 follow-up), real bubblewrap (`sandbox-worktrees-real.test.ts`): + from inside, `git worktree add ../repo-worktrees/x -b feature/x`, a commit + and a rebase there, `git worktree list`, a second one in `.worktrees/`, + `git worktree remove` of both and `git worktree prune` work, and the branch + is there outside; a worktree made in one sandbox is committed in and removed + (`--force`) in the session's next. Each of these is refused, from a plain + repository and from a linked worktree in the sibling location, and the + user's next git (outside the sandbox) runs nothing planted: an existing + worktree's `commondir`, `gitdir` or `config.worktree` rewritten; its admin + dir renamed, removed, or something made in or beside it under its name; + `worktrees/` renamed away; `git worktree remove --force` of the user's + worktree in the sibling location (its files stay), its `.git` file + redirected or a file written in it; the `.git` file of the user's worktree + outside the locations redirected; `git worktree prune` of the user's + worktree whose checkout is gone; the repository's config, hooks and `info/` + written. A worktree someone else added after the session started is held in + its next sandbox (outside the locations, or one codeoid made in the sibling + location); a configured `sandbox.worktreeRoots` location is writable, `..` + and `~` aren't taken. `sandbox-bwrap-real.test.ts` G: a worktree added while + a sandbox runs is held by the next, and a `commondir` rewritten meanwhile is + named by the end-of-turn look. The real Claude CLI + (`sandbox-claude-real.test.ts`): under its own sandbox a command adds a + worktree in `-worktrees/`, commits there and removes it, while the + user's worktrees and the config stay held. **To verify on a real Mac** (Seatbelt; profile generation is unit-tested only): @@ -862,6 +982,10 @@ works, and skip where it doesn't): 7. Codex's app-server, pi, the Gemini CLI and Qwen launched under `sandbox-exec` (node writes caches under `~/Library`: add writable state if they fail). 8. opencode's shell shim through a Seatbelt launcher. +9. Worktrees: that a `literal` write denial on `worktrees/` and a worktree + location (pinned as writable git dirs are) refuses `mv` of the dir while + `git worktree add` can make a new admin dir and checkout in it; that an + existing worktree's admin dir (`subpath` deny) can't be renamed. **Docker** (no Docker daemon was reachable here): `CODEOID_TEST_DOCKER=1 bun test src/tests/sandbox-docker.test.ts` runs the container checks. Also to check: the @@ -875,7 +999,9 @@ reaching the client from a launcher (opencode, Qwen). [sandbox-providers.md](sandbox-providers.md) describes it and how to add one. - `registry.ts` — `auto | off | `, cached probes. - `policy.ts` — the hidden home and its allowlist (`home-readable.ts`), the - hidden-path list, the workspace's git repository, and the spec. + hidden-path list, the workspace's git repository, and the spec; + `worktrees.ts` — the worktree locations and which worktrees are the + session's own. - `backend-home.ts` — a process backend's private home. - `bwrap.ts`, `seatbelt.ts`, `docker.ts` — the providers. - `confinement.ts` — per (backend, provider), how it is put inside. diff --git a/src/config.ts b/src/config.ts index 856a1497..2811fa6a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -271,6 +271,12 @@ const SandboxSchema = z hiddenPaths: z.array(z.string().min(1).max(4096)).max(256).default([]), /** What of the hidden home stays readable, read-only (`~` expands): toolchains and tool settings with no secrets. */ homeReadable: z.array(z.string().min(1).max(4096)).max(256).default([...DEFAULT_HOME_READABLE]), + /** + * More places a sandboxed command may check a worktree of its repository + * out, on top of `-worktrees/` and `/.worktrees/` (`~` + * expands; a relative one is the repository's main checkout's). + */ + worktreeRoots: z.array(z.string().min(1).max(4096)).max(64).default([]), /** The bwrap binary (default: on PATH). */ bwrapPath: z.string().refine(isAbsolute, "must be an absolute path").optional(), docker: z @@ -280,7 +286,7 @@ const SandboxSchema = z }) .default({ image: "node:22-bookworm-slim" }), }) - .default({ provider: "auto", network: "on", allowedHosts: [], hiddenPaths: [], homeReadable: [...DEFAULT_HOME_READABLE], docker: { image: "node:22-bookworm-slim" } }); + .default({ provider: "auto", network: "on", allowedHosts: [], hiddenPaths: [], homeReadable: [...DEFAULT_HOME_READABLE], worktreeRoots: [], docker: { image: "node:22-bookworm-slim" } }); const CompressSchema = z .object({ @@ -1792,6 +1798,7 @@ const ENV_OVERRIDES: readonly EnvOverride[] = [ { env: "CODEOID_SANDBOX_HIDDEN_PATHS", path: "sandbox.hiddenPaths", kind: "csv" }, { env: "CODEOID_SANDBOX_HOME_READABLE", path: "sandbox.homeReadable", kind: "csv" }, { env: "CODEOID_SANDBOX_DOCKER_IMAGE", path: "sandbox.docker.image", kind: "string" }, + { env: "CODEOID_SANDBOX_WORKTREE_ROOTS", path: "sandbox.worktreeRoots", kind: "csv" }, { env: "CODEOID_ROUTINES_MAX_CONCURRENT", path: "routines.maxConcurrentRuns", kind: "int" }, { env: "CODEOID_MCP_CONTROL_ACTIONS", path: "mcpControl.allowActions", kind: "boolean" }, { env: "CODEOID_MAX_SESSIONS_PER_USER", path: "rateLimit.maxSessionsPerUser", kind: "int" }, diff --git a/src/daemon/git-worktree.ts b/src/daemon/git-worktree.ts index 4081dd17..ad6854b6 100644 --- a/src/daemon/git-worktree.ts +++ b/src/daemon/git-worktree.ts @@ -35,6 +35,7 @@ import { existsSync, lstatSync, mkdirSync, realpathSync, type Stats, unlinkSync import path from "node:path"; import { promisify } from "node:util"; import { GIT_BIN, hardenedGit, safeGitEnv } from "./git-env.js"; +import { noteCodeoidWorktree } from "./sandbox/worktrees.js"; const execFileP = promisify(execFile); @@ -260,6 +261,8 @@ export async function createForkWorktree(opts: { } opts.onPath?.(worktreePath); + // codeoid's own: a session's sandbox holds it like the user's, even one started before it was made (sandbox/worktrees.ts). + noteCodeoidWorktree(worktreePath); try { // --end-of-options: treat `base` as a rev, never a flag, so a value // starting with '-' can't inject a git option. diff --git a/src/daemon/providers/codex/index.ts b/src/daemon/providers/codex/index.ts index b3a77eaa..e571522c 100644 --- a/src/daemon/providers/codex/index.ts +++ b/src/daemon/providers/codex/index.ts @@ -240,8 +240,9 @@ export function codexSandboxPolicy(rt: SandboxRuntime | null, fallback: SandboxM type: "workspaceWrite", // The repository's git dirs too: codex's own sandbox makes every `.git` read-only, so nothing would commit. // What of them must stay read-only is held so by codeoid's bubblewrap around the app-server (gitWorkspacePaths). - // And the sandbox's cache dir, where the toolchains' caches are pointed (SandboxSpec.env). - writableRoots: [...new Set([workdir, ...tempRoots(rt.spec), ...(rt.spec.gitDirs ?? []), ...cacheRoots(rt.spec)])], + // And the sandbox's cache dir, where the toolchains' caches are pointed (SandboxSpec.env), and the repository's + // worktree locations, where a command checks a new worktree out (sandbox/worktrees.ts). + writableRoots: [...new Set([workdir, ...tempRoots(rt.spec), ...(rt.spec.gitDirs ?? []), ...cacheRoots(rt.spec), ...(rt.spec.worktreeLocations ?? [])])], networkAccess: rt.spec.network.mode === "on", excludeTmpdirEnvVar: false, excludeSlashTmp: false, diff --git a/src/daemon/sandbox/git-watch.ts b/src/daemon/sandbox/git-watch.ts index 9bfa87f1..7ac9cc78 100644 --- a/src/daemon/sandbox/git-watch.ts +++ b/src/daemon/sandbox/git-watch.ts @@ -3,7 +3,9 @@ * command, outside any sandbox, that `git status` and a diff don't show * (#448): its config, hooks (the hooks dir's, and `core.hooksPath`'s when it * points into the repository: husky's `.husky/_`), attributes, and what - * names its git dirs (`commondir`, a worktree's `gitdir`, the `.git` file). + * names its git dirs (`commondir`, a worktree's `gitdir`, the `.git` file; + * a worktree as `git worktree add` makes one, naming its own repository, + * isn't news: an agent may add one). * * The sandbox holds most of these read-only, as defence in depth; it doesn't * guarantee the workspace is safe to run (it is the agent's output, and is @@ -68,6 +70,20 @@ function placeholder(p: string, name: string): boolean { } } +/** A worktree's git dir as git makes it: `commondir` naming its repository (`../..`), no `config.worktree` but codeoid's empty one. */ +function plainWorktree(dir: string): boolean { + try { + if (readFileSync(join(dir, "commondir"), "utf8").trim() !== "../..") return false; + } catch { + return false; + } + try { + return readFileSync(join(dir, "config.worktree"), "utf8") === ""; + } catch (err) { + return (err as NodeJS.ErrnoException).code === "ENOENT"; + } +} + function entries(dir: string): string[] { try { return readdirSync(dir).sort().slice(0, DIR_LIMIT); @@ -124,7 +140,14 @@ export function gitControlSnapshot(workspace: string, home: string = process.env for (const n of ["config", "config.worktree", "commondir"]) watch(join(common, n)); watch(join(common, "info", "attributes")); watchDir(join(common, "hooks")); - for (const w of entries(join(common, "worktrees"))) for (const n of ["commondir", "gitdir", "config.worktree"]) watch(join(common, "worktrees", w, n)); + // A worktree as `git worktree add` makes one (its repository's `commondir`, no config of its own) names no other + // git dir: one a sandboxed command adds reads as nothing changed. One that names another repository, or carries + // a config of its own, is watched, with what names its checkout. + for (const w of entries(join(common, "worktrees"))) { + const dir = join(common, "worktrees", w); + if (plainWorktree(dir)) continue; + for (const n of ["commondir", "gitdir", "config.worktree"]) watch(join(dir, n)); + } const hooksPath = hooksPathOf([join(common, "config"), join(common, "config.worktree"), ...(layout.own ? [join(layout.own, "config.worktree")] : [])]); if (hooksPath) { const dir = hooksPath.startsWith("~/") ? join(home, hooksPath.slice(2)) : resolve(top, hooksPath); diff --git a/src/daemon/sandbox/policy.ts b/src/daemon/sandbox/policy.ts index 1c8e73e4..355463e4 100644 --- a/src/daemon/sandbox/policy.ts +++ b/src/daemon/sandbox/policy.ts @@ -34,6 +34,7 @@ import { cliCredentialFiles, registeredSecretLocations } from "../secret-paths.j import { DEFAULT_HOME_READABLE } from "./home-readable.js"; import { validAllowedHost } from "./hosts.js"; import { sandboxRoot } from "./root.js"; +import { isOwnWorktree, worktreeAdminsOf, worktreeLocationsOf } from "./worktrees.js"; import type { NetworkMode, NetworkPolicy, SandboxSpec } from "./types.js"; export { DEFAULT_HOME_READABLE, validAllowedHost }; @@ -54,6 +55,12 @@ export interface SandboxConfig { homeReadable: string[]; /** The bwrap binary (default: on PATH). */ bwrapPath?: string; + /** + * More places a command may check a worktree of its repository out, on top + * of `-worktrees/` and `/.worktrees/` (`~` expands; a relative + * one is the repository's main checkout's): writable (worktrees.ts). + */ + worktreeRoots: string[]; docker: { /** Image a Docker sandbox runs; must carry what the backend runs (a shell, node for pi / the Gemini CLI). */ image: string; @@ -69,6 +76,7 @@ export const DEFAULT_SANDBOX_CONFIG: SandboxConfig = { allowedHosts: [], hiddenPaths: [], homeReadable: [...DEFAULT_HOME_READABLE], + worktreeRoots: [], docker: { image: "node:22-bookworm-slim" }, }; @@ -263,6 +271,8 @@ export interface SpecInputs { projectConfig?: boolean; /** Give the toolchains a writable cache (default: yes; sandboxCacheDir). */ cache?: boolean; + /** The repository's worktrees when the session's sandbox first looked (GitWorkspaceOptions.worktreeBaseline). */ + worktreeBaseline?: ReadonlySet; } /** The home a spec hides: absolute, and not `/` (nothing is left to show then). */ @@ -320,6 +330,21 @@ export interface GitPaths { readOnly: string[]; /** The git dirs a commit writes in: what a backend's own sandbox must also let it write (Codex's writable roots). */ gitDirs: string[]; + /** The repository's worktree locations (worktrees.ts): writable, where a command checks a new worktree out. */ + locations: string[]; +} + +/** What {@link gitWorkspacePaths} is told of the session's own worktrees. */ +export interface GitWorkspaceOptions { + /** `sandbox.worktreeRoots`: more worktree locations (worktrees.ts `worktreeLocationsOf`). */ + worktreeRoots?: readonly string[]; + /** + * The repository's worktree admin dirs when the session's sandbox first + * looked: one made since, checked out in the workspace or a worktree + * location, is the session's own and left writable whole (worktrees.ts + * `isOwnWorktree`). Absent: every existing worktree is held. + */ + worktreeBaseline?: ReadonlySet; } /** @@ -329,7 +354,9 @@ export interface GitPaths { * where the config turns it on; `info/` holds attributes and sparse rules, * `modules/` the submodules' git dirs (each with its own config and hooks), * `remotes/` and `branches/` remotes defined as files, `worktrees/` the - * linked worktrees' git dirs (each naming its repository in `commondir`). + * linked worktrees' git dirs (each naming its repository in `commondir`): + * `worktrees/` itself is writable (a command may add a worktree), each + * existing worktree's git dir in it held (gitWorkspacePaths). */ export const GIT_CONTROL = ["config", "config.worktree", "commondir", "hooks", "info", "modules", "remotes", "branches", "worktrees"] as const; /** In a linked worktree's own git dir: what names its repository and its checkout, and its own config. */ @@ -452,13 +479,16 @@ export function projectDirsOf(workdir: string, workspace: string): string[] { * at). So, read-only: * * - in the repository's git dir: `config`, `config.worktree`, - * `commondir`, `hooks/`, `info/`, `modules/`, `remotes/`, `branches/` - * and `worktrees/` whole ({@link GIT_CONTROL}): every worktree's git - * dir, those added after the sandbox opened too; - * - in a linked worktree's own git dir (bound writable on top of - * `worktrees/`): `commondir`, `gitdir`, `config.worktree`; - * - the workspace's `.git` file, and the `.git` file of every other - * worktree or submodule checked out inside the workspace; + * `commondir`, `hooks/`, `info/`, `modules/`, `remotes/` and + * `branches/` ({@link GIT_CONTROL}); + * - `worktrees/` is writable, bound at its own path (a command adds a + * worktree there: worktrees.ts), but every worktree's admin dir in it + * that isn't the session's own is read-only whole, with its checkout's + * `.git` file, and its whole checkout when that is in a worktree + * location; the workspace's own: `commondir`, `gitdir`, + * `config.worktree`; + * - the workspace's `.git` file, and the `.git` file of every submodule + * checked out inside the workspace; * - a plain repository at the top of the workspace: an empty `HEAD/` * beside its `.git` (made when missing; git doesn't see an empty dir). * A `.git` made invalid (its `HEAD` garbled, `refs/` removed) would @@ -484,8 +514,8 @@ export function projectDirsOf(workdir: string, workspace: string): string[] { * and so is what it names when that is inside the workspace: nothing * commits from inside then ({@link gitLayoutProblem} says so). */ -export function gitWorkspacePaths(workspace: string, home: string = process.env.HOME ?? homedir()): GitPaths { - const out: GitPaths = { writable: [], readOnly: [], gitDirs: [] }; +export function gitWorkspacePaths(workspace: string, home: string = process.env.HOME ?? homedir(), opts: GitWorkspaceOptions = {}): GitPaths { + const out: GitPaths = { writable: [], readOnly: [], gitDirs: [], locations: [] }; const ws = resolve(workspace); const layout = gitLayoutOf(ws, home); if (!layout) return out; @@ -510,13 +540,10 @@ export function gitWorkspacePaths(workspace: string, home: string = process.env. // `.` is the git dir itself, as git, libgit2 and gitoxide read it: one an agent writes can't send git elsewhere. makeIfMissing(join(common, "commondir"), ".\n"); const worktrees = join(common, "worktrees"); - const others: string[] = []; - for (const name of listDirs(worktrees)) { - const dir = join(worktrees, name); - makeIfMissing(join(dir, "config.worktree"), ""); - if (own && resolve(dir) === resolve(own)) continue; - others.push(dir); - } + const all = worktreeAdminsOf(common); + for (const a of all) makeIfMissing(join(a.dir, "config.worktree"), ""); + if (own) makeIfMissing(join(own, "config.worktree"), ""); + const admins = all.filter((a) => !own || resolve(a.dir) !== resolve(own)); const control = [ ...GIT_CONTROL.map((n) => join(common, n)), ...(own ? WORKTREE_CONTROL.map((n) => join(own, n)) : []), @@ -529,12 +556,28 @@ export function gitWorkspacePaths(workspace: string, home: string = process.env. return out; } out.writable.push(common); - // Over the read-only `worktrees/`: this worktree's own git dir, writable, its control read-only again. + // `worktrees/` writable, so a command can add a worktree (git makes its admin dir there), and bound at its own + // path so it can't be renamed away and rebuilt with an admin dir a checkout of the user's would follow. + out.writable.push(worktrees); + // This worktree's own git dir, bound at its own path, its control read-only again. if (own) out.writable.push(own); - out.readOnly.push(...control); + out.readOnly.push(...control.filter((p) => p !== worktrees)); if (dotgitFile) out.readOnly.push(dotgit); - // Other checkouts of this repository inside the workspace (a worktree, a submodule): their `.git` files too. - for (const f of [...others.map((d) => gitdirTarget(d)), ...submoduleCheckouts(join(common, "modules"))]) { + // Where a command checks a new worktree out: writable (worktrees.ts). + out.locations.push(...worktreeLocationsOf(layout, { home, workspace: ws, ...(opts.worktreeRoots ? { roots: opts.worktreeRoots } : {}) })); + const reach = [ws, ...out.locations]; + // Every other worktree, unless the session made it: its admin dir read-only whole (not rewritten, renamed, removed + // or pruned), its `.git` file, and its whole checkout when that is somewhere writable outside the workspace (a + // worktree location: `git worktree remove --force` deletes nothing of it). + for (const a of admins) { + if (isOwnWorktree(a, opts.worktreeBaseline, reach)) continue; + if (!isLink(a.dir)) out.readOnly.push(a.dir); + const checkout = a.dotgit ? dirname(a.dotgit) : null; + if (checkout && !within(realOr(checkout), realOr(ws)) && out.locations.some((l) => within(realOr(checkout), l)) && lexists(checkout) && !isLink(checkout)) out.readOnly.push(checkout); + else if (a.dotgit && a.dotgit !== dotgit && reach.some((r) => within(realOr(a.dotgit as string), realOr(r))) && lexists(a.dotgit) && !isLink(a.dotgit)) out.readOnly.push(a.dotgit); + } + // Submodules checked out inside the workspace: their `.git` files too. + for (const f of submoduleCheckouts(join(common, "modules"))) { if (f && f !== dotgit && within(f, ws) && lexists(f) && !isLink(f)) out.readOnly.push(f); } out.gitDirs.push(common, ...(own ? [own] : [])); @@ -826,7 +869,11 @@ export function buildSandboxSpec(inputs: SpecInputs): SandboxSpec { const env = inputs.hidden?.env ?? process.env; const home = inputs.hidden?.home ?? env.HOME ?? homedir(); const workspace = resolve(inputs.workspace); - const git = inputs.git === false ? { writable: [], readOnly: [], gitDirs: [] } : gitWorkspacePaths(workspace, home); + const sandboxCfg = sandboxConfigOf(inputs.config); + const git: GitPaths = + inputs.git === false + ? { writable: [], readOnly: [], gitDirs: [], locations: [] } + : gitWorkspacePaths(workspace, home, { worktreeRoots: sandboxCfg.worktreeRoots, ...(inputs.worktreeBaseline ? { worktreeBaseline: inputs.worktreeBaseline } : {}) }); const projectConfig = inputs.projectConfig === false ? [] : projectDirsOf(inputs.workdir ?? workspace, workspace).flatMap((dir) => projectConfigPaths(dir, home)); const cache = inputs.cache === false ? null : sandboxCacheDir(home, env); const own = [...(inputs.backendState ?? []).map((p) => resolve(p)), ...git.writable, ...(cache ? [cache] : [])]; @@ -845,14 +892,18 @@ export function buildSandboxSpec(inputs: SpecInputs): SandboxSpec { // The allowlist, less what `sandbox.hiddenPaths` hides: an explicit hide wins. ...allowed, ]; - // What stays visible inside the hidden home: a hidden path there matters only inside one of these. - const shown = [workspace, tempDir, ...own, ...ownReal, ...reexposed, ...reexposed.map(realOr)]; - const hidden = [ + const hiddenAll = [ ...(homeHidden ? [homeHidden] : []), ...hiddenPathsFor(inputs.config, inputs.hidden), ...homeCredentialFilesIn(reexposed), ...(root ? [root] : []), - ].filter( + ]; + // The worktree locations: writable, but never inside something hidden (a root configured in `~/.ssh`), and, + // unlike the backend's own state, never taking a hidden path inside them out of the hidden list. + const locations = git.locations.filter((l) => !hiddenAll.some((h) => h !== homeHidden && (within(l, h) || within(l, realOr(h))))); + // What stays visible inside the hidden home: a hidden path there matters only inside one of these. + const shown = [workspace, tempDir, ...own, ...ownReal, ...locations, ...reexposed, ...reexposed.map(realOr)]; + const hidden = hiddenAll.filter( (h, i, all) => all.indexOf(h) === i && // The backend's own state stays reachable by it; nothing it holds is taken out otherwise. @@ -860,21 +911,23 @@ export function buildSandboxSpec(inputs: SpecInputs): SandboxSpec { // Inside the hidden home, only what is inside something shown needs hiding again. (!homeHidden || h === homeHidden || !within(h, homeHidden) || shown.some((x) => within(h, x))), ); - return { + const spec: SandboxSpec = { sessionId: inputs.sessionId, workspace, tempDir, - writable: own, + writable: [...own, ...locations], hidden, readOnly: [...git.readOnly, ...projectConfig], gitDirs: git.gitDirs, + ...(locations.length > 0 ? { worktreeLocations: locations } : {}), ...(cache ? { env: toolchainCacheEnv(cache, home, env) } : {}), - pinned: protectedAncestors(hidden, [workspace, tempDir, ...own]), + pinned: protectedAncestors(hidden, [workspace, tempDir, ...own, ...locations]), reexposed, containerSockets: containerSocketPaths(home, env), network: inputs.network, unsetEnv: SANDBOX_UNSET_ENV, }; + return spec; } function sandboxRootOrNull(): string | null { diff --git a/src/daemon/sandbox/session-sandbox.ts b/src/daemon/sandbox/session-sandbox.ts index a3b98e5a..5ae09ada 100644 --- a/src/daemon/sandbox/session-sandbox.ts +++ b/src/daemon/sandbox/session-sandbox.ts @@ -22,7 +22,8 @@ import type { ClaudeTempDirs } from "./claude-native.js"; import { type ConfinementPlan, confinementFor, runsNoCommands } from "./confinement.js"; import { GIT_BIN } from "../git-env.js"; import { CWD_FROM_CALLER } from "./docker.js"; -import { buildSandboxSpec, gitLayoutProblem, networkPolicyOf, type SandboxConfig, sandboxConfigOf, sandboxWorkspaceOf, validAllowedHost } from "./policy.js"; +import { buildSandboxSpec, gitLayoutOf, gitLayoutProblem, networkPolicyOf, type SandboxConfig, sandboxConfigOf, sandboxWorkspaceOf, validAllowedHost } from "./policy.js"; +import { worktreeAdminsOf } from "./worktrees.js"; import type { SandboxRegistry } from "./registry.js"; import { cliWorktrees, gitProgramLocations, gitShimSpec, writeGitShim } from "./git-shim.js"; import { launcherRoot, sandboxRoot } from "./root.js"; @@ -270,6 +271,7 @@ export class SessionSandbox { #homesDir: string | null = null; #claudeTemp: ClaudeTempDirs | null = null; #gitTempDir: string | null = null; + #worktreeBaseline: Set | null = null; readonly #onWarning: ((text: string) => void) | undefined; @@ -529,19 +531,36 @@ export class SessionSandbox { } #spec(network: NetworkPolicy, backendState: readonly string[], readable: readonly string[] = []): SandboxSpec { + // A monorepo's package: the repository's top (#448 round 9); the session's cwd stays the workdir. + const workspace = sandboxWorkspaceOf(this.#workdir); return buildSandboxSpec({ backendReadable: readable, sessionId: this.#sessionId, - // A monorepo's package: the repository's top (#448 round 9); the session's cwd stays the workdir. - workspace: sandboxWorkspaceOf(this.#workdir), + workspace, workdir: this.#workdir, tempDir: this.#ensureTempDir(), network, ...(this.#config ? { config: this.#config } : {}), backendState, + worktreeBaseline: this.#worktreesAtStart(workspace), }); } + /** + * The repository's worktrees when this session's sandbox first looked: a + * worktree made since, checked out where its commands can write, is the + * session's own (worktrees.ts `isOwnWorktree`); every other is held. + * Kept for the session's life in this daemon (after a restart, what a + * session made before is held like anyone's). + */ + #worktreesAtStart(workspace: string): ReadonlySet { + if (!this.#worktreeBaseline) { + const common = gitLayoutOf(workspace)?.common; + this.#worktreeBaseline = new Set(common ? worktreeAdminsOf(common).map((a) => a.dir) : []); + } + return this.#worktreeBaseline; + } + /** The session's private temp dir (made on first use, 0700). */ #ensureTempDir(): string { if (!this.#tempDir) this.#tempDir = mkdtempSync(join(sandboxRoot(), `tmp-${this.#sessionSlug()}-`)); diff --git a/src/daemon/sandbox/types.ts b/src/daemon/sandbox/types.ts index 115c0947..b2a0bcfe 100644 --- a/src/daemon/sandbox/types.ts +++ b/src/daemon/sandbox/types.ts @@ -116,6 +116,13 @@ export interface SandboxSpec { * `~/.config/gh` out from under the rule that hides it. */ pinned?: readonly string[]; + /** + * The repository's worktree locations (in {@link writable} too): where a + * command checks a new worktree out (`-worktrees/`, `.worktrees/`, + * `sandbox.worktreeRoots`; policy/worktrees.ts). What a backend's own + * sandbox must also let it write (Codex's writable roots). + */ + worktreeLocations?: readonly string[]; /** Container runtime sockets (and their dirs): never reachable, they are root-equivalent. */ containerSockets: readonly string[]; network: NetworkPolicy; diff --git a/src/daemon/sandbox/worktrees.ts b/src/daemon/sandbox/worktrees.ts new file mode 100644 index 00000000..811974df --- /dev/null +++ b/src/daemon/sandbox/worktrees.ts @@ -0,0 +1,189 @@ +/** + * Worktrees an agent makes inside the sandbox (#448 follow-up). + * + * Agents branch in a worktree of their own (`git worktree add -b + * `), never in a checkout others share. Inside the sandbox that + * needs two things the repository's structure otherwise holds read-only: + * git making a new admin dir under `/worktrees/`, and the checkout + * landing outside the workspace. So: + * + * - `/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 + * `-worktrees/` (codeoid's own convention, git-worktree.ts), the + * repository's `.worktrees/`, and `sandbox.worktreeRoots` + * ({@link worktreeLocationsOf}); + * - every worktree that isn't the session's own is held read-only whole: + * its admin dir (so it can't be rewritten, renamed, removed or pruned), + * its checkout's `.git` file, and its whole checkout when that is in a + * worktree location (so `git worktree remove --force` deletes nothing of + * it). The session's own are those that appeared since the session's + * sandbox first looked ({@link worktreeAdminsOf}, its baseline), checked + * out where a command of it could have put one, and not made by codeoid + * itself ({@link noteCodeoidWorktree}: a fork's, a loop's). + * + * A worktree the agent made is its own output, like the workspace. + */ + +import { lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, dirname, isAbsolute, join, resolve } from "node:path"; +import type { GitLayout } from "./policy.js"; + +function within(child: string, parent: string): boolean { + return child === parent || child.startsWith(parent.endsWith("/") ? parent : `${parent}/`); +} + +function realOr(p: string): string { + try { + return realpathSync(p); + } catch { + return p; + } +} + +function isLink(p: string): boolean { + try { + return lstatSync(p).isSymbolicLink(); + } catch { + return false; + } +} + +/** Checkouts codeoid itself made (a fork's, a loop's): never a session's own, whenever they appeared. */ +const codeoidMade = new Set(); + +/** Record a worktree checkout codeoid makes, before git makes it (git-worktree.ts). */ +export function noteCodeoidWorktree(checkout: string): void { + codeoidMade.add(resolve(checkout)); + codeoidMade.add(realOr(resolve(checkout))); +} + +/** Whether codeoid made the worktree checked out at `checkout` (in this daemon's life). */ +export function madeByCodeoid(checkout: string): boolean { + return codeoidMade.has(resolve(checkout)) || codeoidMade.has(realOr(resolve(checkout))); +} + +/** + * The repository's main checkout, as `git worktree list` names it first + * (git-worktree.ts `mainWorktreeRoot`): the dir holding a `.git` dir, a + * bare repository itself, a submodule's checkout. Null for a layout codeoid + * can't verify. + */ +export function mainCheckoutOf(layout: GitLayout): string | null { + if (!layout.common) return null; + if (layout.kind === "worktree") return basename(layout.common) === ".git" ? dirname(layout.common) : layout.common; + return dirname(layout.dotgit); +} + +/** The repository's worktree locations by default, relative to its main checkout: codeoid's sibling dir and `.worktrees/`. */ +export function defaultWorktreeLocations(main: string): string[] { + return [join(dirname(main), `${basename(main)}-worktrees`), join(main, ".worktrees")]; +} + +/** + * Where a command may check a new worktree of the repository out: the + * defaults ({@link defaultWorktreeLocations}) and `roots` + * (`sandbox.worktreeRoots`: `~` expands, a relative one is the main + * checkout's), absolute and real. Never `/`, the home or a dir holding it, + * one holding the main checkout or the repository's git dir, or one inside + * the git dir, none inside `workspace` (writable already), and none that is + * a link or is reached through one below the main checkout's parent, or + * isn't the daemon user's own. Made where + * missing (the defaults only where their parent is there): a mount needs + * something to mount. + */ +export function worktreeLocationsOf(layout: GitLayout, opts: { roots?: readonly string[]; home?: string; workspace?: string } = {}): string[] { + const main = mainCheckoutOf(layout); + if (!main || !layout.common) return []; + const home = resolve(opts.home ?? process.env.HOME ?? homedir()); + const common = realOr(layout.common); + const mainReal = realOr(main); + const out: string[] = []; + const ws = opts.workspace ? resolve(opts.workspace) : null; + const mainLit = resolve(main); + // Never through a link: one a session's command made where it could write (`.worktrees` in the main checkout, + // a dir in a location) would hand the next session a writable dir anywhere. From the main checkout's parent + // down for what is below it, else the location itself. + const linked = (at: string): boolean => { + const base = within(at, dirname(mainLit)) ? dirname(mainLit) : dirname(at); + for (let p = at; p !== base && within(p, base); p = dirname(p)) if (isLink(p)) return true; + return false; + }; + const add = (p: string, recursive: boolean) => { + const at = resolve(p); + // Inside the workspace it is writable already: nothing to mount, so nothing is made there. + if (ws && within(at, ws)) return; + if (linked(at)) return; + try { + mkdirSync(at, { recursive, mode: 0o755 }); + } catch { + /* there already, or can't be made: then only an existing one is used */ + } + try { + const st = lstatSync(at); + // Ours: in a shared dir (a repository right under /tmp) another user could have made it first. + if (linked(at) || !st.isDirectory() || (typeof process.getuid === "function" && st.uid !== process.getuid())) return; + } catch { + return; + } + const real = realOr(at); + if (real === "/" || within(home, real) || within(realOr(home), real)) return; + if (within(mainReal, real) || within(common, real) || within(real, common)) return; + if (!out.includes(real)) out.push(real); + }; + // A submodule's sibling would be made inside its superproject's working tree: only the configured roots there. + if (layout.kind !== "submodule") for (const d of defaultWorktreeLocations(main)) add(d, false); + for (const r of opts.roots ?? []) { + const p = r === "~" ? home : r.startsWith("~/") ? join(home, r.slice(2)) : r; + add(isAbsolute(p) ? p : join(main, p), true); + } + return out; +} + +/** One linked worktree of a repository: its admin dir and where its `.git` file is (as its `gitdir` names it). */ +export interface WorktreeAdmin { + /** `/worktrees/`. */ + dir: string; + /** The checkout's `.git` file, absolute; null when its `gitdir` can't be read. */ + dotgit: string | null; +} + +/** The linked worktrees in `/worktrees/` (bounded). */ +export function worktreeAdminsOf(common: string, limit = 500): WorktreeAdmin[] { + const dir = join(common, "worktrees"); + let names: string[] = []; + try { + names = readdirSync(dir, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name) + .slice(0, limit); + } catch { + return []; + } + return names.map((n) => { + const at = join(dir, n); + let dotgit: string | null = null; + try { + const named = readFileSync(join(at, "gitdir"), "utf8").trim(); + dotgit = named ? resolve(at, named) : null; + } catch { + /* no gitdir: a half-made or broken worktree */ + } + return { dir: at, dotgit }; + }); +} + +/** + * Whether `admin` is a worktree the session made itself: not there when + * its sandbox first looked (`baseline`, admin dirs by path), checked out + * where a command could put one (the workspace, a worktree location), and + * not codeoid's own. Without a baseline nothing is. + */ +export function isOwnWorktree(admin: WorktreeAdmin, baseline: ReadonlySet | undefined, writable: readonly string[]): boolean { + if (!baseline || baseline.has(admin.dir) || !admin.dotgit) return false; + const checkout = dirname(admin.dotgit); + if (madeByCodeoid(checkout)) return false; + const real = realOr(checkout); + return writable.some((w) => within(real, realOr(w)) || within(resolve(checkout), w)); +} diff --git a/src/daemon/settings/manifest.ts b/src/daemon/settings/manifest.ts index b393cf76..b3509a7d 100644 --- a/src/daemon/settings/manifest.ts +++ b/src/daemon/settings/manifest.ts @@ -233,6 +233,11 @@ const sandbox: SettingsTab = { envVar: "CODEOID_SANDBOX_HIDDEN_PATHS", applies: "next-session", }), + cfg("sandbox.worktreeRoots", "More worktree locations", "Where a sandboxed command may check out a git worktree of its repository, on top of -worktrees/ and /.worktrees/: writable. ~ expands; a relative path is the repository's. Never the home, / or a dir holding the repository.", { + kind: "string[]", + envVar: "CODEOID_SANDBOX_WORKTREE_ROOTS", + applies: "next-session", + }), ], }, { diff --git a/src/tests/sandbox-bwrap-real.test.ts b/src/tests/sandbox-bwrap-real.test.ts index c0e0a16f..41293c91 100644 --- a/src/tests/sandbox-bwrap-real.test.ts +++ b/src/tests/sandbox-bwrap-real.test.ts @@ -20,11 +20,13 @@ import { dirname, join } from "node:path"; import { runCheck } from "../daemon/evidence/runner.js"; import { BwrapSandboxProvider } from "../daemon/sandbox/bwrap.js"; import { hostToolBoundsFor, sandboxReads, sandboxWrites } from "../daemon/sandbox/host-file-tools.js"; +import { gitControlSnapshot } from "../daemon/sandbox/git-watch.js"; import { buildSandboxSpec, programLocations, sandboxWorkspaceOf } from "../daemon/sandbox/policy.js"; import { SandboxRegistry } from "../daemon/sandbox/registry.js"; import { sandboxRoot } from "../daemon/sandbox/root.js"; import { openCheckSandbox } from "../daemon/sandbox/session-sandbox.js"; import type { SandboxHandle, SandboxSpec } from "../daemon/sandbox/types.js"; +import { worktreeAdminsOf } from "../daemon/sandbox/worktrees.js"; const provider = new BwrapSandboxProvider(); const probe = await provider.probe(); @@ -743,32 +745,47 @@ describe.skipIf(!probe.available || !Bun.which("git"))("bubblewrap, for real: ro } }); - test("G (#448 round 3): a worktree added after the sandbox opened is read-only from it (its commondir, its gitdir), and so is anything made in worktrees/", async () => { + test("G (#448 round 3, follow-up): a worktree added after the sandbox opened is held from the session's next sandbox, and a commondir rewritten meanwhile is named at the turn's end; worktrees/ itself can't be swapped", async () => { for (const worktree of [false, true]) { const f = fixture(worktree ? "g-wt" : "g", { worktree }); - const h = await provider.open(f.spec); const late = join(f.dir, "late"); + const lateDir = join(f.common, "worktrees", "late"); + const h = await provider.open(f.spec); + const before = gitControlSnapshot(f.ws, f.home); try { // The user (or a fork) adds a worktree while the session's sandbox is open. git(["worktree", "add", "-q", "-b", "late", late], f.repo, f.home); - const lateDir = join(f.common, "worktrees", "late"); const r = await sh( h, f.ws, - `cp -r '${f.common}' "$PWD/.evilcommon" && git --git-dir="$PWD/.evilcommon" config core.fsmonitor '${f.mark}' && echo BUILT; echo "$PWD/.evilcommon" > '${join(lateDir, "commondir")}' 2>/dev/null && echo REWROTE || echo LATE-READ-ONLY; echo x > '${join(lateDir, "gitdir")}' 2>/dev/null && echo REWROTE-GITDIR || echo GITDIR-READ-ONLY; mkdir '${join(f.common, "worktrees", "planted")}' 2>/dev/null && echo MADE || echo WORKTREES-READ-ONLY`, + `cp -r '${f.common}' "$PWD/.evilcommon" && git --git-dir="$PWD/.evilcommon" config core.fsmonitor '${f.mark}' && echo BUILT; echo "$PWD/.evilcommon" > '${join(lateDir, "commondir")}' 2>/dev/null && echo REWROTE || echo LATE-READ-ONLY; mv '${join(f.common, "worktrees")}' '${join(f.common, "wt-old")}' 2>/dev/null && echo SWAPPED || echo WORKTREES-PINNED`, f.env, ); expect(r.stdout).toContain("BUILT"); - expect(r.stdout).toContain("LATE-READ-ONLY"); - expect(r.stdout).toContain("GITDIR-READ-ONLY"); - expect(r.stdout).toContain("WORKTREES-READ-ONLY"); - // Still commits (the worktree's own git dir stays writable over the read-only worktrees/). + expect(r.stdout).toContain("WORKTREES-PINNED"); + // A sandbox already running doesn't hold a worktree it didn't see (docs/sandbox.md, Limits): when it + // rewrote one, the session's end-of-turn look names it before the user runs git there. + if (r.stdout.includes("REWROTE")) { + expect([...gitControlSnapshot(f.ws, f.home).keys()].filter((k) => !before.has(k)).some((k) => k.endsWith("worktrees/late/commondir"))).toBe(true); + writeFileSync(join(lateDir, "commondir"), "../..\n"); + } + // Still commits (the worktree's own git dir stays writable). writeFileSync(join(f.ws, "g.txt"), "g\n"); const c = await sh(h, f.ws, "git add g.txt && git commit -qm g-inside && echo COMMITTED", f.env); expect(c.stdout).toContain("COMMITTED"); } finally { await h.close(); } + // The session's next sandbox (the same baseline: taken before the late one was added) holds it. + const baseline = new Set([...worktreeAdminsOf(f.common).map((a) => a.dir)].filter((d) => d !== lateDir)); + const next = await provider.open(buildSandboxSpec({ sessionId: "g2", workspace: f.ws, tempDir: join(f.dir, "tmp"), network: { mode: "off" }, hidden: { home: f.home, configDir: join(f.home, ".codeoid"), env: { HOME: f.home }, daemonTmp: "/tmp" }, worktreeBaseline: baseline })); + try { + const r = await sh(next, f.ws, `echo x > '${join(lateDir, "commondir")}' 2>/dev/null && echo REWROTE || echo LATE-READ-ONLY; echo x > '${join(lateDir, "gitdir")}' 2>/dev/null && echo REWROTE-GITDIR || echo GITDIR-READ-ONLY`, f.env); + expect(r.stdout).toContain("LATE-READ-ONLY"); + expect(r.stdout).toContain("GITDIR-READ-ONLY"); + } finally { + await next.close(); + } userGit(f, late); expect(existsSync(f.marker)).toBe(false); } diff --git a/src/tests/sandbox-claude-real.test.ts b/src/tests/sandbox-claude-real.test.ts index 1f92176e..ebd7494a 100644 --- a/src/tests/sandbox-claude-real.test.ts +++ b/src/tests/sandbox-claude-real.test.ts @@ -183,8 +183,8 @@ describe.skipIf(!ready)("Claude Code's sandbox with codeoid's policy, for real", expect(listing).not.toContain("codeoid-login-"); }, 120_000); - test("a worktree added while a command runs is read-only to it, under Claude's sandbox too (a plain repository's worktrees/ is held whole) (#448 round 3)", async () => { - const r2 = realpathSync(mkdtempSync(join(scratchParent(), "codeoid-sbx-claude-late-"))); + test("under Claude's sandbox too, a command adds a worktree in the sibling -worktrees location, commits there and removes it, while the user's worktrees stay held (#448 follow-up)", async () => { + const r2 = realpathSync(mkdtempSync(join(scratchParent(), "codeoid-sbx-claude-wt-"))); try { const home = join(r2, "home"); const repo = join(home, "proj"); @@ -195,31 +195,28 @@ describe.skipIf(!ready)("Claude Code's sandbox with codeoid's policy, for real", writeFileSync(join(repo, "a.txt"), "a\n"); git(["add", "."], repo); git(["commit", "-qm", "init"], repo); - const lateDir = join(repo, ".git", "worktrees", "late"); + // The user's own worktrees: one beside the repository, one in its -worktrees location. + expect(git(["worktree", "add", "-q", "-b", "user", join(r2, "userwt")], repo).exitCode).toBe(0); + expect(git(["worktree", "add", "-q", "-b", "userloc", join(home, "proj-worktrees", "userloc")], repo).exitCode).toBe(0); + const wts = join(repo, ".git", "worktrees"); const command = [ - `touch ${join(repo, "started")}`, - `i=0; while [ ! -e ${join(repo, "go")} ] && [ $i -lt 300 ]; do sleep 0.1; i=$((i+1)); done`, - `echo /elsewhere > ${join(lateDir, "commondir")} 2>/dev/null && echo REWROTE || echo LATE-READ-ONLY`, - `echo t > ${join(repo, "t.txt")} && git add t.txt && git -c user.name=t -c user.email=t@example.com commit -qm inside && echo COMMITTED`, + "git worktree add -q ../proj-worktrees/x -b feature/x && echo ADDED", + "cd ../proj-worktrees/x && echo x > x.txt && git add x.txt && git -c user.name=t -c user.email=t@example.com commit -qm in-worktree && echo COMMITTED", + `cd '${repo}' && git worktree remove ../proj-worktrees/x && echo REMOVED`, + `echo /elsewhere > ${join(wts, "userwt", "commondir")} 2>/dev/null && echo REWROTE || echo USER-HELD`, + `echo x > ${join(home, "proj-worktrees", "userloc", "x.txt")} 2>/dev/null && echo WROTE-USERLOC || echo USERLOC-HELD`, + `echo bad >> ${join(repo, ".git", "config")} 2>/dev/null && echo WROTE-CONFIG || echo CONFIG-HELD`, ].join("; "); - const file = join(r2, "cmd-late.txt"); + const file = join(r2, "cmd-wt.txt"); writeFileSync(file, command); const p = Bun.spawn([process.execPath, PROBE, r2, "off", file, "policy"], { stdout: "pipe", stderr: "pipe", timeout: 90_000, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1" } }); - let exited = false; - void p.exited.then(() => { - exited = true; - }); - for (let i = 0; i < 600 && !exited && !existsSync(join(repo, "started")); i++) await Bun.sleep(100); - expect(existsSync(join(repo, "started"))).toBe(true); - expect(git(["worktree", "add", "-q", "-b", "late", join(r2, "late")], repo).exitCode).toBe(0); - writeFileSync(join(repo, "go"), ""); const out = await new Response(p.stdout).text(); await p.exited; const r = JSON.parse(out.trim().split("\n").at(-1) ?? "{}") as { result: string; toolResult: string | null }; expect(r.result).toBe("success"); - expect(r.toolResult).toContain("LATE-READ-ONLY"); - expect(r.toolResult).toContain("COMMITTED"); - expect(readFileSync(join(lateDir, "commondir"), "utf8")).not.toContain("elsewhere"); + for (const w of ["ADDED", "COMMITTED", "REMOVED", "USER-HELD", "USERLOC-HELD", "CONFIG-HELD"]) expect(r.toolResult).toContain(w); + expect(git(["log", "--oneline", "feature/x"], repo).stdout.toString()).toContain("in-worktree"); + expect(readFileSync(join(wts, "userwt", "commondir"), "utf8")).not.toContain("elsewhere"); } finally { rmSync(r2, { recursive: true, force: true }); } diff --git a/src/tests/sandbox-codex.test.ts b/src/tests/sandbox-codex.test.ts index 97af1ca7..a5f1a981 100644 --- a/src/tests/sandbox-codex.test.ts +++ b/src/tests/sandbox-codex.test.ts @@ -79,6 +79,10 @@ describe("codex's own sandbox, per session", () => { // Under the host's /tmp the temp dir is codeoid's bubblewrap's /tmp (writable: excludeSlashTmp false), not at its own path: // as a root of its own it would fail every command in codex's sandbox (#448 round 2). expect(codexSandboxPolicy(fakeRuntime("native", "off", "/tmp/codeoid-sandbox-s/tmp-x").rt, "danger-full-access", "/w")).toMatchObject({ writableRoots: ["/w"], excludeSlashTmp: false }); + // The repository's worktree locations too (#448 follow-up): where a command checks a new worktree out. + const wt = fakeRuntime("native", "off", "/var/tmp/s").rt; + (wt.spec as { worktreeLocations?: string[] }).worktreeLocations = ["/repo-worktrees"]; + expect(codexSandboxPolicy(wt, "danger-full-access", "/w")).toMatchObject({ writableRoots: ["/w", "/var/tmp/s", "/repo-worktrees"] }); }); test("native: the repository's git dirs and the sandbox's cache dir are writable roots too (#448 round 2: codex's own sandbox makes every .git read-only)", () => { diff --git a/src/tests/sandbox-git-watch.test.ts b/src/tests/sandbox-git-watch.test.ts index a389e041..78d44d37 100644 --- a/src/tests/sandbox-git-watch.test.ts +++ b/src/tests/sandbox-git-watch.test.ts @@ -76,7 +76,8 @@ describe("what of a repository git runs at your next command, and what changed ( git(["worktree", "add", "-q", "-b", "g", join(root, "late")], r); const before = gitControlSnapshot(wt, root); writeFileSync(join(r, ".git", "worktrees", "late", "commondir"), "/elsewhere\n"); - expect(gitControlChanges(before, gitControlSnapshot(wt, root))).toEqual([join(r, ".git", "worktrees", "late", "commondir")]); + // No longer as git made it: what names its repository and its checkout are both named. + expect(gitControlChanges(before, gitControlSnapshot(wt, root))).toEqual([join(r, ".git", "worktrees", "late", "commondir"), join(r, ".git", "worktrees", "late", "gitdir")]); }); test("no repository: nothing to watch", () => { diff --git a/src/tests/sandbox-home.test.ts b/src/tests/sandbox-home.test.ts index 0d300782..74c19a10 100644 --- a/src/tests/sandbox-home.test.ts +++ b/src/tests/sandbox-home.test.ts @@ -134,13 +134,18 @@ describe("the workspace's git repository", () => { const common = join(repo, ".git"); const own = join(common, "worktrees", "gh-wt"); const g = gitWorkspacePaths(wt, home); - expect(g.writable).toEqual([common, own]); + // worktrees/ writable at its own path (a command may add a worktree), each other worktree's git dir held whole. + expect(g.writable).toEqual([common, join(common, "worktrees"), own]); expect(g.gitDirs).toEqual([common, own]); - for (const p of ["hooks", "info", "config", "config.worktree", "commondir", "modules", "remotes", "branches", "worktrees"]) expect(g.readOnly).toContain(join(common, p)); + for (const p of ["hooks", "info", "config", "config.worktree", "commondir", "modules", "remotes", "branches"]) expect(g.readOnly).toContain(join(common, p)); + expect(g.readOnly).not.toContain(join(common, "worktrees")); + expect(g.readOnly).toContain(join(common, "worktrees", "nested")); for (const p of ["commondir", "gitdir", "config.worktree"]) expect(g.readOnly).toContain(join(own, p)); expect(g.readOnly).toContain(join(wt, ".git")); expect(g.readOnly).toContain(join(nested, ".git")); expect(g.readOnly).not.toContain(own); + // The repository's worktree locations, made where missing: its sibling `-worktrees` dir and the main checkout's `.worktrees`. + expect(g.locations).toEqual([join(home, "repo-worktrees"), join(repo, ".worktrees")]); for (const d of ["hooks", "info", "modules", "remotes", "branches"]) expect(statSync(join(common, d)).isDirectory()).toBe(true); // No HEAD placeholder in a linked worktree: its `.git` file, made invalid, doesn't fall through. expect(existsSync(join(wt, "HEAD"))).toBe(false); @@ -152,9 +157,14 @@ describe("the workspace's git repository", () => { expect(git(["rev-parse", "--path-format=absolute", "--git-common-dir"], repo).trim()).toBe(realpathSync(common)); // A plain repository: its .git bound writable at its own path (no rename), the same held read-only. const plain = gitWorkspacePaths(repo, home); - expect(plain.writable).toEqual([common]); + expect(plain.writable).toEqual([common, join(common, "worktrees")]); expect(plain.gitDirs).toEqual([common]); - for (const p of ["hooks", "config", "commondir", "worktrees"]) expect(plain.readOnly).toContain(join(common, p)); + for (const p of ["hooks", "config", "commondir"]) expect(plain.readOnly).toContain(join(common, p)); + for (const n of ["gh-wt", "nested"]) expect(plain.readOnly).toContain(join(common, "worktrees", n)); + // Its .git file is outside the workspace and every worktree location: nothing of it is writable to hold. + expect(plain.readOnly).not.toContain(join(wt, ".git")); + // In the main checkout, `.worktrees` is the workspace's own (writable already): not a location of its own. + expect(plain.locations).toEqual([join(home, "repo-worktrees")]); // Its top can't become a git dir of its own when .git is made invalid: an empty HEAD dir, held (#448 round 3). expect(plain.readOnly).toContain(join(repo, "HEAD")); expect(readdirSync(join(repo, "HEAD"))).toEqual([]); @@ -283,7 +293,7 @@ describe("the workspace's git repository", () => { mkdirSync(join(target, "worktrees", "x"), { recursive: true }); for (const named of [target, join(target, "worktrees", "x"), "../.ssh"]) { writeFileSync(join(ws, ".git"), `gitdir: ${named}\n`); - expect(gitWorkspacePaths(ws, home)).toEqual({ writable: [], readOnly: [join(ws, ".git")], gitDirs: [] }); + expect(gitWorkspacePaths(ws, home)).toEqual({ writable: [], readOnly: [join(ws, ".git")], gitDirs: [], locations: [] }); expect(gitLayoutProblem(ws, home)).toContain("committing from inside fails"); } // Even a real repository's worktree dir, when its back-link names another worktree. @@ -293,15 +303,15 @@ describe("the workspace's git repository", () => { mkdirSync(join(repo, ".git", "worktrees", "other"), { recursive: true }); writeFileSync(join(repo, ".git", "worktrees", "other", "gitdir"), `${join(root, "elsewhere", ".git")}\n`); writeFileSync(join(ws, ".git"), `gitdir: ${join(repo, ".git", "worktrees", "other")}\n`); - expect(gitWorkspacePaths(ws, home)).toEqual({ writable: [], readOnly: [join(ws, ".git")], gitDirs: [] }); + expect(gitWorkspacePaths(ws, home)).toEqual({ writable: [], readOnly: [join(ws, ".git")], gitDirs: [], locations: [] }); // A git dir inside the workspace that names nothing back (--separate-git-dir into it): read-only whole too. git(["init", "-q", `--separate-git-dir=${join(ws, ".store")}`, join(home, "unused")], home); writeFileSync(join(ws, ".git"), `gitdir: ${join(ws, ".store")}\n`); - expect(gitWorkspacePaths(ws, home)).toEqual({ writable: [], readOnly: [join(ws, ".git"), join(ws, ".store")], gitDirs: [] }); + expect(gitWorkspacePaths(ws, home)).toEqual({ writable: [], readOnly: [join(ws, ".git"), join(ws, ".store")], gitDirs: [], locations: [] }); // A link where `.git` should be is no repository either (and can't be held). rmSync(join(ws, ".git")); symlinkSync(join(repo, ".git"), join(ws, ".git")); - expect(gitWorkspacePaths(ws, home)).toEqual({ writable: [], readOnly: [], gitDirs: [] }); + expect(gitWorkspacePaths(ws, home)).toEqual({ writable: [], readOnly: [], gitDirs: [], locations: [] }); expect(gitLayoutProblem(repo, home)).toBeNull(); }); @@ -321,9 +331,12 @@ describe("the workspace's git repository", () => { const modules = join(sup, ".git", "modules", "sub"); expect(gitLayoutOf(ws, home)).toEqual({ dotgit: join(ws, ".git"), kind: "submodule", common: modules, own: null }); const g = gitWorkspacePaths(ws, home); - expect(g.writable).toEqual([modules]); + expect(g.writable).toEqual([modules, join(modules, "worktrees")]); expect(g.gitDirs).toEqual([modules]); - for (const p of ["config", "hooks", "commondir", "info", "worktrees"]) expect(g.readOnly).toContain(join(modules, p)); + for (const p of ["config", "hooks", "commondir", "info"]) expect(g.readOnly).toContain(join(modules, p)); + // No default worktree location for a submodule: its sibling would be made in the superproject's working tree. + expect(g.locations).toEqual([]); + expect(existsSync(join(sup, "sub-worktrees"))).toBe(false); expect(g.readOnly).toContain(join(ws, ".git")); expect(gitLayoutProblem(ws, home)).toBeNull(); }); @@ -332,12 +345,12 @@ describe("the workspace's git repository", () => { const home = join(root, "gd"); mkdirSync(join(home, "proj"), { recursive: true }); git(["init", "-q"], home); - expect(gitWorkspacePaths(join(home, "proj"), home)).toEqual({ writable: [], readOnly: [], gitDirs: [] }); + expect(gitWorkspacePaths(join(home, "proj"), home)).toEqual({ writable: [], readOnly: [], gitDirs: [], locations: [] }); }); }); describe("Claude's own sandbox, with the home model", () => { - test("a read-only dir holding a writable one (worktrees/ holding this worktree's git dir) is denied entry by entry around it; a plain repository's worktrees/ whole (#448 round 3)", () => { + test("worktrees/ is writable (a command may add a worktree), every other worktree's git dir denied whole, this worktree's own control files denied (#448 round 3, follow-up)", () => { const home = join(root, "da"); const repo = join(home, "repo"); mkdirSync(repo, { recursive: true }); @@ -357,7 +370,9 @@ describe("Claude's own sandbox, with the home model", () => { expect(deny).toContain(join(worktrees, "da-wt", "commondir")); expect(deny).toContain(join(repo, ".git", "config")); const plain = gitWorkspacePaths(repo, home); - expect(denyAround(plain.readOnly, plain.writable)).toContain(worktrees); + const plainDeny = denyAround(plain.readOnly, plain.writable); + expect(plainDeny).not.toContain(worktrees); + for (const n of ["da-wt", "da-other"]) expect(plainDeny).toContain(join(worktrees, n)); }); test("every path physical: a socket behind a linked dir (/var/run → /run) is named where it is; what doesn't exist yet keeps its name under its real parent", () => { diff --git a/src/tests/sandbox-manager.test.ts b/src/tests/sandbox-manager.test.ts index b41b610b..55660ccc 100644 --- a/src/tests/sandbox-manager.test.ts +++ b/src/tests/sandbox-manager.test.ts @@ -75,7 +75,7 @@ function newManager(probe: SandboxProbe = { available: true }, sandbox?: Record< const sandboxRegistry = new SandboxRegistry("linux", HOST_HAS); sandboxRegistry.register(fake("bwrap", probe)); sandboxRegistry.register(fake("docker", { available: false, reason: "the Docker daemon isn't reachable" })); - const config = { ...mkConfig(tmp), ...(sandbox ? { sandbox: { provider: "auto", network: "on", allowedHosts: [], hiddenPaths: [], homeReadable: [], docker: { image: "x" }, ...sandbox } } : {}) } as CodeoidConfig; + const config = { ...mkConfig(tmp), ...(sandbox ? { sandbox: { provider: "auto", network: "on", allowedHosts: [], hiddenPaths: [], homeReadable: [], worktreeRoots: [], docker: { image: "x" }, ...sandbox } } : {}) } as CodeoidConfig; const m = new SessionManager(store, transcript, undefined, undefined, undefined, { config, providers: registry, sandboxRegistry }); managers.push(m); return m; @@ -267,7 +267,7 @@ describe("round 1 of the #448 audit: wider than the daemon's sandbox is the owne it("a delegated token can't change the daemon's sandbox settings, and is audited", async () => { const m = newManager(); - for (const key of ["sandbox.provider", "sandbox.bwrapPath", "sandbox.docker.image", "sandbox.homeReadable"]) { + for (const key of ["sandbox.provider", "sandbox.bwrapPath", "sandbox.docker.image", "sandbox.homeReadable", "sandbox.worktreeRoots"]) { const r = await m.handle({ type: "settings.set", id: "s", patches: [{ key, value: "off" }] } as never, DELEGATED, { ...CLIENT, auth: DELEGATED }); expect(r).toMatchObject({ type: "response.error", code: "forbidden" }); expect((r as { error: string }).error).toContain("sandbox"); @@ -293,7 +293,7 @@ describe("routines (#448 round 2)", () => { sandboxRegistry.register(fake("seatbelt-like", { available: true })); const config = { ...mkConfig(tmp), - sandbox: { provider: "auto", network: "allowlist", allowedHosts: ["a.example"], hiddenPaths: [], homeReadable: [], docker: { image: "x" } }, + sandbox: { provider: "auto", network: "allowlist", allowedHosts: ["a.example"], hiddenPaths: [], homeReadable: [], worktreeRoots: [], docker: { image: "x" } }, routines: { enabled: true, tickMs: 600_000, maxConcurrentRuns: 2, minIntervalSeconds: 60, apiFiresPerHour: 30, missedGraceSeconds: 120 }, } as CodeoidConfig; const m = new SessionManager(store, transcript, undefined, undefined, undefined, { config, providers: registry, sandboxRegistry }); diff --git a/src/tests/sandbox-policy.test.ts b/src/tests/sandbox-policy.test.ts index 475b222a..91e40407 100644 --- a/src/tests/sandbox-policy.test.ts +++ b/src/tests/sandbox-policy.test.ts @@ -255,7 +255,7 @@ describe("hidden paths", () => { describe("config", () => { test("on by default: auto, network on", () => { const c = loadConfig({ raw: {}, env: {}, quiet: true }); - expect(c.sandbox).toEqual({ provider: "auto", network: "on", allowedHosts: [], hiddenPaths: [], homeReadable: [...DEFAULT_HOME_READABLE], docker: { image: "node:22-bookworm-slim" } }); + expect(c.sandbox).toEqual({ provider: "auto", network: "on", allowedHosts: [], hiddenPaths: [], homeReadable: [...DEFAULT_HOME_READABLE], worktreeRoots: [], docker: { image: "node:22-bookworm-slim" } }); }); test("env vars override the file", () => { diff --git a/src/tests/sandbox-seatbelt.test.ts b/src/tests/sandbox-seatbelt.test.ts index ba54c63e..5f76e2ce 100644 --- a/src/tests/sandbox-seatbelt.test.ts +++ b/src/tests/sandbox-seatbelt.test.ts @@ -92,6 +92,14 @@ describe("seatbeltProfile", () => { expect(text).toContain('"/Users/u/repo/opencode.json"'); }); + test("worktrees (#448 follow-up): worktrees/ and a worktree location writable but not renamable; another worktree's git dir and checkout read-only", () => { + const { text } = readable(spec({ writable: ["/Users/u/repo/.git", "/Users/u/repo/.git/worktrees", "/Users/u/repo-worktrees"], readOnly: ["/Users/u/repo/.git/worktrees/theirs", "/Users/u/repo-worktrees/theirs"] })); + for (const p of ["/Users/u/repo/.git/worktrees", "/Users/u/repo-worktrees"]) { + expect(text).toContain(`(deny file-write* (literal ${JSON.stringify(p)}))`); + } + for (const p of ["/Users/u/repo/.git/worktrees/theirs", "/Users/u/repo-worktrees/theirs"]) expect(text).toMatch(new RegExp(`\\(deny file-write\\* [^\\n]*${p.replace(/[/.]/g, "\\$&")}`)); + }); + test("a repository's hooks and config can't be written, with what is in them", () => { const { text } = readable(spec({ readOnly: ["/Users/u/repo/.git/hooks", "/Users/u/repo/.git/config"] })); expect(text).toContain('"/Users/u/repo/.git/hooks"'); diff --git a/src/tests/sandbox-worktrees-real.test.ts b/src/tests/sandbox-worktrees-real.test.ts new file mode 100644 index 00000000..9bedacc9 --- /dev/null +++ b/src/tests/sandbox-worktrees-real.test.ts @@ -0,0 +1,334 @@ +/** + * Real bubblewrap (#448 follow-up), skipped where it can't run: an agent + * makes git worktrees inside the sandbox. + * + * Everything destructive runs INSIDE the sandbox under test, against a + * repository this test planted in its own temp dir; "the user's next git" + * runs outside the session's sandbox, in a bubblewrap that may write only the + * test's dir, and must run nothing a command planted. + */ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; +import { BwrapSandboxProvider } from "../daemon/sandbox/bwrap.js"; +import { gitControlSnapshot } from "../daemon/sandbox/git-watch.js"; +import { buildSandboxSpec } from "../daemon/sandbox/policy.js"; +import type { SandboxHandle } from "../daemon/sandbox/types.js"; +import { noteCodeoidWorktree, worktreeAdminsOf } from "../daemon/sandbox/worktrees.js"; + +const provider = new BwrapSandboxProvider(); +const probe = await provider.probe(); + +/** Not under the host's /tmp, which the sandbox replaces with its own. */ +function scratchParent(): string { + for (const dir of [process.env.CODEOID_TEST_SANDBOX_DIR, "/var/tmp", join(homedir(), ".cache")]) { + if (!dir) continue; + try { + mkdirSync(dir, { recursive: true }); + const p = mkdtempSync(join(dir, "codeoid-wt-probe-")); + rmSync(p, { recursive: true, force: true }); + return dir; + } catch { + /* next */ + } + } + return tmpdir(); +} + +function gitEnv(home: string): Record { + return { + PATH: process.env.PATH ?? "/usr/bin:/bin", + HOME: home, + GIT_CONFIG_NOSYSTEM: "1", + GIT_AUTHOR_NAME: "t", + GIT_AUTHOR_EMAIL: "t@example.invalid", + GIT_COMMITTER_NAME: "t", + GIT_COMMITTER_EMAIL: "t@example.invalid", + }; +} + +function git(args: string[], cwd: string, home: string): string { + const r = Bun.spawnSync(["git", ...args], { cwd, env: gitEnv(home) }); + if (r.exitCode !== 0) throw new Error(`git ${args.join(" ")}: ${r.stderr.toString()}`); + return r.stdout.toString(); +} + +describe.skipIf(!probe.available || !Bun.which("git"))("bubblewrap, for real: an agent's own git worktrees", () => { + let root: string; + beforeAll(() => { + root = realpathSync(mkdtempSync(join(scratchParent(), "codeoid-sbx-wt-"))); + }); + afterAll(() => rmSync(root, { recursive: true, force: true })); + + const sh = (h: SandboxHandle, cwd: string, script: string, env: Record) => h.exec(["/bin/sh", "-c", script], { cwd, env, timeoutMs: 30_000 }); + + /** + * A repository with a commit, two worktrees of the user's (one beside it, + * one in its `-worktrees` location), and one whose checkout is gone (as + * after an unmounted disk: `git worktree prune` would remove it). The + * session's baseline is what is there now, as SessionSandbox takes it. + */ + function fixture(name: string, opts: { workspace?: "main" | "worktree" } = {}) { + const dir = join(root, name); + const home = join(dir, "home"); + const repo = join(dir, "repo"); + mkdirSync(home, { recursive: true }); + mkdirSync(repo, { recursive: true }); + git(["init", "-q", "-b", "main"], repo, home); + writeFileSync(join(repo, "a.txt"), "a\n"); + git(["add", "."], repo, home); + git(["commit", "-qm", "init"], repo, home); + const userWt = join(dir, "userwt"); + git(["worktree", "add", "-q", "-b", "user", userWt], repo, home); + const userLoc = join(dir, "repo-worktrees", "userloc"); + git(["worktree", "add", "-q", "-b", "userloc", userLoc], repo, home); + const gone = join(dir, "gone"); + git(["worktree", "add", "-q", "-b", "gone", gone], repo, home); + rmSync(gone, { recursive: true, force: true }); + let ws = repo; + if (opts.workspace === "worktree") { + ws = join(dir, "repo-worktrees", "session"); + git(["worktree", "add", "-q", "-b", "session", ws], repo, home); + } + const common = join(repo, ".git"); + const marker = join(dir, "RAN-OUTSIDE"); + const mark = join(dir, "mark.sh"); + writeFileSync(mark, `#!/bin/sh\necho ran >> '${marker}'\n`, { mode: 0o755 }); + const tmp = join(dir, "tmp"); + mkdirSync(tmp); + const baseline = new Set(worktreeAdminsOf(common).map((a) => a.dir)); + const spec = () => + buildSandboxSpec({ + sessionId: name, + workspace: ws, + tempDir: tmp, + network: { mode: "off" }, + hidden: { home, configDir: join(home, ".codeoid"), env: { HOME: home }, daemonTmp: "/tmp" }, + worktreeBaseline: baseline, + }); + return { dir, home, repo, ws, common, userWt, userLoc, gone, marker, mark, spec, env: gitEnv(home) }; + } + + /** The user's next git in `cwd`, outside the session's sandbox (it may write only the test's dir). */ + function userGit(f: { dir: string; home: string }, cwd: string): void { + const bwrap = provider.binary() as string; + Bun.spawnSync([bwrap, "--die-with-parent", "--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", "--tmpfs", "/tmp", "--bind", f.dir, f.dir, "--chdir", cwd, "--", "git", "status", "--short"], { env: gitEnv(f.home) }); + } + + test("git worktree add in the sibling -worktrees location, commit there, list, remove, add in .worktrees, prune: all from inside", async () => { + const f = fixture("add"); + const h = await provider.open(f.spec()); + try { + const add = await sh(h, f.repo, "git worktree add ../repo-worktrees/x -b feature/x && echo ADDED", f.env); + expect(add.stderr).not.toContain("Operation not permitted"); + expect(add.stdout).toContain("ADDED"); + const commit = await sh(h, join(f.dir, "repo-worktrees", "x"), "echo x > x.txt && git add x.txt && git commit -qm in-worktree && echo COMMITTED && git rebase -q main && echo REBASED", f.env); + expect(commit.stdout).toContain("COMMITTED"); + expect(commit.stdout).toContain("REBASED"); + const list = await sh(h, f.repo, "git worktree list --porcelain", f.env); + expect(list.stdout).toContain(join(f.dir, "repo-worktrees", "x")); + // A second one, in the repository's .worktrees, then both gone again. + const more = await sh(h, f.repo, "git worktree add -q .worktrees/y -b y && echo ADDED-Y && git worktree remove ../repo-worktrees/x && echo REMOVED-X && git worktree remove .worktrees/y && echo REMOVED-Y", f.env); + expect(more.stdout).toContain("ADDED-Y"); + expect(more.stdout).toContain("REMOVED-X"); + expect(more.stdout).toContain("REMOVED-Y"); + // prune runs; the user's admin dir whose checkout is gone (an unmounted disk) is not the session's to remove. + const prune = await sh(h, f.repo, "git worktree prune; echo PRUNED", f.env); + expect(prune.stdout).toContain("PRUNED"); + } finally { + await h.close(); + } + expect(git(["log", "--oneline", "feature/x"], f.repo, f.home)).toContain("in-worktree"); + expect(existsSync(join(f.dir, "repo-worktrees", "x"))).toBe(false); + expect(existsSync(join(f.common, "worktrees", "x"))).toBe(false); + expect(existsSync(join(f.common, "worktrees", "gone"))).toBe(true); + expect(git(["status", "--short"], f.userLoc, f.home)).toBe(""); + }); + + test("a worktree the session made stays its own in its next sandbox: committed in and removed there", async () => { + const f = fixture("own"); + const h1 = await provider.open(f.spec()); + try { + expect((await sh(h1, f.repo, "git worktree add -q ../repo-worktrees/mine -b mine && echo ADDED", f.env)).stdout).toContain("ADDED"); + } finally { + await h1.close(); + } + // The next sandbox (a backend restarted, the next query) is built with the same baseline. + const h2 = await provider.open(f.spec()); + try { + const r = await sh(h2, join(f.dir, "repo-worktrees", "mine"), "echo m > m.txt && git add m.txt && git commit -qm mine && echo COMMITTED && cd ../../repo && git worktree remove --force ../repo-worktrees/mine && echo REMOVED", f.env); + expect(r.stdout).toContain("COMMITTED"); + expect(r.stdout).toContain("REMOVED"); + } finally { + await h2.close(); + } + expect(git(["log", "--oneline", "mine"], f.repo, f.home)).toContain("mine"); + }); + + test("the user's worktrees can't be redirected, renamed, removed or pruned; worktrees/ can't be swapped; config and hooks stay read-only", async () => { + for (const workspace of ["main", "worktree"] as const) { + const f = fixture(`held-${workspace}`, { workspace }); + const wts = join(f.common, "worktrees"); + const h = await provider.open(f.spec()); + try { + const r = await sh( + h, + f.ws, + [ + `cp -r '${f.common}' "$PWD/.evil" && git --git-dir="$PWD/.evil" config core.fsmonitor '${f.mark}' && echo BUILT`, + // An existing worktree's admin files: what decides where git looks. + `echo "$PWD/.evil" > '${join(wts, "userwt", "commondir")}' 2>/dev/null && echo REWROTE-COMMONDIR || echo COMMONDIR-HELD`, + `echo x > '${join(wts, "userwt", "gitdir")}' 2>/dev/null && echo REWROTE-GITDIR || echo GITDIR-HELD`, + `echo '[core]' > '${join(wts, "userloc", "config.worktree")}' 2>/dev/null && echo WROTE-CONFIG-WORKTREE || echo CONFIG-WORKTREE-HELD`, + // Renamed away, then planted again under its name. + `mv '${join(wts, "userwt")}' '${join(wts, "moved")}' 2>/dev/null && echo MOVED-ADMIN || echo ADMIN-PINNED`, + `rm -rf '${join(wts, "userwt")}' 2>/dev/null; [ -f '${join(wts, "userwt", "commondir")}' ] && echo ADMIN-KEPT || echo ADMIN-REMOVED`, + `mv '${wts}' '${join(f.common, "wt-old")}' 2>/dev/null && echo MOVED-WORKTREES || echo WORKTREES-PINNED`, + // The checkout in a worktree location the session didn't make: not removed, not redirected. + `git worktree remove --force '${f.userLoc}' 2>/dev/null && echo REMOVED-USERLOC || echo USERLOC-NOT-REMOVED`, + `echo "gitdir: $PWD/.evil" > '${join(f.userLoc, ".git")}' 2>/dev/null && echo REDIRECTED-USERLOC || echo USERLOC-DOTGIT-HELD`, + `echo hi > '${join(f.userLoc, "new.txt")}' 2>/dev/null && echo WROTE-USERLOC || echo USERLOC-READ-ONLY`, + // The checkout outside every location: its .git file isn't the session's either. + `echo "gitdir: $PWD/.evil" > '${join(f.userWt, ".git")}' 2>/dev/null && echo REDIRECTED-USERWT || echo USERWT-HELD`, + // A prunable worktree of the user's (its disk unmounted) stays. + "git worktree prune 2>/dev/null; echo PRUNE-RAN", + `[ -d '${join(wts, "gone")}' ] && echo GONE-KEPT || echo GONE-PRUNED`, + // The repository's own structure, as before. + `echo bad >> '${join(f.common, "config")}' 2>/dev/null && echo WROTE-CONFIG || echo CONFIG-HELD`, + `echo bad > '${join(f.common, "hooks", "post-checkout")}' 2>/dev/null && echo WROTE-HOOK || echo HOOKS-HELD`, + `echo bad > '${join(f.common, "info", "attributes")}' 2>/dev/null && echo WROTE-INFO || echo INFO-HELD`, + ].join("; "), + f.env, + ); + expect(r.stdout).toContain("BUILT"); + for (const held of ["COMMONDIR-HELD", "GITDIR-HELD", "CONFIG-WORKTREE-HELD", "ADMIN-PINNED", "ADMIN-KEPT", "WORKTREES-PINNED", "USERLOC-NOT-REMOVED", "USERLOC-DOTGIT-HELD", "USERLOC-READ-ONLY", "USERWT-HELD", "PRUNE-RAN", "GONE-KEPT", "CONFIG-HELD", "HOOKS-HELD", "INFO-HELD"]) { + expect(r.stdout).toContain(held); + } + // Still: a worktree of its own, and a commit where it works. + const own = await sh(h, f.ws, "git worktree add -q ../repo-worktrees/agent -b agent 2>&1 && echo ADDED", f.env); + expect(own.stdout).toContain("ADDED"); + writeFileSync(join(f.ws, "w.txt"), "w\n"); + expect((await sh(h, f.ws, "git add w.txt && git commit -qm inside && echo COMMITTED", f.env)).stdout).toContain("COMMITTED"); + } finally { + await h.close(); + } + expect(readFileSync(join(f.userLoc, "a.txt"), "utf8")).toBe("a\n"); + for (const at of [f.ws, f.userWt, f.userLoc, f.repo]) userGit(f, at); + expect(existsSync(f.marker)).toBe(false); + } + }); + + test("planting an admin dir: a new one is the session's own, never one a checkout of the user's names", async () => { + const f = fixture("plant"); + const wts = join(f.common, "worktrees"); + const h = await provider.open(f.spec()); + try { + // The user's checkout names worktrees/userwt: held, so nothing can be laid in its place. + const r = await sh( + h, + f.repo, + `cp -r '${f.common}' "$PWD/.evil" && git --git-dir="$PWD/.evil" config core.fsmonitor '${f.mark}'; mkdir -p '${join(wts, "userwt", "x")}' 2>/dev/null && echo MADE-IN-USERS || echo USERS-HELD; echo "$PWD/.evil" > '${join(wts, "userwt", "commondir.new")}' 2>/dev/null && echo WROTE-BESIDE || echo USERS-DIR-HELD`, + f.env, + ); + expect(r.stdout).toContain("USERS-HELD"); + expect(r.stdout).toContain("USERS-DIR-HELD"); + } finally { + await h.close(); + } + userGit(f, f.userWt); + expect(existsSync(f.marker)).toBe(false); + }); + + test("a worktree added by someone else after the session started is held in the session's next sandbox (outside the locations, or made by codeoid)", async () => { + const f = fixture("late"); + const late = join(f.dir, "late"); + git(["worktree", "add", "-q", "-b", "late", late], f.repo, f.home); + const fork = join(f.dir, "repo-worktrees", "fork"); + noteCodeoidWorktree(fork); + git(["worktree", "add", "-q", "-b", "fork", fork], f.repo, f.home); + const h = await provider.open(f.spec()); + try { + const r = await sh( + h, + f.repo, + `echo x > '${join(f.common, "worktrees", "late", "commondir")}' 2>/dev/null && echo REWROTE-LATE || echo LATE-HELD; echo x > '${join(f.common, "worktrees", "fork", "commondir")}' 2>/dev/null && echo REWROTE-FORK || echo FORK-HELD; echo x > '${join(fork, "f.txt")}' 2>/dev/null && echo WROTE-FORK || echo FORK-CHECKOUT-HELD`, + f.env, + ); + expect(r.stdout).toContain("LATE-HELD"); + expect(r.stdout).toContain("FORK-HELD"); + expect(r.stdout).toContain("FORK-CHECKOUT-HELD"); + } finally { + await h.close(); + } + }); + + test("the end-of-turn warning: a worktree git made reads as nothing changed; one naming another repository is named", async () => { + const f = fixture("watch"); + const before = gitControlSnapshot(f.repo, f.home); + const h = await provider.open(f.spec()); + try { + await sh(h, f.repo, "git worktree add -q ../repo-worktrees/w -b w", f.env); + } finally { + await h.close(); + } + const after = gitControlSnapshot(f.repo, f.home); + expect([...after.keys()].filter((k) => !before.has(k))).toEqual([]); + writeFileSync(join(f.common, "worktrees", "w", "commondir"), "/elsewhere\n"); + expect([...gitControlSnapshot(f.repo, f.home).keys()].some((k) => k.includes("worktrees/w/commondir"))).toBe(true); + }); + + test("a location planted as a link (.worktrees in the main checkout, made by a command) leads no later session anywhere", async () => { + const f = fixture("link"); + const elsewhere = join(f.dir, "elsewhere"); + mkdirSync(elsewhere); + writeFileSync(join(elsewhere, "keep.txt"), "keep\n"); + // A session in the main checkout: its workspace, so it may make `.worktrees` a link. + const h = await provider.open(f.spec()); + try { + expect((await sh(h, f.repo, `ln -s '${elsewhere}' .worktrees && echo LINKED`, f.env)).stdout).toContain("LINKED"); + } finally { + await h.close(); + } + // A later session in a linked worktree of the repository: `.worktrees` would be a location of its own. + const ws = join(f.dir, "repo-worktrees", "later"); + git(["worktree", "add", "-q", "-b", "later", ws], f.repo, f.home); + const spec = buildSandboxSpec({ sessionId: "later", workspace: ws, tempDir: join(f.dir, "tmp"), network: { mode: "off" }, hidden: { home: f.home, configDir: join(f.home, ".codeoid"), env: { HOME: f.home }, daemonTmp: "/tmp" } }); + expect(spec.worktreeLocations ?? []).not.toContain(elsewhere); + expect(spec.writable).not.toContain(elsewhere); + const h2 = await provider.open(spec); + try { + const r = await sh(h2, ws, `echo x > '${join(elsewhere, "keep.txt")}' 2>/dev/null && echo WROTE || echo ELSEWHERE-READ-ONLY`, f.env); + expect(r.stdout).toContain("ELSEWHERE-READ-ONLY"); + } finally { + await h2.close(); + } + expect(readFileSync(join(elsewhere, "keep.txt"), "utf8")).toBe("keep\n"); + }); + + test("sandbox.worktreeRoots: a configured location is writable; one holding the repository, or the home, is not", async () => { + const f = fixture("roots"); + const extra = join(f.dir, "elsewhere", "wts"); + const config = { sandbox: { worktreeRoots: [extra, "..", "~"] } } as never; + const spec = buildSandboxSpec({ + sessionId: "roots", + workspace: f.repo, + tempDir: join(f.dir, "tmp"), + network: { mode: "off" }, + config, + hidden: { home: f.home, configDir: join(f.home, ".codeoid"), env: { HOME: f.home }, daemonTmp: "/tmp" }, + worktreeBaseline: new Set(worktreeAdminsOf(f.common).map((a) => a.dir)), + }); + expect(spec.worktreeLocations).toContain(realpathSync(extra)); + expect(spec.worktreeLocations).not.toContain(f.dir); + expect(spec.worktreeLocations).not.toContain(f.home); + const h = await provider.open(spec); + try { + const r = await sh(h, f.repo, `git worktree add -q '${join(extra, "e")}' -b e && echo ADDED; echo x > '${join(f.dir, "beside.txt")}' 2>/dev/null && echo WROTE-PARENT || echo PARENT-READ-ONLY`, f.env); + expect(r.stdout).toContain("ADDED"); + expect(r.stdout).toContain("PARENT-READ-ONLY"); + } finally { + await h.close(); + } + }); +}); diff --git a/src/tests/settings.test.ts b/src/tests/settings.test.ts index a9bf7c4e..4bde27b3 100644 --- a/src/tests/settings.test.ts +++ b/src/tests/settings.test.ts @@ -129,6 +129,18 @@ describe("settings store", () => { expect(onDisk.session.turnStallTimeoutMs).toBe(200_000); }); + it("the Sandbox tab's worktree locations (#448 follow-up): default, written, read back, and loaded", () => { + expect(getSnapshot().values["sandbox.worktreeRoots"]?.value ?? []).toEqual([]); + const r = applyPatches([{ key: "sandbox.worktreeRoots", value: ["~/wt", "../shared-worktrees"] }]); + expect(r).toMatchObject({ ok: true, errors: [] }); + expect(getSnapshot().values["sandbox.worktreeRoots"]).toEqual({ value: ["~/wt", "../shared-worktrees"], source: "config" }); + const onDisk = JSON.parse(readFileSync(configFilePaths().configPath, "utf8")); + expect(onDisk.sandbox.worktreeRoots).toEqual(["~/wt", "../shared-worktrees"]); + expect(loadConfig({ raw: onDisk, env: {}, quiet: true }).sandbox?.worktreeRoots).toEqual(["~/wt", "../shared-worktrees"]); + // The env var overrides. + expect(loadConfig({ raw: {}, env: { CODEOID_SANDBOX_WORKTREE_ROOTS: "/a,/b" }, quiet: true }).sandbox?.worktreeRoots).toEqual(["/a", "/b"]); + }); + it("rejects an out-of-range value and writes NOTHING", () => { applyPatches([{ key: "memory.enabled", value: false }]); // establish a file const before = readFileSync(configFilePaths().configPath, "utf8"); From d7e80b5579c9406aed827613d08c5086455768af Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Sat, 7 Nov 2026 00:41:53 +0800 Subject: [PATCH 2/9] feat: Docker access, an opt-in sandbox option for tests that start containers (#448) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 … 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 --- CLAUDE.md | 13 +- docs/CONFIGURATION.md | 2 + docs/sandbox-providers.md | 2 + docs/sandbox.md | 142 +++++++++++++++--- packages/core/src/sandbox.test.ts | 23 ++- packages/core/src/sandbox.ts | 25 ++- packages/core/src/slash.ts | 9 +- packages/protocol/src/approvals.ts | 2 + packages/protocol/src/schemas.ts | 1 + packages/protocol/src/types.ts | 10 ++ src/cli.ts | 18 ++- src/config.ts | 7 +- src/daemon/sandbox/bwrap.ts | 10 ++ src/daemon/sandbox/claude-native.ts | 7 +- src/daemon/sandbox/docker.ts | 36 ++++- src/daemon/sandbox/git-shim.ts | 6 +- src/daemon/sandbox/policy.ts | 65 +++++++- src/daemon/sandbox/seatbelt.ts | 12 +- src/daemon/sandbox/session-sandbox.ts | 55 +++++-- src/daemon/sandbox/types.ts | 8 + src/daemon/session-manager.ts | 13 +- src/daemon/session.ts | 18 ++- src/daemon/settings/manifest.ts | 6 + src/terminal/client.ts | 6 +- src/terminal/routine-format.ts | 5 +- src/tests/fixtures/claude-sandbox-probe.ts | 5 +- src/tests/sandbox-claude-real.test.ts | 21 +++ src/tests/sandbox-docker.test.ts | 15 ++ src/tests/sandbox-git-shim.test.ts | 4 + src/tests/sandbox-home.test.ts | 15 ++ src/tests/sandbox-manager.test.ts | 86 ++++++++++- src/tests/sandbox-policy.test.ts | 26 +++- src/tests/sandbox-seatbelt.test.ts | 13 ++ src/tests/sandbox-session.test.ts | 30 +++- src/tests/sandbox-worktrees-real.test.ts | 77 +++++++++- src/tests/settings.test.ts | 26 +++- web/src/components/NewSessionModal.test.tsx | 17 +++ web/src/components/NewSessionModal.tsx | 27 +++- web/src/components/SandboxChip.tsx | 2 +- .../transcript/ApprovalBar.test.tsx | 16 +- web/src/components/transcript/ApprovalBar.tsx | 20 ++- web/src/components/transcript/MessageRow.tsx | 2 +- 42 files changed, 797 insertions(+), 106 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index fddcc04b..aa271c81 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -214,8 +214,8 @@ codeoid interrupt codeoid approve [--deny] [--pick N | --id ] [--yes] codeoid account ls|login |add-key |add-gateway|add-token|add-bedrock|default |use |--follow-default|rm [--backend ] # accounts for every backend (docs/backend-accounts-design.md) codeoid destroy -codeoid new --sandbox auto|bwrap|seatbelt|docker|off [--network on|off|allowlist] [--allow-host h] # the session's OS sandbox (#448, docs/sandbox.md) -codeoid sandbox providers | set [--network …] # what runs here; change a session's (widening asks its owner) +codeoid new --sandbox auto|bwrap|seatbelt|docker|off [--network on|off|allowlist] [--allow-host h] [--docker-socket] # the session's OS sandbox (#448, docs/sandbox.md) +codeoid sandbox providers | set [provider] [--network …] [--[no-]docker-socket] # what runs here; change a session's (widening asks its owner) codeoid mcp config [--scopes observe|operate|full] # MCP client entry for /mcp/control codeoid mcp headers [--scopes …] # fresh bearer header (an MCP headersHelper) codeoid mcp reconnect # redial a backend MCP server that's down (#389) @@ -398,7 +398,13 @@ are writable, so a command can `git worktree add/remove/prune`; every worktree that isn't the session's own is held whole (its admin dir, its `.git` file, its checkout when in a location). Own = appeared since `SessionSandbox`'s first look (`worktreeBaseline`), checked out in the workspace or a location, -not codeoid's (`noteCodeoidWorktree` in git-worktree.ts). +not codeoid's (`noteCodeoidWorktree` in git-worktree.ts). Docker access +(`sandbox.docker.socket`, a session's `dockerSocket`, off by default): +`dockerSocketPaths` → `SandboxSpec.dockerSockets` + `DOCKER_HOST`, a widening +in `widens` (so create/fork/routine/loop/set_sandbox authority follows), +autonomous owner-only; every card `sandboxDocker` ("sandboxed, Docker access"), +the chip amber (`DOCKER_ACCESS_WHY`); Claude native needs `allowAllUnixSockets` +on Linux (its seccomp blocks unix sockets otherwise). Shell commands are approved by risk (#431, docs/approvals.md): `src/daemon/shell/` parses a command line with tree-sitter-bash (WASM, vendored `tree-sitter-bash.wasm`; @@ -448,6 +454,7 @@ CODEOID_ROUTINES_ENABLED=true # scheduler + routine commands (docs/routine CODEOID_DEFAULT_EFFORT= # thinking effort for new sessions: off|minimal|low|medium|high|xhigh|max (docs/thinking-effort.md) CODEOID_SANDBOX=auto # OS sandbox for agents' commands: auto|bwrap|seatbelt|docker|off (docs/sandbox.md) CODEOID_SANDBOX_NETWORK=on # on|off|allowlist (CODEOID_SANDBOX_ALLOWED_HOSTS); hiddenPaths: CODEOID_SANDBOX_HIDDEN_PATHS +CODEOID_SANDBOX_DOCKER_SOCKET=false # let sandboxed commands reach the container runtime (root-equivalent; docs/sandbox.md) CODEOID_SANDBOX_WORKTREE_ROOTS= # more places a sandboxed command may check a worktree out (csv) # Claude auth: uses your existing `claude login` session (Pro/Max subscription). diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index c40ce906..a096459e 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -113,6 +113,7 @@ CODEOID_SANDBOX_ALLOWED_HOSTS= # with allowlist: github.com,*.npmjs.org CODEOID_SANDBOX_HIDDEN_PATHS= # more paths to hide: ~/work/.env CODEOID_SANDBOX_HOME_READABLE= # replaces the home allowlist: ~/.bun,~/.cargo/bin,~/.gitconfig CODEOID_SANDBOX_DOCKER_IMAGE=node:22-bookworm-slim +CODEOID_SANDBOX_DOCKER_SOCKET=false # Docker access: commands reach the container runtime (root-equivalent) CODEOID_SANDBOX_WORKTREE_ROOTS= # more places a command may check a worktree out: ~/wt,../shared-worktrees # Forks, backend switches and rewinds (docs/FEATURES.md#how-a-fork-switch-or-rewind-carries-the-conversation) @@ -250,6 +251,7 @@ Agents' commands run in an OS sandbox, on by default ([sandbox.md](sandbox.md)): - Every `sandbox.*` change through `settings.set` needs an undelegated token, and is audited old → new. - `bwrapPath` — the bubblewrap binary (default: on `PATH`). Claude Code's own sandbox on Linux also needs `socat`. - `docker.image` (`CODEOID_SANDBOX_DOCKER_IMAGE`) and `docker.runtime` — what a Docker sandbox runs, and `runsc` for gVisor. +- `docker.socket` (`CODEOID_SANDBOX_DOCKER_SOCKET`, default `false`) — Docker access: sandboxed commands can reach the host's container runtime socket (Docker, rootless Docker, Podman; Docker Desktop on macOS), with `DOCKER_HOST` set, for tests that start containers. Root-equivalent (a container can mount `/`), so every session with it says "sandboxed, Docker access"; a session turns it on for itself with `--docker-socket` / `/sandbox … docker`, asked of its owner ([sandbox.md](sandbox.md#docker-access)). - `worktreeRoots` (`CODEOID_SANDBOX_WORKTREE_ROOTS`, comma separated) — more places a sandboxed command may check a git worktree of its repository out, on top of `-worktrees/` and `/.worktrees/` (`~` expands; a relative path is the repository's main checkout's). Writable to sandboxed commands ([sandbox.md](sandbox.md#git-worktrees-inside-the-sandbox)). - Where the sandbox can't run, interactive sessions run unsandboxed and say so on every approval; autonomous sessions, routines and loops refuse to start. `codeoid sandbox providers` shows what runs here. diff --git a/docs/sandbox-providers.md b/docs/sandbox-providers.md index 7bf558a1..2bc7ace4 100644 --- a/docs/sandbox-providers.md +++ b/docs/sandbox-providers.md @@ -48,6 +48,8 @@ interface SandboxSpec { env?: Record; // set inside: the toolchains' caches in the sandbox's cache dir pinned?: string[]; // can't be renamed or removed, contents still writable (a dir between the workspace and a hidden path) containerSockets: string[]; // never reachable + dockerSockets?: string[]; // the owner's opt-in (Docker access): reachable at their own paths, DOCKER_HOST (in env) the first; + // a provider that can't show one must leave it out, never widen past it network: { mode: "on" | "off" | "allowlist"; allow?: string[] }; unsetEnv: string[]; // removed inside (agent and desktop sockets) } diff --git a/docs/sandbox.md b/docs/sandbox.md index 0605a383..182028ce 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -5,8 +5,8 @@ runs inside an operating-system sandbox. The workspace and a private temp directory are writable (and, so an agent can branch in a worktree of its own, the repository's [worktree locations](#git-worktrees-inside-the-sandbox)); everything else is read-only; credential stores are not there at all; -container runtime sockets can't be reached; the network is a per-session -setting. +container runtime sockets can't be reached unless the owner turns on +[Docker access](#docker-access); the network is a per-session setting. The approval gate's risk levels (#431, [approvals.md](approvals.md)) read a command's text. They are guidance: three audit rounds showed that reading bash @@ -76,7 +76,7 @@ With the default policy (workspace-write): | **Project settings** (read-only) | `.claude/`, `.gemini/`, `.qwen/`, `.codex/`, `.pi/`, `.opencode/` in the workspace (and, for a session in a monorepo's package, in every dir from the package up to the repository's top: where the CLIs look), whole (made empty first where missing: git doesn't see an empty dir; not in a workspace that is your home or holds it, a temp root or `/`, where they would be the CLIs' own config dirs: there only those that exist are held), and `.mcp.json`, `opencode.json`, `opencode.jsonc` at its top, made first where missing (in the same places) holding an empty config (`{"mcpServers":{}}`, `{}`: what the CLIs read as none; your own `opencode` loads a project `opencode.json`'s MCP servers and plugins at its start) and, in a git repository, listed in its `.git/info/exclude` so `git status` and `git add -A` don't see them (an existing file is held as it is). A Claude hook, an MCP server, a pi or opencode plugin written there by one backend's command would run on the host in a later session of another, or in your own CLI. Changing them needs the sandbox lifted for that command: Claude's `dangerouslyDisableSandbox` and Codex's permission request are asked about in guarded and interactive mode and refused in autonomous mode (below); on other backends edit them yourself, or turn the session's sandbox off. A file tool that runs in the backend's own process, outside the sandbox (Claude's Write, Edit, MultiEdit and NotebookEdit, opencode's write, edit and patch) is held to the same rules at codeoid's gate in autonomous mode ([below](#file-tools-that-run-outside-the-sandbox)): writing what the sandbox holds read-only is refused there, and asked about in guarded and interactive mode. Writing the rest of **the repository's git dir** (its objects and refs, which a sandboxed commit writes, a linked worktree's, the `.git` file, another checkout's `.git` file in the workspace), or a file with more than one hard link, is asked about in guarded and interactive mode while the sandbox is on, the card saying why (audited `session.git_structure_write_ask` / `session.project_settings_write_ask`), and refused at once in autonomous mode (audited `session.autonomous_refuse`, kind `held_write`); on Claude the PreToolUse hook routes it to the gate past any allow rule. A backend whose file tools run inside the sandbox (Codex, pi, the Gemini CLI, Qwen; Claude under Docker) is held by the sandbox itself: nothing of this applies to it, a hard-linked file in `node_modules` included. | | **Hidden besides** (not readable at all) | outside the home, or in a workspace that holds it: codeoid's config dir (`~/.codeoid`: keys, accounts, endpoints, the local token) and every data dir it owns wherever it is configured (the database, transcripts and checkpoints, opencode's database and data dirs, memory's; their lock files); the agent CLIs' logins (`~/.claude/.credentials.json`, `~/.codex/auth.json`, `~/.qwen/oauth_creds.json`, `~/.gemini/oauth_creds.json` …, pi's `auth.json`) and what backends registered as credentials (opencode's key files); `~/.ssh`, `~/.aws`, `~/.azure`, `~/.config/gcloud`, `~/.kube`, `~/.docker`, `~/.gnupg`, `~/.config/gh`, `~/.config/hub`, `~/.config/glab-cli`, `~/.netrc`, `~/.git-credentials`, `~/.config/git/credentials`, `~/.local/share/keyrings`, `~/.password-store`, `~/.config/op`, `~/.vault-token`, `~/.pypirc`, `~/.cargo/credentials(.toml)`, `~/.terraform.d/credentials.tfrc.json`, `~/.claude.json` (MCP tokens), `~/.local/share/opencode/auth.json` and `mcp-auth.json`, `~/.config/github-copilot`, `~/.pgpass`, `~/.config/rclone`, `~/.s3cfg`, `~/.boto`, `~/.pulumi/credentials.json`, `~/.config/doctl`, `~/.fly`, `~/.npmrc`, `~/.bunfig.toml`, `~/.yarnrc.yml`, `~/.gradle/gradle.properties`, `~/.m2/settings.xml`, `~/Library/Keychains`; the desktop session's runtime dir (`$XDG_RUNTIME_DIR`: keyring, D-Bus, agent sockets) and `$SSH_AUTH_SOCK`'s dir; the daemon's own temp dir when it isn't `/tmp`, and always the daemon's private dir in it (`codeoid-sandbox-*`, made at its start, 0700), where it makes every temp entry of its own: sign-in homes, a check's artifacts, a loop's index, pi's bridge, the sessions' temp dirs and launchers — so one made while a session runs is hidden from it too, on Claude's own sandbox (which keeps the host's `/tmp`) as well; plus `sandbox.hiddenPaths` (which also beats the allowlist). | | **The daemon's own git** | every git codeoid runs in a repository an agent can write (a fork's worktree made and removed, a destroyed session's work snapshotted, a worktree's setup, the branch and status the UI shows, loops) runs nothing that repository's config names, sandbox or not: no hook (the hooks dir's or one defined in config, `hook..command`), fsmonitor, alternates' ref command (`core.alternateRefsCommand`), external diff, signing or signature check (gpg's and ssh's programs, `gpg.ssh.defaultKeyCommand`), editor or pager, git-protocol proxy or auto gc, `core.sshCommand` is `ssh`, and every filter, diff or merge driver and credential helper defined in the repository's own config (local or worktree, includes too) is reset, your own global or system definition put back (git lfs installed globally keeps working; a repository redefining it doesn't). Reaching a remote (a base branch fetched for a fork, a partial clone's objects) is https or ssh only. Loops' repositories are stricter still: every driver off, no system config. Checkpoints run in codeoid's own shadow repository, whose config is codeoid's. | -| **Never reachable** | container runtime sockets: `/run/docker.sock`, `/var/run/docker.sock`, containerd, podman, CRI-O, buildkit, k3s, the user's rootless sockets, Docker Desktop / Colima / Lima / Rancher / OrbStack, `$DOCKER_HOST`. A container socket is root on the machine. | +| **Never reachable** | container runtime sockets: `/run/docker.sock`, `/var/run/docker.sock`, containerd, podman, CRI-O, buildkit, k3s, the user's rootless sockets, Docker Desktop / Colima / Lima / Rancher / OrbStack, `$DOCKER_HOST`. A container socket is root on the machine. The one exception is the owner's: [Docker access](#docker-access), off by default. | | **Toolchain caches** | the home is read-only, so the caches are pointed at the sandbox's own cache dir, `${XDG_CACHE_HOME:-~/.cache}/codeoid-sandbox` (one per user, writable inside, read by none of your own tools): `XDG_CACHE_HOME` (pip, uv, Go's build cache and what else follows XDG), `GOCACHE`, `GOMODCACHE`, `BUN_INSTALL_CACHE_DIR`, `npm_config_cache`, `UV_CACHE_DIR`, `PIP_CACHE_DIR`, `CARGO_HOME` (cargo's registry cache; your `~/.cargo/config.toml` is still read from above a workspace in your home, not from `CARGO_HOME`). Go modules you already downloaded are read from your own module cache first, as a file proxy (`GOPROXY=file://~/go/pkg/mod/cache/download,`), so `go build` works with the network off. `go build`/`go test`, `bun install`, `npm install`, `uv` and `pip` work inside. The sandboxed sessions share this dir: what one downloads or builds, another reuses (and could have tampered with: a build cache is trust among your sandboxed sessions). A cache is code to the toolchain that reads it (`CARGO_HOME`'s `config.toml` and `bin/`, a module in `GOMODCACHE`, an install in npx's cache), so **these variables are set only for what runs inside a sandbox**, never for anything outside one: on Claude's own sandbox they aren't in the CLI's environment (nor, so, its MCP servers', its hooks', or a command you let run outside the sandbox); the CLI sources a file of codeoid's (`CLAUDE_ENV_FILE`, in the CLI's own temp dir) into each command, which sets them only where the CLI marks the command as sandboxed (`SANDBOX_RUNTIME=1`). | | **Environment** | `SSH_AUTH_SOCK`, `SSH_AGENT_PID`, `GPG_AGENT_INFO`, `DBUS_SESSION_BUS_ADDRESS`, `DOCKER_HOST`, `CONTAINER_HOST`, `KUBECONFIG` are unset inside. On Claude, every credential-named variable the CLI runs with is denied to its commands. | | **Network** | `on` (default), `off`, or `allowlist` (hosts in `sandbox.allowedHosts`), per session and in config. | @@ -128,7 +128,9 @@ with `sandbox.allowedHosts` (`CODEOID_SANDBOX_ALLOWED_HOSTS`, `example.com`, `sandbox.hiddenPaths` (`CODEOID_SANDBOX_HIDDEN_PATHS`) adds paths to hide (`~` expands). `sandbox.worktreeRoots` (`CODEOID_SANDBOX_WORKTREE_ROOTS`, Settings > Sandbox > More worktree locations) adds places a command may check a worktree -out ([below](#git-worktrees-inside-the-sandbox)). `sandbox.homeReadable` (`CODEOID_SANDBOX_HOME_READABLE`, comma +out ([below](#git-worktrees-inside-the-sandbox)). `sandbox.docker.socket` +(`CODEOID_SANDBOX_DOCKER_SOCKET`, Settings > Sandbox > Docker access, default +`false`) gives every new session [Docker access](#docker-access). `sandbox.homeReadable` (`CODEOID_SANDBOX_HOME_READABLE`, comma separated) replaces the home allowlist: add what your tools need that holds no secret (`~/.volta`, `~/.sdkman/candidates`, `~/.config/starship.toml`), never a file with a token. `sandbox.bwrapPath` picks the bwrap binary. @@ -139,19 +141,22 @@ needs `settings:write` from an undelegated token, and is audited old → new Per session: -- at creation: `codeoid new --sandbox bwrap|seatbelt|docker|auto|off [--network on|off|allowlist] [--allow-host h …]`, - the web UI's new-session dialog ("Sandbox: the daemon's default. change"), or - `session.create`'s `sandbox: { provider?, network?, allowedHosts? }`; -- later, by the session's owner: `codeoid sandbox set [--network …]`, - `/sandbox [on|off|allow:host,…]`, or `session.set_sandbox`. +- at creation: `codeoid new --sandbox bwrap|seatbelt|docker|auto|off [--network on|off|allowlist] [--allow-host h …] [--docker-socket]`, + the web UI's new-session dialog ("Sandbox: the daemon's default. change", + with a "Docker access" box), or `session.create`'s + `sandbox: { provider?, network?, allowedHosts?, dockerSocket? }`; +- later, by the session's owner: `codeoid sandbox set [provider] [--network …] [--docker-socket | --no-docker-socket]`, + `/sandbox [on|off|allow:host,…] [docker|nodocker]` (`docker` + after a provider is Docker access; alone it is the provider), or + `session.set_sandbox`. A change is made only while the session is idle: no turn running, no approval or dialog waiting, no background work (a dev server, a subagent). Otherwise it is refused, saying why ("the session is busy", "has background work running") — what runs must match what the session reports, and a backend running in the old sandbox is restarted to take the new one, never in the middle of work. Narrowing (on, the network off) is applied without - asking. **Widening — turning it off, a wider network, another provider — is - a confirm shown on the session, answered only by its owner with their own + asking. **Widening — turning it off, a wider network, another provider, + Docker access — is a confirm shown on the session, answered only by its owner with their own token (another member's or a delegated token's answer is refused, audited `session.ui_response_refused`), in every mode, and audited** (`session.sandbox_widen_ask`, `…_denied`, `session.set_sandbox` naming who @@ -165,16 +170,19 @@ Per session: starting a loop) whose sandbox is off or wider than the daemon's default — `off`, a wider network, another provider, hosts the default doesn't allow — needs `session:approve` and an undelegated token (audited - `session.sandbox_wider_refused` when refused). A fork or a compare of a + `session.sandbox_wider_refused` when refused). Docker access counts as + wider whenever the daemon's default doesn't give it. A fork or a compare of a session with such a sandbox is made only by its owner, undelegated (anyone in the tenant may fork one whose sandbox is the daemon's or narrower). A - session whose sandbox is off is switched to autonomous only by its owner's - own token. A routine whose runs' sandbox is off or wider than the daemon's + session whose sandbox is off, or has Docker access, is switched to + autonomous only by its owner's own token. A routine whose runs' sandbox is off or wider than the daemon's (the same rule) needs `session:approve` (routine changes are refused to delegated tokens). A provider named explicitly that can't run here is refused (choose another, or `off`); -- routines (`codeoid routine add … --sandbox …`, the spec's `sandbox`) and loops - (`codeoid loop start … --sandbox …`, `loop.start`'s `sandbox`) take one too. +- routines (`codeoid routine add … --sandbox … [--docker-socket]`, the spec's + `sandbox`) and loops (`codeoid loop start … --sandbox … [--docker-socket]`, + `loop.start`'s `sandbox`) take one too; a loop's acceptance checks get + Docker access when its sandbox has it. - A fork, and a collaboration's children, keep their parent's setting (a collaboration's lead is created by the same request, under the rule above). @@ -676,6 +684,85 @@ Limits (defence in depth, as the rest of git's structure): either). On Seatbelt `worktrees/` and each location are kept from being renamed by a `literal` write denial (not verified on a Mac: see below). +## Docker access + +Tests that start containers (testcontainers' MySQL or Postgres, `docker run` +in a script) need the container runtime's socket, which the sandbox hides on +purpose: **a container socket is root on the machine** (a command can start a +container that mounts `/` and read or change anything, your credentials +included). So it is an **opt-in, off by default**: + +- daemon-wide: `sandbox.docker.socket` (`CODEOID_SANDBOX_DOCKER_SOCKET=true`, + Settings > Sandbox > Docker access), which needs `settings:write` from an + undelegated token, as every `sandbox.*` setting; +- per session: the setting's `dockerSocket: true` — `codeoid new … --docker-socket`, + the new-session dialog's "Docker access" box, `/sandbox … docker`, + `codeoid sandbox set … --docker-socket` (`--no-docker-socket`, + `/sandbox … nodocker` take it away); routines and loops `--docker-socket`. + +Turning it on is a **widening**, under the same rules as turning the sandbox +off or widening the network: on a session, a confirm only its owner answers +with their own token, in every mode, audited (`session.sandbox_widen_ask` … +`session.set_sandbox`, "→ auto, network on, Docker access"); at create, fork, +compare, routine or loop start it needs `session:approve` and an undelegated +token when the daemon's default doesn't give it; a delegated token can't ask +for it. Turning it off is applied at once. A session with Docker access is +switched to autonomous only by its owner's own token; autonomous mode, +routines and loops then run on it (the owner chose it, as with `off`). + +The session says so plainly: its chip reads **"sandboxed, Docker access"** +(amber; the tooltip: "Docker access can reach the whole machine (a container +can mount /)"), `codeoid ls` "sandbox: bubblewrap, network on, Docker access +(can reach the whole machine)", the sandbox's limits name the socket, and +**every approval card** reads "⚠️ sandboxed, Docker access: …" (a file tool's +card adds "(the sandbox has Docker access)"). + +What a command gets: + +- **The host's runtime sockets that exist**, each at its own path: `$DOCKER_HOST`'s + when it is a unix socket, Docker's `/var/run/docker.sock`, rootless Docker's + `$XDG_RUNTIME_DIR/docker.sock`, Podman's (`$XDG_RUNTIME_DIR/podman/podman.sock`, + `/run/podman/podman.sock`); on macOS Docker Desktop's + `~/.docker/run/docker.sock` and `/var/run/docker.sock`, Colima's, OrbStack's, + Rancher Desktop's. Nothing else of the dirs holding them: the rest of + `$XDG_RUNTIME_DIR` (keyring, D-Bus, agent sockets) stays hidden, as do + containerd's and the other runtimes' sockets. +- **`DOCKER_HOST`** naming the first of them (the docker CLI and testcontainers + read it). Never the client's credentials: `~/.docker` (`config.json`, its + registry logins and credential helpers) stays hidden, so pulling a private + image fails inside — pull it yourself first. A docker context of yours isn't + there either; `DOCKER_HOST` is. +- The network is still the session's: reaching a container's published port + (`localhost:`, how testcontainers connects) needs the network `on` + (the default); with it `off` a command can start containers but not reach + them over TCP. + +Per provider: + +- **bubblewrap**: each socket bound read-only at its own path (connecting + needs no write to the mount; it can't be removed or replaced), over the + hidden dir holding it; a pathname socket works across the sandbox's network + namespace, so with the network off too. +- **Claude's own sandbox**: the socket allowed for reading (only) through the + denied home and runtime dir; on Linux its sandbox otherwise refuses to + create any unix socket (checked on the real CLI: `socket(AF_UNIX)` fails with + "Operation not permitted"), so Docker access turns on its + `allowAllUnixSockets` — the filesystem still decides which are reachable + (every other runtime socket, the desktop's runtime dir and agent sockets are + hidden, and its network namespace has none of the host's abstract sockets); + on macOS the socket is allowed by path (`allowUnixSockets`). +- **Seatbelt**: the socket's file (read, and written as data: never removed or + renamed) and a connection to it allowed last, by its own path and where it + leads, the network off too (not verified on a Mac). +- **Docker** (the provider): the runtime's socket mounted at + `/var/run/docker.sock` in the container (Docker Desktop's own name for it on + macOS), with its group added so the container's user can connect, and + `DOCKER_HOST` naming it. A container started from there is a sibling on the + host's runtime, not nested. +- **Codex** under bubblewrap: the socket is shown in codeoid's bubblewrap + around the app-server; whether codex's own sandbox (nested in it) lets a + command connect to it isn't verified here. + ## Committed project settings **Starting a Claude session in a repository trusts its committed project @@ -960,6 +1047,13 @@ works, and skip where it doesn't): (`sandbox-claude-real.test.ts`): under its own sandbox a command adds a worktree in `-worktrees/`, commits there and removes it, while the user's worktrees and the config stay held. +- Docker access (#448 follow-up), with a planted listening socket standing in + for rootless Docker's in a hidden `$XDG_RUNTIME_DIR` (no Docker daemon is + reachable here): under real bubblewrap it can't be connected to by default; + with the option it can (network off and on), `DOCKER_HOST` names it and the + rest of the runtime dir stays hidden. Under the real Claude CLI's sandbox + the same, and without `allowAllUnixSockets` its Linux sandbox refuses to + create a unix socket at all. **To verify on a real Mac** (Seatbelt; profile generation is unit-tested only): @@ -986,12 +1080,18 @@ works, and skip where it doesn't): location (pinned as writable git dirs are) refuses `mv` of the dir while `git worktree add` can make a new admin dir and checkout in it; that an existing worktree's admin dir (`subpath` deny) can't be renamed. +10. Docker access: with Docker Desktop, a sandboxed `docker ps` reaches + `~/.docker/run/docker.sock` (and through `/var/run/docker.sock`'s link), + the network off too; Claude's own Seatbelt sandbox with `allowUnixSockets`. **Docker** (no Docker daemon was reachable here): `CODEOID_TEST_DOCKER=1 bun test src/tests/sandbox-docker.test.ts` runs the container checks. Also to check: the Claude CLI in the default image (it needs glibc), codex's app-server in a -container, opencode's per-line latency, and a rootless Docker's `DOCKER_HOST` -reaching the client from a launcher (opencode, Qwen). +container, opencode's per-line latency, a rootless Docker's `DOCKER_HOST` +reaching the client from a launcher (opencode, Qwen), and Docker access from +the Docker provider (the socket at `/var/run/docker.sock` with its group +added, testcontainers starting a sibling container) and from a real Docker +socket under bubblewrap (`docker ps`, testcontainers' Ryuk). ## How it is built @@ -999,9 +1099,9 @@ reaching the client from a launcher (opencode, Qwen). [sandbox-providers.md](sandbox-providers.md) describes it and how to add one. - `registry.ts` — `auto | off | `, cached probes. - `policy.ts` — the hidden home and its allowlist (`home-readable.ts`), the - hidden-path list, the workspace's git repository, and the spec; - `worktrees.ts` — the worktree locations and which worktrees are the - session's own. + hidden-path list, the workspace's git repository, Docker access's sockets + (`dockerSocketPaths`), and the spec; `worktrees.ts` — the worktree + locations and which worktrees are the session's own. - `backend-home.ts` — a process backend's private home. - `bwrap.ts`, `seatbelt.ts`, `docker.ts` — the providers. - `confinement.ts` — per (backend, provider), how it is put inside. diff --git a/packages/core/src/sandbox.test.ts b/packages/core/src/sandbox.test.ts index 0e007caa..34b9b846 100644 --- a/packages/core/src/sandbox.test.ts +++ b/packages/core/src/sandbox.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; import type { SessionSandboxInfo } from "@highflame/codeoid-protocol"; -import { approvalSandboxLine, modeSummary, sandboxBadge, sandboxSummary } from "./sandbox.js"; +import { approvalSandboxLine, approvalSandboxWarns, DOCKER_ACCESS_WHY, modeSummary, sandboxBadge, sandboxSummary } from "./sandbox.js"; import { dispatchSlash, parseSlash } from "./slash.js"; const on: SessionSandboxInfo = { choice: "auto", state: "on", provider: "bwrap", providerName: "bubblewrap", network: "off", confinement: "native", via: "Claude Code's sandbox (bubblewrap) with codeoid's policy", enforced: true }; @@ -30,6 +30,27 @@ describe("sandbox display (#448)", () => { expect(approvalSandboxLine({})).toBeNull(); }); + it("Docker access (#448 follow-up): the badge, the summary and every card say so, as a warning, with why", () => { + const docker = { ...on, dockerSocket: true }; + expect(sandboxBadge(docker)).toMatchObject({ text: "sandboxed, Docker access", tone: "warn" }); + expect(sandboxBadge(docker)?.title).toContain(DOCKER_ACCESS_WHY); + expect(sandboxBadge({ ...docker, enforced: false })?.text).toBe("sandboxed partly, Docker access"); + expect(sandboxSummary(docker)).toBe("sandbox: bubblewrap, network off, Docker access (can reach the whole machine)"); + const card = { sandboxed: true, sandboxDocker: true, sandboxNote: "sandboxed, Docker access: bubblewrap, network on" }; + expect(approvalSandboxLine(card)).toBe("⚠️ sandboxed, Docker access: bubblewrap, network on"); + expect(approvalSandboxWarns(card)).toBe(true); + expect(approvalSandboxWarns({ sandboxed: true })).toBe(false); + expect(approvalSandboxLine({ sandboxed: false, sandboxDocker: true, sandboxNote: "runs in the backend's own process, outside the sandbox" })).toBe("⚠️ runs in the backend's own process, outside the sandbox (the sandbox has Docker access)"); + }); + + it("/sandbox … docker: Docker access after a provider; docker alone is the provider; nodocker takes it away", () => { + expect(parseSlash("/sandbox auto docker")).toEqual({ kind: "sandbox", sandbox: { provider: "auto", dockerSocket: true } }); + expect(parseSlash("/sandbox bwrap on docker")).toEqual({ kind: "sandbox", sandbox: { provider: "bwrap", network: "on", dockerSocket: true } }); + expect(parseSlash("/sandbox docker docker")).toEqual({ kind: "sandbox", sandbox: { provider: "docker", dockerSocket: true } }); + expect(parseSlash("/sandbox docker")).toEqual({ kind: "sandbox", sandbox: { provider: "docker" } }); + expect(parseSlash("/sandbox nodocker")).toEqual({ kind: "sandbox", sandbox: { dockerSocket: false } }); + }); + it("/sandbox: a provider, a network, an allowlist, off", () => { expect(parseSlash("/sandbox off")).toEqual({ kind: "sandbox", sandbox: { provider: "off" } }); expect(parseSlash("/sandbox auto off")).toEqual({ kind: "sandbox", sandbox: { provider: "auto", network: "off" } }); diff --git a/packages/core/src/sandbox.ts b/packages/core/src/sandbox.ts index 01791611..3fb95abd 100644 --- a/packages/core/src/sandbox.ts +++ b/packages/core/src/sandbox.ts @@ -17,6 +17,9 @@ export interface SandboxBadge { title: string; } +/** Why Docker access is a warning, wherever it is shown (a tooltip, a card). */ +export const DOCKER_ACCESS_WHY = "Docker access can reach the whole machine (a container can mount /)"; + /** The session header's badge. Null on a daemon without sandboxing. */ export function sandboxBadge(info: SessionSandboxInfo | undefined): SandboxBadge | null { if (!info) return null; @@ -24,6 +27,13 @@ export function sandboxBadge(info: SessionSandboxInfo | undefined): SandboxBadge case "on": { const net = info.networkRequested ? `network ${info.network} (asked: ${info.networkRequested})` : `network ${info.network}`; const limits = info.limits?.length ? `\n${info.limits.map((l) => `• ${l}`).join("\n")}` : ""; + if (info.dockerSocket) { + return { + text: info.enforced ? "sandboxed, Docker access" : "sandboxed partly, Docker access", + tone: "warn", + title: `Sandboxed, with Docker access: ${DOCKER_ACCESS_WHY}. ${info.via ?? info.providerName ?? info.provider ?? "on"}, ${net}.${limits}`, + }; + } return { text: info.enforced ? "sandboxed" : "sandboxed, partly", tone: info.enforced ? "on" : "warn", @@ -50,7 +60,7 @@ export function sandboxSummary(info: SessionSandboxInfo | undefined): string | n case "on": { const net = info.networkRequested ? `network ${info.network} (asked ${info.networkRequested})` : `network ${info.network}`; const hosts = info.network === "allowlist" && info.allowedHosts?.length ? ` [${info.allowedHosts.join(", ")}]` : ""; - return `sandbox: ${info.providerName ?? info.provider}, ${net}${hosts}${info.enforced ? "" : " — not everything asked is enforced"}`; + return `sandbox: ${info.providerName ?? info.provider}, ${net}${hosts}${info.dockerSocket ? ", Docker access (can reach the whole machine)" : ""}${info.enforced ? "" : " — not everything asked is enforced"}`; } case "off": return "sandbox: off"; @@ -73,9 +83,18 @@ export function modeSummary(s: Pick): string | null { +/** The approval card's sandbox line, or null when the daemon said nothing of it. A sandbox with Docker access is a warning. */ +export function approvalSandboxLine(state: Pick): string | null { if (state.sandboxed === undefined && !state.sandboxNote) return null; + if (state.sandboxDocker) { + const note = state.sandboxNote ?? "sandboxed, Docker access"; + return `⚠️ ${note}${note.includes("Docker access") ? "" : " (the sandbox has Docker access)"}`; + } if (state.sandboxed) return `🛡 ${state.sandboxNote ?? "sandboxed"}`; return `⚠️ ${state.sandboxNote ?? "not sandboxed"}`; } + +/** Whether the approval card's sandbox line is a warning (not sandboxed, or a sandbox with Docker access). */ +export function approvalSandboxWarns(state: Pick): boolean { + return !state.sandboxed || state.sandboxDocker === true; +} diff --git a/packages/core/src/slash.ts b/packages/core/src/slash.ts index b3d0762a..6264981e 100644 --- a/packages/core/src/slash.ts +++ b/packages/core/src/slash.ts @@ -6,7 +6,7 @@ * /interrupt — interrupt current turn * /rotate — rotate context (refresh skills/settings) * /mode — interactive | guarded | autonomous - * /sandbox [on|off|allow:host,host] — the session's OS sandbox (#448); off is asked of the owner + * /sandbox [on|off|allow:host,host] [docker|nodocker] — the session's OS sandbox (#448); off and Docker access are asked of the owner * /model — switch model (opus / sonnet / haiku / full id) * /account — switch the session's Claude account * /help — surface help modal (TODO P6) @@ -94,7 +94,7 @@ export function parseSlash(raw: string, opts?: ParseSlashOptions): SlashCommand case "rotate": return { kind: "rotate" }; case "sandbox": { - const usage = "/sandbox [on|off|allow:host,host]"; + const usage = "/sandbox [on|off|allow:host,host] [docker|nodocker]"; if (rest.length === 0) throw new Error(usage); const sandbox: SessionSandboxSetting = {}; for (const word of rest) { @@ -106,6 +106,11 @@ export function parseSlash(raw: string, opts?: ParseSlashOptions): SlashCommand } else if (w.startsWith("allow:")) { sandbox.network = "allowlist"; sandbox.allowedHosts = word.slice("allow:".length).split(",").filter(Boolean); + } else if (w === "docker" && sandbox.provider !== undefined) { + // After a provider, `docker` is Docker access (the container runtime's socket), asked of the owner. + sandbox.dockerSocket = true; + } else if (w === "nodocker" || w === "no-docker") { + sandbox.dockerSocket = false; } else if (sandbox.provider === undefined) { sandbox.provider = w; } else { diff --git a/packages/protocol/src/approvals.ts b/packages/protocol/src/approvals.ts index bdd79b84..524a6b33 100644 --- a/packages/protocol/src/approvals.ts +++ b/packages/protocol/src/approvals.ts @@ -40,6 +40,8 @@ export interface PendingToolApproval { /** Whether it runs inside the session's OS sandbox, and why not (#448; as on the card). */ sandboxed?: boolean; sandboxNote?: string; + /** The sandbox lets commands reach the container runtime (Docker access). */ + sandboxDocker?: boolean; } export interface SessionApprovalsResultMsg { diff --git a/packages/protocol/src/schemas.ts b/packages/protocol/src/schemas.ts index ec46dff3..06cff85f 100644 --- a/packages/protocol/src/schemas.ts +++ b/packages/protocol/src/schemas.ts @@ -92,6 +92,7 @@ export const sessionSandboxSettingSchema = z provider: z.string().min(1).max(64).optional(), network: z.enum(["on", "off", "allowlist"]).optional(), allowedHosts: z.array(z.string().min(1).max(253)).max(256).optional(), + dockerSocket: z.boolean().optional(), }) .strict(); diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index 7f35f722..c583c9dc 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -380,6 +380,12 @@ export interface SessionSandboxSetting { network?: SandboxNetworkMode; /** Hosts allowed with `network: "allowlist"` (`example.com`, `*.example.com`). */ allowedHosts?: string[]; + /** + * Let commands reach the host's container runtime socket (testcontainers, + * `docker run`): off by default (`sandbox.docker.socket`). Root on the + * machine: turning it on is a widening, asked of the owner. + */ + dockerSocket?: boolean; } export type SandboxNetworkMode = "on" | "off" | "allowlist"; @@ -415,6 +421,8 @@ export interface SessionSandboxInfo { via?: string; /** What isn't confined this way, in plain words. */ limits?: string[]; + /** Commands can reach the container runtime's socket (the owner's opt-in): it can reach the whole machine. */ + dockerSocket?: boolean; /** Everything asked is enforced: what an autonomous session, a routine or a loop needs to start. */ enforced: boolean; } @@ -1070,6 +1078,8 @@ export interface ToolWaitingConfirmationState { sandboxed?: boolean; /** Why not, or what's asked: "not sandboxed: bubblewrap is not installed", "asks to run outside the sandbox". */ sandboxNote?: string; + /** It runs inside a sandbox whose commands can reach the container runtime (Docker access): said as a warning. */ + sandboxDocker?: boolean; } /** How risky a shell command is, and why (#431). */ diff --git a/src/cli.ts b/src/cli.ts index 503cc7f5..018f273b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1080,6 +1080,7 @@ program ) .option("--network ", "What a sandboxed command may reach: on | off | allowlist (with --allow-host). Default: sandbox.network.") .option("--allow-host ", "A host the allowlist lets through, repeatable (example.com, *.example.com).", (v: string, p: string[] = []) => [...p, v], [] as string[]) + .option("--docker-socket", "Let the session's sandboxed commands reach the container runtime (testcontainers, docker run). Docker access can reach the whole machine; needs session:approve. Default: sandbox.docker.socket.") .option( "--pack ", "Activate an installed SDLC pack on the session (inject its constitution, expose its skills/subagents). With --collaborate, the collaboration ADOPTS the pack: role names bind strictly to the pack's declared roles.", @@ -1117,6 +1118,7 @@ program sandbox?: string; network?: string; allowHost: string[]; + dockerSocket?: boolean; }, ) => { const config = loadConfig(); @@ -1194,8 +1196,8 @@ program }, ); -/** A session's sandbox setting from `--sandbox` / `--network` / `--allow-host` (#448); undefined when none is given. */ -function sandboxSettingFromFlags(o: { sandbox?: string; network?: string; allowHost?: string[] }): SessionSandboxSetting | undefined { +/** A session's sandbox setting from `--sandbox` / `--network` / `--allow-host` / `--[no-]docker-socket` (#448); undefined when none is given. */ +function sandboxSettingFromFlags(o: { sandbox?: string; network?: string; allowHost?: string[]; dockerSocket?: boolean }): SessionSandboxSetting | undefined { const out: SessionSandboxSetting = {}; if (o.sandbox) out.provider = o.sandbox; if (o.network) { @@ -1207,6 +1209,7 @@ function sandboxSettingFromFlags(o: { sandbox?: string; network?: string; allowH out.network = "allowlist"; out.allowedHosts = o.allowHost; } + if (o.dockerSocket !== undefined) out.dockerSocket = o.dockerSocket; return Object.keys(out).length > 0 ? out : undefined; } @@ -1226,10 +1229,12 @@ sandboxCmd sandboxCmd .command("set [provider]") - .description("Change a session's sandbox: auto | bwrap | seatbelt | docker | off, and/or --network. Turning it off or widening it is asked of the owner on the session") + .description("Change a session's sandbox: auto | bwrap | seatbelt | docker | off, and/or --network, --docker-socket. Turning it off or widening it (Docker access included) is asked of the owner on the session") .option("--network ", "on | off | allowlist") .option("--allow-host ", "a host the allowlist lets through, repeatable", (v: string, p: string[] = []) => [...p, v], [] as string[]) - .action(async (session: string, provider: string | undefined, o: { network?: string; allowHost: string[] }) => { + .option("--docker-socket", "let its commands reach the container runtime (Docker access can reach the whole machine)") + .option("--no-docker-socket", "take Docker access away") + .action(async (session: string, provider: string | undefined, o: { network?: string; allowHost: string[]; dockerSocket?: boolean }) => { let setting: SessionSandboxSetting | undefined; try { setting = sandboxSettingFromFlags({ ...(provider ? { sandbox: provider } : {}), ...o }); @@ -1238,7 +1243,7 @@ sandboxCmd process.exit(1); } if (!setting) { - console.error("Give a provider (auto | bwrap | seatbelt | docker | off) or --network."); + console.error("Give a provider (auto | bwrap | seatbelt | docker | off), --network or --[no-]docker-socket."); process.exit(1); } const client = new TerminalClient(loadConfig()); @@ -1677,6 +1682,7 @@ function routineSpecOptions(cmd: Command, mode: "add" | "edit"): Command { .option("--sandbox ", "OS sandbox for its runs: auto | bwrap | seatbelt | docker | off (default: sandbox.provider); a run whose sandbox can't run here doesn't start") .option("--network ", "what a sandboxed command may reach: on | off | allowlist") .option("--allow-host ", "a host the allowlist lets through (repeatable)", collect, []) + .option("--docker-socket", "let its runs' commands reach the container runtime (Docker access can reach the whole machine; needs session:approve)") .option("--guarded", "every non-read tool waits for your approval") .option("--autonomous", "tools auto-approve within --tool-budget (default)") .option("--tool-budget ", "autonomous tool calls per run (default 50)") @@ -1741,6 +1747,7 @@ loop .option("--sandbox ", "The worker's OS sandbox: auto | bwrap | seatbelt | docker | off (default: sandbox.provider); the run is refused where it can't run") .option("--network ", "What the worker's commands may reach: on | off | allowlist") .option("--allow-host ", "A host the allowlist lets through (repeatable)", collect, []) + .option("--docker-socket", "Let the worker's commands and the checks reach the container runtime (tests that start containers). Docker access can reach the whole machine") .option("--wait", "Follow the run until it ends") .action( ( @@ -1758,6 +1765,7 @@ loop sandbox?: string; network?: string; allowHost: string[]; + dockerSocket?: boolean; wait?: boolean; }, ) => diff --git a/src/config.ts b/src/config.ts index 2811fa6a..26d84402 100644 --- a/src/config.ts +++ b/src/config.ts @@ -283,10 +283,12 @@ const SandboxSchema = z .object({ image: z.string().min(1).max(512).default("node:22-bookworm-slim"), runtime: z.string().min(1).max(64).optional(), + /** Let sandboxed commands reach the host's container runtime socket (root-equivalent): off by default. */ + socket: z.boolean().default(false), }) - .default({ image: "node:22-bookworm-slim" }), + .default({ image: "node:22-bookworm-slim", socket: false }), }) - .default({ provider: "auto", network: "on", allowedHosts: [], hiddenPaths: [], homeReadable: [...DEFAULT_HOME_READABLE], worktreeRoots: [], docker: { image: "node:22-bookworm-slim" } }); + .default({ provider: "auto", network: "on", allowedHosts: [], hiddenPaths: [], homeReadable: [...DEFAULT_HOME_READABLE], worktreeRoots: [], docker: { image: "node:22-bookworm-slim", socket: false } }); const CompressSchema = z .object({ @@ -1798,6 +1800,7 @@ const ENV_OVERRIDES: readonly EnvOverride[] = [ { env: "CODEOID_SANDBOX_HIDDEN_PATHS", path: "sandbox.hiddenPaths", kind: "csv" }, { env: "CODEOID_SANDBOX_HOME_READABLE", path: "sandbox.homeReadable", kind: "csv" }, { env: "CODEOID_SANDBOX_DOCKER_IMAGE", path: "sandbox.docker.image", kind: "string" }, + { env: "CODEOID_SANDBOX_DOCKER_SOCKET", path: "sandbox.docker.socket", kind: "boolean" }, { env: "CODEOID_SANDBOX_WORKTREE_ROOTS", path: "sandbox.worktreeRoots", kind: "csv" }, { env: "CODEOID_ROUTINES_MAX_CONCURRENT", path: "routines.maxConcurrentRuns", kind: "int" }, { env: "CODEOID_MCP_CONTROL_ACTIONS", path: "mcpControl.allowActions", kind: "boolean" }, diff --git a/src/daemon/sandbox/bwrap.ts b/src/daemon/sandbox/bwrap.ts index 312475f7..a209aeed 100644 --- a/src/daemon/sandbox/bwrap.ts +++ b/src/daemon/sandbox/bwrap.ts @@ -14,6 +14,8 @@ * --ro-bind

read-only inside something writable (what of a repository decides its config, * hooks and git dir; the project settings agent CLIs run code from) * --bind a pinned directory: a mount point, so it can't be renamed, still writable inside + * --ro-bind a container runtime socket the owner let in (spec.dockerSockets; off by default): + * connecting needs no write to the mount, and it can't be removed or replaced * --setenv the toolchains' caches in the sandbox's cache dir (spec.env) * --unshare-net network off * --die-with-parent, --new-session; bwrap always sets no_new_privs @@ -132,6 +134,14 @@ export function bwrapArgs(spec: SandboxSpec, cwd: string, kind: (p: string) => P seen.add(`o:${at}`); mounts.push({ kind: "bind", src: at, path: at }); } + // Container runtime sockets the owner let in (spec.dockerSockets): each bound at its own path, over a hidden dir holding it. + for (const p of spec.dockerSockets ?? []) { + const at = realOr(p); + if (kind(at) !== "other" || seen.has(`d:${at}`)) continue; + seen.add(`d:${at}`); + // Read-only: connecting to a socket needs no write to its mount, and it can't be removed or replaced. + mounts.push({ kind: "ro-bind", src: at, path: at }); + } const coveredAt = (at: string) => hiddenDirs.some((d) => within(at, d) && d !== at) || (within(at, "/tmp") && at !== "/tmp" && !writable.some((w) => within(at, w))); for (const p of spec.reexposed) { const at = realOr(p); diff --git a/src/daemon/sandbox/claude-native.ts b/src/daemon/sandbox/claude-native.ts index a254fb5a..afd643ec 100644 --- a/src/daemon/sandbox/claude-native.ts +++ b/src/daemon/sandbox/claude-native.ts @@ -123,6 +123,11 @@ export function claudeSandboxSettings(rt: Pick 0) Object.assign(network, process.platform === "linux" ? { allowAllUnixSockets: true } : { allowUnixSockets: sockets }); const envVars = credentialEnvNames(env, spec.unsetEnv).map((name) => ({ name, mode: "deny" as const })); return { enabled: true, @@ -141,7 +146,7 @@ export function claudeSandboxSettings(rt: Pick 0 ? { credentials: { envVars } } : {}), diff --git a/src/daemon/sandbox/docker.ts b/src/daemon/sandbox/docker.ts index 4670a219..d86f3986 100644 --- a/src/daemon/sandbox/docker.ts +++ b/src/daemon/sandbox/docker.ts @@ -21,8 +21,10 @@ * names no container; each handle labels its containers, so closing one * removes only its own. * - * The container runtime's socket is never mounted, and `DOCKER_HOST` is the - * docker client's only, never the container's. With `--runtime runsc` + * The container runtime's socket is never mounted unless the owner let it + * in (`spec.dockerSockets`, `sandbox.docker.socket`: mounted at + * `/var/run/docker.sock`, its group added, `DOCKER_HOST` naming it), and the + * docker client's own `DOCKER_HOST` is never the container's. With `--runtime runsc` * (gVisor) the isolation is a separate kernel. * * Not verified against a Docker daemon in this change (the integration tests @@ -31,7 +33,7 @@ import { execFile } from "node:child_process"; import { randomBytes } from "node:crypto"; -import { lstatSync, realpathSync } from "node:fs"; +import { lstatSync, realpathSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { resolve as resolvePath } from "node:path"; import { LocalSandboxHandle } from "./local-handle.js"; @@ -87,6 +89,21 @@ export interface DockerRunOptions { uid: number; gid: number; kind?: (p: string) => DockerPathKind; + /** The platform the Docker daemon's sockets are named for (default: this one's). */ + platform?: NodeJS.Platform; + /** The group owning a socket on the host, for `--group-add` (tests replace it). */ + socketGroup?: (p: string) => number | null; +} + +/** Where a container runtime socket the owner let in is mounted inside the container (`DOCKER_HOST` names it). */ +export const CONTAINER_DOCKER_SOCKET = "/var/run/docker.sock"; + +function socketGroup(p: string): number | null { + try { + return statSync(p).gid; + } catch { + return null; + } } /** `docker run`'s arguments for `argv` under `spec`. Pure but for `kind` (tests replace it). */ @@ -155,9 +172,20 @@ export function dockerRunArgs(spec: SandboxSpec, argv: readonly string[], o: Doc if (k === "dir") args.push("--mount", mountSpec([["type", "tmpfs"], ["destination", at]], true)); else if (k === "other") args.push("--mount", mountSpec([["type", "bind"], ["source", "/dev/null"], ["target", at]], true)); } + // The container runtime's socket, when the owner let it in (spec.dockerSockets): the one the Docker daemon serves, + // as it names it (Docker Desktop's is `/var/run/docker.sock` whatever the host's link), at the usual path, and its + // group added so the container's user can connect. + const socket = spec.dockerSockets?.[0]; + if (socket) { + const source = (o.platform ?? process.platform) === "darwin" ? CONTAINER_DOCKER_SOCKET : realOr(socket); + args.push("--mount", mountSpec([["type", "bind"], ["source", source], ["target", CONTAINER_DOCKER_SOCKET]], false)); + const group = (o.platform ?? process.platform) === "darwin" ? 0 : (o.socketGroup ?? socketGroup)(source); + if (group !== null) args.push("--group-add", String(group)); + } args.push("-w", o.cwd ? o.cwd : CWD_FROM_CALLER); for (const n of o.envNames) if (!spec.unsetEnv.includes(n) && !CLIENT_ENV.includes(n)) args.push("-e", n); - for (const [k, v] of Object.entries(spec.env ?? {})) args.push("-e", `${k}=${v}`); + for (const [k, v] of Object.entries(spec.env ?? {})) if (k !== "DOCKER_HOST") args.push("-e", `${k}=${v}`); + if (socket) args.push("-e", `DOCKER_HOST=unix://${CONTAINER_DOCKER_SOCKET}`); args.push("-e", "TMPDIR=/tmp", "-e", `HOME=${homedir()}`); args.push(o.image, ...argv); return args; diff --git a/src/daemon/sandbox/git-shim.ts b/src/daemon/sandbox/git-shim.ts index 93cd9b5e..3de051eb 100644 --- a/src/daemon/sandbox/git-shim.ts +++ b/src/daemon/sandbox/git-shim.ts @@ -187,8 +187,12 @@ export function gitShimSpec(spec: SandboxSpec, opts: { tempParent: string; workt const open = opts.worktrees?.open ?? []; const keep = opts.worktrees?.keep ?? []; const held = (p: string) => !open.some((o) => within(p, o)) || keep.some((k) => within(p, k)); + // git reaches no container runtime, whatever the session's commands may. + const { dockerSockets: _sockets, ...rest } = spec; + const { DOCKER_HOST: _host, ...env } = spec.env ?? {}; return { - ...spec, + ...rest, + ...(spec.env ? { env } : {}), tempDir: opts.tempParent, privateTemp: true, writable: [...spec.writable, ...open], diff --git a/src/daemon/sandbox/policy.ts b/src/daemon/sandbox/policy.ts index 355463e4..695ad541 100644 --- a/src/daemon/sandbox/policy.ts +++ b/src/daemon/sandbox/policy.ts @@ -23,7 +23,7 @@ * - network per policy. */ -import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, realpathSync, writeFileSync } from "node:fs"; +import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, realpathSync, statSync, writeFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import type { CodeoidConfig } from "../../config.js"; @@ -66,6 +66,12 @@ export interface SandboxConfig { image: string; /** `--runtime` (e.g. `runsc` for gVisor). */ runtime?: string; + /** + * Let sandboxed commands reach the host's container runtime socket + * (testcontainers, `docker run`): off by default. Root-equivalent: a + * container can mount `/`. A session's own `dockerSocket` overrides it. + */ + socket: boolean; }; } @@ -77,7 +83,7 @@ export const DEFAULT_SANDBOX_CONFIG: SandboxConfig = { hiddenPaths: [], homeReadable: [...DEFAULT_HOME_READABLE], worktreeRoots: [], - docker: { image: "node:22-bookworm-slim" }, + docker: { image: "node:22-bookworm-slim", socket: false }, }; export function sandboxConfigOf(config: Pick | undefined): SandboxConfig { @@ -173,6 +179,46 @@ export function containerSocketPaths(home: string, env: Record = process.env, + platform: NodeJS.Platform = process.platform, + isSocket: (p: string) => boolean = socketAt, +): string[] { + const runtime = env.XDG_RUNTIME_DIR || (typeof process.getuid === "function" ? `/run/user/${process.getuid()}` : ""); + const host = env.DOCKER_HOST?.startsWith("unix://") ? env.DOCKER_HOST.slice("unix://".length) : null; + const candidates = + platform === "darwin" + ? [join(home, ".docker/run/docker.sock"), "/var/run/docker.sock", join(home, ".colima/default/docker.sock"), join(home, ".orbstack/run/docker.sock"), join(home, ".rd/docker.sock")] + : ["/var/run/docker.sock", "/run/docker.sock", ...(runtime ? [join(runtime, "docker.sock"), join(runtime, "podman/podman.sock")] : []), "/run/podman/podman.sock"]; + const out: string[] = []; + const seen = new Set(); + for (const p of [...(host && isAbsolute(host) ? [host] : []), ...candidates]) { + if (!isSocket(p)) continue; + const real = realOr(p); + if (seen.has(real)) continue; + seen.add(real); + out.push(p); + } + return out; +} + +function socketAt(p: string): boolean { + try { + return statSync(p).isSocket(); + } catch { + return false; + } +} + /** Environment variables that point into the desktop session or an agent: never handed inside. */ export const SANDBOX_UNSET_ENV: readonly string[] = [ "SSH_AUTH_SOCK", @@ -273,6 +319,11 @@ export interface SpecInputs { cache?: boolean; /** The repository's worktrees when the session's sandbox first looked (GitWorkspaceOptions.worktreeBaseline). */ worktreeBaseline?: ReadonlySet; + /** + * Let commands reach the host's container runtime sockets + * (`sandbox.docker.socket`, opt-in: root-equivalent; dockerSocketPaths). + */ + dockerSocket?: boolean; } /** The home a spec hides: absolute, and not `/` (nothing is left to show then). */ @@ -911,6 +962,9 @@ export function buildSandboxSpec(inputs: SpecInputs): SandboxSpec { // Inside the hidden home, only what is inside something shown needs hiding again. (!homeHidden || h === homeHidden || !within(h, homeHidden) || shown.some((x) => within(h, x))), ); + // The container runtime's sockets, when the owner opted in: reachable at their own paths, `DOCKER_HOST` the first. + const sockets = inputs.dockerSocket ? dockerSocketPaths(home, env) : []; + const exposed = new Set(sockets.flatMap((s) => [s, realOr(s)])); const spec: SandboxSpec = { sessionId: inputs.sessionId, workspace, @@ -920,12 +974,13 @@ export function buildSandboxSpec(inputs: SpecInputs): SandboxSpec { readOnly: [...git.readOnly, ...projectConfig], gitDirs: git.gitDirs, ...(locations.length > 0 ? { worktreeLocations: locations } : {}), - ...(cache ? { env: toolchainCacheEnv(cache, home, env) } : {}), + ...(cache || sockets.length > 0 ? { env: { ...(cache ? toolchainCacheEnv(cache, home, env) : {}), ...(sockets[0] ? { DOCKER_HOST: `unix://${sockets[0]}` } : {}) } } : {}), pinned: protectedAncestors(hidden, [workspace, tempDir, ...own, ...locations]), reexposed, - containerSockets: containerSocketPaths(home, env), + containerSockets: containerSocketPaths(home, env).filter((p) => !exposed.has(p) && !exposed.has(realOr(p))), + ...(sockets.length > 0 ? { dockerSockets: sockets } : {}), network: inputs.network, - unsetEnv: SANDBOX_UNSET_ENV, + unsetEnv: sockets.length > 0 ? SANDBOX_UNSET_ENV.filter((n) => n !== "DOCKER_HOST") : SANDBOX_UNSET_ENV, }; return spec; } diff --git a/src/daemon/sandbox/seatbelt.ts b/src/daemon/sandbox/seatbelt.ts index af00ea52..211da3b6 100644 --- a/src/daemon/sandbox/seatbelt.ts +++ b/src/daemon/sandbox/seatbelt.ts @@ -12,7 +12,8 @@ * workspace hidden again (as bwrap.ts lays its mounts); a repository's * hooks and config not writable; a pinned directory not writable itself * (`literal`: no rename, no removal), what is in it still writable; - * - container sockets: not readable, and no connection to them; + * - container sockets: not readable, and no connection to them (but + * those the owner let in, `spec.dockerSockets`: allowed last); * - network off (and an allowlist, which Seatbelt can't enforce by host): * no network at all. * @@ -137,6 +138,15 @@ export function seatbeltProfile( if (isDir(s) !== true) lines.push(`(deny network-outbound (remote unix-socket (path-literal ${param(s)})))`); } if (seatbeltNetwork(spec.network.mode) === "off") lines.push("(deny network*)"); + // Container runtime sockets the owner let in (spec.dockerSockets), last, so they win over the rules above: the file + // (by its own path and where it leads: Docker Desktop's `/var/run/docker.sock` links into the home), read and + // written as a socket is, never removed or replaced, and a connection to it, the network off or not. + for (const s of spec.dockerSockets ?? []) { + for (const p of new Set([s, real(s)])) { + lines.push(`(allow file-read* file-write-data (literal ${param(p)}))`); + lines.push(`(allow network-outbound (remote unix-socket (path-literal ${param(p)})))`); + } + } return { profile: `${lines.join("\n")}\n`, params }; } diff --git a/src/daemon/sandbox/session-sandbox.ts b/src/daemon/sandbox/session-sandbox.ts index 5ae09ada..d1182c26 100644 --- a/src/daemon/sandbox/session-sandbox.ts +++ b/src/daemon/sandbox/session-sandbox.ts @@ -22,7 +22,7 @@ import type { ClaudeTempDirs } from "./claude-native.js"; import { type ConfinementPlan, confinementFor, runsNoCommands } from "./confinement.js"; import { GIT_BIN } from "../git-env.js"; import { CWD_FROM_CALLER } from "./docker.js"; -import { buildSandboxSpec, gitLayoutOf, gitLayoutProblem, networkPolicyOf, type SandboxConfig, sandboxConfigOf, sandboxWorkspaceOf, validAllowedHost } from "./policy.js"; +import { buildSandboxSpec, dockerSocketPaths, gitLayoutOf, gitLayoutProblem, networkPolicyOf, type SandboxConfig, sandboxConfigOf, sandboxWorkspaceOf, validAllowedHost } from "./policy.js"; import { worktreeAdminsOf } from "./worktrees.js"; import type { SandboxRegistry } from "./registry.js"; import { cliWorktrees, gitProgramLocations, gitShimSpec, writeGitShim } from "./git-shim.js"; @@ -127,14 +127,28 @@ export interface SandboxRuntime { gitShim(caller: string): string; } +/** A sandbox setting with the config filled in: the provider choice, the network, and whether commands reach the container runtime. */ +export interface EffectiveSandbox { + choice: string; + network: NetworkPolicy; + /** The host's container runtime socket is reachable (`sandbox.docker.socket` / the session's `dockerSocket`): root-equivalent. */ + dockerSocket: boolean; +} + /** The setting a session ends up with: its own fields over the config's. */ -export function effectiveSetting(config: SandboxConfig, setting: SessionSandboxSetting | undefined): { choice: string; network: NetworkPolicy } { +export function effectiveSetting(config: SandboxConfig, setting: SessionSandboxSetting | undefined): EffectiveSandbox { const choice = setting?.provider ?? config.provider; const network = networkPolicyOf(config, { ...(setting?.network ? { mode: setting.network } : {}), ...(setting?.allowedHosts ? { allow: setting.allowedHosts } : {}), }); - return { choice, network }; + return { choice, network, dockerSocket: setting?.dockerSocket ?? config.docker.socket ?? false }; +} + +/** A setting in words, for a change's line and audit: "bwrap, network on, Docker access". */ +export function describeSandbox(e: EffectiveSandbox): string { + if (e.choice === SANDBOX_OFF) return "off"; + return `${e.choice}, network ${e.network.mode}${e.network.mode === "allowlist" ? ` (${(e.network.allow ?? []).join(", ")})` : ""}${e.dockerSocket ? ", Docker access" : ""}`; } /** Why a setting is malformed (an unknown provider, a host that isn't one), or null. */ @@ -154,13 +168,18 @@ function networkWidth(mode: NetworkMode): number { /** * Whether moving from `before` to `after` lets commands do more: the sandbox - * turned off, the network wider, an allowlist with a host it didn't have. - * Switching provider counts as widening (a different boundary). + * turned off, the network wider, an allowlist with a host it didn't have, + * the container runtime's socket let in (root on the machine). Switching + * provider counts as widening (a different boundary). */ -export function widens(before: { choice: string; network: NetworkPolicy }, after: { choice: string; network: NetworkPolicy }): boolean { +export function widens( + before: { choice: string; network: NetworkPolicy; dockerSocket?: boolean }, + after: { choice: string; network: NetworkPolicy; dockerSocket?: boolean }, +): boolean { if (after.choice === SANDBOX_OFF && before.choice !== SANDBOX_OFF) return true; if (before.choice === SANDBOX_OFF) return false; if (after.choice !== before.choice) return true; + if (after.dockerSocket === true && before.dockerSocket !== true) return true; const b = networkWidth(before.network.mode); const a = networkWidth(after.network.mode); if (a !== b) return a > b; @@ -195,7 +214,7 @@ export async function resolveSandbox(opts: { /** The session's working directory: `/` can't be a sandbox's workspace. */ workspace?: string; }): Promise<{ info: SessionSandboxInfo; provider: SandboxProvider | null; plan: ConfinementPlan | null; network: NetworkPolicy }> { - const { choice, network } = effectiveSetting(opts.config, opts.setting); + const { choice, network, dockerSocket } = effectiveSetting(opts.config, opts.setting); const base = { choice, network: network.mode, @@ -226,7 +245,14 @@ export async function resolveSandbox(opts: { // A monorepo's package: what its commands may write is the whole repository (#448 round 9). const top = opts.workspace !== undefined && c.plan.kind !== "no-shell" ? sandboxWorkspaceOf(opts.workspace) : null; const topNote = top && opts.workspace !== undefined && top !== resolve(opts.workspace) ? `the workspace is the repository's top (${top}), which the working directory is inside: all of it is readable and writable, its git structure and every project settings dir on the way held read-only` : null; - const limits = [...c.plan.limits, ...(net.note ? [net.note] : []), ...(gitNote ? [gitNote] : []), ...(topNote ? [topNote] : [])]; + // The container runtime's socket, when the owner let it in: said, and said when there is none to reach. + const sockets = dockerSocket ? dockerSocketPaths(process.env.HOME ?? homedir()) : []; + const dockerNote = dockerSocket + ? sockets.length > 0 + ? `Docker access: commands can reach the container runtime (${sockets.join(", ")}), which can reach the whole machine (a container can mount /)` + : "Docker access was asked for, but no container runtime socket was found here" + : null; + const limits = [...c.plan.limits, ...(net.note ? [net.note] : []), ...(gitNote ? [gitNote] : []), ...(topNote ? [topNote] : []), ...(dockerNote ? [dockerNote] : [])]; const applied: NetworkPolicy = net.mode === "allowlist" ? network : { mode: net.mode }; return { info: { @@ -239,6 +265,7 @@ export async function resolveSandbox(opts: { confinement: c.plan.kind === "no-shell" ? undefined : c.plan.kind, via: c.plan.via, ...(limits.length > 0 ? { limits } : {}), + ...(dockerSocket ? { dockerSocket: true } : {}), enforced: !net.widened, }, provider, @@ -307,7 +334,7 @@ export class SessionSandbox { } /** What `setting` means with the config filled in. */ - effective(setting: SessionSandboxSetting | undefined = this.#setting): { choice: string; network: NetworkPolicy } { + effective(setting: SessionSandboxSetting | undefined = this.#setting): EffectiveSandbox { return effectiveSetting(this.config, setting); } @@ -380,9 +407,10 @@ export class SessionSandbox { const provider = r.provider; const plan = r.plan; const config = this.config; + const dockerSocket = r.info.dockerSocket === true; const open = (backendState: readonly string[] = [], o: OpenOptions = {}) => { if (this.#closed) throw new Error(`session ${this.#sessionId} is gone`); - return this.#spec(o.backendNetwork ? { mode: "on" } : r.network, backendState, o.readable ?? []); + return this.#spec(o.backendNetwork ? { mode: "on" } : r.network, backendState, o.readable ?? [], dockerSocket); }; // Built (and the temp dir made) when a backend first needs it. let spec: SandboxSpec | null = null; @@ -530,7 +558,7 @@ export class SessionSandbox { return this.#handles.size; } - #spec(network: NetworkPolicy, backendState: readonly string[], readable: readonly string[] = []): SandboxSpec { + #spec(network: NetworkPolicy, backendState: readonly string[], readable: readonly string[] = [], dockerSocket = false): SandboxSpec { // A monorepo's package: the repository's top (#448 round 9); the session's cwd stays the workdir. const workspace = sandboxWorkspaceOf(this.#workdir); return buildSandboxSpec({ @@ -543,6 +571,7 @@ export class SessionSandbox { ...(this.#config ? { config: this.#config } : {}), backendState, worktreeBaseline: this.#worktreesAtStart(workspace), + dockerSocket, }); } @@ -664,7 +693,7 @@ export async function openCheckSandbox(opts: { label: string; }): Promise { const cfg = sandboxConfigOf(opts.config); - const { choice, network } = effectiveSetting(cfg, opts.setting); + const { choice, network, dockerSocket } = effectiveSetting(cfg, opts.setting); if (choice === SANDBOX_OFF) return { kind: "off" }; const resolved = await opts.registry.resolve(choice); if (resolved.kind === "off") return { kind: "off" }; @@ -688,6 +717,8 @@ export async function openCheckSandbox(opts: { tempDir, network: mode === "allowlist" ? network : { mode }, ...(opts.config ? { config: opts.config } : {}), + // A check that starts containers (testcontainers) gets the socket when the owner let the run's sandbox have it. + dockerSocket, }); handle = await provider.open(spec); } catch (err) { diff --git a/src/daemon/sandbox/types.ts b/src/daemon/sandbox/types.ts index b2a0bcfe..38853efb 100644 --- a/src/daemon/sandbox/types.ts +++ b/src/daemon/sandbox/types.ts @@ -125,6 +125,14 @@ export interface SandboxSpec { worktreeLocations?: readonly string[]; /** Container runtime sockets (and their dirs): never reachable, they are root-equivalent. */ containerSockets: readonly string[]; + /** + * Container runtime sockets the owner let commands reach + * (`sandbox.docker.socket` / a session's `dockerSocket`, off by default): + * each reachable at its own path, `DOCKER_HOST` naming the first + * ({@link env}). Root-equivalent, so never without the owner's choice; + * never the client's credentials (`~/.docker/config.json` stays hidden). + */ + dockerSockets?: readonly string[]; network: NetworkPolicy; /** Environment variables removed from what runs inside (an agent socket, a desktop bus). */ unsetEnv: readonly string[]; diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts index 14cb6b7d..b28660d3 100644 --- a/src/daemon/session-manager.ts +++ b/src/daemon/session-manager.ts @@ -8959,6 +8959,11 @@ mcpHub: this.#mcpHub, this.#store.audit(auth.sub, "session.sandbox_refused", session.id, "set_mode autonomous: sandbox off, not the owner's own token"); return { type: "response.error", requestId: msg.id, error: "This session's sandbox is off: only its owner, with their own token, can make it autonomous", code: "forbidden" }; } + // Docker access (root on the machine) is the owner's choice too. + if (session.sandboxInfo?.dockerSocket === true && (auth.delegationDepth > 0 || auth.sub !== session.createdBy)) { + this.#store.audit(auth.sub, "session.sandbox_refused", session.id, "set_mode autonomous: Docker access, not the owner's own token"); + return { type: "response.error", requestId: msg.id, error: "This session's sandbox has Docker access: only its owner, with their own token, can make it autonomous", code: "forbidden" }; + } } session.setMode(msg.mode, msg.maxTurns, auth); return { type: "response.ok", requestId: msg.id }; @@ -8977,11 +8982,11 @@ mcpHub: this.#mcpHub, const cfg = sandboxConfigOf(this.#config); if (!widens(effectiveSetting(cfg, undefined), effectiveSetting(cfg, setting))) return null; const refusal = !hasScope(auth.scopes as string[], SCOPES.SESSION_APPROVE) - ? `Missing scope: session:approve (needed to ${what} with a sandbox off or wider than the daemon's)` + ? `Missing scope: session:approve (needed to ${what} with a sandbox off, wider than the daemon's, or with Docker access)` : auth.delegationDepth > 0 - ? `A delegated token can't ${what} with a sandbox off or wider than the daemon's` + ? `A delegated token can't ${what} with a sandbox off, wider than the daemon's, or with Docker access` : owner !== undefined && owner !== auth.sub - ? `Only the session's owner can ${what} with its sandbox (off or wider than the daemon's)` + ? `Only the session's owner can ${what} with its sandbox (off, wider than the daemon's, or with Docker access)` : null; if (refusal) this.#store.audit(auth.sub, "session.sandbox_wider_refused", "", `${what}: ${refusal}`); return refusal; @@ -9061,7 +9066,7 @@ mcpHub: this.#mcpHub, requestId: msg.id, data: { enabled: registry !== undefined, - default: { provider: config.provider, network: config.network, allowedHosts: config.allowedHosts }, + default: { provider: config.provider, network: config.network, allowedHosts: config.allowedHosts, dockerSocket: config.docker.socket }, auto: registry?.autoId() ?? null, providers, }, diff --git a/src/daemon/session.ts b/src/daemon/session.ts index 76c0f8ab..1db6426b 100644 --- a/src/daemon/session.ts +++ b/src/daemon/session.ts @@ -209,7 +209,7 @@ import { PRIMARY_CHAIN, RepeatToolGuard } from "./guard/index.js"; import { asksOutsideSandbox } from "./sandbox/confinement.js"; import { type HostToolBreach, hostFileBreachNote, hostFileBreachReason, hostFileToolBreach, hostToolBoundsFor, isHostFileTool } from "./sandbox/host-file-tools.js"; import type { SandboxRegistry } from "./sandbox/registry.js"; -import { resolveSandbox, SandboxRequiredError, SessionSandbox, sandboxRefusalOf, sandboxSettingProblem, widens } from "./sandbox/session-sandbox.js"; +import { describeSandbox, resolveSandbox, SandboxRequiredError, SessionSandbox, sandboxRefusalOf, sandboxSettingProblem, widens } from "./sandbox/session-sandbox.js"; import { type GitControlSnapshot, gitControlChanges, gitControlSnapshot, gitControlWarning, refTips, settingsCommits, settingsCommitWarning } from "./sandbox/git-watch.js"; import { protectedWriteTargets, userHiddenPaths } from "./sandbox/policy.js"; import { @@ -3177,8 +3177,7 @@ export class Session { if (setting.network && setting.network !== "allowlist" && setting.allowedHosts === undefined) delete next.allowedHosts; const before = sbx.effective(); const after = sbx.effective(next); - const describe = (e: { choice: string; network: { mode: string; allow?: readonly string[] } }) => - `${e.choice === "off" ? "off" : e.choice}, network ${e.network.mode}${e.network.mode === "allowlist" ? ` (${(e.network.allow ?? []).join(", ")})` : ""}`; + const describe = describeSandbox; const change = `${describe(before)} → ${describe(after)}`; const apply = async (answeredBy?: string): Promise => { // A confirm answered later: the session may have got busy meanwhile. @@ -3214,10 +3213,11 @@ export class Session { // What it would really be here: a provider that can't run is no sandbox at all. const resolvedAfter = await resolveSandbox({ registry: sbx.registry, config: sbx.config, setting: next, backendId: this.#provider.id }).catch(() => null); const unsandboxed = after.choice === "off" || (resolvedAfter !== null && resolvedAfter.info.state !== "on" && resolvedAfter.info.state !== "no-shell"); - const title = unsandboxed ? `Turn the sandbox off for "${this.name}"?` : `Widen the sandbox for "${this.name}"?`; + const dockerAdded = !unsandboxed && after.dockerSocket && !before.dockerSocket; + const title = unsandboxed ? `Turn the sandbox off for "${this.name}"?` : dockerAdded ? `Give "${this.name}" Docker access?` : `Widen the sandbox for "${this.name}"?`; const message = unsandboxed ? `The agent's commands would run with your full rights${after.choice === "off" ? "" : ` (${resolvedAfter?.info.reason ?? "the sandbox can't run here"})`}: they could read your credentials and change any file you can.` - : `Commands would run with: ${describe(after)} (now: ${describe(before)}).`; + : `Commands would run with: ${describe(after)} (now: ${describe(before)}).${dockerAdded ? " Docker access can reach the whole machine: a command could start a container that mounts / and read or change anything, your credentials included." : ""}`; // Answered by the owner alone, undelegated: what bounds the owner's agent is the owner's call. let answeredBy: string | undefined; void this.requestUserInput({ method: "confirm", title, message }, { @@ -3323,7 +3323,7 @@ export class Session { } /** What an approval card says of the sandbox (#448): whether the call runs inside, and why not. */ - #cardSandbox(toolName: string, input: unknown, shellCwd?: string | null): { sandboxed?: boolean; sandboxNote?: string } { + #cardSandbox(toolName: string, input: unknown, shellCwd?: string | null): { sandboxed?: boolean; sandboxNote?: string; sandboxDocker?: boolean } { const info = this.#sandbox?.info; if (!info) return {}; if (this.#unsandboxedRequest(toolName, input)) { @@ -3334,10 +3334,12 @@ export class Session { const runner = this.#sandbox?.runtime?.plan; const shell = isShellTool(toolName) || isUnresolvedPathTool(toolName); const inside = shell || runner?.fileTools === true; - if (inside) return { sandboxed: true, sandboxNote: `sandboxed: ${info.providerName ?? info.provider}, network ${info.network}` }; + // Commands can reach the container runtime (the owner's opt-in): every card says so, as a warning. + const docker = info.dockerSocket === true ? { sandboxDocker: true } : {}; + if (inside) return { sandboxed: true, sandboxNote: `sandboxed${info.dockerSocket ? ", Docker access" : ""}: ${info.providerName ?? info.provider}, network ${info.network}`, ...docker }; // Beyond what the sandbox lets its commands do: the owner decides, told so (#448 round 9). const beyond = this.#hostBoundsNote(toolName, input, shellCwd); - return { sandboxed: false, sandboxNote: beyond ?? "runs in the backend's own process, outside the sandbox" }; + return { sandboxed: false, sandboxNote: beyond ?? "runs in the backend's own process, outside the sandbox", ...docker }; } case "no-shell": return {}; diff --git a/src/daemon/settings/manifest.ts b/src/daemon/settings/manifest.ts index b3509a7d..3eb705e4 100644 --- a/src/daemon/settings/manifest.ts +++ b/src/daemon/settings/manifest.ts @@ -238,6 +238,12 @@ const sandbox: SettingsTab = { envVar: "CODEOID_SANDBOX_WORKTREE_ROOTS", applies: "next-session", }), + cfg("sandbox.docker.socket", "Docker access", "Let sandboxed commands reach the container runtime's socket (Docker, rootless Docker, Podman; Docker Desktop on macOS) — for tests that start containers (testcontainers). Docker access can reach the whole machine: a container can mount /. Sessions say \"sandboxed, Docker access\"; a session can turn it on for itself (asked of its owner).", { + kind: "boolean", + envVar: "CODEOID_SANDBOX_DOCKER_SOCKET", + default: false, + applies: "next-session", + }), ], }, { diff --git a/src/terminal/client.ts b/src/terminal/client.ts index 377a1755..2773bcdb 100644 --- a/src/terminal/client.ts +++ b/src/terminal/client.ts @@ -158,7 +158,7 @@ export function renderStreamMessage(msg: DaemonMessage, state: StreamRenderState } case "session.message": { - const sm = msg as { role?: string; content?: string; messageId?: string; tool?: { name?: string; state?: { phase?: string; approvalId?: string; description?: string; risk?: ToolRisk; sandboxed?: boolean; sandboxNote?: string } }; identity?: { name?: string; type?: string } }; + const sm = msg as { role?: string; content?: string; messageId?: string; tool?: { name?: string; state?: { phase?: string; approvalId?: string; description?: string; risk?: ToolRisk; sandboxed?: boolean; sandboxNote?: string; sandboxDocker?: boolean } }; identity?: { name?: string; type?: string } }; const id = sm.identity?.name ? `${DIM}${S(sm.identity.name)}${RESET} ` : ""; switch (sm.role) { case "user": @@ -472,12 +472,12 @@ export class TerminalClient { this.#printError(resp); return; } - const data = resp.data as { enabled: boolean; auto: string | null; default: { provider: string; network: string }; providers: SandboxProviderWire[] }; + const data = resp.data as { enabled: boolean; auto: string | null; default: { provider: string; network: string; dockerSocket?: boolean }; providers: SandboxProviderWire[] }; if (!data.enabled) { console.log("This daemon has no OS sandbox."); return; } - console.log(`\n Default: ${data.default.provider}${data.default.provider === "auto" && data.auto ? ` (${data.auto} here)` : ""}, network ${data.default.network}\n`); + console.log(`\n Default: ${data.default.provider}${data.default.provider === "auto" && data.auto ? ` (${data.auto} here)` : ""}, network ${data.default.network}${data.default.dockerSocket ? ", Docker access (can reach the whole machine)" : ""}\n`); for (const p of data.providers) { const where = `${p.isolation} isolation, ${p.placement}, network ${p.network.join("/")}`; console.log(` ${p.id.padEnd(10)} ${p.available ? "available" : "not available"} ${p.displayName} — ${where}`); diff --git a/src/terminal/routine-format.ts b/src/terminal/routine-format.ts index 25a6b24b..029136ab 100644 --- a/src/terminal/routine-format.ts +++ b/src/terminal/routine-format.ts @@ -30,6 +30,8 @@ export interface RoutineFlags { sandbox?: string; network?: string; allowHost?: string[]; + /** --docker-socket: its runs' commands reach the container runtime. */ + dockerSocket?: boolean; guarded?: boolean; autonomous?: boolean; toolBudget?: string; @@ -121,12 +123,13 @@ export function specFromFlags( else if (f.checkTimeout !== undefined) throw new RoutineFlagError("--check-timeout needs --check"); if (f.provider !== undefined) out.provider = f.provider; if (f.model !== undefined) out.model = f.model; - if (f.sandbox !== undefined || f.network !== undefined || (f.allowHost?.length ?? 0) > 0) { + if (f.sandbox !== undefined || f.network !== undefined || (f.allowHost?.length ?? 0) > 0 || f.dockerSocket !== undefined) { if (f.network !== undefined && !["on", "off", "allowlist"].includes(f.network)) throw new RoutineFlagError("--network is on, off or allowlist"); if (f.allowHost?.length && f.network !== undefined && f.network !== "allowlist") throw new RoutineFlagError("--allow-host goes with --network allowlist"); out.sandbox = { ...(f.sandbox !== undefined ? { provider: f.sandbox } : {}), ...(f.allowHost?.length ? { network: "allowlist" as const, allowedHosts: f.allowHost } : f.network !== undefined ? { network: f.network as "on" | "off" | "allowlist" } : {}), + ...(f.dockerSocket !== undefined ? { dockerSocket: f.dockerSocket } : {}), }; } if (f.guarded && f.autonomous) throw new RoutineFlagError("use --guarded or --autonomous, not both"); diff --git a/src/tests/fixtures/claude-sandbox-probe.ts b/src/tests/fixtures/claude-sandbox-probe.ts index 998d398f..9e65b51d 100644 --- a/src/tests/fixtures/claude-sandbox-probe.ts +++ b/src/tests/fixtures/claude-sandbox-probe.ts @@ -117,7 +117,10 @@ const spec = policy tempDir: join(root, "tmp"), network: network === "allowlist" ? { mode: network, allow: [allowHost] } : { mode: network }, config: { sandbox: config } as never, - hidden: { home, configDir: join(home, ".codeoid"), env: { HOME: home }, daemonTmp: "/tmp" }, + // CODEOID_PROBE_RUNTIME_DIR: the desktop runtime dir (hidden), where a planted socket stands in for rootless + // Docker's; CODEOID_PROBE_DOCKER_SOCKET=1: the owner let commands reach it (spec.dockerSockets). + hidden: { home, configDir: join(home, ".codeoid"), env: { HOME: home, ...(process.env.CODEOID_PROBE_RUNTIME_DIR ? { XDG_RUNTIME_DIR: process.env.CODEOID_PROBE_RUNTIME_DIR } : {}) }, daemonTmp: "/tmp" }, + dockerSocket: process.env.CODEOID_PROBE_DOCKER_SOCKET === "1", }) : { sessionId: "probe", diff --git a/src/tests/sandbox-claude-real.test.ts b/src/tests/sandbox-claude-real.test.ts index ebd7494a..2931ffb8 100644 --- a/src/tests/sandbox-claude-real.test.ts +++ b/src/tests/sandbox-claude-real.test.ts @@ -7,6 +7,7 @@ * scratch dir, outside the host's /tmp; nothing runs outside a sandbox but * the one call the gate refuses, which therefore never runs. */ +import { createServer } from "node:net"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; @@ -183,6 +184,26 @@ describe.skipIf(!ready)("Claude Code's sandbox with codeoid's policy, for real", expect(listing).not.toContain("codeoid-login-"); }, 120_000); + test("Docker access: a planted socket in the hidden runtime dir is unreachable by default, and reachable with the owner's option (#448 follow-up)", async () => { + if (!Bun.which("python3")) return; + const runtime = join(root, "run-user"); + mkdirSync(runtime, { recursive: true, mode: 0o700 }); + const sock = join(runtime, "docker.sock"); + const server = createServer((c) => c.end("PLANTED-RUNTIME\n")); + await new Promise((r) => server.listen(sock, r)); + try { + const cmd = `python3 -c 'import socket,sys; s=socket.socket(socket.AF_UNIX); s.connect(sys.argv[1]); print(s.recv(100).decode().strip())' '${sock}' 2>&1 || true`; + const hidden = await probe("off", cmd, ["policy"], { CODEOID_PROBE_RUNTIME_DIR: runtime }); + expect(hidden.result).toBe("success"); + expect(hidden.toolResult).not.toContain("PLANTED-RUNTIME"); + const shown = await probe("off", cmd, ["policy"], { CODEOID_PROBE_RUNTIME_DIR: runtime, CODEOID_PROBE_DOCKER_SOCKET: "1" }); + expect(shown.result).toBe("success"); + expect(shown.toolResult).toContain("PLANTED-RUNTIME"); + } finally { + await new Promise((r) => server.close(() => r())); + } + }, 300_000); + test("under Claude's sandbox too, a command adds a worktree in the sibling -worktrees location, commits there and removes it, while the user's worktrees stay held (#448 follow-up)", async () => { const r2 = realpathSync(mkdtempSync(join(scratchParent(), "codeoid-sbx-claude-wt-"))); try { diff --git a/src/tests/sandbox-docker.test.ts b/src/tests/sandbox-docker.test.ts index 2b34ebef..310d9a00 100644 --- a/src/tests/sandbox-docker.test.ts +++ b/src/tests/sandbox-docker.test.ts @@ -131,6 +131,21 @@ describe("dockerRunArgs", () => { expect(flags(other.wrap?.(["sh"], { cwd: "", env: {} }).argv ?? [], "--label")).not.toContain(handleLabel ?? ""); }); + test("Docker access (#448 follow-up): the runtime's socket mounted at /var/run/docker.sock with its group, DOCKER_HOST naming it; never without the option", () => { + expect(dockerRunArgs(spec(), ["sh"], base).join(" ")).not.toContain("docker.sock"); + const on = spec({ containerSockets: [], dockerSockets: ["/run/user/1000/docker.sock"], env: { DOCKER_HOST: "unix:///run/user/1000/docker.sock", GOCACHE: "/c" } }); + const args = dockerRunArgs(on, ["sh"], { ...base, platform: "linux", socketGroup: () => 998 }); + expect(flags(args, "--mount")).toContain("type=bind,source=/run/user/1000/docker.sock,target=/var/run/docker.sock"); + expect(flags(args, "--group-add")).toEqual(["998"]); + expect(flags(args, "-e")).toContain("DOCKER_HOST=unix:///var/run/docker.sock"); + expect(flags(args, "-e")).not.toContain("DOCKER_HOST=unix:///run/user/1000/docker.sock"); + expect(flags(args, "-e")).toContain("GOCACHE=/c"); + // Docker Desktop: the daemon's own name for its socket, whatever the host's link. + const mac = dockerRunArgs(spec({ dockerSockets: ["/Users/u/.docker/run/docker.sock"] }), ["sh"], { ...base, platform: "darwin" }); + expect(flags(mac, "--mount")).toContain("type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock"); + expect(flags(mac, "--group-add")).toEqual(["0"]); + }); + test("network: on is the bridge, off and an allowlist (Docker can't hold one) are none", () => { expect(flags(dockerRunArgs(spec(), ["x"], base), "--network")).toEqual(["bridge"]); expect(dockerNetwork("off")).toBe("none"); diff --git a/src/tests/sandbox-git-shim.test.ts b/src/tests/sandbox-git-shim.test.ts index f9629a31..3f42649e 100644 --- a/src/tests/sandbox-git-shim.test.ts +++ b/src/tests/sandbox-git-shim.test.ts @@ -183,6 +183,10 @@ describe("git shim: the script", () => { expect(s.privateTemp).toBe(true); // No worktree dirs: exactly the command spec's paths. expect(gitShimSpec(spec, { tempParent: "/g-tmp" }).readOnly).toEqual(spec.readOnly as string[]); + // git reaches no container runtime, whatever the session's commands may (#448 follow-up). + const docker = gitShimSpec({ ...spec, dockerSockets: ["/run/docker.sock"], env: { DOCKER_HOST: "unix:///run/docker.sock", GOCACHE: "/c" } }, { tempParent: "/g-tmp" }); + expect(docker.dockerSockets).toBeUndefined(); + expect(docker.env).toEqual({ GOCACHE: "/c" }); }); test("the CLI's worktree dirs: the repository's worktrees/ and the workspace's .claude/worktrees, real dirs only, never in a git dir held whole", () => { diff --git a/src/tests/sandbox-home.test.ts b/src/tests/sandbox-home.test.ts index 74c19a10..ed0a6cd4 100644 --- a/src/tests/sandbox-home.test.ts +++ b/src/tests/sandbox-home.test.ts @@ -406,6 +406,21 @@ describe("Claude's own sandbox, with the home model", () => { // The CLI's TMPDIR is the parent of its commands' (it adds claude-), its own files elsewhere. expect(claudeTempEnv(temp)).toEqual({ TMPDIR: temp.root, CLAUDE_CODE_TMPDIR: temp.cli }); }); + + test("Docker access (#448 follow-up): the socket readable through the denied home, and unix sockets let through the CLI's seccomp on Linux (by path on macOS)", () => { + const sock = join(root, "rt", "docker.sock"); + const spec = { sessionId: "d", workspace: join(root, "dw"), tempDir: join(root, "dt"), writable: [], hidden: [join(root, "rt")], reexposed: [], containerSockets: [], dockerSockets: [sock], network: { mode: "off" as const }, unsetEnv: [] }; + const s = claudeSandboxSettings({ spec, config: sandboxConfigOf(undefined) }, { HOME: join(root, "h") }); + expect(s.filesystem?.allowRead).toContain(sock); + // Connecting needs no write to it (checked on the real CLI): it can't be removed or replaced. + expect(s.filesystem?.allowWrite).not.toContain(sock); + expect(s.filesystem?.denyRead).toContain(join(root, "rt")); + if (process.platform === "linux") expect(s.network).toMatchObject({ allowAllUnixSockets: true, deniedDomains: ["*"] }); + else expect(s.network).toMatchObject({ allowUnixSockets: [sock] }); + const none = claudeSandboxSettings({ spec: { ...spec, dockerSockets: undefined }, config: sandboxConfigOf(undefined) }, { HOME: join(root, "h") }); + expect(none.network?.allowAllUnixSockets).toBeUndefined(); + expect(none.network?.allowUnixSockets).toBeUndefined(); + }); }); describe("a backend's private home", () => { diff --git a/src/tests/sandbox-manager.test.ts b/src/tests/sandbox-manager.test.ts index 55660ccc..e7d35e1d 100644 --- a/src/tests/sandbox-manager.test.ts +++ b/src/tests/sandbox-manager.test.ts @@ -7,6 +7,7 @@ * up front. */ +import { sandboxBadge, sandboxSummary } from "@highflame/codeoid-core"; import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -75,7 +76,7 @@ function newManager(probe: SandboxProbe = { available: true }, sandbox?: Record< const sandboxRegistry = new SandboxRegistry("linux", HOST_HAS); sandboxRegistry.register(fake("bwrap", probe)); sandboxRegistry.register(fake("docker", { available: false, reason: "the Docker daemon isn't reachable" })); - const config = { ...mkConfig(tmp), ...(sandbox ? { sandbox: { provider: "auto", network: "on", allowedHosts: [], hiddenPaths: [], homeReadable: [], worktreeRoots: [], docker: { image: "x" }, ...sandbox } } : {}) } as CodeoidConfig; + const config = { ...mkConfig(tmp), ...(sandbox ? { sandbox: { provider: "auto", network: "on", allowedHosts: [], hiddenPaths: [], homeReadable: [], worktreeRoots: [], docker: { image: "x", socket: false }, ...sandbox } } : {}) } as CodeoidConfig; const m = new SessionManager(store, transcript, undefined, undefined, undefined, { config, providers: registry, sandboxRegistry }); managers.push(m); return m; @@ -267,7 +268,7 @@ describe("round 1 of the #448 audit: wider than the daemon's sandbox is the owne it("a delegated token can't change the daemon's sandbox settings, and is audited", async () => { const m = newManager(); - for (const key of ["sandbox.provider", "sandbox.bwrapPath", "sandbox.docker.image", "sandbox.homeReadable", "sandbox.worktreeRoots"]) { + for (const key of ["sandbox.provider", "sandbox.bwrapPath", "sandbox.docker.image", "sandbox.homeReadable", "sandbox.docker.socket", "sandbox.worktreeRoots"]) { const r = await m.handle({ type: "settings.set", id: "s", patches: [{ key, value: "off" }] } as never, DELEGATED, { ...CLIENT, auth: DELEGATED }); expect(r).toMatchObject({ type: "response.error", code: "forbidden" }); expect((r as { error: string }).error).toContain("sandbox"); @@ -293,14 +294,14 @@ describe("routines (#448 round 2)", () => { sandboxRegistry.register(fake("seatbelt-like", { available: true })); const config = { ...mkConfig(tmp), - sandbox: { provider: "auto", network: "allowlist", allowedHosts: ["a.example"], hiddenPaths: [], homeReadable: [], worktreeRoots: [], docker: { image: "x" } }, + sandbox: { provider: "auto", network: "allowlist", allowedHosts: ["a.example"], hiddenPaths: [], homeReadable: [], worktreeRoots: [], docker: { image: "x", socket: false } }, routines: { enabled: true, tickMs: 600_000, maxConcurrentRuns: 2, minIntervalSeconds: 60, apiFiresPerHour: 30, missedGraceSeconds: 120 }, } as CodeoidConfig; const m = new SessionManager(store, transcript, undefined, undefined, undefined, { config, providers: registry, sandboxRegistry }); managers.push(m); const call = (spec: Record, auth: AuthContext) => m.handle({ type: "routine.create", spec: { prompt: "p", workdir, approvals: "guarded", ...spec } } as never, auth, CLIENT) as Promise; - for (const [i, sandbox] of [{ provider: "off" }, { provider: "seatbelt-like" }, { network: "on" }, { allowedHosts: ["a.example", "b.example"] }].entries()) { + for (const [i, sandbox] of [{ provider: "off" }, { provider: "seatbelt-like" }, { network: "on" }, { allowedHosts: ["a.example", "b.example"] }, { dockerSocket: true }].entries()) { const res = await call({ name: `wide-${i}`, sandbox }, NO_APPROVE); expect(res).toMatchObject({ type: "response.error", code: "forbidden" }); expect(res.type === "response.error" && res.error).toContain("session:approve"); @@ -313,3 +314,80 @@ describe("routines (#448 round 2)", () => { expect(await call({ name: "wide-ok", sandbox: { network: "on" } }, AUTH)).toMatchObject({ type: "routine.snapshot" }); }); }); + +describe("Docker access (#448 follow-up): an opt-in widening, the owner's own", () => { + const DELEGATED: AuthContext = { ...AUTH, delegationDepth: 1, delegatedBy: AUTH.sub } as AuthContext; + let audits: Array<{ action: string; detail: string }> = []; + beforeEach(() => { + audits = []; + const audit = store.audit.bind(store); + store.audit = (subject, action, sessionId, detail) => { + audits.push({ action, detail: detail ?? "" }); + audit(subject, action, sessionId, detail); + }; + }); + + it("create with it needs session:approve and an undelegated token; the session says so", async () => { + const m = newManager(); + for (const who of [DELEGATED, NO_APPROVE]) { + const r = await create(m, { sandbox: { dockerSocket: true } }, who); + expect(r).toMatchObject({ type: "response.error", code: "forbidden" }); + expect((r as { error: string }).error).toContain("Docker access"); + } + expect(audits.some((a) => a.action === "session.sandbox_wider_refused")).toBe(true); + const info = infoOf(await create(m, { sandbox: { dockerSocket: true } })); + expect(info?.sandbox).toMatchObject({ state: "on", dockerSocket: true }); + expect(sandboxBadge(info?.sandbox)).toMatchObject({ text: "sandboxed, Docker access", tone: "warn" }); + expect(sandboxBadge(info?.sandbox)?.title).toContain("Docker access can reach the whole machine"); + expect(sandboxSummary(info?.sandbox)).toContain("Docker access (can reach the whole machine)"); + }); + + it("set_sandbox: turning it on is a confirm the owner alone answers; a delegated token can't ask; turning it off applies at once", async () => { + const m = newManager(); + const info = infoOf(await create(m)); + const id = info?.id as string; + expect(await m.handle({ type: "session.set_sandbox", id: "x", sessionId: id, sandbox: { dockerSocket: true } }, DELEGATED, { ...CLIENT, auth: DELEGATED })).toMatchObject({ type: "response.error", code: "forbidden" }); + const asked: Array<{ requestId: string; title?: string; message?: string }> = []; + m.findByName(info?.name as string, AUTH)?.attach({ + id: "dialogs", + auth: AUTH, + capabilities: [CAPABILITIES.UI_DIALOGS], + send: (msg: { type: string; requestId?: string; request?: { title?: string; message?: string } }) => { + if (msg.type === "session.ui_request") asked.push({ requestId: msg.requestId ?? "", ...(msg.request ?? {}) }); + }, + } as never); + expect(await m.handle({ type: "session.set_sandbox", id: "x", sessionId: id, sandbox: { dockerSocket: true } }, AUTH, CLIENT)).toMatchObject({ data: { pending: true } }); + for (let i = 0; i < 100 && asked.length === 0; i++) await Bun.sleep(10); + expect(audits.some((a) => a.action === "session.sandbox_widen_ask" && a.detail.endsWith("network on, Docker access"))).toBe(true); + const requestId = asked[0]?.requestId ?? ""; + expect(requestId).not.toBe(""); + for (const who of [DELEGATED, OTHER]) { + expect(await m.handle({ type: "session.ui_response", id: "u", sessionId: id, requestId, confirmed: true } as never, who, { ...CLIENT, auth: who })).toMatchObject({ type: "response.error", code: "forbidden" }); + } + expect(await m.handle({ type: "session.ui_response", id: "u", sessionId: id, requestId, confirmed: true } as never, AUTH, CLIENT)).toMatchObject({ type: "response.ok" }); + for (let i = 0; i < 100 && !audits.some((a) => a.action === "session.set_sandbox"); i++) await Bun.sleep(10); + expect(audits.filter((a) => a.action === "session.set_sandbox").at(-1)?.detail).toContain("→ auto, network on, Docker access"); + expect(m.findByName(info?.name as string, AUTH)?.toInfo().sandbox?.dockerSocket).toBe(true); + // Off again: narrowing, applied without asking, by a delegated token too. + const narrowed = await m.handle({ type: "session.set_sandbox", id: "x", sessionId: id, sandbox: { dockerSocket: false } }, DELEGATED, { ...CLIENT, auth: DELEGATED }); + expect(narrowed).toMatchObject({ type: "response.ok" }); + expect(m.findByName(info?.name as string, AUTH)?.toInfo().sandbox?.dockerSocket).toBeUndefined(); + }); + + it("a fork carries it only for its owner, undelegated; autonomous on it is the owner's own call", async () => { + const m = newManager(); + const id = infoOf(await create(m, { sandbox: { dockerSocket: true } }))?.id as string; + for (const who of [OTHER, DELEGATED]) { + expect(await m.handle({ type: "session.fork", id: "f", sessionId: id } as never, who, { ...CLIENT, auth: who })).toMatchObject({ type: "response.error", code: "forbidden" }); + expect(await m.handle({ type: "session.set_mode", id: "m", sessionId: id, mode: "autonomous" }, who, { ...CLIENT, auth: who })).toMatchObject({ code: "forbidden" }); + } + expect((await m.handle({ type: "session.fork", id: "f", sessionId: id } as never, AUTH, CLIENT)).type).not.toBe("response.error"); + expect(await m.handle({ type: "session.set_mode", id: "m", sessionId: id, mode: "autonomous" }, AUTH, CLIENT)).toMatchObject({ type: "response.ok" }); + }); + + it("the daemon's default with it: a session gets it without asking for it, and asking for none is narrower", async () => { + const m = newManagerWith({ docker: { image: "x", socket: true } }); + expect(infoOf(await create(m, {}, DELEGATED))?.sandbox?.dockerSocket).toBe(true); + expect(infoOf(await create(m, { sandbox: { dockerSocket: false } }, DELEGATED))?.sandbox?.dockerSocket).toBeUndefined(); + }); +}); diff --git a/src/tests/sandbox-policy.test.ts b/src/tests/sandbox-policy.test.ts index 91e40407..25c22d38 100644 --- a/src/tests/sandbox-policy.test.ts +++ b/src/tests/sandbox-policy.test.ts @@ -12,6 +12,7 @@ import type { CodeoidConfig } from "../config.js"; import { loadConfig } from "../config.js"; import { BwrapSandboxProvider, type PathKind, bwrapArgs } from "../daemon/sandbox/bwrap.js"; import { + dockerSocketPaths, DEFAULT_HOME_READABLE, HOME_CREDENTIAL_PATHS, buildSandboxSpec, @@ -58,6 +59,29 @@ function mounts(args: string[]): string[][] { } describe("bwrapArgs", () => { + test("Docker access (#448 follow-up): a socket the owner let in is bound read-only at its own path, over the hidden runtime dir holding it; none without it", () => { + const run = "/run/user/1000"; + const sock = `${run}/docker.sock`; + const kinds = fs({ "/work/repo": "dir", [run]: "dir", [sock]: "other", "/var/tmp/codeoid-s1": "dir" }); + const without = mounts(bwrapArgs(spec({ hidden: [run] }), "", kinds)); + expect(without.some((m) => m[1] === sock)).toBe(false); + const with_ = mounts(bwrapArgs(spec({ hidden: [run], dockerSockets: [sock], env: { DOCKER_HOST: `unix://${sock}` } }), "", kinds)); + const hide = with_.findIndex((m) => m[0] === "--tmpfs" && m[1] === run); + const bind = with_.findIndex((m) => m[0] === "--ro-bind" && m[1] === sock && m[2] === sock); + expect(hide).toBeGreaterThanOrEqual(0); + expect(bind).toBeGreaterThan(hide); + expect(with_).toContainEqual(["--setenv", "DOCKER_HOST", `unix://${sock}`]); + }); + + test("dockerSocketPaths (#448 follow-up): $DOCKER_HOST's first, then Docker's, rootless Docker's, Podman's; only sockets that exist; on macOS Docker Desktop's", () => { + const there = new Set(["/run/user/7/podman/podman.sock", "/var/run/docker.sock", "/srv/d.sock", "/h/.docker/run/docker.sock"]); + const isSocket = (p: string) => there.has(p); + expect(dockerSocketPaths("/h", { XDG_RUNTIME_DIR: "/run/user/7" }, "linux", isSocket)).toEqual(["/var/run/docker.sock", "/run/user/7/podman/podman.sock"]); + expect(dockerSocketPaths("/h", { XDG_RUNTIME_DIR: "/run/user/7", DOCKER_HOST: "unix:///srv/d.sock" }, "linux", isSocket)[0]).toBe("/srv/d.sock"); + expect(dockerSocketPaths("/h", { DOCKER_HOST: "tcp://1.2.3.4:2375" }, "linux", () => false)).toEqual([]); + expect(dockerSocketPaths("/h", {}, "darwin", isSocket)).toEqual(["/h/.docker/run/docker.sock", "/var/run/docker.sock"]); + }); + test("the host read-only, a new /dev and /proc, a private /tmp, the workspace writable, starting in cwd", () => { const args = bwrapArgs(spec(), "/work/repo/sub", fs({ "/work/repo": "dir", "/var/tmp/codeoid-s1": "dir" })); expect(args.slice(0, 6)).toEqual(["--die-with-parent", "--new-session", "--unshare-pid", "--unshare-ipc", "--unshare-uts", "--unshare-cgroup-try"]); @@ -255,7 +279,7 @@ describe("hidden paths", () => { describe("config", () => { test("on by default: auto, network on", () => { const c = loadConfig({ raw: {}, env: {}, quiet: true }); - expect(c.sandbox).toEqual({ provider: "auto", network: "on", allowedHosts: [], hiddenPaths: [], homeReadable: [...DEFAULT_HOME_READABLE], worktreeRoots: [], docker: { image: "node:22-bookworm-slim" } }); + expect(c.sandbox).toEqual({ provider: "auto", network: "on", allowedHosts: [], hiddenPaths: [], homeReadable: [...DEFAULT_HOME_READABLE], worktreeRoots: [], docker: { image: "node:22-bookworm-slim", socket: false } }); }); test("env vars override the file", () => { diff --git a/src/tests/sandbox-seatbelt.test.ts b/src/tests/sandbox-seatbelt.test.ts index 5f76e2ce..80f2642c 100644 --- a/src/tests/sandbox-seatbelt.test.ts +++ b/src/tests/sandbox-seatbelt.test.ts @@ -92,6 +92,19 @@ describe("seatbeltProfile", () => { expect(text).toContain('"/Users/u/repo/opencode.json"'); }); + test("Docker access (#448 follow-up): a socket the owner let in is allowed last, its file and a connection to it, the network off too; Docker Desktop's link by both paths", () => { + const { text } = readable(spec({ containerSockets: [], dockerSockets: ["/var/run/docker.sock"], network: { mode: "off" } })); + const lines = text.split("\n"); + const deny = lines.indexOf("(deny network*)"); + for (const p of ["/var/run/docker.sock", "/private/var/run/docker.sock"]) { + const file = lines.indexOf(`(allow file-read* file-write-data (literal ${JSON.stringify(p)}))`); + const conn = lines.indexOf(`(allow network-outbound (remote unix-socket (path-literal ${JSON.stringify(p)})))`); + expect(file).toBeGreaterThan(deny); + expect(conn).toBeGreaterThan(deny); + } + expect(readable(spec()).text).not.toContain("(allow network-outbound"); + }); + test("worktrees (#448 follow-up): worktrees/ and a worktree location writable but not renamable; another worktree's git dir and checkout read-only", () => { const { text } = readable(spec({ writable: ["/Users/u/repo/.git", "/Users/u/repo/.git/worktrees", "/Users/u/repo-worktrees"], readOnly: ["/Users/u/repo/.git/worktrees/theirs", "/Users/u/repo-worktrees/theirs"] })); for (const p of ["/Users/u/repo/.git/worktrees", "/Users/u/repo-worktrees"]) { diff --git a/src/tests/sandbox-session.test.ts b/src/tests/sandbox-session.test.ts index d8efbd65..32a3cd1f 100644 --- a/src/tests/sandbox-session.test.ts +++ b/src/tests/sandbox-session.test.ts @@ -11,6 +11,7 @@ import { randomUUID } from "node:crypto"; import { existsSync, linkSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; +import { approvalSandboxLine } from "@highflame/codeoid-core"; import type { CodeoidConfig } from "../config.js"; import type { ProviderEvent } from "../daemon/providers/interface.js"; import { MockSessionProvider } from "../daemon/providers/mock/session-provider.js"; @@ -20,7 +21,7 @@ import type { BackendHostFiles } from "../daemon/sandbox/host-file-tools.js"; import { confinementFor } from "../daemon/sandbox/confinement.js"; import { LocalSandboxHandle } from "../daemon/sandbox/local-handle.js"; import { SandboxRegistry } from "../daemon/sandbox/registry.js"; -import { openCheckSandbox, resolveSandbox, SandboxRequiredError, type SandboxRuntime, SessionSandbox, sandboxRefusalOf, widens } from "../daemon/sandbox/session-sandbox.js"; +import { effectiveSetting, openCheckSandbox, resolveSandbox, SandboxRequiredError, type SandboxRuntime, SessionSandbox, sandboxRefusalOf, widens } from "../daemon/sandbox/session-sandbox.js"; import { sandboxConfigOf } from "../daemon/sandbox/policy.js"; import { sandboxRoot } from "../daemon/sandbox/root.js"; import type { NetworkMode, SandboxProbe, SandboxProvider, SandboxSpec } from "../daemon/sandbox/types.js"; @@ -157,7 +158,7 @@ async function until(cond: () => boolean, ms = 5_000): Promise { } } -type Card = { phase: string; sandboxed?: boolean; sandboxNote?: string; message?: string; risk?: { reasons: string[] } }; +type Card = { phase: string; sandboxed?: boolean; sandboxNote?: string; sandboxDocker?: boolean; message?: string; risk?: { reasons: string[] } }; /** The mock backend, saying what its own file tools keep outside the sandbox (SessionProvider.hostFiles). */ class WithHostFiles extends MockSessionProvider { @@ -309,6 +310,16 @@ describe("what a session's sandbox resolves to", () => { expect(widens(list("a.com"), on)).toBe(true); expect(widens(on, { choice: "docker", network: { mode: "on" } })).toBe(true); expect(widens({ choice: "off", network: { mode: "on" } }, on)).toBe(false); + // Docker access (#448 follow-up): root on the machine, so turning it on widens, off narrows. + expect(widens({ ...off, dockerSocket: false }, { ...off, dockerSocket: true })).toBe(true); + expect(widens({ ...on, dockerSocket: true }, { ...on, dockerSocket: false })).toBe(false); + expect(widens({ ...on, dockerSocket: true }, { ...off, dockerSocket: true })).toBe(false); + expect(widens({ choice: "off", network: { mode: "on" }, dockerSocket: false }, { ...on, dockerSocket: true })).toBe(false); + // The daemon's default counts: a session that sets nothing gets it; one that sets false doesn't. + const cfg = sandboxConfigOf({ sandbox: { docker: { socket: true } } } as never); + expect(effectiveSetting(cfg, undefined).dockerSocket).toBe(true); + expect(effectiveSetting(cfg, { dockerSocket: false }).dockerSocket).toBe(false); + expect(effectiveSetting(sandboxConfigOf(undefined), undefined).dockerSocket).toBe(false); }); }); @@ -360,6 +371,21 @@ describe("the approval card and the gate", () => { expect(r.card).toMatchObject({ sandboxed: true, sandboxNote: "sandboxed: fake-bwrap, network on" }); }); + test("a sandbox with Docker access: every card says so, as a warning (#448 follow-up)", async () => { + const r = await gateCall("Bash", { command: "ls" }, { sandbox: { dockerSocket: true } }); + expect(r.card).toMatchObject({ sandboxed: true, sandboxDocker: true, sandboxNote: "sandboxed, Docker access: fake-bwrap, network on" }); + expect(r.pending[0]).toMatchObject({ sandboxDocker: true, sandboxNote: "sandboxed, Docker access: fake-bwrap, network on" }); + expect(approvalSandboxLine(r.card as Card)).toBe("⚠️ sandboxed, Docker access: fake-bwrap, network on"); + const file = await gateCall("Write", { file_path: join(workdir, "a.ts"), content: "x" }, { sandbox: { dockerSocket: true } }); + expect(file.card).toMatchObject({ sandboxed: false, sandboxDocker: true }); + expect(approvalSandboxLine(file.card as Card)).toBe("⚠️ runs in the backend's own process, outside the sandbox (the sandbox has Docker access)"); + const info = r.session.toInfo().sandbox; + expect(info).toMatchObject({ state: "on", dockerSocket: true, enforced: true }); + expect(info?.limits?.some((l) => l.startsWith("Docker access"))).toBe(true); + // Autonomous runs on it: the owner chose it, as with off. + expect(r.session.sandboxRefusal({ forMode: "autonomous" })).toBeNull(); + }); + test("a Claude file tool runs in the CLI, outside its sandbox: the card says so", async () => { const r = await gateCall("Write", { file_path: join(workdir, "a.ts"), content: "x" }); expect(r.card).toMatchObject({ sandboxed: false, sandboxNote: "runs in the backend's own process, outside the sandbox" }); diff --git a/src/tests/sandbox-worktrees-real.test.ts b/src/tests/sandbox-worktrees-real.test.ts index 9bedacc9..6d6120cb 100644 --- a/src/tests/sandbox-worktrees-real.test.ts +++ b/src/tests/sandbox-worktrees-real.test.ts @@ -1,19 +1,22 @@ /** * Real bubblewrap (#448 follow-up), skipped where it can't run: an agent - * makes git worktrees inside the sandbox. + * makes git worktrees inside the sandbox, and tests that start containers + * get the container runtime's socket only when the owner opted in. * * Everything destructive runs INSIDE the sandbox under test, against a * repository this test planted in its own temp dir; "the user's next git" * runs outside the session's sandbox, in a bubblewrap that may write only the - * test's dir, and must run nothing a command planted. + * test's dir, and must run nothing a command planted. No real Docker socket + * is touched: a listening unix socket this test makes stands in for one. */ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { createServer, type Server } from "node:net"; import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; import { BwrapSandboxProvider } from "../daemon/sandbox/bwrap.js"; import { gitControlSnapshot } from "../daemon/sandbox/git-watch.js"; -import { buildSandboxSpec } from "../daemon/sandbox/policy.js"; +import { buildSandboxSpec, dockerSocketPaths } from "../daemon/sandbox/policy.js"; import type { SandboxHandle } from "../daemon/sandbox/types.js"; import { noteCodeoidWorktree, worktreeAdminsOf } from "../daemon/sandbox/worktrees.js"; @@ -332,3 +335,71 @@ describe.skipIf(!probe.available || !Bun.which("git"))("bubblewrap, for real: an } }); }); + +describe.skipIf(!probe.available)("bubblewrap, for real: Docker access (a planted socket stands in for the runtime's)", () => { + let root: string; + let runtime: string; + let sock: string; + let server: Server; + beforeAll(async () => { + root = realpathSync(mkdtempSync(join(scratchParent(), "codeoid-sbx-docker-"))); + // Rootless Docker's place: $XDG_RUNTIME_DIR/docker.sock, in a dir every sandbox hides. + runtime = join(root, "run-user"); + mkdirSync(runtime, { mode: 0o700 }); + mkdirSync(join(root, "ws")); + mkdirSync(join(root, "tmp")); + sock = join(runtime, "docker.sock"); + server = createServer((c) => c.end("PLANTED-RUNTIME\n")); + await new Promise((r) => server.listen(sock, r)); + }); + afterAll(async () => { + await new Promise((r) => server.close(() => r())); + rmSync(root, { recursive: true, force: true }); + }); + + const env = () => ({ HOME: join(root, "home"), XDG_RUNTIME_DIR: runtime, PATH: process.env.PATH ?? "/usr/bin:/bin" }); + const spec = (dockerSocket: boolean, network: "on" | "off" = "off") => + buildSandboxSpec({ + sessionId: "docker", + workspace: join(root, "ws"), + tempDir: join(root, "tmp"), + network: { mode: network }, + git: false, + dockerSocket, + hidden: { home: join(root, "home"), configDir: join(root, "home", ".codeoid"), env: env(), daemonTmp: "/tmp" }, + }); + const connect = (h: SandboxHandle, target: string) => + h.exec(["/bin/sh", "-c", `python3 -c 'import socket,sys; s=socket.socket(socket.AF_UNIX); s.connect(sys.argv[1]); print(s.recv(100).decode().strip())' '${target}' 2>&1; echo "DOCKER_HOST=$DOCKER_HOST"`], { cwd: join(root, "ws"), env: { PATH: "/usr/bin:/bin", DOCKER_HOST: "unix:///host-value" }, timeoutMs: 20_000 }); + + test.skipIf(!Bun.which("python3"))("hidden by default; reachable, with DOCKER_HOST naming it, only with the option (network off too)", async () => { + expect(dockerSocketPaths(join(root, "home"), env())).toEqual([sock]); + const off = spec(false); + expect(off.dockerSockets).toBeUndefined(); + const h0 = await provider.open(off); + try { + const r = await connect(h0, sock); + expect(r.stdout).not.toContain("PLANTED-RUNTIME"); + expect(r.stdout).toContain("DOCKER_HOST=\n"); + } finally { + await h0.close(); + } + for (const network of ["off", "on"] as const) { + const on = spec(true, network); + expect(on.dockerSockets).toEqual([sock]); + const h1 = await provider.open(on); + try { + const r = await connect(h1, sock); + expect(r.stdout).toContain("PLANTED-RUNTIME"); + expect(r.stdout).toContain(`DOCKER_HOST=unix://${sock}`); + // Reachable, never removable or replaceable. + const rm = await h1.exec(["/bin/sh", "-c", `rm -f '${sock}' 2>/dev/null; [ -S '${sock}' ] && echo SOCKET-KEPT`], { cwd: join(root, "ws"), env: { PATH: "/usr/bin:/bin" }, timeoutMs: 10_000 }); + expect(rm.stdout).toContain("SOCKET-KEPT"); + // The rest of the runtime dir stays hidden. + const ls = await h1.exec(["/bin/sh", "-c", `ls -A '${runtime}'`], { cwd: join(root, "ws"), env: { PATH: "/usr/bin:/bin" }, timeoutMs: 10_000 }); + expect(ls.stdout.trim()).toBe("docker.sock"); + } finally { + await h1.close(); + } + } + }); +}); diff --git a/src/tests/settings.test.ts b/src/tests/settings.test.ts index 4bde27b3..6ab5a9c5 100644 --- a/src/tests/settings.test.ts +++ b/src/tests/settings.test.ts @@ -129,16 +129,28 @@ describe("settings store", () => { expect(onDisk.session.turnStallTimeoutMs).toBe(200_000); }); - it("the Sandbox tab's worktree locations (#448 follow-up): default, written, read back, and loaded", () => { - expect(getSnapshot().values["sandbox.worktreeRoots"]?.value ?? []).toEqual([]); - const r = applyPatches([{ key: "sandbox.worktreeRoots", value: ["~/wt", "../shared-worktrees"] }]); + it("the Sandbox tab's Docker access and worktree locations (#448 follow-up): defaults, written, read back, and loaded", () => { + const before = getSnapshot(); + expect(before.values["sandbox.docker.socket"]).toEqual({ value: false, source: "default" }); + expect(before.values["sandbox.worktreeRoots"]?.value ?? []).toEqual([]); + const r = applyPatches([ + { key: "sandbox.docker.socket", value: true }, + { key: "sandbox.worktreeRoots", value: ["~/wt", "../shared-worktrees"] }, + ]); expect(r).toMatchObject({ ok: true, errors: [] }); - expect(getSnapshot().values["sandbox.worktreeRoots"]).toEqual({ value: ["~/wt", "../shared-worktrees"], source: "config" }); + const s = getSnapshot(); + expect(s.values["sandbox.docker.socket"]).toEqual({ value: true, source: "config" }); + expect(s.values["sandbox.worktreeRoots"]).toEqual({ value: ["~/wt", "../shared-worktrees"], source: "config" }); const onDisk = JSON.parse(readFileSync(configFilePaths().configPath, "utf8")); + expect(onDisk.sandbox.docker).toMatchObject({ socket: true }); expect(onDisk.sandbox.worktreeRoots).toEqual(["~/wt", "../shared-worktrees"]); - expect(loadConfig({ raw: onDisk, env: {}, quiet: true }).sandbox?.worktreeRoots).toEqual(["~/wt", "../shared-worktrees"]); - // The env var overrides. - expect(loadConfig({ raw: {}, env: { CODEOID_SANDBOX_WORKTREE_ROOTS: "/a,/b" }, quiet: true }).sandbox?.worktreeRoots).toEqual(["/a", "/b"]); + const loaded = loadConfig({ raw: onDisk, env: {}, quiet: true }); + expect(loaded.sandbox?.docker.socket).toBe(true); + expect(loaded.sandbox?.worktreeRoots).toEqual(["~/wt", "../shared-worktrees"]); + // The env vars override. + const env = loadConfig({ raw: {}, env: { CODEOID_SANDBOX_DOCKER_SOCKET: "true", CODEOID_SANDBOX_WORKTREE_ROOTS: "/a,/b" }, quiet: true }); + expect(env.sandbox?.docker.socket).toBe(true); + expect(env.sandbox?.worktreeRoots).toEqual(["/a", "/b"]); }); it("rejects an out-of-range value and writes NOTHING", () => { diff --git a/web/src/components/NewSessionModal.test.tsx b/web/src/components/NewSessionModal.test.tsx index 182022f0..a76bc6c4 100644 --- a/web/src/components/NewSessionModal.test.tsx +++ b/web/src/components/NewSessionModal.test.tsx @@ -865,6 +865,23 @@ describe("NewSessionModal sandbox choice (#448)", () => { ); }); + it("Docker access (#448 follow-up): a box, warned about, sent as dockerSocket; not offered with the sandbox off", async () => { + authMock.mockReturnValue(authOk(["claude"])); + requestMock.mockImplementation(async (m: { type: string }) => (m.type === "sandbox.providers" ? providersReply : { id: "s-new", name: "n", workdir: "/w" })); + const { getByText, getByLabelText, queryByLabelText, getByPlaceholderText } = render(() => ); + openNewSessionModal(); + fireEvent.click(getByText("change")); + await waitFor(() => expect(getByLabelText("Docker access")).toBeTruthy()); + fireEvent.click(getByLabelText("Docker access")); + expect(getByText(/Docker access can reach the whole machine/)).toBeTruthy(); + fireEvent.change(getByLabelText("Sandbox"), { target: { value: "off" } }); + expect(queryByLabelText("Docker access")).toBeNull(); + fireEvent.change(getByLabelText("Sandbox"), { target: { value: "" } }); + fireEvent.input(getByPlaceholderText("e.g. shield-refactor"), { target: { value: "demo" } }); + fireEvent.click(getByText("create")); + await waitFor(() => expect(requestMock).toHaveBeenCalledWith(expect.objectContaining({ type: "session.create", sandbox: { dockerSocket: true } }))); + }); + it("off says what it means", async () => { authMock.mockReturnValue(authOk(["claude"])); requestMock.mockResolvedValue(providersReply); diff --git a/web/src/components/NewSessionModal.tsx b/web/src/components/NewSessionModal.tsx index 7831c746..b693d2ad 100644 --- a/web/src/components/NewSessionModal.tsx +++ b/web/src/components/NewSessionModal.tsx @@ -32,14 +32,14 @@ import type { PackWire, SandboxProviderWire, SessionInfo } from "../protocol/typ interface SandboxProvidersData { enabled: boolean; auto: string | null; - default: { provider: string; network: string }; + default: { provider: string; network: string; dockerSocket?: boolean }; providers: SandboxProviderWire[]; } function sandboxDefaultLabel(d: SandboxProvidersData | null): string { if (!d) return "auto"; const p = d.default.provider === "auto" && d.auto ? `auto: ${d.auto}` : d.default.provider; - return `${p}, network ${d.default.network}`; + return `${p}, network ${d.default.network}${d.default.dockerSocket ? ", Docker access" : ""}`; } import DirectoryPicker from "./files/DirectoryPicker"; import { providerIds, providerLabel } from "../state/providers"; @@ -151,6 +151,8 @@ const NewSessionModal: Component = () => { const [sandboxChoice, setSandboxChoice] = createSignal(""); // "" = the daemon's default network; else on | off. const [sandboxNetwork, setSandboxNetwork] = createSignal(""); + // Docker access (the container runtime's socket): off unless ticked, or the daemon's default has it. + const [sandboxDocker, setSandboxDocker] = createSignal(false); // The sandbox choice is opened on demand ("change"), and only then asks the // daemon what it has (absent: an older daemon, or no sandboxing). const [sandboxOpen, setSandboxOpen] = createSignal(false); @@ -438,6 +440,7 @@ const NewSessionModal: Component = () => { setPackRole(""); setSandboxChoice(""); setSandboxNetwork(""); + setSandboxDocker(false); setSandboxOpen(false); setRoles(defaultRoles()); setWarnings([]); @@ -576,11 +579,12 @@ const NewSessionModal: Component = () => { : {}), ...(packId() ? { pack: packId() } : {}), ...(packRole() ? { packRole: packRole() } : {}), - ...(sandboxChoice() || sandboxNetwork() + ...(sandboxChoice() || sandboxNetwork() || sandboxDocker() ? { sandbox: { ...(sandboxChoice() ? { provider: sandboxChoice() } : {}), ...(sandboxNetwork() ? { network: sandboxNetwork() as "on" | "off" } : {}), + ...(sandboxDocker() ? { dockerSocket: true } : {}), }, } : {}), @@ -1027,6 +1031,23 @@ const NewSessionModal: Component = () => { + + + + +

+ Docker access can reach the whole machine: a command could start a container that mounts / and read or change anything, your credentials included. +

+

Off: the agent's commands can read your credentials and change any file you can, in every mode. diff --git a/web/src/components/SandboxChip.tsx b/web/src/components/SandboxChip.tsx index 8b5128f5..da412bf1 100644 --- a/web/src/components/SandboxChip.tsx +++ b/web/src/components/SandboxChip.tsx @@ -24,7 +24,7 @@ export const SandboxChip: Component<{ session: SessionInfo }> = (props) => { {(b) => ( [on|off|allow:host,…]`} + title={`${b().title}\nChange it with /sandbox [on|off|allow:host,…] [docker|nodocker]`} data-sandbox={props.session.sandbox?.state} > 🛡 {b().text} diff --git a/web/src/components/transcript/ApprovalBar.test.tsx b/web/src/components/transcript/ApprovalBar.test.tsx index 10f50dee..4f8995c1 100644 --- a/web/src/components/transcript/ApprovalBar.test.tsx +++ b/web/src/components/transcript/ApprovalBar.test.tsx @@ -11,7 +11,7 @@ vi.mock("../../state/connection", () => ({ newRequestId: () => "r", })); -import ApprovalBar from "./ApprovalBar"; +import ApprovalBar, { SandboxLine } from "./ApprovalBar"; import { ingestSessionList, focusSession, @@ -148,3 +148,17 @@ describe("ApprovalBar buttons", () => { ); }); }); + +describe("SandboxLine (#448)", () => { + it("a sandbox with Docker access is a warning on the card, saying why in its tooltip", () => { + const { getByText } = render(() => ); + const line = getByText("⚠️ sandboxed, Docker access: bubblewrap, network on"); + expect(line.className).toContain("text-warn"); + expect(line.getAttribute("title")).toContain("Docker access can reach the whole machine"); + cleanup(); + const plain = render(() => ).getByText("🛡 sandboxed: bubblewrap, network on"); + expect(plain.className).toContain("text-success"); + expect(plain.getAttribute("title")).toBeNull(); + }); +}); + diff --git a/web/src/components/transcript/ApprovalBar.tsx b/web/src/components/transcript/ApprovalBar.tsx index f27baa22..29d4686b 100644 --- a/web/src/components/transcript/ApprovalBar.tsx +++ b/web/src/components/transcript/ApprovalBar.tsx @@ -47,7 +47,7 @@ import { import { sessionList } from "../../state/sessions"; import type { CollaborationCost, SessionMessage, ToolRisk } from "../../protocol/types"; import { formatCollaborationCost, formatToolRisk } from "../../lib/format"; -import { approvalSandboxLine } from "@highflame/codeoid-core"; +import { approvalSandboxLine, approvalSandboxWarns, DOCKER_ACCESS_WHY } from "@highflame/codeoid-core"; /** * Repo/branch/content preview for a send-class fleet dispatch, or null for any @@ -142,6 +142,7 @@ const ApprovalBar: Component = () => { risk: s.risk, sandboxed: s.sandboxed, sandboxNote: s.sandboxNote, + sandboxDocker: s.sandboxDocker, }; }); @@ -230,6 +231,7 @@ const ApprovalBar: Component = () => { risk={snap().risk} sandboxed={snap().sandboxed} sandboxNote={snap().sandboxNote} + sandboxDocker={snap().sandboxDocker} dispatch={dispatchFor(snap().toolName, snap().input)} isPlanMode={isPlanMode()} busy={busy()} @@ -323,6 +325,8 @@ const BinaryBar: Component<{ /** Whether it runs in the session's OS sandbox (#448). */ sandboxed?: boolean; sandboxNote?: string; + /** The sandbox lets commands reach the container runtime (Docker access): a warning. */ + sandboxDocker?: boolean; /** Present only for a send-class fleet dispatch — repo/branch/content (R3). */ dispatch?: DispatchPreviewModel | null; isPlanMode: boolean; @@ -356,7 +360,7 @@ const BinaryBar: Component<{ {(d) => } - + {(c) => (

@@ -414,12 +418,18 @@ export const RiskLine: Component<{ risk?: ToolRisk }> = (props) => ( /** * Whether the call runs inside the session's OS sandbox (#448), in the same * words as every other surface (approvalSandboxLine). Not sandboxed is a - * warning: the risk line above is guidance, the sandbox is the boundary. + * warning: the risk line above is guidance, the sandbox is the boundary. So + * is a sandbox with Docker access (it can reach the whole machine). */ -export const SandboxLine: Component<{ sandboxed?: boolean; sandboxNote?: string }> = (props) => ( +export const SandboxLine: Component<{ sandboxed?: boolean; sandboxNote?: string; sandboxDocker?: boolean }> = (props) => ( {(text) => ( -
+
{text()}
)} diff --git a/web/src/components/transcript/MessageRow.tsx b/web/src/components/transcript/MessageRow.tsx index a01a529e..faa694d6 100644 --- a/web/src/components/transcript/MessageRow.tsx +++ b/web/src/components/transcript/MessageRow.tsx @@ -467,7 +467,7 @@ const WaitingConfirmationBody: Component<{ {props.state.description}
- +
From b4dfb80cc8d3ac23b9462747e20236a068477946 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Sat, 7 Nov 2026 09:26:25 +0800 Subject: [PATCH 3/9] fix: #448 round-10 audit: sandboxed sessions write git worktrees only 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 (`-worktrees/`, `/.worktrees/`, `//` 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 --- CHANGELOG.md | 5 + CLAUDE.md | 26 +- docs/CONFIGURATION.md | 4 +- docs/sandbox-providers.md | 5 +- docs/sandbox.md | 188 ++++++---- src/config.ts | 21 +- src/daemon/providers/codex/index.ts | 4 +- src/daemon/sandbox/claude-native.ts | 12 +- src/daemon/sandbox/git-watch.ts | 151 +++++++- src/daemon/sandbox/policy.ts | 78 +++-- src/daemon/sandbox/session-sandbox.ts | 82 +++-- src/daemon/sandbox/types.ts | 12 +- src/daemon/sandbox/worktrees.ts | 340 ++++++++++++++---- src/daemon/session.ts | 21 +- src/daemon/settings/manifest.ts | 2 +- src/tests/fixtures/claude-sandbox-probe.ts | 7 +- src/tests/sandbox-bwrap-real.test.ts | 10 +- src/tests/sandbox-claude-real.test.ts | 23 +- src/tests/sandbox-codex.test.ts | 6 +- src/tests/sandbox-git-watch.test.ts | 4 +- src/tests/sandbox-home.test.ts | 33 +- src/tests/sandbox-worktree-folders.test.ts | 216 ++++++++++++ src/tests/sandbox-worktrees-real.test.ts | 378 ++++++++++++++------- 23 files changed, 1241 insertions(+), 387 deletions(-) create mode 100644 src/tests/sandbox-worktree-folders.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c52ebe3d..a29b0fbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,11 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). The session header, `codeoid ls`, approval cards (web, CLI, Telegram) show whether commands are sandboxed. See [docs/sandbox.md](docs/sandbox.md): what is protected per provider and per backend, what isn't, and what remains to verify on macOS and with Docker. +- **Sandboxed sessions can make git worktrees, each in a folder of its own** (#448). + `git worktree add`, commit, rebase, `remove` and `prune` work inside the sandbox, in the session's own folder of each worktree location: `-worktrees//`, `/.worktrees//`, and `///` for each `sandbox.worktreeRoots` entry (`CODEOID_SANDBOX_WORKTREE_ROOTS`, Settings > Sandbox). The folder is named after the session with a short id (`fix-login-3f9a2c1d`), stays the same across a rename and a restart, and the session's system prompt tells the agent where it is. + Everything else in a location stays read-only to the session: your worktrees, other sessions' folders, codeoid's fork, loop and `--worktree` checkouts, and anything made there later; every existing worktree's admin dir is held, and a session's sandbox is rebuilt when one appears. A `worktreeRoots` entry where tools load programs or settings (`~/.config`, `~/.local`, `~/Library`, …), the home or `/` fails the config's load; a root that doesn't exist is never made away from the repository. + The end-of-turn warning names a worktree in the session's folder whose `.git` points anywhere but its own admin dir, and an admin dir that changed. See [docs/sandbox.md](docs/sandbox.md#git-worktrees-inside-the-sandbox). + - **Autonomous mode never waits on approvals; the OS sandbox is its boundary** (#448). An autonomous session, routine, loop or dispatched worker doesn't start without a sandbox that runs (or its owner's own choice to turn it off), so inside it a shell command of any risk level runs unasked, high and not understood included, and a write lands wherever it leads, outside the working directory too; risk levels are information there (on cards in guarded mode, in the audit log). What the sandbox can't be left to hold is refused at once instead of asked, the agent told what was refused and that the owner can do it or switch the session to guarded mode, with an info line in the transcript and an audit entry (`session.autonomous_refuse`): a command asking to run outside the sandbox or to widen it, a file tool writing the repository's git structure or project settings, a write to a Claude settings file on a named account, a search or command reaching a directory that holds credentials, and a path a backend couldn't resolve for certain. Credential reads stay refused in every mode. diff --git a/CLAUDE.md b/CLAUDE.md index aa271c81..81637b6d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -391,14 +391,22 @@ past allow rules by the PreToolUse hook; owner-only; widening is a confirm on the session (`widens`), never on an unattended turn. Forks and collaboration children inherit the setting. Worktrees (sandbox/worktrees.ts, docs/sandbox.md "Git worktrees inside the -sandbox"): `/worktrees/` is writable, bound at its own path, and the -repository's worktree locations (`-worktrees/`, `.worktrees/`, -`sandbox.worktreeRoots`; `GitPaths.locations`, `SandboxSpec.worktreeLocations`) -are writable, so a command can `git worktree add/remove/prune`; every worktree -that isn't the session's own is held whole (its admin dir, its `.git` file, -its checkout when in a location). Own = appeared since `SessionSandbox`'s -first look (`worktreeBaseline`), checked out in the workspace or a location, -not codeoid's (`noteCodeoidWorktree` in git-worktree.ts). Docker access +sandbox", #448 round 10): `/worktrees/` is writable, bound at its own +path; a session writes only its own folder of each worktree location +(`-worktrees/`, `.worktrees/`, `//` +for `sandbox.worktreeRoots`; `sessionFolderName`: its name and short id, found +again by the id after a rename or restart; `SandboxSpec.worktreeFolders`, +Codex's roots), the rest of a location read-only (shown read-only, but not on +Claude native, whose CLI would lay it over the folder), a location inside the +workspace held whole; told to the agent in the system prompt +(`worktreeFolderNote`). Every admin dir not checked out in the folder is held +whole (`isOwnWorktree`); `SessionSandbox`'s spec is rebuilt when an admin dir +appears or goes (`worktreeSignature`). `worktreeRootProblem` refuses a root at +config load (`/`, home, a dot dir of the home, `~/Library`, the config dir, a +system dir); a missing root is made only beside the repository +(`worktreeRootNotes` says the rest). The end-of-turn look covers the +folder's `.git`s (`GitControlOptions.ownFolders`) and treats admin dirs as +groups (a new plain one or a removed one isn't news). Docker access (`sandbox.docker.socket`, a session's `dockerSocket`, off by default): `dockerSocketPaths` → `SandboxSpec.dockerSockets` + `DOCKER_HOST`, a widening in `widens` (so create/fork/routine/loop/set_sandbox authority follows), @@ -455,7 +463,7 @@ CODEOID_DEFAULT_EFFORT= # thinking effort for new sessions: off|mini CODEOID_SANDBOX=auto # OS sandbox for agents' commands: auto|bwrap|seatbelt|docker|off (docs/sandbox.md) CODEOID_SANDBOX_NETWORK=on # on|off|allowlist (CODEOID_SANDBOX_ALLOWED_HOSTS); hiddenPaths: CODEOID_SANDBOX_HIDDEN_PATHS CODEOID_SANDBOX_DOCKER_SOCKET=false # let sandboxed commands reach the container runtime (root-equivalent; docs/sandbox.md) -CODEOID_SANDBOX_WORKTREE_ROOTS= # more places a sandboxed command may check a worktree out (csv) +CODEOID_SANDBOX_WORKTREE_ROOTS= # more worktree locations (csv): //; never ~/.config, ~, / # Claude auth: uses your existing `claude login` session (Pro/Max subscription). # No ANTHROPIC_API_KEY needed if you're logged in via Claude Code CLI. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index a096459e..900cee73 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -114,7 +114,7 @@ CODEOID_SANDBOX_HIDDEN_PATHS= # more paths to hide: ~/work/.env CODEOID_SANDBOX_HOME_READABLE= # replaces the home allowlist: ~/.bun,~/.cargo/bin,~/.gitconfig CODEOID_SANDBOX_DOCKER_IMAGE=node:22-bookworm-slim CODEOID_SANDBOX_DOCKER_SOCKET=false # Docker access: commands reach the container runtime (root-equivalent) -CODEOID_SANDBOX_WORKTREE_ROOTS= # more places a command may check a worktree out: ~/wt,../shared-worktrees +CODEOID_SANDBOX_WORKTREE_ROOTS= # more worktree locations: ~/wt,../shared-worktrees (each repo gets //, each session a folder there) # Forks, backend switches and rewinds (docs/FEATURES.md#how-a-fork-switch-or-rewind-carries-the-conversation) CODEOID_NATIVE_CONTINUATION=true # hand Claude/Codex the conversation in their own form; false = text transcript @@ -252,7 +252,7 @@ Agents' commands run in an OS sandbox, on by default ([sandbox.md](sandbox.md)): - `bwrapPath` — the bubblewrap binary (default: on `PATH`). Claude Code's own sandbox on Linux also needs `socat`. - `docker.image` (`CODEOID_SANDBOX_DOCKER_IMAGE`) and `docker.runtime` — what a Docker sandbox runs, and `runsc` for gVisor. - `docker.socket` (`CODEOID_SANDBOX_DOCKER_SOCKET`, default `false`) — Docker access: sandboxed commands can reach the host's container runtime socket (Docker, rootless Docker, Podman; Docker Desktop on macOS), with `DOCKER_HOST` set, for tests that start containers. Root-equivalent (a container can mount `/`), so every session with it says "sandboxed, Docker access"; a session turns it on for itself with `--docker-socket` / `/sandbox … docker`, asked of its owner ([sandbox.md](sandbox.md#docker-access)). -- `worktreeRoots` (`CODEOID_SANDBOX_WORKTREE_ROOTS`, comma separated) — more places a sandboxed command may check a git worktree of its repository out, on top of `-worktrees/` and `/.worktrees/` (`~` expands; a relative path is the repository's main checkout's). Writable to sandboxed commands ([sandbox.md](sandbox.md#git-worktrees-inside-the-sandbox)). +- `worktreeRoots` (`CODEOID_SANDBOX_WORKTREE_ROOTS`, comma separated) — more places a sandboxed session may check a git worktree of its repository out, on top of `-worktrees/` and `/.worktrees/` (`~` expands; a relative path is the repository's main checkout's, at most one level up). A root may be shared: each repository gets `//`, and a sandboxed session writes only its own folder there. A root that doesn't exist is used once you create it (codeoid makes one only beside the repository). The config fails to load on `/`, the home or a dir holding it, anything in a hidden dir of the home (`~/.config`, `~/.local`, …), `~/Library`, codeoid's config dir or a system dir ([sandbox.md](sandbox.md#git-worktrees-inside-the-sandbox)). - Where the sandbox can't run, interactive sessions run unsandboxed and say so on every approval; autonomous sessions, routines and loops refuse to start. `codeoid sandbox providers` shows what runs here. ### One daemon per data directory diff --git a/docs/sandbox-providers.md b/docs/sandbox-providers.md index 2bc7ace4..57170810 100644 --- a/docs/sandbox-providers.md +++ b/docs/sandbox-providers.md @@ -37,14 +37,15 @@ interface SandboxSpec { privateTemp?: boolean; // a fresh temp dir per program, gone when it exits, never tempDir itself (the git shim's): // bwrap/Docker a tmpfs /tmp; Seatbelt a dir the wrapper makes in tempDir and removes writable: string[]; // the backend's private home (when its whole process is inside); a git repository's dirs (its - // worktrees/ too); the cache dir; the repository's worktree locations + // worktrees/ too); the cache dir; the session's own folder in each worktree location // (each bound at its own path: none may be renamed, as a mount point can't) hidden: string[]; // not readable at all (the user's home first, whole) reexposed: string[]; // read-only inside something hidden (the home allowlist, the backend's program and login) readOnly?: string[]; // read-only inside something writable, with what is in it (what picks a repository's config, // hooks and git dir; the project settings agent CLIs run code from); each exists gitDirs?: string[]; // the git dirs a commit writes (a backend's own sandbox's extra writable roots) - worktreeLocations?: string[]; // where a command checks a new worktree out (in writable too; a backend's own sandbox's roots) + worktreeFolders?: string[]; // the session's folder in each worktree location: where a command checks a new worktree out + // (in writable too; a backend's own sandbox's roots); nothing else of a location is writable env?: Record; // set inside: the toolchains' caches in the sandbox's cache dir pinned?: string[]; // can't be renamed or removed, contents still writable (a dir between the workspace and a hidden path) containerSockets: string[]; // never reachable diff --git a/docs/sandbox.md b/docs/sandbox.md index 182028ce..c2ef3927 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -3,7 +3,7 @@ Every shell command an agent runs, on every backend that can be sandboxed, runs inside an operating-system sandbox. The workspace and a private temp directory are writable (and, so an agent can branch in a worktree of its own, -the repository's [worktree locations](#git-worktrees-inside-the-sandbox)); +the session's own [worktree folder](#git-worktrees-inside-the-sandbox)); everything else is read-only; credential stores are not there at all; container runtime sockets can't be reached unless the owner turns on [Docker access](#docker-access); the network is a per-session setting. @@ -71,8 +71,8 @@ With the default policy (workspace-write): |---|---| | **Your home: hidden whole** | the sandbox starts from an empty, read-only home. A credential that isn't on any list, one created after the session started (`aws configure`, `gh auth login`), or one a program replaces by writing a new file and renaming it over the old one, is simply not there. What is shown again, read-only: the **allowlist** `sandbox.homeReadable` (below), and what the backend itself needs (its program; its private home, below). The workspace, when it is in your home, is shown writable. | | **Home allowlist** (`sandbox.homeReadable`, read-only) | by default toolchains and tool settings that hold no secret: `~/.bun`, `~/.cargo/bin`, `~/.cargo/registry`, `~/.cargo/git`, `~/.rustup`, `~/.nvm`, `~/.npm`, `~/.local/bin`, `~/.local/lib` (pip `--user` packages), `~/.local/share/pnpm`, `~/.local/share/uv`, `~/.local/share/pipx`, `~/.local/share/mise`, `~/.local/share/claude/versions` (the Claude installer's binaries), `~/.pyenv`, `~/go/pkg`, `~/go/bin`, `~/.gitconfig`, `~/.config/git/config`, `~/.config/git/ignore`, `~/.config/git/attributes`, `~/.claude/plugins` and `~/.claude/skills` (installed plugins and user skills, so a skill's script run as a command can read its own files; the CLI's settings, credentials and hooks stay hidden), and `~/.claude/projects` (every Claude session's transcript and memory, read-only, so an agent can look up what another session did; a secret once pasted into a session is in its transcript, so remove it from the list to hide them). Not listed, so hidden: `~/.npmrc`, `~/.bunfig.toml`, `~/.yarnrc.yml`, `~/.pypirc`, `~/.cargo/credentials.toml`, `~/.git-credentials`, `~/.config/git/credentials`, `~/.gradle`, `~/.m2` and everything else. A global npmrc inside an nvm install (`~/.nvm/versions/node/*/etc/npmrc`) and uv's credential store (`~/.local/share/uv/credentials`) are hidden again. A link inside a listed dir (`~/.local/bin/x`) works only where it leads into the list itself (a pipx or `uv tool` venv, the Claude installer's versions) or outside the home: what it leads to elsewhere in the home (a project of yours, with its `.env`) stays hidden, and the link dangles. List the target's dir in `sandbox.homeReadable` to show it. A link in the list (`~/.gitconfig` into a dotfiles repository) is shown at its own path and where it leads. A token written into `~/.gitconfig` itself (a URL carrying one, an `http.extraHeader`) would show: keep it in a credential helper. | -| **Writable** | the session's working directory (its worktree), a private temp dir made for the session (`$TMPDIR`, and the sandbox's `/tmp` under bubblewrap and Docker), what a commit in the workspace's **git repository** writes: its git dir, a linked worktree's own git dir and its repository's (objects, refs, logs, the index, `HEAD`, `packed-refs` and the lock files beside them), wherever they are, a repository under the host's `/tmp` included; and, so a command can add a worktree, the repository's `worktrees/` (where git makes a new worktree's git dir) and its **worktree locations**: `-worktrees/`, `/.worktrees/` and `sandbox.worktreeRoots` ([below](#git-worktrees-inside-the-sandbox)). Nothing else. | -| **Read-only** | everything else on the machine. `rm -rf ~` fails with "Read-only file system". Inside the writable, **a repository's structure**: everything that decides which config, hooks or git dir git uses, since a hook, `core.hooksPath` or `core.fsmonitor` planted there (or a config git is pointed at) would run outside the sandbox at your next `git status`. That is, in the repository's git dir: `config`, `config.worktree`, `commondir`, `hooks/`, `info/`, `modules/` (a submodule's git dir carries hooks and config of its own), `remotes/`, `branches/`; in `worktrees/` (itself writable, so a command can add a worktree, but bound at its own path: it can't be renamed away and rebuilt), **every worktree's git dir that isn't the session's own, whole** (its `commondir`, `gitdir`, `config.worktree`, HEAD and index: it can't be rewritten, renamed, removed or pruned), with its checkout's `.git` file wherever the session can write, and its whole checkout when that is in a worktree location ([below](#git-worktrees-inside-the-sandbox)); in a linked worktree's own git dir: `commondir`, `gitdir`, `config.worktree`; the worktree's `.git` file, and the `.git` file of every submodule checked out inside the workspace. A **submodule checkout** as the workspace (its `.git` a file naming `/.git/modules/`, whose `core.worktree` names it back) is held the same way, its git dir being the repository's. Any other `.git` file (a `--separate-git-dir` checkout, which git's records don't name back) is held read-only, with what it names when that is inside the workspace: nothing commits from inside, and the session's sandbox limits say so. The git dirs themselves are bound at their own paths, so none can be renamed away and rebuilt (`mv .git .git-old` fails), the workspace's `.git` dir included. A plain repository at the top of the workspace gets an empty `HEAD/` dir beside its `.git`, held read-only (git doesn't see an empty dir): a `.git` made invalid (its `HEAD` garbled, `refs/` removed) would otherwise have git take the workspace's top for a bare repository planted there, with its own config. What of these isn't there yet is made first, so there is something to hold: empty `hooks/`, `info/`, `modules/`, `remotes/`, `branches/`, `worktrees/`, an empty `config.worktree` in every git dir of the repository, and a `commondir` in the repository's git dir holding `.` (git, libgit2 and gitoxide read it as that git dir itself; see Limits). An entry that would have to be held but is a link (a bind can't hold one) makes the git dir read-only whole: commits from inside then fail. And **the project settings agent CLIs run code from** (below). | +| **Writable** | the session's working directory (its worktree), a private temp dir made for the session (`$TMPDIR`, and the sandbox's `/tmp` under bubblewrap and Docker), what a commit in the workspace's **git repository** writes: its git dir, a linked worktree's own git dir and its repository's (objects, refs, logs, the index, `HEAD`, `packed-refs` and the lock files beside them), wherever they are, a repository under the host's `/tmp` included; and, so a command can add a worktree, the repository's `worktrees/` (where git makes a new worktree's git dir) and the session's own **worktree folder** in each worktree location: `-worktrees//`, `/.worktrees//` and `///` for `sandbox.worktreeRoots` ([below](#git-worktrees-inside-the-sandbox)). Nothing else. | +| **Read-only** | everything else on the machine. `rm -rf ~` fails with "Read-only file system". Inside the writable, **a repository's structure**: everything that decides which config, hooks or git dir git uses, since a hook, `core.hooksPath` or `core.fsmonitor` planted there (or a config git is pointed at) would run outside the sandbox at your next `git status`. That is, in the repository's git dir: `config`, `config.worktree`, `commondir`, `hooks/`, `info/`, `modules/` (a submodule's git dir carries hooks and config of its own), `remotes/`, `branches/`; in `worktrees/` (itself writable, so a command can add a worktree, but bound at its own path: it can't be renamed away and rebuilt), **every worktree's git dir that isn't checked out in the session's own folder, whole** (its `commondir`, `gitdir`, `config.worktree`, HEAD and index: it can't be rewritten, renamed, removed or pruned), with its checkout's `.git` file when that is in the workspace; the rest of every worktree location, the main checkout's `.worktrees/` included when it is in the workspace ([below](#git-worktrees-inside-the-sandbox)); in a linked worktree's own git dir: `commondir`, `gitdir`, `config.worktree`; the worktree's `.git` file, and the `.git` file of every submodule checked out inside the workspace. A **submodule checkout** as the workspace (its `.git` a file naming `/.git/modules/`, whose `core.worktree` names it back) is held the same way, its git dir being the repository's. Any other `.git` file (a `--separate-git-dir` checkout, which git's records don't name back) is held read-only, with what it names when that is inside the workspace: nothing commits from inside, and the session's sandbox limits say so. The git dirs themselves are bound at their own paths, so none can be renamed away and rebuilt (`mv .git .git-old` fails), the workspace's `.git` dir included. A plain repository at the top of the workspace gets an empty `HEAD/` dir beside its `.git`, held read-only (git doesn't see an empty dir): a `.git` made invalid (its `HEAD` garbled, `refs/` removed) would otherwise have git take the workspace's top for a bare repository planted there, with its own config. What of these isn't there yet is made first, so there is something to hold: empty `hooks/`, `info/`, `modules/`, `remotes/`, `branches/`, `worktrees/`, an empty `config.worktree` in every git dir of the repository, and a `commondir` in the repository's git dir holding `.` (git, libgit2 and gitoxide read it as that git dir itself; see Limits). An entry that would have to be held but is a link (a bind can't hold one) makes the git dir read-only whole: commits from inside then fail. And **the project settings agent CLIs run code from** (below). | | **Project settings** (read-only) | `.claude/`, `.gemini/`, `.qwen/`, `.codex/`, `.pi/`, `.opencode/` in the workspace (and, for a session in a monorepo's package, in every dir from the package up to the repository's top: where the CLIs look), whole (made empty first where missing: git doesn't see an empty dir; not in a workspace that is your home or holds it, a temp root or `/`, where they would be the CLIs' own config dirs: there only those that exist are held), and `.mcp.json`, `opencode.json`, `opencode.jsonc` at its top, made first where missing (in the same places) holding an empty config (`{"mcpServers":{}}`, `{}`: what the CLIs read as none; your own `opencode` loads a project `opencode.json`'s MCP servers and plugins at its start) and, in a git repository, listed in its `.git/info/exclude` so `git status` and `git add -A` don't see them (an existing file is held as it is). A Claude hook, an MCP server, a pi or opencode plugin written there by one backend's command would run on the host in a later session of another, or in your own CLI. Changing them needs the sandbox lifted for that command: Claude's `dangerouslyDisableSandbox` and Codex's permission request are asked about in guarded and interactive mode and refused in autonomous mode (below); on other backends edit them yourself, or turn the session's sandbox off. A file tool that runs in the backend's own process, outside the sandbox (Claude's Write, Edit, MultiEdit and NotebookEdit, opencode's write, edit and patch) is held to the same rules at codeoid's gate in autonomous mode ([below](#file-tools-that-run-outside-the-sandbox)): writing what the sandbox holds read-only is refused there, and asked about in guarded and interactive mode. Writing the rest of **the repository's git dir** (its objects and refs, which a sandboxed commit writes, a linked worktree's, the `.git` file, another checkout's `.git` file in the workspace), or a file with more than one hard link, is asked about in guarded and interactive mode while the sandbox is on, the card saying why (audited `session.git_structure_write_ask` / `session.project_settings_write_ask`), and refused at once in autonomous mode (audited `session.autonomous_refuse`, kind `held_write`); on Claude the PreToolUse hook routes it to the gate past any allow rule. A backend whose file tools run inside the sandbox (Codex, pi, the Gemini CLI, Qwen; Claude under Docker) is held by the sandbox itself: nothing of this applies to it, a hard-linked file in `node_modules` included. | | **Hidden besides** (not readable at all) | outside the home, or in a workspace that holds it: codeoid's config dir (`~/.codeoid`: keys, accounts, endpoints, the local token) and every data dir it owns wherever it is configured (the database, transcripts and checkpoints, opencode's database and data dirs, memory's; their lock files); the agent CLIs' logins (`~/.claude/.credentials.json`, `~/.codex/auth.json`, `~/.qwen/oauth_creds.json`, `~/.gemini/oauth_creds.json` …, pi's `auth.json`) and what backends registered as credentials (opencode's key files); `~/.ssh`, `~/.aws`, `~/.azure`, `~/.config/gcloud`, `~/.kube`, `~/.docker`, `~/.gnupg`, `~/.config/gh`, `~/.config/hub`, `~/.config/glab-cli`, `~/.netrc`, `~/.git-credentials`, `~/.config/git/credentials`, `~/.local/share/keyrings`, `~/.password-store`, `~/.config/op`, `~/.vault-token`, `~/.pypirc`, `~/.cargo/credentials(.toml)`, `~/.terraform.d/credentials.tfrc.json`, `~/.claude.json` (MCP tokens), `~/.local/share/opencode/auth.json` and `mcp-auth.json`, `~/.config/github-copilot`, `~/.pgpass`, `~/.config/rclone`, `~/.s3cfg`, `~/.boto`, `~/.pulumi/credentials.json`, `~/.config/doctl`, `~/.fly`, `~/.npmrc`, `~/.bunfig.toml`, `~/.yarnrc.yml`, `~/.gradle/gradle.properties`, `~/.m2/settings.xml`, `~/Library/Keychains`; the desktop session's runtime dir (`$XDG_RUNTIME_DIR`: keyring, D-Bus, agent sockets) and `$SSH_AUTH_SOCK`'s dir; the daemon's own temp dir when it isn't `/tmp`, and always the daemon's private dir in it (`codeoid-sandbox-*`, made at its start, 0700), where it makes every temp entry of its own: sign-in homes, a check's artifacts, a loop's index, pi's bridge, the sessions' temp dirs and launchers — so one made while a session runs is hidden from it too, on Claude's own sandbox (which keeps the host's `/tmp`) as well; plus `sandbox.hiddenPaths` (which also beats the allowlist). | | **The daemon's own git** | every git codeoid runs in a repository an agent can write (a fork's worktree made and removed, a destroyed session's work snapshotted, a worktree's setup, the branch and status the UI shows, loops) runs nothing that repository's config names, sandbox or not: no hook (the hooks dir's or one defined in config, `hook..command`), fsmonitor, alternates' ref command (`core.alternateRefsCommand`), external diff, signing or signature check (gpg's and ssh's programs, `gpg.ssh.defaultKeyCommand`), editor or pager, git-protocol proxy or auto gc, `core.sshCommand` is `ssh`, and every filter, diff or merge driver and credential helper defined in the repository's own config (local or worktree, includes too) is reset, your own global or system definition put back (git lfs installed globally keeps working; a repository redefining it doesn't). Reaching a remote (a base branch fetched for a fork, a partial clone's objects) is https or ssh only. Loops' repositories are stricter still: every driver off, no system config. Checkpoints run in codeoid's own shadow repository, whose config is codeoid's. | @@ -127,8 +127,10 @@ with `sandbox.allowedHosts` (`CODEOID_SANDBOX_ALLOWED_HOSTS`, `example.com`, `*.example.com`, `localhost`; anything else fails the config's load). `sandbox.hiddenPaths` (`CODEOID_SANDBOX_HIDDEN_PATHS`) adds paths to hide (`~` expands). `sandbox.worktreeRoots` (`CODEOID_SANDBOX_WORKTREE_ROOTS`, Settings > -Sandbox > More worktree locations) adds places a command may check a worktree -out ([below](#git-worktrees-inside-the-sandbox)). `sandbox.docker.socket` +Sandbox > More worktree locations) adds places a session may check a worktree +out, `///`; a root where tools load programs or settings +from (`~/.config`, `~/.local`, `~/Library`, …), the home or `/` fails the +config's load ([below](#git-worktrees-inside-the-sandbox)). `sandbox.docker.socket` (`CODEOID_SANDBOX_DOCKER_SOCKET`, Settings > Sandbox > Docker access, default `false`) gives every new session [Docker access](#docker-access). `sandbox.homeReadable` (`CODEOID_SANDBOX_HOME_READABLE`, comma separated) replaces the home allowlist: add what your tools need that holds no @@ -600,89 +602,124 @@ in the CLI's process with the session's settings. Agents branch in a worktree of their own, never in a checkout others share: `git worktree add -b `, work and commit there, then -`git worktree remove`. Inside the sandbox that works, in every mode -(`src/daemon/sandbox/worktrees.ts`): - -- **Where**: a command may check a worktree out in the repository's **worktree - locations**, which are writable: the sibling `-worktrees/` (codeoid's - own convention for forks and loops: `
-worktrees/`), the - repository's `.worktrees/`, and each `sandbox.worktreeRoots` entry (`~` - expands; a relative one is the main checkout's: `../worktrees`). The - defaults are made empty where missing (a mount needs something to mount; git - doesn't see an empty dir), `.worktrees/` only when the workspace doesn't - already hold it; a submodule gets only the configured ones (its sibling - would be made inside its superproject). Never a location of `/`, the home or - a dir holding it, one holding the main checkout or the git dir, one inside - the git dir, one inside a hidden path, one another user owns, and never a - link or a path through one below the main checkout's parent (a command in - the main checkout could otherwise make `.worktrees` a link that hands a - later session's sandbox a writable dir anywhere); such an entry is left out. +`git worktree remove`. Inside the sandbox that works, in every mode, **in the +session's own worktree folder** (`src/daemon/sandbox/worktrees.ts`): + +- **Where**: each of the repository's **worktree locations** holds one folder + per session, and only the session's own is writable to it: + `-worktrees//` (the sibling dir, codeoid's own convention for + forks and loops), `/.worktrees//`, and + `///` for each `sandbox.worktreeRoots` entry (a root is + shared by every repository, so each gets its own dir in it). `` is + the session's name made filesystem-safe and a short id + (`fix-login-3f9a2c1d`): readable, never another session's, the same across a + resume, a rename and a daemon restart (found again by its short id); a fork + gets its own. The folders are made when the session's sandbox is built + (empty; git doesn't see an empty dir) and an empty one is removed with the + session. +- **The agent is told**: the session's system prompt (every backend: Claude's + and Codex's instructions, the others' preamble) says "Create git worktrees + under `/` … other locations are read-only in this sandbox". A + `git worktree add` anywhere else fails with the OS's "Read-only file + system" (git: "could not create leading directories"); the OS can't say + why, the note and this page do. +- **Everything else in a location is read-only to the session**: the user's + worktrees there, other sessions' folders, codeoid's own fork, loop and + `--worktree` checkouts, and anything anyone makes there later (the session's + writable set is its folder, fixed by name, so a later arrival is never in + it). A location inside the session's workspace — the main checkout's + `.worktrees/` for a session working in the main checkout — is held + read-only whole and gives that session no folder (its folder is then only in + `-worktrees/`), since other sessions' folders are there. - **git's own records**: the repository's `worktrees/` is writable, so git can make the new worktree's admin dir, and the new branch's refs are written as any commit's. `worktrees/` is bound at its own path: it can't be renamed - away and rebuilt. -- `git worktree add`, `list`, `remove` and `prune`, committing, rebasing and - removing in such a worktree, and in it in the session's next sandbox (a - backend restarted, the next query), all work. -- **What stays the user's** (the reason the structure is read-only: the user's - own checkouts must not be redirectable): the repository's `config`, - `config.worktree`, `hooks/`, `info/`, `modules/`, and **every worktree that - isn't the session's own**: its admin dir in `worktrees/`, whole (its - `commondir`, `gitdir`, `config.worktree`: what decides where git looks; it - can't be renamed, removed or replaced by a planted one under its name, - `git worktree prune` leaves it — it says it failed to delete it — and - `git worktree remove` can't remove it), its checkout's `.git` file, and, - when the checkout is in a worktree location, the whole checkout (so - `git worktree remove --force` deletes nothing of it). A checkout's `.git` - file outside the worktree locations and the workspace isn't writable at all. -- **The session's own worktrees** are those whose admin dir appeared after the - session's sandbox first looked at the repository, checked out where its - commands can write (the workspace, a worktree location), and not made by - codeoid itself (a fork's, a loop's: those are held like yours in every - other session's sandbox, whenever they were made). They are writable whole: - a worktree the agent creates is its own output, like the workspace (its - files, its `.git` file, its admin dir). Review it as you would the - workspace. + away and rebuilt. Every admin dir in it when the sandbox's spec is built + that isn't checked out in the session's folder is held read-only whole + (its `commondir`, `gitdir`, `config.worktree`: what decides where git looks; + it can't be rewritten, renamed, removed or replaced by a planted one under + its name, `git worktree prune` leaves it — it says it failed to delete it — + and `git worktree remove` can't remove it), with its checkout's `.git` file + when that is in the workspace. A session's spec is built again whenever an + admin dir appears or goes, so one made meanwhile is held from the session's + next sandbox on (a Claude query is rebuilt at its next turn when its spec + changed). +- `git worktree add`, `list`, `remove`, `move` and `prune`, committing and + rebasing in such a worktree, and in it in the session's next sandbox (a + backend restarted, the next query, after a daemon restart: it is still in + the session's folder), all work. +- **The session's own worktrees** are those checked out in its folder: a + worktree the agent creates is its own output, like the workspace (its files, + its `.git` file, its admin dir), with none of the workspace's git-structure + protection. Review it as you would the workspace. - **The end-of-turn warning** ([above](#what-it-guarantees-and-what-it-doesnt)) - treats a worktree as `git worktree add` makes one (its `commondir` naming - its own repository, `../..`, no worktree config) as nothing changed, so - adding one isn't news; one whose `commondir` names anything else, or that - carries a `config.worktree`, is named (its `commondir` and `gitdir`). + covers them: a checkout in the session's folder whose `.git` names anything + but its own admin dir (a planted git dir with `core.fsmonitor` would run at + your next `git status` there), or a `.git` dir planted in the folder, is + named; so is an admin dir that was there and changed (its `commondir`, + `gitdir` or `config.worktree`; for the session's own, a `gitdir` moved by + `git worktree move` isn't news), and one that appeared naming another + repository or carrying a config of its own. A worktree as `git worktree add` + makes one appearing, or one removed, isn't news. Best effort: every + worktree git records in the folder is looked at, and the folder's dirs to + depth 3 (500 dirs) for anything planted besides. +- **A session started in a worktree** (its working directory) has it as its + workspace, writable as any workspace: that is how to continue another + session's worktree (`codeoid new `). - **Codeoid's own sessions and loops in a worktree it made** (`--worktree`, a - fork, a loop run) are unaffected: their workspace's own git dir is held as - before, and their siblings in `-worktrees/` are other sessions' (held). + fork, a loop run) are unaffected: codeoid makes those checkouts itself, its + workspace's own git dir is held as before, and it gets a folder of its own. + +`sandbox.worktreeRoots` (`CODEOID_SANDBOX_WORKTREE_ROOTS`, Settings > Sandbox +> More worktree locations): `~` expands; a relative one is the main +checkout's and climbs at most one level (`../wt`). Refused, at config load, in +a settings write and from the environment, with the reason: `/`, the home or a +dir holding it, anything in a hidden dir of the home (`~/.config`, `~/.local`, +`~/.ssh`, shell and systemd user dirs, the backends' and codeoid's own: tools +load programs and settings from there outside the sandbox), `~/Library` +(LaunchAgents), codeoid's config dir or a dir holding it, a system dir (`/usr`, +`/etc`, `/opt`, …), `.`, `..`. At a session's start, also left out (and said +on the sandbox's limits line): a root that is a link or reached through one, +one another user owns, one holding the repository or its git dir, one inside a +hidden path; and a root that doesn't exist, unless it is beside the +repository (in the main checkout's parent), where it is made: anywhere else +codeoid never makes it — create it to use it. The defaults are made where +their parent is there; a submodule gets only the configured ones (its sibling +would be made inside its superproject). Limits (defence in depth, as the rest of git's structure): -- A worktree **you add yourself while a session's sandbox runs** isn't held by - that sandbox (it didn't see it): its admin dir is in the writable - `worktrees/`, so a command running then could rewrite its `commondir`. The - session's next sandbox holds it (it is checked out outside the locations, or - codeoid made it), and the end-of-turn warning names a `commondir` changed - meanwhile. Add worktrees while no session in that repository is running a - turn, or look at the warning before running git in the new one. A worktree - someone else checks out **in a worktree location** while the session runs - counts as the session's own in its next sandbox (unless codeoid made it): - the locations are the agents'. +- **An admin dir someone else creates while the session's sandbox runs** (a + worktree another session, codeoid or you add then) isn't protected from that + sandbox until its spec is built again: mount-based sandboxes fix their + writable set when a command starts (bubblewrap, Docker), a Claude query at + its turn's start, a process backend (pi, the Gemini CLI, Qwen, Claude or + Codex under Docker) when its process starts. Its admin dir is in the + writable `worktrees/`, so a command running then could rewrite its + `commondir`; its checkout is never writable (it isn't in the session's + folder). The end-of-turn warning names an admin dir that appeared naming + another repository or was changed. Add worktrees while no other session in + that repository runs a turn, or look at the warning before running git in + the new one. +- On **Claude's own sandbox**, a location inside your (hidden) home isn't + shown: its CLI lays what is readable over what is writable, so showing it + read-only would make the session's folder read-only too. There, a + `git worktree add` elsewhere in such a location lands in the sandbox's + scratch home, never on your disk, and leaves only a stale admin dir and its + branch (`git worktree prune` removes the admin dir); other worktrees in the + location aren't visible. Under bubblewrap, Seatbelt and Docker the locations + are shown read-only and such an add fails. - A checkout of yours **whose admin dir is already gone** (pruned while its disk was unmounted) isn't known to the sandbox: a command could make an admin dir under its name. git already treats such a checkout as broken ("not a git repository"); repair it (`git worktree repair`) before a session runs, or remove it. -- After the daemon restarts, a session's earlier worktrees are held like - yours (the session's look at the repository starts again): remove them - yourself, or from a session that runs outside the sandbox. - `git worktree add -b origin/main` makes the worktree but can't record the upstream (`branch..merge` is in the repository's config, held read-only): git says "could not write config file". Pass `--no-track`, or set the upstream yourself. -- Everything in a worktree location is writable to every sandboxed session of - that repository but the checkouts held above (worktrees the session didn't - make): don't keep anything else there. -- On Claude's own sandbox the rules are fixed when its query starts, as for - the rest (a worktree added during the query by someone else isn't held there - either). On Seatbelt `worktrees/` and each location are kept from being - renamed by a `literal` write denial (not verified on a Mac: see below). +- On Seatbelt `worktrees/` and each session folder are kept from being renamed + by a `literal` write denial (not verified on a Mac: see below). ## Docker access @@ -903,10 +940,11 @@ to the workspace. In autonomous mode it is information only (the audit log's repository is one codeoid doesn't widen to: above the home, a `.git` file git's records don't name back); a symlinked `.git` can't be held; a top-level `HEAD` that is already the project's isn't - held; a worktree someone else adds while a sandbox runs (a Claude query - included) isn't held by it, nor a checkout of yours whose admin dir is - already gone, nor what a worktree location holds besides others' - worktrees; a `.git` file git's records don't name back is held but + held; the admin dir of a worktree someone else adds while a sandbox runs + isn't held by it until its spec is built again (a Claude query at its + next turn), nor a checkout of yours whose admin dir is already gone; a + worktree the agent made in its own folder has none of this protection (the + end-of-turn warning names its `.git` pointed elsewhere); a `.git` file git's records don't name back is held but unverified. Your global `safe.bareRepository=explicit` closes the bare repository fallback entirely. None of these reach the git a backend CLI runs for itself, which is contained ([above](#a-backend-clis-own-git)): diff --git a/src/config.ts b/src/config.ts index 26d84402..a27f24c9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -18,6 +18,7 @@ import type { ModelPrice } from "./daemon/pricing/index.js"; import type { SandboxConfig } from "./daemon/sandbox/policy.js"; import { DEFAULT_HOME_READABLE } from "./daemon/sandbox/home-readable.js"; import { validAllowedHost } from "./daemon/sandbox/hosts.js"; +import { worktreeRootProblem } from "./daemon/sandbox/worktrees.js"; import { existsSync, readFileSync, mkdirSync, writeFileSync, renameSync, chmodSync, rmSync } from "node:fs"; import { randomBytes } from "node:crypto"; import { join, isAbsolute, resolve, dirname } from "node:path"; @@ -274,9 +275,25 @@ const SandboxSchema = z /** * More places a sandboxed command may check a worktree of its repository * out, on top of `-worktrees/` and `/.worktrees/` (`~` - * expands; a relative one is the repository's main checkout's). + * expands; a relative one is the repository's main checkout's): each + * repository gets `//`, each session its own folder there. + * Refused: `/`, the home or a dir holding it, a hidden dir of the home, + * `~/Library`, codeoid's config dir, a system dir (worktreeRootProblem). */ - worktreeRoots: z.array(z.string().min(1).max(4096)).max(64).default([]), + worktreeRoots: z + .array( + z + .string() + .min(1) + .max(4096) + .superRefine((root, ctx) => { + // Never a place whose contents run outside the sandbox, the home or `/` (#448 round 10, sandbox/worktrees.ts). + const problem = worktreeRootProblem(root, { configDir: getConfigDir() }); + if (problem) ctx.addIssue({ code: z.ZodIssueCode.custom, message: `not a worktree root: ${problem}` }); + }), + ) + .max(64) + .default([]), /** The bwrap binary (default: on PATH). */ bwrapPath: z.string().refine(isAbsolute, "must be an absolute path").optional(), docker: z diff --git a/src/daemon/providers/codex/index.ts b/src/daemon/providers/codex/index.ts index e571522c..92f6fc45 100644 --- a/src/daemon/providers/codex/index.ts +++ b/src/daemon/providers/codex/index.ts @@ -241,8 +241,8 @@ export function codexSandboxPolicy(rt: SandboxRuntime | null, fallback: SandboxM // The repository's git dirs too: codex's own sandbox makes every `.git` read-only, so nothing would commit. // What of them must stay read-only is held so by codeoid's bubblewrap around the app-server (gitWorkspacePaths). // And the sandbox's cache dir, where the toolchains' caches are pointed (SandboxSpec.env), and the repository's - // worktree locations, where a command checks a new worktree out (sandbox/worktrees.ts). - writableRoots: [...new Set([workdir, ...tempRoots(rt.spec), ...(rt.spec.gitDirs ?? []), ...cacheRoots(rt.spec), ...(rt.spec.worktreeLocations ?? [])])], + // worktree folders, where a command checks a new worktree out (sandbox/worktrees.ts). + writableRoots: [...new Set([workdir, ...tempRoots(rt.spec), ...(rt.spec.gitDirs ?? []), ...cacheRoots(rt.spec), ...(rt.spec.worktreeFolders ?? [])])], networkAccess: rt.spec.network.mode === "on", excludeTmpdirEnvVar: false, excludeSlashTmp: false, diff --git a/src/daemon/sandbox/claude-native.ts b/src/daemon/sandbox/claude-native.ts index afd643ec..6ef929d3 100644 --- a/src/daemon/sandbox/claude-native.ts +++ b/src/daemon/sandbox/claude-native.ts @@ -145,8 +145,10 @@ export function claudeSandboxSettings(rt: Pick