Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
8874fb8
feat: sandboxed sessions read other repositories under project roots,…
saucam Nov 8, 2026
3ac7acf
feat: project roots and added directories in the CLI, the web UI and …
saucam Nov 8, 2026
f86afe6
test: Claude's own sandbox reads a project root around the workspace,…
saucam Nov 8, 2026
f928c46
docs: working across repositories in the sandbox — project roots, add…
saucam Nov 8, 2026
a338a11
fix: an unanswered request_directory returns before the backend's MCP…
saucam Nov 8, 2026
5d7c8f0
fix: in autonomous mode the agent is given only a directory inside a …
saucam Nov 8, 2026
1094588
test: packages/core's sandbox test typechecks again (#448)
saucam Nov 9, 2026
49a6109
fix: #448 round-12 audit: Claude's own sandbox never shows a project …
saucam Nov 9, 2026
beb728f
fix: #448 round-12 audit: an added directory is what it was when gran…
saucam Nov 9, 2026
8552e5e
docs: #448 round-12 audit: working across repositories as it now behaves
saucam Nov 9, 2026
0b72e29
fix: #448 round-13 audit: every git config git may read with a creden…
saucam Nov 9, 2026
b413641
fix: #448 round-13 audit: a directory the sandbox binds by name is pi…
saucam Nov 9, 2026
38b84e1
fix: #448 round-13 audit: a directory the agent added itself keeps a …
saucam Nov 9, 2026
cfaace5
fix: #448 round-13 audit: a routine's added directories are checked a…
saucam Nov 9, 2026
e69726a
docs: #448 round-13 audit: pinned directories, an agent's own added d…
saucam Nov 9, 2026
0d8ebb3
fix: #448 round-13 audit: an autonomous grant refused for the session…
saucam Nov 9, 2026
2ef6804
fix: #448 round-14 audit: every path a sandbox binds is pinned and bo…
saucam Nov 9, 2026
c09f81e
fix: #448 round-14 audit: a monorepo package's working directory, the…
saucam Nov 9, 2026
0dac660
fix: #448 round-14 audit: checkpoints, going back a turn, a fork's fi…
saucam Nov 9, 2026
27e6921
fix: #448 round-14 audit: an agent's own added directory is let go on…
saucam Nov 9, 2026
84a773a
fix: #448 round-14 audit: a direct use of a session path in the sandb…
saucam Nov 9, 2026
95b7ae9
docs: #448 round-14 audit: pinned paths, what is held in place, the d…
saucam Nov 9, 2026
9be8f99
fix: #448 round-14 audit: Docker holds the working directory, its way…
saucam Nov 9, 2026
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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
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).

