RFC 0022 — Sandbox runtimes for the scan worker
| Field | Value |
|---|---|
| Status | Draft |
| Short | Sandbox runtimes |
| Settles | Where a scanner's process runs — bwrap, an OCI container, a Kubernetes pod — behind one port, and what every runtime must prove |
| Author | Max Batleforc maxleriche.60@gmail.com |
| Co-author | — |
| Created | 2026-09-09 |
| Supersedes | — |
| Depends on | RFC 0018 (the worker role, the scanner port, [worker.sandbox]); RFC 0018-bis for how many runs one worker has in flight — its sandbox pool is sized from the per-run cost this RFC defines |
| Touches | crates/core, crates/adapters, crates/config, crates/sandbox (new), server, helm, docs |
1. Summary
RFC 0018 made the scan worker the one process that opens attacker-controlled archives while holding database and storage credentials, and put every binary scanner (postmortem, guarddog, trivy) behind bwrap. That sandbox is hard-wired: Sandbox.runtime is a string, subprocess::run branches on it, and the only alternative to bwrap is none — the bare command, refused outside tests unless BATLEHUB_UNSAFE_NO_SANDBOX=1 is set. Where the host forbids unprivileged user namespaces (Ubuntu 24.04's AppArmor default, most hardened Kubernetes nodes, every rootless container without --userns), bwrap starts, prints nothing, and the worker reads the empty output as a scanner error: the deployment is either unsandboxed or not scanning.
This RFC turns "where a scanner runs" into a port. crates/core gains SandboxRuntime — run one command over a bundle (the files the scanner needs, described as data, never as a host path) and get its output back — and crates/adapters gains one adapter per runtime: none and bwrap (today's behaviour, unchanged for every existing deployment), oci (a fresh Podman/Docker container per run) and kubernetes (a fresh pod per run, in the worker's namespace or a dedicated one, optionally under a gVisor/Kata runtime class per scan). The scanners stop knowing what a sandbox is: they describe a bundle and an argv, and read stdout.
Inside every runtime the same thing executes: batlehub-sandbox, a separate, statically linked agent that reads no config, no environment and no stdin — it takes two URLs, fetches its bundle from the first, extracts the archive inside the walls, runs the argv without a shell under the rlimits and a seccomp filter of its own, and uploads the result to the second. On kubernetes and (optionally) oci those URLs are presigned, single-key, short-lived URLs on the storage backend, so the sandbox has no channel to the worker at all: the only thing inside the wall is one GET and one PUT on one prefix the proxy never reads. Every runtime must pass a probe at startup that asserts, from inside, that the walls it claims are real; a worker whose sandbox does not hold refuses to start rather than scanning unsandboxed or silently holding every version. The agent installs a seccomp filter on every runtime, and on bwrap it runs on a per-scanner root rather than the worker's, so the room an escaped scanner lands in is the same empty one everywhere. An nsjail sibling of bwrap is the one runtime left proposed but not designed; a remote daemon and systemd-run are rejected in §8.
Before / after
# today — one switch, two positions, one of them unsafe
[worker.sandbox]
runtime = "bwrap" # or "none" + BATLEHUB_UNSAFE_NO_SANDBOX=1
memory_limit_mb = 2048
cpu_seconds = 300
# with this RFC — the same keys keep the same meaning; the runtime is a choice
[worker.sandbox]
runtime = "kubernetes"
memory_limit_mb = 2048 # → the pod's memory limit (and RLIMIT_AS inside)
cpu_seconds = 300 # → RLIMIT_CPU inside; the scanner timeout bounds wall time
[worker.sandbox.kubernetes]
namespace = "batlehub-sandbox" # default: the worker's own
runtime_class = "gvisor" # optional: kernel isolation per scan
image = "ghcr.io/batleforc/batlehub-sandbox-postmortem@sha256:…"
[scanners.guarddog]
command = "guarddog"
image = "ghcr.io/batleforc/batlehub-sandbox-guarddog@sha256:…" # per-scanner image, image runtimes only$ batlehub --roles worker --config config.toml
INFO sandbox: probing runtime "kubernetes" (namespace batlehub-sandbox, runtime class gvisor, bundles on s3://cache/sandbox/)
INFO sandbox: probe passed in 2.1s — no network but the storage endpoint, read-only root, empty environment, uid 65532, no service-account token, agent 1.3.0
INFO security worker: ready (4 slots, runtime kubernetes)
# and on a node where bwrap cannot do what it says
$ batlehub --roles worker --config config.toml
ERROR sandbox: probe failed for runtime "bwrap": the sandbox never started — bwrap exited 1 and no result came back: "bwrap: setting up uid map: Permission denied"
ERROR sandbox: refusing to start — a worker without its sandbox scans nothing, it does not scan unsandboxed
(kernel.apparmor_restrict_unprivileged_userns=1 on this host; see docs/operations/scan-worker.md#runtimes)2. Motivation
- The sandbox is a string switch, not a seam.
Sandbox { runtime: String }incrates/adapters/src/scanners/subprocess.rsis read in exactly one place,run(), which doesif runtime == "none" { bare } else { bwrap }. The three binary scanners each hold a copy of that struct and callrundirectly. Adding a third mechanism today means a third branch in the one function, a third set of fields on the struct, and the scanners still knowing nothing about it. The rest of the tree does not work this way: storage isdyn StorageBackend, the cachedyn CacheStore, the queuedyn ScanQueue. Isolation is the one infrastructure choice without a port. bwraphas a hard host requirement, and the fallback is the absence of a sandbox. Bubblewrap needs unprivileged user namespaces. Ubuntu 24.04 restricts them under AppArmor by default; the GitHububuntu-latestrunner broke the heavy suite this way on PR #146 (bwrap --versionsucceeds, every invocation exits with empty stdout, the worker holdsSCAN_PENDING, andbatlehub waittimes out five minutes later). Kubernetes nodes hardened by a PSSrestrictedprofile, aseccompdefault that deniesunshare, or a distribution kernel withkernel.unprivileged_userns_clone=0do the same. The Helm chart's documented answer (helm/batlehub/values.yaml, theworker.securityContextcomment) is to grantCAP_SYS_ADMINor to setruntime = "none"under a gVisor runtime class with the unsafe env var — which drops the per-scanner wall and puts the worker, credentials and all, inside the only sandbox left.- The strongest configuration is not reachable. RFC 0018 §7 calls a sandboxed runtime class "the opt-in fourth wall", around the whole worker pod. What an operator actually wants is that wall around each scan, with the worker outside it: a gVisor pod per invocation is exactly that, and it needs the worker to be able to create pods, not to be one.
- What a scanner can reach if it escapes is too much. Today an escape from the scanner process lands in a
bwrapnamespace on the worker's host, next to the worker's cgroup, with the worker's binary —batlehub, every adapter, every client, the config parser — on the read-only root. Nothing there is reachable (empty environment, no credentials), but all of it is present, and presence is surface. The thing inside the wall should be the smallest program that can do the job, and it should hold nothing that outlives the job. - Blast radius today is the worker's own cgroup.
RLIMIT_ASandRLIMIT_CPUare per process; a decompression bomb that stays underRLIMIT_ASbut pushes the pod over its memory limit takes the worker down with it, and the job returns to the queue for a second try at the same archive. A container or pod per run gives every scan its own limit and its own OOM, and the worker only sees aCrashed. - Extraction happens outside the sandbox.
ExtractPolicyis thorough (RFC 0018 §6.3), but it runs in the worker process: the decompressor, the tar reader and the path checks are the part of the pipeline that reads the most hostile bytes with the most privilege. The agent this RFC introduces moves extraction inside the walls on every runtime, includingbwrap. - A sandbox that does not hold fails silently. Nothing at startup checks that
bwrapcan do what its argv says. The AppArmor case above was diagnosed from a five-minute timeout and anEOFin a log line about JSON. A runtime that must prove its walls before the worker takes a job turns that into a refusal to start, with the reason on the first line.
3. Goals / non-goals
Goals
- One port,
SandboxRuntime, incrates/core; the worker and the scanners depend on it and on nothing more specific. Adding a runtime is one adapter file and onematcharm inbuild_sandbox_runtime(server/src/builders.rs), whichbuild_scannersinserver/src/setup.rscalls. - Four runtimes at the end of §12:
none,bwrap,oci,kubernetes. The first two behave exactly as today for every existing config. - The scanner's inputs cross the port as data (a bundle), never as a host path, so a runtime that shares no filesystem with the worker is not a special case.
- The agent is a separate static binary with no configuration surface: two URLs on its argv, nothing from the environment, nothing on stdin, no file it reads that it did not just fetch. What an escaped scanner finds next to it is that binary and the bundle it already had.
- No live channel from the sandbox to the worker. The sandbox fetches its input from, and writes its output to, a storage endpoint — presigned, single-key, short-lived on the image runtimes; a bind-mounted directory on the local ones. The worker reads the result after the sandbox is gone.
- Extraction of the artifact happens inside the sandbox, on every runtime.
- A startup probe per runtime that asserts the walls from inside, and a worker that refuses to start when it fails.
batlehub_sandbox_readysays which runtime holds. - The same
[worker.sandbox]limits mean the same thing on every runtime, mapped to what the runtime has (rlimit, cgroup, podresources). - Helm:
worker.sandbox.runtime = kubernetesproduces the Role, the RoleBinding, the sandbox pods' ServiceAccount and the NetworkPolicies, and nothing else changes.
Non-goals
- Distributing jobs differently, or deciding how many runs a worker has in flight. Which worker takes which job is settled by RFC 0018's PostgreSQL queue and the worker Deployment's replica count; how many sandboxes one worker keeps busy is RFC 0018-bis's sandbox pool, sized from the per-run cost this RFC defines. This RFC is about where one scanner's process runs once a worker holds the job and a pool permit. §8 records why a pod-per-job executor is the wrong tier.
- Sandboxing the network scanners (
osv,socket,mlab,sigstore,trivyin server mode). They run no artifact-derived code and make HTTP calls from the worker; that stays. - Hiding the cold start of a pod per run. A warm pool is rejected in §8: a scan that takes a few seconds longer is acceptable, a sandbox that existed before its job is not.
- Designing the
nsjailruntime. §5.4 says what it would be and what it would need; it is a bis of this RFC when a deployment asks.remoteandsystemd-runare not deferred, they are rejected (§8). - Any change to what the scanners find, to verdicts, or to the client-facing surface. A
batlehub whyoutput is byte-identical before and after.
4. User-facing design
4.1 Configuration
[worker.sandbox]
# Which runtime runs a bundle. "bwrap" is the default and today's
# behaviour; "none" keeps its refusal outside tests.
runtime = "bwrap" # none | bwrap | oci | kubernetes
# The limits, unchanged in name and meaning. Each runtime maps them to
# what it has — the table in §4.2 says how.
memory_limit_mb = 2048
cpu_seconds = 300
max_extracted_mb = 512 # applied by the agent, inside the sandbox; on oci and
# kubernetes also the size of the /work tmpfs (§4.2)
max_entries = 50000
# How long the runtime may take to get the agent running (image pull, pod
# scheduling, the bundle fetch) before the run is a ScannerError::Timeout.
# Added to the scanner's own timeout for the sandbox's deadline.
start_timeout_secs = 120
# How often a passed probe is repeated while the worker runs (§4.3).
probe_interval_secs = 600
# Where the image runtimes put what a run needs that is not already in the
# cache — the per-run manifest, an uncached artifact, the result — and for
# how long a presigned URL is valid past the run's deadline. The keys live on
# one leaf backend of `[storage]`, by name; unset means the default backend.
# They carry none of the proxy's key namespaces and never enter the storage
# router's bookkeeping (§5.2, §7).
bundle_backend = ""
bundle_prefix = "sandbox/"
bundle_ttl_secs = 60
[worker.sandbox.bwrap]
binary = "bwrap" # today hard-coded to "bwrap" on PATH (server/src/setup.rs); now a key
[worker.sandbox.oci]
engine = "podman" # podman | docker
binary = "podman" # the CLI; the runtime talks to it, never to a socket directly
image = "" # default: the worker's own image (§4.2)
# How the bundle reaches the container: a bind mount of the per-run
# directory ("mount", the default — same host, no storage round-trip) or
# the storage backend's presigned URLs ("storage", the kubernetes way).
transport = "mount"
# Extra flags appended verbatim — for a `--runtime=runsc` or a `--cgroup-parent`.
extra_args = []
[worker.sandbox.kubernetes]
namespace = "" # default: the worker's own namespace
service_account = "batlehub-sandbox"
image = "" # default: the worker's own image
runtime_class = "" # e.g. "gvisor" — kernel isolation per scan
node_selector = {}
image_pull_secrets = []
# Pods still around after this many seconds past their deadline are ours to
# delete, and so are their bundles: the orphan sweep (§4.2).
orphan_after_secs = 900
[scanners.postmortem]
command = "postmortem"
# On the image runtimes only: the image a run of this scanner is opened
# from. The recommended layout is one slim image per scanner (§5.3), and
# the chart ships them. Refused on `none` and `bwrap` (§4.3).
image = ""Nothing here is required. A config with today's [worker.sandbox] block — or none at all — loads and behaves as it does today.
4.2 Behaviour rules
One run, from the scanner's ask to the RunOutput or the ScannerError it becomes — the same on every runtime, only the instance differs:
A run is one bundle, one argv, one sandbox instance. A scanner asks the runtime to run a command over a bundle (§5.2) with a
networkflag and the limits, and gets the output back. The sandbox instance — thebwrapprocess tree, the container, the pod — exists for that run and no longer. Two runs never share a filesystem, on any runtime.Network is a property of the run. A scanner that needs both an offline and an online step —
postmortemwithtimeline = truerunsscanoffline andtimelineonline — makes two runs. Thetimelinerun carries an empty bundle: it queries the registry by name and never reads the artifact, so it never shares a filesystem with the process that did. This is stricter than today, where both run in the same directory. On the image runtimes with the storage transport, "no network" means no network but the storage endpoint — and, onkubernetes, the cluster resolver that names it (§5.2, §5.3, §7).The agent runs on every runtime, and it is not
batlehub.batlehub-sandboxis a separate binary from its own crate (§6.2): static, no config file, no environment read, no stdin, argv of exactly--in <url> --out <url>. It fetches the manifest, then each input the manifest names by URL, extracts archive entries under the manifest'sExtractPolicy, setsRLIMIT_ASandRLIMIT_CPU, installs the seccomp filter on the child (§7), clears the environment but forHOMEandPATH, spawns the argv directly (no shell, as today), caps stdout atSTDOUT_CAP_BYTES, keeps the tail of stderr, and uploads a result carrying the exit status. It exits 0 when it uploaded a result — a scanner that exited 1 is an answer — and non-zero only when it could not. Onnonethe same crate runs as a library inside the worker process (§6.3); the behaviour is identical, the walls are absent.The limits map per runtime.
[worker.sandbox]none/bwrapocikubernetesmemory_limit_mbRLIMIT_AS(agent)--memoryon the container,RLIMIT_ASinsideresources.limits.memory,RLIMIT_ASinsidecpu_secondsRLIMIT_CPU(agent)RLIMIT_CPUinsideRLIMIT_CPUinsidescanner timeoutkill the process tree podman killthenrmactiveDeadlineSeconds = start_timeout + timeout, then deletestart_timeout_secsn/a (start is a spawn) the container must reach the bundle fetch the pod must reach Runningand fetch its bundlemax_extracted_mb,max_entriesagent agent agent /work(the writable dir)a host TempDir--tmpfs /worksizedmax_extracted_mb, charged to--memorya MemoryemptyDirsizedmax_extracted_mb, charged to the memory limitA limit the runtime cannot raise is not an error — the agent's rlimits are best-effort where the sandbox's own cgroup already bounds the process, as today. On the image runtimes
/workis memory-backed, and a memory-backed mount is charged to the container's memory limit: what the scanner extracts and writes there competes with its own heap undermemory_limit_mb, which is why §4.3 refusesmax_extracted_mbat or above it.trivyis the scanner this bites: without a[scanners.trivy] endpointit downloads its database into--cache-dirunder/workon every run (today too, into the job'sTempDir), so a deployment on an image runtime either points it at the server the chart deploys or sizes/workfor the database.The default image is the worker's own; the recommended one is per scanner. With no
image, a run uses the image the worker was started from — read from theBATLEHUB_IMAGEenv that §6.5 adds to the Containerfiles — which carries the agent and every scanner. The chart's default values point each scanner at its slim image (batlehub-sandbox-postmortem,-trivy,-guarddog): the scanner, its runtime,batlehub-sandbox, and nothing else — nobatlehub, no shell. Every image a run uses must containbatlehub-sandboxat its wire version; the probe (§4.3) catches one that does not.A run that fails to start is a
ScannerError, not a skipped scanner. Under the defaultscanner_error = "quarantine"it holds the artifact, as any scanner failure does. The runtime never falls back to a weaker runtime on its own: akubernetesrun that cannot be scheduled does not become abwrapone.Orphans are ours to sweep — pods, containers and bundles. A worker that dies mid-run leaves a container or a pod behind, and on the storage transport a manifest, possibly an uncached artifact, and possibly an
out. Every sandbox instance carries a label with the worker id, the job id and the scanner name; every bundle key carries the same in its path. At startup and on every idle pass the runtime deletes the ones labelled with this worker id, and the ones older thanorphan_after_secsregardless of worker; bundle keys underbundle_prefixolder thanorphan_after_secsgo the same way, listed on the bundle backend itself — never through the storage router, whoselist_keysanswers from its own tables and has never seen a key a presigned PUT wrote (§5.2).noneandbwraphave--die-with-parentand aTempDir, and nothing to sweep.One run costs
memory_limit_mb + max_extracted_mb, and that number is what sizes the worker's sandbox pool. RFC 0018-bis derives the pool from the worker's memory budget divided by this cost (floor(budget / cost), at least 1) and runs at most that many sandboxes at once; every run holds one permit for its whole life,bwrapprocess tree, container or pod alike. Until 0018-bis lands the pool is one — the sequential loop the tree has today. Onkubernetesthe namespace'sResourceQuotais the operator's ceiling; the chart sets one fromreplicas × pool × cost, which is the most memory the fleet's sandboxes can hold at once, and the worker's ownresources.limits.memoryis what the pool is derived from.Embedded mode is unchanged.
roles = ["proxy", "worker"]withruntime = "kubernetes"works — the proxy pod's ServiceAccount then holds the pod-creating Role, which §7 recommends against; the docs say so and the chart only wires the Role to the worker Deployment.
The sweep, at startup and on every idle pass, decides per instance and per bundle key with two questions and no third:
4.3 Validation
At config load:
runtimeoutside{none, bwrap, oci, kubernetes}is refused with the list.nonekeeps RFC 0018's rule: refused outside tests unlessBATLEHUB_UNSAFE_NO_SANDBOX=1.runtime = "kubernetes", orociwithtransport = "storage", while the bundle backend (bundle_backend, else the default backend of[storage]) cannot presign (filesystem,in_memory) is refused: "the kubernetes runtime moves bundles through the storage backend and 'filesystem' cannot presign a URL; configure an S3-compatible backend, or name one inbundle_backend".bundle_backendnaming a backend[storage]does not declare is refused with the list. A sandbox with no way to receive its input is not a sandbox that fails later, it is a config error now. The artifact's backend is not checked: a cached artifact on a backend that cannot presign is uploaded to the bundle backend as if it were uncached (§5.2).- On the image runtimes,
max_extracted_mb >= memory_limit_mbis refused:/workis memory-backed and charged to the same limit (§4.2), so the scanner would have no memory left once the extraction filled it. [scanners.<name>] imageset whileruntimeisnoneorbwrapis refused: "imageon scanner 'guarddog' has no effect under runtime 'bwrap'; it is read by the oci and kubernetes runtimes". A key that is silently ignored is a deployment that believes it is running something it is not.[worker.sandbox.kubernetes]set whileruntime != "kubernetes"(and the same foroci) is a warning, not a refusal: the block is inert, and a config that is switched between runtimes by a layer (load_layered,docs/guide/configuration.md§ Layered config files) legitimately carries both.bundle_prefixmust be one path segment with a trailing/, and must not beblob/nor start withartifact:,local:ormeta:. Every key the proxy reads carries one of those three logical namespaces (proxy_artifact_key,artifact_storage_key, the metadata sibling) or is a physicalblob/<sha256>the deduplicating router writes; the one thing the sandbox may write must never share a prefix with any of them (§7). A registry's name cannot collide with the prefix — it sits behind the namespace, not in front of it.- On the image runtimes,
[scanners.<name>] commandis not checked against the worker'sPATHat worker setup (it is today, byrequire_commandinbuild_scanners): the binary lives in the scanner's image, and the probe checks it there.
At worker startup, the probe — once per room the config names: every distinct image on the image runtimes, every /opt/sandbox/<scanner> tree on bwrap (none skips it) — the runtime does one run with a bundle whose manifest says probe and network = false, and requires a result to come back at all and every assertion the agent makes from inside to pass:
| Assertion | How the agent checks | Runtime that would fail it |
|---|---|---|
| a result at all | out.tar exists once the sandbox has exited — the agent ran, fetched its bundle and could write back | bwrap on a host that restricts user namespaces (AppArmor): bwrap exits with setting up uid map: Permission denied and nothing runs — the failure PR #146 met, named on the first line with bwrap's stderr tail |
| no network | a connect() to each canary the manifest lists — the worker's own /livez listener, 1.1.1.1:53 — must not reach anything: ENETUNREACH/EACCES at once (no route in the namespace: bwrap, --network none) or a SYN that gets no answer within 3 s (a CNI that drops: kubernetes) both pass; a connection, or an ECONNREFUSED, is a packet that arrived, and fails. On the storage transport the bundle fetch itself proves the one allowed destination; a canary is an address, never a name, because DNS to the cluster resolver is allowed there (§5.3) | a pod whose NetworkPolicy is not enforced by the CNI, an oci engine whose --network none an extra_args overrode |
| read-only root | open(O_WRONLY) on /usr/bin/.probe fails with EROFS | a misconfigured oci --read-only |
| one writable dir | the work dir is writable, nosuid and nodev (from /proc/self/mounts) | oci with a plain bind |
| empty environment | the environ the agent received is HOME, PATH and, on kubernetes only, the KUBERNETES_SERVICE_*/KUBERNETES_PORT* set the kubelet injects for the API service whatever enableServiceLinks says — anything else fails; what the scanner receives is always exactly HOME, PATH, because the agent clears the rest before execve | oci with --env-host, a pod carrying env the chart did not render |
| unprivileged | geteuid() != 0, no capability in CapEff | a pod without runAsNonRoot |
| no credentials in reach | /var/run/secrets/kubernetes.io/serviceaccount/token absent; no DATABASE_URL/AWS_*/BATLEHUB_* in the environment | kubernetes with automountServiceAccountToken unset |
| no shell, no worker binary | no sh, bash, busybox or batlehub on PATH | a scanner image built FROM debian without pruning — a warning on the worker's own image (it has both, by construction), a failure on a per-scanner one |
| the scanner's command exists | every command the manifest lists for this image is an executable file | an image built for another scanner |
| the agent's wire version | the manifest's wire equals the agent's | a per-scanner image built from an older release |
| pid namespace | getpid() == 1 or the agent is the child of pid 1 | none (expected: none skips the probe and logs that it did) |
The probe's report also carries the SHA-256 of every command it found in the room, which is the scanner fingerprint RFC 0018-bis keys reusable results on: on the image runtimes the binary lives in the image, not on the worker's PATH, and the probe is the one place that has already opened it.
A failed probe is fatal: the process exits non-zero with the failed assertion on the first line and the runtime-specific hint on the second. batlehub_sandbox_ready{runtime} is 1 after a passed probe and 0 after a failed re-probe — the probe runs again on config reload (the runtime or an image may have changed) and every probe_interval_secs (default 600), and a worker whose re-probe fails stops leasing jobs and says so, rather than scanning behind walls that fell. A version on the queue then stays SCAN_PENDING until a worker with a sandbox takes it, which is RFC 0018's degraded mode, not a new one.
The probe's place in the worker's life — one path exits, the other only stops leasing:
5. Architecture
5.1 Two tiers, one new port
Job distribution was settled by RFC 0018: any number of worker processes lease from one PostgreSQL queue, and the Helm chart scales them on the queue. RFC 0018-bis sits one tier down: a worker that holds a job plans its scanner invocations and runs the sandboxed ones from a pool whose size is derived from memory. What this RFC adds is the tier below that: an invocation that holds a pool permit asks a runtime for a sandbox around its scanner process. The three tiers compose freely — three worker replicas on bwrap with two permits each, or one worker opening gVisor pods — and none knows the others' choice.
The port is in crates/core (ports/sandbox.rs) rather than beside the scanners in crates/adapters, for the same reason ArtifactScanner is: the invariant it carries — the worker never executes artifact-supplied code with credentials in reach — is RFC 0018 §7's, a domain rule, and the startup probe that enforces it is a worker concern, not a scanner's. The scanners are the port's consumers; server is what wires an implementation into them. The types that cross the port are data (Bundle, RunSpec, RunOutput) and core needs no I/O to define or test them.
5.2 The bundle and the storage hop
Today a scanner writes files into a host temp dir and hands the sandbox a path. That is the one thing a pod cannot use. The port therefore takes a bundle: a manifest.json naming each input (a relative path, whether it is a plain file or an archive to extract there, and where to get it), the ExtractPolicy to extract under, the argv, the limits and the timeout. Small inputs — the synthetic lockfile, a probe's canary list — are inline in the manifest; the artifact is a URL. postmortem's bundle is the artifact as an archive input at the path its layout expects and a synthetic lockfile inline — the same two things materialise writes today, described instead of written. The result is a tar: result.json (exit status, byte counts, the stderr tail, the probe verdicts when the run was one) and stdout.
The manifest reaches the agent by URL, the inputs by the URLs it names, and the result leaves the same way. Two schemes, one agent:
file://— the local runtimes. The worker writesmanifest.jsonand the artifact into the per-runTempDir, the runtime makes that directory the sandbox's one writable mount, the agent readsfile:///work/manifest.jsonand the inputs beside it and writesfile:///work/out.tar, and the worker reads it after the sandbox exited. No socket, no pipe: stdout of the agent is not read at all.https://— the storage transport. The worker asks the storage backend for presigned URLs, each valid forstart_timeout + timeout + bundle_ttl:- a GET on the artifact's own blob when the artifact is cached — which is the common case, since a job is queued by a request that just fetched it. The logical key (
artifact:<registry>/<name>/<version>) is resolved the way the deduplicatingStorageRouterresolves every read: throughartifact_dedup_refsto the physicalblob/<sha256>on the backend that recorded it, or to the legacy key on its backend. The presigned GET is on that physical object. No copy: the pod reads exactly the bytes it is scanning, read-only, and nothing else — a blob shared by every logical key with the same content, which a read cannot tell apart and cannot alter. When that backend cannot presign (filesystem), the artifact takes the uncached path below; - a GET on
<bundle_prefix><job>/artifactwhen it is not — the worker uploads it there once per job, and every run of the job's scanners names the same URL (RFC 0018 §6.3 keeps the worker from writing an uncached artifact to the cache; this is not the cache); - a GET on
<bundle_prefix><job>/<scanner>/<attempt>/manifest.json, a few KiB the worker writes per run; - a PUT on the sibling
out.tar, with aContent-Lengthceiling.
Two strings on the agent's argv — the manifest's GET, the result's PUT — are the whole of what the sandbox is given; the artifact's URL is inside the manifest. The agent fetches, runs, uploads. The worker watches the sandbox to completion, reads
out.tarthrough its own storage credentials, deletes the per-run keys (and the per-job artifact when the job closes), and only then parses the result. The sandbox never holds a credential, never reaches the worker, reads one or two objects it was going to read anyway, and can write exactly one object at one key the proxy never reads. A 500 MiB artifact that is in the cache costs the storage hop nothing but three GETs.- a GET on the artifact's own blob when the artifact is cached — which is the common case, since a job is queued by a request that just fetched it. The logical key (
The storage transport in order, for one run whose artifact is cached. The file:// transport is the same sequence with the leaf backend replaced by the per-run TempDir and no presigning:
StorageBackend gains presign_get(key, ttl) and presign_put(key, ttl, max_bytes) for this; the S3 backend implements them (the SDK's presigning is local, no round-trip), the filesystem and in-memory backends return Unsupported. The StorageRouter implements presign_get only, by resolving the logical key to its physical blob and backend as retrieve does. Bundle keys never go through the router. A presigned PUT lands on the backend with no bookkeeping row, so the router's list_keys, stat_by_prefix and dedup tables would never know the key existed; the bundle module therefore holds the leaf backend bundle_backend names (the default backend when unset, resolved once from [storage] at build time) and does every bundle operation on it directly — the manifest write, the per-job artifact upload, the out.tar read, the deletes, the sweep. The artifact's blob and the bundle keys may thus sit on different backends, each presigning its own.
The PUT is presigned with a Content-Length ceiling of STDOUT_CAP_BYTES plus the result's own bounds. SigV4 signs whichever headers the signer includes, so the ceiling is a signed Content-Length the backend refuses to exceed where it honours signed headers (S3, MinIO); because that is a property of the backend and not of the design, the worker HEADs the result before reading it and refuses one above the ceiling regardless. The agent cannot make the worker read more than it will.
trivy's bundle is the extracted tree as an archive input — or, when the job carries an SBOM, the SBOM inline as sbom.cdx.json and no archive at all — and its argv keeps --cache-dir /work/.trivy, so its database lands in the one writable place, as it does today.
The probe run is a bundle whose manifest says so: no argv, the canary addresses to try, the commands to stat; the result carries the verdicts of §4.3.
5.3 The four runtimes
The four rooms side by side — the same agent and the same scanner in each, and only the walls around them differ:
none— the agent crate as a library, called in-process on aTempDirwithfile://URLs; the argv is spawned by the worker process. Whatruntime = "none"does today, with extraction moved into the same code path as every other runtime. Skips the probe and logs that it did.bwrap— today's argv (bwrap_argv) with two changes: the command after--isbatlehub-sandbox --in file:///work/manifest.json --out file:///work/out.tar, and the read-only root is the scanner's own tree, not the host's:--ro-bind /opt/sandbox/<scanner> /instead of--ro-bind / /.Containerfile.workerpopulates/opt/sandbox/<scanner>with aCOPY --from=<the scanner's sandbox image> / /opt/sandbox/<scanner>per scanner — the same multi-stage that builds the per-scanner images of §6.5, so the tree is by construction what akubernetespod would see: the scanner, the agent, no shell, nobatlehub. Every other flag stays. The work dir is a freshTempDirbind-mounted as the only writable mount, as today; the agent extracts into it, from inside.oci— onepodman run(ordocker run) per run:textpodman run --rm --name batlehub-sbx-<job>-<scanner>-<attempt> --label io.batlehub.worker=<worker_id> --label io.batlehub.job=<job> --network none # or the engine's default when network = true; # with transport = "storage", a network the # storage endpoint is reachable from (§7) --read-only --tmpfs /work:rw,nosuid,nodev,size=<max_extracted_mb>m -v <run_dir>:/bundle:rw,nosuid,nodev,U # transport = "mount": manifest + artifact in, out.tar out; # `U` chowns the bind to the uid `--userns auto` maps (podman); # docker has no `U`: the runtime creates the per-run dir # writable by the uid the daemon's remap gives 65532 --cap-drop ALL --security-opt no-new-privileges --security-opt seccomp=default --userns auto # podman; docker: the daemon's userns-remap or nothing --memory <memory_limit_mb>m --pids-limit 256 --cpus 1 --env-host=false -e HOME=/work -e PATH=/usr/local/bin:/usr/bin:/bin -w /work --user 65532:65532 <extra_args…> <image> batlehub-sandbox --in file:///bundle/manifest.json --out file:///bundle/out.tarArgv, never a shell; the CLI, never the socket (§7); no
-i, the container's stdin is closed. The runtime kills the container on timeout and removes it on close; the sweep lists by label.kubernetes— one Pod per run, through the API server with the worker's in-cluster credentials (kubecrate, behind thesandbox-kubernetescargo feature so the proxy build does not carry it):yamlapiVersion: v1 kind: Pod metadata: generateName: batlehub-sbx-<scanner>- namespace: <namespace> labels: app.kubernetes.io/name: batlehub app.kubernetes.io/component: sandbox io.batlehub/worker: <worker_id> io.batlehub/job: <job> io.batlehub/network: storage # or "egress": what the NetworkPolicies select on spec: restartPolicy: Never activeDeadlineSeconds: <start_timeout + scanner timeout> serviceAccountName: batlehub-sandbox automountServiceAccountToken: false enableServiceLinks: false # no *_SERVICE_HOST env leaking the cluster's shape runtimeClassName: <runtime_class> # when set securityContext: { runAsNonRoot: true, runAsUser: 65532, seccompProfile: { type: RuntimeDefault } } containers: - name: sandbox image: <image> command: ["batlehub-sandbox", "--in", "<presigned GET>", "--out", "<presigned PUT>"] env: [{ name: HOME, value: /work }, { name: PATH, value: /usr/local/bin:/usr/bin:/bin }] workingDir: /work resources: { limits: { memory: <memory_limit_mb>Mi, cpu: "1" }, requests: { memory: <memory_limit_mb>Mi, cpu: 250m } } securityContext: { allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, capabilities: { drop: [ALL] } } volumeMounts: [{ name: work, mountPath: /work }] volumes: - name: work emptyDir: { medium: Memory, sizeLimit: <max_extracted_mb>Mi }The runtime creates the pod and watches it (bounded by the deadline) to
SucceededorFailed; then readsout.tarfrom storage; then deletes the pod with a zero grace period and the per-run keys. There is no attach, no exec, no stdin: the worker's Role needspodscreate/get/list/watch/delete and nothing on any subresource. The pod's network is decided by two NetworkPolicies the chart installs: onio.batlehub/network=storage, egress to the storage endpoint only (a CIDR, or an FQDN rule where the CNI has them — the chart takesworker.sandbox.kubernetes.storageEgressand renders whichever it is given) plus UDP/TCP 53 to the cluster's DNS pods, because the presigned URL names the endpoint by host and the SigV4 signature covers that host, so the agent must resolve it and the worker cannot substitute an address; onio.batlehub/network=egress, the same plus the allowlist RFC 0018 §7 describes forpostmortem.online— and fortrivy, which always runs with the network (its server, or the database download). The pod selector is the label, so the runtime never touches a policy. Two consequences for the operator: the S3 endpoint in[storage]must be a name the sandbox namespace resolves (minio.batlehub.svc, notminio, when the namespaces differ), and anemptyDirwithmedium: Memoryis charged to the container's memory limit (§4.2).A pod per run costs a schedule and, on a cold node, an image pull. That is accepted (§8): the per-scanner images are small,
imagePullPolicy: IfNotPresentwith digest-pinned references makes the pull a one-time cost per node, and a scan is not on any request's path.
What the chart renders for runtime = "kubernetes", and who may do what to whom:
5.4 Proposed, not designed
Each of these is a runtime the port accommodates without change. They are listed so the port's shape is judged against them, and so a deployment that needs one knows what it would be asking for.
| Runtime | What it is | What it buys | What it needs | Verdict |
|---|---|---|---|---|
remote | A sandbox daemon (batlehub sandboxd) on another host: the worker hands it the two presigned URLs and a RunSpec, it opens the sandbox with whichever local runtime it has (bwrap, oci) and reports the sandbox's exit | A worker on a host that cannot sandbox uses one that can | A listener, an auth scheme (mTLS or a shared bearer), capacity/queueing on the daemon side | Rejected (§8): its one real case is a developer's macOS or Windows machine, and oci over podman machine already covers it |
nsjail | A sibling of bwrap with a richer built-in seccomp/cgroup surface | cgroup limits without a container engine | The same unprivileged userns bwrap needs, or root | Candidate, same host requirement as bwrap so it does not solve §2's second bullet; one file when someone wants it |
systemd-run | A transient unit with DynamicUser, PrivateNetwork, ProtectSystem=strict, cgroup limits | On a plain VM with systemd, most of bwrap's walls plus cgroup limits | The system manager — root or a polkit rule for the worker; under --user, PrivateNetwork falls back on user namespaces, the requirement it was meant to escape | Rejected (§8): rootless Podman is one package and oci gives more with fewer conditions |
microvm | Firecracker or Cloud Hypervisor per run, driven directly | Kernel isolation without Kubernetes | /dev/kvm on the worker host, a rootfs image, a VM lifecycle in the runtime | Not here. On Kubernetes, runtime_class = kata on the kubernetes runtime is the same thing with the lifecycle handled |
wasm | Scanners compiled to WASI, run in-process under wasmtime | No process, no kernel surface | The scanners to exist as WASI modules — postmortem and trivy are native, GuardDog is Python | Rejected for the current toolchains; the port would carry it if that changed |
6. Detailed design
The crates and the direction of every dependency — core ← adapters ← server as today, the wire crate under all of them, and the agent that depends on the wire crate and nothing else of ours:
6.1 crates/core
ports/sandbox.rs— the port:rust/// A runtime that can put a process behind walls (RFC 0022 §5.1). #[async_trait] pub trait SandboxRuntime: Send + Sync { /// `none`, `bwrap`, `oci`, `kubernetes` — the `runtime` label of the metrics. fn name(&self) -> &'static str; /// One run: create the sandbox, get the bundle to the agent, wait for /// the sandbox to end, bring the result back. Bounded by /// `start_timeout + spec.timeout`. async fn run(&self, spec: RunSpec) -> Result<RunOutput, SandboxError>; /// The startup probe (§4.3), once per image the config names. async fn probe(&self, image: Option<&str>) -> Result<ProbeReport, SandboxError>; /// Remove what a previous worker left behind (§4.2). async fn sweep(&self, older_than: Duration) -> Result<usize, SandboxError>; } pub struct RunSpec { pub bundle: Bundle, pub argv: Vec<String>, pub timeout: Duration, pub network: bool, pub limits: Limits, /// The image runtimes' override; `None` is the runtime's default. pub image: Option<String>, /// For labels, names and bundle keys: job id, scanner name, attempt. pub label: RunLabel, } pub struct RunOutput { pub status: Option<i32>, pub stdout: Bytes, pub stderr_tail: String }Bundle,Manifest,Entry,RelPath,ExtractPolicyandResultare re-exported frombatlehub-sandbox-wire(§6.2), whichcoredepends on.ScannerErrorgainsFrom<SandboxError>: a run that timed out starting isTimeout, a probe or start failureCrashed, a malformed resultOutput.ports/storage/backend.rs—presign_getandpresign_putwith a default body returningCoreError::Unsupported, so every existing implementation compiles and only S3 answers.services/scan_worker.rs— takesArc<dyn SandboxRuntime>; callsprobe()for every image before the first pass and on reload,sweep()at startup and on idle passes, and refuses to lease while the last probe failed. Exposesbatlehub_sandbox_ready. Everyrun()is made from a permit of RFC 0018-bis's sandbox pool, so the runtime never sees more runs at once than the memory budget allows. Nothing about jobs changes.
6.2 crates/sandbox — the agent, and its wire
Two new workspace members, sized for what runs inside the wall:
batlehub-sandbox-wire(crates/sandbox/wire) — the bundle and result formats, as data and nothing else:Manifest,Entry,RelPath(refuses.., absolute paths and separators other than/at construction),ExtractPolicyas the policy (the limits, moved fromcrates/adapters/src/scanners/extract.rs), the result schema and the wire version. Its dependencies areserde,serde_json,bytes: no I/O, no async, no network, no config. Depended on bycore,adaptersand the agent — andcorestays what it is, a crate with no I/O, which it would not if the extractor and itstar/flate2/zipcame in with the types.- The extractor that applies the policy — the rest of
extract.rs, with its tests — moves to the agent crate, the only place that extracts once §4.2 holds.adaptersreaches it through the agent's library entry (thenoneruntime), never throughcore. batlehub-sandbox(crates/sandbox/agent) — the binary, and a library entryrun(in_url, out_url) -> ExitCodethenoneruntime calls in-process. Built statically (x86_64-unknown-linux-muslandaarch64-unknown-linux-musl,panic = "abort", LTO, stripped) so a scanner image needs no libc, no loader, no/lib. Beyond the wire crate it carries the extractor'star,flate2andzip, a minimal HTTP client (ureqwithrustlsandwebpki-roots, for the two presigned calls and nothing else) andseccompiler(a pure-Rust BPF compiler; nolibseccomp). What it does not have:clap(two flags, parsed by hand),tokio,tracing(its diagnostics go intoresult.json, nothing is logged), anybatlehub_*crate but the wire, any read of the environment, any file it did not fetch. The fence is a secondcargo deny check bansintask security, rooted at the agent's manifest (--manifest-path crates/sandbox/agent/Cargo.toml, without--workspace) and reading its owncrates/sandbox/agent/deny.toml, whose[bans].denyrefusesbatlehub-core,batlehub-adapters,batlehub-config,reqwest,tokioandclap. A[bans]entry in the workspace'sdeny.tomlis graph-wide and cannot say "not in this crate's tree", which is why the fence has its own file. The surface cannot grow back by accident.- The agent's own sequence: parse argv → GET
in(or read the file) → parsemanifest.jsonwith size caps checked before allocation → GET each input the manifest names (or read it beside the manifest), bounded by the size the manifest declared for it → extract under the policy into/work→ drop exec bits → set rlimits → install the seccomp filter (§7) →execvethe argv withHOME,PATHonly → collect capped stdout and the stderr tail → writeresult.jsonandstdoutintoout.tar→ PUTout(or write the file) → exit. On a probe manifest (a field, not a flag — the agent has two flags), the middle is the assertions of §4.3 instead of anexecve.
6.3 crates/adapters
sandbox/mod.rs— a new module besidescanners/, one file per runtime:none.rs,bwrap.rs,oci.rs,kubernetes.rs(featuresandbox-kubernetes, on in the default set likeregistry-*), plusbundle.rs(writing the manifest and, when needed, the artifact to aTempDiror to the leaf bundle backend of §5.2; asking the router to presign the artifact's blob when it is cached; readingout.tarback; the sweep of keys — all of it on the leaf backend, never the router) andlocal.rs(whatsubprocess.rshas today for the process the runtime spawns —bwrapor the engine CLI: the kill on timeout, the stderr drain,work_dir). The agent's stdout is never read; a local runtime's success is the sandbox's exit plus the presence ofout.tar.scanners/subprocess.rsshrinks tocommand_existsandparse_json, which are about scanners, not sandboxes.bwrap.rskeepsbwrap_argvand its test (the_bwrap_argv_is_the_sandbox_the_rfc_describes), with the two assertions that change: the--ro-bindsource is/opt/sandbox/<scanner>, and the command after--is the agent. Every other flag the test asserts is asserted still.oci.rsandkubernetes.rseach expose their builder as a pure function —oci_argv(&OciConfig, &RunSpec, &Urls) -> Vec<String>,pod_manifest(&KubernetesConfig, &RunSpec, &Urls) -> Pod— tested without an engine or a cluster, exactly asbwrap_argvis tested withoutbwrap.storage/s3/backend.rs—presign_get/presign_putover the SDK'spresigned()request builders; the PUT carries theContent-Lengthceiling.storage/router/mod.rs—presign_getonly: the logical-to-physical resolutionretrievedoes, then the leaf backend's.presign_putstaysUnsupportedon the router, on purpose (§5.2). The router also exposes the leaf backend for a name, which is howbundle.rsgets its.scanners/postmortem.rs,guarddog.rs,trivy.rs— holdArc<dyn SandboxRuntime>instead ofSandbox;materialisebecomesbundle()and returns the entries it used to write.postmortem'stimelinebecomes its own empty-bundle, network-on run (§4.2). The mapping of exit codes and the JSON parsing do not change.
6.4 server
builders.rs—build_sandbox_runtime(&SandboxConfig, &StorageRouter), returninganyhow::Result<Arc<dyn SandboxRuntime>>: the onematchonSandboxRuntimeKind, and the resolution of the leaf bundle backend. The default image resolution of §4.2 lives here.setup.rs—build_scannersbuilds the three binary scanners with that result instead of theSandboxstruct it assembles today, and skipsrequire_commandfor them on the image runtimes (§4.3).- The worker startup sequence gains the probes before the first pass, with the exit semantics of §4.3. No new subcommand:
batlehubnever runs inside a sandbox.
6.5 helm, images, docs
values.yaml—worker.sandbox.runtime,worker.sandbox.kubernetes.*(includingstorageEgress) rendered into the worker'sconfig.toml; when the runtime iskubernetes: aRole(pods: create, get, list, watch, delete — in the sandbox namespace only, no subresource), itsRoleBindingto the worker's ServiceAccount, thebatlehub-sandboxServiceAccount (automountServiceAccountToken: false), the two NetworkPolicies of §5.3, and aResourceQuotasized fromreplicas × pool × (memory_limit_mb + max_extracted_mb), the pool being RFC 0018-bis's derived number (one until it lands). Per-scannerimagevalues default to the slim images at the chart'sappVersion. Theworker.securityContextcomment that recommendsCAP_SYS_ADMINorruntime = "none"is replaced by a pointer to this runtime.helm-docsregenerates the README (task helm:docs).Containerfile.sandbox-<scanner>— one per binary scanner,FROM scratchwhere the scanner is static (postmortem,trivy),FROM gcr.io/distroless/python3for GuardDog: the scanner, its data files, the agent, a/workmount point,USER 65532. No shell, no package manager, nobatlehub. Built and scanned by the same workflows as the worker image (image-scan.yaml,build.yaml), attested the same way.Containerfile.worker*— gainbatlehub-sandboxbesidebatlehub, one/opt/sandbox/<scanner>tree per scanner copied from the per-scanner image stages (thebwraproot of §5.3), and theBATLEHUB_IMAGEbuild-arg for the default-image rule.docs/operations/scan-worker.md— "What the scanners run under" becomes a section per runtime with its host requirement and its probe hint;docs/guide/configuration.mdgains the sub-tables;docs/contributing/ security-scanning.mdgains the bundle, the agent and the storage hop.
6.6 Observability
batlehub_sandbox_ready{runtime}gauge (0/1).batlehub_sandbox_runs_total{runtime, outcome}—ok,start_timeout,run_timeout,crashed,result_invalid.batlehub_sandbox_run_seconds{runtime, phase}histogram —start(to the bundle fetch),run,collect(result read + cleanup).batlehub_sandbox_orphans_swept_total{runtime, kind}—pod,container,bundle.- Spans: one per run, with the runtime, the scanner, the image and the pod or container name as fields, so a stuck pod is found from a trace.
7. Security considerations
The invariant does not move: the worker never executes code supplied by the artifact, and nothing that opens the artifact holds a credential. What this RFC changes is who opens the archive (the agent, inside the walls, on every runtime), what is inside the walls with it, and how many kinds of walls there are.
Who holds what, and what crosses the wall — there is no arrow from the scanner to the worker because there is no channel: no socket, no pipe, no attach, no stdin.
What an escaped scanner finds. The design question the agent answers is "a scanner broke out of its process — what is in the room?" Per runtime:
| In the room | bwrap | oci / kubernetes, per-scanner image |
|---|---|---|
| Binaries on the read-only root | the scanner, batlehub-sandbox — /opt/sandbox/<scanner> is the root, not the worker's | the scanner, batlehub-sandbox; no shell, no libc on the static images |
| Processes | the agent (pid 1 of the namespace), the scanner | the same |
| Environment | HOME, PATH | HOME, PATH |
| Credentials | none | none; the SA token is not mounted |
| Network | none, or the pod's egress when network = true | the storage endpoint; plus the allowlist when network = true |
| Writable | /work | /work |
| What the agent holds | file:// paths under /work | presigned URLs: GETs already consumed (the manifest, the artifact), one PUT on one key |
| Kernel | the host's, shared with the worker | the host's, or gVisor/Kata's per run |
The bwrap column used to be the worker's whole userland — batlehub, every scanner, a shell, apt's leftovers — because --ro-bind / / is what RFC 0018 shipped. Binding the per-scanner tree instead costs one COPY per scanner in the worker image and makes the room the same on every runtime. Nothing batlehub links is reachable from inside anywhere.
The presigned URLs are the sandbox's only reach, and they are bounded. The GETs are on the manifest and on the artifact — the artifact's own dedup blob when it is cached, a per-job copy under bundle_prefix when not — and are consumed before the scanner starts; a GET on a dedup blob is a read of the one object the sandbox was handed to scan, and a presigned GET cannot be turned into a write or a list. The PUT is on one key under bundle_prefix, valid for the run's deadline plus bundle_ttl, with a Content-Length ceiling. The proxy never reads under bundle_prefix — every key it reads carries artifact:, local: or meta:, or is a blob/<sha256> the router wrote, and §4.3 refuses a prefix that touches any of them — so a hostile PUT cannot poison the cache; it can only replace this run's result, which the worker parses as hostile data regardless. Nor can it reach the router's bookkeeping: a presigned PUT writes an object, never a dedup row, so nothing the sandbox writes is ever resolved as an artifact. Both keys are deleted when the run is collected, and swept if it never is. The URLs are visible in the pod spec to anyone with pods read in the sandbox namespace, which is one more reason that namespace is dedicated and the Role is scoped to it.
The agent is small on purpose, and kept small by a gate. Its dependency tree is bounded by cargo deny (§6.2); its release artifact is attested with the images; the probe's wire-version assertion keeps an old agent from running under a new worker. It reads nothing it did not fetch and logs nothing: an attacker who owns the agent owns a process that can do one PUT.
The agent installs a seccomp filter on the scanner. RFC 0018 §6.3 promised one for bwrap and phase 3 did not ship it (bwrap --seccomp takes a compiled BPF program). With seccompiler the agent compiles and installs the filter itself before execve, on every runtime, enforcing from phase 1. The list is a deny list, and it is Docker's default profile's: ptrace, mount/umount2/pivot_root, keyctl/add_key/request_key, bpf, io_uring_*, unshare, setns, clone with any CLONE_NEW* flag, process_vm_readv/writev, perf_event_open, userfaultfd, kexec_*, reboot, init_module/finit_module/delete_module, open_by_handle_at, mknod, ioperm/iopl, personality, acct, quotactl, swapon/swapoff. A deny list rather than an allow list because the Go runtime and CPython each touch dozens of syscalls that vary by version, and an allow list would be maintained by chasing regressions; Docker's list runs trivy and GuardDog in containers every day, so the chance it breaks a scanner is as low as such a chance gets. The action is SECCOMP_RET_ERRNO(EPERM), not KILL: a scanner that trips it gets a readable error, exits, and the agent reports the exit and the stderr tail in result.json, rather than a signal nobody can attribute. On the image runtimes the filter stacks on the engine's default profile, which is the same list applied twice.
Per runtime, what the worker gains as power and what bounds it:
kubernetesgives the worker the power to create pods. A compromised worker could then run any image in that namespace. Bounds: the Role is namespaced (a dedicatednamespaceis the recommendation, and the chart's default whenworker.sandbox.kubernetes.namespaceis set); the Role has no subresource (no attach, no exec, no log); the sandbox ServiceAccount has no token and no Role; the namespace carries theResourceQuotaand, where the cluster enforces Pod Security Standards, therestrictedlabel — every pod §5.3 creates satisfiesrestricted, which is why it setsseccompProfile,runAsNonRootand drops all capabilities explicitly rather than relying on defaults. A pod the worker creates cannot mount a Secret it cannot read, and the worker's own Role grants no Secret access.- The storage endpoint is inside the wall. A pod that can reach S3 can try any request against it; without credentials every one but the two presigned ones is a
403. The NetworkPolicy limits egress to that endpoint's address and to the cluster resolver on port 53 (§5.3), so the storage hop does not open the cluster; the resolver is the one shared service inside the wall, and what it answers is names, not bytes. Where the storage endpoint is a public cloud service, the policy is a CIDR or an FQDN rule per the CNI; the docs give both forms. ociwith Docker means a socket, and the socket is root. The runtime drives the CLI so the choice of transport is the operator's, but a worker that can reachdocker.sockcan do anything Docker can. The docs say what this means:ociis for the bare-VM and Compose deployments, with rootless Podman as the recommended engine; on Kubernetes the answer is thekubernetesruntime, never a mounted socket.- The image is part of the trust boundary. A run executes whatever
imagenames. Images are pinned by digest in the chart and the docs refuse tags in the recommended config; the probe'sno shell, no worker binaryand wire-version assertions catch an image that is not what the release built. - Extraction inside the walls closes the one place RFC 0018 §7 accepted the worker opening the archive itself. A bomb that
ExtractPolicywould have refused is still refused; one that exploits the decompressor now does so with no credentials, the storage endpoint as its only destination, and one writable directory. - The result is a new parser on hostile data, and it is parsed by the worker after the sandbox is gone: fixed schema, sizes checked before allocation, the stdout bytes handed to the scanner's existing JSON parsing with the same discipline as today. There is no live socket between the two, which is what the attach design would have added and this one does not.
- A probe that passes once is not a wall that holds forever. The re-probe interval and the refusal to lease on failure are what turn a CNI upgrade that stops enforcing NetworkPolicy, or a node reboot that flips a sysctl, into a metric and a stopped worker rather than into unsandboxed scans.
- Fail-closed stays. A run that cannot start is a scanner error, which under the default policy holds the artifact. There is no runtime fallback chain, by design: a deployment that configured gVisor pods and got
bwrapbecause the API server was slow would be a silent downgrade.
8. Alternatives considered
- A pod per job, running the whole scan out of process — the worker submits the job to a pod that fetches the artifact, runs every scanner and writes the findings. Rejected: the pod then holds storage credentials (to fetch) and either database credentials (to write) or a callback channel to the worker with its own auth; it duplicates the worker's loop; and it is the tier RFC 0018 already has — a pod per job is a worker replica, and the queue already distributes those. The per-invocation tier keeps the sandboxed thing credential-free and the scanners where they are.
- A live channel to the sandbox —
pods/attachwith stdin, a framed protocol over it. The first draft of this RFC. Rejected: it gives the worker a socket into the sandbox and the sandbox a socket into the worker, which is a parser on a live stream fed by the process that just ran the scanner; it needspods/attachin the Role; it holds an API-server connection open for the length of every scan; and it ties the transport to Kubernetes' streaming protocol. The storage hop has none of that: two presigned URLs, a pod that ends on its own, a result read after the fact. The cost is a storage backend that can presign, which every Kubernetes deployment of this project has. - One long-lived sandbox pod with
pods/execper run. Saves the cold start. Rejected: a reused sandbox is shared state between jobs; a scanner that left a file, a process or a mount behind contaminates the next scan; and the network decision cannot change between execs. - A warm pool of pods created ahead and used once. Rejected for now: a pod that existed before its job is a pod that could have been tampered with before its job, and the cold start it hides is seconds on a path no request waits on. Revisited only if a measured p50 makes the queue fall behind.
- Kubernetes
Jobobjects instead of Pods. Rejected: retries are the queue's (max_attempts), not the Job controller's; a Job adds controller latency, a TTL to manage and a second object to sweep. - Ship the bundle some other way — a ConfigMap (1 MiB cap), a PVC (
ReadWriteManyis not a given, and a shared volume is shared state), an init container that pulls from the worker over HTTP (a listener on the worker, and the worker inside the wall's egress). The storage backend is the one thing every deployment already has, already secures, and already puts artifacts on. - The agent as a
batlehubsubcommand. Rejected: it puts the whole worker binary — every adapter, every client, the config parser,clap,tokio— inside the wall, and reads config-shaped things by construction. A separate static binary with acargo denyfence is what keeps the room small. - Keep
Sandbox { runtime: String }and add branches. That is what a port replaces. Theociandkubernetesbranches would each be several hundred lines inside one function ofscanners/subprocess.rs, tested through the scanners rather than on their own. nsjailinstead of adding runtimes. It would give cgroup limits, but it hasbwrap's host requirement and solves none of §2's second bullet. Listed in §5.4 as a candidate sibling.- A
remotesandbox daemon. A worker on a host that cannot sandbox would hand the run to a daemon on one that can. Rejected:kubernetesandocicover every deployment this project targets, and the one host left — a developer's macOS or Windows machine — runsocioverpodman machinetoday. A listener with its own auth and capacity model, for no deployment that asked, is surface without a user. systemd-runas a runtime. A transient unit withDynamicUser,PrivateNetwork,ProtectSystem=strictand cgroup limits, to sandbox on a plain VM without user namespaces. Rejected: those directives do their full work only through the system manager, so the worker needs root or a polkit rule; undersystemd-run --user,PrivateNetworkfalls back on user namespaces, which is the requirement it was meant to escape. Rootless Podman is one package, andocigives more with fewer conditions.- Ship the artifact inside every run's bundle. The first draft of §5.2: one
in.tarper run carrying the artifact. Rejected once the cost was written down — three scanners on a 500 MiB artifact is 1.5 GiB up and 1.5 GiB down per job — in favour of a manifest that names the artifact by URL, which is the artifact's own blob whenever it is cached, and a per-job copy underbundle_prefixonly when it is not. - Keep
--ro-bind / /forbwrap. The existing behaviour, and the first draft of this RFC left it. Rejected: the per-scanner images of §6.5 exist anyway, and copying their trees into the worker image is one multi-stage line per scanner for a room that no longer containsbatlehub, a shell or the other scanners.
9. Rollout and compatibility
Which runtime a deployment ends up on, from where the worker runs:
- No config change is needed.
runtimedefaults tobwrap; every existing[worker.sandbox]key keeps its name and meaning; thebwrapbinary, hard-coded tobwraponPATHtoday, becomes[worker.sandbox.bwrap] binarywith that default.BATLEHUB_UNSAFE_NO_SANDBOXkeeps its semantics. - Three behavioural changes on
bwrap, two onnone: extraction happens inside the agent, and the agent installs the seccomp filter (both runtimes); the read-only root a scanner sees underbwrapis its own tree, not the host's. The limits are the same, the refusals are the same, the findings are the same; what an operator may notice is that a bomb now shows as a sandboxCrashedrather than an extractor error in the worker log, and that a scanner which read something off the host root it should not have needed — there is none known — would now fail its probe. - The worker image gains
batlehub-sandboxand one tree per scanner under/opt/sandbox/, whichbwrapneeds. A worker image without them fails the probe with the assertion named. - The probe is new and can stop a worker that started yesterday. A deployment whose
bwrapnever actually held (the AppArmor case) has been holding every version asSCAN_PENDING; after upgrade its worker refuses to start and says why. That is the intended outcome, and the release notes say so with the hint per runtime. There is no flag to skip the probe:BATLEHUB_UNSAFE_NO_SANDBOX=1withruntime = "none"is the documented way to run without walls, and it already says "unsafe". - Operator prerequisites, per runtime.
oci: the engine CLI on the worker host, and for rootless Podman a/etc/subuid//etc/subgidrange for the worker's user (--userns autoneeds one). Rootless Podman creates a user namespace too, so on a host that restricts them it lives or dies by the distribution's AppArmor profile forpodman, exactly asbwrapdoes by its own; the honest answer on such a host is the rootful engine or Kubernetes, and the docs say so.kubernetes: a presignable bundle backend, an S3 endpoint the sandbox namespace resolves, a CNI that enforces NetworkPolicy (the probe finds one that does not), and the Role the chart renders. - Rollback is
runtime = "bwrap"(or the previous release) and a restart; nothing about a job is persisted differently. What a downgrade leaves behind is at most a few keys underbundle_prefixfrom runs in flight, which the older release does not sweep; they are small, inert, and safe to delete by hand. - Helm — the chart's
worker.sandbox.runtimedefaults tobwrap; the RBAC, NetworkPolicy and quota objects only render forkubernetes. A chart upgrade with unchanged values renders the same manifests it did. - Images — three new per-scanner images join the release matrix; the proxy image is untouched.
10. Test plan
- Unit (
crates/sandbox/wire): bundle and result round-trip; every cap (max_entries,max_extracted_mb,STDOUT_CAP_BYTES) refuses before reading a payload;RelPathrefuses traversal, absolute paths and backslashes; the existingextract.rstests move here unchanged. - Unit (
crates/sandbox/agent): the library entry over aTempDirwithfile://URLs: extraction under policy, the rlimits, the environment, the exit-code mapping, the probe's assertions each made to fail once, a scanner that trips the filter reported with its exit and stderr tail;postmortem,trivyand GuardDog each run once under the filter in theocijob, which is where a scanner that needs a denied syscall is found. Acargo denycheck of the agent's tree against the fence, as part oftask security. - Unit (
crates/adapters):bwrap_argv's existing test with its--ro-bindand command assertions updated;oci_argvasserts--network none,--read-only,--cap-drop ALL,--env-host=false, no-i, no shell;pod_manifestassertsrestartPolicy: Never,automountServiceAccountToken: false,enableServiceLinks: false,readOnlyRootFilesystem, the memoryemptyDir, the network label, nostdin, and therestrictedPSS conformance — with a cluster nowhere near. The bundle writer against the in-memory storage backend, with presigning stubbed: a cached artifact presigns its blob and uploads nothing; an uncached one is uploaded once for a job of three scanners; the sweep lists on the leaf backend and finds a key the router'slist_keysdoes not. - Integration (
none): today'ssubprocess.rstests move to thenoneruntime and pass unchanged (a_scanner_that_hangs_times_out,stdout_above_the_cap_is_an_output_error_not_an_answer, …). - Integration (
oci):task test:sandbox-oci— locally under Podman, the enginetask coveragealready needs for Postgres and MinIO; in CI under the runner's Docker daemon, which is what theservices:oftest.yamlrun on and is rootful, so it does not depend on the user namespaces §2 says the runner restricts (engine = "docker"in the job's config, and the job says so). The job opens a real run against a locally built scanner image on both transports (mount, andstorageagainst MinIO with real presigned URLs), runs the probe, then apostmortemscan of a fixture, and asserts the findings equal thenoneruntime's on the same fixture. Also the failure modes: an image without the agent (probe fails on the version assertion), an image with a shell (probe fails on the room assertion), a container killed on timeout, an orphan swept, a bundle swept. - Integration (
kubernetes):task test:sandbox-k8son akindcluster in its own workflow (sandbox-k8s.yaml, on the same trigger as the heavy suites): the chart installed withruntime = kubernetesand MinIO in-cluster, the probe through a real NetworkPolicy (kind with Calico, so theno networkassertion and the storage-only egress are real), one scan, the orphan sweep after a killed worker, and aruntime_classcase skipped with a reason where the runner has no gVisor. - Heavy:
tests/heavy/quarantine.shtakesHEAVY_SANDBOX_RUNTIME; the client-facing claims of RFC 0018 §13.4 are re-proven underoci. - Probe on the GitHub runner: a unit of the CI job asserts that with
kernel.apparmor_restrict_unprivileged_userns=1the worker exits non-zero naming thea result at allassertion withbwrap's stderr tail (setting up uid map: Permission denied) — the failure that cost PR #146 an afternoon becomes a test that the failure is loud. It is the start that fails there, not a wall:bwrapnever runs the agent. task fuzz:check:fuzz_sandbox_manifestandfuzz_sandbox_resultover the wire crate's parsers.
11. Decisions and open questions
Resolved
- The port is per invocation, not per job. §5.1, §8. The job tier is RFC 0018's and stays.
- The port lives in
crates/core. §5.1. It carries a domain invariant and the worker enforces it at startup. - Inputs cross the port as a bundle, never as a path. §5.2. This is what makes a filesystem-less runtime a peer of
bwraprather than a special case. - The agent is a separate static binary with no configuration surface, from its own crate, fenced by
cargo deny. §6.2, §7, §8. Two URLs on its argv; nothing from the environment, stdin or any file it did not fetch.batlehubnever runs inside a sandbox. - No live channel: the storage backend is the transport on the image runtimes, a bind mount on the local ones. §5.2, §8. The storage endpoint is inside the wall; the worker is not.
StorageBackendgains presigning; runtimes that need it refuse a backend that cannot. - A fresh sandbox per run; the cold start is accepted. §5.3, §8. No warm pool, no reuse.
- Extraction moves inside the sandbox on every runtime. §4.2, §7.
- Network is a run property;
postmortem timelinegets its own run with an empty bundle. §4.2. - A failed probe stops the worker; a failed re-probe stops leasing. §4.3. No flag skips it. The probe checks the room (no shell, no
batlehub) as well as the walls. - No runtime falls back to another. §4.2, §7.
- A Pod, not a Job; watched, never attached; no subresource in the Role. §5.3, §8.
- Per-scanner slim images are the recommended layout and the chart's default; the worker's own image is the fallback. §4.2, §6.5.
- The seccomp filter is the agent's, on every runtime, enforcing from phase 1; its list is Docker's default deny list, its action
ERRNO. §7. It resolves the promise RFC 0018 §6.3 made forbwrapalone. A deny list, not an allow list: the runtimes behind the scanners vary their syscalls by version, and this list already runs them daily. - The manifest names the artifact by URL; a cached artifact is read from its own dedup blob, an uncached one is uploaded once per job. §5.2, §8. The storage hop costs a cached 500 MiB artifact nothing but GETs, so no measurement gates the design.
bwrapbinds the scanner's own tree as its root, not the host's. §5.3, §7, §8. OneCOPYper scanner in the worker image; the room is the same on every runtime.remoteandsystemd-runare rejected, not deferred. §5.4, §8.nsjailstays the one candidate sibling ofbwrap.- Bundle keys live on one leaf backend, outside the router's bookkeeping. §4.1, §5.2, §7. A presigned PUT writes no dedup row, so the router could neither list nor sweep them;
bundle_backendnames the leaf, andpresign_geton the router resolves a blob the wayretrievedoes. - This RFC defines the cost of one run; RFC 0018-bis decides how many run at once. §4.2, §6.1, §6.5. The cost is
memory_limit_mb + max_extracted_mb; the pool is derived from it and the worker's memory budget, and everyrun()holds a permit. The quota isreplicas × pool × cost. Until 0018-bis lands, the pool is one, which is the sequential loop the tree has today. - The probe's first assertion is that a result came back. §4.3, §10. The AppArmor failure is a start that never happens, not a wall that leaks; the probe names it with
bwrap's stderr.
Still open
- q1 — which scanner, if any, trips the filter. The deny list is one no container-run scanner is known to trip, and the only way to know for these three is to run them under it, which phase 2's
ocijob does. The answer is recorded here; if one trips, the syscall it needs is the question, not the list.
12. Implementation phases
Phase 1 is useful on its own even if nothing after it lands: the probe and the smaller bwrap room are its deliverables, and every existing deployment gets both.
- The wire crate, the agent with its seccomp filter, the port,
noneandbwrapbehind it, the probe. Same findings, same config, samebwrapflags but the root and the command.ExtractPolicyand the extractor move to the wire crate; extraction moves into the agent;subprocess.rsshrinks; the worker image gainsbatlehub-sandboxand the per-scanner trees (which means the per-scanner image stages land here, published or not); thecargo denyfence lands with the crate. The probe ships here with its exit semantics, and the GitHub-runner test of §10 with it. One PR. oci, the published per-scanner images, presigning. The runtime with both transports,oci_argvand its tests,presign_get/presign_puton the storage port, the S3 backend, the router'spresign_getand leaf-backend accessor,bundle_backend, the blob-or-upload rule of §5.2, the threeContainerfile.sandbox-*in the build and scan workflows,task test:sandbox-ociin the coverage job with each scanner run under the filter (q1 answered here), the docs section. The Compose deployment gets a sandbox that works wherebwrapdoes not.kubernetes. The runtime behindsandbox-kubernetes,pod_manifestand its tests, the chart objects,task test:sandbox-k8son kind, the docs section, the replacement of theCAP_SYS_ADMINcomment.