What this Runner does to contain untrusted code, what it deliberately does not do, and where the boundary that actually holds is. Written for the person who has to deploy it.
The one sentence to take away: the boundary is the host, not the container. Everything below narrows what a submission can reach; none of it makes the evaluation host safe to share with anything else.
Not as a slogan — as a deployment instruction.
- No secrets on it. No Server credentials, no registry tokens, no
.env, no keys for anything but the Runner's own identity. - Nothing else on it. Not the Server, not the database, not a reverse proxy for something you care about.
- Reproducible. You should be able to destroy and rebuild it without thinking, because that is the response to any suspicion.
- No lateral reach. It should not be able to open a connection to anything in your network that it does not need. It initiates one outbound connection, to the Server, and nothing else.
The reason is §3.
Each step of the pipeline runs in its own container, started by the Runner through the container runtime's API. One container per test — two where the package brought a checker or an interactor — and never reused. State carried between tests is a thing untrusted code tries, and a fresh container is the only answer that does not depend on cleanup having been written correctly.
The two containers never share a directory (2026-09-05). Both run as the same unprivileged user, so anything they shared would be a place each could reach the other's: the submission's own channels are in one directory mounted only into its container, and what the checker is given is in another mounted only into the checker's. Nothing is passed between them directly — every byte is copied by the Runner, which is what keeps the counting, the output cap and the early stop in trusted code.
| Applied to every step that runs a submission | |
|---|---|
--network=none |
no route anywhere |
--cap-drop=ALL |
no capabilities |
--security-opt=no-new-privileges |
and none may be gained |
| read-only root filesystem | writes go nowhere it covers — see /dev/shm below |
--user 65534:65534 |
never root, even inside |
memory.max with memory.swap.max zero, on a cgroup holding the submission and nothing else |
without the second the limit means nothing: the program swaps instead of being killed. The cgroup is a child of the container's, made by the shim: it counts the submission, everything it forks and every tmpfs page it writes, and not the container's own start. The container keeps a --memory of its own, above the problem's because it is above it in the tree. Two sources say a kill happened and either is enough — see below |
--pids-limit |
a fork bomb hits a wall |
| the host's cgroup namespace, on a judged run alone | the shim has to name its container's own cgroup to make the submission's underneath it, and /proc/self/cgroup reads / in a private namespace. Without this the shim cannot make the cgroup its memory limit goes on, and refuses to start the submission rather than run it unlimited. It grants no write: the only cgroup file the container can reach is the one bound for it, which is root's. What it costs is that /proc/self/cgroup names the host's path rather than / |
--cpus |
one processor's worth per second. The threading hole is closed by the accounting as well — cpu.stat sums the subtree, so threads spend the budget faster rather than escaping it |
--cpuset-cpus, only where the Runner was given a set |
an operator's division of the host, carried to the job containers, which inherit no affinity of their own. Given the whole machine the Runner pins nothing: several Runners choosing processors with nothing coordinating them is worse than letting the host place the work |
| wall clock = four times the limit plus four seconds without progress | not a limit anybody is judged against: a time limit is processor time, so this reaps what is not spending any — one stuck in an uninterruptible syscall, or one that waits for input that never comes. It counts consecutive time: any processor time at all resets it, so a program descheduled on a busy host is never reaped for it. Four times the limit is roughly a host loaded four times past what it can carry, and the four seconds are what make this the guard against a hang at a limit small enough that four times it would not be |
| processor time past the limit plus two seconds | there is no reason to keep waiting; the verdict is still decided afterwards on the measurement. Added rather than multiplied: the reading is the cgroup's, so it carries the container's own start, and a container costs what it costs whatever limit the problem set |
| wall clock past five times that ceiling, whatever the progress | the last bound there is, and the only one a program making steady progress can reach — one waking for a millisecond a tick never stalls and never approaches its limit. Five times what the run was entitled to spend, so a problem allowed more processor time has earned more wall clock. It is reported as its own outcome, because "no processor time for four seconds" is false about a run that spent some in every one of them |
| an output cap counted as the bytes cross | 2026-09-05. Nothing stores a submission's output any more: it goes from the program to whatever is comparing it, one 64 KiB copy at a time, and the count is taken there. It used to be RLIMIT_FSIZE on a file, and before that a count of what the daemon had already written to its own log — 76 MB of it, measured, for one flooding submission against a 64 MiB cap. What the cap now bounds is what one submission can make the Runner hold, and it is reached only by output with no separator in it: anything else is decided wrong long before |
| nothing on the container's own streams | 2026-09-05. A judged container is started with no log driver at all, its output travels on a pipe, and its standard error goes to /dev/null. There is nothing for the daemon to write down, which is what removed the write amplification above; and an image with no measuring shim can no longer judge, because for it that silence would be indistinguishable from a program that printed nothing |
No step that runs a submission is given a tmpfs. The two profiles that ask
for one are the submission build and the checker build, and both ask for a
writable root on the next line — so the two properties are mutually exclusive
across the pipeline.
A running submission's only writable path is /dev/shm, which the contract below
describes as a surface nobody declared and rule 3's test names explicitly.
A memory kill is told from two places. The container runtime reports
OOMKilled on the container, and the kernel counts it in memory.events — read
beside memory.peak, and read by the Runner from a cgroup of its own that
outlives the container. That file is hierarchical, so a kill inside the
submission's own cgroup is counted there without anything in the container being
able to reach it. Either source is enough, and neither is checked against the
other.
The kernel's half is two fields and needs both, because each alone says the wrong thing — the definitions are the kernel's own:
| Field | "…" | Alone it would |
|---|---|---|
oom_kill |
the number of processes belonging to this cgroup killed by any kind of OOM killer | blame a submission for a host that ran out of memory |
oom |
the number of time the cgroup's memory usage was reached the limit and allocation was about to fail | report a limit reached but survived — measured on one slice after a term of judging, oom 845 against oom_kill 843 |
Together they are a program killed for exceeding the limit it was given.
memory.events and not memory.events.local, because the container runs in a
child of the cgroup this reads and only the first is hierarchical.
Two sources because the runtime's flag is not reliable on its own. It has
been observed reporting OOMKilled false for a container that exited 137
after 117 ms on a systemd-driver host. That is not a missing number but a
wrong verdict — a memory limit told to a participant as a runtime error —
and Stopped::Memory is the only route to that verdict, so nothing else would
catch it. It does not recur in 25 attempts, so it is a rare race in the runtime
rather than a broken flag; a second opinion is the cheap answer to a rare one.
Every row has a test in crates/aj-sandbox/tests/adversarial.rs, and each
asserts two things: the program was stopped correctly, and the host is
unchanged afterwards. A sandbox that contains a program by leaking a process
has not contained it.
The memory row is proved in two places, because it holds two things. That a
container over its limit is killed is adversarial.rs; that a submission over
the limit its problem stated is Memory limit exceeded — at a limit smaller than
the runtime would accept on a container, for a tmpfs write, and for what a forked
child spent — is aj-standard-io/tests/judging.rs, where a submission is what is
being judged.
One of those four taught something that outlives it. The obvious capability
test — assert the effective set is empty — is no test at all here. Measured with
cap_drop deleted and nothing else changed: CapInh, CapPrm, CapEff and
CapAmb stay all zeros, because the kernel clears them on the drop to uid 65534.
Only CapBnd moves, to 00000000a80425fb. The bounding set is the assertion
that gates, and it is also the one that matters: it is the ceiling on what may
ever be regained.
Two more, from the pipeline rather than the sandbox:
- The build gets no writable host path. It writes to its own container layer and the artefact is read back through the runtime API. The alternative was opening a directory to every user on the host.
- A package-supplied checker is sandboxed too. It comes from a problem author rather than from the platform, and it runs with its own limits and no network, never in the Runner's process.
Four rules. They are cheap to hold and expensive to notice the loss of, so each has a test rather than a paragraph.
-
One test, one container, never reused. A fresh container is the only answer that does not depend on cleanup having been written correctly.
-
The program is given its own test's input and nothing else. One file:
<name>.in, mounted read-only and opened by the measuring shim, which is handed exactly two paths — what to read and what to write. Mounting the wholetests/directory would put<name>.out— the answer — inside the submission's own container. Seepipeline.rs::input_mount, andjudging.rs::a_judged_submission_cannot_read_the_answer_key, which proves it from inside a container: a submission that callsopenon every test file the package has reaches its own.inand nothing else — not its own.out, and not another test's anything. Its interactive twin reaches nothing at all,/inincluded. -
Nothing a program writes reaches the next test. Asserted in
adversarial.rs::nothing_survives_from_one_run_to_the_next, for the scratch tmpfs and for/dev/shm. -
The checker is contained on the same terms, and so is an interactor. Same
Sandbox::run, so the same table above applies to it, with its own limits. One stopped by a limit is reported as a broken checker, never as a wrong answer — a bug in the package must not become a rejected submission.The one difference is that it opens no measurement: a judged run holds the cgroup gate for its whole length, and on the systemd cgroup driver that gate is an owned mutex, so a program beside it asking for one of its own would wait for a run that is waiting for it.
That was two differences until 2026-09-05, and this document named one. The
--cpuset-cpusrow above was applied to the judged run alone, so on a host an operator had divided, every build and every judge floated across the whole machine while the program being measured sat on its one processor. All five containers carry the set now, andpipeline.rs::every_container_this_pipeline_starts_is_confined_to_the_runners_processorsis what keeps a sixth from being added without it — a source check, because the behaviour is only observable on a machine that has been divided up, which neither a developer's nor CI's is.
Two things that follow, and are easy to get wrong in the opposite direction:
-
/dev/shmis writable and the profile does not ask for it. The runtime mounts a 64 MiB tmpfs there in every container and a read-only root filesystem does not cover it. It breaks none of the four — it is new with the container, and tmpfs pages are charged to the cgroup that writes them, which for a submission is the cgroup carrying its memory limit, so a program spending it is spending its own budget and is killed for spending too much. Measured: a submission writing 64 MiB there under a 32 MiB limit isMemory limit exceeded. It is still a surface nobody declared, which is why rule 3's test names it explicitly. -
The input is a mounted file, not a pipe — for a batch problem. A pipe would be marginally stricter and is deliberately not used: it is not seekable, so a solution that reads its input twice would work on the author's machine and fail here. The access surface is already one test either way, so the stricter option buys nothing and costs a participant a verdict they cannot explain.
For an interactive problem it is a pipe, and consciously so (2026-09-05). There is no file to mount: the input does not exist until an interactor decides what to send, having read what the submission wrote. The cost above is real and is paid — a solution cannot re-read its input — and it is inherent to the problem type rather than a choice this document is making.
-
The submission's output is a pipe in every case (2026-09-05), and this is where the paragraph above stops applying: nobody re-reads their own output. It is read once, forwards, by the Runner, which either compares it token by token or copies it onward to a checker. What that buys is the point of it — a wrong answer is found at the first differing token and the program is stopped there, instead of being left to produce an answer nobody was going to look at.
The Runner holds the container runtime's socket, and anything that can reach
that socket is root on the host. It can start a privileged container that
mounts /. That is not a flaw in the arrangement; it is the arrangement.
Three consequences, stated so nobody has to discover them:
- Mounting the socket read-only restricts nothing that matters. The flag applies to the socket file, not to the API spoken over it.
- A bug in the Runner is a host compromise, not a container compromise. The Runner is trusted code; the containers it starts are where the untrusted code goes, and they never get the socket.
- This is reducible, not safe. The path runs through an API that can be proxied, scoped, moved to rootless Podman, or pushed onto a separate machine reachable over mTLS. Each of those narrows it. None removes §1.
Privileged Docker-in-Docker. The Runner would run --privileged and start a
nested daemon. The privilege the infrastructure needs then becomes the
privilege available to anyone who escapes the inner sandbox — which is exactly
how the Judge0 CVE chain turned an arbitrary-file-write bug into host
compromise. isolate was not the flaw; running it inside a privileged container
is what made the flaw fatal.
Passing the socket into the submission container. This hands untrusted code the host directly. It is not a weaker version of the sibling model; it is a different mistake, and the rule is one line for code review: the socket goes to the Runner and to nothing the Runner starts.
Job containers are siblings, so they outlive the process that made them. The Runner labels every container it starts and sweeps orphans at startup; without that, a crash-loop fills the evaluation host with dead sandboxes until it runs out of disk.
It is a policy control, and the difference is not pedantry.
It exists to catch a violation of the activity's rules early, so a
participant learns they broke a rule instead of finding out from a results
table. It runs before the build, gives PolicyViolation with score 0, names the
rule, and leaves the submission rejudgeable.
Every rule in it is expected to be bypassable: token pasting, macro
indirection, runtime name construction with dlsym, inline assembly, raw
syscalls, encoding tricks. This is the project's own conclusion from its
engineering thesis, and it is the consensus elsewhere — none of isolate,
nsjail or sio2jail implements source filtering at all.
A submission that gets past it is still contained by §2. A document that suggests otherwise is teaching a false sense of safety.
Checked at start; the Runner refuses without it. docs/CGROUP_V2.md states the
minimum a host has to satisfy and how to check it; this section is the reason.
The limits are enforced on v1 — measured, not assumed: a container over its
memory limit is OOM-killed there and OOMKilled is reported. What v1 cannot do
is measure: memory.peak and cpu.stat are v2 interfaces, and isolate
2.x dropped v1 outright.
That is a refusal to judge, not a missing number. A time limit is decided on
processor time read from cpu.stat, so the refusal covers three conditions:
cgroup v1, a cgroup driver this Runner knows neither of, and a cgroup tree it
cannot use.
Measuring at all needs the Runner's own container to run as root under
cgroupfs, and only the peak-memory number needs it under systemd — the
tree's directories are root's, and cgroupfs is the backend that creates one.
Running as root is a decision rather than a slip: the Runner holds the container runtime's socket, which is root-equivalent on the
host by §1 of this document, so the uid inside its container was never the
boundary. Nothing it starts gains anything by it — a job container still runs
as 65534:65534 with every capability dropped.
AJ_Sandbox__AllowUnmeasured starts anyway — and only starts. Such a Runner
registers and answers the protocol and then fails every job it claims with an
infrastructure error, which is what a conformance suite needs and all it needs.
It is off by default and shouts at ERROR on every start, because a quiet
override is a production setting waiting to happen. AJ_Sandbox__AllowCgroupV1
is the old name and is still honoured.
Stated so that absence is not read as a decision:
-
A custom seccomp profile. Docker's builtin profile applies and does real work — measured 2026-08-09: it refuses
keyctl,add_key,bpf,perf_event_open,mount,setns,io_uring_setup,open_by_handle_atandunshare(CLONE_NEWUSER), which without the profile succeeds.Check that it is on.
docker infomust reportname=seccomp,profile=builtin, and a process in the sandbox must showSeccomp: 2in/proc/self/status. Docker Desktop 24.0.7 on this workstation reportedprofile=unconfined— no syscall filtering at all, while this document claimed otherwise. Upgrading to 29.x turned it on. A profile nobody verified is a profile nobody has.What a custom profile would add, with one concrete reason.
isolate's own filter is allow-by-default with four rules, and Docker already covers three:keyctl,AF_VSOCKandio_uring_setup. The fourth it does not cover is file locks —flockandfcntlwithF_SETLK/F_OFD_SETLK/F_SETLEASE. Locks are shared across mount-namespace boundaries on a shared inode, and that is demonstrated here, not theoretical: two containers with the same volume mounted read-only see each other's locks, one holding a shared lock and the other refused an exclusive one.Corrected 2026-08-09: our sandboxes do not share an inode, so this does not reach us. An earlier version of this section said two submissions to the same problem share the mounted tests, because the package is cached per problem version. What is cached is the archive; the extraction target is
Scratch::new(…, job_id), a directory per job, so two concurrently judged submissions unpack their own copies and lock nothing in common. The channel is real where a file is shared — which is why it is written down — and the architecture that would expose it is one where the unpacked package is mounted from the cache. It is not.So the lock rule has no concrete driver here today, and a custom profile goes back to being defence in depth without a named threat. What would bring it back is a change nobody would think of as a security decision: mounting the unpacked package from the cache instead of unpacking per job, to save the copy. That would be a sensible-looking performance change and would open a contestant-to-contestant channel.
Decided 2026-08-09: the property is pinned instead of the syscall.
run.rs::two_jobs_mount_nothing_in_commonasserts that every path a sandbox mounts comes from the job's own scratch, and it was checked by breaking the invariant on purpose and watching it fail.Denying
flockwould close one road and leave POSIX record locks,F_NOTIFYand anything else two processes can do to one inode. What protects us is that there is no shared inode to hold, so that is what is guarded.The other half of the trade is what a custom profile costs. Docker's
--security-opt seccomp=replaces the builtin rather than extending it, so ours would have to carry everything measured above —default.jsonis a generated artefact, ~830 lines, published under a moby tag and absent from master. A vendored copy that falls behind does not leave us standing still: Docker improves its builtin between releases and ours would not, so the sandbox would get quietly weaker than doing nothing. That is defensible only with a CI job that fails when the two diverge, and it is a cost worth paying for a different reason than this one — a Runner accepting problem types from outside our control, where the mount layout is no longer ours to promise. -
isolateas a deeper supervisor. Not adopted — the spike ran on 2026-08-09 and is indocs/spikes/ISOLATE.md. It works in a non-privileged container, needingCAP_SYS_ADMINandCAP_NET_ADMINover a self-delegated cgroup v2 subtree. It was wanted for honest CPU-time and peak-memory numbers, and those turn out to be readable from the sandbox container's own cgroup with nothing granted — so the remaining trade wasCAP_SYS_ADMIN, the capability that permits mounting, in a container that runs untrusted code. Reopening it needs a different argument than measurement. -
Rootless Podman, which is the more defensible posture for §3 and which the container client already speaks to.
This is a component that runs code written by people who are being marked. If
you find a way out of §2, or a way to reach the host that §3 does not describe,
say so before it is interesting: AlgoJudge-Runner issues, or privately to the
maintainers listed in AUTHORS.txt.