- **Sandboxed sessions work across repositories: other repositories readable, more directories writable** (#448).
Everything under a session's **project roots** is readable to its commands, read-only: by default the parent of its repository (a session in `~/Workspace/codeoid` reads `~/Workspace/*`: the repositories beside it, and what other sessions did in their checkouts there). `sandbox.projectRoots` (`CODEOID_SANDBOX_PROJECT_ROOTS`, Settings > Sandbox) sets it, `@parent` being the default; per session at create `codeoid new --project-root <dir>` / `--no-project-roots` or the new-session dialog. What the sandbox hides anywhere stays hidden inside a root, and so do the credential files anywhere in it, codeoid's worktrees of the repositories there included (`.env`, `.env.*` but examples, `.envrc`, `.npmrc`, `*.pem`, `*.key`, private keys, kubeconfigs, Terraform state, `.aws/`, …), and a repository's `.git/config` carrying a credential (git there then can't read its config, said); a root too big to look through (20000 directories) isn't used, said. On Claude's own sandbox a link in a root is left out, never shown by where it leads, and nothing hidden is shown again. `/`, the home, a dir holding it, a hidden dir of it, `~/Library`, system dirs and the host's temp dir or anything in it are refused; a default that would be the home (a repository directly in it) or in a temp dir falls back to none, and a workspace in no repository has no default root, said on the session.
A session can be given **more directories to write**, each treated as its workspace (its git structure and project settings held, its own worktree folder there, the end-of-turn git look): `/sandbox add <dir>` / `/sandbox remove <dir>`, `codeoid sandbox add|remove <session> <dir>`, `codeoid new --add-dir <dir>`, the new-session dialog. Adding is a widening: its owner's own, undelegated, confirmed on the session, audited. What is added is the directory the path really is when granted (the card names it); one replaced by a link or removed later is dropped, said, never followed. A git or project settings dir (`.git`, `.claude/`, …) or one inside it is never added; a folder of repositories is, with each one's git structure and settings held. The agent can ask with codeoid's `request_directory` tool (every backend that runs commands): in guarded and interactive mode its owner gets a card with the directory and the reason; in autonomous mode (an unattended run's too) a repository, or a dir in one, under a project root, named with no symbolic link, is added unasked and anything else refused at once with a reason; a guarded unattended run is never parked. Added directories survive a restart and are inherited by forks. A change takes effect at the session's next turn with no background work running (Claude's query rebuilt, a process backend restarted then), never mid-work; until then it shows as pending.
The chip, `codeoid ls` and approval cards say what the sandbox reads and writes besides the workspace. A session whose workspace is the home itself is allowed and says "sandboxed: workspace is your home — only credentials are protected". See [docs/sandbox.md](docs/sandbox.md#working-across-repositories).

- **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).

Expand Down Expand Up @@ -251,6 +256,21 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Fixed

