Skip to content

fix: stopping the daemon with memory on no longer crashes Bun - #453

Merged
saucam merged 1 commit into
mainfrom
fix/memory-worker-shutdown
Nov 2, 2026
Merged

saucam merged 1 commit into
mainfrom
fix/memory-worker-shutdown

Conversation

@saucam

@saucam saucam commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator

Summary

Stopping a daemon with memory on could crash Bun. The daemon then died with SIGILL (exit 132) before memory's store was closed and before the data-dir lock (#450) was released. Shutdown now stops the embedding model's worker in a way that can't crash.

What was wrong

Memory's embedding model runs in a worker thread, through onnxruntime, a native addon. Shutdown called Worker.terminate() on that worker. If onnxruntime still held the thread, Bun panicked (NAPI FATAL ERROR: Error::New napi_create_error) and the whole process died. That covers three cases: loading the model, running a batch, or with native finalizers from the last batch still pending.

Why it matters

  • Affected: any daemon with memory on (the default) whose model has loaded. The crash happens on shutdown, more often on Bun 1.3 than on 1.4.
  • What a crash skips: the shutdown steps after memory. Memory's SQLite store isn't closed, and the data-dir lock isn't released. The next start takes the lock over and SQLite recovers its log, so no data is lost, but systemd records a crash.
  • How it showed up: the new real-daemon lock tests in feat: opencode is a codeoid backend, with every tool call approved by codeoid #450 failed on CI three times with exit 132. CI's log captured the panic once the tests printed the daemon's output.

What changed

  • The worker runs requests one at a time. On a stop message it finishes what it is running, collects garbage so the pending native finalizers run, then exits itself.
  • The main thread asks the worker to stop and waits up to 5 s for it to exit. It never calls terminate(). A worker that doesn't exit in time is left to end with the process.
  • The real-daemon lock tests run their daemons without embeddings. Each test daemon has a fresh home, so it downloaded the 34 MB model before listening. That's how one start missed its 30 s window on CI.

