Skip to content

feat: agent commands run in an OS sandbox, behind pluggable providers (#448) - #454

Merged
saucam merged 73 commits into
mainfrom
feat/sandbox-448
Nov 5, 2026
Merged

saucam merged 73 commits into
mainfrom
feat/sandbox-448

Conversation

@saucam

@saucam saucam commented Nov 4, 2026 •

Copy link
Copy Markdown
Collaborator

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

  1. Credentials and the home directory are unreadable. The home is hidden whole. Only a read-only allowlist is shown back: toolchains and git config, which hold no secrets, configurable as 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.
  2. Nothing outside the workspace, the session's temp dir and codeoid's toolchain cache is writable. Container runtime sockets aren't reachable.
  3. Nothing a sandboxed command writes is run outside a sandbox by codeoid, by a backend CLI, or by anything codeoid starts:
    • A repository's structure is read-only. Only commit data is writable, so git can't be redirected to config or hooks a command planted.
    • Codeoid's own git runs nothing a repository's config names.
    • A backend CLI's own git runs inside the session's sandbox, through a codeoid git shim, with no credentials.
    • Project settings that agent CLIs run code from (.claude/settings*, .mcp.json, and the other backends' equivalents) are read-only inside.
    • Each backend runs in a private per-session copy of its home. A refreshed login is written back as a token refresh only.

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

  • Choosing a sandbox.
    • The default comes from sandbox.provider (auto: bwrap on Linux, Seatbelt on macOS), also in Settings.
    • Each session can pick its own: codeoid new --sandbox <bwrap|seatbelt|docker|off>, the web UI's new-session dialog, or a routine or loop spec.
    • Turning a session's sandbox off or wider is the owner's decision. It's an approval shown in every mode, and refused to delegated tokens and other tenant members.
  • Network. On by default; off and allowlist are configurable. An allowlist is enforced on Claude, through its sandbox proxy. Where a provider can't enforce an allowlist, it falls back to off and says so; it never silently opens up.
  • Where a sandbox can't run (bwrap missing, a prerequisite like socat absent):
    • Autonomous sessions, routines, loops and dispatched work refuse to start, and a session can't be switched to autonomous.
    • Interactive sessions run with "not sandboxed: " on the session and every approval card.
  • Per backend:
  • Surfaces. A sandbox badge on the session, sandbox state on approval cards, and codeoid ls show what really runs.
  • Local mode. Local mode no longer hands its token to a loopback request. A sandboxed command with network on could otherwise have taken full control of the daemon. The web UI signs in with a one-time link instead (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.

  • Runs unasked: shell commands of any risk level, and writes outside the workdir. The OS sandbox contains them.
  • Refused at once, with a reason the agent reads:
    • a request to run a command outside the sandbox;
    • credential reads, and searches over credential directories in an unsandboxed session;
    • a named Claude account's settings;
    • paths that can't be resolved;
    • for file tools that run outside the OS sandbox (Claude's and opencode's), anything beyond the sandbox's own bounds. The gate holds them to the same spec the sandbox is built from. Claude's own context files stay its own: auto-memory, plans, todos, task output, and its agents, commands, rules and plugins (read-only).
  • Still goes to you: AskUserQuestion, and confirming a fleet dispatch to another session.
  • Guarded and interactive modes behave as on main.
  • An exhausted autonomous tool budget (dispatched workers, routines and loops with a budget) now says so in the transcript, on the session and in codeoid ls, instead of the session silently going quiet.
  • A session in a monorepo package gets the repository's top level as its sandbox workspace, so sibling packages and git work as on 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

  • Six audit rounds, each with three sandboxed auditors (correctness, security escape attempts, real-binary behaviour), every finding reproduced first and fixed with a regression test. Rounds 1–3 found holes in the first design, and the design changed: the home went from deny-listed to hidden whole, backends got private homes, and the git rule was inverted. Rounds 4–5 found the Claude CLI's own git and the loopback token; both are fixed. A narrow pass followed on committed project settings.
  • Real programs. Tests ran against real bubblewrap 0.6.1, the real Claude CLI with its native sandbox, real Codex 0.166.0 and real opencode 2.2.5. A sandboxed rm -rf of a planted outside directory fails, and the directory survives. A planted secret isn't readable. Network-off blocks DNS.
  • Real Docker (run by the maintainer, outside the auditors' sandbox): 12 of 12 pass, including the real-container tests.
  • Fresh install, CI's Bun 1.3.13. Typecheck and lint pass. The full suite gives 5,693 pass and 10 fail, all in 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 on main (120 s against 156 s). Web tests: the same 39 environment failures as main.
  • CI is green: daemon-check, e2e and web-check. Its first run caught two problems, now fixed:
    • A git test assumed no system-wide git-lfs, but GitHub's runners have it. The test now uses a driver name of its own.
    • A start refused on a taken port exited with memory's embedding worker still running, and Bun crashed with exit 132, the same crash fix: stopping the daemon with memory on no longer crashes Bun #453 fixed for normal stops. A failed start now closes memory first, and only memory, so the token of the daemon that holds the port is left alone.
  • Not verified: macOS. Seatbelt's profile generation is unit-tested, but it needs a run on a real Mac; there's a checklist in docs/sandbox.md. The e2e suite runs in CI.

Known limits

All are documented in docs/sandbox.md:

  • Host allowlists are enforced only on Claude; elsewhere an allowlist means network off.
  • A routine's gate check runs unsandboxed, because the documented gh gates need your credentials.
  • Claude's own sandbox leaves placeholder files that make git add -A warn.
  • A worktree added mid-session in a Claude-native session is detected and warned about, not prevented.
  • With network on, other localhost services are reachable from inside. Use off or 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: SDK sandbox settings.
    • session-sandbox.ts: resolution, fallback rules, idle-only changes.
    • root.ts.
  • Backend wiring: 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 and daemon: session.ts (gate, cards, end-of-session git warning), session-manager.ts (create, set_sandbox authorization, routines and loops), git-env.ts (defusing every program-running git config key), local-mode auth (one-time UI link).
  • Protocol and UI: packages/protocol/src/types.ts (session sandbox info, card flag), web NewSessionModal / header / Settings, cli.ts (--sandbox, codeoid ui), config.ts and settings/manifest.ts (sandbox.*).
  • Docs: docs/sandbox.md (guarantees, per-provider and per-backend detail, limits, macOS checklist) and docs/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 behind CODEOID_TEST_DOCKER=1.

Follow-ups

  • Verify Seatbelt on a Mac.
  • Consider raising outside-writes.test.ts's per-test timeout if CI flakes.
  • A Forge provider once Forge has a stdio transport and file sync.

🤖 Generated with Claude Code

saucam and others added 30 commits November 3, 2026 17:55
…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>
saucam and others added 26 commits November 4, 2026 09:08
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Run agent shell commands in a sandbox so credentials and files outside the workspace are protected by the OS

2 participants