- **A monorepo package session can't plant its package's Claude settings, and going back a turn never follows a working directory swapped for a link** (#448).
The working directory and every directory between it and the repository's top can't be moved aside from inside the sandbox any more, nor can a repository inside an added directory or any directory holding a path the sandbox keeps read-only. Before, a sandboxed command could move `packages/app` aside and make it again around a planted `.claude/settings.json`, whose hook a later Claude start there would run on the host. The working directory is pinned by its identity too, and the daemon's own work in it (the snapshots at each turn, going back a turn, a fork from a turn and its setup, a client's file read, the project's MCP config, the end-of-turn git looks) checks it before and after and refuses, says and audits a change; before, a working directory swapped for a link to the home had the home snapshotted, and going back a turn wrote and deleted files there.
Every path a sandbox binds is pinned and bound as it was, never resolved again at a launch: before, a directory another session swapped for a link (one this session holds in place, or keeps read-only) was bound where the link led at this session's next command, writable, the daemon's private dir with every session's launchers included. An agent's own added directory is let go once another session works in it. See [docs/sandbox.md](docs/sandbox.md#pinned-paths).

- **A directory the sandbox binds by name is never followed to where a link put there leads** (#448).
The workspace (a monorepo's top), the working directory, every added directory, every project root and the session's worktree folders are pinned when the session is created, the directory granted or the root first resolved: by real path and identity. Every sandbox build, every launch inside one (bubblewrap, Seatbelt, Docker, a launcher script written once included), Claude's own sandbox settings and every tool call at the gate check it first; one swapped for a link, made anew or gone refuses the command, and the session says what changed. A changed added dir is dropped at the next turn; a changed workspace stops the session's commands until it is put back. The dirs between a writable directory and another shown inside it can't be moved aside from inside (this also restores the same protection for a hidden file's dirs in a workspace under the home). Before, a session granted the repository holding its own workspace, or a directory inside another session's checkout, could get the home bound writable.

- **A directory the agent added itself keeps a project root's credential hiding** (#448).
An autonomous `request_directory` grant no longer makes the repository's `.env`, keys or a token in its git config readable; one the owner adds (or approves) still does, and the owner's card now says so. An autonomous grant is also refused for another session's workspace, worktree folder or added directory (or a dir in or holding one), a checkout in a worktree location (forks, loops, `--worktree`), and the session's own main checkout from a fork; the owner's card warns of the same.

- **More credentials are hidden in a project root** (#448).
Every git config git may read with a credential (a bare mirror's, a submodule's under `.git/modules`, a worktree's `config.worktree`, a file one includes, a credential store in the git dir), `extraheader` in every syntax git accepts, inline credential helpers and tokens in a URL's query; private keys by their usual names (`*_rsa`, `*_ed25519`, `id_*`, deploy keys, `*.ppk`, `*.p8`); a Mercurial `hgrc`, Yarn's `.yarnrc.yml` and Composer's `auth.json` holding one. A root named through a link is read and hidden where it really is (on bubblewrap its credential files showed through the link's path; on Claude's sandbox every command failed).

- **A routine's added directories are checked and kept by their real path when it is defined** (#448), so a `~/…` or link-named one works at every run instead of being dropped, and one that can't be added is refused up front. A fork in a linked worktree is no longer told its own repository's git config is hidden. The look through roots for credentials no longer holds up the daemon and is done once per resolution, and a default root too big to look through says what counts and what to set.

- **Agents in a sandboxed session can read other Claude sessions' transcripts again.**
`~/.claude/projects` is on the sandbox's read-only home allowlist by default: shell commands and Claude's file tools can look up what another session did, never change it. Remove it from `sandbox.homeReadable` to hide them (a secret once pasted into a session is in its transcript).

Expand Down
50 changes: 50 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ codeoid account ls|login <name>|add-key <name>|add-gateway|add-token|add-bedrock
codeoid destroy <n>
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 sandbox add|remove <session> <dir> # another dir its commands write, as a workspace (adding asks its owner); codeoid new … --add-dir <dir> --project-root <dir> --no-project-roots
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 @@ -438,6 +439,54 @@ containerd, …: `containerSocketPaths`) and the system bus and screen's dir
(Postgres, MySQL, Redis: `localServiceSocketPaths` → `SandboxSpec.serviceSockets`)
when the network the session asked for is off or an allowlist.

Working across repositories (sandbox/project-roots.ts, dir-rules.ts, request-directory.ts; docs/sandbox.md
"Working across repositories"): `sandbox.projectRoots` (default `@parent`: the parent of the repository's main
checkout, `defaultProjectRoot`; none outside a repository, the home or a temp dir falls back to none, said; never
the host's temp dir or in it, `projectRootProblem`) are shown read-only (`SandboxSpec.projectRoots`, in `reexposed`),
the hidden list hidden inside and `projectRootSecrets` (credential files by name or content anywhere in a root and in the
worktree locations shown, every git config git reads with a credential: `gitDirSecrets`, bare, `modules/**`,
`worktrees/*`, includes, a store; not the session's own; bounded per root, a root past it not used: fail closed, said;
looked through once per resolution, `projectRootSecretsAsync`, reused by its specs: `rootScan`); a root named through
a link is used by its real path. `SessionSandboxSetting.addedDirs` are kept by their real path when granted
(`canonicalAddedDirs`, `addedDirReal`) and dropped at a build when that no longer is a directory reached through no
link (`stillRealDir`; `Session#sayDroppedDirs`: said, audited, out of the setting), writable, each a workspace to
`buildSandboxSpec` (its `gitWorkspacePaths`, `projectConfigPaths`, worktree folder, a `WorktreeEntryGuard` per
repository; every repository, bare git dir and settings dir inside it, `nestedControl`, bounded: past it refused /
left out) and to `protectedWriteTargets` and the end-of-turn git look (`#checkAddedGitControl`); validated by
`addedDirProblem` (`sandboxDirProblem`'s refusals, what the sandbox hides, a PATH entry, a git or settings dir or one
in it, `controlDirProblem`). Adding a dir or a root `widens()`. `session.sandbox_dir` (owner, undelegated, owner-only confirm; remove narrows) and the agent's
`request_directory` (`Session#requestDirectory`: guarded/interactive an owner-only confirm with the reason,
autonomous (an unattended run's too) only `autonomousGrantProblem`'s: a repository or a dir in one strictly under a
root, by a path with no link, never another session's or codeoid's checkout (`checkoutOverlap`, SessionManager's
`sandboxCheckouts`; the owner's card warns), else refused; what the agent added itself (`agentDirs`) keeps a root's
credential hiding, the owner's are the session's own (the card says so); guarded unattended refused; in-process on Claude/Qwen, pi's bridge,
`/mcp/sandbox` loopback with a per-session token for Codex/Gemini CLI/opencode; `isRequestDirectoryTool` skips the
gate card). A dirs-only change (`onlyDirsChanged`) leaves the runtime until `Session.ensureSandbox` applies it at a
turn start with no turn, approval or background work (`dirsPending`, `SessionSandboxInfo.pendingDirs`), then
`#restartForSandboxDirs` restarts a process backend. Claude native shows a root around what is writable
(`readAround`: its CLI binds allowRead after allowWrite), never a link in it (it would show the target) nor an entry
that is hidden (an `allowRead` equal to a `denyRead` wins; one strictly inside stays denied, checked on the real CLI). `homeWorkspace`: the chip says "sandboxed: workspace is
your home — only credentials are protected".
Every path a session uses goes through one module, sandbox/pinned.ts (#448 rounds 13, 14, docs/sandbox.md
"Pinned paths"). Every path a sandbox binds by name (workspace, workdir, added dirs, roots, worktree folders and
locations, writable git/state/cache dirs, temp dir, `readOnly`, `pinned`, `heldInPlace`) is pinned (`SandboxSpec.pins`,
`pinOf`): real path and `dev:ino` (a held file, a codeoid-made folder: links only), the workspace's and the workdir's
taken once (`SessionSandboxSetting.pins`, daemon-kept), the rest at each build; bound as pinned, never `realpath`'d at a
launch (`boundPath`: only hidden paths, the allowlist, sockets are resolved); checked at every `buildSandboxSpec`,
every `LocalSandboxHandle.wrap` and again in the launched argv (`pinGuardArgv`, one `stat`: a launcher checks at each
run), in `claudeSandboxSettings`, and at the gate (`Session#sandboxPinRefusal`, all of the gate spec's pins); a changed
one refuses, never followed. Held in place (mount points; Seatbelt `literal`): dirs between nested writable roots
(`nestedRootAncestors`), and (`SandboxSpec.heldInPlace`, `heldAncestors`) the workdir and its way up to the
workspace and every dir between a writable root and a held path (a package's `.claude/`); Claude native leaves those
to the CLI, which binds them itself (real-CLI test). The daemon's own work in the workdir (checkpoints, rewind,
`rollFilesTo`, fork creation and setup, `fs.read`, MCP config reads, end-of-turn git looks) takes
`Session#pinnedWorkdir` (`PinnedDir`: `inPinnedDir` checks before and after, git with it as cwd and
`GIT_WORK_TREE=.`); a change refuses, said (`workdir.changed`) and audited (`session.workdir_changed`). Daemon-side
makes go through `makeAt`/`makeInPinned`/`replaceInPinned` (O_EXCL|O_NOFOLLOW), removes through `removeEmptyDir`.
`src/tests/pinned-paths-static.test.ts` fails on a direct fs/spawn/cwd/`GIT_WORK_TREE` use in sandbox/, session.ts
or checkpoints.ts without a `// paths: <why>` comment. An agent's own grant that another session comes to share is
let go at the next turn (`Session#dropSharedAgentDirs`).

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`;
the daemon loads it at start, so the gate judges synchronously and the approval
Expand Down Expand Up @@ -488,6 +537,7 @@ CODEOID_SANDBOX=auto # OS sandbox for agents' commands: auto|bwra
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, ~, /
CODEOID_SANDBOX_PROJECT_ROOTS=@parent # readable roots (csv): @parent = the repository's parent; [] none; never ~, /

# 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
Loading
Loading