Basis Lab

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.

FIG 1The ten attempts as ten assertions
stepthe attemptwhat the check assertsexpected resultwhere it has to runwhat happened
1read a file holding credentialsa path outside the job image and the scratch folder cannot be openedrefused on ownership, nothing readinsideAn 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.
2open a connection outcreating an outbound socket is refused before any packet leavesrefused at the entry point, and zero bytes at the host counterinside and hostTwo 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.
3fill the diskwriting past 64 MB in the scratch folder failsout of space inside, host free space unchangedinside and hostThe 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.
4keep making processesthe thirty-third process is refusedrefused, and the group never exceeds 32insideCount 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.
5spin the processor foreverthe job ends at 2 seconds of processor timekilled, recorded against the processor capinside and hostWhich 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.
6allocate memory until the host suffersnothing outside the job is disturbedrefused inside at 256 MB, nothing outside killed or swappedhostThe 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.
7write outside the scratch folderevery path outside the scratch folder is read onlyrefused, read only file systeminsideCheck 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.
8read another reader's submissionno other job's input or result is present in the view at allnot present, which is different from refusedhostThis 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.
9reach the cloud metadata addressthe address is unroutable from the jobno route, and zero bytes at the host counterinside and hostThe 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.
10leave a process running after the answer was returnedthe job's process group is empty and its scratch folder is gonezero processes, folder absenthostMeasured 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.
10 steps
Read the fourth column. Four of the ten are answerable from inside, three need the host as the authority with the inside half as corroboration, and three have no inside answer. The eighth row is the one to remember: an expected result of refused would be a passing check that proves another reader's submission was within reach.

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.

FIG 2The three host side assertions, run once the result is stored
python
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)}')
Each assertion names the wall and the expected value, so a failure says which configuration is not where it is believed to be. Note that all three run after the result has been handed back, which is deliberate: these are the checks about what the job left behind, and a check that runs while the job is alive cannot make them.

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 contents

This 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

The rest of this course

  1. 01What a Submitted Program Can Actually Ask For
  2. 02Four Places the Boundary Can Go, and What Each One Stopsopening only
  3. 03Run It as Nobody, Owning Nothingopening only
  4. 04Five Ceilings, and the Twenty Cores They Implyopening only
  5. 05Give It No Route Out, Then Name the Exceptionsopening only
  6. 06Nothing in the Environment Worth Stealingopening only
  7. 07The Narrow Door, and Nothing Elseopening only
  8. 08Ten Checks, and the One Wall That Can Still Fallyou are here

Read alongside