Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,22 @@ 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.
Where the sandbox can't run, interactive sessions run and say "not sandboxed: <why>" on the session and on every approval; autonomous sessions, routines and loops refuse to start.
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: `<repo>-worktrees/<folder>/`, and `<root>/<repo>/<folder>/` 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 `<repo>-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.
Expand Down
53 changes: 51 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,8 +214,8 @@ codeoid interrupt <n>
codeoid approve <n> [--deny] [--pick N | --id <approvalId>] [--yes]
codeoid account ls|login <name>|add-key <name>|add-gateway|add-token|add-bedrock|default <name>|use <session> <name>|--follow-default|rm <name> [--backend <id>] # accounts for every backend (docs/backend-accounts-design.md)
codeoid destroy <n>
codeoid new <n> <d> --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 <session> <provider> [--network …] # what runs here; change a session's (widening asks its owner)
codeoid new <n> <d> --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 <session> [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 <session> <server> # redial a backend MCP server that's down (#389)
Expand Down Expand Up @@ -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): `<common>/worktrees/` is writable, bound at its own
path; a session writes only its own folder of each worktree location
(`<repo>-worktrees/<folder>`, `<root>/<repo>/<folder>` 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 `<repo>-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 `<common>/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`;
Expand Down Expand Up @@ -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): <root>/<repo>/<session folder>; 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.
Expand Down
6 changes: 5 additions & 1 deletion docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <root>/<repo>/, 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
Expand Down Expand Up @@ -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 `<repo>-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 `<root>/<repo>/`, 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
Expand Down
Loading
Loading