Skip to content

feat(sandbox): make it a real axis, and turn it on by default - #226

Merged
oratis merged 1 commit into
mainfrom
feat/sandbox-axis
Aug 3, 2026
Merged

feat(sandbox): make it a real axis, and turn it on by default#226
oratis merged 1 commit into
mainfrom
feat/sandbox-axis

Conversation

@oratis

@oratis oratis commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Findings F5/F6 in docs/THREE_WAY_REVIEW.md. The sandbox was off unless configured, and one mode expressed both what a command may touch and how it gets approved. Codex separates those; CODEX_ALIGNMENT_PLAN.md §5.5 says to, and it hadn't been done.

The axis

sandbox.mode (settings) and --sandbox (CLI) take read-only | workspace-write | danger-full-access, orthogonal to --mode. So "never ask me, but keep writes inside the workspace" is now expressible:

deepcode --mode dontAsk --sandbox workspace-write

The legacy enabled boolean keeps working (true → workspace-write, false → danger-full-access); mode wins when both are set. Every host defaults to workspace-write through RuntimeHost. Library callers of wrapBashCommand keep the old "off unless configured" behaviour unless they pass defaultMode, so embedding DeepCode can't become silently sandboxed by an upgrade. /status and --help show the resolved mode.

Why it wasn't usable before

On macOS the profile is (deny default) with no rule for cwd — an enabled sandbox denied reads of the project directory itself, so cat src/a.ts failed inside it while the Linux path bound cwd read-write. Nobody hit it because nobody could turn the sandbox on.

Reads become allow-by-default

The read allowlist did not survive contact with real commands. Each of these was a separate, silent failure:

  • git couldn't resolve Xcode's active developer dir (/Applications wasn't readable) → "No developer tools were found"
  • nothing could open /dev/nullfatal: could not open '/dev/null'
  • every temp write failed — SBPL subpath doesn't match the directory node, so /tmp and /private/tmp needed literal reads
  • ~/.gitconfig was denied

Writes and network stay deny-by-default — that is what the sandbox is actually for. Credential stores (~/.ssh, ~/.aws, ~/.gnupg, ~/.netrc, ~/.docker/config.json, ~/.config/gh, ~/.deepcode/credentials.json, ~/Library/Keychains) are denied for reading, and filesystem.denyRead still applies last, so a stricter posture remains expressible — it just isn't what has to be right for ls to work. This trade-off is written up in docs/security-model.md.

Package-manager caches (~/.npm, ~/.cache, ~/.cargo, …) are writable: denying them turns npm install into a permission error while protecting a content-addressed cache. A linked worktree's git dirs live outside the workspace and are added too — otherwise every git command fails inside the worktrees EnterWorktree creates.

Verified, not asserted

Run against the real sandbox-exec on macOS:

workspace-write read-only
read workspace file
write workspace file ⛔ (correct)
write $TMPDIR / /tmp
git status / git log
node, tsc, vitest, npm install
read ~/.gitconfig
read ~/.ssh/<key>
write outside the workspace

The end-to-end attack test had its target inside $TMPDIR, which the profile intentionally allows — it was passing for the wrong reason and now writes outside the temp allowance.

19 new policy unit tests + 4 CLI flag tests. pnpm typecheck · lint · format:check clean; core 739/16 skipped · cli 208 · desktop 75 · server 41 · protocol 24 · vscode 12 · lsp 13 · scripts 21.

Linux is unchanged beyond sharing the mode resolution — bwrap already bound cwd read-write. The bwrap path deserves the same empirical pass on a Linux host before we lean on it.

🤖 Generated with Claude Code

Findings F5/F6 in docs/THREE_WAY_REVIEW.md: the sandbox was off unless
configured, and a single `mode` expressed both what a command may touch and
how it gets approved. Codex separates those; the alignment plan §5.5 says to,
and hadn't.

The axis. `sandbox.mode` in settings and `--sandbox` on the CLI take
read-only / workspace-write / danger-full-access, orthogonal to `--mode`. The
legacy `enabled` boolean still works (true → workspace-write, false →
danger-full-access) and `mode` wins when both are set. Every host now defaults
to workspace-write via RuntimeHost; library callers of wrapBashCommand keep
the old "off unless configured" behaviour unless they pass defaultMode, so
embedders can't be silently sandboxed by an upgrade.

Why it wasn't usable. On macOS the profile is `(deny default)` with no rule
for cwd, so an enabled sandbox denied reads of the project directory itself —
`cat src/a.ts` failed inside it, while Linux bound cwd read-write. Nobody hit
this because nobody could turn it on.

Reads become allow-by-default. The read allowlist did not survive real
commands: git couldn't resolve Xcode's developer dir under /Applications,
nothing could open /dev/null, temp writes failed because SBPL `subpath`
doesn't match the directory node itself, and ~/.gitconfig was denied. Each
failure surfaces inside an agent as a confusing permission error. Writes and
network stay deny-by-default; well-known credential stores (~/.ssh, ~/.aws,
~/.gnupg, ~/.netrc, ~/.config/gh, the DeepCode credentials file, Keychains)
are denied for reading, and filesystem.denyRead still applies last.

Package-manager caches are writable — denying ~/.npm turns `npm install` into
a permission error while protecting a content-addressed cache. A linked
worktree's git dirs live outside the workspace and are added too, or every git
command fails in the worktrees EnterWorktree creates.

Verified on macOS rather than asserted: under workspace-write, workspace
read/write, temp writes, git, node, npm install, tsc and vitest all succeed
while writes outside the workspace and reads of ~/.ssh are denied; read-only
additionally denies workspace writes. The end-to-end attack test moved its
target out of $TMPDIR, which the profile intentionally allows — it had been
passing for the wrong reason.

Co-Authored-By: Claude Opus 5 <[email protected]>
@oratis
oratis merged commit f31a218 into main Aug 3, 2026
5 checks passed
oratis added a commit that referenced this pull request Aug 9, 2026
`buildLinuxBwrapArgs` ended with an unconditional `--bind <cwd> <cwd>`. bwrap
applies binds in order and the last one wins, so under `read-only` the
`--ro-bind-try` that `sandboxConfigForMode` had correctly asked for was
overwritten a few arguments later. The mode resolved right, the profile said the
right thing, and a command could still write to the workspace.

macOS never had this: `buildMacOsProfile` grants writes only from `allowWrite`,
which read-only leaves empty. #226 introduced the mode axis and live-verified it
on macOS — where it caught the mirror-image bug, a profile that denied reads of
the project directory. This is the half nobody looked at, which is exactly what
THREE_WAY_REVIEW recorded under 仍未做.

Found by writing the missing test rather than by reading the code: the bwrap
integration suite only ever exercised the legacy `enabled: true` shape, so no
test had spawned bwrap in a named mode to see what a command could actually do.
The mode resolution was unit-tested and the arguments were argument-tested, and
between those two correct halves the behaviour was wrong.

An absent mode keeps the read-write bind, so callers on the legacy shape are
unaffected.

Linux CI now sets DC_REQUIRE_BWRAP=1. These are the only tests that observe what
the sandbox does rather than what it builds, and they self-skip — a green suite
that skipped every real enforcement check is how this survived.

Co-Authored-By: Claude Opus 5 <[email protected]>
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.

1 participant