How a job is boxed
The content of a job is hostile, and so is whatever opens it: a library that decodes an image or reads a model’s reply can be exploited by what it reads. So sandcastle never opens a job itself. Each job is opened in a box made for it alone, with nothing in it worth taking and no way out, and the box is gone when the job ends.
sandcastle never runs a capability itself. Every job and every check goes to a box of its own,
which is the only place where the job’s content is opened: the input, the image, the model’s
reply. sandcastle and the box speak in JSON lines on the box’s stdin and stdout, one message per
line; sandcastle box <capability> is the box’s side.
Each box is a new rootless Podman container, podman run --rm -i, removed when it ends:
- no network, a read-only root, and
/tmpas its only writable folder, ofbox.tmp; - no folder of the machine mounted, no capabilities, no new privileges;
- the user
65534, which is not the operator’s on the machine, and an empty environment; box.memorywith no swap on top,box.cpus,box.pids, and a time limit;- its stderr never reaches the journal.
Nothing of a job goes in an argument or in the environment. Each box is named
sandcastle-box-<random> and labelled sandcastle.box=sandcastle, and sandcastle.role=core or
sandcastle.role=check for who started it. When a box runs past its time, sandcastle removes it
by name and confirms it is gone, since ending podman run leaves the box alive; no other box
starts until it is. When run or once starts, it removes the core’s boxes a crash left behind,
never the box of a check running alongside.
The images are sandcastle-ocr:<build> and sandcastle-claude:<build>. The build is the first
12 characters of the SHA-256 of the sandcastle binary, and each image holds that same binary, so
a box of another build is refused. sandcastle version shows the build and the commit. The
binary must be a static Linux binary (CGO_ENABLED=0).
The limits of each capability’s box can be lowered or raised in its box block; check warns of
a limit above the default, which is outside the promise. No key opens a lock. The defaults are in
config.yaml, for claude and
for ocr.
- sandcastle reads only a job’s
id,capabilityandinput, and passes the input on untouched. Other fields in the envelope, such as adestinationor aurl, have no effect. - Each capability has a
max_inputand amax_output, and atimeoutfor its box (the source’sjob_timeoutalso applies). A line from the box that is not JSON, of an unknown type, one too many, larger thanmax_output, a box that runs out of time, memory or processes, or one that exits with another code than 0, fails the job. - A missing Podman, a box Podman cannot create (a missing image, for one) and a box of another
build release the job, and the capability rests for its
cooldown. - What the box says about a failure, and what it writes on stderr, is kept cut short in the job’s record and never in the journal.