Letting a Model Call Your Code Safely
Ten Checks, and the One Wall That Can Still Fall
Last timeWhat It Is Allowed to Ask For
The ten hostile attempts become ten assertions with an expected result each. Four can be answered from inside the sandbox, six need the host, and one failure brings down all of them at once.
Ten attempts, ten assertions
The walls are up. The submission runs as nobody, owning nothing, with 290
of the 350 entry points refused, 2 seconds of processor time, 10 seconds
of wall clock, 256 MB of memory, 64 MB of writing, 32 processes, one
writable scratch folder, no route off the machine, no secret worth having
inside the boundary, and one narrow door with no argument it chose.
None of that is known to be true. Every one of those is a line of
configuration, and configuration is believed rather than observed until
somebody observes it. The useful thing about having built the design
around ten named attempts is that the ten convert directly into ten
assertions, and each assertion has an expected result that can be written
down before anything runs.
The expected result is the part worth being careful about, and it is where
these harnesses usually go wrong. The tempting assertion is that nothing
bad happened. It is a bad assertion, because it is satisfied by a wall
that works, and equally by a harness whose check never ran, a
configuration that was never applied to this host, and a limit somebody
removed last month to debug something. All four produce the same silence.
So the expected result of every check is a refusal of a named kind,
counted at a named place. The refusal is the observable thing. Where the
request goes through the kernel's entry point filter, the filter can be
set to return an error, to record the attempt, or to end the process, and
which of the three happened is itself the evidence.
| step | the attempt | what the check asserts | expected result | where it has to run | what happened |
|---|---|---|---|---|---|
| 1 | read a file holding credentials | a path outside the job image and the scratch folder cannot be opened | refused on ownership, nothing read | inside | An ownership refusal, not an absence. If the expected result were written as nothing was read, the check would pass on a host where the file was simply not installed. |
| 2 | open a connection out | creating an outbound socket is refused before any packet leaves | refused at the entry point, and zero bytes at the host counter | inside and host | Two observations of one wall. The inside half proves the request was refused, the host half proves nothing left by any other route, and only the second is the authority. |
| 3 | fill the disk | writing past 64 MB in the scratch folder fails | out of space inside, host free space unchanged | inside and host | The host half is what distinguishes a quota from a wall. A quota that is accounted on the host rather than on the mount lets the job fail while the host still loses the space. |
| 4 | keep making processes | the thirty-third process is refused | refused, and the group never exceeds 32 | inside | Count the group rather than trusting the refusal, because a limit applied to the wrong scope refuses the thirty-third child of one parent and allows the thirty-third process. |
| 5 | spin the processor forever | the job ends at 2 seconds of processor time | killed, recorded against the processor cap | inside and host | Which cap was recorded is the assertion. A run that ends at 10 seconds of wall clock instead means the processor cap is not applied and the wall clock caught it. |
| 6 | allocate memory until the host suffers | nothing outside the job is disturbed | refused inside at 256 MB, nothing outside killed or swapped | host | The inside half is almost worthless here. The thing being asserted is about the host, and a job cannot observe the host choosing to kill somebody else's process. |
| 7 | write outside the scratch folder | every path outside the scratch folder is read only | refused, read only file system | inside | Check a path in each mount rather than one path, since a single read only mount proves one mount and the attempt is made against all of them. |
| 8 | read another reader's submission | no other job's input or result is present in the view at all | not present, which is different from refused | host | This is the one where the expected result carries the whole point. Refused means it was there and permission held. Not present means it was never in the mount table, and only the host can see the mount table. |
| 9 | reach the cloud metadata address | the address is unroutable from the job | no route, and zero bytes at the host counter | inside and host | The same pairing as the outbound connection, and worth its own row because this address is reachable by default on every cloud and is refused by a different rule from the one that refuses the internet. |
| 10 | leave a process running after the answer was returned | the job's process group is empty and its scratch folder is gone | zero processes, folder absent | host | Measured after the result was stored, which is the moment the sandbox no longer exists. There is nothing left inside to ask, so this check has no inside form at all. |
What cannot be checked from inside
The last column of that figure is not a note about convenience. It follows
from two facts about what a check inside a sandbox is able to see.
The first is that an inside check can only report on a sandbox that still
exists. Everything about what outlived the job happens after the only
observer has gone. That is the tenth row, and it is the attempt most
likely to be real rather than hostile, because a leftover process is also
what a teardown bug produces.
The second is that an inside check can only see what the sandbox was
shown. That is the point of the sandbox. So the mount table, the host's
free space, the host's own memory pressure and the bytes that actually
crossed the host's network edge are all outside the observer's view by
construction, and an inside check that reports on them is reporting on
something it was handed.
What to do about the six is the same move in each case. Pair the inside
assertion with a host observation and make the host observation the
authority, so that the inside half tells you the request was refused and
the host half tells you nothing happened anyway. Three of the six have no
inside half at all and are simply host checks, run at the moment the
result is stored.
def after_return(job):
left = processes_in_group(job.pgid)
assert left == 0, f'teardown left {left} processes in {job.pgid}'
assert not exists(job.scratch), f'{job.scratch} survived the job'
out = egress_bytes(job.sandbox_address)
assert out == 0, f'{out} bytes left a sandbox with no route out'
seen = sorted(mounts_visible_to(job))
assert seen == sorted(job.granted_mounts), (
f'the job saw {set(seen) - set(job.granted_mounts)}')Both halves run continuously rather than once. Continuously means on every
deploy of the runner, on every change to the base image or the kernel, and
on a schedule against the configuration that production is actually using,
because the thing being checked is a configuration and configurations
drift from the file that was supposed to produce them.
The lesson stops here
6 more paragraphs to go
You have read the opening. The rest of the argument, the problems that check whether it landed, and the lines worth keeping at the end all come with a plan.
The first lesson of every course in the library reads the whole way through, free, so you can see exactly what the rest of them are.
See the planThe contentsThis is the reading half
Starting the course gives you your own copy of it. Every idea on every page has problems standing under it, marked with a reason rather than a tick, and any sentence you do not believe can be opened and argued with. None of that can happen on a page nobody owns.
The contents