Saphan StudioDocs
Security

Sandboxing: which agent is confined, where, and how we know

The confinement each agent gets per platform, what it costs, what it does not cover, and how each cell was measured.

Who this document is for: the person deciding whether a given agent vendor may run work on a given machine — and the engineer who has to defend that decision afterwards.

What this document is not: a design description. Runners: what happens during a run describes the confinement design — who confines what, the fail-closed rule, the bypass refusal. This document holds the MEASUREMENTS, cell by cell, with the command that produced each one and the date it was taken.

The two answer different questions. Security answers "what does the engine intend to enforce?" This answers "what did somebody actually observe, on which host, and what would falsify it?" When they disagree, this document is the one that was measured — and the disagreement is itself a defect to be filed.


Why a matrix at all

A sentence like "the agent is sandboxed" has three hidden arguments: which vendor, which platform, and which of our mechanisms. Collapse any one of them and the sentence becomes unfalsifiable.

2026-08-29, measured in one day, on a fleet running three vendors: the same vendor is confined on one platform and collides on the other; a second vendor collides on both, and fails silently while doing so; and the mechanism that works for one vendor cannot be nested inside the mechanism that works for another.

Six cells minimum (3 vendors × 2 platforms), and none of them is a corollary of another.


The matrix, as measured on 2026-08-29

vendorLinux (Landlock / bubblewrap)macOS (Seatbelt)
claude-codeconfined — the fleet's production postureconfined
qwen-codeNESTS under Landlock · NESTS under our bwrapNESTS under Seatbelt
codexCOLLIDES — and silentlyCOLLIDES — and silently
openai-compat · agent-loop · procunmeasuredunmeasured

`` means nobody looked. ⛔ It does not mean "no". Three different negatives look identical in output and only one is a fact about the host — see §5.

Which vendor CLI version each finding applies to

A finding about a CLI is a finding about a VERSION of it. These tools ship several times a week, so a statement with no version beside it is not something you can check against what you are running.

findingapplies to
codex — collides, Linuxcodex-cli 0.149.1
codex — collides, macOScodex-cli 0.14x — the series, for the reason below
qwen-code — nests, Linux and macOSqwen-code 0.22.2
claude-code — confined, Linux and macOSclaude-code 2.1.2342.1.251
droid — nests, Linuxdroid 0.213.0

What to do with a version you do not see here. A newer CLI may behave differently — vendors change their sandboxes. Treat a row as a statement about the version it names and re-check on yours before relying on it. The codex remedy below is the exception: it does not depend on a codex version, because it removes the nesting rather than working around it.

The macOS codex row is bounded more loosely than the others, deliberately. The mechanism it rests on is a property of macOS's own sandbox — a nested profile can only narrow, never widen — so it does not turn on a codex release. The exact version does.

What "NESTS" was required to mean

A cell is NESTS only when all of these hold, each with the output kept:

  1. a baseline control/bin/echo in the same domain runs clean. Any failure signature the vendor produces that /bin/echo also produces proves nothing;
  2. a write inside the grant succeeds, with the artefact read back;
  3. a write outside the grant is refused;
  4. a grandchild — two exec hops down — is still refused.

A refusal alone is not a boundary. Without (2) it measures the absence of a capability, not the presence of a wall. Every negative result in this document was paired this way or it is not here.


The collisions, named to the syscall

codex × Linux — the vendor's sandbox dies on a procfs write our domain refuses

unshare -U                → SUCCEEDS   (unprivileged_userns_clone = 1)
write /proc/self/uid_map  → REFUSED
write /proc/self/comm     → REFUSED    ← a file its own owner may write on any Linux

The refusal is our blanket ro=[/] filesystem domain, not a namespace ban. Codex's sandbox must write uid_map to start, so it cannot.

Both of codex's own backends fail, in opposite directions: the legacy-Landlock backend nests but cannot write ("permission profiles requiring direct runtime enforcement are incompatible with --use-legacy-landlock"); the bwrap backend writes but cannot nest (dies on the uid_map write even with the grant widened).

