Skip to content

fix(sandbox): stage and read files through one owned path - #378

Merged
sudoTomas merged 1 commit into
mainfrom
fix/sandbox-file-writes
Nov 5, 2026
Merged

sudoTomas merged 1 commit into
mainfrom
fix/sandbox-file-writes

Conversation

@sudoTomas

@sudoTomas sudoTomas commented Nov 5, 2026 •

Copy link
Copy Markdown
Owner

Summary

Move every file embry0 puts into or reads out of a job's sandbox through one module, embry0.execution.sandbox_files, instead of docker cp at fixed paths. A copy in is written as root into a fresh root-only directory; a copy out is read as the agent. Fixes #361.

Motivation — Intent + Problem

embry0 stages files into a sandbox (a delivery's file, a resumed session, an http-context file) and reads files back out (a session blob, a boot screenshot, a job's deliverables). It did this with docker cp at fixed paths. docker cp runs in the daemon as root and follows a link at the path it is given, so a copy-in could overwrite a file the agent cannot write and a copy-out could read one it cannot read.

  • copy_bytes_into relies on the orchestrator and agent sharing a uid #361: a staged file kept the orchestrator's uid and 0600 mode, so a delivery only worked while the orchestrator and the agent shared a uid.
  • A first version of this change read copy-out as root. Under cap_drop: ALL root has no DAC_OVERRIDE, and the CLI writes its session file 0600 inside 0700 agent-owned directories — so reading as root failed, every resume fell back to the bounded text replay, and 0600 deliverables were dropped. This revision reads as the agent, which also closes the copy-out attack surface.

How it works

  • Copy in — put_bytes_for_reader. Writes the bytes as root, from stdin, into a freshly made /run/embry0/drop/<random>/ that only root may write, created O_EXCL | O_NOFOLLOW. The file is left root-owned and readable through its other bits — the sandbox drops every capability, so root cannot chown it, and a chowned file would be agent-writable. A caller that must land it at an agent path copies it there as the agent with cp --remove-destination (place_as_reader_script), which never follows a link at the destination.
  • Copy out — read_regular_file. Reads as the agent, not root: the agent only ever gets bytes it could already read, so a path that resolves to a file it cannot read — however the parent directories are arranged, and even if a parent is swapped mid-read — yields nothing new. The read opens O_RDONLY | O_NOFOLLOW | O_NONBLOCK (no final-link following; a FIFO or device returns at once instead of blocking), requires a regular file by fstat, and refuses a file past the caller's byte limit — enforced again on the orchestrator: run_cmd_bytes reads the pipe in chunks, never asks it for more than the limit plus 64 bytes, and kills the exec past that, so another agent process writing into the reader's stdout cannot make embry0 buffer an unbounded amount. It raises a distinct reason for permission-denied, link, not-regular, too-large and missing. Every helper runs python3 -I with the working directory /.
  • Deliverables. Listed NUL-delimited as raw, capped, unstripped bytes (a name may hold a newline or end in a space); each path is normalised and refused unless it stays under the deliverables directory, and a name with a control character is skipped and logged rather than put into a storage key; the per-file and total byte caps are enforced by the reader, not by a listed size.
  • Cleanup. Each drop directory is removed as root once its reader is done (discard_drop, which acts only on a normalised path of the exact shape DROP_ROOT/<32 hex>/<name>); the http body is not stored twice.
  • DockerClient.copy_into / copy_bytes_into / copy_bytes_from are removed; run_cmd_bytes returns a read's raw bytes. The guard test enforces the three invariants below.

Pattern provenance

