* fix: return result.json and stdout results from sandboxed containers Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: capture unmasked stdout-only last line, validate container result.json Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: reject image WorkingDir that escapes the container root Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: verify the whole result mount destination against the extracted rootfs Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: name the right skip reason and gate the symlink test to unix Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
6.9 KiB
Sandboxed container runtime (daemonless docker)
Windmill bash scripts can run a container image. There are two runtimes:
legacy # docker |
sandboxed # sandbox <image> |
|
|---|---|---|
| selected by | bare # docker |
# sandbox <image> |
| runtime | dind / Docker daemon (bollard, dind feature) |
daemonless: extract rootfs + nsjail-run |
| boundary | separate (daemon outside the jail) | the job's own nsjail sandbox |
| nsjail | not provided (trusted-tenant) | required — this is the sandbox |
| safety | trusted-tenant | sandboxed (untrusted-capable) |
| compat | full docker run/-d/API |
run-a-command subset |
The three bash annotations are distinct and don't overload each other:
# docker→ legacy daemon docker (unchanged).# sandbox→ run the bash script under nsjail.# sandbox <image>→ run that image's command under nsjail (this runtime).
Using it
Put the image ref on a # sandbox annotation line; the rest of the script runs
inside that image:
# sandbox python:3.12-slim
name="$1" # windmill args bind positionally, like any bash script
python3 -c "import sys; print('hello', sys.argv[1])" "$name"
- The body runs via the image's
/bin/sh -c(so the image needs a shell). - An empty body runs the image's
ENTRYPOINT+CMD. - Windmill args (declared
x="$1", …) are appended to the command. - The image's
Env,WorkingDirare applied; the windmill reserved variables (WM_TOKEN,BASE_INTERNAL_URL, …) are injected sowmill/API calls work.
Result
Same conventions as a plain bash script, in this order:
-
./result.json(relative to the image'sWorkingDir) if non-empty → returned as the JSON result; malformed JSON fails the job. It is a host file bind-mounted into the container, so it works forWorkingDir: ""(→/) too, which otherwise lands in nsjail's ephemeral root and never reaches the job. Being a bind-mount point, it must be written in place (> ./result.json); a write-temp-then-renamefails.The bind is skipped, with a warning in the job logs, when the destination can't be proven to stay inside the container: a
WorkingDirwith.., one whose path in the extracted rootfs crosses a symlink, or one under/tmp,/proc,/devor/sys(mounted after this bind, so a result there would be shadowed anyway). nsjail resolves a mount destination beforepivot_root, so an unverified path would have it create a file on the host. For the same reason the bind is applied directly after the rootfs binds — a# volumemounted over theWorkingDirtherefore hides it, and the result falls through to the stdout rule below. -
Otherwise the last non-empty line of stdout, returned as a string. stderr is excluded on purpose: the two pipes are merged by a fair
select, so a diagnostic on stderr could otherwise beat a block-buffered stdout result. -
Otherwise a completion message.
How it works
- Pull/extract (
crane, no daemon/store/root):crane export <image>streams the image's flattened root filesystem to a tar (layers + whiteouts applied, likedocker export) andcrane configreads its OCI config. The tar + config are cached content-addressed by digest (crane digest) so unchanged digests reuse the cache;tar -xmaterializes the per-job{job_dir}/rootfs. crane is a single ~25 MB static binary — we never run the image with it (nsjail does), so a full container engine like podman isn't needed. - Run (the job's nsjail sandbox): nsjail binds each top-level entry of the
rootfs in place (binding the whole rootfs at
/trips nsjail's read-only remount of its base root in a rootless userns), mounts the standard pseudo-filesystems (/procfrom the jail's pid namespace, a tmpfs/tmp,/devnodes), maps uid/gid 0 inside → the worker user outside, and runs the command. The container is the jail.
# sandbox <image> ─▶ crane export → digest-keyed rootfs cache → tar -x → {job_dir}/rootfs ─▶ nsjail (chroot rootfs)
crane config (OCI config) ───────────────────────────────────────────▶ Env / Cmd / WorkingDir
Because the run is just the job's own nsjail with the image's filesystem as root, the container inherits exactly the job's confinement:
- Filesystem: only the rootfs + the job's mounts are visible — no host
/, no other job dirs, no dep cache. There is nothing to bind-mount escape to. - /proc: the jail's own pid namespace — the worker and other jobs aren't visible.
- uid: a single-uid jail — an escape lands as the unprivileged worker user.
- network: the job's network (same as any bash job).
Image storage, freshness & limits
- Where pulls live: a content-addressed cache of flattened rootfs tars (+ OCI
config sidecars) keyed by image digest, under
{ROOT_CACHE_DIR}/sandbox_rootfs(persistent, dedups pulls across jobs). The per-job extracted rootfs lives in{job_dir}/rootfsand is removed with the job. - Freshness (
SANDBOX_IMAGE_PULL_POLICY, defaultnewer): the cache is keyed by digest, so a moving tag whose digest changed re-pulls automatically.newer(default) /alwaysre-resolve the digest each job (one cheapcrane digestmanifest fetch);missingreuses a cached digest for the ref without hitting the registry;neveronly uses the cache (errors if absent). Pinning a digest (img@sha256:…) is immutable and never stale. - Per-image size cap (
SANDBOX_IMAGE_MAX_SIZE_MB, default 0 = off): images whose compressed download size (crane manifest) exceeds the cap are rejected before any layer is downloaded. - Cache size cap (
SANDBOX_IMAGE_CACHE_MAX_MB, default 0 = off): best-effort eviction — after a run, the oldest cached rootfs tars (by creation time) are removed until the cache is back under the cap.
Requirements
craneandtaron the worker for image pull/extract (a single static binary — no daemon, root, or privileged).nsjailon the worker — required. If nsjail is absent, a# sandbox <image>job errors clearly (use a bare# docker+ a daemon instead).
Limitations (by design — daemonless, run-to-completion)
- No
docker run -d+ laterexec/attach/logs -f, nodocker build,compose, swarm, healthchecks. - No arbitrary
-vhost bind mounts,--privileged,--cap-add,--device, host namespace sharing. - Images that drop to a non-root uid or chown to arbitrary uids inside need a
subuid range in the jail (single-uid only today — follow-up:
newuidmaprange mapping).
Follow-ups
- Subuid-range nsjail variant for multi-uid images.
- Per-container isolated networking (slirp/pasta).
- Support under the non-nsjail
unshareisolation mode.