codex × macOS — nested Seatbelt denies everything, including what our grant allows

codex --sandbox workspace-write, UNDER our seatbelt   →  "Operation not permitted" on every FS op
                                                         rc=0, artefact ABSENT
codex --sandbox workspace-write, WITHOUT our seatbelt →  rc=0, artefact PRESENT   ← the control

⇒ The vendor's nested profile can only narrow, so its own grants do not survive inside ours — even for a path our policy explicitly permits. The control is what makes this a collision rather than a broken vendor.

Both codex collisions are SILENT — and that is the finding, not the footnote

On Linux, an end-to-end run: the tool call was issued, the sandbox panicked, the vendor wrapper printed Script completed, the model said DONE, rc=0 — and the file was never created.

An engine that confines codex today would produce legs that report success and do nothing.

rc=0 is not evidence. On this class of failure it is the symptom. Every cell in this document that runs a real vendor turn ends in an artefact check: the turn writes a named file, and its absence is RED regardless of exit code. Measured three times on 2026-08-29 that this was necessary.


droid — the one cell where the vendor's own sandbox is a SILENT stop

vendorLinuxmacOS
droidNESTS — a run completed inside this engine's own domain, a write in the working directory succeeded and a write outside it was refusedunmeasured

With both sandboxes on, the run returned success and NOTHING WAS WRITTEN. The vendor's own sandbox, nested inside ours, reported success with no error, and the only trace was one sentence inside the result text saying the workspace sandbox was unavailable and had denied all writes.

Theirs inside ours is not a second boundary. It is a silent stop wearing a success verdict — and that is the difference from codex, whose version of the same collision is loud: it crashes and names the call that failed. A silent one is worse to operate, because nothing in the run's own verdict tells you the work did not happen.

macOS is unmeasured for droid, and a blank cell means nobody looked.

What to do about codex

Everything above is a diagnosis. Stopping there leaves the impression that codex cannot be used here, and that is not the case — a codex machine runs, and nothing below replaces it.

The collision is a NESTING collision and only that. codex's sandbox is not broken and neither is ours; the two cannot be one inside the other. Every option therefore comes down to one question: who is the single boundary?

If you want the stronger boundary, we recommend running the vendor inside one of our container images. It is a recommendation rather than a replacement: the container becomes the single boundary, so codex's own sandbox is never asked to live inside a second one.

your optionswhat it means
Keep the machine as it iscodex runs; the two sandboxes do not nest, and the crash names the call that failed
Run the vendor in one of our imagesrecommended when you want it saferone boundary does the work, and it is the container's
Turn codex's own sandbox off and keep oursworks, at the cost of a tool with no sandbox of its own
Turn our confinement off and rely on codex'snever — the run record would read confined while nothing you control is confining it
Run codex unconfined⛔ not a posture this platform is for

Why a container resolves it, per platform:

  • Linux — inside a container there is no domain of ours over codex at all. The container's own namespaces, read-only rootfs, unprivileged user, process limit and network mode are the boundary, and the privileged write codex's sandbox needs happens inside the container's own user namespace where nothing of ours refuses it.
  • macOS — a nested profile can only narrow, which is why codex's own grants die inside ours. A container on macOS runs inside a Linux virtual machine, so there is no profile above codex to narrow its own. ⚠ And that is the honest limit: the boundary is then the virtual machine's rather than the operating system's — a different boundary, not a stronger one.

See Agent machines for the images themselves.

What the vendor needs from us before it can start

Not every failure is a collision. Two vendors need a writable profile home and behave differently without one:

vendorprofile home unwritable inside the jail
qwenDIESEACCES … <seat home>/extension-store/lock, before any work
codexWARNS and PROCEEDS

Narrowing the qwen root is impossible: besides extension-store/, there is a second, earlier write at the home ROOT. ⇒ the whole seat home goes into the writable set, or the leg never starts.

