Introduction
sandcastle is a worker that runs jobs from sources you do not trust. It pulls self-contained jobs from configured sources, runs each with one capability from a closed toolbox, sends the result back and keeps a record. It only makes outgoing calls and listens on no port.
It started with a site I built that needed data living only inside my home network. sandcastle is how that data gets out safely, without exposing the home network.
The parts
flowchart TD
core["core<br/>no secrets"]
box["box<br/>one per job, no network"]
keep["keep<br/>holds the secrets"]
gate["gate<br/>the only way out"]
dest["destinations"]
core -- "stdin / stdout" --> box
core -- "Unix socket" --> keep
keep -- "sandcastle-link" --> gate
gate -- "sandcastle-egress" --> dest
The core, with no secrets, speaks to a box of its own for each job through its stdin and stdout,
and to the keep over a Unix socket; the keep, which holds the secrets, reaches the gate on
sandcastle-link; and the gate, the only way out, reaches the destinations on sandcastle-egress.
The promise
A source you configured, or the content of one of its jobs, cannot:
- read your keys or tokens;
- reach your internal network, by a redirect or by DNS;
- leave anything on your machine that outlives the job.
The promise says it in full, and the threat model says what lies outside it.
Where to go next
- To run sandcastle, start with Install and Your first job.
- To write a source, read How a source talks to sandcastle and The source contract.
- To judge its security, read The promise, the threat model and the test matrix.
- To look something up, go to the Reference.
Install
In this tutorial you build sandcastle from its most recent version, give it the example
configuration and put it up as a service of your user. At the end, sandcastle check says what
is ready, and the next tutorial, Your first job, runs a job through it.
You need a Linux machine that meets Supported systems, with Go and Git, and an account at the Claude Console. Run every command as yourself, never as root.
1. Get the most recent version
A version is a tag that passed every test of the promise. Anything between tags is work in progress.
git clone https://github.com/gravataLonga/sandcastle.git
cd sandcastle
git checkout "$(git tag --list 'v*' --sort=-v:refname | head -n 1)"
2. Build the binary
CGO_ENABLED=0 go build -trimpath -o dist/sandcastle .
./dist/sandcastle version
The binary must be static (CGO_ENABLED=0): the same file goes into every image. version prints
its build and the commit it came from.
3. Copy the example configuration
mkdir -p ~/.config/sandcastle/sources
cp examples/config.yaml ~/.config/sandcastle/
cp examples/sources/notes.yaml ~/.config/sandcastle/sources/
The example source, notes, runs on this machine in the next tutorial. Find this machine’s
address in your network:
ip -4 -brief address
Say it is 192.168.1.20. Open ~/.config/sandcastle/sources/notes.yaml and put that address in
internal:, keeping the port, as in internal: 192.168.1.20:8080. Every key is in
config.yaml.
4. Set the two secrets
Create an API key for sandcastle in the Claude Console, in a workspace of its own with a spend limit. The spend limit is the only cap on what sandcastle can spend, so do not skip it. See The key.
Give the key to sandcastle. It asks for it without showing it:
./dist/sandcastle secret set claude
notes takes any token you choose. Make one up:
head -c 32 /dev/urandom | base64 | ./dist/sandcastle secret set notes
Each prints secret <name> set.
5. Install
./dist/sandcastle install
It says each step on a line of its own: the configuration is valid, the machine has what it needs,
the four images are built, the binary is copied to ~/.local/bin/sandcastle, the units are
written, lingering is on, sandcastle.target is restarted and the keep is ready. Then it runs
sandcastle check.
If the configuration is wrong or the machine lacks something, install says what and changes
nothing. Fix it and run install again.
6. Read the check
The last lines are the check’s. Every line should be ok or warn but one:
FAIL source notes: notes cannot be reached: …
Nothing listens at that address yet. That is the next tutorial.
Make sure ~/.local/bin is on your PATH, so that sandcastle is the installed binary from now
on. Then go on to Your first job.
Your first job
In this tutorial you run notes, the example source, and watch sandcastle tag its notes with
Claude. You need the installation from Install, with its two secrets and the
address of this machine in sources/notes.yaml.
notes is the smallest source that works. It holds three notes in memory and hands each out as a
claude job that asks for one tag. It prints each tag that comes back. It uses only Go’s standard
library: its code is in examples/notes.
1. Start notes
In the folder where you built sandcastle, in a terminal of its own:
go run ./examples/notes -listen 192.168.1.20:8080 \
-token-file ~/.local/share/sandcastle/secrets/notes
Use the address you put in internal:. notes reads its token from the secret you set, and
listens only on that address, never on loopback, which sandcastle never opens. It prints:
… notes: 3 notes waiting at http://192.168.1.20:8080
Leave it running.
2. Check
In a second terminal:
sandcastle check
Among its lines are these:
ok keep: ready
warn source notes: internal exception: the gate lets 192.168.1.20:8080 through for notes.home.arpa
warn source notes: http:// is outside the promise: nothing encrypts the way to 192.168.1.20:8080
ok source notes: reachable, 3 waiting
ok capability claude: usable
ok capability ocr: usable
The keep reached notes through the gate, with the token, and notes says three notes wait. The
two warn lines are there because notes lives inside your network: the gate lets that one
address through for it. Reach a service inside your network says what that
opens. capability claude: usable means the Claude API took the key and knows the model.
Every line is explained in Check messages.
3. Let the core ask now
The core asked notes while nothing listened, and waits longer after each failure. Restart it, so
it asks at once:
systemctl --user restart sandcastle
4. Watch the tags come back
In the first terminal, notes prints a line for each note, such as:
note 1 tagged "recipes"
The tag is the model’s choice. The core takes one job each time it asks, every 30 seconds, so the three notes are tagged one after the other, within about a minute.
5. See what sandcastle kept
The core’s journal has a line for each job:
journalctl --user -u sandcastle -n 20
… msg=job source=notes job=1 capability=claude status=succeeded seconds=… error="" input_tokens=… output_tokens=…
Each job left a folder with its record:
ls ~/.local/share/sandcastle/jobs/notes/1
cat ~/.local/share/sandcastle/jobs/notes/1/meta.json
meta.json says "status": "succeeded", with the tokens the job spent. The files are described in
Files and folders.
What happened
For each note, the core asked the keep to claim a job from notes. The keep added the token and
sent the request out through the gate. The core started a box for the job, with no network and no
secret. The box read the job and handed back the prompt and the schema. The keep sent them to the
Claude API with the key. The box checked the reply against the schema, and the core sent it back
to notes.
Stop notes with Ctrl-C when you are done. It forgets its notes when it stops. To put your own
source in its place, see Add a source. To write one, see
How a source talks to sandcastle.
Add a source
A source is one file in sources/ of the configuration folder. Adding one changes no program.
-
Write
~/.config/sandcastle/sources/<name>.yaml:name: recipes destination: address: https://recipes.example.com/api/worker secret: recipes poll_interval: 30s capabilities: [claude] max_jobs_per_hour: 30 job_timeout: 6mEvery key is required; each is in config.yaml. List in
capabilitiesonly what this source needs.If the source lives inside your network, add
internal:as in Reach a service inside your network. -
Set the token the source gave you:
sandcastle secret set recipes -
Restart the three pieces. The gate makes its list of names from the configuration when it starts, the keep its destinations, and the core its sources:
systemctl --user restart sandcastle.targetA job running at that moment is stopped.
-
Check:
sandcastle checkYou want
ok source recipes: reachable, <n> waiting. Any other line is in Check messages.
Remove a source
- Delete its file in
sources/. - Restart
sandcastle.target:systemctl --user restart sandcastle.target. - Delete its secret:
sandcastle secret rm recipes.
Manage secrets
A secret is a key or a token that the keep adds to the requests of one destination. Each lives in
a file of its own in ~/.local/share/sandcastle/secrets, which only you can read. The value is
never written in the configuration: a destination names its secret in destination.secret.
Set a secret
sandcastle secret set notes
At a terminal, it asks for the value without showing it. Otherwise it reads stdin, so the value can come from a password manager:
pass show sandcastle/notes | sandcastle secret set notes
A value is never taken as an argument, where the shell’s history and ps would keep it.
Replace a secret
Set it again. The keep reads a secret for every request, so the new value is used from the next request on, with no restart.
List the secrets
sandcastle secret list
missing claude
used notes
unused old-token
used is named by a destination, unused is named by none, and missing is named but not set.
list never opens a secret.
Delete a secret
sandcastle secret rm old-token
It deletes the file without asking. A backup or a copy of the disk may still hold the value, so revoke the key or the token where it was made.
The rules
A name has only a-z, 0-9, - and _, starts with a letter or a digit, and has at most 64
characters.
The keep refuses a secret whose file:
- is a symbolic link, or not a regular file;
- is not yours, or other users have access to it;
- is empty, larger than 16 KiB, or holds a line break before its end or another control character.
sandcastle secret set writes files that keep these rules. When a file breaks one, check names
the secret and the rule, and nothing of its content. How the keep holds the secrets is in
How secrets are kept.
Reach a service inside your network
The gate refuses every address of your network, even when a public name resolves to it. To let one
destination reach a service inside, give it an internal exception: the ip:port its name stands
for.
Open the exception
-
In the destination, write the service’s address with a name of your choice, and its
ip:portininternal:destination: address: https://recipes.home.arpa/api/worker secret: recipes internal: 192.168.1.30:443The keep connects to that
ip:portand sends the name as the host. The name does not need to resolve. Withinternal, the address may behttp://.The
ip:portmust be one address of the internal network:10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,100.64.0.0/10orfc00::/7. A sealed address is refused: loopback, link-local, multicast, a translation range, or the installation’s own ranges. -
Restart the three pieces:
systemctl --user restart sandcastle.target. -
Run
sandcastle check. From now on it prints, every time:warn source recipes: internal exception: the gate lets 192.168.1.30:443 through for recipes.home.arpa
What it opens, and the risk
The exception opens that one address for that one destination. The gate lets it through, and the gate’s network gets a route to it. The route opens the whole IP; the port is held by the keep, which only ever asks for the port in the configuration. Another destination pointed at the same address fails at the keep’s TLS check before its secret is sent.
A job still cannot reach the service: a box has no network. What reaches it is the keep’s requests for that destination: the contract’s calls, with its token.
The service itself is outside the promise. If it is compromised, sandcastle does not protect you
from it. With http://, nothing encrypts the way to it either, and check says so:
warn source recipes: http:// is outside the promise: nothing encrypts the way to 192.168.1.30:443
Use https:// when the service can. The two barriers, and why an exception opens the whole IP at
the second, are in How the network is closed.
Close it
Remove internal from the destination, or the destination itself, and restart
sandcastle.target.
Upgrade
An upgrade is a new binary and sandcastle install again.
-
Read the changelog. A new major version means you edit the configuration, or a promise changed.
-
In the folder where you built sandcastle, take the most recent version and build it:
git fetch --tags git checkout "$(git tag --list 'v*' --sort=-v:refname | head -n 1)" CGO_ENABLED=0 go build -trimpath -o dist/sandcastle . -
Install it:
./dist/sandcastle installIt checks the configuration first. If it is not valid for the new version,
installsays what is wrong and changes nothing.While the four images are built, the running pieces keep working. Then
installputs the new binary in~/.local/bin/sandcastleby a rename, so a core still running keeps its old file. It writes the units again, restartssandcastle.targetand removes the images of older builds. It remembers the ranges you gave with--linkand--egress; you do not repeat them. -
Confirm:
sandcastle version sandcastle check
Keep your changes to the units
Change a unit with a drop-in, such as
~/.config/containers/systemd/sandcastle-keep.container.d/10-mine.conf. install never touches a
drop-in, so it survives every upgrade. A change made in a unit itself is lost at the next
install.
A drop-in that removes a lock of the keep or the gate, or adds a port or a capability, makes
check warn; see Check messages.
Uninstall
Remove the installation, keep your data
sandcastle uninstall
It stops sandcastle.target, then removes the units, the sandcastle-* images and the two
networks, and says each step. It keeps the configuration, the secrets, the jobs’ records, the
binary and your drop-ins, so sandcastle install puts everything back as it was.
Files and folders says what stays and what goes, path by path.
An image still in use stays, and uninstall names it and ends with a failure. Stop what uses it and
run uninstall again.
Remove everything
After uninstall:
rm -r ~/.config/sandcastle ~/.local/share/sandcastle ~/.local/bin/sandcastle
rm -r ~/.config/containers/systemd/sandcastle-*.container.d
uninstall leaves lingering on. Turn it off only if no other service of yours runs without a
session:
loginctl disable-linger
The secrets go with their folder, but a backup or a copy of the disk may still hold them. Revoke each key and token where it was made: the Claude key in the Claude Console, each source’s token at the source.
Troubleshoot
Start with sandcastle check. It prints one line per item: a FAIL line keeps the installation
from working, a warn line is something to know. Every line, and what to do about it, is in
Check messages.
Then look at the journals and at the job’s record:
journalctl --user -u sandcastle -f # the core: one line per poll that found work and per job
journalctl --user -u sandcastle-keep -f # the keep: one line per request, with destination, path and status
journalctl --user -u sandcastle-gate -f # the gate: what it let through and what it refused
ls ~/.local/share/sandcastle/jobs/<source>/<job id>/
install refuses the machine
this machine does not serve, nothing was changed:
…
Each line under it is one thing to fix, with the version found and the one needed. The least versions are in Supported systems.
-
The cgroup controller … is not delegated to your user. A box’s limits need the
cpu,memoryandpidscontrollers. Delegate them to every user’s systemd, as root:sudo mkdir -p /etc/systemd/system/user@.service.d printf '[Service]\nDelegate=cpu cpuset io memory pids\n' | sudo tee /etc/systemd/system/user@.service.d/delegate.conf sudo systemctl daemon-reloadThen log out and in again, and check with
cat /sys/fs/cgroup/user.slice/user-$(id -u).slice/user@$(id -u).service/cgroup.controllers. -
/etc/subuid has no range for you. Add one, as root:
sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 "$USER", thenpodman system migrate. -
The link or egress range is already in use. Choose two free IPv4
/24s with--linkand--egress.installremembers them. See Network names and ranges.
install stops halfway
After the images are built, install undoes nothing: it stops at the step that failed and says
which. Fix the cause and run install again.
systemd did not load sandcastle-keep.service: Quadlet found a problem in a unit; …
A drop-in of yours is the likeliest cause. The line gives the command that shows the problem.
The keep does not answer
FAIL keep: the keep does not answer at …/sandcastle-keep/keep.sock; start it with sandcastle keep
Under the installation, the keep is a service, so look at it and at the gate, which it waits for:
systemctl --user status sandcastle-keep sandcastle-gate
journalctl --user -u sandcastle-keep -u sandcastle-gate -n 50
The gate does not start when its preparation fails, for one when the configuration has no destination or cannot be read. The core waits for the keep without ending, and carries on by itself once the keep answers.
A source stopped answering
When the gate said no, check shows it at the end, with the likeliest cause:
warn gate: refused notes 3 time(s) in the last hour, last at 10:15:46 (…): the name resolves to the internal network; …
- The name resolves to the internal network. The gate refuses an address inside your network unless the destination has an internal exception. If the source does live inside, give it one, as in Reach a service inside your network. If it does not, its DNS changed.
- The name is not on the gate’s list. The configuration changed after the gate started:
systemctl --user restart sandcastle.target.
A source the gate refused is asked again at its poll_interval. One that cannot be reached is
asked again later each time, up to every 10 minutes; restart the core to ask it at once:
systemctl --user restart sandcastle.
A token or a key was rejected
FAIL source notes: the token was rejected; create a new worker token and set it with sandcastle secret set notes
Set the new value with sandcastle secret set. The keep reads it at the next request; nothing
needs a restart. A rejected Claude key releases the jobs and the capability rests for its
cooldown.
An image is missing or of another build
FAIL capability ocr: the image sandcastle-ocr:… of this build is missing; run sandcastle install
The binary changed and its images were not built, or the ocr languages changed. Run
sandcastle install. While a capability has no image, its jobs are given back (released) and
the capability rests.
A job failed
The job’s folder holds what happened: see A job’s record.
meta.json has the status, the error and the start of what the box wrote on stderr. The box’s
own words never go to the journal: they are hostile, and are kept only in the record.
How a source talks to sandcastle
A source is a service that has work for sandcastle: an HTTP API you write, inside your network or on the internet. This page explains how the two talk and why. The exact calls are in The source contract.
sandcastle starts every exchange
sandcastle listens on no port. It asks a source for work, and a source never calls it. So a source needs no way into the machine that runs sandcastle, and nothing on the internet can hand sandcastle a job unless the operator configured it as a source.
To the source, sandcastle is a worker with a token: requests that carry
Authorization: Bearer <token>. The operator sets the token as a secret, and only the keep ever
holds it.
A job carries everything
sandcastle knows nothing about a source’s data. A job is self-contained: it names one capability and holds that capability’s input, and nothing else is read. A source that wants a note tagged sends the note and the question in the job; it cannot point sandcastle at a file, a URL or another service.
The capabilities are fixed, and so is what each takes and gives back: see Capabilities. A source cannot choose a program, a model or an argument. The operator chose them.
The life of a job
- The core asks the source for jobs, naming the capabilities the operator allowed for it and that are not resting. It asks for one job at a time.
- The source hands out a job and holds it for this worker for as long as it decides.
- A box runs the job: made for it alone, with no network and no secret, and gone when the job ends.
- The core sends the result back to the source that handed the job out, under the job’s id:
succeeded, with the output;failed, with the error: the job itself is at fault, so offering it again is pointless;released: not now, and not the job’s fault. The capability could not be used, so the source should offer the job again later, without counting an attempt.
sandcastle trusts the source no more than the job
Every source is treated as hostile, even one you wrote. sandcastle never follows a redirect, reads only the fields of the contract, and keeps each source’s jobs, results and token apart from every other source’s. A source cannot learn another source’s token, see another source’s jobs or send sandcastle anywhere else.
The source should return the favour. An output is what a box made from the job’s content, and that content may be hostile: a model can be talked into a reply that looks like an instruction. Treat every output as untrusted, and check it has the shape you asked for before you use it.
How often a source is asked
- One job runs at a time, across all sources. A slow job delays the others.
- Each source is asked every
poll_interval. max_jobs_per_hourcaps how many of its jobs run in an hour. When it is reached, nothing more is claimed from it until the hour moves on.- A source that cannot be reached is asked again later each time, up to every 10 minutes. It never holds the others back.
- When the keep is down, or the source’s secret cannot be used, the source is left out of that
round and asked again at its
poll_interval.
These keys are in config.yaml.
The source contract
A source answers three calls under its destination’s address. Each call carries
Authorization: Bearer <token>, and every body is JSON. A source that works is
examples/notes: the
three calls, in one file that uses only Go’s standard library.
| Call | Request body | Answer |
|---|---|---|
GET /ping | none | 200 {"waiting": 2} |
POST /jobs/claim | {"capabilities": ["claude"], "limit": 1} | 200 {"jobs": [{"id": 41, "capability": "claude", "input": {…}}]} |
POST /jobs/{id}/result | {"status": "succeeded", "output": {…}, "started_at": "…", "finished_at": "…"} | any 2xx, or 409 |
With an address of https://notes.example.com/api/worker, the first call is
GET https://notes.example.com/api/worker/ping.
Ping
check calls it to prove the address and the token, and shows waiting, how many jobs wait. It
claims nothing.
Claim
capabilitieslists what the source may hand out now: those the operator allowed for it, minus any that is resting. Hand out no job for another.limitis how many jobs to hand out at most; sandcastle asks for 1.jobsmay be empty.- A job has three fields, and sandcastle reads no other. Any other field, such as a
destinationor aurl, has no effect.id: a number or a text, without slashes, backslashes, dots or spaces. A job without an id makes the whole answer unreadable.capability:claudeorocr.input: what the capability takes.
- A claimed job is held for this worker for as long as the source decides. sandcastle sends a result for every job it claimed, unless it stops in the middle.
Result
| Field | When | What it holds |
|---|---|---|
status | always | succeeded, failed or released |
output | succeeded | what the capability gave back |
error | failed and released | why, in a line of text |
started_at, finished_at | always | when the job started and ended, in RFC 3339 |
releasedmeans “not now, and not the job’s fault”: the capability could not be used. Offer the job again without counting an attempt.- Answer
409when the source no longer holds the job for this worker. sandcastle keeps the result in its record and does not send it again. - An output is untrusted. Check its shape before you use it.
Answers
401or403to any call means the token is wrong.checksays so, and names the secret to set.- Any other answer outside
2xxfails the call; sandcastle shows the first line of its body. - A redirect is never followed, and fails the call.
- Each call has 30 seconds. An answer may be up to 32 MiB.
The promise
sandcastle runs jobs from sources you do not trust. This page says, in plain words, what those sources cannot do to you. The threat model says it in full, with an id for each promise, and says what lies outside it. The test matrix links each id to the tests that prove it.
A source you configured, or the content of one of its jobs, cannot
- read your keys or tokens;
- reach your internal network, by a redirect or by DNS;
- leave anything on your machine that outlives the job;
- spend more than the jobs per hour you allowed it;
- see or change the jobs of another source.
A program running in a box cannot
- read your keys or tokens;
- reach any network;
- leave files or processes behind;
- read the records of other jobs;
- use more memory, processor, processes or time than its box has.
A reply from a model is never run
It goes back, as text, to the source that sent the job, and to no other. It cannot make sandcastle send a request or read a file, and each job asks the model once at most.
Another account on your machine cannot
read your keys, tokens or records, see a secret in the list of processes, hand sandcastle a job, or use the gate.
Only what is proved counts
The promise holds for a version: a tag that passed every test of the matrix on the reference machine. Whatever lies between versions is work in progress.
The promise does not cover your machine’s root, programs that run as your own user, a tampered sandcastle, or how much a provider charges you. The threat model lists all fourteen points outside it. The money you can spend is capped by the spending limit of your workspace at the model’s provider, and setting that limit is not optional.
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.
How secrets are kept
A secret is only safe where nothing hostile runs beside it. So the secrets live in one place, the keep, which never opens the content of a job and never runs a capability. The core, which does handle jobs, holds no secret: it asks the keep to send a request, and the keep adds the secret on the way out.
The keep is the only part of sandcastle that reads a secret and the only one that reaches the
network. sandcastle keep listens on a Unix socket, $XDG_RUNTIME_DIR/sandcastle-keep/keep.sock,
in a folder only you can open. The rest of sandcastle sends it a closed envelope that names a
destination, and the keep does the rest:
{"destination":"notes","method":"POST","path":"/jobs/claim","headers":{…},"body":{…}}
- Only
GETandPOST. Thepathis added to the destination’saddress, and is refused when it holds..,//,\, a scheme, a query or a fragment, written or encoded. A field the envelope does not have, such as aurl, is refused. - The headers it takes are
content-type,acceptandanthropic-version. An authentication header is refused: the keep adds the secret itself, asx-api-keyforclaudeand asAuthorization: Bearerfor a source. - It reads the secret for every request. A secret it cannot use stops the request with
unusable secret: <name>, and nothing of its content: theclaudejob is released and the capability rests, or the source is left out until its next round. - It connects only to the destination’s name and port (or its
internalip:port), always verifies TLS, and never follows a redirect. It gives back only the status,content-type,retry-afterand a body of up to 32 MiB. - Its log has one line per envelope: the destination, the method, the path and the status. Never
a body or a secret. It ends with 0 on
SIGTERM.
The keep reads the configuration when it starts: restart it after changing a destination.
On disk
Each secret is a file of its own in ~/.local/share/sandcastle/secrets/, readable only by you,
and the keep reads it again for every request, so a changed secret counts at once. The files are
not encrypted: they are guarded by their permissions alone. Encrypting the disk, and keeping the
values in a password manager, are yours to do; the threat model
says so. Manage secrets says how to set, list and remove them.
How the network is closed
Only one part of sandcastle reaches the network: the gate. The core and the boxes have no way out of their own, and the keep, which adds the secrets, reaches only the gate. This page explains how the gate decides what passes, and why the internal network stays closed even if it did not.
flowchart LR
core["core"]
keep["keep"]
gate["gate<br/>Smokescreen"]
dest["destinations"]
inside["internal network"]
core -- "Unix socket" --> keep
keep -- "sandcastle-link<br/>internal, no DNS" --> gate
gate -- "sandcastle-egress" --> dest
gate -. "refused after resolving,<br/>and no route" .-x inside
The core reaches the keep through a Unix socket. The keep reaches the gate on sandcastle-link,
a network with no way out and no DNS. The gate reaches the destinations on sandcastle-egress,
and the internal network is closed to it twice: the gate refuses it after resolving a name, and
the egress network has no route to it.
Who speaks to whom
- A box has no network at all: Podman starts it with
--network none. - The core has no secrets and names a destination, never an address. Everything it sends,
the polls of the sources, the results and the requests of
claude, goes to the keep. - The keep sits only on
sandcastle-link, with no DNS and IPv6 turned off. It cannot resolve a name or leave on its own: it asks the gate for a tunnel withCONNECTto the destination’s name and port as the configuration writes them, and to nothing else. Inside the tunnel it always verifies TLS against the root certificates of its image, and it never follows a redirect. - The gate is Smokescreen, and the only part with a way out. It lets through only the names of the destinations, and it alone resolves them.
The first barrier: the gate refuses after resolving
The gate’s list of names is made from the destinations of the configuration, in one rule, the
default; there are no roles to fall into. A name not in it is refused. Changing a destination
asks for a restart of the gate: it never reads its list again while it runs.
DNS is not a barrier: a name in the list may resolve to anything, and the answer may change from
one second to the next. So the gate resolves the name itself, for IPv4 only, and checks the
address it is about to connect to. An address in the internal network (private, CGNAT,
loopback, link-local) is refused, with a reason, and the refusal is recorded. sandcastle check shows the recent refusals and their likely
cause, and the job that met one goes back to its source to be run later, since the job could not
have caused it.
The second barrier: no route inside
The gate is a program, and a program can have flaws. So under it, the network it leaves through
has no way inside. When the gate starts, sandcastle recreates sandcastle-egress as an IPv4-only
network with a prohibit route for each of the fourteen IPv4 ranges of the IANA’s bogons, the
internal ranges among them. A connection to any of them fails at once, whatever the gate decided.
IPv6 is turned off in the gate.
The gate runs without CAP_NET_ADMIN, so it cannot remove a route. sandcastle reads the network
back before the gate starts, and the gate does not start when a route is missing; check holds
the running network to the same routes.
An internal exception
internal: ip:port in a destination opens one service of your network for that destination
alone. It cannot be a range, and never a sealed address: loopback, link-local (where the cloud
metadata address lives), NAT64, 6to4, Teredo, IPv4 mapped into IPv6, multicast, the unspecified
address, or the two networks of the installation.
The exception opens the address at both barriers: the gate lets it through, and the egress
network gets a /32 route to it. The route opens the whole IP, every port. The port is held by
the keep, which only ever asks for the one in the configuration; the name is held by the keep’s
TLS, so another destination pointed at the same address fails before its secret is sent.
check warns of each exception, and of http:// beside one, which nothing encrypts and which
is outside the promise.
What a tampered gate can still do
A gate that was tampered with is outside the promise: it is part of sandcastle. It could connect anywhere its network lets it, but the second barrier holds it: it gets as far as Podman’s own addresses on the egress network, and no further.
Threat model
This page says who sandcastle defends against, what each of them cannot achieve, and what lies outside the promise. Each promise has an id; the test matrix follows each id to the attacks tried against it and the tests that prove them stopped.
The trusted base
The promise rests on these, and promises nothing against them:
- you, the operator;
- the machine: the kernel, Podman, systemd and root;
- the configuration files;
- sandcastle itself, with its dependencies.
A source you configured is not trusted, and neither is anything it sends.
A compromised source, and the content of a job
A source, or the content of one of its jobs, cannot:
| id | promise |
|---|---|
| S1 | read your keys or tokens |
| S2 | reach the internal network, by a redirect or by DNS |
| S3 | leave anything that outlives the job |
| S4 | spend more than its max_jobs_per_hour |
| S5 | see or change the jobs of another source |
It can still send useless jobs up to its limit, see the results of its own jobs, stop answering and delay the other sources.
The program in a box
The program a capability runs is treated as hostile, whether an input exploited it or it came tampered. It cannot:
| id | promise |
|---|---|
| B0 | run without the locks of its box: no network, a read-only root, no capabilities, no new privileges, a user that is not yours, no swap above its memory |
| B1 | read your keys or tokens |
| B2 | reach any network |
| B3 | leave files or processes behind |
| B4 | read the records of other jobs |
| B5 | spend memory, processor, processes, disk or time without a limit |
A model’s reply
| id | promise |
|---|---|
| M1 | a reply is never run |
| M2 | a reply goes only to the source that sent the job |
| M3 | a reply causes no request and no read |
| M4 | a job costs one request to the model at most |
Another account without privileges
Another account on the machine, with no privileges, cannot:
| id | promise |
|---|---|
| A1 | read your keys, tokens and records |
| A2 | see a secret in the list of processes |
| A3 | hand sandcastle a job or speak to it |
| A4 | use the gate |
The network
| id | promise |
|---|---|
| N1 | the internal network is closed after a name is resolved |
| N2 | a sealed address never opens |
| N3 | an internal exception opens only its own ip:port |
| N4 | the keep never follows a redirect |
| N5 | the keep checks the port |
| N6 | there are no roles: only the default rule |
| N7 | the keep and the gate run with the locks the repository gives them |
| N8 | the gate’s own network refuses the internal network, without the gate |
The internal network is closed by two independent barriers. The gate refuses after resolving the name. Under it, the gate’s network has no route to the internal network, and the gate cannot open one. An internal exception opens the whole IP at the second barrier; the port is checked by the keep, and the name by the keep’s TLS. How the network is closed explains both.
Outside the promise
These are not defended against. A report that one of them should move inside the promise is welcome as an ordinary issue; it is not a vulnerability.
- Root and the machine’s system: the kernel, Podman and systemd.
- Programs that run as the same account as sandcastle.
- sandcastle or its dependencies, when tampered with. A tampered gate can get around the second barrier, but only as far as Podman’s own addresses.
- Availability: a source can delay the others, a job can fail, sandcastle can stop.
- The correctness of a result: it can be wrong or misleading and still be valid.
- A base image that was already bad when its digest was fixed.
- An internal destination you opened, and
http://to it. - Money beyond the spending limit you set at the model’s provider.
- Escapes from Podman’s isolation through flaws nobody knows yet.
- The model’s provider.
- Data that leaves through a destination you allowed.
- Side channels.
- Physical access to the machine, and copies of it. The secrets are kept in clear, guarded only by the permissions of their files; encrypting the disk and keeping the values in a password manager are yours to do.
- Mistakes in your configuration, a box’s limits set above their defaults among them.
The test matrix marks where these touch an attack.
Test matrix
Each promise of the threat model, the attacks tried against it, and where the
proof lives. The ids are stable: a test cites the ids it proves in a comment that starts
with them (// S1, N4: …), or at the start of its name or a subtest’s, and a test of the repository holds this page to the
tests. Each id needs at least one proof of each of its levels, the proof names every test file that
cites the id, and an id a test cites must be on this page.
The level says where the attack runs:
- U: a unit test, run by
go test ./...on any machine; - I: an integration test, run by
go test -tags integration ./...against an installed sandcastle with real Podman, only on the reference machine; - M: a step done by hand for each version, in the manual list.
An attack counts as stopped only when three things hold: it fails; it fails for the reason
expected (the gate’s reason, 137 at the memory limit, “read-only file system”, EACCES), never a
timeout or any error; and a control, the same attack without the lock or against an allowed
destination, succeeds.
Test runs records each version’s run of this matrix. The most recent is the one at the top of that page.
A compromised source, and the content of a job
| Id | Promise | Attacks | Level | Proof |
|---|---|---|---|---|
| S1 | reads no key or token | a redirect to the attacker’s server, waiting for the token; a destination or url in the envelope; text that looks like a token in the errors; the decoy secrets looked for everywhere an attacker sees | U, I | hostile_integration_test.go, internal/keep/keep_test.go, main_test.go |
| S2 | reaches no internal address | a redirect to 192.168.x, 169.254.169.254 and localhost; the source’s name starts to resolve inside; internal URLs in the content | U, I | dns_integration_test.go, hostile_integration_test.go, internal/config/config_test.go |
| S3 | leaves nothing behind the job | the capability writes files and starts processes; afterwards no box with the label, no volume, no file outside the job’s record | I | attacker_integration_test.go |
| S4 | spends no more than its limit | jobs without end; content above the maximum; a prompt above max_input | U | internal/box/core_test.go, internal/runner/runner_test.go |
| S5 | sees and changes no job of another source | a job with another source’s job id; with an honest source and an attacking one, the attacker never gets the other’s result or token | U, I | hostile_integration_test.go, internal/keep/keep_test.go, internal/source/client_test.go, main_test.go |
The program in a box
| Id | Promise | Attacks | Level | Proof |
|---|---|---|---|---|
| B0 | runs with every lock of its box | inspecting a running box: no network, a read-only root, no capabilities, no-new-privileges, a user that is not the operator’s, --memory-swap equal to the memory; an image of another build is refused | I | integration_test.go, internal/box/core_test.go, internal/box/podman_test.go, internal/config/config_test.go |
| B1 | reads no key or token | /proc/self/environ and /proc/1/environ; the mounts; looking for the decoy; the keep’s and Podman’s sockets; the machine’s processes | I | attacker_integration_test.go |
| B2 | reaches no network | the list of ways out; abstract Unix sockets; a second request out; choosing the destination, the model or the headers | U, I | attacker_integration_test.go, internal/box/core_test.go |
| B3 | leaves no file or process | writing to the root; /tmp unseen by the next box; a process in the background (setsid, a double fork); the core killed midway and the box removed at the next start | I | attacker_integration_test.go, integration_test.go, internal/box/podman_test.go |
| B4 | reads no record of another job | looking for the data_dir and the previous job’s result | I | attacker_integration_test.go |
| B5 | spends nothing without a limit | memory with swap free; a fork bomb; the processor until its time; an endless sleep; filling /tmp; flooding stdout and stderr; a huge line with no line break | I | attacker_integration_test.go, integration_test.go, internal/box/podman_test.go |
A model’s reply
| Id | Promise | Attacks | Level | Proof |
|---|---|---|---|---|
| M1 | is never run | shell commands and “ignore the instructions” reach the source as text; fields outside the schema are refused | U, I | hostile_integration_test.go, internal/capability/claude_test.go, main_test.go |
| M2 | goes only to the source of the job | a reply that asks for another source, or holds another job’s id | U | main_test.go |
| M3 | causes no request and no read | URLs and paths (file:///etc/passwd) in the reply; the keep’s log shows one request | I | hostile_integration_test.go |
| M4 | costs one request at most | a request to go on, max_tokens, tool_use; no second request leaves | U, I | hostile_integration_test.go, internal/box/core_test.go, internal/capability/claude_test.go |
Another account without privileges
| Id | Promise | Attacks | Level | Proof |
|---|---|---|---|---|
| A1 | reads no key, token or record | another account reads the configuration, the secrets folder and the data_dir | M | the manual list |
| A2 | sees no secret in the processes | ps auxww and /proc/*/environ during a job, looking for the decoy | M | the manual list |
| A3 | hands sandcastle no job | connecting to the keep’s socket; writing to the data_dir | M | the manual list |
| A4 | cannot use the gate | connecting to the gate’s address and port; scanning the machine’s ports | M | the manual list |
The network
| Id | Promise | Attacks | Level | Proof |
|---|---|---|---|---|
| N1 | the internal network is closed after resolving | an allowed name resolves to each internal range and to ::ffff: mapped addresses; rebinding with a TTL of 0; a CNAME inside; a mixed answer connects only to the public address | I | dns_integration_test.go, online_integration_test.go |
| N2 | a sealed address never opens | the configuration refuses internal: with each sealed address, loopback and link-local among them; the gate’s files never hold one | U | internal/config/internal_test.go, internal/ranges/ranges_test.go, setup_test.go |
| N3 | an exception opens only its ip:port | another port of the same IP; the neighbouring IP; a destination without internal resolving inside; another destination pointed at the open ip:port dies in the keep’s TLS before its secret is sent | I | hostile_integration_test.go |
| N4 | the keep never follows a redirect | covered by S1 and S2 | — | hostile_integration_test.go, internal/keep/keep_test.go, internal/source/client_test.go, main_test.go |
| N5 | the keep checks the port | the keep only asks for the name:port of the configuration; control: the gate alone lets port 22 through | U, I | dns_integration_test.go, internal/keep/gate_test.go, internal/keep/keep_test.go |
| N6 | only the default rule | the gate’s file has one rule; a client without a certificate falls in it and in no other | U, I | install_integration_test.go, internal/gate/gate_test.go, setup_test.go |
| N7 | the keep and the gate run with their locks | inspecting the keep (no DNS, no IPv6, only on the link) and the gate (no ports, IPv6 off), and the egress network (subnet, routes, no IPv6); the keep tries to leave without the gate and fails | I | install_integration_test.go, internal/install/inspect_test.go, internal/install/units_test.go |
| N8 | the egress network refuses the inside without the gate | the attacker’s image, on the egress network with the gate’s locks, connects to each of the fourteen ranges and gets EACCES at once; ip route del fails; no IPv6 route; with an exception, the /32 is reached and the neighbouring IP is not. Control: a network without the routes is reached | I | egress_integration_test.go |
Outside the promise
These attacks touch a point outside the promise. They are written down so nobody mistakes them for a gap in the matrix; nothing proves them stopped.
| Point | Next to | What an attacker can do |
|---|---|---|
| 3, a tampered sandcastle | N8 | a tampered gate binds a socket to the egress interface (SO_BINDTODEVICE) and gets past the routes, as far as Podman’s own addresses |
| 7, an internal destination | N3 | read and change what goes to an http:// internal destination, which nothing encrypts |
| 8, money | S4, M4 | spend up to the limit set at the model’s provider, one request per job |
| 14, the operator’s mistakes | B5 | use a box’s limits set above their defaults |
The manual list
Done by hand on the reference machine, for each version, in the same session as the automatic tests. Each step’s result goes in the version’s record in Test runs.
- A1: as
sctest-other, an account with no privileges, try to read the configuration folder, the secrets folder and thedata_dir. Each read fails with “permission denied”. Control: the operator reads them. - A2: during a job, as
sctest-other, runps auxwwand read/proc/*/environ, looking for the decoySCTEST-CANARY-…. It is never found. Control: the operator finds the decoy in its secret file. - A3: as
sctest-other, connect to the keep’s socket and write to thedata_dir. Both fail with “permission denied”. - A4: as
sctest-other, connect to the gate’s address and port, and scan the machine’s ports. Nothing answers; sandcastle listens on no port of the machine. - Traffic: capture with
tcpdump, inside the rootless Podman network namespace, during the integration run. Every flow is keep → gate or gate → an allowed destination. - Boot: restart the machine without logging in. The gate, the keep and the core are up
(linger and the enabled
sandcastle.target). - Private names: the publication script finds no private name in the git history, on GitHub or in the built site.
Test runs
Each version runs the whole test matrix on the reference machine, in one session, before its tag is made. Its record is added here, newest first. A record never names the machine, its addresses or its network.
v1.0.0
- Date: 2026-10-16
- Commit: 0851a23aff269507d10c1c7527ab2a163c5f7f39
- Kernel: 7.1.8-arch1-Watanare-T2-3-t2
- Podman: 6.1.0
- systemd: systemd 261 (261.2-1-arch)
- Smokescreen: 609eb8931420453daf5893509be0b25b21bd9edb
| Level | Command | Result |
|---|---|---|
| U | go test ./... | pass |
| I | go test -tags integration ./... | pass |
| Online | go test -tags integration,online -run Online ./... | pass |
| Fuzz | each target for 5 minutes | no new failure |
| Manual step | Result |
|---|---|
| A1 | pass, “permission denied” on the configuration, the secrets and the data_dir; the operator reads them |
| A2 | pass, the decoy is in no process list or environment in 30 samples, 6 during a box; the operator finds it in its files |
| A3 | pass, “permission denied” on the keep’s socket and on the data_dir; the operator connects |
| A4 | pass, the gate does not answer; no listening port of the machine is sandcastle’s |
| Traffic | pass, sandcastle’s pieces open only keep → gate and gate → an allowed destination; every other flow is a test’s attacker or control |
| Boot | pass, the gate, the keep and the core were up from the lingering user manager, whose session logind opened first; the desktop’s auto-login opened in the same second |
| Private names | pass: the publication script finds no private name in the git history, on GitHub or in the built site |
The shape of a record
## vX.Y.Z
- **Date**: YYYY-MM-DD
- **Commit**: the full hash the tag points at
- **Kernel**: `uname -r`
- **Podman**: `podman version --format '{{.Client.Version}}'`
- **systemd**: `systemctl --version`, first line
- **Smokescreen**: the commit the gate's recipe fixes
| Level | Command | Result |
|---|---|---|
| U | `go test ./...` | pass / fail |
| I | `go test -tags integration ./...` | pass / fail |
| Online | `go test -tags integration,online -run Online ./...` | pass / fail |
| Fuzz | each target for 5 minutes | no new failure / the failing input |
| Manual step | Result |
|---|---|
| A1 | pass / fail, with what was seen |
| A2 | … |
| A3 | … |
| A4 | … |
| Traffic | … |
| Boot | … |
| Private names | … |
A version is released only when every row passes. A promise that cannot be proved is fixed, or moved outside the promise with the threat model rewritten; a row is never left empty.
Reporting a vulnerability
Report a flaw privately, through GitHub’s private vulnerability reporting: the Security tab of the repository, then Report a vulnerability. Please do not open a public issue for it.
What counts
A flaw is anything that breaks the promise: an attacker achieving something the promise says they cannot. What the threat model lists as outside the promise is not a flaw, though a report that something should move inside it is welcome as an ordinary issue.
What is supported
Only main is supported. There is no bug bounty.
Commands
sandcastle [--config DIR] [--secrets DIR] [--prefix NAME] <command>
| Command | What it does |
|---|---|
install [--link RANGE] [--egress RANGE] | checks the configuration and the machine, builds the four images, copies the binary to ~/.local/bin/sandcastle, writes the units, turns lingering on, starts sandcastle.target and runs check. See Install |
uninstall | stops everything and removes the units, the images and the two networks; the configuration, the secrets, the jobs and the binary stay. See Uninstall |
check | asks the keep, checks the configuration, the units, the networks and the images, pings every source and tests every capability. See Check messages |
run | polls every source at its own interval until it is stopped. The core’s service runs it |
once | polls every source a single time, runs what it gets, and ends |
secret set <name> | reads a secret from stdin, without echo at a terminal, and keeps it. See Manage secrets |
secret list | lists the secrets by name: used, unused or missing |
secret rm <name> | deletes a secret |
version | prints the build of this binary and the commit it came from |
run, once and check need the keep. run and once wait for it, asking again every 5
seconds, before they ask for any job.
Flags
| Flag | Default | What it sets |
|---|---|---|
--config DIR | $SANDCASTLE_CONFIG, or ~/.config/sandcastle | the configuration folder |
--secrets DIR | ~/.local/share/sandcastle/secrets | the secrets folder |
--prefix NAME | sandcastle | the first word of every name of the installation: units, images, networks, the keep’s socket |
--link RANGE | see Ranges | install only: the range of the network between the keep and the gate |
--egress RANGE | see Ranges | install only: the range of the gate’s way out |
Exit codes
| Code | Means |
|---|---|
0 | done; for check, no line is FAIL |
1 | it failed, and said why on stderr; for check, a line is FAIL |
2 | the command or its arguments were wrong; the usage is printed |
Started by sandcastle itself
These run inside the installation. You do not start them.
| Command | What it is |
|---|---|
box <capability> | one job’s box, speaking JSON lines on stdin and stdout |
keep [GATE] | the keep, listening on $XDG_RUNTIME_DIR/sandcastle-keep/keep.sock and sending out through the gate at GATE |
prepare-gate LINK EGRESS OUT | the gate’s preparation: writes its files to OUT and creates its egress network |
config.yaml
The configuration is one folder: ~/.config/sandcastle, or $SANDCASTLE_CONFIG, or the folder
given with --config DIR.
config.yaml the general settings and the toolbox
sources/<name>.yaml one file per source
It holds no secret, so it can be kept in version control. examples/config.yaml and
examples/sources/notes.yaml are a complete configuration to start from.
Every problem is reported at once, by check, install and the core when it starts, with the
file and the key. A key the program does not know is an error, so a typo is never ignored. A
folder with no file in sources/ is an error.
General settings
The keys at the top of config.yaml.
| Key | Default | What it sets |
|---|---|---|
data_dir | ~/.local/share/sandcastle/jobs | where each job’s record is kept; see Files and folders. A folder that holds the secrets folder, or lies inside it, is refused |
retention_days | 30 | records older than this many days are deleted; at least 1 |
capabilities | none | one block per capability of the toolbox, claude and ocr; see below |
Destinations
Each source, and the claude capability, has a destination block: the one place it may reach,
and the secret sent there.
destination:
address: https://notes.example.com/api/worker
secret: notes
| Key | Default | What it sets |
|---|---|---|
destination.address | required | https:// and a full domain name, with an optional port (443 for https://, 80 for http://) and path. No IP address, wildcard, range, user, password, query or fragment. http:// only with internal |
destination.secret | required | the name of a secret |
destination.internal | none | one ip:port inside your network that the address’s name stands for; see Reach a service inside your network |
The gate’s list of names is made from the destinations: nothing else can be reached.
Sources
One file per source, in sources/. Every key is required.
| Key | Default | What it sets |
|---|---|---|
name | required | the source’s name, and its destination’s. No slashes, dots or spaces, not claude or ocr, and not another source’s |
destination | required | where the source is, and its secret; see Destinations |
poll_interval | required | how often the source is asked for jobs; at least 1s |
capabilities | required | the capabilities the source may use, each enabled in config.yaml; a job for another fails |
max_jobs_per_hour | required | how many jobs of this source run in an hour, at most; at least 1 |
job_timeout | required | how long one job may run, its box included; at least 1s. Give it a little more than the capability’s timeout |
claude
capabilities.claude in config.yaml. What a job sends and gets back is in
claude.
| Key | Default | What it sets |
|---|---|---|
enabled | false | whether sources may use it |
destination | required | the Claude API, written by hand: https://api.anthropic.com, with the key’s secret |
model | required | the model every job asks, such as claude-haiku-5-5 |
effort | low | how hard the model works: low, medium, high, xhigh or max |
max_tokens | 16000 | the longest reply; a reply cut off here fails the job |
system_prompt | a fixed, generic one | the system prompt of every request |
timeout | 5m | how long the job’s box may run, the request included |
cooldown | 15m | how long the capability rests after a failure that is not the job’s |
max_input | 256KiB | the largest job input, and the largest prompt sent |
max_output | 1MiB | the largest line the box may write back |
box.memory | 256MiB | the box’s memory, with no swap on top |
box.cpus | 1 | the box’s processors |
box.pids | 32 | the box’s processes |
box.tmp | 16MiB | the size of /tmp, the box’s only writable folder |
ocr
capabilities.ocr in config.yaml. What a job sends and gets back is in ocr.
It reaches nothing outside its box, so a destination is refused.
| Key | Default | What it sets |
|---|---|---|
enabled | false | whether sources may use it |
languages | por+eng | the Tesseract languages, joined with +; only these go into the image |
max_pixels | 25000000 | the largest image decoded; a larger one fails the job |
max_edge | 2500 | the longest side an image is scaled down to before it is read |
timeout | 30s | how long the job’s box may run |
cooldown | 15m | how long the capability rests after a failure that is not the job’s |
max_input | 16MiB | the largest job input; a larger one fails before a box starts |
max_output | 1MiB | the largest line the box may write back |
box.memory | 1GiB | the box’s memory, with no swap on top |
box.cpus | 1 | the box’s processors |
box.pids | 64 | the box’s processes |
box.tmp | 64MiB | the size of /tmp, the box’s only writable folder |
The box block of each capability holds its four limits. A limit above its default is allowed,
and check warns of it: what the box then spends is outside the promise. No key opens a lock of
the box; see How a job is boxed.
Values
- A duration is a number and a unit:
30s,15m,1h30m. - A size is a number of bytes, plain or with
KiB,MiBorGiB:1024,256KiB,16MiB. box.cpusis a number above 0, such as1or0.5.box.pidsis a whole number of at least 1.
Keys that are gone
A file that still has one of these is refused, with what replaces it.
| Key | What replaces it |
|---|---|
sandbox | nothing: every job runs in a box of its own |
tesseract | nothing: Tesseract comes in the ocr box’s image |
api_key_file | sandcastle secret set claude, named in destination.secret |
base_url | destination.address |
token_file | sandcastle secret set <name>, named in destination.secret |
Check messages
sandcastle check prints one line per item, each starting with a mark. It exits with 1 when any
line is FAIL, and with 0 otherwise.
| Mark | Means |
|---|---|
ok | the item is as it should be |
FAIL | the item keeps the installation from working; fix it |
warn | something to know; it fails nothing |
The lines come in the order of the sections below. Without a keep that answers, check stops
after the images: everything after them is asked through the keep. Without a configuration it can
read, it stops after the configuration. The lines about the units, the networks and the gate are
printed only on a machine where sandcastle install wrote the units.
In the lines, <…> stands for a value: a name, an address, a number.
The keep
| Line | What to do |
|---|---|
ok keep: ready | — |
FAIL keep: the keep does not answer at <socket>; start it with sandcastle keep | see Troubleshoot |
FAIL keep: XDG_RUNTIME_DIR is not set to a folder; the keep's socket lives there | run check in your own session, as the operator |
FAIL keep: <reason> | the keep answered, but not as it should; see journalctl --user -u sandcastle-keep |
The configuration
| Line | What to do |
|---|---|
FAIL config: <file>: <key and rule> | fix the file; the line names the key. The rules are in config.yaml |
The files are read here. What they ask of the toolbox is checked later.
The units and the networks
| Line | What to do |
|---|---|
ok keep and gate: run as their units define | — |
FAIL <piece>: the container sandcastle-<piece> does not exist; run sandcastle install | sandcastle install |
FAIL <piece>: the container is <state>, not running | systemctl --user restart sandcastle.target, then read its journal |
FAIL keep: runs as "<user>", not as the operator (<uid>) | sandcastle install |
FAIL gate: runs as the operator (<uid>); it must not | sandcastle install |
warn <piece>: the container was not started with "<flag>", as its unit says | a drop-in removed a lock; remove the drop-in |
warn <piece>: the container was started with <flag> | a drop-in added a port, a capability or a namespace of the machine; remove it |
warn <piece>: the container publishes ports | the same |
warn <piece>: the container has capabilities <list> | the same |
warn <piece>: the container's disk is writable | the same |
warn <piece>: the container is on the networks <list>, not <list> | the same |
warn keep: the container has DNS settings; its unit says DNS=none | the same |
warn <piece>: runs <image>, not this binary's build <build>; restart it with systemctl --user restart sandcastle-<piece> | restart it as the line says |
FAIL link: the network sandcastle-link does not exist; run sandcastle install | sandcastle install |
FAIL link: the network sandcastle-link is <range>, but its unit says <range>; run sandcastle install --link <range> | run it as the line says |
ok gate: its way out has the routes of the second barrier | — |
FAIL gate: the network sandcastle-egress is not what the installation needs: <how> | systemctl --user restart sandcastle-gate, which makes the network anew |
FAIL gate: the network sandcastle-egress cannot be read: <reason> | the same |
ok gate: its list is the configuration's | — |
warn gate: the list it runs is not the configuration's; restart it with systemctl --user restart sandcastle-gate | restart it as the line says |
FAIL gate: its files cannot be read at <folder>: <reason> | systemctl --user restart sandcastle-gate |
FAIL gate: the ACL has <n> rule(s) besides the default | the same; the gate writes its list anew when it starts |
FAIL gate: <reason> | the same |
The networks and their routes are in Network names and ranges.
The internal exceptions
One pair of lines for each destination with internal:, every time. See
Reach a service inside your network.
| Line | What to do |
|---|---|
warn <source or capability> <name>: internal exception: the gate lets <ip:port> through for <name> | a reminder that the exception is open |
warn <source or capability> <name>: http:// is outside the promise: nothing encrypts the way to <ip:port> | use https:// if the service can |
FAIL <source or capability> <name>: internal <ip:port> is in the keep-gate network or the gate's way out, which is never opened | choose an address outside the installation’s ranges |
The toolbox and the images
What the configuration asks of the toolbox, then each capability’s box limits and image.
| Line | What to do |
|---|---|
FAIL config: capability claude: destination and model are required | add them to capabilities.claude |
FAIL config: capability claude: effort "<value>" is not one of low, medium, high, xhigh, max | fix effort |
FAIL config: capability claude: max_tokens must be at least 1 | fix max_tokens |
FAIL config: capability ocr: reaches nothing outside its box; remove destination | remove the block |
FAIL config: capability <name>: sandcastle has no such capability | only claude and ocr exist |
FAIL config: source <name>: the name "<name>" is a capability's; give the source another name | rename the source |
FAIL config: source <name>: the capability "<name>" is not enabled in config.yaml | enable it, or take it out of the source’s capabilities |
warn capability <name>: box.<limit> <value> is above the default <value> | a limit above its default is outside the promise; lower it if you can |
FAIL capability <name>: the image sandcastle-<name>:<build> of this build is missing; run sandcastle install | sandcastle install |
FAIL capability ocr: the image has the languages "<languages>" and the configuration "<languages>"; run sandcastle install | sandcastle install |
FAIL capability <name>: podman cannot be run: <reason> | install Podman, or put it on PATH |
FAIL capability <name>: podman cannot look at the image <image>: <reason> | see what podman image inspect says |
The sources
| Line | What to do |
|---|---|
ok source <name>: reachable, <n> waiting | — |
FAIL keep: <reason> | the keep could not say which secrets it can use; see its journal |
FAIL source <name>: secret <secret>: missing; run sandcastle secret set <secret> | run it as the line says |
FAIL source <name>: secret <secret>: <rule> | the secret’s file breaks a rule; see Manage secrets |
FAIL source <name>: the keep does not know this destination; restart the keep after changing the configuration | systemctl --user restart sandcastle.target |
FAIL source <name>: the token was rejected; create a new worker token and set it with sandcastle secret set <secret> | run it as the line says |
FAIL source <name>: <reason> | the source could not be asked: the line says what the keep, the gate or the source answered. See Troubleshoot |
The capabilities
| Line | What to do |
|---|---|
ok capability <name>: usable | — |
FAIL capability claude: secret claude: <rule> | as for a source’s secret |
FAIL capability claude: the Claude API rejected the key (<what it said>); create a key in the runner's workspace and set it with sandcastle secret set | see claude |
FAIL capability claude: the model "<model>" does not exist for this key | fix model in config.yaml |
FAIL capability claude: the Claude API answered <status>: <what it said> | read what it said |
FAIL capability claude: <reason> | the API could not be reached; the line says why |
FAIL capability ocr: tesseract has no language data for "<language>"; install it | sandcastle install, which builds the image with the configured languages |
FAIL capability <name>: <reason> | the box did not answer the check; the line says how |
The gate’s refusals
Last, for each destination the gate refused in the last hour, the likeliest cause.
| Line | What to do |
|---|---|
warn gate: refused <name> <n> time(s) in the last hour, last at <time> (<reason>): the name resolves to the internal network; if that is meant, give the destination an internal: ip:port | see Troubleshoot |
warn gate: refused <name> <n> time(s) in the last hour, last at <time> (<reason>): the name is not on the gate's list; restart the gate after changing the configuration | the same |
warn gate: refused <name> <n> time(s) in the last hour, last at <time> (<reason>): see the gate's log | journalctl --user -u sandcastle-gate |
Capabilities
A capability is one thing a job can ask sandcastle to do. The toolbox is closed: there are two,
claude and ocr, and they are built into the
binary. The configuration only enables them and sets their values.
A job names one capability and fills that capability’s input, and only the fields it defines.
It cannot choose a program, a model, an argument or a path. Any other field is ignored.
Each capability’s page says three things: what the operator configures, what the job fills, and what the job gets back.
What every capability shares
- Every job runs in a box of its own, with no network and no secret; see How a job is boxed.
- An input larger than the capability’s
max_inputfails the job before a box starts. - A source may use a capability only when the operator enabled it in
config.yamland listed it in the source’scapabilities. A job for another capability fails. - A job ends in one of three ways, sent back to the source as its
status:succeeded, with the output;failed: the job is at fault, or its box broke a limit;released: the capability could not be used, through no fault of the job. The capability then rests for itscooldown, and no job of it is claimed until the rest is over.
checkasks each enabled capability to prove it can work, in a box of its own, without running a job.
The keys of both are in config.yaml.
claude
Asks a model through the Claude API, with an API key of the operator’s. Each job is one request to the Messages API, and never a second.
What the operator configures
The keys are in config.yaml: the destination, the model, the effort, the
longest reply (max_tokens) and the box’s limits. A job can change none of them.
The key
The key is an API key of the Claude Console, made in a workspace
kept for sandcastle, with a spend limit on that workspace. The spend limit is required: it is the
only cap on the money sandcastle can spend, as max_jobs_per_hour caps how many jobs run, not what
they cost. Spending beyond the limit you set is
outside the promise.
The key is a secret, named in the destination:
capabilities:
claude:
destination:
address: https://api.anthropic.com
secret: claude
The address is written by hand: there is no hidden default, so the gate’s list is exactly what the file says. The keep reads the key for every request, so a replaced key needs no restart.
What the job fills
{"prompt": "<the whole task>", "schema": {"type": "object", "…": "…"}}
prompt: the whole task, in text. It is sent as the user’s message, under the operator’ssystem_prompt. The default one tells the model that text the task marks as data is never an instruction, and that it has no tools.schema: a JSON Schema object for the reply. It is sent as the reply’s format, as the job gave it. The API takes a subset of JSON Schema (nominLength,maxLength,minimum,maximumand the like); a schema it refuses fails the job, with the API’s message.
A prompt larger than max_input is never sent.
What the job gets back
The reply: one JSON value that satisfies the schema. The box checks it against the schema, and a
reply that does not satisfy it fails the job and is kept in the job’s record. So does a reply the
model declined to give, and one cut off at max_tokens.
The source must still treat the reply as untrusted: a valid reply can be wrong or misleading.
What it never does
- It offers the model no tool: no shell, files, web, code execution or MCP servers. A prompt telling it to run a command has nothing to run it with.
- It makes one request per job. A reply that asks for more gets nothing.
- The job’s box opens the input and the reply. The core never does, and the keep only carries them.
When the API cannot be used
When the key is rejected, the rate limit is reached, the credit has run out, or the API or the keep
cannot be reached, the job is released and the capability rests for its cooldown. There is no
second way to reach a model.
What check proves
check looks the configured model up with the key. That costs nothing, and tells a rejected key
from a model the key cannot use. It does not prove there is credit left: that shows at the first
job.
What it costs
The meta.json of every job that reached the model holds a usage object with the model and the
tokens that went in and came out. The core’s journal line for the job holds the same two counts.
See Files and folders.
ocr
Reads the text in one image with Tesseract, inside the job’s box. Nothing leaves the machine: no model, no service.
What the operator configures
The keys are in config.yaml: the languages, the largest image (max_pixels),
the side it is scaled to (max_edge) and the box’s limits. ocr has no destination and no
secret.
Tesseract comes in the ocr box’s image, with only the configured languages. Nothing needs to be
installed on the machine. After changing languages, run sandcastle install to build the image
again; until then check fails, saying both lists.
What the job fills
{"image": "<the bytes of one image, in base64>"}
One image per job, as JPEG, PNG or WebP. A job cannot name a URL, a program, an argument or a path.
What the job gets back
{"text": "<the text that was read>"}
text is empty when the image holds no text.
What happens to the image
The box decodes the image itself, and draws it anew before Tesseract reads it. So Tesseract and its image library only ever parse a bitmap sandcastle made, never the bytes that came in the job.
- A file that is not JPEG, PNG or WebP fails the job.
- An image of more than
max_pixelsfails the job before it is decoded. - An image with a side longer than
max_edgeis scaled down first.
The core never opens the input. The job’s record keeps the image’s size and the SHA-256 of its bytes, never the bytes, so the image does not stay behind after the reading.
What check proves
check starts a box that runs tesseract --list-langs, and fails when a configured language is
missing.
Files and folders
Every path is in your home or in your runtime folder, $XDG_RUNTIME_DIR (/run/user/<uid>). Every
name starts with the prefix, sandcastle.
| Path | What it holds | Written by | uninstall |
|---|---|---|---|
~/.config/sandcastle/ | the configuration | you | keeps it |
~/.local/share/sandcastle/secrets/ | one file per secret, readable only by you | sandcastle secret set | keeps it |
~/.local/share/sandcastle/jobs/ | the jobs’ records; it is data_dir | the core | keeps it |
~/.local/bin/sandcastle | the installed binary, which the core and the gate’s preparation run | install | keeps it |
~/.config/containers/systemd/sandcastle-link.network | the link network’s unit | install | removes it |
~/.config/containers/systemd/sandcastle-keep.container | the keep’s unit | install | removes it |
~/.config/containers/systemd/sandcastle-gate.container | the gate’s unit | install | removes it |
~/.config/systemd/user/sandcastle.service | the core’s unit | install | removes it |
~/.config/systemd/user/sandcastle.target | the target that groups the three | install | removes it |
~/.config/containers/systemd/sandcastle-*.container.d/ | your drop-ins | you | keeps them |
$XDG_RUNTIME_DIR/sandcastle-keep/keep.sock | the keep’s socket, the core’s only way to it | the keep | gone when the keep stops |
$XDG_RUNTIME_DIR/sandcastle-gate/ | the gate’s smokescreen.yaml and acl.yaml, its list of names | the gate’s preparation | gone when the gate stops |
uninstall also removes the images and the two networks, which live in Podman: see
Network names and ranges. It leaves lingering on, as other services of yours may
need it.
A different --config or --secrets folder, or $SANDCASTLE_CONFIG, moves the first two;
install writes the folders it was given into the units.
A job’s record
Each job leaves a folder under data_dir, readable only by you:
<source>/<job id>/input.json what the source sent; for ocr, the image's size and SHA-256 instead of its bytes
<source>/<job id>/output.json what was sent back, or the reply that was refused
<source>/<job id>/meta.json what happened
meta.json holds source, job, capability, status (succeeded, failed or released),
error, stderr (the start of what the box wrote there, which is hostile and is kept only here),
started_at, finished_at, and usage with the model and the tokens when the job reached one.
The core deletes the folders older than retention_days when it starts, then every hour.
Network names and ranges
Every name starts with the prefix, sandcastle, which --prefix changes.
| Name | What it is |
|---|---|
sandcastle.target | groups the three services; enabled, so it starts with the machine |
sandcastle.service | the core |
sandcastle-keep.service, container sandcastle-keep | the keep |
sandcastle-gate.service, container sandcastle-gate | the gate |
sandcastle-link | the network of the keep and the gate, with no way out |
sandcastle-egress | the gate’s way out |
sandcastle-box-<random> | a job’s box, removed when the job ends |
sandcastle-{ocr,claude,keep,gate}:<build> | the images, tagged with the binary’s build |
Ranges
| Network | Default | Flag of install |
|---|---|---|
sandcastle-link | 10.89.250.0/24 | --link |
sandcastle-egress | 10.89.251.0/24 | --egress |
Each range is an IPv4 /24. install refuses one that a Podman network or a route of the
machine already uses, and says which. It remembers the ranges you chose. With another
--prefix, there are no defaults: give both ranges. The gate’s address is
the link’s .2, port 4750. No piece publishes a port on the machine.
A box has no network at all.
The routes of the egress network
The egress network has a prohibit route for each of the fourteen IPv4 bogon ranges, so a
connection to one fails at once, with or without the gate:
0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 169.254.0.0/16 172.16.0.0/12
192.0.0.0/24 192.0.2.0/24 192.88.99.0/24 192.168.0.0/16 198.18.0.0/15
198.51.100.0/24 203.0.113.0/24 224.0.0.0/4 240.0.0.0/4
Each internal exception adds a /32 to its address through the
network’s gateway, which wins over the range it is in. The network has no IPv6. The gate makes
it anew each time it starts, and check holds it to these routes. Why there are two barriers is
in How the network is closed.
Supported systems
Tested on Arch with Podman 6.1 and systemd 261. Every version is tested there before it is tagged; see Test runs.
The least that install accepts
install checks each of these before it changes anything, and names the version it found and the
one it needs. No flag gets around them.
- Linux, on a machine you have an ordinary account on. sandcastle never runs as root.
- Podman 6.0.0, rootless, with the netavark 2.0.0 network backend and pasta.
- systemd 252, with a user manager for your account.
- A range for your account in
/etc/subuidand in/etc/subgid. - The
cpu,memoryandpidscgroup controllers delegated to your user. - Two free IPv4
/24ranges for the installation’s networks; see Network names and ranges.
install also uses ip and loginctl. Troubleshoot
says how to fix what it refuses.
To build
Go, at the version in go.mod, and Git. The binary must be built with CGO_ENABLED=0. It can be
built on another machine, with GOOS=linux and the GOARCH of the machine that runs it, and
copied there.
Changelog
Every change of behaviour is noted here. The format follows Keep a Changelog, and versions follow Semantic Versioning.
[Unreleased]
[1.0.0] - 2026-10-16
First public release.
Glossary
A worker that pulls self-contained jobs from sources and runs each with one capability from a closed toolbox. This glossary fixes the words used to say what it defends and what it does not.
Language
Trust
Operator: The person who installs and configures sandcastle. Theirs are the only choices that count. Avoid: Admin, owner, user
Trusted base: Everything the promise rests on and promises nothing against: the operator, the machine, the configuration and sandcastle itself. Avoid: Trust root, TCB
Promise: What an attacker cannot achieve. Whatever is not in the promise is written down as outside it. Avoid: Guarantee, security model
Parts
Core: The part of sandcastle that decides what runs: it asks the sources for jobs, starts a box for each and sends the result back. It reads the envelope of a job and never opens its content. It holds no secrets and names a destination, never an address. Avoid: Runner, host process, daemon
Box: Where a capability runs for one job. It starts clean, holds no secrets, has no network, and nothing of it remains. Avoid: Sandbox, container, jail
Keep: The one place the secrets live. It adds the right secret to a request the core hands it and sends it to the destination the operator configured. It never gives a secret out and never opens the content of a job. In the trade: a credential broker. Avoid: Vault, keys box, secret store
Secret: A key or token the keep adds to a request for one destination. The configuration names it, and only the keep ever reads its value. Avoid: Credential, token file, key file
Gate: The only way out to the network. Everything sandcastle sends leaves through it, and it lets through only what the operator allowed. In the trade: an egress proxy. Avoid: Network box, firewall, proxy
Network
Destination: A named place sandcastle may send to, written by the operator. Each source and each capability that goes out has its own. The core says the name, the keep knows the address and the secret, and the gate lets through only these. Avoid: Endpoint, target, upstream, allowlist entry
Internal network: The address ranges closed by default: private, CGNAT, loopback and link-local. Never the network between the keep and the gate. Avoid: LAN, private network, intranet
Internal exception: One concrete internal address and port the operator opened for one destination. Avoid: Whitelist entry, bypass, hole
Sealed: Said of the addresses that never open, not even by an internal exception. Avoid: Hard block, blacklist, forbidden range
Refusal: The gate saying no to a connection, with a recorded reason. Avoid: Block, drop, deny, rejection
Releases
Version: A numbered, published point of sandcastle that passed every test of the promise. Only a version keeps the promise; what lies between versions is work in progress. Avoid: Release candidate, snapshot, latest
Build: One compiled sandcastle, named by its own fingerprint. It says what is installed, not what was published. Avoid: Version, revision