diff --git a/CHANGELOG.md b/CHANGELOG.md index c52ebe3d..e7931a48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added - **Agents' commands run in an OS sandbox, on by default** (#448). - The workspace and a private temp dir are writable; everything else is read-only (`rm -rf ~` fails with "Read-only file system"); codeoid's config and data, every agent CLI's login, `~/.ssh`, `~/.aws`, `~/.config/gcloud`, `~/.kube`, `~/.docker`, `~/.gnupg`, `~/.config/gh`, `~/.netrc`, `~/.git-credentials`, keyrings and the paths in `sandbox.hiddenPaths` aren't there at all; container runtime sockets can't be reached; the network is `on`, `off` or an `allowlist`, per session. + The workspace and a private temp dir are writable; everything else is read-only (`rm -rf ~` fails with "Read-only file system"); codeoid's config and data, every agent CLI's login, `~/.ssh`, `~/.aws`, `~/.config/gcloud`, `~/.kube`, `~/.docker`, `~/.gnupg`, `~/.config/gh`, `~/.netrc`, `~/.git-credentials`, keyrings and the paths in `sandbox.hiddenPaths` aren't there at all; container runtime sockets can't be reached; the network is `on`, `off` or an `allowlist`, per session, and with it `off` or an allowlist local services' unix sockets that let a connection in by who you are (Postgres, MySQL/MariaDB, Redis, …) can't be reached either. bubblewrap on Linux and Seatbelt on macOS (`auto`), or Docker when chosen, behind one pluggable provider interface ([docs/sandbox-providers.md](docs/sandbox-providers.md), with what Forge needs to slot in). Claude runs its commands in Claude Code's own sandbox with codeoid's policy (it can hold a host allowlist); Codex runs its workspace-write sandbox inside codeoid's bubblewrap, so in autonomous mode its sandbox is the boundary; pi, the Gemini CLI and Qwen Code run whole inside the session's sandbox; opencode runs each shell line in the calling session's sandbox; HTTP backends run no commands. A daemon-wide default (`sandbox.*`, `CODEOID_SANDBOX*`, the Sandbox settings tab), and per session: `codeoid new --sandbox`, the new-session dialog, `session.create`, `/sandbox`, `codeoid sandbox set`; routines and loops take one too, and forks keep their parent's. Turning a session's sandbox off or widening it is a confirm its owner answers on the session, in every mode, and audited; running one command outside it (Claude's unsandboxed retry, Codex asking for more) is asked in guarded and interactive mode and refused in autonomous mode. @@ -17,6 +17,14 @@ 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//`, 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. Nothing is made inside your checkout, and the folder (with `-worktrees/` when that is left empty) is removed while empty, at a turn's end and with the session; never anything with something in it. + 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. A worktree added or removed anywhere never rebuilds a session's sandbox (a Claude query, and its background tasks, keep running): instead every admin dir is checked against git's own layout when the sandbox is built, at every turn's end and at destroy, what a command changed there (its `gitdir`, `commondir`, a `config.worktree`, a planted `config` or hook, a duplicate a `git worktree repair` would adopt) is restored, and what can't be restored with certainty (an admin dir replaced by a link: the link is removed) is said and left; both on the session and in the audit log (`session.worktree_entry_restored`). A worktree is the session's own only when it made it, never because an admin dir says so. A `worktreeRoots` entry where tools load programs or settings (`~/.config`, `~/.local`, `~/Library`, …), the home or `/` fails the config's load, and a relative one is judged again where it leads (`../.config`); 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). + +- **Docker access, an opt-in for tests that start containers** (#448). + `sandbox.docker.socket` (`CODEOID_SANDBOX_DOCKER_SOCKET`) or a session's own `--docker-socket` lets sandboxed commands reach the container runtime's socket (Docker, rootless Docker, Podman; Docker Desktop on macOS) with `DOCKER_HOST` set; never the client's credentials. Off by default, a widening its owner confirms, and said on the chip and every approval card: it can reach the whole machine. Only that runtime's socket is shown: libvirt's, LXD's, Incus's and every other runtime's stay hidden in every sandbox, as do the system D-Bus and screen's sessions. On Claude's Linux sandbox, which can only allow every unix socket, the host's `/tmp` (tmux, X11, ssh-agent sockets) is hidden too, so are local databases' sockets with the network off, and what stays reachable is said. A runtime socket you can't connect to isn't opened up. See [docs/sandbox.md](docs/sandbox.md#docker-access). + - **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 c7f6d7f4..a148acd2 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) @@ -390,6 +390,53 @@ 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", #448 round 10): `/worktrees/` is writable, bound at its own +path; a session writes only its own folder of each worktree location +(`-worktrees/`, `//` for +`sandbox.worktreeRoots`; nothing made inside the checkout, an existing +`.worktrees/` held whole for a session there; `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); told to the agent in the system prompt +(`worktreeFolderNote`). The folder is made only in a repository and removed +while empty, with an emptied `-worktrees/` (`SessionSandbox.tidyFolders` +at a turn's end with no background work and no wrapped backend, and at +close; `prepareFolders` at the next turn's start). Every admin dir the +session didn't make is held whole at spec build; a worktree added or removed +never rebuilds the spec (#448 round 11: it would rebuild a Claude query). +Instead `WorktreeEntryGuard` (worktree-entries.ts) checks every admin dir +against git's layout at spec build, turn end and destroy +(`SessionSandbox.checkWorktreeEntries`): not a link, `commondir` the common +dir, no `config`/`hooks`/`info` (but sparse-checkout), `config.worktree` +empty or the user's, `gitdir` naming a checkout that names it back (the +back-link found by scanning the locations, beside the main checkout and the +last record, never the tampered file); restores what is certain (moved aside +into `/codeoid-held-worktrees/`, never deleted), removes a link and +says the rest; Session says it (`sandbox.worktree_entry`) and audits +(`session.worktree_entry_restored`/`_unrestored`). Own = first seen checked +out in the folder and claimed by no other checkout, recorded per entry inode +(`ownWorktreeEntries` to the spec). `worktreeRootProblem` refuses a root at +config load (`/`, home, a dot dir of the home, `~/Library`, the config dir, a +system dir; case-folded on macOS), again where a relative one leads, and one +inside the checkout; 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), +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), so there the host's +`/tmp` and `/var/tmp` are denied too (`HOST_SHARED_TEMP`) and what stays +reachable is said (`CLAUDE_ALL_SOCKETS_NOTE`); a socket the user can't +connect to isn't counted. Every other runtime's socket (libvirt, LXD, Incus, +containerd, …: `containerSocketPaths`) and the system bus and screen's dir +(`HOST_SOCKET_DIRS`) are hidden in every sandbox; local services' sockets +(Postgres, MySQL, Redis: `localServiceSocketPaths` → `SandboxSpec.serviceSockets`) +when the network the session asked for is off or an allowlist. 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 +486,8 @@ 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 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 f9293e0b..b79900e4 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -113,6 +113,8 @@ 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 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 @@ -243,12 +245,14 @@ Agents' commands run in an OS sandbox, on by default ([sandbox.md](sandbox.md)): ``` - `provider` (`CODEOID_SANDBOX`) — `auto` (bubblewrap on Linux, Seatbelt on macOS), `bwrap`, `seatbelt`, `docker`, or `off`. The default for new sessions; each session can choose its own (`codeoid new --sandbox …`, the new-session dialog, `/sandbox`), and turning one off is asked of its owner. -- `network` (`CODEOID_SANDBOX_NETWORK`) — `on`, `off` or `allowlist`; `allowedHosts` (`CODEOID_SANDBOX_ALLOWED_HOSTS`) for the allowlist. Only Claude's own sandbox enforces an allowlist; elsewhere it means no network, and the session says so. +- `network` (`CODEOID_SANDBOX_NETWORK`) — `on`, `off` or `allowlist`; `allowedHosts` (`CODEOID_SANDBOX_ALLOWED_HOSTS`) for the allowlist. Only Claude's own sandbox enforces an allowlist; elsewhere it means no network, and the session says so. With `off` or `allowlist`, local services' unix sockets that let a connection in by who you are (Postgres, MySQL/MariaDB, Redis, …) are hidden too ([sandbox.md](sandbox.md#what-is-protected)). - `hiddenPaths` (`CODEOID_SANDBOX_HIDDEN_PATHS`) — more paths to hide, on top of codeoid's data, the agent CLIs' logins and the usual credential stores (`~` expands). An entry here also beats `homeReadable`. - `homeReadable` (`CODEOID_SANDBOX_HOME_READABLE`, comma separated) — what of your home a sandboxed command may read (read-only); the rest of the home isn't there. Default: toolchains and tool settings that hold no secret (`~/.bun`, `~/.cargo/bin`, `~/.cargo/registry`, `~/.cargo/git`, `~/.rustup`, `~/.nvm`, `~/.npm`, `~/.local/bin`, `~/.local/lib`, `~/.local/share/{pnpm,uv,pipx,mise}`, `~/.local/share/claude/versions`, `~/.pyenv`, `~/go/pkg`, `~/go/bin`, `~/.gitconfig`, `~/.config/git/{config,ignore,attributes}`); a link in one of these works only where it leads into the list (or outside the home): what it leads to elsewhere in the home stays hidden. Setting it replaces the list: never add a file holding a token (`~/.npmrc`). - 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 session may check a git worktree of its repository out, on top of `-worktrees/` (`~` expands; a relative path is the repository's main checkout's, at most one level up, judged again where it leads: `../.config` of a repository in your home is refused; never inside the checkout). 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 9b84d975..0ab15260 100644 --- a/docs/sandbox-providers.md +++ b/docs/sandbox-providers.md @@ -36,16 +36,23 @@ 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 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) + 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 + serviceSockets?: string[]; // local services' sockets (Postgres, MySQL, Redis), with the session's network off or an + // allowlist: never reachable then (hide them as containerSockets) + 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 fc91b408..611e9e74 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 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. 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,12 +71,12 @@ 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 the session's own **worktree folder** in each worktree location: `-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 there when the sandbox is built that the session didn't make, 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; one made later is checked against git's own layout at every turn's end and restored ([below](#git-worktrees-inside-the-sandbox)); the rest of every worktree location, and a `.worktrees/` you have in the main checkout, whole, for a session working there ([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. | -| **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 and VM runtime sockets: `/run/docker.sock`, `/var/run/docker.sock`, dockerd's `/run/docker/`, cri-dockerd, containerd, podman, CRI-O, buildkit, k3s, k0s, microk8s, firecracker-containerd, balena, **libvirt** (`/run/libvirt/`, `$XDG_RUNTIME_DIR/libvirt/`: a VM with the host's disk), **LXD** (`/var/snap/lxd/common/lxd/`, `/var/lib/lxd/`) and **Incus** (`/var/lib/incus/`, `/run/incus/`: a privileged container), the user's rootless sockets, Docker Desktop / Colima / Lima / Rancher / OrbStack, `$DOCKER_HOST`. A runtime socket is root on the machine. The one exception is the owner's: [Docker access](#docker-access), off by default, which shows only Docker's (or Podman's) own. Hidden too, in every sandbox: the system D-Bus (`/run/dbus/`: polkit lets a desktop user mount drives, change the network, power off) and screen's sessions (`/run/screen/`), as the desktop session's runtime dir is (its bus, keyring and agents). **With the network `off` or an `allowlist`, local services' sockets** too, since a filesystem socket isn't the network and a connection to one gets in by who you are, no password asked (Postgres's peer auth, MySQL and MariaDB's `unix_socket`, a Redis without auth): `/run/postgresql/`, `/var/run/postgresql/`, `/run/mysqld/`, `/var/run/mysqld/`, `/run/mariadb/`, `/var/lib/mysql/mysql.sock`, `/run/redis/`, `/run/redis-server/`, `/run/valkey/`, `/run/memcached/`, `/run/mongodb/`, `/run/pgbouncer/` (and their `/var/run/` forms), and those found in `/tmp`, `/private/tmp` and `/var/tmp` by name (`.s.PGSQL.*`, `mysql.sock`, `mysqlx.sock`, `redis.sock`, `mongodb-*.sock`, … : Homebrew's and source builds' defaults). It follows the network the session asked for, so a backend that keeps the network on to reach its model (pi, the Gemini CLI, Qwen) still hides them. With the network `on` they are reachable as before (a command can reach the host's loopback then anyway); hide one more with `sandbox.hiddenPaths`. | | **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. | @@ -124,7 +126,13 @@ 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 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 secret (`~/.volta`, `~/.sdkman/candidates`, `~/.config/starship.toml`), never a file with a token. `sandbox.bwrapPath` picks the bwrap binary. @@ -135,19 +143,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 @@ -161,16 +172,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). @@ -584,6 +598,280 @@ 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, **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) and `///` for each + `sandbox.worktreeRoots` entry (a root is shared by every repository, so each + gets its own dir in it). **Nothing is made inside your checkout**: a + `.worktrees/` is no location (one you already have is held read-only whole + for a session working in that checkout). `` 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 while it exists); a fork gets its own. A folder + is made only for a session whose workspace is in a git repository, when its + sandbox is built (a mount needs something there), and **removed while + empty**, with `-worktrees/` when that is left empty too: at the end of + a turn with nothing running in the sandbox (no background work; not for a + backend whose whole process runs inside, pi, the Gemini CLI, Qwen, Codex, + Claude under Docker, whose mounts were fixed at its start), and with the + session. The next turn makes it again. Nothing with anything in it is ever + removed: a worktree there is yours to keep. +- **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 `.worktrees/` you have in the main checkout is held read-only whole + for a session working there (worktrees of yours, or other sessions' from + before, may be in it), and gives no session a folder. +- **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. Every admin dir in it when the sandbox's spec is built + that the session didn't make 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 worktree added or removed anywhere (by + you, a fork, another session) **never rebuilds a session's sandbox**: that + would rebuild a Claude query, killing its background tasks, or restart a + process backend. The spec is built again only when it would be anyway (the + session's sandbox setting changed, its backend restarted). +- **Every admin dir is checked against git's own layout** instead + (`src/daemon/sandbox/worktree-entries.ts`), when the session's sandbox is + built, at the end of every turn and at destroy: it is a dir, not a link; + its `commondir` names the repository's git dir (`../..`, as git writes it); + it holds no `config`, `hooks/` or `info/` (but a sparse worktree's + `info/sparse-checkout`), which git never makes there; its `config.worktree` + is empty, unless the repository has `extensions.worktreeConfig` and it was + there when the session first looked (or copies the main worktree's, as + `git worktree add` does); and its `gitdir` names a checkout's `.git` file + that names it back, the one the session last saw it name where that one + still does. **What fails is restored** to git's own form: `commondir` and + `gitdir` written again, the `gitdir` from the back-link (the checkout's + `.git` file, looked for in the worktree locations, beside the main + checkout and where the session last saw it; never from the changed file), + what git doesn't make moved aside into `.git/codeoid-held-worktrees/` + (nothing is deleted), and so is an admin dir that appeared naming another + worktree's checkout (a `git worktree repair` would point that checkout at + it; one the repair already adopted is undone, the checkout's `.git` + pointed back). **What can't be restored with certainty is said and left**: + an admin dir replaced by a link (the link is removed, so git in its + checkout stops with "not a git repository" instead of following it; look + at where it led before putting anything back), one whose checkout nothing + vouches for (run `git worktree repair `), one removed while + its checkout still names it. Either way the session says so: a warning + line naming the admin dir, what was found and what was done (event + `sandbox.worktree_entry`), and the audit log (`session.worktree_entry_restored`, + `session.worktree_entry_unrestored`). +- `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 it made: an admin dir first seen + checked out in its folder, named by no other checkout. That is the + session's record (for its life), never read again from the admin dir's + `gitdir`, which a command can write: an admin dir someone else made never + becomes the session's, and a name the session's own had, reused by + another's, is looked at afresh. A worktree the agent creates is its own + output, like the workspace (its files, its `.git` file, its HEAD and + index), left writable in its next sandbox; its admin dir is still checked + as above (a `commondir` or `config.worktree` there is read by your git in + its checkout). Review it as you would the workspace. +- **The end-of-turn warning** ([above](#what-it-guarantees-and-what-it-doesnt)) + 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: 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`, …), `.`, `..`; on macOS compared without regard to case, as +its filesystem does. A relative root is judged again, by the same list, where +it leads once resolved for the repository, links and all (`../.config` of a +repository at `~/proj` is `~/.config`: refused), and one inside the main +checkout is refused (nothing is made in your checkout). 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, a relative one refused where it leads; +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 sibling location is made where its 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): + +- **An admin dir someone else creates while the session's sandbox runs** (a + worktree another session, codeoid or you add then) isn't held by that + sandbox until its spec is next built (its setting changed, its backend + restarted): it is in the writable `worktrees/`, so a command running then + could change it (its `commondir`, `gitdir`, `config.worktree`, a link in + its place); its checkout is never writable (it isn't in the session's + folder). **The gap is from that change to the session's next check**, at + the end of the turn it happened in (or at destroy; an admin dir that git + may still be making, naming no checkout yet, is looked at again 2s later, + never later however it is touched): in between, git in that checkout + could follow what was planted (a `core.fsmonitor` in a planted git dir + runs at its next `git status`). The check restores it at the turn's end + and says so ([above](#git-worktrees-inside-the-sandbox)). Its HEAD and + index aren't checked (they are the worktree's state, not where git + looks): a command could rewrite them in that window, as it could commit + there; and a `modules/` in it (a submodule's git dir of that worktree) + isn't checked. One narrower case isn't caught: an admin dir of yours + added at a path outside the worktree locations and not beside the main + checkout, that a command points at a checkout planted in the session's own + folder before the session's first look at it, is taken for the session's + own (left writable to it, its HEAD and index the session's to change; + still checked as above, so nothing of it runs). Add your worktrees beside + the repository or in a worktree location, or while no session in that + repository runs a turn. +- 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. +- `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. +- 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 + +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 macOS it is allowed by path + (`allowUnixSockets`). On Linux its sandbox refuses to create any unix socket + (checked on the real CLI: `socket(AF_UNIX)` fails with "Operation not + permitted") unless all are allowed, so Docker access turns on its + `allowAllUnixSockets`, and then **any unix socket a command can see is + connectable**. So, with Docker access on, the host's `/tmp` and `/var/tmp` + are denied as well (the CLI's sandbox otherwise keeps them: tmux's + `/tmp/tmux-/`, X11's `/tmp/.X11-unix/`, an `ssh-agent`'s `/tmp/ssh-*/`, + an IDE's sockets), only what the sandbox shows again in them (the session's + temp dirs, a workspace there) remaining; every other runtime socket, the + desktop's runtime dir (its bus, keyring, gpg and ssh agents), the system bus + and screen's dir are hidden; its network namespace has none of the host's + abstract sockets (checked on the real CLI: a tmux-style socket planted in + the host's `/tmp` is out of reach, the runtime's is reached, the network + proxy still works). **What stays reachable** there, said on the sandbox's + limits line and in the confirm: a socket in the workspace or the session's + worktree folders, in what your home shows (`sandbox.homeReadable`), or in a + system dir open to every user outside the home and `/tmp` (systemd's + journal, printing, snapd's; a local database's only with the network `on`, + [above](#what-is-protected)). A runtime socket you can't connect to (not in + its group) isn't counted: Docker access then says it found one you can't + use, and nothing is opened up. +- **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 @@ -645,7 +933,9 @@ to the workspace. In autonomous mode it is information only (the audit log's `allowlist` of the hosts the work needs (enforced on Claude's own sandbox; elsewhere an allowlist runs with the network off). With the network `off` under bubblewrap a command has its own loopback and none of - these. + these, and local services' unix sockets (a Postgres, MySQL or Redis that + lets you in by who you are) are hidden too ([above](#what-is-protected)); + with the network `on` they are reachable. - **Allowlists** are enforced by Claude's sandbox only (its proxy). bubblewrap, Seatbelt and Docker run an allowlist with the network off. - **Seatbelt has no pid namespace**: a sandboxed command can list other @@ -675,8 +965,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 +982,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; what a command changes in it is restored at the turn's end + ([above](#git-worktrees-inside-the-sandbox)). 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 +1014,12 @@ 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; the admin dir of a worktree someone else adds while a sandbox runs + isn't held by it until its spec is next built, only checked (and + restored) at the turn's end, 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)): @@ -840,6 +1136,59 @@ 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`, + `git worktree remove` 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 isn't held + by its running sandbox and doesn't rebuild it; what a command changes in its + admin dir is restored at the turn's end (below), and it is held once the + spec is next built; a configured `sandbox.worktreeRoots` location is + writable, `..` and `~` aren't taken. +- Worktree admin dirs (#448 round 11), real bubblewrap + (`sandbox-worktree-entries-real.test.ts`): admin dirs added while the + session's sandbox runs, then changed inside it: a `gitdir` pointed into the + session's folder (with and without a checkout planted there naming it + back), a `commondir` at a planted git dir with `core.fsmonitor`, a + `config.worktree`, `config` and hook planted, the admin dir replaced by a + link to a copy whose `../..` is a planted git dir. At the turn's end each is + restored (the link removed and said), said once, never taken for the + session's own, and the user's next git there runs nothing planted; the + session's spec object is the same throughout. Admin dirs planted naming the + user's worktree's checkout are moved aside before `git worktree repair` + can adopt one, and one it already adopted is undone (the checkout's `.git` + pointed back). A name the session's own worktree had, reused by the user's, + is checked as the user's. Nothing is made in the checkout; the session's + sibling folder and `-worktrees/` go while empty, come back for the + next turn, and stay with a worktree in them. A unix socket standing in for + `/run/postgresql` is out of reach with the network off and reachable with it + on; `../.config` of a repository in the home is refused and never made. The + Claude provider (`provider-claude.test.ts`): a worktree added and one + removed elsewhere in the repository leave its query as it was (no rebuild, + no background tasks dropped); its own sandbox changing still rebuilds it. `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. +- 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): @@ -862,12 +1211,22 @@ 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. +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 @@ -875,7 +1234,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, 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 856a1497..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"; @@ -271,16 +272,40 @@ 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): 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) + .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 .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], 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({ @@ -1792,6 +1817,8 @@ 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" }, { 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..92f6fc45 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 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/bwrap.ts b/src/daemon/sandbox/bwrap.ts index 312475f7..07c1279e 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 @@ -101,7 +103,7 @@ export function bwrapArgs(spec: SandboxSpec, cwd: string, kind: (p: string) => P mounts.push(k === "dir" ? { kind: "hide-dir", path: at } : { kind: "hide-file", path: at }); }; for (const p of spec.hidden) hide(p); - for (const p of spec.containerSockets) hide(p); + for (const p of [...spec.containerSockets, ...(spec.serviceSockets ?? [])]) hide(p); const hiddenDirs = mounts.filter((m) => m.kind === "hide-dir").map((m) => m.path); for (const at of writable) { if (!kind(at) || seen.has(`w:${at}`)) continue; @@ -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..1d9903ba 100644 --- a/src/daemon/sandbox/claude-native.ts +++ b/src/daemon/sandbox/claude-native.ts @@ -113,8 +113,22 @@ export function claudeNativeEnv( return { ...rest, ...claudeTempEnv(temp), CLAUDE_ENV_FILE: file, ...(path === undefined ? {} : { PATH: path }) }; } +/** + * The host's shared temp dirs, denied in Claude's Linux sandbox while + * Docker access lets its commands connect to every unix socket they can see + * (tmux's `/tmp/tmux-`, X11's `/tmp/.X11-unix`, `ssh-agent`'s + * `/tmp/ssh-*`, an IDE's): what is shown again inside them (the workspace, + * the session's temp dirs) stays. + */ +export const HOST_SHARED_TEMP: readonly string[] = ["/tmp", "/var/tmp"]; + /** The SDK's `sandbox` option for a session whose sandbox is on and native. */ -export function claudeSandboxSettings(rt: Pick, env: Record, temp?: ClaudeTempDirs): SandboxSettings { +export function claudeSandboxSettings( + rt: Pick, + env: Record, + temp?: ClaudeTempDirs, + platform: NodeJS.Platform = process.platform, +): SandboxSettings { const spec = rt.spec; const tempDir = temp?.commands ?? spec.tempDir; const network: NonNullable = @@ -123,6 +137,14 @@ export function claudeSandboxSettings(rt: Pick 0 && platform === "linux"; + if (sockets.length > 0) Object.assign(network, allSockets ? { allowAllUnixSockets: true } : { allowUnixSockets: sockets }); const envVars = credentialEnvNames(env, spec.unsetEnv).map((name) => ({ name, mode: "deny" as const })); return { enabled: true, @@ -139,9 +161,13 @@ export function claudeSandboxSettings(rt: Pick

/.worktrees`, or `/`. */ + dir: string; + /** For a configured root: the root as written, and where it is. */ + root?: { written: string; at: string }; +} + +/** + * The repository's worktree locations, with no side effect: the defaults + * ({@link defaultWorktreeLocations}; a submodule gets only the configured + * ones, its sibling being inside its superproject) and `/` for + * each of `roots` (`~` expands, a relative one is the main checkout's), + * scoped by repository so a root shared by several holds each one's apart. + * A root {@link worktreeRootProblem} refuses is in `problems`, not here, as + * written and again once resolved, links and all (#448 round 11: `../.config` + * of a repository in the home is `~/.config`); so is one inside the main + * checkout (nothing is made in the user's checkout, and its project settings + * dirs are there). + */ +export function worktreeLocationDirs(layout: GitLayout, opts: { roots?: readonly string[]; home?: string; configDir?: string; platform?: NodeJS.Platform } = {}): { locations: WorktreeLocation[]; problems: string[] } { + const main = mainCheckoutOf(layout); + const locations: WorktreeLocation[] = []; + const problems: string[] = []; + if (!main || !layout.common) return { locations, problems }; + const home = resolve(opts.home ?? process.env.HOME ?? homedir()); + if (layout.kind !== "submodule") for (const d of defaultWorktreeLocations(main)) locations.push({ dir: d }); + const ctx = { home, ...(opts.configDir ? { configDir: opts.configDir } : {}), ...(opts.platform ? { platform: opts.platform } : {}) }; + for (const r of opts.roots ?? []) { + const p = r === "~" ? home : r.startsWith("~/") ? join(home, r.slice(2)) : r; + const at = resolve(isAbsolute(p) ? p : join(main, p)); + // As written (a relative one may climb one level only), then where it really is. + const written = worktreeRootProblem(r, ctx); + const resolved = written ? null : worktreeRootProblem(at, ctx); + const problem = written ?? (resolved ? `${r} → ${resolved}` : null); + if (problem) { + problems.push(`sandbox.worktreeRoots: ${problem}: not used`); + continue; + } + if (within(at, resolve(main)) || within(realOr(at), realOr(main))) { + problems.push(`sandbox.worktreeRoots: ${r} is inside the repository's checkout (${resolve(main)}), where nothing is made: not used`); + continue; + } + locations.push({ dir: join(at, basename(main)), root: { written: r, at } }); + } + return { locations, problems }; +} + +/** + * What the sandbox's limits say of `sandbox.worktreeRoots` for this + * repository, with no side effect: a root refused, or one that isn't there + * and isn't beside the repository (never made: create it to use it). + */ +export function worktreeRootNotes(layout: GitLayout, opts: { roots?: readonly string[]; home?: string; configDir?: string } = {}): string[] { + const main = mainCheckoutOf(layout); + if (!main || !opts.roots?.length) return []; + const { locations, problems } = worktreeLocationDirs(layout, opts); + const beside = (at: string) => within(at, dirname(resolve(main))) && !within(at, resolve(main)); + for (const l of locations) { + if (l.root && !ownDir(l.root.at) && !beside(l.root.at)) problems.push(`sandbox.worktreeRoots: ${l.root.written} (${l.root.at}) isn't a dir of yours — create it to use it: not used`); + } + return problems; +} + +/** What a session's sandbox makes of the repository's worktree locations. */ +export interface WorktreeFolders { + /** The session's own folder in each location: writable, made where missing. */ + folders: string[]; + /** + * The locations those folders are in: shown read-only (a sandbox that + * hides the home shows them again), so a worktree added anywhere else in + * one fails, as it does where they aren't hidden, rather than land in a + * throwaway dir (Claude's own sandbox makes a hidden home a scratch dir). + */ + locations: string[]; + /** Locations inside the workspace: held read-only whole (other sessions' folders are there). */ + held: string[]; + /** Why a location gives no folder (a root refused or missing): the sandbox's limits say so. */ + problems: string[]; +} + +/** + * The session's folder `/` in each worktree location + * ({@link worktreeLocationDirs}), made where missing (a mount needs + * something to mount): the defaults themselves where their parent is + * there, a configured root's `` dir in it, but a configured root only + * when it is beside the repository (in its main checkout's parent): one + * elsewhere that isn't there is said, never made. Never a location of `/`, + * the home or a dir holding it, one holding the main checkout or the + * repository's git dir or inside it, one another user owns, and never one + * that is (or is reached through) a link below the main checkout's parent + * (a command could have planted it). A location inside the workspace isn't + * given a folder: it is held read-only whole. Nothing is made inside the + * main checkout: a `.worktrees/` already there is held read-only whole for a + * session working in it (#448 round 11), never made. + */ +export function worktreeFoldersOf( + layout: GitLayout, + folderName: string, + opts: { roots?: readonly string[]; home?: string; workspace?: string; configDir?: string; make?: boolean } = {}, +): WorktreeFolders { + const out: WorktreeFolders = { folders: [], locations: [], held: [], problems: [] }; + const main = mainCheckoutOf(layout); + if (!main || !layout.common) return out; + if (!folderName || folderName.includes("/") || folderName === "." || folderName === "..") return out; + const make = opts.make !== false; + const home = resolve(opts.home ?? process.env.HOME ?? homedir()); + const common = realOr(layout.common); + const mainLit = resolve(main); + const mainReal = realOr(main); + const ws = opts.workspace ? resolve(opts.workspace) : null; + const { locations, problems } = worktreeLocationDirs(layout, { home, ...(opts.roots ? { roots: opts.roots } : {}), ...(opts.configDir ? { configDir: opts.configDir } : {}) }); + out.problems.push(...problems); + // Never through a link: one a session's command made where it could write (`.worktrees` in the main checkout) + // would hand the next session a writable dir anywhere. From the main checkout's parent down for what is below + // it, else from the location's parent. + const linked = (at: string, base: string): boolean => { + for (let p = at; p !== base && within(p, base); p = dirname(p)) if (isLink(p)) return true; + return false; + }; + const mk = (p: string): void => { + if (!make) return; + try { + mkdirSync(p, { mode: 0o755 }); + } catch { + /* there already, or can't be made: then only an existing one is used */ + } + }; + for (const loc of locations) { + const at = resolve(loc.dir); + const base = within(at, dirname(mainLit)) ? dirname(mainLit) : loc.root ? dirname(loc.root.at) : dirname(at); + if (linked(at, base)) { + if (loc.root) out.problems.push(`sandbox.worktreeRoots: ${loc.root.written} is reached through a link: not used`); + continue; + } + if (loc.root && !ownDir(loc.root.at)) { + // Beside the repository it is made; elsewhere it must be there already (never made where you didn't say). + if (within(loc.root.at, dirname(mainLit)) && !within(loc.root.at, mainLit)) mk(loc.root.at); + if (!ownDir(loc.root.at)) { + out.problems.push(`sandbox.worktreeRoots: ${loc.root.written} (${loc.root.at}) isn't a dir of yours — create it to use it: not used`); + continue; + } + } + mk(at); + if (linked(at, base) || !ownDir(at)) continue; + const real = realOr(at); + if (real === "/" || within(home, real) || within(realOr(home), real)) continue; + if (within(mainReal, real) || within(common, real) || within(real, common)) continue; + // Inside the workspace: other sessions' folders are there, so all of it is held (and no folder of this one's). + if (ws && (within(at, ws) || within(real, realOr(ws)))) { + if (!out.held.includes(real)) out.held.push(real); + continue; + } + const folder = join(real, folderName); + mk(folder); + if (!ownDir(folder) || isLink(folder)) continue; + // A folder holding the workspace: writable already. + if (ws && within(ws, folder)) continue; + if (!out.folders.includes(folder)) out.folders.push(folder); + if (!out.locations.includes(real)) out.locations.push(real); + } + // Worktrees a user (or an earlier codeoid) put in the checkout's `.worktrees/`: not a location, but not the agent's to change. + const dot = join(mainLit, ".worktrees"); + if (ws && within(dot, ws) && ownDir(dot) && !linked(dot, dirname(mainLit)) && !out.held.includes(realOr(dot))) out.held.push(realOr(dot)); + return out; +} + +/** + * The name of a session's folder: one already in a location ending in its + * short id (made under an earlier name: a rename keeps its folder), else + * {@link sessionFolderName}. Only a dir of the daemon user's own, never a + * link, counts. + */ +export function existingFolderName(locations: readonly WorktreeLocation[], sessionId: string): string | null { + const suffix = `-${sessionShortId(sessionId)}`; + for (const loc of locations) { + let names: string[] = []; + try { + names = readdirSync(loc.dir).filter((n) => n.endsWith(suffix) && n.length > suffix.length); + } catch { + continue; + } + for (const n of names.sort()) if (ownDir(join(loc.dir, n))) return n; + } + return null; +} + +/** + * The session's note to its agent on where git worktrees go (its system + * prompt, every backend: Session's prompt append), since a `git worktree + * add` anywhere else fails with the OS's bare "Read-only file system". + */ +export function worktreeFolderNote(folders: readonly string[]): string { + const where = folders.map((f) => `${f}/`).join(" or "); + return `# Git worktrees in this sandbox\nCreate git worktrees under ${where} (e.g. \`git worktree add ${folders[0]}/ -b \`): that is this session's own worktree folder. Other worktree locations, other sessions' folders and the repository's existing worktrees are read-only in this sandbox, so \`git worktree add\` elsewhere fails with "Read-only file system".`; +} + +/** 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) + .sort() + .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 checked out in one of the session's folders + * (`folders`), and not codeoid's own, as its `gitdir` says: the verdict of a + * spec built without a session's record of its own entries (a check's, the + * git shim's: no folder there). A session's sandbox decides by its record + * (worktree-entries.ts), never by this file alone, which a command can write. + */ +export function isOwnWorktree(admin: WorktreeAdmin, folders: readonly string[]): boolean { + if (!admin.dotgit || folders.length === 0) return false; + const checkout = dirname(admin.dotgit); + if (madeByCodeoid(checkout)) return false; + const real = realOr(checkout); + return folders.some((f) => (within(real, realOr(f)) || within(resolve(checkout), f)) && resolve(checkout) !== resolve(f)); +} 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..08de8b1c 100644 --- a/src/daemon/session.ts +++ b/src/daemon/session.ts @@ -209,8 +209,10 @@ 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 { CLAUDE_ALL_SOCKETS_NOTE, describeSandbox, resolveSandbox, SandboxRequiredError, SessionSandbox, sandboxRefusalOf, sandboxSettingProblem, widens } from "./sandbox/session-sandbox.js"; +import { type WorktreeEntryFinding, worktreeEntryLine, worktreeEntryWarning } from "./sandbox/worktree-entries.js"; import { type GitControlSnapshot, gitControlChanges, gitControlSnapshot, gitControlWarning, refTips, settingsCommits, settingsCommitWarning } from "./sandbox/git-watch.js"; +import { worktreeFolderNote } from "./sandbox/worktrees.js"; import { protectedWriteTargets, userHiddenPaths } from "./sandbox/policy.js"; import { CLAUDE_PROVIDER_ID, @@ -1569,11 +1571,13 @@ export class Session { if (opts.sandboxRegistry) { this.#sandbox = new SessionSandbox({ sessionId: this.id, + name: () => this.name, workdir: opts.workdir, registry: opts.sandboxRegistry, ...(opts.config ? { config: opts.config } : {}), ...(opts.sandbox ? { setting: opts.sandbox } : {}), onWarning: (text) => this.#sandboxWarning(text, "sandbox.login_not_saved"), + onWorktreeEntries: (common, findings) => this.#worktreeEntriesFound(common, findings), }); } this.#requireSandbox = opts.requireSandbox === true; @@ -3177,8 +3181,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 +3217,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.${resolvedAfter?.plan?.kind === "native" && process.platform === "linux" ? ` Also, ${CLAUDE_ALL_SOCKETS_NOTE}.` : ""}` : ""}`; // 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 }, { @@ -3268,10 +3272,12 @@ export class Session { */ #checkGitControl(sub: string): Promise { const commits = this.#checkSettingsCommits(sub); + // The repository's worktree entries first (#448 round 11): what is restored there isn't news below. + this.#sandbox?.checkWorktreeEntries(); const before = this.#gitBaseline; if (!before) return commits; try { - const now = gitControlSnapshot(this.workdir); + const now = gitControlSnapshot(this.workdir, undefined, { ownFolders: this.#sandbox?.worktreeFolders() ?? [] }); const changed = gitControlChanges(before, now); this.#gitBaseline = now; if (changed.length > 0) { @@ -3314,6 +3320,30 @@ export class Session { return this.#settingsCheck; } + /** + * What a look at the repository's worktree entries found (#448 round 11, + * sandbox/worktree-entries.ts): an entry not as git makes it, restored or + * left for the owner. Audited one by one, said in one warning line. + */ + #worktreeEntriesFound(common: string, findings: readonly WorktreeEntryFinding[]): void { + for (const f of findings) { + this.#store.audit(SYSTEM_IDENTITY.sub, f.restored ? "session.worktree_entry_restored" : "session.worktree_entry_unrestored", this.id, worktreeEntryLine(common, f).slice(0, 2000)); + } + this.#sandboxWarning(worktreeEntryWarning(common, findings), "sandbox.worktree_entry"); + } + + /** + * The session's empty worktree folders removed at a turn's end (#448 round + * 11), unless something still runs in its sandbox: background work, or a + * backend whose process is inside it (its mounts were fixed at its start). + * The next turn makes them again (SessionSandbox.prepareFolders). + */ + #tidyWorktreeFolders(): void { + const sbx = this.#sandbox; + if (!sbx?.runtime || sbx.runtime.plan.wrapsBackend || this.#hasAgentBackgroundWork()) return; + sbx.tidyFolders(); + } + /** A warning line on the session about its sandbox (#448), persisted and shown to every client. */ #sandboxWarning(text: string, event: string): void { if (this.#destroyed) return; @@ -3323,7 +3353,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 +3364,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 {}; @@ -4028,6 +4060,8 @@ export class Session { // The OS sandbox (#448), resolved for this backend before it starts. An // autonomous or unattended turn doesn't start without one that holds. await this.ensureSandbox(); + // Its worktree folders, removed while empty at the last turn's end, are there again for this one's commands. + this.#sandbox?.prepareFolders(); const sandboxRefused = this.sandboxRefusal() ?? // Its backend still runs in the sandbox it had before a change: not what the session reports. @@ -4038,7 +4072,7 @@ export class Session { } this.#store.audit(sender.sub, "session.send", this.id); // What of the repository git runs at the user's next command, as it is before the agent works (#448). - if (this.#sandbox && !this.#gitBaseline) this.#gitBaseline = gitControlSnapshot(this.workdir); + if (this.#sandbox && !this.#gitBaseline) this.#gitBaseline = gitControlSnapshot(this.workdir, undefined, { ownFolders: this.#sandbox.worktreeFolders() }); // And the commits its refs name, so a commit made from here on can be told (#448 round 6). if (this.#sandbox && !this.#refBaseline) this.#refBaseline = refTips(this.workdir); await this.#refBaseline; @@ -7620,9 +7654,24 @@ export class Session { // the active capability role into every turn's system prompt. if (this.#pack) parts.push(this.#buildPackPromptAppend(this.#pack)); if (this.#memory) parts.push(this.#buildMemoryPromptAppend()); + const worktrees = this.#worktreePromptAppend(); + if (worktrees) parts.push(worktrees); return parts.length > 0 ? parts.join("\n\n") : undefined; } + /** + * Where the session's sandboxed commands may make git worktrees (#448 + * round 10, sandbox/worktrees.ts): its own folder in each worktree + * location. A `git worktree add` anywhere else fails with the OS's + * "Read-only file system", which says nothing of why: this says where. + * Stable for the session's life (a cache-friendly prefix). + */ + #worktreePromptAppend(): string | null { + const folders = this.#sandbox?.worktreeFolders() ?? []; + if (folders.length === 0) return null; + return worktreeFolderNote(folders); + } + /** * Base prompt append plus a per-turn before_turn hook contribution. The * hook part goes LAST so the stable base stays a cache-friendly prefix. @@ -9292,6 +9341,7 @@ export class Session { result: event.result, }); void this.#checkGitControl(SYSTEM_IDENTITY.sub); + this.#tidyWorktreeFolders(); if (event.result.isError) { const errText = event.result.errorMessage ?? "Turn ended with an error"; // The CLI reports an overflow as the turn's assistant text and ends diff --git a/src/daemon/settings/manifest.ts b/src/daemon/settings/manifest.ts index b393cf76..329b45e2 100644 --- a/src/daemon/settings/manifest.ts +++ b/src/daemon/settings/manifest.ts @@ -233,6 +233,17 @@ const sandbox: SettingsTab = { envVar: "CODEOID_SANDBOX_HIDDEN_PATHS", applies: "next-session", }), + cfg("sandbox.worktreeRoots", "More worktree locations", "More places a sandboxed session may check out git worktrees, on top of -worktrees/ and /.worktrees/: each repository gets //, and each session writes only its own folder there. ~ expands; a relative path is the repository's (at most one level up: ../wt). A root that isn't there is used only once you create it. Refused: /, your home or a dir holding it, a hidden dir of your home (~/.config, ~/.local, …), ~/Library, codeoid's config dir, a system dir, a dir holding the repository.", { + kind: "string[]", + 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..ab15bda0 100644 --- a/src/tests/fixtures/claude-sandbox-probe.ts +++ b/src/tests/fixtures/claude-sandbox-probe.ts @@ -23,7 +23,8 @@ * offline, with an environment of its own) or not, and a second query after the first (a new * CLI start in the same workspace, the one that runs its `git status`); * the second one's system prompt is told as `system`. A workspace of the - * test's own: `CODEOID_PROBE_WORKSPACE`. Run in its own + * test's own: `CODEOID_PROBE_WORKSPACE`; the session's worktree folder + * name: `CODEOID_PROBE_WORKTREE_FOLDER`. Run in its own * process: a test file that mocks the SDK module must not see this one. * canUseTool here allows a sandboxed call and DENIES one asking to run * outside the sandbox, as codeoid's gate would until its owner says yes. @@ -109,7 +110,7 @@ const env: Record = { DISABLE_AUTOUPDATER: "1", ...(policy ? { ...claudeTempEnv(temp), GIT_CONFIG_NOSYSTEM: "1" } : {}), }; -const config = { provider: "auto", network, allowedHosts: [], hiddenPaths: [], homeReadable: ["~/.gitconfig", "~/.bun"], docker: { image: "x" } }; +const config = { provider: "auto", network, allowedHosts: [], hiddenPaths: [], homeReadable: ["~/.gitconfig", "~/.bun"], docker: { image: "x" }, ...(process.env.CODEOID_PROBE_BWRAP_PATH ? { bwrapPath: process.env.CODEOID_PROBE_BWRAP_PATH } : {}) }; const spec = policy ? buildSandboxSpec({ sessionId: "probe", @@ -117,7 +118,12 @@ 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", + // CODEOID_PROBE_WORKTREE_FOLDER: the session's worktree folder name (sandbox/worktrees.ts). + ...(process.env.CODEOID_PROBE_WORKTREE_FOLDER ? { worktreeFolder: process.env.CODEOID_PROBE_WORKTREE_FOLDER } : {}), }) : { sessionId: "probe", diff --git a/src/tests/provider-claude.test.ts b/src/tests/provider-claude.test.ts index 419c11aa..7f1f5c78 100644 --- a/src/tests/provider-claude.test.ts +++ b/src/tests/provider-claude.test.ts @@ -141,6 +141,10 @@ import { SqliteEpisodeStore } from "../daemon/memory/store.js"; import { MEMORY_TOOL_NAMES } from "../daemon/memory/index.js"; import type { Embedder } from "../daemon/memory/embedder.js"; import type { EffortLevel } from "../protocol/types.js"; +import { LocalSandboxHandle } from "../daemon/sandbox/local-handle.js"; +import { SandboxRegistry } from "../daemon/sandbox/registry.js"; +import { SessionSandbox } from "../daemon/sandbox/session-sandbox.js"; +import type { SandboxProvider, SandboxSpec } from "../daemon/sandbox/types.js"; // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -3896,3 +3900,87 @@ describe("ClaudeProvider — the OS sandbox", () => { ]); }); }); + +describe("ClaudeProvider — a worktree added elsewhere in the repository (#448 round 11)", () => { + let openGate: () => void = () => {}; + beforeEach(() => { + sdkMessages = []; + sdkThrowError = null; + capturedQueryOpts = null; + queryCallCount = 0; + sdkGate = new Promise((r) => { + openGate = r; + }); + }); + + /** bubblewrap as the registry sees it: nothing runs here, the spec is the real policy's. */ + function fakeBwrap(): SandboxProvider { + const open = (spec: SandboxSpec) => new LocalSandboxHandle("bwrap", spec, (argv, o) => ({ argv: [...argv], env: o.env })); + return { + id: "bwrap", + displayName: "bubblewrap", + capabilities: { isolation: "os", placement: "local", workspace: "mount", stdioProcess: true, commands: true, wrapsArgv: true, network: ["on", "off"], platforms: ["linux"] }, + probe: async () => ({ available: true }), + open: async (spec) => open(spec), + openSync: open, + }; + } + + it("doesn't rebuild the session's query (nor kill its background tasks): the same query, warm, until its own sandbox changes", async () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), "codeoid-claude-wt-"))); + const env = { ...process.env, GIT_CONFIG_NOSYSTEM: "1", GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@example.invalid", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@example.invalid", HOME: root }; + const git = (args: string[], cwd: string) => { + const r = Bun.spawnSync(["git", ...args], { cwd, env }); + if (r.exitCode !== 0) throw new Error(r.stderr.toString()); + }; + const repo = join(root, "repo"); + mkdirSync(repo); + git(["init", "-q", "-b", "main"], repo); + git(["commit", "-q", "--allow-empty", "-m", "i"], repo); + git(["worktree", "add", "-q", "-b", "before", join(root, "before")], repo); + const registry = new SandboxRegistry("linux", (n) => `/usr/bin/${n}`); + registry.register(fakeBwrap()); + const sbx = new SessionSandbox({ sessionId: "2222bbbb-0000-4000-8000-000000000000", name: "parent", workdir: repo, registry, setting: { provider: "bwrap" } }); + const events: SessionScopedEvent[] = []; + try { + expect((await sbx.refresh("claude")).state).toBe("on"); + expect(sbx.runtimeFor("claude")?.plan.kind).toBe("native"); + const provider = new ClaudeProvider({ + sessionId: "test-session", + initialBackingId: "test-backing", + workspaceId: "ws_test", + store: { + audit: () => {}, + getClaudeCodeSessionId: () => null, + setClaudeCodeSessionId: () => {}, + getSkillCommandGrants: () => new Map(), + setSkillCommandGrant: () => {}, + } as never, + sandbox: () => sbx.runtimeFor("claude"), + }); + provider.onSessionEvent = (e) => events.push(e); + const turn = (msg: string) => provider.runTurn({ history: [], userMessage: msg, workdir: repo, canUseTool: async () => ({ behavior: "allow" as const }) }); + turn("t1"); + const query = capturedQueryOpts; + expect(queryCallCount).toBe(1); + // A fork of this session (codeoid's checkout beside the repository), your own worktree, another session's. + git(["worktree", "add", "-q", "-b", "fork", join(root, "repo-worktrees", "fork-9999eeee")], repo); + git(["worktree", "add", "-q", "-b", "yours", join(root, "yours")], repo); + git(["worktree", "remove", join(root, "before")], repo); + turn("t2"); + expect(queryCallCount).toBe(1); + expect(capturedQueryOpts).toBe(query); + expect(events.filter((e) => e.type === "background_tasks")).toEqual([]); + // Its own sandbox changed (the network off): that one rebuilds, as before. + await sbx.set({ provider: "bwrap", network: "off" }); + await sbx.refresh("claude"); + turn("t3"); + expect(queryCallCount).toBe(2); + openGate(); + await provider.teardown(); + } finally { + await sbx.close(); + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/src/tests/sandbox-bwrap-real.test.ts b/src/tests/sandbox-bwrap-real.test.ts index c0e0a16f..6401faa6 100644 --- a/src/tests/sandbox-bwrap-real.test.ts +++ b/src/tests/sandbox-bwrap-real.test.ts @@ -20,6 +20,7 @@ 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 { gitControlChanges, 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"; @@ -743,32 +744,46 @@ 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(gitControlChanges(before, gitControlSnapshot(f.ws, f.home)).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 holds it: it isn't checked out in the session's own worktree folder. + 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" }, worktreeFolder: "g2-1234abcd" })); + 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..e1968122 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,8 +184,28 @@ 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("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 its own folder, commits there and removes it; nothing lands elsewhere in the location, and the user's and other sessions' worktrees stay held (#448 round 10)", async () => { + const r2 = realpathSync(mkdtempSync(join(scratchParent(), "codeoid-sbx-claude-wt-"))); try { const home = join(r2, "home"); const repo = join(home, "proj"); @@ -195,36 +216,69 @@ 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; another session's folder. + 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); + expect(git(["worktree", "add", "-q", "-b", "other", join(home, "proj-worktrees", "other-2222bbbb", "o")], repo).exitCode).toBe(0); + const wts = join(repo, ".git", "worktrees"); + const own = join(home, "proj-worktrees", "agent-1111aaaa"); 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 ${own}/x -b feature/x && echo ADDED`, + `cd ${own}/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 ${own}/x && echo REMOVED`, + "git worktree add -q ../proj-worktrees/y -b y 2>/dev/null && echo ADDED-ELSEWHERE || echo ELSEWHERE-REFUSED", + `echo x > ${join(home, "proj-worktrees", "other-2222bbbb", "o", "x.txt")} 2>/dev/null && echo WROTE-OTHER || echo OTHER-HELD`, + `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 p = Bun.spawn([process.execPath, PROBE, r2, "off", file, "policy"], { stdout: "pipe", stderr: "pipe", timeout: 90_000, env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", CODEOID_PROBE_WORKTREE_FOLDER: "agent-1111aaaa" } }); 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", "OTHER-HELD", "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"); + // Elsewhere in a location inside the (denied) home it lands in the CLI's scratch home, never on the host + // (docs/sandbox.md: on Claude's own sandbox the location isn't shown, it would cover the session's folder). + expect(existsSync(join(home, "proj-worktrees", "y"))).toBe(false); + expect(readFileSync(join(home, "proj-worktrees", "other-2222bbbb", "o", "a.txt"), "utf8")).toBe("a\n"); + expect(existsSync(join(home, "proj-worktrees", "other-2222bbbb", "o", "x.txt"))).toBe(false); } finally { rmSync(r2, { recursive: true, force: true }); } }, 120_000); + test("Docker access on Claude's Linux sandbox (every unix socket allowed): a tmux-style socket in the host's /tmp is out of reach, the runtime's isn't (#448 round 10)", async () => { + if (!Bun.which("python3")) return; + const runtime = join(root, "run-user-tmux"); + mkdirSync(runtime, { recursive: true, mode: 0o700 }); + const tmuxDir = realpathSync(mkdtempSync("/tmp/codeoid-test-tmux-")); + const sockets = { docker: join(runtime, "docker.sock"), tmux: join(tmuxDir, "default") }; + const servers = await Promise.all( + Object.entries(sockets).map( + ([name, at]) => + new Promise>((r) => { + const s = createServer((c) => c.end(`REACHED-${name.toUpperCase()}\n`)); + s.listen(at, () => r(s)); + }), + ), + ); + try { + const py = (at: string) => `python3 -c 'import socket,sys; s=socket.socket(socket.AF_UNIX); s.connect(sys.argv[1]); print(s.recv(100).decode().strip())' '${at}' 2>&1 | tail -1`; + const r = await probe("off", `${py(sockets.docker)}; ${py(sockets.tmux)}`, ["policy"], { CODEOID_PROBE_RUNTIME_DIR: runtime, CODEOID_PROBE_DOCKER_SOCKET: "1" }); + expect(r.result).toBe("success"); + expect(r.toolResult).toContain("REACHED-DOCKER"); + expect(r.toolResult).not.toContain("REACHED-TMUX"); + } finally { + for (const s of servers) s.close(); + rmSync(tmuxDir, { recursive: true, force: true }); + } + }, 300_000); + test("the toolchains' cache variables reach only sandboxed commands: never the CLI, its MCP servers, or a command run outside the sandbox (#448 round 3)", async () => { mkdirSync(join(root, "home", "proj"), { recursive: true }); const show = `echo "cargo=[$CARGO_HOME] gomod=[$GOMODCACHE] npm=[$npm_config_cache] xdg=[$XDG_CACHE_HOME] marker=[$SANDBOX_RUNTIME]"`; diff --git a/src/tests/sandbox-codex.test.ts b/src/tests/sandbox-codex.test.ts index 97af1ca7..68240d55 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 session's worktree folders too (#448 round 10): where a command checks a new worktree out. + const wt = fakeRuntime("native", "off", "/var/tmp/s").rt; + (wt.spec as { worktreeFolders?: string[] }).worktreeFolders = ["/repo-worktrees/s-1234abcd"]; + expect(codexSandboxPolicy(wt, "danger-full-access", "/w")).toMatchObject({ writableRoots: ["/w", "/var/tmp/s", "/repo-worktrees/s-1234abcd"] }); }); 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-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-git-watch.test.ts b/src/tests/sandbox-git-watch.test.ts index a389e041..aa7b98ff 100644 --- a/src/tests/sandbox-git-watch.test.ts +++ b/src/tests/sandbox-git-watch.test.ts @@ -76,6 +76,7 @@ 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"); + // An existing worktree's admin file changed: named. expect(gitControlChanges(before, gitControlSnapshot(wt, root))).toEqual([join(r, ".git", "worktrees", "late", "commondir")]); }); diff --git a/src/tests/sandbox-home.test.ts b/src/tests/sandbox-home.test.ts index 0d300782..e17553dd 100644 --- a/src/tests/sandbox-home.test.ts +++ b/src/tests/sandbox-home.test.ts @@ -133,14 +133,25 @@ describe("the workspace's git repository", () => { git(["worktree", "add", "-q", "-b", "g", nested], repo); const common = join(repo, ".git"); const own = join(common, "worktrees", "gh-wt"); - const g = gitWorkspacePaths(wt, home); - expect(g.writable).toEqual([common, own]); + const g = gitWorkspacePaths(wt, home, { worktreeFolder: "s-1234abcd" }); + // 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 session's own folder in each worktree location, made where missing (#448 round 10): in its sibling + // `-worktrees` dir; nothing else of it is writable, and nothing is made in the main checkout (#448 round 11). + expect(g.folders).toEqual([join(home, "repo-worktrees", "s-1234abcd")]); + expect(existsSync(join(repo, ".worktrees"))).toBe(false); + expect(g.problems).toEqual([]); + expect(g.writable).not.toContain(join(home, "repo-worktrees")); + // Without a folder name (a check, the git shim): none. + expect(gitWorkspacePaths(wt, home).folders).toEqual([]); 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); @@ -151,10 +162,20 @@ describe("the workspace's git repository", () => { // git still reads it as itself. 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]); + const plain = gitWorkspacePaths(repo, home, { worktreeFolder: "m-5678abcd" }); + 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 nothing is made (#448 round 11): no `.worktrees`; one you have there is held read-only + // whole (worktrees may be in it); the session's folder is only in the sibling location. + expect(plain.folders).toEqual([join(home, "repo-worktrees", "m-5678abcd")]); + expect(existsSync(join(repo, ".worktrees"))).toBe(false); + expect(plain.readOnly).not.toContain(join(repo, ".worktrees")); + mkdirSync(join(repo, ".worktrees")); + expect(gitWorkspacePaths(repo, home, { worktreeFolder: "m-5678abcd" }).readOnly).toContain(join(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 +304,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: [], folders: [], locations: [], problems: [] }); 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 +314,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: [], folders: [], locations: [], problems: [] }); // 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: [], folders: [], locations: [], problems: [] }); // 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: [], folders: [], locations: [], problems: [] }); expect(gitLayoutProblem(repo, home)).toBeNull(); }); @@ -320,10 +341,13 @@ describe("the workspace's git repository", () => { const ws = join(sup, "sub"); 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]); + const g = gitWorkspacePaths(ws, home, { worktreeFolder: "sub-1" }); + 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.folders).toEqual([]); + expect(existsSync(join(sup, "sub-worktrees"))).toBe(false); expect(g.readOnly).toContain(join(ws, ".git")); expect(gitLayoutProblem(ws, home)).toBeNull(); }); @@ -332,12 +356,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: [], folders: [], locations: [], problems: [] }); }); }); 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 +381,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", () => { @@ -391,6 +417,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-local-services-real.test.ts b/src/tests/sandbox-local-services-real.test.ts new file mode 100644 index 00000000..8bd4d409 --- /dev/null +++ b/src/tests/sandbox-local-services-real.test.ts @@ -0,0 +1,87 @@ +/** + * Real bubblewrap (#448 round 11), skipped where it can't run: with the + * network off, a local service's unix socket (a database that lets a + * connection in by who the user is) is out of a sandboxed command's reach; + * with it on, it is reachable as before. A socket this test makes stands in + * for /run/postgresql's; no real service is touched. + */ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, 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 { buildSandboxSpec, LOCAL_SERVICE_SOCKET_DIRS } from "../daemon/sandbox/policy.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-svc-probe-")); + rmSync(p, { recursive: true, force: true }); + return dir; + } catch { + /* next */ + } + } + return tmpdir(); +} + +describe.skipIf(!probe.available || !Bun.which("python3"))("bubblewrap, for real: local services' sockets with the network off (#448 round 11)", () => { + let root: string; + let server: Server; + let svc: string; + let got = ""; + beforeAll(async () => { + root = realpathSync(mkdtempSync(join(scratchParent(), "codeoid-sbx-svc-"))); + // Stands in for /run/postgresql: a socket a connection gets in by who it is. + svc = join(root, "postgresql"); + mkdirSync(svc); + server = createServer((c) => + c.on("data", (d) => { + got += d.toString(); + c.end("PEER-AUTH-OK\n"); + }), + ); + await new Promise((r) => server.listen(join(svc, ".s.PGSQL.5432"), r)); + }); + afterAll(async () => { + await new Promise((r) => server.close(() => r())); + rmSync(root, { recursive: true, force: true }); + }); + + test("hidden with the network off, reachable with it on; the spec names the system's database socket dirs only when off", async () => { + const ws = join(root, "ws"); + const home = join(root, "home"); + mkdirSync(ws, { recursive: true }); + mkdirSync(home, { recursive: true }); + mkdirSync(join(root, "tmp"), { recursive: true }); + writeFileSync(join(ws, "c.py"), `import socket\ns=socket.socket(socket.AF_UNIX)\ns.connect(${JSON.stringify(join(svc, ".s.PGSQL.5432"))})\ns.send(b'startup user=me')\nprint(s.recv(100))\n`); + const spec = (mode: "on" | "off" | "allowlist") => + buildSandboxSpec({ sessionId: `svc-${mode}`, workspace: ws, tempDir: join(root, "tmp"), network: mode === "allowlist" ? { mode, allow: ["example.com"] } : { mode }, hidden: { home, configDir: join(home, ".codeoid"), env: { HOME: home }, daemonTmp: "/tmp" } }); + for (const mode of ["off", "allowlist"] as const) expect(spec(mode).serviceSockets).toEqual(expect.arrayContaining([...LOCAL_SERVICE_SOCKET_DIRS])); + expect(spec("on").serviceSockets).toBeUndefined(); + const run = async (s: ReturnType) => { + const h = await provider.open(s); + try { + return await h.exec(["python3", join(ws, "c.py")], { cwd: ws, env: { PATH: "/usr/bin:/bin" }, timeoutMs: 20_000 }); + } finally { + await h.close(); + } + }; + const off = spec("off"); + const offRun = await run({ ...off, serviceSockets: [...(off.serviceSockets ?? []), svc] }); + // python ran, and its connect found nothing there. + expect(offRun.exitCode).not.toBe(0); + expect(String(offRun.stderr)).toMatch(/FileNotFoundError|No such file/); + expect(String(offRun.stderr)).not.toContain("bwrap:"); + expect(got).toBe(""); + const onRun = await run(spec("on")); + expect(String(onRun.stdout)).toContain("PEER-AUTH-OK"); + }); +}); diff --git a/src/tests/sandbox-manager.test.ts b/src/tests/sandbox-manager.test.ts index b41b610b..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: [], 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"]) { + 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: [], 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 475b222a..b7dfed74 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,30 @@ 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 the user's own (rootless Docker, Podman), then the system'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); + // The user's own runtime before the root daemon's: DOCKER_HOST names the one that isn't root on the host. + expect(dockerSocketPaths("/h", { XDG_RUNTIME_DIR: "/run/user/7" }, "linux", isSocket)).toEqual(["/run/user/7/podman/podman.sock", "/var/run/docker.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 +280,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", 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 ba54c63e..80f2642c 100644 --- a/src/tests/sandbox-seatbelt.test.ts +++ b/src/tests/sandbox-seatbelt.test.ts @@ -92,6 +92,27 @@ 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"]) { + 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-session.test.ts b/src/tests/sandbox-session.test.ts index d8efbd65..b832dfc3 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" }); @@ -597,6 +623,53 @@ describe("review fixes (#448)", () => { } }); + test("a worktree entry taken over while the session ran is restored at the turn's end, said on the session and audited; its empty worktree folder goes at a turn's end and is back for the next (#448 round 11)", async () => { + const env = { ...process.env, GIT_CONFIG_NOSYSTEM: "1", GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@e", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@e" }; + const run = (args: string[]) => { + const r = Bun.spawnSync(["git", ...args], { cwd: workdir, env }); + if (r.exitCode !== 0) throw new Error(`git ${args.join(" ")}: ${r.stderr.toString()}`); + }; + run(["init", "-q", "-b", "main"]); + run(["commit", "-q", "--allow-empty", "-m", "i"]); + const provider = new MockSessionProvider("claude", [[DONE], [DONE], [DONE]]); + const { session } = makeSession(provider); + const infos: Array<{ text: string; event?: string }> = []; + session.attach({ + id: "w", + auth: AUTH, + capabilities: [], + send: (m: { type: string; role?: string; content?: string; metadata?: { event?: string } }) => { + if (m.type === "session.message" && m.role === "info") infos.push({ text: m.content ?? "", event: m.metadata?.event }); + }, + } as never); + const said = () => infos.filter((i) => i.event === "sandbox.worktree_entry"); + const loc = join(root, "work-worktrees"); + await session.send("one", AUTH); + await until(() => session.status === "idle"); + // Claude's own sandbox (nothing of it runs between turns): the folder, left empty, is gone with its location. + expect(existsSync(loc)).toBe(false); + expect(said()).toEqual([]); + // Yours, added while the session runs, and what a command could then do to its entry (here between turns). + const yours = join(root, "yours"); + run(["worktree", "add", "-q", "-b", "yours", yours]); + writeFileSync(join(workdir, ".git", "worktrees", "yours", "commondir"), `${join(root, "planted")}\n`); + let madeAgain = false; + const runTurn = provider.runTurn.bind(provider); + provider.runTurn = (o) => { + madeAgain = existsSync(loc); + return runTurn(o); + }; + await session.send("two", AUTH); + await until(() => said().length === 1); + expect(madeAgain).toBe(true); + expect(said()[0]!.text).toContain(".git/worktrees/yours commondir named"); + expect(said()[0]!.text).toContain("is restored"); + expect(readFileSync(join(workdir, ".git", "worktrees", "yours", "commondir"), "utf8").trim()).toBe("../.."); + expect(audits.filter((a) => a.action === "session.worktree_entry_restored")).toEqual([expect.objectContaining({ sessionId: session.id, detail: expect.stringContaining("worktrees/yours commondir") })]); + await until(() => session.status === "idle"); + expect(existsSync(loc)).toBe(false); + }); + test("a commit made during the session that changed project settings (through git plumbing, never in the working tree) is named with its files: at the turn's end, once, and at destroy (#448 round 6)", async () => { const env = { ...process.env, GIT_CONFIG_NOSYSTEM: "1", GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@e", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@e" }; const run = (args: string[], extra: Record = {}, input?: string) => { diff --git a/src/tests/sandbox-worktree-entries-real.test.ts b/src/tests/sandbox-worktree-entries-real.test.ts new file mode 100644 index 00000000..80600b11 --- /dev/null +++ b/src/tests/sandbox-worktree-entries-real.test.ts @@ -0,0 +1,271 @@ +/** + * Real bubblewrap (#448 round 11), skipped where it can't run: the + * repository's worktree entries (`.git/worktrees/`) kept as git makes + * them (sandbox/worktree-entries.ts), without rebuilding a session's sandbox + * when a worktree is added; nothing made in the user's checkout, the + * session's worktree folder removed while empty. + * + * Everything destructive runs INSIDE the sandbox under test, against a + * repository this test planted in its own 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, readdirSync, 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 { SandboxRegistry } from "../daemon/sandbox/registry.js"; +import { SessionSandbox } from "../daemon/sandbox/session-sandbox.js"; +import type { SandboxHandle } from "../daemon/sandbox/types.js"; +import { QUARANTINE_DIR, type WorktreeEntryFinding } from "../daemon/sandbox/worktree-entries.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-wte-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(); +} + +const SESSION_ID = "1111aaaa-0000-4000-8000-000000000000"; +const FOLDER = "agent-1111aaaa"; + +describe.skipIf(!probe.available || !Bun.which("git"))("bubblewrap, for real: worktree entries kept as git makes them (#448 round 11)", () => { + let root: string; + beforeAll(() => { + root = realpathSync(mkdtempSync(join(scratchParent(), "codeoid-sbx-wte-"))); + }); + 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 and a worktree of the user's beside it; a session sandbox (bwrap, network off) whose folder is FOLDER. */ + async function fixture(name: string) { + 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 marker = join(dir, "RAN-OUTSIDE"); + const mark = join(dir, "mark.sh"); + writeFileSync(mark, `#!/bin/sh\necho ran >> '${marker}'\n`, { mode: 0o755 }); + const registry = new SandboxRegistry("linux"); + registry.register(provider); + const found: WorktreeEntryFinding[] = []; + const sbx = new SessionSandbox({ sessionId: SESSION_ID, name: "agent", workdir: repo, registry, setting: { provider: "bwrap", network: "off" }, onWorktreeEntries: (_c, f) => found.push(...f) }); + const info = await sbx.refresh("pi"); + expect(info.state).toBe("on"); + const rt = sbx.runtimeFor("pi"); + if (!rt) throw new Error("no runtime"); + const loc = join(dir, "repo-worktrees"); + return { dir, home, repo, userWt, marker, mark, sbx, rt, found, loc, own: join(loc, FOLDER), common: join(repo, ".git"), env: gitEnv(home) }; + } + + /** The user's next git in `cwd`, outside the session's sandbox (it may write only the test's dir); its exit code. */ + function userGit(f: { dir: string; home: string }, cwd: string, args: string[] = ["status", "--short"]): number { + const bwrap = provider.binary() as string; + return Bun.spawnSync([bwrap, "--die-with-parent", "--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", "--tmpfs", "/tmp", "--bind", f.dir, f.dir, "--chdir", cwd, "--", "git", ...args], { env: gitEnv(f.home) }).exitCode; + } + + test("an entry made after the sandbox started and taken over in it is restored at the turn's end and said; the user's git runs nothing planted; the sandbox isn't rebuilt", async () => { + const f = await fixture("takeover"); + try { + const spec = f.rt.spec; + const h = await provider.open(spec); + const wts = join(f.common, "worktrees"); + // Made while the sandbox runs, as you (or a fork, or another session) would: one beside the repository, the + // rest in the worktree location. + const late: Record = {}; + for (const n of ["la", "lb", "lc", "ld", "le"]) { + late[n] = n === "la" ? join(f.dir, n) : join(f.loc, n); + git(["worktree", "add", "-q", "-b", n, late[n] as string], f.repo, f.home); + } + // No rebuild: the spec (a Claude query's, a process backend's sandbox) is the same object. + expect(f.rt.spec).toBe(spec); + try { + const P = join(f.own, "p"); + const r = await sh( + h, + f.repo, + [ + // A planted git dir with core.fsmonitor, in the session's own folder. + `git init -q --bare '${P}' && cp -r '${join(f.common, "objects")}/.' '${P}/objects/' && cp -r '${join(f.common, "refs")}/.' '${P}/refs/' && git --git-dir='${P}' config core.fsmonitor '${f.mark}' && echo PLANTED`, + // la: its gitdir pointed into the session's folder. + `printf '%s\\n' '${join(f.own, "x", ".git")}' > '${join(wts, "la", "gitdir")}' && echo RETARGETED-LA`, + // lb: the same, with a checkout planted there naming it back. + `mkdir -p '${join(f.own, "y")}' && printf 'gitdir: %s\\n' '${join(wts, "lb")}' > '${join(f.own, "y", ".git")}' && printf '%s\\n' '${join(f.own, "y", ".git")}' > '${join(wts, "lb", "gitdir")}' && echo FORGED-LB`, + // lc: replaced with a link to a copy in the folder, whose ../.. is the planted git dir. + `mkdir -p '${join(P, "q")}' && cp -r '${join(wts, "lc")}' '${join(P, "q", "lc")}' && rm -rf '${join(wts, "lc")}' && ln -s '${join(P, "q", "lc")}' '${join(wts, "lc")}' && echo SWAPPED-LC`, + // ld: its commondir at the planted git dir. + `printf '%s\\n' '${P}' > '${join(wts, "ld", "commondir")}' && echo REDIRECTED-LD`, + // le: a config.worktree with core.fsmonitor, a config and a hook of its own. + `printf '[core]\\n\\tfsmonitor = %s\\n' '${f.mark}' > '${join(wts, "le", "config.worktree")}' && printf '[core]\\n\\tfsmonitor = %s\\n' '${f.mark}' > '${join(wts, "le", "config")}' && mkdir -p '${join(wts, "le", "hooks")}' && printf '#!/bin/sh\\n%s\\n' '${f.mark}' > '${join(wts, "le", "hooks", "post-checkout")}' && chmod +x '${join(wts, "le", "hooks", "post-checkout")}' && echo PLANTED-LE`, + ].join(" && "), + f.env, + ); + for (const w of ["PLANTED", "RETARGETED-LA", "FORGED-LB", "SWAPPED-LC", "REDIRECTED-LD", "PLANTED-LE"]) expect(r.stdout).toContain(w); + } finally { + await h.close(); + } + // The turn's end. `la` names no checkout yet, as an entry git is still making would: looked at again once the + // grace is over (never later, however its times are touched). + f.sbx.checkWorktreeEntries(); + const by = (n: string) => f.found.filter((x) => x.name === n); + expect(by("la")).toEqual([]); + for (let i = 0; i < 60 && by("la").length === 0; i++) await Bun.sleep(100); + for (const n of ["la", "lb", "ld", "le"]) expect([n, by(n).some((x) => x.restored)]).toEqual([n, true]); + expect(by("lc")).toEqual([expect.objectContaining({ restored: false, what: expect.stringContaining("link") })]); + expect(readFileSync(join(wts, "la", "gitdir"), "utf8").trim()).toBe(join(late.la as string, ".git")); + expect(readFileSync(join(wts, "lb", "gitdir"), "utf8").trim()).toBe(join(late.lb as string, ".git")); + expect(readFileSync(join(wts, "ld", "commondir"), "utf8").trim()).toBe("../.."); + expect(readFileSync(join(wts, "le", "config.worktree"), "utf8")).toBe(""); + expect(existsSync(join(wts, "le", "config"))).toBe(false); + expect(existsSync(join(wts, "le", "hooks"))).toBe(false); + expect(existsSync(join(wts, "lc"))).toBe(false); + expect(readdirSync(join(f.common, QUARANTINE_DIR)).length).toBeGreaterThan(0); + // None of them is the session's own. + expect(f.sbx.worktreeFolders()).toEqual([f.own]); + // The user's next git there: what works works, and nothing planted runs. + for (const n of ["la", "lb", "ld", "le"]) expect([n, userGit(f, late[n] as string)]).toEqual([n, 0]); + userGit(f, late.lc as string); + expect(existsSync(f.marker)).toBe(false); + // Said once: the next look finds nothing new. + const before = f.found.length; + f.sbx.checkWorktreeEntries(); + expect(f.found.length).toBe(before); + } finally { + await f.sbx.close(); + } + }); + + test("an entry planted naming your worktree's checkout is moved aside before git worktree repair can adopt it; one it already adopted is undone", async () => { + const f = await fixture("repair"); + try { + const wts = join(f.common, "worktrees"); + const head = git(["rev-parse", "HEAD"], f.repo, f.home).trim(); + const plant = (prefix: string) => + Array.from({ length: 6 }, (_, i) => `mkdir -p '${join(wts, `${prefix}${i}`)}' && printf '../..\\n' > '${join(wts, `${prefix}${i}`, "commondir")}' && printf '%s\\n' '${join(f.userWt, ".git")}' > '${join(wts, `${prefix}${i}`, "gitdir")}' && printf '${head}\\n' > '${join(wts, `${prefix}${i}`, "HEAD")}'`).join(" && "); + // The first look, as at the session's first turn. + f.sbx.checkWorktreeEntries(); + const h = await provider.open(f.rt.spec); + try { + expect((await sh(h, f.repo, `${plant("zz")} && echo PLANTED`, f.env)).stdout).toContain("PLANTED"); + } finally { + await h.close(); + } + f.sbx.checkWorktreeEntries(); + expect(f.found.filter((x) => x.name.startsWith("zz") && x.restored).length).toBe(6); + expect(readdirSync(wts).filter((n) => n.startsWith("zz"))).toEqual([]); + expect(userGit(f, f.repo, ["worktree", "repair"])).toBe(0); + expect(readFileSync(join(f.userWt, ".git"), "utf8").trim()).toBe(`gitdir: ${join(wts, "userwt")}`); + // Planted again, and the user's repair runs before the session's next look. + const h2 = await provider.open(f.rt.spec); + try { + expect((await sh(h2, f.repo, `${plant("yy")} && echo PLANTED`, f.env)).stdout).toContain("PLANTED"); + } finally { + await h2.close(); + } + userGit(f, f.repo, ["worktree", "repair"]); + expect(readFileSync(join(f.userWt, ".git"), "utf8")).toContain(join(wts, "yy")); + f.sbx.checkWorktreeEntries(); + expect(readFileSync(join(f.userWt, ".git"), "utf8").trim()).toBe(`gitdir: ${join(wts, "userwt")}`); + expect(readdirSync(wts).filter((n) => n.startsWith("yy"))).toEqual([]); + expect(f.found.some((x) => x.name === "userwt" && x.restored && x.what.includes("pointed back"))).toBe(true); + expect(userGit(f, f.userWt)).toBe(0); + } finally { + await f.sbx.close(); + } + }); + + test("a name the session's own worktree had, reused by yours, is yours: checked, never left to the session", async () => { + const f = await fixture("reuse"); + try { + const wts = join(f.common, "worktrees"); + const h = await provider.open(f.rt.spec); + try { + expect((await sh(h, f.repo, `git worktree add -q '${join(f.own, "feat")}' -b feat-own && echo ADDED`, f.env)).stdout).toContain("ADDED"); + f.sbx.checkWorktreeEntries(); + expect(f.found).toEqual([]); + expect((await sh(h, f.repo, `git worktree remove '${join(f.own, "feat")}' && echo REMOVED`, f.env)).stdout).toContain("REMOVED"); + // Yours, under the same name, while the sandbox still runs (its spec never held `feat`: it was the session's). + const yours = join(f.dir, "u", "feat"); + mkdirSync(join(f.dir, "u")); + git(["worktree", "add", "-q", "-b", "feat-user", yours], f.repo, f.home); + expect((await sh(h, f.repo, `echo '${join(f.own, "p")}' > '${join(wts, "feat", "commondir")}' && echo REDIRECTED`, f.env)).stdout).toContain("REDIRECTED"); + } finally { + await h.close(); + } + f.sbx.checkWorktreeEntries(); + expect(f.found).toEqual([expect.objectContaining({ name: "feat", restored: true, what: expect.stringContaining("commondir") })]); + expect(readFileSync(join(wts, "feat", "commondir"), "utf8").trim()).toBe("../.."); + } finally { + await f.sbx.close(); + } + }); + + test("nothing is made in the checkout; the session's sibling folder goes while empty and stays once it holds a worktree", async () => { + const f = await fixture("tidy"); + try { + const spec = f.rt.spec; + expect(spec.worktreeFolders).toEqual([f.own]); + expect(existsSync(join(f.repo, ".worktrees"))).toBe(false); + expect(existsSync(f.own)).toBe(true); + // A turn's end with nothing made: the folder, and the location it alone was in, go. + f.sbx.tidyFolders(); + expect(existsSync(f.own)).toBe(false); + expect(existsSync(f.loc)).toBe(false); + // The next turn's start makes them again, and a worktree made there keeps them. + f.sbx.prepareFolders(); + expect(existsSync(f.own)).toBe(true); + const h = await provider.open(spec); + try { + expect((await sh(h, f.repo, `git worktree add -q '${join(f.own, "w")}' -b w && echo ADDED`, f.env)).stdout).toContain("ADDED"); + } finally { + await h.close(); + } + f.sbx.tidyFolders(); + expect(existsSync(join(f.own, "w", "a.txt"))).toBe(true); + } finally { + await f.sbx.close(); + } + // Kept with the session gone: a worktree is the user's to keep. + expect(existsSync(join(f.own, "w", "a.txt"))).toBe(true); + expect(existsSync(join(f.repo, ".worktrees"))).toBe(false); + }); +}); diff --git a/src/tests/sandbox-worktree-entries.test.ts b/src/tests/sandbox-worktree-entries.test.ts new file mode 100644 index 00000000..2008109d --- /dev/null +++ b/src/tests/sandbox-worktree-entries.test.ts @@ -0,0 +1,207 @@ +/** + * #448 round 11: the repository's worktree entries kept as git makes them + * (sandbox/worktree-entries.ts), without a sandbox: what a command could + * have written is written here directly. sandbox-worktree-entries-real.test.ts + * runs the same through real bubblewrap. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ENTRY_GRACE_MS, WorktreeEntryGuard, type WorktreeEntryContext, worktreeEntryWarning } from "../daemon/sandbox/worktree-entries.js"; + +let root: string; +beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), "codeoid-wt-entries-"))); +}); +afterEach(() => rmSync(root, { recursive: true, force: true })); + +const ENV = { 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): string { + const r = Bun.spawnSync(["git", ...args], { cwd, env: { ...process.env, ...ENV, HOME: root } }); + if (r.exitCode !== 0) throw new Error(r.stderr.toString()); + return r.stdout.toString(); +} + +/** A repository, the session's folder beside it, and the context a session's look gets. */ +function fixture() { + const repo = join(root, "repo"); + mkdirSync(repo); + git(["init", "-q", "-b", "main"], repo); + git(["commit", "-q", "--allow-empty", "-m", "i"], repo); + const loc = join(root, "repo-worktrees"); + const own = join(loc, "agent-1111aaaa"); + mkdirSync(own, { recursive: true }); + const common = join(repo, ".git"); + const ctx = (now?: number): WorktreeEntryContext => ({ + common, + main: repo, + ownFolders: [own], + writable: [repo, own, common, join(common, "worktrees")], + readOnly: [], + locations: [loc], + ...(now !== undefined ? { now } : {}), + }); + return { repo, loc, own, common, wts: join(common, "worktrees"), ctx }; +} + +/** An entry's time pushed back, so it isn't one git may still be making. */ +function settle(dir: string): void { + const t = new Date(Date.now() - 60_000); + utimesSync(dir, t, t); +} + +describe("the session's own entries", () => { + test("one checked out in its folder when first seen, named by no other checkout, is its own; another's isn't", () => { + const f = fixture(); + git(["worktree", "add", "-q", "-b", "o", join(f.own, "o")], f.repo); + git(["worktree", "add", "-q", "-b", "u", join(root, "u")], f.repo); + const g = new WorktreeEntryGuard(); + expect(g.check(f.ctx()).findings).toEqual([]); + expect(g.ownEntries()).toEqual([join(f.wts, "o")]); + }); + + test("an entry pointed into the folder, while its real checkout still names it, is restored to that checkout and never the session's", () => { + const f = fixture(); + const g = new WorktreeEntryGuard(); + g.check(f.ctx()); + const theirs = join(f.loc, "other-2222bbbb", "bw"); + git(["worktree", "add", "-q", "-b", "bw", theirs], f.repo); + settle(join(f.wts, "bw")); + // A command forges it before the session's next look: its gitdir, and a checkout planted to name it back. + mkdirSync(join(f.own, "x")); + writeFileSync(join(f.own, "x", ".git"), `gitdir: ${join(f.wts, "bw")}\n`); + writeFileSync(join(f.wts, "bw", "gitdir"), `${join(f.own, "x", ".git")}\n`); + const { findings } = g.check(f.ctx()); + expect(findings).toEqual([expect.objectContaining({ name: "bw", restored: true })]); + expect(readFileSync(join(f.wts, "bw", "gitdir"), "utf8").trim()).toBe(join(theirs, ".git")); + expect(g.ownEntries()).toEqual([]); + }); + + test("its own entry's commondir and config.worktree are checked too (git in its checkout reads them at your next git there)", () => { + const f = fixture(); + const g = new WorktreeEntryGuard(); + g.check(f.ctx()); + git(["worktree", "add", "-q", "-b", "o", join(f.own, "o")], f.repo); + g.check(f.ctx()); + writeFileSync(join(f.wts, "o", "commondir"), `${join(f.own, "p")}\n`); + const { findings } = g.check(f.ctx()); + expect(findings).toEqual([expect.objectContaining({ name: "o", restored: true, what: expect.stringContaining("commondir") })]); + expect(g.ownEntries()).toEqual([join(f.wts, "o")]); + }); +}); + +describe("what was there at the first look", () => { + test("a checkout you remove by hand later leaves a prunable entry: nothing said", () => { + const f = fixture(); + const u = join(root, "u"); + git(["worktree", "add", "-q", "-b", "u", u], f.repo); + const g = new WorktreeEntryGuard(); + g.check(f.ctx()); + rmSync(u, { recursive: true, force: true }); + expect(g.check(f.ctx()).findings).toEqual([]); + expect(existsSync(join(f.wts, "u"))).toBe(true); + }); + + test("a prunable worktree (its checkout gone) is as git leaves it: never said, never moved", () => { + const f = fixture(); + const gone = join(root, "gone"); + git(["worktree", "add", "-q", "-b", "gone", gone], f.repo); + rmSync(gone, { recursive: true, force: true }); + const g = new WorktreeEntryGuard(); + expect(g.check(f.ctx()).findings).toEqual([]); + expect(g.check(f.ctx()).findings).toEqual([]); + expect(existsSync(join(f.wts, "gone"))).toBe(true); + }); + + test("per-worktree config the repository reads is kept as first seen; changed later, it is restored and the change kept aside", () => { + const f = fixture(); + git(["config", "extensions.worktreeConfig", "true"], f.repo); + const u = join(root, "u"); + git(["worktree", "add", "-q", "-b", "u", u], f.repo); + git(["config", "--worktree", "user.name", "Mine"], u); + const g = new WorktreeEntryGuard(); + expect(g.check(f.ctx()).findings).toEqual([]); + const yours = readFileSync(join(f.wts, "u", "config.worktree"), "utf8"); + writeFileSync(join(f.wts, "u", "config.worktree"), `${yours}[core]\n\tfsmonitor = /planted\n`); + const { findings } = g.check(f.ctx()); + expect(findings).toEqual([expect.objectContaining({ name: "u", restored: true, what: expect.stringContaining("config.worktree") })]); + expect(readFileSync(join(f.wts, "u", "config.worktree"), "utf8")).toBe(""); + }); + + test("a new worktree's config.worktree copying the main worktree's (as git worktree add does) is kept", () => { + const f = fixture(); + git(["config", "extensions.worktreeConfig", "true"], f.repo); + git(["config", "--worktree", "core.sparseCheckout", "false"], f.repo); + const g = new WorktreeEntryGuard(); + g.check(f.ctx()); + git(["worktree", "add", "-q", "-b", "n", join(root, "n")], f.repo); + writeFileSync(join(f.wts, "n", "config.worktree"), readFileSync(join(f.common, "config.worktree"), "utf8")); + settle(join(f.wts, "n")); + expect(g.check(f.ctx()).findings).toEqual([]); + }); + + test("a sparse worktree's info/sparse-checkout is git's own; a planted info/ beside it isn't", () => { + const f = fixture(); + const g = new WorktreeEntryGuard(); + g.check(f.ctx()); + git(["worktree", "add", "-q", "-b", "s", join(root, "s")], f.repo); + mkdirSync(join(f.wts, "s", "info")); + writeFileSync(join(f.wts, "s", "info", "sparse-checkout"), "/*\n"); + settle(join(f.wts, "s")); + expect(g.check(f.ctx()).findings).toEqual([]); + writeFileSync(join(f.wts, "s", "info", "attributes"), "* filter=x\n"); + expect(g.check(f.ctx()).findings).toEqual([expect.objectContaining({ name: "s", restored: true, what: expect.stringContaining("info/") })]); + expect(existsSync(join(f.wts, "s", "info"))).toBe(false); + }); +}); + +describe("what can't be restored with certainty is said, once, and left", () => { + test("an entry removed while its checkout still names it", () => { + const f = fixture(); + const u = join(root, "u"); + git(["worktree", "add", "-q", "-b", "u", u], f.repo); + const g = new WorktreeEntryGuard(); + g.check(f.ctx()); + rmSync(join(f.wts, "u"), { recursive: true, force: true }); + const { findings } = g.check(f.ctx()); + expect(findings).toEqual([expect.objectContaining({ name: "u", restored: false, what: expect.stringContaining("was removed while its checkout") })]); + expect(g.check(f.ctx()).findings).toEqual([]); + }); + + test("an entry made since, pointed at a checkout nobody vouches for", () => { + const f = fixture(); + const g = new WorktreeEntryGuard(); + g.check(f.ctx()); + const away = join(root, "far", "away", "deep", "w"); + mkdirSync(join(root, "far", "away", "deep"), { recursive: true }); + git(["worktree", "add", "-q", "-b", "w", away], f.repo); + writeFileSync(join(f.wts, "w", "gitdir"), `${join(f.own, "nothing", ".git")}\n`); + // Past the grace an entry naming no checkout may be one git is still making. + const { findings } = g.check(f.ctx(Date.now() + ENTRY_GRACE_MS + 1000)); + expect(findings).toEqual([expect.objectContaining({ name: "w", restored: false, what: expect.stringContaining("git worktree repair") })]); + const text = worktreeEntryWarning(f.common, findings); + expect(text).toContain("Not restored, for you to look at: .git/worktrees/w appeared naming"); + }); +}); + +describe("an entry git may still be making", () => { + test("waits for the grace once, and no longer, however its times are touched", () => { + const f = fixture(); + const g = new WorktreeEntryGuard(); + g.check(f.ctx()); + const wt = join(root, "late"); + git(["worktree", "add", "-q", "-b", "late", wt], f.repo); + writeFileSync(join(f.wts, "late", "gitdir"), `${join(f.own, "x", ".git")}\n`); + const now = Date.now(); + const first = g.check(f.ctx(now)); + expect(first).toEqual({ findings: [], pending: true }); + // Touched meanwhile, so it still looks new: past the grace it is looked at anyway. + const t = new Date(now + ENTRY_GRACE_MS + 50); + utimesSync(join(f.wts, "late"), t, t); + const second = g.check(f.ctx(now + ENTRY_GRACE_MS + 100)); + expect(second.pending).toBe(false); + expect(second.findings).toEqual([expect.objectContaining({ name: "late", restored: true })]); + expect(readFileSync(join(f.wts, "late", "gitdir"), "utf8").trim()).toBe(join(wt, ".git")); + }); +}); diff --git a/src/tests/sandbox-worktree-folders.test.ts b/src/tests/sandbox-worktree-folders.test.ts new file mode 100644 index 00000000..ee8c0b18 --- /dev/null +++ b/src/tests/sandbox-worktree-folders.test.ts @@ -0,0 +1,336 @@ +/** + * #448 round 10: a sandboxed session's git worktrees go in its own folder of + * each worktree location (sandbox/worktrees.ts), `sandbox.worktreeRoots` is + * validated and scoped per repository, every other runtime's socket stays + * hidden, and Docker access on Claude's Linux sandbox hides the host's /tmp. + * No sandbox runs here (sandbox-worktrees-real.test.ts and + * sandbox-claude-real.test.ts run the real ones). + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { createServer, type Server } from "node:net"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadConfig, validateConfigObject } from "../config.js"; +import type { CodeoidConfig } from "../config.js"; +import { MockSessionProvider } from "../daemon/providers/mock/session-provider.js"; +import type { ProviderEvent } from "../daemon/providers/interface.js"; +import { claudeSandboxSettings, HOST_SHARED_TEMP, physicalOf } from "../daemon/sandbox/claude-native.js"; +import { LocalSandboxHandle } from "../daemon/sandbox/local-handle.js"; +import { buildSandboxSpec, containerSocketPaths, dockerSocketPaths, gitLayoutOf, HOST_SOCKET_DIRS, hiddenPathsFor, LOCAL_SERVICE_SOCKET_DIRS, localServiceSocketPaths, unconnectableDockerSockets } from "../daemon/sandbox/policy.js"; +import { SandboxRegistry } from "../daemon/sandbox/registry.js"; +import { CLAUDE_ALL_SOCKETS_NOTE, resolveSandbox, SessionSandbox } from "../daemon/sandbox/session-sandbox.js"; +import type { SandboxProvider, SandboxSpec } from "../daemon/sandbox/types.js"; +import { existingFolderName, sessionFolderName, worktreeFolderNote, worktreeLocationDirs, worktreeRootNotes, worktreeRootProblem } from "../daemon/sandbox/worktrees.js"; +import { Session } from "../daemon/session.js"; +import { Store } from "../daemon/store.js"; +import { TranscriptStore } from "../daemon/transcript.js"; +import type { AuthContext } from "../protocol/types.js"; + +let root: string; +beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), "codeoid-wt-folders-"))); +}); +afterEach(() => rmSync(root, { recursive: true, force: true })); + +const ENV = { 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 repo(at: string): string { + mkdirSync(at, { recursive: true }); + for (const args of [["init", "-q", "-b", "main"], ["commit", "-q", "--allow-empty", "-m", "i"]]) { + const r = Bun.spawnSync(["git", ...args], { cwd: at, env: { ...process.env, ...ENV, HOME: root } }); + if (r.exitCode !== 0) throw new Error(r.stderr.toString()); + } + return at; +} + +describe("a session's worktree folder name", () => { + test("its name made safe, and a short id: stable, never a path", () => { + expect(sessionFolderName("Fix login bug", "3f9a2c1d-0000-4000-8000-000000000000")).toBe("fix-login-bug-3f9a2c1d"); + expect(sessionFolderName("../..", "abc")).toBe("session-abc"); + expect(sessionFolderName("a/b\\c", "ID-1")).toBe("a-b-c-id1"); + expect(sessionFolderName("", "")).toBe("session-session"); + expect(sessionFolderName("x".repeat(100), "12345678zz")).toBe(`${"x".repeat(40)}-12345678`); + }); + + test("a renamed session keeps its folder (found by its short id); another session's isn't taken", () => { + const loc = join(root, "repo-worktrees"); + mkdirSync(join(loc, "old-name-3f9a2c1d"), { recursive: true }); + mkdirSync(join(loc, "other-11112222"), { recursive: true }); + expect(existingFolderName([{ dir: loc }], "3f9a2c1d-0000")).toBe("old-name-3f9a2c1d"); + expect(existingFolderName([{ dir: loc }], "99998888-0000")).toBeNull(); + // A link with the id isn't one. + symlinkSync(root, join(loc, "planted-99998888")); + expect(existingFolderName([{ dir: loc }], "99998888-0000")).toBeNull(); + }); +}); + +describe("sandbox.worktreeRoots validation (#448 round 10)", () => { + const home = "/home/u"; + const ctx = { home, configDir: "/home/u/.codeoid" }; + test("refused: /, the home or a dir holding it, a hidden dir of the home, ~/Library, codeoid's config dir, a system dir, a relative one climbing out", () => { + for (const r of ["/", "~", "/home", "~/.config", "~/.config/systemd/user", "~/.local", "~/.local/bin", "~/.ssh/wt", "~/.bashrc.d", "~/.claude/wt", "~/Library/LaunchAgents", "/home/u/.codeoid/wt", "/usr/local/wt", "/etc/wt", ".", "..", "../..", "../../wt"]) { + expect([r, worktreeRootProblem(r, ctx)]).not.toEqual([r, null]); + } + }); + test("accepted: a dir of yours, beside the repository, a shared one (each repository gets its own dir in it)", () => { + for (const r of ["~/wt", "~/src/worktrees", "/data/wt", "../wt", "wts"]) expect([r, worktreeRootProblem(r, ctx)]).toEqual([r, null]); + }); + test("a shared root holds each repository apart: /", () => { + const a = repo(join(root, "a")); + const layout = gitLayoutOf(a, join(root, "home")); + if (!layout) throw new Error("no layout"); + const { locations, problems } = worktreeLocationDirs(layout, { roots: [join(root, "wt"), "~/.config"], home: join(root, "home") }); + expect(locations.map((l) => l.dir)).toEqual([join(root, "a-worktrees"), join(root, "wt", "a")]); + expect(problems).toEqual([expect.stringContaining("~/.config is in a hidden dir of your home")]); + // A root that isn't there and isn't beside the repository: said, never made. + const notes = worktreeRootNotes(layout, { roots: [join(root, "elsewhere", "missing")], home: join(root, "home") }); + expect(notes).toEqual([]); + const away = realpathSync(mkdtempSync(join(tmpdir(), "codeoid-wt-away-"))); + try { + const missing = join(away, "missing"); + expect(worktreeRootNotes(layout, { roots: [missing], home: join(root, "home") })).toEqual([expect.stringContaining("create it to use it")]); + expect(existsSync(missing)).toBe(false); + } finally { + rmSync(away, { recursive: true, force: true }); + } + }); + test("a relative root is judged again where it leads (#448 round 11): ../.config of a repository in the home is ~/.config; one inside the checkout is refused", () => { + const home = join(root, "home"); + const proj = repo(join(home, "proj")); + const layout = gitLayoutOf(proj, join(root, "elsewhere")); + if (!layout) throw new Error("no layout"); + const { locations, problems } = worktreeLocationDirs(layout, { roots: ["../.config", "../.local", "./../.ssh", "../wt", ".claude/commands", "wts"], home }); + expect(locations.map((l) => l.dir)).toEqual([join(home, "proj-worktrees"), join(home, "wt", "proj")]); + expect(problems).toEqual([ + expect.stringContaining(`../.config → ${join(home, ".config")} is in a hidden dir of your home (~/.config)`), + expect.stringContaining("hidden dir of your home (~/.local)"), + expect.stringContaining("hidden dir of your home (~/.ssh)"), + expect.stringContaining(".claude/commands is inside the repository's checkout"), + expect.stringContaining("wts is inside the repository's checkout"), + ]); + // Through a link too: a root beside the repository that leads into the home's hidden dirs. + mkdirSync(join(home, ".config")); + symlinkSync(join(home, ".config"), join(home, "cfg")); + expect(worktreeLocationDirs(layout, { roots: ["../cfg"], home }).problems).toEqual([expect.stringContaining("hidden dir of your home (~/.config)")]); + }); + test("on macOS a root differing only in case is the same dir (the filesystem ignores case)", () => { + expect(worktreeRootProblem("/Users/Me/.Config/wt", { home: "/Users/me", platform: "darwin" })).toContain("hidden dir of your home"); + expect(worktreeRootProblem("/USERS/me/LIBRARY/wt", { home: "/Users/me", platform: "darwin" })).toContain("~/Library"); + expect(worktreeRootProblem("/Users/Me", { home: "/Users/me", platform: "darwin" })).toContain("your home"); + expect(worktreeRootProblem("/Users/Me/.Config/wt", { home: "/Users/me", platform: "linux" })).toBeNull(); + }); + test("the config refuses one at load, in a settings write, and from the environment, saying why", () => { + const bad = validateConfigObject({ sandbox: { worktreeRoots: ["~/.local/bin"] } }); + expect(bad.ok).toBe(false); + if (!bad.ok) expect(bad.issues[0]).toMatchObject({ path: "sandbox.worktreeRoots.0", message: expect.stringContaining("hidden dir of your home") }); + expect(validateConfigObject({ sandbox: { worktreeRoots: ["~/wt"] } })).toEqual({ ok: true }); + expect(() => loadConfig({ raw: { sandbox: { worktreeRoots: ["/"] } }, env: {}, quiet: true })).toThrow(/not a worktree root: \/ is \//); + expect(() => loadConfig({ raw: {}, env: { CODEOID_SANDBOX_WORKTREE_ROOTS: `${homedir()}/.config` }, quiet: true })).toThrow(/not a worktree root/); + }); +}); + +describe("local services' sockets (#448 round 11)", () => { + test("hidden while the network is off or an allowlist, as the session's (not a backend handle's) network says; not with it on", async () => { + const ws = join(root, "ws"); + mkdirSync(ws); + const base = { sessionId: "s", workspace: ws, tempDir: join(root, "tmp"), hidden: { home: join(root, "home"), configDir: join(root, "home", ".codeoid"), env: { HOME: join(root, "home") }, daemonTmp: "/tmp" } }; + for (const network of [{ mode: "off" as const }, { mode: "allowlist" as const, allow: ["example.com"] }]) { + const spec = buildSandboxSpec({ ...base, network }); + for (const d of ["/run/postgresql", "/var/run/postgresql", "/run/mysqld", "/var/run/mysqld", "/run/redis"]) expect(spec.serviceSockets).toContain(d); + } + expect(buildSandboxSpec({ ...base, network: { mode: "on" } }).serviceSockets).toBeUndefined(); + // A process backend's handle has the network on (for its model); a session with it off still hides them. + expect(buildSandboxSpec({ ...base, network: { mode: "on" }, hideLocalServices: true }).serviceSockets).toEqual(expect.arrayContaining([...LOCAL_SERVICE_SOCKET_DIRS])); + const reg = new SandboxRegistry("linux", (n) => `/usr/bin/${n}`); + reg.register(fakeBwrap()); + const sbx = new SessionSandbox({ sessionId: "x", workdir: ws, registry: reg, setting: { provider: "bwrap", network: "off" } }); + try { + await sbx.refresh("pi"); + const rt = sbx.runtimeFor("pi"); + if (!rt) throw new Error("no runtime"); + const h = await rt.open([], { backendNetwork: true }); + expect(h.spec.network.mode).toBe("on"); + expect(h.spec.serviceSockets).toEqual(expect.arrayContaining([...LOCAL_SERVICE_SOCKET_DIRS])); + expect(rt.spec.serviceSockets).toEqual(expect.arrayContaining([...LOCAL_SERVICE_SOCKET_DIRS])); + } finally { + await sbx.close(); + } + }); + test("those in the host's shared temp dirs are found by name (Homebrew's Postgres and MySQL put theirs in /tmp)", () => { + const t = join(root, "t"); + mkdirSync(t); + for (const n of [".s.PGSQL.5432", ".s.PGSQL.5432.lock", "mysql.sock", "mysqlx.sock", "redis.sock", "mongodb-27017.sock", "tmux-1000", "notes.txt"]) writeFileSync(join(t, n), ""); + const found = localServiceSocketPaths([t]).filter((p) => p.startsWith(t)); + expect(found.sort()).toEqual([".s.PGSQL.5432", ".s.PGSQL.5432.lock", "mongodb-27017.sock", "mysql.sock", "mysqlx.sock", "redis.sock"].map((n) => join(t, n)).sort()); + }); + test("Claude's own sandbox denies their dirs; those in /tmp are behind its unix socket block or the denied /tmp", () => { + const spec = { sessionId: "s", workspace: "/w", tempDir: "/t", writable: [], hidden: [], reexposed: [], containerSockets: [], serviceSockets: ["/run/postgresql", "/tmp/.s.PGSQL.5432"], network: { mode: "off" as const }, unsetEnv: [] }; + const sb = claudeSandboxSettings({ spec, config: { bwrapPath: undefined } as never }, {}, undefined, "linux"); + expect(sb.filesystem?.denyRead).toContain(physicalOf("/run/postgresql")); + expect(sb.filesystem?.denyRead).not.toContain("/tmp/.s.PGSQL.5432"); + }); +}); + +describe("runtime sockets (#448 round 10)", () => { + test("libvirt, LXD, Incus and more runtimes are hidden; the system bus and screen's dir in every sandbox", () => { + const paths = containerSocketPaths("/h", { XDG_RUNTIME_DIR: "/run/user/7" }); + for (const p of ["/run/libvirt", "/var/run/libvirt", "/run/user/7/libvirt", "/var/snap/lxd/common/lxd", "/var/lib/lxd", "/var/lib/incus", "/run/incus", "/run/docker", "/run/k0s", "/var/snap/microk8s/common/run", "/run/containerd", "/run/k3s"]) expect(paths).toContain(p); + const hidden = hiddenPathsFor(undefined, { home: "/h", configDir: "/h/.codeoid", env: {}, daemonTmp: "/tmp" }); + for (const p of HOST_SOCKET_DIRS) expect(hidden).toContain(p); + }); + + test("a runtime socket the daemon's user can't connect to isn't shown, and is said", async () => { + const run = join(root, "run"); + mkdirSync(run, { mode: 0o700 }); + const sock = join(run, "docker.sock"); + const server: Server = createServer(() => {}); + await new Promise((r) => server.listen(sock, r)); + try { + const env = { XDG_RUNTIME_DIR: run }; + // Only what this test planted: the machine's own runtime socket, if it has one, isn't the test's. + const planted = (paths: string[]) => paths.filter((p) => p.startsWith(root)); + expect(planted(dockerSocketPaths(root, env, "linux"))).toEqual([sock]); + chmodSync(sock, 0o400); + if (process.getuid?.() === 0) return; + expect(planted(dockerSocketPaths(root, env, "linux"))).toEqual([]); + expect(planted(unconnectableDockerSockets(root, env, "linux"))).toEqual([sock]); + } finally { + await new Promise((r) => server.close(() => r())); + } + }); +}); + +describe("Docker access on Claude's own sandbox (#448 round 10)", () => { + const spec = (dockerSockets?: string[]): SandboxSpec => ({ + sessionId: "s", + workspace: "/w", + tempDir: "/t", + writable: ["/w-worktrees/agent-1"], + hidden: ["/h"], + reexposed: ["/w-worktrees", "/h/.bun"], + containerSockets: ["/run/libvirt"], + worktreeFolders: ["/w-worktrees/agent-1"], + network: { mode: "off" }, + unsetEnv: [], + ...(dockerSockets ? { dockerSockets } : {}), + }); + const config = { provider: "auto", network: "off", allowedHosts: [], hiddenPaths: [], homeReadable: [], worktreeRoots: [], docker: { image: "x", socket: true } } as never; + test("Linux: every unix socket allowed, so the host's /tmp and /var/tmp are denied; without Docker access, or on macOS, they aren't", () => { + const on = claudeSandboxSettings({ spec: spec(["/run/user/1/docker.sock"]), config }, {}, undefined, "linux"); + expect(on.network?.allowAllUnixSockets).toBe(true); + for (const t of HOST_SHARED_TEMP) expect(on.filesystem?.denyRead).toContain(t); + const off = claudeSandboxSettings({ spec: spec(), config }, {}, undefined, "linux"); + for (const t of HOST_SHARED_TEMP) expect(off.filesystem?.denyRead).not.toContain(t); + const mac = claudeSandboxSettings({ spec: spec(["/nonexistent/docker.sock"]), config }, {}, undefined, "darwin"); + expect(mac.network?.allowUnixSockets).toEqual(["/nonexistent/docker.sock"]); + for (const t of HOST_SHARED_TEMP) expect(mac.filesystem?.denyRead).not.toContain(t); + }); + test("a worktree location isn't readable there (the CLI would lay it over the session's writable folder); the folder is writable", () => { + const s = claudeSandboxSettings({ spec: spec(), config }, {}, undefined, "linux"); + expect(s.filesystem?.allowRead).not.toContain("/w-worktrees"); + expect(s.filesystem?.allowRead).toContain("/h/.bun"); + expect(s.filesystem?.allowWrite).toContain("/w-worktrees/agent-1"); + }); +}); + +function fakeBwrap(): SandboxProvider { + const open = (spec: SandboxSpec) => new LocalSandboxHandle("bwrap", spec, (argv, o) => ({ argv: [...argv], env: o.env })); + return { + id: "bwrap", + displayName: "bubblewrap", + capabilities: { isolation: "os", placement: "local", workspace: "mount", stdioProcess: true, commands: true, wrapsArgv: true, network: ["on", "off"], platforms: ["linux"] }, + probe: async () => ({ available: true }), + open: async (spec) => open(spec), + openSync: open, + }; +} + +describe("the sandbox's limits and the session's note", () => { + test("Claude on Linux with Docker access says every socket it can see is reachable; a missing root elsewhere is said", async () => { + const run = join(root, "run"); + mkdirSync(run, { mode: 0o700 }); + const sock = join(run, "docker.sock"); + const server: Server = createServer(() => {}); + await new Promise((r) => server.listen(sock, r)); + const saved = process.env.XDG_RUNTIME_DIR; + process.env.XDG_RUNTIME_DIR = run; + const away = realpathSync(mkdtempSync(join(tmpdir(), "codeoid-wt-away-"))); + try { + const reg = new SandboxRegistry("linux", (n) => `/usr/bin/${n}`); + reg.register(fakeBwrap()); + const ws = repo(join(root, "proj")); + const config = { provider: "auto", network: "on", allowedHosts: [], hiddenPaths: [], homeReadable: [], worktreeRoots: [join(away, "missing")], docker: { image: "x", socket: false } } as never; + const r = await resolveSandbox({ registry: reg, config, setting: { dockerSocket: true }, backendId: "claude", workspace: ws }); + expect(r.info.state).toBe("on"); + if (process.platform === "linux") expect(r.info.limits?.some((l) => l.includes(CLAUDE_ALL_SOCKETS_NOTE))).toBe(true); + expect(r.info.limits?.some((l) => l.includes("create it to use it"))).toBe(true); + const pi = await resolveSandbox({ registry: reg, config, setting: { dockerSocket: true }, backendId: "pi", workspace: ws }); + expect(pi.info.limits?.some((l) => l.includes(CLAUDE_ALL_SOCKETS_NOTE))).toBe(false); + } finally { + if (saved === undefined) delete process.env.XDG_RUNTIME_DIR; + else process.env.XDG_RUNTIME_DIR = saved; + await new Promise((r) => server.close(() => r())); + rmSync(away, { recursive: true, force: true }); + } + }); + + test("the session tells its agent where its worktrees go, in the system prompt every backend gets", async () => { + const ws = repo(join(root, "proj")); + const store = new Store(join(root, "codeoid.db")); + const transcriptStore = new TranscriptStore(join(root, "transcripts")); + const auth: AuthContext = { sub: "u", scopes: [], delegationDepth: 0, accountId: "a", projectId: "p" }; + const id = randomUUID(); + store.createSession({ id, name: "Fix Login", workdir: ws, status: "idle", createdBy: "u", createdAt: new Date().toISOString(), attachedClients: 0, accountId: "a", projectId: "p" }); + const done: ProviderEvent = { type: "turn_done", result: { providerId: "pi", model: "m", inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheCreationTokens: 0, totalCostUsd: 0, durationMs: 1 } }; + const provider = new MockSessionProvider("pi", [[done]]); + const reg = new SandboxRegistry("linux", (n) => `/usr/bin/${n}`); + reg.register(fakeBwrap()); + const session = new Session({ + name: "Fix Login", + workdir: ws, + auth, + store, + transcriptStore, + existingId: id, + _testProvider: provider, + initialMode: { mode: "interactive" }, + config: { + workspaceIndex: { enabled: false, episodeThreshold: 5, timeThresholdMs: 60_000, debounceMs: 15_000 }, + compress: { enabled: false, excludeCommands: [], excludePatterns: [], compressPipes: false, minBytes: 1024 }, + labeling: {}, + telemetry: { osc8: "auto" }, + autoRotate: { enabled: false, warnPct: 0.6, rotatePct: 0.8, hardRotatePct: 0.9, minTurnsBeforeRotate: 3, strategy: "task-anchor" }, + session: {}, + } as unknown as CodeoidConfig, + sandboxRegistry: reg, + sandbox: { provider: "bwrap" }, + }); + try { + await session.send("go", auth); + const folder = join(root, "proj-worktrees", sessionFolderName("Fix Login", id)); + expect(provider.capturedOpts[0]?.systemPromptAppend).toContain(worktreeFolderNote([folder])); + expect(worktreeFolderNote([folder])).toContain(`Create git worktrees under ${folder}/`); + expect(existsSync(folder)).toBe(true); + } finally { + await session.destroy(auth).catch(() => {}); + await transcriptStore.flush().catch(() => {}); + store.close(); + } + // Left empty, it goes with the session. + expect(existsSync(join(root, "proj-worktrees", sessionFolderName("Fix Login", id)))).toBe(false); + }); + + test("SessionSandbox: no folder outside a repository", async () => { + const reg = new SandboxRegistry("linux", (n) => `/usr/bin/${n}`); + reg.register(fakeBwrap()); + mkdirSync(join(root, "plain")); + const sbx = new SessionSandbox({ sessionId: "x", workdir: join(root, "plain"), registry: reg, setting: { provider: "bwrap" } }); + try { + await sbx.refresh("pi"); + expect(sbx.worktreeFolders()).toEqual([]); + } finally { + await sbx.close(); + } + }); +}); diff --git a/src/tests/sandbox-worktree-roots-real.test.ts b/src/tests/sandbox-worktree-roots-real.test.ts new file mode 100644 index 00000000..a019034e --- /dev/null +++ b/src/tests/sandbox-worktree-roots-real.test.ts @@ -0,0 +1,106 @@ +/** + * Real bubblewrap (#448 round 11), skipped where it can't run: a relative + * `sandbox.worktreeRoots` entry is judged where it leads for the repository + * (`../.config` of one in the home is `~/.config`): refused, never made, + * nothing of it writable inside; a relative one that is fine still works. + * Everything runs against a fake home and repository this test made. + */ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; +import { BwrapSandboxProvider } from "../daemon/sandbox/bwrap.js"; +import { buildSandboxSpec, gitLayoutOf } from "../daemon/sandbox/policy.js"; +import { worktreeLocationDirs } 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-roots-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(); +} + +const FOLDER = "agent-1111aaaa"; + +describe.skipIf(!probe.available || !Bun.which("git"))("bubblewrap, for real: relative worktree roots judged where they lead (#448 round 11)", () => { + let root: string; + beforeAll(() => { + root = realpathSync(mkdtempSync(join(scratchParent(), "codeoid-sbx-roots-"))); + }); + afterAll(() => rmSync(root, { recursive: true, force: true })); + + test("../.config of a repository in the home is refused and never made, nothing of ~/.config is writable inside; ../wt still works", async () => { + const home = join(root, "home"); + const repo = join(home, "proj"); + mkdirSync(repo, { recursive: true }); + git(["init", "-q", "-b", "main"], repo, home); + git(["commit", "-q", "--allow-empty", "-m", "i"], repo, home); + const layout = gitLayoutOf(repo, join(root, "elsewhere")); + if (!layout) throw new Error("no layout"); + const { locations, problems } = worktreeLocationDirs(layout, { roots: ["../.config", "../.local/share", "../wt"], home }); + expect(locations.map((l) => l.dir)).toEqual([join(home, "proj-worktrees"), join(home, "wt", "proj")]); + expect(problems).toEqual([expect.stringContaining("hidden dir of your home (~/.config)"), expect.stringContaining("hidden dir of your home (~/.local)")]); + const tmp = join(root, "tmp"); + mkdirSync(tmp); + const spec = buildSandboxSpec({ + sessionId: "roots", + workspace: repo, + tempDir: tmp, + network: { mode: "off" }, + config: { sandbox: { worktreeRoots: ["../.config", "../wt"] } } as never, + hidden: { home, configDir: join(home, ".codeoid"), env: { HOME: home }, daemonTmp: "/tmp" }, + worktreeFolder: FOLDER, + }); + expect(spec.worktreeFolders).toEqual([join(home, "proj-worktrees", FOLDER), join(home, "wt", "proj", FOLDER)]); + expect(existsSync(join(home, ".config"))).toBe(false); + const h = await provider.open(spec); + try { + const r = await h.exec( + [ + "/bin/sh", + "-c", + [ + `mkdir -p '${join(home, ".config", "proj", FOLDER)}' 2>/dev/null && echo MADE-CONFIG || echo CONFIG-REFUSED`, + `git worktree add -q '${join(home, "wt", "proj", FOLDER, "x")}' -b x && echo ADDED`, + ].join("; "), + ], + { cwd: repo, env: gitEnv(home), timeoutMs: 30_000 }, + ); + expect(r.stdout).toContain("CONFIG-REFUSED"); + expect(r.stdout).toContain("ADDED"); + } finally { + await h.close(); + } + expect(existsSync(join(home, ".config"))).toBe(false); + }); +}); diff --git a/src/tests/sandbox-worktrees-real.test.ts b/src/tests/sandbox-worktrees-real.test.ts new file mode 100644 index 00000000..0bf5a798 --- /dev/null +++ b/src/tests/sandbox-worktrees-real.test.ts @@ -0,0 +1,566 @@ +/** + * Real bubblewrap (#448 follow-up, round 10), skipped where it can't run: an + * agent makes git worktrees inside the sandbox, in its own folder of each + * worktree location only, and tests that start containers get the container + * runtime's socket only when the owner opted in (no other runtime's). + * + * 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. 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, readdirSync, readFileSync, realpathSync, rmSync, symlinkSync, 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 { gitControlChanges, gitControlSnapshot } from "../daemon/sandbox/git-watch.js"; +import { buildSandboxSpec, dockerSocketPaths } from "../daemon/sandbox/policy.js"; +import { SandboxRegistry } from "../daemon/sandbox/registry.js"; +import { SessionSandbox } from "../daemon/sandbox/session-sandbox.js"; +import type { SandboxHandle } from "../daemon/sandbox/types.js"; +import { noteCodeoidWorktree } 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(); +} + +const FOLDER = "agent-1111aaaa"; + +describe.skipIf(!probe.available || !Bun.which("git"))("bubblewrap, for real: an agent's own git worktrees, in its own folder (#448 round 10)", () => { + 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; the user's worktrees (one beside it, one in + * its `-worktrees` location); one whose checkout is gone (as after an + * unmounted disk); another session's worktree in its own folder there; and + * a codeoid fork's checkout. The session under test is `FOLDER`'s. + */ + function fixture(name: string, opts: { workspace?: "main" | "worktree"; config?: unknown } = {}) { + 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 loc = join(dir, "repo-worktrees"); + const userWt = join(dir, "userwt"); + git(["worktree", "add", "-q", "-b", "user", userWt], repo, home); + const userLoc = join(loc, "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 }); + // Another session's worktree, in its folder. + const otherWt = join(loc, "other-2222bbbb", "ow"); + git(["worktree", "add", "-q", "-b", "ow", otherWt], repo, home); + // A codeoid fork's checkout. + const fork = join(loc, "fork-3333cccc"); + noteCodeoidWorktree(fork); + git(["worktree", "add", "-q", "-b", "fork", fork], repo, home); + let ws = repo; + if (opts.workspace === "worktree") { + ws = join(loc, "session-4444dddd"); + noteCodeoidWorktree(ws); + 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 spec = (folder: string = FOLDER) => + buildSandboxSpec({ + sessionId: name, + workspace: ws, + tempDir: tmp, + network: { mode: "off" }, + ...(opts.config ? { config: opts.config as never } : {}), + hidden: { home, configDir: join(home, ".codeoid"), env: { HOME: home }, daemonTmp: "/tmp" }, + worktreeFolder: folder, + }); + return { dir, home, repo, ws, loc, own: join(loc, FOLDER), common, userWt, userLoc, gone, otherWt, fork, 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("in its own folder: git worktree add, commit, rebase, list, remove, prune; from a linked worktree too; still its own in the next sandbox", async () => { + for (const workspace of ["main", "worktree"] as const) { + const f = fixture(`add-${workspace}`, { workspace }); + const spec = f.spec(); + // Only beside the repository: nothing is made in the main checkout (#448 round 11). + expect(spec.worktreeFolders).toEqual([f.own]); + expect(existsSync(join(f.repo, ".worktrees"))).toBe(false); + const h = await provider.open(spec); + try { + const add = await sh(h, f.ws, `git worktree add '${join(f.own, "x")}' -b feature/x 2>&1 && echo ADDED`, f.env); + expect(add.stdout).toContain("ADDED"); + const commit = await sh(h, join(f.own, "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"); + expect((await sh(h, f.ws, "git worktree list --porcelain", f.env)).stdout).toContain(join(f.own, "x")); + expect((await sh(h, f.ws, `git worktree remove '${join(f.own, "x")}' && echo REMOVED && git worktree prune && echo PRUNED`, f.env)).stdout).toContain("PRUNED"); + expect((await sh(h, f.ws, `git worktree add -q '${join(f.own, "mine")}' -b mine-${workspace} && echo ADDED`, f.env)).stdout).toContain("ADDED"); + } finally { + await h.close(); + } + // The next sandbox (a backend restarted, the next query, a daemon restart): it is checked out in the session's folder. + const h2 = await provider.open(f.spec()); + try { + const r = await sh(h2, join(f.own, "mine"), `echo m > m.txt && git add m.txt && git commit -qm mine && echo COMMITTED && cd '${f.ws}' && git worktree remove --force '${join(f.own, "mine")}' && echo REMOVED`, f.env); + expect(r.stdout).toContain("COMMITTED"); + expect(r.stdout).toContain("REMOVED"); + } finally { + await h2.close(); + } + expect(git(["log", "--oneline", "feature/x"], f.repo, f.home)).toContain("in-worktree"); + expect(git(["log", "--oneline", `mine-${workspace}`], f.repo, f.home)).toContain("mine"); + 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("anywhere else in a location it fails (Read-only file system): the location itself, another session's folder, a .worktrees you have in the main checkout from a session there", async () => { + const f = fixture("elsewhere"); + // Yours, from before: no location (nothing is made there), but held for a session working in the checkout. + mkdirSync(join(f.repo, ".worktrees")); + const h = await provider.open(f.spec()); + try { + const r = await sh( + h, + f.repo, + [ + `git worktree add '${join(f.loc, "x")}' -b x 2>&1 && echo ADDED-IN-LOCATION || echo LOCATION-REFUSED`, + `git worktree add '${join(f.loc, "other-2222bbbb", "y")}' -b y 2>&1 && echo ADDED-IN-OTHER || echo OTHER-REFUSED`, + `mkdir '${join(f.loc, "mine-too")}' 2>&1 && echo MADE-FOLDER || echo FOLDER-REFUSED`, + "git worktree add .worktrees/z -b z 2>&1 && echo ADDED-DOT || echo DOT-REFUSED", + ].join("; "), + f.env, + ); + for (const w of ["LOCATION-REFUSED", "OTHER-REFUSED", "FOLDER-REFUSED", "DOT-REFUSED"]) expect(r.stdout).toContain(w); + expect(r.stdout).toContain("Read-only file system"); + } finally { + await h.close(); + } + for (const p of [join(f.loc, "x"), join(f.loc, "other-2222bbbb", "y"), join(f.loc, "mine-too"), join(f.repo, ".worktrees", "z")]) expect(existsSync(p)).toBe(false); + }); + + test("another session's folder, the user's worktrees and a codeoid fork stay read-only, and so do a folder and a fork made after the sandbox opened", async () => { + for (const workspace of ["main", "worktree"] as const) { + const f = fixture(`others-${workspace}`, { workspace }); + const h = await provider.open(f.spec()); + // Made while the sandbox runs: another session's folder with a worktree in it, and a codeoid fork. + const lateOther = join(f.loc, "late-5555eeee", "lw"); + git(["worktree", "add", "-q", "-b", "lw", lateOther], f.repo, f.home); + const lateFork = join(f.loc, "fork2-6666ffff"); + noteCodeoidWorktree(lateFork); + git(["worktree", "add", "-q", "-b", "fork2", lateFork], f.repo, f.home); + try { + const lines: string[] = []; + for (const [tag, at] of [["OTHER", f.otherWt], ["USERLOC", f.userLoc], ["USERWT", f.userWt], ["FORK", f.fork], ["LATE", lateOther], ["LATEFORK", lateFork]] as const) { + lines.push(`echo hi > '${join(at, "new.txt")}' 2>/dev/null && echo WROTE-${tag} || echo ${tag}-READ-ONLY`); + lines.push(`echo "gitdir: /planted" > '${join(at, ".git")}' 2>/dev/null && echo REDIRECTED-${tag} || echo ${tag}-DOTGIT-HELD`); + lines.push(`mkdir -p '${join(at, ".claude")}' 2>/dev/null; echo '{}' > '${join(at, ".claude", "settings.json")}' 2>/dev/null && echo SETTINGS-${tag} || echo ${tag}-SETTINGS-HELD`); + } + lines.push(`git worktree remove --force '${f.otherWt}' 2>/dev/null && echo REMOVED-OTHER || echo OTHER-NOT-REMOVED`); + lines.push(`mkdir '${join(f.loc, "late-5555eeee", "planted")}' 2>/dev/null && echo WROTE-LATE-FOLDER || echo LATE-FOLDER-READ-ONLY`); + const r = await sh(h, f.ws, lines.join("; "), f.env); + for (const tag of ["OTHER", "USERLOC", "USERWT", "FORK", "LATE", "LATEFORK"]) { + expect(r.stdout).toContain(`${tag}-READ-ONLY`); + expect(r.stdout).toContain(`${tag}-DOTGIT-HELD`); + expect(r.stdout).toContain(`${tag}-SETTINGS-HELD`); + } + expect(r.stdout).toContain("OTHER-NOT-REMOVED"); + expect(r.stdout).toContain("LATE-FOLDER-READ-ONLY"); + } finally { + await h.close(); + } + // The next sandbox holds the late ones' admin dirs too. + const h2 = await provider.open(f.spec()); + try { + const r = await sh(h2, f.ws, `echo x > '${join(f.common, "worktrees", "lw", "commondir")}' 2>/dev/null && echo REWROTE-LATE || echo LATE-ADMIN-HELD; echo x > '${join(f.common, "worktrees", "fork2-6666ffff", "commondir")}' 2>/dev/null && echo REWROTE-FORK || echo FORK-ADMIN-HELD`, f.env); + expect(r.stdout).toContain("LATE-ADMIN-HELD"); + expect(r.stdout).toContain("FORK-ADMIN-HELD"); + } finally { + await h2.close(); + } + for (const at of [f.otherWt, f.userLoc, f.userWt, f.fork, lateOther, lateFork]) { + expect(existsSync(join(at, "new.txt"))).toBe(false); + expect(readFileSync(join(at, ".git"), "utf8")).not.toContain("planted"); + userGit(f, at); + } + expect(existsSync(f.marker)).toBe(false); + } + }); + + test("the repository's existing 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}' '${join(f.own, "evil")}' && git --git-dir='${join(f.own, "evil")}' config core.fsmonitor '${f.mark}' && echo BUILT`, + // An existing worktree's admin files: what decides where git looks. + `echo '${join(f.own, "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`, + `echo x > '${join(wts, "ow", "commondir")}' 2>/dev/null && echo REWROTE-OTHER-ADMIN || echo OTHER-ADMIN-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`, + // 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 session's own folder can't be renamed away either. + `mv '${f.own}' '${join(f.own, "..", "renamed")}' 2>/dev/null && echo MOVED-OWN || echo OWN-PINNED`, + // 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", "OTHER-ADMIN-HELD", "ADMIN-PINNED", "ADMIN-KEPT", "WORKTREES-PINNED", "PRUNE-RAN", "GONE-KEPT", "OWN-PINNED", "CONFIG-HELD", "HOOKS-HELD", "INFO-HELD"]) { + expect(r.stdout).toContain(held); + } + // Still: a commit where it works. + 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(); + } + for (const at of [f.ws, f.userWt, f.userLoc, f.repo, f.otherWt]) userGit(f, at); + expect(existsSync(f.marker)).toBe(false); + } + }); + + test("planting an admin dir under a held one's name fails; a new one is the session's own only when checked out in its folder", async () => { + const f = fixture("plant"); + const wts = join(f.common, "worktrees"); + const h = await provider.open(f.spec()); + try { + const r = await sh( + h, + f.repo, + `mkdir -p '${join(wts, "userwt", "x")}' 2>/dev/null && echo MADE-IN-USERS || echo USERS-HELD; echo x > '${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 while the session runs doesn't rebuild its sandbox; what a command changes in its entry is restored at the turn's end, and the next spec holds it (SessionSandbox, #448 round 11)", async () => { + const f = fixture("refresh"); + const registry = new SandboxRegistry("linux"); + registry.register(provider); + const found: string[] = []; + const sbx = new SessionSandbox({ sessionId: "1111aaaa-0000-4000-8000-000000000000", name: "agent", workdir: f.repo, registry, setting: { provider: "bwrap", network: "off" }, onWorktreeEntries: (_c, fs) => found.push(...fs.map((x) => x.name)) }); + try { + const info = await sbx.refresh("pi"); + expect(info.state).toBe("on"); + const rt = sbx.runtimeFor("pi"); + if (!rt) throw new Error("no runtime"); + const first = rt.spec; + expect(sbx.worktreeFolders()).toEqual([join(f.loc, FOLDER)]); + const fork = join(f.loc, "fork9-7777aaaa"); + noteCodeoidWorktree(fork); + git(["worktree", "add", "-q", "-b", "fork9", fork], f.repo, f.home); + // Not rebuilt: a Claude query, a process backend keep running as they are. + expect(rt.spec).toBe(first); + const h = await provider.open(first); + try { + const r = await sh(h, f.repo, `echo x > '${join(f.common, "worktrees", "fork9-7777aaaa", "commondir")}' 2>/dev/null && echo REWROTE || echo FORK-ADMIN-HELD; echo x > '${join(fork, "f.txt")}' 2>/dev/null && echo WROTE || echo FORK-CHECKOUT-HELD`, f.env); + expect(r.stdout).toContain("REWROTE"); + expect(r.stdout).toContain("FORK-CHECKOUT-HELD"); + } finally { + await h.close(); + } + sbx.checkWorktreeEntries(); + expect(found).toEqual(["fork9-7777aaaa"]); + expect(readFileSync(join(f.common, "worktrees", "fork9-7777aaaa", "commondir"), "utf8").trim()).toBe("../.."); + userGit(f, fork); + expect(existsSync(f.marker)).toBe(false); + // The next natural rebuild (its setting changed) holds it. + await sbx.set({ provider: "bwrap", network: "off", allowedHosts: [] }); + await sbx.refresh("pi"); + const next = sbx.runtimeFor("pi")?.spec; + expect(next).not.toBe(first); + expect(next?.readOnly).toContain(join(f.common, "worktrees", "fork9-7777aaaa")); + } finally { + await sbx.close(); + } + // Its folder, left empty, is removed with the session. + expect(existsSync(join(f.loc, FOLDER))).toBe(false); + }); + + test("the end-of-turn warning: a worktree git made in the session's folder reads as nothing changed; its .git pointed at a planted git dir is named", async () => { + const f = fixture("watch"); + const opts = { ownFolders: [f.own] }; + const spec = f.spec(); + const before = gitControlSnapshot(f.repo, f.home, opts); + const h = await provider.open(spec); + try { + await sh(h, f.repo, `git worktree add -q '${join(f.own, "w")}' -b w`, f.env); + } finally { + await h.close(); + } + const after = gitControlSnapshot(f.repo, f.home, opts); + expect(gitControlChanges(before, after)).toEqual([]); + // The agent redirects its own worktree's .git to a copy of the git dir carrying core.fsmonitor. + const h2 = await provider.open(spec); + try { + const r = await sh( + h2, + f.repo, + `cp -r '${f.common}' '${join(f.own, ".evil")}' && git --git-dir='${join(f.own, ".evil")}' config core.fsmonitor '${f.mark}' && echo "gitdir: ${join(f.own, ".evil")}" > '${join(f.own, "w", ".git")}' && echo REDIRECTED`, + f.env, + ); + expect(r.stdout).toContain("REDIRECTED"); + } finally { + await h2.close(); + } + const changed = gitControlChanges(after, gitControlSnapshot(f.repo, f.home, opts)); + expect(changed).toContain(join(f.own, "w", ".git")); + // A .git dir planted in the folder (a repository of its own, its config obeyed there) is named too. + const before3 = gitControlSnapshot(f.repo, f.home, opts); + mkdirSync(join(f.own, "clone", ".git"), { recursive: true }); + writeFileSync(join(f.own, "clone", ".git", "config"), "[core]\n"); + expect(gitControlChanges(before3, gitControlSnapshot(f.repo, f.home, opts))).toContain(join(f.own, "clone", ".git")); + // Another's admin dir changed is named; one removed is not news. + const before4 = gitControlSnapshot(f.repo, f.home, opts); + writeFileSync(join(f.common, "worktrees", "ow", "gitdir"), "/elsewhere/.git\n"); + rmSync(join(f.common, "worktrees", "userloc"), { recursive: true, force: true }); + expect(gitControlChanges(before4, gitControlSnapshot(f.repo, f.home, opts))).toEqual([".git/worktrees/ow/gitdir"]); + }); + + 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"); + // Made by a command before any sandbox held .worktrees (or by hand). + rmSync(join(f.repo, ".worktrees"), { recursive: true, force: true }); + symlinkSync(elsewhere, join(f.repo, ".worktrees")); + // A later session in a linked worktree of the repository: `.worktrees` would be a location of its own. + const ws = join(f.loc, "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" }, worktreeFolder: "later-8888aaaa" }); + expect(spec.worktreeFolders ?? []).toEqual([join(f.loc, "later-8888aaaa")]); + expect(spec.writable.some((w) => w.startsWith(elsewhere))).toBe(false); + 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"); + expect(readdirSync(elsewhere)).toEqual(["keep.txt"]); + }); + + test("sandbox.worktreeRoots: a root shared by repositories gets //; another repository's worktree there stays read-only; a missing root elsewhere isn't made; a refused one isn't used", async () => { + const base = join(root, "roots-elsewhere"); + const shared = join(base, "shared"); + mkdirSync(shared, { recursive: true }); + const f = fixture("roots", { config: { sandbox: { worktreeRoots: [shared, join(base, "missing"), "../beside", "~/.config", "~"] } } }); + // Repository B's worktree in the shared root (the docs' old `~/wt` shape). + const b = join(base, "b"); + mkdirSync(b); + git(["init", "-q", "-b", "main"], b, f.home); + writeFileSync(join(b, "b.txt"), "b\n"); + git(["add", "."], b, f.home); + git(["commit", "-qm", "b"], b, f.home); + const bFeat = join(shared, "b-feat"); + git(["worktree", "add", "-q", "-b", "feat", bFeat], b, f.home); + const spec = f.spec(); + const mine = join(shared, "repo", FOLDER); + const beside = join(f.dir, "beside", "repo", FOLDER); + expect(spec.worktreeFolders).toEqual([f.own, mine, beside]); + expect(existsSync(join(base, "missing"))).toBe(false); + const h = await provider.open(spec); + try { + const r = await sh( + h, + f.repo, + [ + `git worktree add -q '${join(mine, "e")}' -b e && echo ADDED`, + `echo "gitdir: /planted" > '${join(bFeat, ".git")}' 2>/dev/null && echo REDIRECTED-B || echo B-DOTGIT-HELD`, + `echo x > '${join(bFeat, "b.txt")}' 2>/dev/null && echo WROTE-B || echo B-READ-ONLY`, + `mkdir '${join(shared, "x")}' 2>/dev/null && echo WROTE-ROOT || echo ROOT-READ-ONLY`, + ].join("; "), + f.env, + ); + for (const w of ["ADDED", "B-DOTGIT-HELD", "B-READ-ONLY", "ROOT-READ-ONLY"]) expect(r.stdout).toContain(w); + } finally { + await h.close(); + } + expect(readFileSync(join(bFeat, ".git"), "utf8")).not.toContain("planted"); + }); +}); + +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)); + // A session libvirt's socket beside it ($XDG_RUNTIME_DIR/libvirt/libvirt-sock): a VM with the host's disk. + mkdirSync(join(runtime, "libvirt"), { mode: 0o700 }); + libvirtSock = join(runtime, "libvirt", "libvirt-sock"); + libvirt = createServer((c) => c.end("PLANTED-LIBVIRT\n")); + await new Promise((r) => libvirt.listen(libvirtSock, r)); + }); + let libvirt: Server; + let libvirtSock: string; + afterAll(async () => { + await new Promise((r) => server.close(() => r())); + await new Promise((r) => libvirt.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 () => { + // The planted socket is the user's runtime one, so it comes first; the machine's own (if any) may follow. + expect(dockerSocketPaths(join(root, "home"), env())[0]).toBe(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?.[0]).toBe(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 (another runtime's dir in it is there empty: its mount point). + const ls = await h1.exec(["/bin/sh", "-c", `ls -A '${runtime}'; echo ---; ls -A '${join(runtime, "libvirt")}'`], { cwd: join(root, "ws"), env: { PATH: "/usr/bin:/bin" }, timeoutMs: 10_000 }); + expect(ls.stdout.trim().split(/\s+/)).toEqual(["docker.sock", "libvirt", "---"]); + } finally { + await h1.close(); + } + } + }); + + test.skipIf(!Bun.which("python3"))("another runtime's socket (libvirt's, #448 round 10) stays hidden with Docker access on; the host's own libvirt, LXD and Incus sockets are unreachable in every sandbox", async () => { + const on = spec(true); + expect(on.containerSockets).toContain(join(runtime, "libvirt")); + const h = await provider.open(on); + try { + const r = await connect(h, libvirtSock); + expect(r.stdout).not.toContain("PLANTED-LIBVIRT"); + expect((await connect(h, sock)).stdout).toContain("PLANTED-RUNTIME"); + } finally { + await h.close(); + } + for (const p of ["/run/libvirt", "/var/run/libvirt", "/var/snap/lxd/common/lxd", "/var/lib/lxd", "/var/lib/incus", "/run/incus", "/run/dbus", "/run/screen"]) { + expect([...spec(false).containerSockets, ...spec(false).hidden]).toContain(p); + } + // Where the host has them (this one may: a libvirt daemon), nothing of them is there inside. + const real = ["/run/libvirt/libvirt-sock", "/run/libvirt/libvirt-sock-ro", "/var/snap/lxd/common/lxd/unix.socket", "/var/lib/incus/unix.socket", "/run/dbus/system_bus_socket"].filter((p) => existsSync(p)); + for (const docker of [false, true]) { + const h2 = await provider.open(spec(docker)); + try { + for (const p of real) { + const r = await connect(h2, p); + expect(r.stdout).toMatch(/FileNotFoundError|ConnectionRefusedError|No such file|refused/); + } + } finally { + await h2.close(); + } + } + }); +}); diff --git a/src/tests/settings.test.ts b/src/tests/settings.test.ts index a9bf7c4e..6ab5a9c5 100644 --- a/src/tests/settings.test.ts +++ b/src/tests/settings.test.ts @@ -129,6 +129,30 @@ describe("settings store", () => { expect(onDisk.session.turnStallTimeoutMs).toBe(200_000); }); + 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: [] }); + 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"]); + 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", () => { applyPatches([{ key: "memory.enabled", value: false }]); // establish a file const before = readFileSync(configFilePaths().configPath, "utf8"); 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}
- +