State the trade-off wherever this is granted: a writable seat home means a leg can rewrite its own profile — and at least one backend has been measured rewriting every settings file it reads.


Why some cells stay unmeasured

A blank cell means nobody looked. It does not mean "no". Three different negatives look identical in a terminal — the mechanism refused, the mechanism was never asked, and the measurement itself was broken — and only one of them is a fact about your host. A cell is filled here only when a control ran beside it and the output was kept.

⇒ Where this matrix is blank, measure it on the host you care about rather than reading the blank as a verdict.

Confinement is not egress

Two mechanisms, two documents, and conflating them is the most common mistake in this area.

what it iswhere it is described
the doora per-run SOCKS5 / CONNECT proxy the leg may reach the network throughEgress
the wallthe network cut that makes the door the only way outEgress, the rung ladder

Without the wall, the door is an exit, not a control — the agent can simply connect beside it. The wall's rungs (netns, cgroup-v2, separate-uid, seatbelt-network-deny, …) are a per-machine verdict, measured by saphan machine probe-confinement and recorded on the machine row.

"The host can jail" ≠ "this backend is jailed" ≠ "egress is cut". Three claims, three rows. A host measured landlock abi=8 may still record no egress rung at all.


What the tool permissions do and do not reach

The engine provisions a permission grant into the worktree. Measured 2026-08-29: it lands at .claude/settings.json — a path only claude-code reads. For the other vendors no permission file arrives at all.

Consequence, measured: on one backend the vendor's own headless policy denied write_file, edit and run_shell_command, the session had no hands, and no grant of yours would have had any say in it either way. The leg reported this itself rather than failing silently — but the boundary was the vendor's, not ours.

And the grant, read whole, contains no saphan entry at all — neither allow nor deny. What looks like "the agent may only run read-only control-plane verbs" is achieved by omission on one vendor and does not exist on the others.

A permission file is a vendor-specific mechanism. A control that must hold for every vendor belongs in our own binary, where no vendor's settings format can ignore it — the shape custody-binary-unsafe already uses (Runners, "the control-plane binary checks its own custody before it runs").


How to add a cell

  1. Pick the host by what the question needs, not by which seat is free. A cell about Landlock needs Linux and the vendor binary on the same host; the fleet has had periods where no single host carried both, and the question was then unanswerable by any leg.
  2. Run the baseline control first. If /bin/echo does not survive the domain, cells 2–6 are unrunnable, not merely unrun — and that is the honest verdict.
  3. Four verdicts, not three: NESTS · COLLIDES · HANGS · INDETERMINATE. HANGS was added after a cell entered an unbounded retry loop that timeout did not reclaim. A hang is a result: record its shape and exit.
  4. Bound the run — vendors ship budgets (qwen: --max-tool-calls, --max-wall-time, --max-session-turns, exit codes 55/53/130). Use them.
  5. End every real turn in an artefact check (§2).
  6. Write the status file first, then amend it. Two legs on this train each produced a megabyte of transcript, were cut by the clock, and delivered nothing, because the verdict lived only in their heads.

Documented limits of this matrix

  • Four of twelve cells are unmeasured (openai-compat, agent-loop, proc, both platforms).
  • Every cell taken by a leg was taken from inside at least one engine domain. A cell whose confined and unconfined results are identical proves "the second domain adds nothing"not "the failure is not ours."
  • Some cells were taken by hand (master, unconfined shell) because §4 makes them unreachable to a leg. Where the bubblewrap prefix was reconstructed from the product rather than emitted by the engine, that measures the shape the engine composes, not the engine composing it. The distinction is real and is recorded with each such cell.
  • The container arm is out of scope here. The engine composes a container boundary only on the local transport; an ssh spawn refuses by name and does not fall back to a weaker jail. See the container work for that axis.

Runners: what happens during a run (the design, the fail-closed rule, the bypass refusal, binary custody) · Egress (the door and the wall) · the per-backend pages (one backend end to end) · Configuration (run.confinement).