N/A — originated in this PR (the root-owned-drop + stdin write mirror the git-config staging added in #353).

Verification

Automated

  • Full unit suite with a database, -n 4 --fail-on-warnings, Docker unreachable: on 642c002e 18529 passed, 21 skipped, 0 failed; rebased onto current main b3457a8c 18608 passed, 21 skipped, 0 failed
  • ruff check . and ruff format --check . on the whole tree: clean
  • mypy embry0: no issues in 652 source files
  • Fail-first, each with its own assertion message: earlier rounds against a clean origin/main; this round's tests against the previous head — the orchestrator-side cap (a fake process streaming 8 MiB past a 1 KiB cap, and a real one), the read's slack and post-check, the flooded stderr, the exact-shape discard_drop, the control-character and trailing-space names, the capped listing, and the real-container collector all fail there.
  • Mutations, each killed. This round: the cap check off, the read size ignoring the cap, an unbounded stderr, the reader passing no cap, a huge slack, the post-check off, the overflow not mapped to too_large, the listing uncapped, discard_drop without its token check / without normpath / back to a prefix check, control characters allowed, the listing stripped, the under-directory check off; and against the real container, -print for -print0, a listing split on newlines, control characters allowed, and a read as root. Earlier rounds: DROP_ROOT under /tmp, a write without O_EXCL or following a link, copy steps or copy-out as root, a read following a link / blocking on a FIFO / skipping the regular-file check / dropping the limit.
  • Guard inverted: outside a per-(path, function) allowlist, any user= / reader= other than "agent", a user/reader key in a dict literal (what ** carries), and any -u / -u<x> / --user / --user= / --privileged argv element or shell substring are refused; docker cp (incl. docker container cp) and the archive API are refused everywhere; the runtime/docker.py exemption is chown_volume_cmd only. Of a 26-form battery (the reviewer's 11 misses among them) the previous guard caught 13 and this one catches 26; every guard detector is mutation-killed.
  • Name denylist after staging: 0 matches. gitleaks over the changed files: no leaks.

Container e2e (tests/integration/test_sandbox_files_e2e.py, requires_docker, runs in CI's Integration job; production flags --cap-drop=ALL --security-opt=no-new-privileges, container default user the agent)

  • F1: a 0600 file an unprivileged agent wrote in a 0700 directory (the session-file and private-deliverable shape) is read back out — root cannot read it, the agent can.
  • F2, through the real _collect_artifacts: over a deliverables directory holding a private report, a name ending in a space, a newline-named directory, a symlink to /root, a FIFO, and a newline-named path whose part after the newline names an agent-readable file outside the directory (reachable only through a symlinked directory find never descends), the collector uploads exactly the report and the trailing-space file — never the root canary or the outside file — and nothing blocks.
  • read_regular_file refuses the planted paths directly: through the link permission_denied, at the link link, at the FIFO not_regular (at once).
  • The image is a plain python:3.12 with an agent user, so the test needs no embry0 image.

Deploy notes

  • No image rebuild. agent_runner.py, docker_client.py and embry0/agents/ are copied into the sandbox image (Dockerfile.sandbox), but the code paths this PR changes run only in the orchestrator, so a stale sandbox image is harmless. agent_runner imports sandbox_files lazily, so the sandbox runner's import surface is unchanged (tests/unit/sandbox/test_runner_import_surface.py still passes). A standalone deployment that builds its own sandbox image rebuilds on its next start with no behaviour change.
  • No new env var, migration, or dependency. New caps: MAX_ARTIFACT_TOTAL_BYTES and MAX_LISTING_BYTES (deliverables) and MAX_SCREENSHOT_BYTES. New failure code ERR_SANDBOX_COPY_OUT_REFUSED.

Fixes #361

@sudoTomas
sudoTomas force-pushed the fix/sandbox-file-writes branch 2 times, most recently from 1e91aef to 1871189 Compare November 5, 2026 17:27
embry0 moved files into and out of a job's sandbox with docker cp at
fixed paths. docker cp runs in the daemon as root and follows a link at
the path it is given, so those paths were not a safe place for embry0 to
write or read as root; and a staged file kept the orchestrator's uid and
a 0600 mode, so a delivery only worked while the orchestrator and the
agent shared a uid (#361).

Add embry0/execution/sandbox_files.py as the single place that moves
bytes across the sandbox boundary.

Copy in (put_bytes_for_reader): write the bytes as root, from stdin,
into a new directory under /run/embry0/drop that only root may write, so
nothing in the sandbox can pre-place the destination. The file is
created O_EXCL and no-follow and left root-owned and readable through
its other bits; the sandbox drops every capability, so root cannot chown
it, and a chowned file would be agent-writable. A caller that must land
it at an agent path copies it there as the agent with cp --remove-
destination, which never follows a link at the destination.

Copy out (read_regular_file): read as the agent, not root. The agent
only ever gets bytes it could already read, so a path that resolves to a
file it cannot read — however the parent directories are arranged, and
even if a parent is swapped mid-read — yields nothing new. The read is
O_NOFOLLOW|O_NONBLOCK (a FIFO or device never blocks it), requires a
regular file, and refuses a file past the caller's byte limit; the
orchestrator enforces the limit too, reading the pipe in chunks and
killing the read once it carries more, so another agent process writing
into it cannot make embry0 buffer an unbounded amount. Each refusal has
a distinct reason for permission, link, not-regular, too-large and
missing. Every helper runs python3 -I with the working directory /.

The deliverables listing is read as raw, capped, NUL-delimited bytes;
every path is refused unless it stays under the deliverables directory,
and a name with a control character is skipped; the per-file and total
byte caps are enforced by the reader. Each drop is removed once its
reader is done. copy_into / copy_bytes_into / copy_bytes_from are
removed; run_cmd_bytes returns a read's raw bytes; a guard test refuses
a docker exec as anyone but the agent, docker cp and the archive API
outside a short per-function allowlist.

No image rebuild: the changed code runs only in the orchestrator, and
agent_runner imports sandbox_files lazily so the sandbox runner's import
surface is unchanged.

Fixes: #361
@sudoTomas
sudoTomas force-pushed the fix/sandbox-file-writes branch from 1871189 to 8a13939 Compare November 5, 2026 18:21
@sudoTomas
sudoTomas merged commit 65cf0ea into main Nov 5, 2026
13 checks passed
@sudoTomas
sudoTomas deleted the fix/sandbox-file-writes branch November 5, 2026 18:40
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.

copy_bytes_into relies on the orchestrator and agent sharing a uid

1 participant