Repository navigation
feat: agent commands run in an OS sandbox, behind pluggable providers (#448) - #454
Merged
Merged
Conversation
…mands to the workspace and hiding credentials (#448) A SandboxProvider declares its isolation tier, placement, workspace mode, stdio/command support and the network modes it enforces, and opens a per-session SandboxHandle (spawn, exec, argv wrap, close). The registry resolves auto | off | <id> and caches probes. The bubblewrap provider lays the workspace-write policy over a read-only host: private /tmp, workspace writable, codeoid's config and data dirs, agent logins, SSH/cloud/kube/ docker/gnupg/gh/netrc/keyring stores hidden, container sockets masked, --unshare-net when the network is off (an allowlist it can't enforce is off, never on). sandbox.* config and CODEOID_SANDBOX* env vars. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… and unattended work refuses to start unsandboxed (#448) Every session resolves its sandbox (its setting over sandbox.*) for its backend: on (with how it's confined and what it can't hold), off by the owner, unavailable with the reason, or no-shell. SessionInfo carries it, approval cards carry sandboxed/sandboxNote, and an autonomous turn, an unattended run and a session started for unattended work (routines, loops, dispatch workers) refuse to start when it can't hold; set_mode autonomous and a loop start are refused up front. Interactive sessions run and say "not sandboxed: <why>" on every card. Claude gets the Agent SDK's sandbox (bubblewrap/Seatbelt): workspace and a private temp dir writable, hidden paths and container sockets unreadable, network on/off/allowlist through its proxy, credential variables denied to commands, failIfUnavailable, every command still asked. A command asking to run outside the sandbox is routed to the gate past allow rules and asked in every mode; SandboxNetworkAccess follows the session's network. Verified against the real CLI with a localhost Messages API stand-in. session.set_sandbox (owner only): narrowing applies, widening is a confirm on the session in every mode, audited. sandbox.providers lists providers. Forks and collaboration children keep their parent's setting; routines and loop starts take one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…commands can't reach credentials even in autonomous mode (#448) With the session's sandbox on and native, every thread/turn carries codex's own workspace-write policy (the workspace and the session's temp dir writable, network per the session) instead of danger-full-access, and the app-server itself starts inside codeoid's bubblewrap (its own home reachable, the network on for it), which hides what codex's sandbox leaves readable. In autonomous mode (approval never, #442) that is the boundary. Where codex's sandbox can't nest (Seatbelt, Docker) the whole app-server runs inside codeoid's with full access there. Off or unavailable: as before, CODEX_SANDBOX_POLICY or full access. A codex permissions request (widening its sandbox) is an always-asked approval like Claude's unsandboxed retry. The session temp dir is also writable at its own path (codex's inner bwrap resolves /tmp to it), and a backend's own program is shown read-only when it would otherwise be hidden or under the replaced /tmp. Verified: codex's sandbox works nested in codeoid's bwrap here; a real-bwrap test shows the app-server can't read a planted credential but reads its own home. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… sandbox (#448) Each process backend's process starts inside the session's sandbox when it is on: pi's rpc process and the Gemini CLI's ACP process through the handle's argv wrap, Qwen Code through a launcher the SDK spawns as its pathToQwenExecutable (a quoted exec script in a daemon-private dir outside anything the sandbox can write, removed with the session). Each keeps its own home reachable (an account's or settings overlay, and the daemon's own the overlay links to) and its program readable; every other credential store and codeoid's data stay hidden. These backends need the network to reach their model, so a session asking for network off on them is not enforced and unattended work there refuses to start. Verified with real bubblewrap: fake pi and ACP agents can't read a planted credential but read their own login; Qwen's real bundled CLI starts through its launcher inside bubblewrap. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sandbox (#448) opencode's one shared server serves every session, so its SHELL is now a codeoid shim (written at server start, 0700): each session's shell environment, which codeoid sets per session and per subagent, names a launcher that runs `bash -c <line>` inside that session's sandbox, or `off`. A line with neither — the server's own environment, a subagent created in a feed gap before codeoid set its environment (I29's known gap), a path that isn't one of codeoid's launchers — runs nothing. Launchers keep the caller's cwd (bwrap without --chdir). opencode's file tools still run in its server, under codeoid's gate. Verified against the real opencode 2.2.5 with the scripted local model (I69): a planted credential is unreadable, rm -rf of a planted dir outside the workspace fails, the workspace is writable, the daemon's processes aren't in the sandbox's /proc; a session whose sandbox is off runs bash as before. The full real-opencode scripted suite passes with the shim. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Seatbelt provider (auto on macOS) runs a program under an SBPL profile: allow by default, then no writes but the workspace, the session's temp dir, the backend's own state and the usual devices; hidden paths neither readable nor writable, laid shallowest first as bwrap's mounts are; container sockets unreadable and not connectable; network off (and an allowlist it can't hold by host) denies all network. Every path reaches the profile as a -D parameter, never as text. TMPDIR and unset agent sockets go in the argv (/usr/bin/env), so launchers get them too. Profile generation is pure and unit-tested; loading and enforcement need a real Mac (listed in docs/sandbox.md). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…what the backend needs mounted (#448) The Docker provider (chosen, never auto) starts each program in a fresh container of sandbox.docker.image: --rm, --init, as the user, all capabilities dropped, no new privileges, a pids limit, labelled with the session; the workspace and the session's /tmp mounted writable, the backend's own state and program mounted when its whole process is inside, nothing else of the host (no credential store, no runtime socket, the docker client's own settings kept out); a hidden path inside a mount covered again; --network none for off (and for an allowlist, which it can't hold). --runtime runsc gives kernel isolation (gVisor). Variables go in by name (-e NAME); launchers forward their caller's names and start in the caller's directory. Closing the session removes its containers. Claude and Codex run whole inside it (spawnClaudeCodeProcess, the app-server), pi, the Gemini CLI and Qwen likewise, opencode one container per shell line. Argument building is unit-tested; the real-container tests run with CODEOID_TEST_DOCKER=1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… and lets the owner choose (#448) - Settings: a Sandbox tab (sandbox.provider, network, allowed hosts, more hidden paths, bwrap path, Docker image and runtime) in the web UI and /settings; sandbox.provider is a closed enum, so a typo fails the load. - Web: a sandbox chip in the session header (green sandboxed, amber not sandboxed or partly, with why, red off), the approval bar and tool cards carry the sandbox line, and the new-session dialog has a "change" that lists the daemon's providers (an unavailable one disabled with why) and the network. - CLI: codeoid new --sandbox/--network/--allow-host, the same on loop start and routine add/edit, codeoid ls and create print the sandbox, approval prompts carry its line, codeoid sandbox providers | set <session>. - Telegram: approval messages carry the sandbox line. - /sandbox <auto|off|id> [on|off|allow:host,…] slash command. - Shared words in @highflame/codeoid-core (sandboxBadge, sandboxSummary, approvalSandboxLine); session.approvals entries carry sandboxed too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t it doesn't, and how to add a provider (#448) docs/sandbox.md: what is writable, read-only, hidden and unreachable; choosing a provider and network per daemon and per session; the fallback rules; the backend × provider matrix and what each leaves open; running one command outside; what was verified here with real bubblewrap, the real Claude CLI, codex and opencode, and what remains to check on a Mac and with Docker. docs/sandbox-providers.md: the provider interface, how codeoid uses it, adding a provider, and what Forge needs to add (a stdio-piped process, streaming exec with stdin, workspace sync, per-session credentials, enforced allowlists, lifecycle). approvals.md: the risk levels are guidance inside the sandbox's boundary. CONFIGURATION.md, opencode.md (the gap child's line now runs nothing), CLAUDE.md, CHANGELOG. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ks run sandboxed (#448) From an independent review of the sandbox: - Every dir between a writable root and a hidden path inside it is read-only (bwrap ro-bind, Seatbelt write deny, Docker :ro, Claude denyWrite), so `mv ~/.config ~/c` with the workspace at ~ can't carry ~/.config/gh out from under the rule that hides it. Verified with real bwrap. - Each daemon keeps session temp dirs and launchers in one private dir (mkdtemp, removed at exit), hidden from every sandbox: a session whose workspace is /tmp or / can't see or rewrite another session's launcher. The temp dir's own-path bind is laid with the other mounts, so a hidden daemon temp dir above it no longer covers it. - Loop acceptance checks (agent-written tests) run in the loop's sandbox (isolation "sandbox"; a check whose sandbox can't run is an error). Routine gate checks are unchanged (they need the owner's credentials). - Claude: its commands can't write the project's .claude/ or the user's config dir (verified with the real CLI), and a file-tool write to .claude/settings*.json asks in every mode under its sandbox. - A provider named explicitly that can't run here is refused at create and set_sandbox; a delegated token can't widen; a routine whose runs are off needs session:approve; the widening confirm says when the result is no sandbox at all. - A changed setting keeps the last runtime until the new one is resolved (no window where a backend starting meanwhile reads "off"); opencode's launcher is rewritten atomically each turn from the filesystem as it is; the run-outside-sandbox ask applies only where the backend's own sandbox is the boundary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… read-only allowlist; a git worktree commits from inside with its repository's hooks and config read-only Hiding was a snapshot of known paths: a credential created after the sandbox started, replaced by a rename (which detaches a bind mount over a file), or on no list (opencode's login, copilot, pgpass, rclone …) was readable. The user's home is now hidden whole (bwrap: a read-only tmpfs; Seatbelt: a subtree denial; Docker: never mounted) and shown back: the workspace when it is inside, sandbox.homeReadable (default: toolchains and git's settings, nothing that holds a token; CODEOID_SANDBOX_HOME_READABLE), and what the backend needs. The explicit hidden list stays for what lies outside the home, a workspace that holds it, and inside what is shown (an nvm global npmrc). sandbox.hiddenPaths beats the allowlist. The workspace's git repository (gitWorkspacePaths): a linked worktree's common dir is writable wherever it is (under the home, under the host's /tmp: bound back), so commit, branch and packed-refs.lock work; the repository's hooks/, config, config.worktree and modules/ are read-only (SandboxSpec.readOnly), in a plain repository too. A worktree's .git file is believed only in git's own back-linked layout, so a planted 'gitdir: ~/.ssh' gets nothing. Also from round 1: - protected ancestors are pinned, not read-only (SandboxSpec.pinned: bwrap self-bind, Seatbelt literal, Docker self-mount): no rename, the dir's contents still writable (C8); - Docker binds are --mount with CSV-quoted fields, so a path with ':' or ',' is one path (C13); a launcher names no container (C9); each handle labels its own containers, so closing one removes only those; - the config's allowedHosts are validated as a session's are (C14); - more logins in HOME_CREDENTIAL_PATHS (S4); - programLocations finds a bare command on PATH, its package and its #! interpreter, never the home itself. Tests: sandbox-bwrap-real (round 1: home, late/renamed credentials, worktree under home and /tmp, plain repo, forged .git, pinned), sandbox-policy, sandbox-seatbelt, sandbox-docker; opencode I69 now accepts 'not there' as well as read-only for a target in the home. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ed instead of failing every turn Claude Code's own sandbox on Linux needs socat besides bubblewrap; with failIfUnavailable every query failed while the session said 'sandboxed'. confinementFor now refuses the native plan without it (claudeNativeProblem, through the registry's host so tests pin it): the session is unavailable with the reason, interactive turns run and say so, unattended ones are refused. Seatbelt needs none. The process-confinement limits now say the backend's login is readable but not writable and its settings a private copy (the next commit). Test: sandbox-session 'Claude on Linux without socat' (in the session commit). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…te copy of its home, and Claude's own sandbox hides the home with temp dirs of the session's own A backend whose whole process is inside (pi, the Gemini CLI, Qwen; Claude and Codex under Docker or Seatbelt; Codex's app-server under bubblewrap) had its real vendor home writable, so a command could plant a hook, an MCP server, an extension or a config.toml that the user's own CLI, or a session with the sandbox off, later ran unsandboxed. Each now starts in a private home made afresh (backend-home.ts into SandboxRuntime.privateHome, pointed at by PI_CODING_AGENT_DIR / GEMINI_CLI_HOME / QWEN_HOME / CODEX_HOME / CLAUDE_CONFIG_DIR): its login linked and read-only, its conversations the real ones (data: resume works with the sandbox on or off; pi's session file is stored by its real path), code it loads read-only, small files copied, other dirs empty. Its handle is closed when its process ends (C15). Claude's own sandbox: denyRead of the whole home with allowRead for the allowlist, the workspace and what is written (checked on the real CLI: a credential made or renamed in while a command runs stays hidden); the CLI's TMPDIR is a dir of the session's whose claude-<uid> is the commands' only writable temp dir, and CLAUDE_CODE_TMPDIR keeps the CLI's own files (its settings file, sockets) out of their reach (R2); denyWrite takes the repository's hooks/config and pinned dirs. Every path is handed physically: the CLI's bubblewrap can't mount through a linked dir, so with /var/run -> /run and /var/run/docker.sock present every command failed (found while testing this round, not in the reports). session-sandbox.ts also: - serializes refreshes and re-resolves when set() ran meanwhile, so a narrowing during a slow first probe isn't lost (C7); - set() no longer closes handles a running backend uses (C10); - a workspace of / is unavailable, and a check there refused (C11); - a loop check's temp dir is removed when its sandbox fails to open (C12); - a closed handle is let go of; private homes and Claude's temp dirs go with the session (C15). opencode keeps its launcher's handle while the spec is unchanged (no pile; an older one isn't closed, a background shell may still run in it). Tests: sandbox-claude-real (round 1: home, rename, late credential, TMPDIR, CLI temp out of reach, worktree commit), sandbox-process-backends and sandbox-codex (login readable not writable, real home not writable), provider-claude, sandbox-home. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… idle, and a sandbox wider than the daemon's is the owner's own decision
What runs must match what the session reports (C6, C10): set_sandbox is
refused while a turn runs, an approval or dialog waits, or background work
is held ('the session is busy', 'has background work running'); once
applied, a process backend that ran is stopped at once and starts in the
new sandbox with the next message, and until it has been a turn that needs
the sandbox is refused. A confirm answered after the session got busy isn't
applied, and says so.
Authority (C3, C4, C5, S3):
- creating a session or starting a loop with a sandbox off or wider than
the daemon's default needs session:approve and an undelegated token;
a fork or compare carries such a sandbox only for the parent's owner,
undelegated (#widerSandboxRefusal, audited session.sandbox_wider_refused);
- the turn-off / widen confirm is answered only by the session's owner with
their own token (requestUserInput ownerOnly; others get forbidden,
audited session.ui_response_refused); session.set_sandbox names who
confirmed;
- sandbox.* daemon settings refuse delegated tokens, audited old -> new
(settings.set.sandbox);
- a session whose sandbox is off is made autonomous only by its owner's
own token.
Tests: sandbox-session (socat, race, '/', check temp leak, handles,
private homes, busy, restart, owner-only confirm), sandbox-manager (create,
fork, confirm through the manager, settings, set_mode). Test registries pin
the host's PATH so Claude's native plan doesn't depend on socat here.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lowlist, private backend homes, git repositories, and who may widen a sandbox docs/sandbox.md: the home model and sandbox.homeReadable, what is writable in a git repository and what stays read-only, private backend homes and what they leave open, Claude's temp dirs and physical paths, socat, a / workspace, changes only while idle, the owner's authority, and new limits (the read-only home and caches, Claude's placeholders breaking git add -A, its subtree denyWrite for pinned dirs, an opencode line's background job ending with the line: documented, not changed). CONFIGURATION.md, sandbox-providers.md and CLAUDE.md to match. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… holding the home (/home) can't show it again Found re-running the auditors' rename probe against this round's fixes: with the workspace a dir that holds the home, the home is both hidden and an ancestor of hidden paths, so it was pinned, and bwrap's pin (a self-bind laid after the home's tmpfs) showed the real home again, writable; Docker's self-mount would too. protectedAncestors now skips a dir that is hidden or inside a hidden one (it is a mount point of its own, or not there), and bwrap and Docker skip such a pin besides. Tests: sandbox-bwrap-real 'a workspace that holds the home' (late and renamed credentials stay hidden, the home isn't writable), sandbox-policy, sandbox-docker. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…om inside the sandbox, and Codex commits under it Under codeoid's own providers (bubblewrap, Seatbelt, Docker), a command could change which config git reads at the user's next git: rewrite a worktree's .git file or its commondir, rename a plain repository's .git and rebuild it, plant a config.worktree that didn't exist, or create a commondir in the repository's git dir (findings c1, s1, r1). gitWorkspacePaths now makes only what a commit writes writable: the git dirs, each bound at its own path so none can be renamed (the workspace's .git dir too, and worktrees/). Read-only: config, config.worktree, commondir, hooks/, info/, modules/, remotes/, branches/ and every other worktree's git dir; a worktree's own commondir, gitdir and config.worktree; the worktree's .git file and the .git file of any other worktree or submodule checked out in the workspace. What is missing is made first (empty dirs, an empty config.worktree in every git dir, a commondir of "." that git reads as the git dir itself), never through a link; an entry that is a link makes the git dir read-only whole. Seatbelt also pins its extra writable roots, as a bind does elsewhere. Codex (c2): codex's own workspace-write makes every .git read-only, so a Codex session couldn't commit. Its writable roots now include the git dirs (codeoid's bubblewrap around the app-server holds the structure). The session temp dir is no longer a root when it is under the host's /tmp: inside codeoid's bubblewrap it is /tmp, not at its own path, and as a root it made codex's sandbox fail every command. Tests: real bubblewrap, variants A-E each refused and the user's next git (outside the session's sandbox) runs nothing planted; commits, branches and branch deletion still work. Real codex sandbox nested in real bubblewrap commits in a plain repository and a worktree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…m are read-only inside the sandbox, and a file tool writing them asks Under codeoid's own providers, one backend's command could write the workspace's .claude/settings.json (a hook), which Claude, a later session or the user's own CLI then runs on the host (finding s2). buildSandboxSpec now holds read-only, for every backend: .claude/, .gemini/, .qwen/, .codex/, .pi/ and .opencode/ whole (made empty first where missing, so nothing can be added in them; git doesn't see an empty dir), and .mcp.json, opencode.json, opencode.jsonc where they exist. A link isn't held (a bind can't hold one) and nothing is made through one. Claude's own sandbox already denied writing .claude. File tools run in the backend's process, outside the sandbox (Claude's, opencode's): while the session's sandbox is on, any backend's file-tool write there asks in every mode (projectSettingsWriteOf, audited session.project_settings_write_ask), as running a command outside the sandbox does. Tests: real bubblewrap refuses writing each of them, renaming .claude, and changing an existing .mcp.json, while the workspace stays writable; the gate asks for opencode's and pi's writes there and not elsewhere, and nothing is held with the sandbox off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e the sandbox, their caches in a writable cache dir of codeoid's
With the home read-only inside, every go build/test failed (no build
cache), bun install and uv failed, and pip ran without a cache, in every
sandboxed session and in loop checks (findings c3, r3).
The spec now carries the toolchains' cache variables (SandboxSpec.env),
pointed at ${XDG_CACHE_HOME:-~/.cache}/codeoid-sandbox: one per user,
0700, writable inside, read by none of the user's own tools.
XDG_CACHE_HOME, GOCACHE, GOMODCACHE, BUN_INSTALL_CACHE_DIR,
npm_config_cache, UV_CACHE_DIR, PIP_CACHE_DIR, CARGO_HOME; Go reads the
user's already downloaded modules first (GOPROXY=file://...), so go
build works with the network off. bubblewrap sets them with --setenv,
Seatbelt with its env prefix, Docker with -e; Claude's CLI (outside its
own sandbox) gets the tools' variables but not XDG_CACHE_HOME; Codex's
own sandbox gets the cache dir as a writable root.
Tests: real bubblewrap, network off: go build of a module depending on
one in the user's module cache, and bun install of a local dependency
(bun's own cache in the home read-only), both fail without the variables
and work with them; unit tests per provider.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s config names, so destroying a worktree session after a planted filter runs nothing Destroying a --worktree session ran git status and git add -A with only hooks and fsmonitor off: a filter driver an agent had defined in the repository's config ran on the host, with the user's rights (finding r1, the daemon side). Every git the daemon runs in a user's repository an agent can write now gets safeGitEnv (git-env.ts): no hook, fsmonitor, external diff, signing, editor or pager; core.sshCommand=ssh; no askpass from config; every filter, diff and merge driver key and credential helper set in an agent-writable scope (local, worktree, an include of one) reset, the user's own global or system value put back (a globally installed git lfs keeps working, a repository redefining it doesn't); only https and ssh when it reaches a remote. That covers worktree create and remove, a destroy's snapshot, the base-branch fetch, a worktree's setup and the share alias. Loops' hardenedGit shares the overrides and stays strict (every driver off). Checkpoints already ran in a shadow repository with codeoid's own config. Tests: a planted fsmonitor, filter (clean, smudge, process), diff and merge drivers, credential helper, ssh command and hooks path run nothing when a fork is made or destroyed, and the work is saved; a filter defined in the user's global config still runs while the repository's redefinition doesn't; driverOverrides' scopes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he daemon's without session:approve #routineHandle counted only provider "off" as needing session:approve, so a token without it could save a routine whose unattended runs had the network on over a daemon default of off, more allowed hosts, or another provider: a setting the same token is refused at session.create (finding c5). It now uses widens() against the daemon's default, as create, fork and loop.start do. Test: through the real SessionManager, each wider setting (off, another provider, a wider network, more hosts) is refused without the scope; the daemon's own or a narrower one isn't; with the scope the owner may. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…very sandbox, Claude's own included, even when made while a session runs Claude's own sandbox keeps the host's /tmp readable, and codeoid made a named-account sign-in's home there (codeoid-login-*), with the new account's token in it: a native Claude session could read it while the owner signed in (finding s3). The daemon's temp dir was hidden only when it wasn't /tmp, and the sandbox root only once it had been made. Every temp entry of the daemon's own now goes under its private root (daemonTempDir: sign-in homes and their input pipe, a check's artifacts, a loop's index, the checkpoints' scratch, pi's bridge), made at `codeoid start` (0700) and always in each spec's hidden list, so one made after a session's sandbox was built is hidden from it too. The stale sign-in sweep also looks inside every daemon's root. Test (real Claude CLI): a sign-in home the daemon makes after the sandbox settings are built is unreadable and unlisted from a command; reproduced first with one in the host's /tmp, which was read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…session's own, never the real dirs every project and every later run share A backend run whole inside the sandbox got its real conversations dirs writable (Claude's projects/, Codex's and pi's sessions/, the Gemini CLI's tmp/ and history/, Qwen's projects/ and tmp/): every sandboxed session could read all of the user's other projects' transcripts, write Claude's project memory, and plant git config or hooks in the Gemini CLI's checkpoint repository (history/), which its checkpointing runs outside any sandbox (findings s4, r2; c6 for tmp/). preparePrivateHome now links each of them to the session's own state dir (SandboxRuntime.stateDir: <transcriptDir>/sandbox-state/<session>/ <backend>/), which a session resumes from across restarts and which is removed when the session is destroyed; without one (an embedder) they are empty dirs of the private home. Codex, pi, the Gemini CLI, Qwen and Claude under Docker pass theirs. A sandbox change, fork or backend switch continues from codeoid's own history, as documented. Tests (real bubblewrap): pi can't read another project's transcript in the real sessions dir, the Gemini CLI can't read the real checkpoint repository's config, and what each writes there lands in the session's state; it is there again for a new process and gone with the session. Destroying a session removes its state. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tten back to the real one, that file only and only a valid credential A process backend's login was a read-only link in its throwaway private home: a CLI that saved a refreshed token by renaming a temp file over it (Qwen's) kept the new token only there, so the real file held a spent refresh token once the provider rotated it, and a CLI that wrote in place couldn't save it at all (finding c4). The credential file each backend refreshes (Claude's .credentials.json, Codex's and pi's auth.json, the Gemini CLI's and Qwen's oauth_creds.json) is now a private 0600 copy, and is written back to the real file when the backend's process ends or its query is replaced (withLoginsSynced, on the handle each provider closes then): that file only, only when it parses as that backend's credential, only when the real file is still what was copied (a refresh saved meanwhile is never overwritten), never through a link, atomically (temp file and rename). Other login files stay read-only links; the real login is no longer shown inside at all. Tests: a fake pi that rotates its refresh token, saving it in place and by a rename, inside real bubblewrap: the real file has it after the process ends (not before), 0600, nothing else of the home written back; an invalid credential, a link, and a real file refreshed meanwhile are not written back; each backend's format. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l/share, and pip --user packages, work inside the sandbox ~/.local/bin was on the allowlist but what its entries link to wasn't: pipx and uv tool installs, the Claude installer's versions, and pip --user packages in ~/.local/lib were missing inside (finding s5). The default allowlist adds ~/.local/lib, ~/.local/share/uv, pipx and mise (uv's credential store, ~/.local/share/uv/credentials, is hidden again), and what a link directly in an allowlisted dir leads to elsewhere in the home is shown read-only (linkedPrograms: its dir, its install, its #! interpreter), never the home itself or anything hidden. Test (real bubblewrap): a pipx-style tool and an installer-style binary linked from ~/.local/bin run, a pip --user package is listed, they are read-only, and a link into ~/.ssh shows nothing of it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e git layout, project settings, caches, logins, conversations and the daemon's private temp dir Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ace that is the home, holds it, or is a temp root The read-only project settings dirs were made empty in any workspace, so a session working in the home made ~/.codex, ~/.pi, ~/.gemini … (the CLIs' own config dirs), and one in /tmp made /tmp/.codex, /tmp/.claude …, which everything walking up from a temp dir then reads (found by the full suite: a test's workspace was /tmp). In such a workspace (the home, a dir holding it, /tmp, /var/tmp, the temp dir, /) what exists is held read-only and nothing is made. Test: a home workspace and one holding the home get none made, an existing ~/.claude is still held. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… command, config-defined hook or signature verifier a repository names safeGitEnv and hardenedGit now turn off every config key git documents as running a program that the overrides missed: core.alternateRefsCommand (run by fetch's negotiation when objects/info/alternates exists, finding c5), config-defined hooks (hook.<name>.command, which core.hooksPath does not cover: every hook event is turned off by name), gpg's and ssh's verifier programs and gpg.ssh.defaultKeyCommand, merge.verifySignatures, core.gitProxy, and auto gc; GIT_EDITOR, GIT_SEQUENCE_EDITOR and GIT_PAGER outrank the config's editors and pager.<cmd>. Test: a table of every program-running key (git help -c), each planted in a repository's config and triggered under safeGitEnv, runs nothing; a control run of each without the overrides does run it. Without the fix the alternateRefsCommand, hook.<name>.command and both verifier rows fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…only as a token refresh, so a planted pi api key never reaches the real login The write-back took the private copy whole whenever it parsed as that backend's credential. pi runs an auth.json api_key starting with `!` as a shell command, so a sandboxed command adding such an entry had it run on the host by the next unsandboxed pi (findings c2, s2). Now each backend lists the token fields a refresh writes (Claude's OAuth and MCP tokens, Codex's ChatGPT tokens, pi's oauth entries' access, refresh and expiry, the Gemini CLI's and Qwen's OAuth tokens): only those are taken from the copy, each a plain token value, into the real file's own content and layout. A copy changed anywhere else (an entry added or removed, an api key, an account id, Qwen's resource_url, an MCP serverUrl) is refused, the real login kept, and the session gets a warning line (SandboxRuntime.warn). Tests: per-backend refresh and refusal cases (refreshedLogin), and end to end the auditor's planted pi entry is not written back and the session is told; the auditor's probe now reports the refusal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…dboxed commands, never Claude's CLI, its MCP servers, hooks or a command run outside the sandbox Under Claude's native sandbox the cache variables (CARGO_HOME, GOCACHE, GOMODCACHE, npm_config_cache, …) were set on the CLI process itself, so everything it starts outside its sandbox inherited them: stdio MCP servers, hooks, a command the owner let run unsandboxed. The cache dir is writable by every sandboxed command, and a cache is code to the toolchain reading it (findings c6, s1, r3). They are no longer in the CLI's environment. The CLI sources CLAUDE_ENV_FILE into each Bash command; codeoid writes that file in the CLI's own temp dir (no command can write it), exporting the variables only where the CLI marks a command as sandboxed (SANDBOX_RUNTIME=1, taken out of the CLI's own environment); a caller's CLAUDE_ENV_FILE is sourced after it. Every other place the env is built already sets it only inside (bwrap --setenv, Seatbelt's env wrapper, Docker -e, Codex inside codeoid's bwrap, loop checks); opencode's server and routine gates get none. Test (the real Claude CLI against a local stand-in API): a sandboxed command sees the cache variables; a stdio MCP server and a command the owner let run outside the sandbox see none. With the variables back in the CLI's environment the test fails (the MCP server gets CARGO_HOME). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d repository, and resets the drivers of the repository the caller names With no sandbox to run in (opencode's server; Claude's shim with its provider gone), git status in the workspace checked a gitlink's dirt by running git inside the nested repository, which read that repository's own config and info/attributes: a filter a sandboxed command planted there ran on the host. Now the defused git ignores submodules (diff.ignoreSubmodules=all, submodule.<name>.ignore=all for every name the config and .gitmodules know, so a planted `ignore = none` doesn't win), no submodule summary or diff, no recursing fetch or push. Also found while fixing it: the driver listing ran in the caller's cwd, so `git -C <repo> diff` reset nothing of <repo>'s and its textconv ran. The listings now take the caller's global options (-C, --git-dir, --work-tree, -c, ...), never one that is a command of its own (--version). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n at the next query, rather than leave the CLI the host's git A temp cleaner removing the session's launcher dir took the shim off the CLI's PATH, and its next `git` was the real one on the host. The runtime now checks the shim at each query and writes it (and its launcher) again when it is gone. One removed while a query runs is still that query's loss: said in docs/sandbox.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A relative entry on the daemon's PATH (`.`, `node_modules/.bin`) made a program the CLI starts by name on the host resolve in its cwd, the workspace a sandboxed command can write. claudeNativeEnv now keeps only PATH's absolute entries, the shim's dir first when there is one (as opencode's server already did). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ode in a program's argv Seatbelt has no pid namespace, so a sandboxed command could list `open <url#code>`'s argv and redeem the two-minute code before the browser did. On macOS the link now reaches the browser by AppleScript read from stdin (`osascript -`, `open location`: an Apple Event, in no argv); a URL that isn't plain is printed, not opened. On Linux xdg-open keeps it: every sandbox there has its own pid namespace, and a page file the browser reads instead would be refused by a snap-confined browser. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…im's own listings from opencode's git The shim now reads, with --no-pager and in the caller's directory, the config's key names and the submodules .gitmodules names before it runs git. The test that checks opencode ran no git in a refused repository counts those as the shim's own (each in the server's empty cwd, each a config or rev-parse listing) and still expects nothing else but codeoid's `git --version`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… own EnterWorktree and ExitWorktree The CLI's EnterWorktree checks a worktree out from HEAD and moves the session there, re-rooting its project settings at it; HEAD can carry settings (hooks) an agent committed through git plumbing inside the sandbox. The real CLI (2.1.281) runs EnterWorktree without asking canUseTool, so codeoid's PreToolUse hook refuses it (and ExitWorktree, which can move the session back into another worktree) while the session is sandboxed, and canUseTool again: "use codeoid's --worktree; the CLI's own worktrees aren't sandboxed". Checked in the CLI: entering re-roots the settings sources but doesn't reload them; a committed hook didn't run after entering, in that query or a resumed one. Recorded in docs/sandbox.md, with the trust model of committed project settings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…at changed project settings a backend runs code from An agent can commit a .claude/settings.json with hooks (or .mcp.json, opencode.json[c], .opencode/, .gemini/ or .qwen/ settings, .codex/, .pi/) through git plumbing without it ever being in the working tree; a worktree or checkout of that commit carries it, and a session started there trusts it. At each turn's end and at destroy, a commit newly reachable from any ref or HEAD (since the session's first turn) that changes one of those files, at any depth, is named with its files in a warning line (event sandbox.settings_committed, audited session.settings_committed). The look runs hardened git (names only, at most 500 new commits), is serialized, keeps its baseline when git can't say, and a baseline commit gone since doesn't fail it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a system-wide git-lfs (GitHub's runners) doesn't take its place Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… so its embedding worker can't crash Bun A start refused on a taken port exited with memory's model loaded and its worker never stopped; on CI that exit died with SIGILL (132), as a stop did before #453. Only memory is closed: the full shutdown would remove this port's local-mode token, which belongs to the daemon holding the port. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… boundary (#448) An autonomous session, routine, loop or dispatched worker already refuses to start without a sandbox that runs, so the approvals that stood in for one before it existed only interrupt work there: - Run, unasked: shell commands of every risk level, not understood and unreadable ones included, and writes wherever they lead. The outside-write rule is gone (its shell write-target reader with it); what is left of outside-writes.ts is the held git-structure / project-settings check, now held-writes.ts. Risk levels stay on cards in guarded mode and go to the audit (`session.auto_approve ... risk=high`). - Refuse at once, never ask, the reason handed to the agent (the owner can do it, or switch the session to guarded mode), an info line in the transcript (`autonomous.refused`) and `session.autonomous_refuse` in the audit: a command asking to run outside the sandbox, a file tool writing the 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, a path a backend couldn't resolve for certain. - Unchanged: credential reads refused in every mode; questions to the owner and fleet send-class dispatch still ask; guarded and interactive mode. `approvals.askOnUnknown` no longer has an effect and is still accepted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…transcript, on the session and in codeoid ls
When the autonomous tool budget runs out the session still reverts to
guarded and its writes and commands ask again, but it used to do so
silently. Now, the moment it happens: an info line in the transcript
("Autonomous budget spent: now guarded, writes and commands will ask;
raise the budget or switch back to autonomous.", event `budget.spent`),
`session.budget_spent` in the audit, `SessionInfo.autonomousBudgetSpent`
until a mode is set again, and a mode line in `codeoid ls` (core's
`modeSummary`, which also shows an autonomous session's remaining budget).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…line and refuses one reaching credentials, guarded asks The integration case I49 asserted the old autonomous ask. Through a Session on the real binary it now checks the new verdicts: in guarded mode `rm -rf ~` asks with its high level on the card; in autonomous mode a high-risk line's verdict is allow (the case's guard still keeps it from running) and `rm -rf ~`, which reaches a directory holding credentials, is refused with the reason the model reads. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ommand that is high, and expect autonomous mode's verdicts `rm -rf build/cache` is medium (inside the workspace), so the opencode cases that said "a high-risk line runs in autonomous mode" now use `git push --force origin main`. The e2e risk spec asserted the old autonomous ask: guarded mode now asks before `rm -rf ~` with its high level, and autonomous mode runs a force push unasked and refuses a search of codeoid's config dir at once, with the info line in the transcript (the scripted backend runs none of these commands). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…in guarded mode and is refused in autonomous mode claude-settings-guard asserted the old autonomous ask. Guarded mode still waits for the owner; autonomous mode refuses the write at once with the reason the agent reads, and nothing is left pending. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…te lands, not the path's spelling A Write to `.claude/./settings.local.json`, `.claude//settings.json`, a `..` back into `.claude`, `~` or a link to the dir or the file was allowed in autonomous mode on a named account (the check was a regex on the text). `writesClaudeSettingsFile` tests the path as written, `..`-collapsed, and where the kernel resolves it (writeLandings without the hard-link rule). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…xed autonomous session `echo $HOME`, `df -h /`, `ls ~`, `stat ~`, `test -d ~` carry a credential `ask` from the shell reader, and autonomous mode refused them since 3cba371, though they ran on main and the sandbox hides the home's credentials. A shell risk's credential ask is now refused in autonomous mode only where the session isn't sandboxed; a file tool's search of such a directory, and a named credential read, stay refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hat run outside the sandbox An edit to a hard-linked file (every pnpm `node_modules/.pnpm` file, a Bun hardlink install) counted as a held write on every backend and was refused in autonomous mode, on Codex, pi, the Gemini CLI and Qwen too, whose file tools run inside the sandbox that already holds the git structure and the project settings read-only. `#heldWrite` (and Claude's PreToolUse routing of it) now applies only where the plan's file tools run outside the sandbox (Claude native, opencode). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…'s own policy at the gate In autonomous mode Claude native's and opencode's file tools (they run in the backend's own process, outside the OS sandbox) wrote anywhere the daemon user can (~/.bashrc, ~/.gitconfig, ~/.ssh/authorized_keys, ~/.claude/settings.json, systemd units) and read every credential the sandbox hides (~/.ssh, ~/.aws), unasked. While a session is sandboxed, sandbox/host-file-tools.ts now judges each such call against the spec the sandbox is built from, resolved as the kernel resolves it: a write only where a sandboxed command could write (the workspace, the session's temp dir, the caches; not what it holds read-only), a read or search never of what it hides. Beyond it the call is refused in every mode, never asked, with "Refused: outside what this session's sandbox allows: <path>" (event `sandbox.refused`, audit `session.sandbox_bounds_refuse`). Claude's PreToolUse hook routes such a call to the gate past allow rules. What the backend itself hands the model outside stays readable (the CLI's saved tool output for the session, its skills' files, opencode's tool output). Under bubblewrap opencode's tools don't read the host's /tmp, which its commands don't see. Unsandboxed sessions keep the gate as it was. The sandbox's `limits` and docs/sandbox.md / approvals.md say so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…epository's top as its sandbox workspace
A workdir below its git working tree's top was sandboxed with only the
package writable and the rest of the repository hidden: git stopped at the
package's mount ("not a git repository"), and with discovery forced across
it a commit recorded the rest of the repository as deleted; the root's
package.json, tsconfig and sibling packages were unreadable.
sandboxWorkspaceOf now gives the repository's top as the workspace (only a
repository git's records vouch for, below the home, never the home's own,
/, a temp root or a dir holding the home); the session's cwd stays the
workdir. Project settings are held read-only in every dir from the workdir
up to the top (projectDirsOf), where the CLIs look for them, for commands
and for host-side file tools (protectedWriteTargets). The session's limits
say when the workspace was widened. Loop checks follow the same rule.
Real bubblewrap: git status and commit work from the package, a sibling is
read and written, nothing above the repository is, settings stay held.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…'s bounds in autonomous mode only, the backend CLI's own context files stay its own Round 8 refused, in every mode, any Claude or opencode file-tool call beyond what the sandbox lets its commands do. That broke, against origin/main, guarded and interactive sessions (project settings writes, reads in ~/Downloads or a sibling repository, /tmp writes) and the CLI's own features (auto-memory, plan files, background task output, user agents and commands, installed plugins' skills). - The bounds refusal applies in autonomous mode only. Guarded and interactive decide as without a sandbox; the approval card's sandboxNote says the call is outside the sandbox's bounds. Claude's PreToolUse hook routes such a call past the CLI's own allows only while the session is autonomous (ProviderSessionInit.isAutonomous). Credential refusals stand. - SessionProvider.hostFiles: the backend's own context files, found in the bundled Claude CLI 2.1.281 and opencode 2.2.5 (providers/claude/host-files.ts): Claude's memory, agent memory and plan dirs (.md written), its CLAUDE_CODE_TMPDIR project dir (background output, scratchpad), tasks and todos (written), user-tier CLAUDE.md, agents, commands, rules, output styles, installed plugins' skills and commands, bundled skills (read); opencode's <dataDir>/tmp/opencode (written). Never settings, credentials, hooks, MCP configs or plugin code. - A search past a hidden path inside the workspace: an autonomous Claude query carries Read deny rules for such paths (claudeSearchExclusions), which the real CLI's Grep and Glob honour, so the search runs; opencode's ripgrep can't exclude, so a search reaching only the user's own sandbox.hiddenPaths inside the workspace runs (documented). - Claude's LSP tool is judged as a read of its filePath. - A refusal names where the agent may write instead (the workdir, the session's temp dir as written and physical), and for a hidden read what the owner can do (copy it in, sandbox.homeReadable, guarded mode). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e the sandbox, so a skill's script can read its own files Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lared variable Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ommands do, and never write them Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rch, and a Stop's late turn record - The credential-read tests use codeoid's real config dir: CI sets XDG_CONFIG_HOME, so ~/.codeoid isn't it there. - The e2e risk spec refuses a credential read (every mode): a search of the dir runs in a sandboxed autonomous session since the sandbox hides it. - The opencode Stop-cost test waits for the stopped turn's record, bounded, instead of 50 ms after idle. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d was refused; the MCP approval test waits for the gate to have the approval - The scripted backend reports a refused call as completed with the reason as its output, so counting "completed" counted the refusal too. - A session shows waiting_approval as the tool call starts, a moment before the gate has the approval: get_session is polled (bounded) until it does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…aits for an approval - A call codeoid refused (or one interrupted) keeps its cancelled state when the backend reports it finished; the backend's text is its reason when the refusal gave none, and a cancelled Edit or Write shows it under its diff. - waiting_approval is set by the gate when it parks a call, not as the call starts: a client reacting to it found no pending approval. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rsharath
approved these changes
Nov 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #448.
Summary
Every shell command an agent runs now runs inside an operating-system sandbox, on by default in every mode. The session's workspace is writable. The rest of the machine is read-only, the home directory is hidden, and credentials can't be read. The sandbox is a pluggable provider, chosen per session or daemon-wide: bubblewrap (Linux), Seatbelt (macOS) and Docker today, with room for remote and VM providers such as Forge/Droneship and Firecracker.
Why
On 2026-10-27 an unsandboxed audit agent ran
rm -rf ~on this machine. Shell risk levels (#446) and the outside-write check read a command's text, and reading shell can't be made complete; several audit rounds proved it. The boundary has to come from the operating system. A sandboxed command can't read what it can't see, or delete what is read-only, however it is written.What the sandbox guarantees
sandbox.homeReadable. Codeoid's config and data dirs, SSH and cloud keys, keyrings and other agents' logins aren't visible. That includes ones created or renamed after the session started.gitshim, with no credentials..claude/settings*,.mcp.json, and the other backends' equivalents) are read-only inside.Not guaranteed, and said plainly in docs/sandbox.md: that the workspace is safe to run after the agent touched it. It's the agent's output, and it's code: package scripts, Makefiles, hooks committed to the tree. Review it like any agent output. When a session ends, it warns if the repository's git config, hooks, hooks path or attributes changed. It also warns if a commit it made changed a backend's project settings.
How it behaves
sandbox.provider(auto: bwrap on Linux, Seatbelt on macOS), also in Settings.codeoid new --sandbox <bwrap|seatbelt|docker|off>, the web UI's new-session dialog, or a routine or loop spec.offandallowlistare configurable. An allowlist is enforced on Claude, through its sandbox proxy. Where a provider can't enforce an allowlist, it falls back tooffand says so; it never silently opens up.socatabsent):EnterWorktree/ExitWorktreeare refused while sandboxed; use--worktree.codeoid lsshow what really runs.codeoid ui, or the link printed at start).Autonomous mode relies on the sandbox, and never waits on you
The approvals that #446 and #450 added to autonomous mode were stopgaps for a missing sandbox, and they interrupted autonomous work. With the sandbox in place they're gone. Autonomous work only runs sandboxed: an autonomous session, routine or loop refuses to start when its sandbox can't run.
AskUserQuestion, and confirming a fleet dispatch to another session.main.codeoid ls, instead of the session silently going quiet.main.Two deep regression-review rounds, comparing this branch against
main, came after this change. Round 1 found that Claude's and opencode's host-side file tools escaped the sandbox. Round 2 found regressions in guarded mode, in Claude's own features and in monorepos. All of them are fixed and tested.Verification
rm -rfof a planted outside directory fails, and the directory survives. A planted secret isn't readable. Network-off blocks DNS.outside-writes.test.ts, as 5 s timeouts on a loaded machine. That file passes 57 of 57 with a longer timeout, and runs faster than onmain(120 s against 156 s). Web tests: the same 39 environment failures asmain.Known limits
All are documented in docs/sandbox.md:
ghgates need your credentials.git add -Awarn.offor an allowlist for unattended work.Forge / Droneship
The interface declares each provider's isolation tier, placement (local or remote), workspace mode (mount or sync), whether it can host a long-lived stdio process, and which network modes it enforces. So a remote provider fits without changing the backends. Forge can't host one yet: it has no stdio-piped process, no file API, and its credential sidecar excludes codeoid. docs/sandbox-providers.md lists what Forge would need.
🤖 Implementation context (for agents / maintainers)
Code
src/daemon/sandbox/types.ts:SandboxProvider,SandboxCapabilities,SandboxSpec,SandboxHandle.registry.ts,policy.ts: spec building; hidden home and allowlist; git layout; project-settings placeholders; caches.bwrap.ts,seatbelt.ts,docker.ts,local-handle.ts.confinement.ts: per-backend choice between native, process, per-command and none.claude-native.ts: SDKsandboxsettings.session-sandbox.ts: resolution, fallback rules, idle-only changes.root.ts.providers/claude/index.ts(native sandbox, git shim on the CLI's PATH, worktree-tool refusal, cache env only inside),providers/codex,providers/opencode(per-line launcher, server git shim), pi, ACP and qwen spawn paths.session.ts(gate, cards, end-of-session git warning),session-manager.ts(create,set_sandboxauthorization, routines and loops),git-env.ts(defusing every program-running git config key), local-mode auth (one-time UI link).packages/protocol/src/types.ts(session sandbox info, card flag), webNewSessionModal/ header / Settings,cli.ts(--sandbox,codeoid ui),config.tsandsettings/manifest.ts(sandbox.*).docs/sandbox.md(guarantees, per-provider and per-backend detail, limits, macOS checklist) anddocs/sandbox-providers.md(the interface, adding a provider, Forge), plus CONFIGURATION, approvals, local-mode, routines, CLAUDE.md and CHANGELOG.Tests:
src/tests/sandbox-*.test.ts. The real-bwrap, real-Claude-CLI and git-shim tests run when the binaries exist. Docker tests are behindCODEOID_TEST_DOCKER=1.Follow-ups
outside-writes.test.ts's per-test timeout if CI flakes.🤖 Generated with Claude Code