Verification

  • Reproduced locally: closing the embedder while the model loads, or during a batch, panicked on nearly every delay tried with the old code.
  • Probe after the fix: 40 of 40 clean runs on Bun 1.3.13 (CI's version) and 40 of 40 on 1.4.4.
  • New regression test memory-embedder-shutdown.test.ts: closes the embedder at 8 points during load and embedding. Old code: 6 of 8 fail on 1.3.13 and 1 of 8 on 1.4.4. Fixed: 8 of 8 pass on both.
    • It needs the model on disk (CODEOID_TEST_EMBEDDING_MODELS, e.g. ~/.codeoid/models), so CI skips it.
  • Checks: typecheck and lint pass; the full suite on Bun 1.3.13 has 5,404 pass and 0 fail.

🤖 Implementation context (for agents / maintainers)
  • src/daemon/memory/embedder-worker.ts: requests are chained on queue. { type: "stop" } sets stopping, which ignores later messages, then awaits the running request. After that it calls embedder.close(), nulls the model, runs Bun.gc(true), sleeps 50 ms, runs Bun.gc(true) again, sleeps 10 ms, and calls process.exit(0) inside the worker.
    • Probe on 1.3.13: no wait, sleep only, and gc only all panicked. gc, sleep, gc was clean in every run.
  • src/daemon/memory/embedder.ts: close() posts the stop and awaits the worker's close event, bounded by WORKER_STOP_MS (5 s). On timeout it calls unref(). #teardown (init failure or worker crash) posts the stop and unrefs. Neither calls terminate().
  • src/tests/data-lock.test.ts: CODEOID_MEMORY_EMBEDDINGS=false in the spawned daemons' env.
  • src/tests/memory-embedder-shutdown.test.ts and src/tests/fixtures/embedder-close.ts: the fixture keeps running 500 ms after close(), as a daemon's shutdown does, because the panic comes a moment after the worker is torn down.
  • Not changed: the reranker runs on the main thread and is disposed there; nothing crashed in that path.

🤖 Generated with Claude Code

Shutdown terminated the embedding model's worker; doing so while onnxruntime
held it (loading the model, running a batch, or with its native finalizers
pending) made Bun panic (NAPI FATAL ERROR, SIGILL) before memory's store
closed and the data-dir lock was released. Seen as exit 132 in the
real-daemon lock tests on CI.

The worker now runs requests one at a time and, on a stop message, finishes
what it is running, collects and lets native finalizers run, then exits
itself. The main thread waits for it (bounded) and never terminates it.

The lock tests' daemons run without embeddings: with a fresh HOME each one
downloaded the model before listening, which is how a start missed its 30 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@saucam
saucam merged commit 7c68b4d into main Nov 2, 2026
5 checks passed
saucam added a commit that referenced this pull request Nov 4, 2026
… 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>
saucam added a commit that referenced this pull request Nov 5, 2026
…#448) (#454)

* feat: a pluggable OS sandbox interface, with bubblewrap confining commands 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>

* feat: Claude's commands run in its own sandbox with codeoid's policy, 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>

* feat: Codex runs workspace-write inside codeoid's bubblewrap, so its 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>

* feat: pi, the Gemini CLI and Qwen Code run whole inside the session's 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>

* feat: opencode runs each session's shell lines inside that session's 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>

* feat: macOS sessions are sandboxed with Seatbelt (#448)

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>

* feat: Docker can be a session's sandbox, with only the workspace and 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>

* feat: every surface shows whether a session's commands are sandboxed, 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>

* docs: the OS sandbox — what it protects per provider and backend, what 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>

* fix: a hidden path can't be renamed out of the sandbox, and loop checks 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>

* fix: #448 round-1 audit: the home is hidden whole and shown back by a 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>

* fix: #448 round-1 audit: Claude without socat is reported not sandboxed 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>

* fix: #448 round-1 audit: a backend inside the sandbox runs in a private 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>

* fix: #448 round-1 audit: a session's sandbox changes only while it is 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>

* fix: #448 round-1 audit: the docs describe the hidden home and its allowlist, 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>

* fix: #448 round-1 audit: a hidden dir is never pinned, so a workspace 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>

* fix: #448 round-2 audit: a repository's structure can't be changed from 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>

* fix: #448 round-2 audit: the project settings agent CLIs run code from 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>

* fix: #448 round-2 audit: go build, bun install, uv and pip work inside 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>

* fix: #448 round-2 audit: codeoid's own git runs nothing a repository'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>

* fix: #448 round-2 audit: a routine can't carry a sandbox wider than the 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>

* fix: #448 round-2 audit: codeoid's own temp entries are hidden from every 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>

* fix: #448 round-2 audit: a sandboxed backend's conversations are its 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>

* fix: #448 round-2 audit: a login a sandboxed backend refreshes is written 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>

* fix: #448 round-2 audit: tools in ~/.local/bin that link into ~/.local/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>

* fix: #448 round-2 audit: CLAUDE.md and the provider guide describe the git layout, project settings, caches, logins, conversations and the daemon's private temp dir

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: #448 round-2 audit: no project settings placeholders in a workspace 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>

* fix: #448 round-3 audit: the daemon's own git runs no alternates' ref 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>

* fix: #448 round-3 audit: a sandboxed backend's login is written back 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>

* fix: #448 round-3 audit: the toolchain cache variables reach only sandboxed 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>

* fix: #448 round-3 audit: a link in the home allowlist shows nothing outside the allowlist, so a ~/.local/bin link into a project no longer exposes its .env and keys

Round 2 showed what a link directly in an allowlisted dir led to elsewhere
in the home: its dir, the nearest package.json root and its interpreter.
A personal tool linked from ~/.local/bin into one of the user's projects
thus showed the whole project, .env and deploy keys included, to every
sandbox (finding s3).

linkedPrograms is gone: a link in the allowlist works only where it leads
into the allowlist itself (or outside the home); anything else stays
hidden and the link dangles. The Claude installer's
~/.local/share/claude/versions joins the default allowlist, so its
binaries linked from ~/.local/bin keep working; pipx and uv tools were
already listed.

Test (real bubblewrap): a ~/.local/bin link into a project with a
package.json, .env and a key shows nothing of it and the tool doesn't run;
the round-2 pipx and installer cases still run. Fails without the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: #448 round-3 audit: worktrees added later, an invalidated .git, a submodule's .git file and remotes/branches can no longer redirect the user's git

Four ways a sandboxed command could still pick what the user's next git
runs outside the sandbox:

- A worktree added after the sandbox opened (the user's, a fork's) was
  writable: only worktrees that existed at spec time were held (finding
  r1). worktrees/ is now held read-only whole (made empty where missing),
  the workspace's own worktree git dir writable over it. On Claude's own
  sandbox, whose denyWrite beats allowWrite, a linked worktree's
  worktrees/ is denied entry by entry around its own git dir (denyAround);
  a plain repository's is held whole there too.
- A plain repository's .git made invalid (HEAD garbled, refs/ removed)
  had git take the workspace's top as a bare repository planted there,
  with its fsmonitor (finding c1). An empty HEAD/ dir beside a top-level
  .git is now held read-only: a HEAD that is a directory makes the top no
  git dir, and git doesn't see an empty dir.
- A workspace whose .git file names a git dir outside the worktree layout
  got nothing, its .git file writable (finding c4). A submodule's git dir,
  named back by its core.worktree, is now the repository's (held as a
  plain one; commits work); any other .git file is held read-only with
  what it names inside the workspace, and the sandbox's limits say commits
  from inside fail (gitLayoutProblem).
- remotes/ and branches/ were documented read-only but not made where
  missing (finding r4): they are placeholders now.

Tests (real bubblewrap, the user's git run outside in a second
bubblewrap): F, both invalidation variants; G, a worktree added while the
handle is open, from a plain repository and from a linked worktree, with
commits still working; H, a submodule checkout; I, remotes/branches; and
branch switching still works. F, G, H and I fail without the fix. The real
Claude CLI: a worktree added while a command runs is read-only to it, and
a linked worktree still commits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: #448 round-3 audit: a new opencode.json, opencode.jsonc or .mcp.json can't be created at the workspace's top from inside

The project config files were held only where they existed, so a
sandboxed command could create opencode.json with a local MCP server or
plugin that the user's own opencode then runs on the host at its next
start in the project (finding c7).

Each is now made first where missing, holding the empty config its CLI
reads as none ({"mcpServers":{}} for Claude, {} for opencode; checked on
the real Claude CLI and opencode 2.2.5, where an empty dir placeholder
breaks the config load), and held read-only like the dirs. In a git
repository the files codeoid made are listed in .git/info/exclude
(anchored to the workspace, read-only inside), so git status and git add
-A don't see them, as they don't see the empty dir placeholders.

Tests: real bubblewrap, creating, removing or renaming each from inside
fails (fails without the fix); unit, the placeholders and their exclude
lines, in a repository's top and a subdirectory, added once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: #448 round-3 audit: the sandbox docs say a missing project config file is made as an empty config and held

The previous commit made .mcp.json, opencode.json and opencode.jsonc
placeholders at the workspace's top; docs/sandbox.md and CLAUDE.md now say
so (and no longer that a new .mcp.json can still be made from inside).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: #448 round-3 audit: a file tool writing the repository's git structure asks in every mode while sandboxed, the card saying why

The sandbox holds a repository's config, hooks and git dir read-only for
commands, but a file tool runs in the backend's own process, outside it
(Claude's Write/Edit/MultiEdit/NotebookEdit, opencode's write/edit/patch).
In autonomous mode such a write into .git/hooks/pre-commit, .git/config,
.git/commondir or a .git file was auto-approved, since .git is inside the
workdir (finding c3). Only the project settings asked.

protectedWriteTargets lists what a file tool must not write unasked: the
repository's git dirs (the workspace's .git, a linked worktree's own and
its repository's, wherever they are), the .git files of other checkouts
in the workspace, and the project settings. heldWriteOf judges a call's
targets as the kernel resolves them (a link in the workspace into .git
counts; one that can't be resolved asks). While the sandbox is on such a
call asks in every mode, autonomous included, its card's first reason
saying what it writes and why (audited session.git_structure_write_ask);
the tool card no longer starts as auto-approved for it; on Claude the
PreToolUse hook routes it to the gate past any allow rule.

Tests: unit (plain repository, through a link, a linked worktree with its
repository outside the workspace, nothing else held); the session gate in
autonomous mode for Claude, opencode and pi tools, the card's reason; the
Claude hook asks past an allow rule. The gate and hook tests fail without
the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: #448 round-3 audit: a session warns when the repository's git config, hooks, hooks path or attributes changed while it ran; the docs state the sandbox's guarantees and limits

The owner's direction for this round: the sandbox's hard guarantees are
credentials and the hidden home unreadable, nothing outside the workspace,
the session's temp dir and the sandbox caches writable, and nothing a
sandboxed command writes run outside a sandbox by codeoid, a backend CLI
or anything codeoid starts. It does not guarantee the workspace is safe to
run after the agent touched it (it is code); the git-structure protection
is defence in depth, best effort, its known limits listed. docs/sandbox.md
and CLAUDE.md now say so, and docs/sandbox.md lists the workspace-is-code
class (husky's core.hooksPath=.husky/_, finding r2) and git's known limits.

The cheap visible signal: sandbox/git-watch.ts fingerprints what git runs
or obeys at the user's next command that git status doesn't show — the
repository's config, config.worktree, commondir, hooks, core.hooksPath and
the files in it, info/attributes, .gitattributes, every worktree's
commondir/gitdir/config.worktree and the .git file (codeoid's own
placeholders read as unchanged). A session takes it at its first turn and
compares at each turn's end and at destroy; a change is a warning line
naming what changed (event sandbox.git_changed, audited
session.git_control_changed), said once.

Tests: unit (placeholders are no change; config, a hook, attributes, a
husky hook git status doesn't show, the hooks path itself, another
worktree's commondir are); a session warns after the turn that saw a
change, says nothing on a quiet turn, and warns at destroy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: #448 round-3 audit: the sandbox docs point a Claude-native late worktree at the end-of-session warning described above

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: #448 round-3 audit: Seatbelt's profile is checked to hold worktrees/ read-only with the worktree's own git dir writable inside it

macOS can't be run here, so the profile builder is checked: the read-only
worktrees/ comes after the writable git dir, the worktree's own git dir
after it, its commondir after that (Seatbelt's later rule wins), and the
HEAD and project config placeholders are denied.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: #448 round-4 audit: the Claude CLI's own git runs inside the session's sandbox, so a repository a command planted runs nothing on the host

The CLI runs `git status`, `git log` and `git remote` in the workspace at
every start, on the host. A sandboxed command could `git init` a workspace
with no `.git` of its own (no repository, or a monorepo package), name a
script as a filter driver and apply it; the next CLI start ran it with the
user's rights. A native session's CLI now finds codeoid's `git` first on
its PATH (git-shim.ts, in the session's launcher dir, hidden from every
sandbox): it runs the real git inside the session's sandbox, read-only and
offline, keeping stdio, exit code, cwd, arguments and environment; with the
provider gone it runs git with every program-running key off, agent-scope
drivers and helpers reset and attributes unread. The CLI's Bash commands
get its PATH without the shim.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: #448 round-4 audit: opencode's server runs its own git through codeoid's shim, a repository's drivers reset by name, and the gate refuses both shims

opencode's shared server runs git in a session's workspace for itself (its
project, snapshots). OPENCODE_GIT_OVERRIDES turns hooks and fsmonitor off
but can't name a filter, diff or merge driver a planted config defines. The
server's PATH now starts with `<dataDir>/git-bin`, whose git (git-shim.ts,
defused: no one session's sandbox fits a shared server) runs the git that
PATH found with every agent-scope driver and credential helper reset, the
working tree's attributes unread and the caller's protocol list kept. A
file tool is refused that dir and the shell shim's `bin/`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: #448 round-4 audit: the git-settings warning says the user's git and other host tools obey the change, not the CLIs codeoid starts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: #448 round-4 audit: local mode never hands its token to a loopback request; the web UI signs in by a one-time link

The daemon injected its all-scopes local token into /ui/'s index.html for
any loopback Host. A sandboxed command with the network on (the default) is
a loopback client: `curl 127.0.0.1:<port>/ui/` read the token its sandbox
hides on disk, and with it the daemon (an unsandboxed session included).

The page now carries only `__CODEOID_LOCAL_MODE__`. The start banner prints
`/ui/#codeoid-local-code=<code>` (256 bits, once, 15 minutes), and
`codeoid ui` asks the daemon for a fresh code with the token read from its
file (`POST /ui/local-link`, bearer; 2 minutes). The page takes the code out
of the URL and exchanges it once at `POST /ui/local-session` (loopback Host
only), keeping the token in its own storage. A real-bwrap test asks every
route from inside a network-on sandbox without the token: none hands it
out or acts. The docs say other localhost services are reachable with the
network on, and point unattended work at `network: off` or an allowlist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: #448 round-4 audit: the git shim runs git in its caller's own directory or fails, never in bubblewrap's fallback $HOME

Started from a directory the session's sandbox hides, bubblewrap without
--chdir quietly starts the program in $HOME instead, so the CLI's git would
answer for another directory. The shim's launcher names the caller's
directory (`"$PWD"`), and such a call fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: #448 round-4 audit: the Claude provider's sandbox tests give their runtime a git shim and check the CLI's PATH starts with it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: #448 round-4 audit: the sandbox docs say a git library inside a backend isn't behind the git shim, and what opencode 2.2.5 was seen to do

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: #448 round-5 audit: a spec can give each program a fresh temp dir of its own, gone when it exits

SandboxSpec.privateTemp: bubblewrap and Docker give the program a tmpfs
/tmp instead of binding the session's temp dir; Seatbelt (no mount
namespace) runs it under a small sh wrapper that makes a dir in the spec's
temp dir, names it in the profile and TMPDIR, and removes it when the
program exits. Nothing written there outlives the program. The git shim's
spec uses it (next commit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: #448 round-5 audit: the CLI's own git, through the shim, gets no credential, a fresh /tmp each time, and can write the workspace as a command can

The Claude CLI's git shim ran git in a read-only, offline sandbox with the
CLI's whole environment (its API key or OAuth token, a gateway's
credential) and the session's temp dir bound at /tmp. A filter a sandboxed
command planted saw the key at the CLI's next start, could keep it in /tmp
(the same dir after a switch to opencode, pi or Codex, whose commands read
it back), and could signal it through git status. And the CLI's own
writing git failed: its commits, `git worktree add`/`remove` for
EnterWorktree and worktree isolation.

Now the shim starts git with `env -i` and an environment built from an
allowlist (SHIM_ENV_NAMES: locale, terminal, user, PATH's absolute
entries, HOME as the sandbox shows it, git's repository, worktree, index
and identity variables, the caller's -c list less any entry whose value
could be a credential); never the CLI's credentials, ANTHROPIC_*/CLAUDE_*,
GIT_ASKPASS, GIT_SSH*, GIT_EXEC_PATH. The defused path gets the same
allowlist (opencode's server password no longer reaches its git), and its
config listings run under it with the overrides. The sandbox is the
session's command sandbox (workspace writable, git structure and project
settings held, home hidden), offline, with a private temp per git
(SandboxSpec.privateTemp), plus the repository's worktrees/ and the
workspace's .claude/worktrees for the CLI's worktree features
(cliWorktrees: real dirs only, none in a git dir held whole).

Tests: a planted driver run through the real shim (real bubblewrap) shows
no credential on stderr or in its env dump, keeps nothing in /tmp between
runs or in the session's temp dir, writes nothing outside the workspace
nor the git config or hooks; the CLI-style worktree add/remove and commits
succeed; the real Claude CLI's git at its next start hands a planted
driver no API key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: #448 round-5 audit: the defused git shim never goes into a nested 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>

* fix: #448 round-5 audit: a git shim removed meanwhile is written again 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>

* fix: #448 round-5 audit: the Claude CLI's PATH has only absolute entries

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>

* fix: #448 round-5 audit: codeoid ui on macOS never puts the sign-in code 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>

* fix: #448 round-5 audit: the opencode lifecycle test tells the git shim'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>

* fix: #448 round-6 audit: a sandboxed Claude session refuses the CLI's 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>

* fix: #448 round-6 audit: a session names commits made while it ran that 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>

* test: #448 the user-driver git test uses a driver name of its own, so a system-wide git-lfs (GitHub's runners) doesn't take its place

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: #448 a daemon that fails to start closes memory before it exits, 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>

* feat: autonomous mode never waits on approvals; the OS sandbox is its 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>

* feat: #448 the autonomous budget running out says so at once, in the 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>

* test: #448 on real opencode 2.2.5 autonomous mode allows a high-risk 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>

* test: #448 the e2e risk spec and the opencode high-risk cases use a command 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>

* test: #448 a write to a Claude settings file on a named account asks 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>

* fix: #448 round-8 audit: the Claude settings check judges where a write 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>

* fix: #448 round-8 audit: a command reaching the home runs in a sandboxed 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>

* fix: #448 round-8 audit: the held-write rule judges only file tools that 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>

* fix: #448 round-8 audit: host-side file tools are held to the sandbox'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>

* fix: #448 round-9 audit: a session in a monorepo…
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.

2 participants