Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

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.

  1. 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: 6m
    

    Every key is required; each is in config.yaml. List in capabilities only what this source needs.

    If the source lives inside your network, add internal: as in Reach a service inside your network.

  2. Set the token the source gave you:

    sandcastle secret set recipes
    
  3. 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.target
    

    A job running at that moment is stopped.

  4. Check:

    sandcastle check
    

    You want ok source recipes: reachable, <n> waiting. Any other line is in Check messages.

Remove a source

  1. Delete its file in sources/.
  2. Restart sandcastle.target: systemctl --user restart sandcastle.target.
  3. 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

  1. In the destination, write the service’s address with a name of your choice, and its ip:port in internal:

    destination:
      address: https://recipes.home.arpa/api/worker
      secret: recipes
      internal: 192.168.1.30:443
    

    The keep connects to that ip:port and sends the name as the host. The name does not need to resolve. With internal, the address may be http://.

    The ip:port must 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/10 or fc00::/7. A sealed address is refused: loopback, link-local, multicast, a translation range, or the installation’s own ranges.

  2. Restart the three pieces: systemctl --user restart sandcastle.target.

  3. 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.

  1. Read the changelog. A new major version means you edit the configuration, or a promise changed.

  2. 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 .
    
  3. Install it:

    ./dist/sandcastle install
    

    It checks the configuration first. If it is not valid for the new version, install says what is wrong and changes nothing.

    While the four images are built, the running pieces keep working. Then install puts the new binary in ~/.local/bin/sandcastle by a rename, so a core still running keeps its old file. It writes the units again, restarts sandcastle.target and removes the images of older builds. It remembers the ranges you gave with --link and --egress; you do not repeat them.

  4. 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, memory and pids controllers. 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-reload
    

    Then 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", then podman system migrate.

  • The link or egress range is already in use. Choose two free IPv4 /24s with --link and --egress. install remembers 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

  1. 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.
  2. The source hands out a job and holds it for this worker for as long as it decides.
  3. A box runs the job: made for it alone, with no network and no secret, and gone when the job ends.
  4. 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_hour caps 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.

CallRequest bodyAnswer
GET /pingnone200 {"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

  • capabilities lists what the source may hand out now: those the operator allowed for it, minus any that is resting. Hand out no job for another.
  • limit is how many jobs to hand out at most; sandcastle asks for 1.
  • jobs may be empty.
  • A job has three fields, and sandcastle reads no other. Any other field, such as a destination or a url, 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: claude or ocr.
    • 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

FieldWhenWhat it holds
statusalwayssucceeded, failed or released
outputsucceededwhat the capability gave back
errorfailed and releasedwhy, in a line of text
started_at, finished_atalwayswhen the job started and ended, in RFC 3339
  • released means “not now, and not the job’s fault”: the capability could not be used. Offer the job again without counting an attempt.
  • Answer 409 when 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

  • 401 or 403 to any call means the token is wrong. check says so, and names the secret to set.
  • Any other answer outside 2xx fails 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 /tmp as its only writable folder, of box.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.memory with 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, capability and input, and passes the input on untouched. Other fields in the envelope, such as a destination or a url, have no effect.
  • Each capability has a max_input and a max_output, and a timeout for its box (the source’s job_timeout also applies). A line from the box that is not JSON, of an unknown type, one too many, larger than max_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 GET and POST. The path is added to the destination’s address, 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 a url, is refused.
  • The headers it takes are content-type, accept and anthropic-version. An authentication header is refused: the keep adds the secret itself, as x-api-key for claude and as Authorization: Bearer for 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: the claude job 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 internal ip:port), always verifies TLS, and never follows a redirect. It gives back only the status, content-type, retry-after and 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 with CONNECT to 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:

idpromise
S1read your keys or tokens
S2reach the internal network, by a redirect or by DNS
S3leave anything that outlives the job
S4spend more than its max_jobs_per_hour
S5see 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:

idpromise
B0run 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
B1read your keys or tokens
B2reach any network
B3leave files or processes behind
B4read the records of other jobs
B5spend memory, processor, processes, disk or time without a limit

A model’s reply

idpromise
M1a reply is never run
M2a reply goes only to the source that sent the job
M3a reply causes no request and no read
M4a job costs one request to the model at most

Another account without privileges

Another account on the machine, with no privileges, cannot:

idpromise
A1read your keys, tokens and records
A2see a secret in the list of processes
A3hand sandcastle a job or speak to it
A4use the gate

The network

idpromise
N1the internal network is closed after a name is resolved
N2a sealed address never opens
N3an internal exception opens only its own ip:port
N4the keep never follows a redirect
N5the keep checks the port
N6there are no roles: only the default rule
N7the keep and the gate run with the locks the repository gives them
N8the 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.

  1. Root and the machine’s system: the kernel, Podman and systemd.
  2. Programs that run as the same account as sandcastle.
  3. 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.
  4. Availability: a source can delay the others, a job can fail, sandcastle can stop.
  5. The correctness of a result: it can be wrong or misleading and still be valid.
  6. A base image that was already bad when its digest was fixed.
  7. An internal destination you opened, and http:// to it.
  8. Money beyond the spending limit you set at the model’s provider.
  9. Escapes from Podman’s isolation through flaws nobody knows yet.
  10. The model’s provider.
  11. Data that leaves through a destination you allowed.
  12. Side channels.
  13. 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.
  14. 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

IdPromiseAttacksLevelProof
S1reads no key or tokena 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 seesU, Ihostile_integration_test.go, internal/keep/keep_test.go, main_test.go
S2reaches no internal addressa redirect to 192.168.x, 169.254.169.254 and localhost; the source’s name starts to resolve inside; internal URLs in the contentU, Idns_integration_test.go, hostile_integration_test.go, internal/config/config_test.go
S3leaves nothing behind the jobthe capability writes files and starts processes; afterwards no box with the label, no volume, no file outside the job’s recordIattacker_integration_test.go
S4spends no more than its limitjobs without end; content above the maximum; a prompt above max_inputUinternal/box/core_test.go, internal/runner/runner_test.go
S5sees and changes no job of another sourcea job with another source’s job id; with an honest source and an attacking one, the attacker never gets the other’s result or tokenU, Ihostile_integration_test.go, internal/keep/keep_test.go, internal/source/client_test.go, main_test.go

The program in a box

IdPromiseAttacksLevelProof
B0runs with every lock of its boxinspecting 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 refusedIintegration_test.go, internal/box/core_test.go, internal/box/podman_test.go, internal/config/config_test.go
B1reads 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 processesIattacker_integration_test.go
B2reaches no networkthe list of ways out; abstract Unix sockets; a second request out; choosing the destination, the model or the headersU, Iattacker_integration_test.go, internal/box/core_test.go
B3leaves no file or processwriting 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 startIattacker_integration_test.go, integration_test.go, internal/box/podman_test.go
B4reads no record of another joblooking for the data_dir and the previous job’s resultIattacker_integration_test.go
B5spends nothing without a limitmemory 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 breakIattacker_integration_test.go, integration_test.go, internal/box/podman_test.go

A model’s reply

IdPromiseAttacksLevelProof
M1is never runshell commands and “ignore the instructions” reach the source as text; fields outside the schema are refusedU, Ihostile_integration_test.go, internal/capability/claude_test.go, main_test.go
M2goes only to the source of the joba reply that asks for another source, or holds another job’s idUmain_test.go
M3causes no request and no readURLs and paths (file:///etc/passwd) in the reply; the keep’s log shows one requestIhostile_integration_test.go
M4costs one request at mosta request to go on, max_tokens, tool_use; no second request leavesU, Ihostile_integration_test.go, internal/box/core_test.go, internal/capability/claude_test.go

Another account without privileges

IdPromiseAttacksLevelProof
A1reads no key, token or recordanother account reads the configuration, the secrets folder and the data_dirMthe manual list
A2sees no secret in the processesps auxww and /proc/*/environ during a job, looking for the decoyMthe manual list
A3hands sandcastle no jobconnecting to the keep’s socket; writing to the data_dirMthe manual list
A4cannot use the gateconnecting to the gate’s address and port; scanning the machine’s portsMthe manual list

The network

IdPromiseAttacksLevelProof
N1the internal network is closed after resolvingan 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 addressIdns_integration_test.go, online_integration_test.go
N2a sealed address never opensthe configuration refuses internal: with each sealed address, loopback and link-local among them; the gate’s files never hold oneUinternal/config/internal_test.go, internal/ranges/ranges_test.go, setup_test.go
N3an exception opens only its ip:portanother 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 sentIhostile_integration_test.go
N4the keep never follows a redirectcovered by S1 and S2—hostile_integration_test.go, internal/keep/keep_test.go, internal/source/client_test.go, main_test.go
N5the keep checks the portthe keep only asks for the name:port of the configuration; control: the gate alone lets port 22 throughU, Idns_integration_test.go, internal/keep/gate_test.go, internal/keep/keep_test.go
N6only the default rulethe gate’s file has one rule; a client without a certificate falls in it and in no otherU, Iinstall_integration_test.go, internal/gate/gate_test.go, setup_test.go
N7the keep and the gate run with their locksinspecting 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 failsIinstall_integration_test.go, internal/install/inspect_test.go, internal/install/units_test.go
N8the egress network refuses the inside without the gatethe 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 reachedIegress_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.

PointNext toWhat an attacker can do
3, a tampered sandcastleN8a 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 destinationN3read and change what goes to an http:// internal destination, which nothing encrypts
8, moneyS4, M4spend up to the limit set at the model’s provider, one request per job
14, the operator’s mistakesB5use 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 the data_dir. Each read fails with “permission denied”. Control: the operator reads them.
  • A2: during a job, as sctest-other, run ps auxww and read /proc/*/environ, looking for the decoy SCTEST-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 the data_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
LevelCommandResult
Ugo test ./...pass
Igo test -tags integration ./...pass
Onlinego test -tags integration,online -run Online ./...pass
Fuzzeach target for 5 minutesno new failure
Manual stepResult
A1pass, “permission denied” on the configuration, the secrets and the data_dir; the operator reads them
A2pass, the decoy is in no process list or environment in 30 samples, 6 during a box; the operator finds it in its files
A3pass, “permission denied” on the keep’s socket and on the data_dir; the operator connects
A4pass, the gate does not answer; no listening port of the machine is sandcastle’s
Trafficpass, sandcastle’s pieces open only keep → gate and gate → an allowed destination; every other flow is a test’s attacker or control
Bootpass, 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 namespass: 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>
CommandWhat 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
uninstallstops everything and removes the units, the images and the two networks; the configuration, the secrets, the jobs and the binary stay. See Uninstall
checkasks the keep, checks the configuration, the units, the networks and the images, pings every source and tests every capability. See Check messages
runpolls every source at its own interval until it is stopped. The core’s service runs it
oncepolls 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 listlists the secrets by name: used, unused or missing
secret rm <name>deletes a secret
versionprints 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

FlagDefaultWhat it sets
--config DIR$SANDCASTLE_CONFIG, or ~/.config/sandcastlethe configuration folder
--secrets DIR~/.local/share/sandcastle/secretsthe secrets folder
--prefix NAMEsandcastlethe first word of every name of the installation: units, images, networks, the keep’s socket
--link RANGEsee Rangesinstall only: the range of the network between the keep and the gate
--egress RANGEsee Rangesinstall only: the range of the gate’s way out

Exit codes

CodeMeans
0done; for check, no line is FAIL
1it failed, and said why on stderr; for check, a line is FAIL
2the command or its arguments were wrong; the usage is printed

Started by sandcastle itself

These run inside the installation. You do not start them.

CommandWhat 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 OUTthe 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.

KeyDefaultWhat it sets
data_dir~/.local/share/sandcastle/jobswhere each job’s record is kept; see Files and folders. A folder that holds the secrets folder, or lies inside it, is refused
retention_days30records older than this many days are deleted; at least 1
capabilitiesnoneone 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
KeyDefaultWhat it sets
destination.addressrequiredhttps:// 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.secretrequiredthe name of a secret
destination.internalnoneone 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.

KeyDefaultWhat it sets
namerequiredthe source’s name, and its destination’s. No slashes, dots or spaces, not claude or ocr, and not another source’s
destinationrequiredwhere the source is, and its secret; see Destinations
poll_intervalrequiredhow often the source is asked for jobs; at least 1s
capabilitiesrequiredthe capabilities the source may use, each enabled in config.yaml; a job for another fails
max_jobs_per_hourrequiredhow many jobs of this source run in an hour, at most; at least 1
job_timeoutrequiredhow 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.

KeyDefaultWhat it sets
enabledfalsewhether sources may use it
destinationrequiredthe Claude API, written by hand: https://api.anthropic.com, with the key’s secret
modelrequiredthe model every job asks, such as claude-haiku-5-5
effortlowhow hard the model works: low, medium, high, xhigh or max
max_tokens16000the longest reply; a reply cut off here fails the job
system_prompta fixed, generic onethe system prompt of every request
timeout5mhow long the job’s box may run, the request included
cooldown15mhow long the capability rests after a failure that is not the job’s
max_input256KiBthe largest job input, and the largest prompt sent
max_output1MiBthe largest line the box may write back
box.memory256MiBthe box’s memory, with no swap on top
box.cpus1the box’s processors
box.pids32the box’s processes
box.tmp16MiBthe 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.

KeyDefaultWhat it sets
enabledfalsewhether sources may use it
languagespor+engthe Tesseract languages, joined with +; only these go into the image
max_pixels25000000the largest image decoded; a larger one fails the job
max_edge2500the longest side an image is scaled down to before it is read
timeout30show long the job’s box may run
cooldown15mhow long the capability rests after a failure that is not the job’s
max_input16MiBthe largest job input; a larger one fails before a box starts
max_output1MiBthe largest line the box may write back
box.memory1GiBthe box’s memory, with no swap on top
box.cpus1the box’s processors
box.pids64the box’s processes
box.tmp64MiBthe 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, MiB or GiB: 1024, 256KiB, 16MiB.
  • box.cpus is a number above 0, such as 1 or 0.5. box.pids is 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.

KeyWhat replaces it
sandboxnothing: every job runs in a box of its own
tesseractnothing: Tesseract comes in the ocr box’s image
api_key_filesandcastle secret set claude, named in destination.secret
base_urldestination.address
token_filesandcastle 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.

MarkMeans
okthe item is as it should be
FAILthe item keeps the installation from working; fix it
warnsomething 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

LineWhat to do
ok keep: ready—
FAIL keep: the keep does not answer at <socket>; start it with sandcastle keepsee Troubleshoot
FAIL keep: XDG_RUNTIME_DIR is not set to a folder; the keep's socket lives thererun 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

LineWhat 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

LineWhat to do
ok keep and gate: run as their units define—
FAIL <piece>: the container sandcastle-<piece> does not exist; run sandcastle installsandcastle install
FAIL <piece>: the container is <state>, not runningsystemctl --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 notsandcastle install
warn <piece>: the container was not started with "<flag>", as its unit saysa 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 portsthe same
warn <piece>: the container has capabilities <list>the same
warn <piece>: the container's disk is writablethe 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=nonethe 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 installsandcastle 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-gaterestart 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 defaultthe 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.

LineWhat 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 openedchoose 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.

LineWhat to do
FAIL config: capability claude: destination and model are requiredadd them to capabilities.claude
FAIL config: capability claude: effort "<value>" is not one of low, medium, high, xhigh, maxfix effort
FAIL config: capability claude: max_tokens must be at least 1fix max_tokens
FAIL config: capability ocr: reaches nothing outside its box; remove destinationremove the block
FAIL config: capability <name>: sandcastle has no such capabilityonly claude and ocr exist
FAIL config: source <name>: the name "<name>" is a capability's; give the source another namerename the source
FAIL config: source <name>: the capability "<name>" is not enabled in config.yamlenable 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 installsandcastle install
FAIL capability ocr: the image has the languages "<languages>" and the configuration "<languages>"; run sandcastle installsandcastle 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

LineWhat 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 configurationsystemctl --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

LineWhat 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 setsee claude
FAIL capability claude: the model "<model>" does not exist for this keyfix 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 itsandcastle 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.

LineWhat 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:portsee 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 configurationthe same
warn gate: refused <name> <n> time(s) in the last hour, last at <time> (<reason>): see the gate's logjournalctl --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_input fails the job before a box starts.
  • A source may use a capability only when the operator enabled it in config.yaml and listed it in the source’s capabilities. 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 its cooldown, and no job of it is claimed until the rest is over.
  • check asks 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’s system_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 (no minLength, maxLength, minimum, maximum and 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_pixels fails the job before it is decoded.
  • An image with a side longer than max_edge is 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.

PathWhat it holdsWritten byuninstall
~/.config/sandcastle/the configurationyoukeeps it
~/.local/share/sandcastle/secrets/one file per secret, readable only by yousandcastle secret setkeeps it
~/.local/share/sandcastle/jobs/the jobs’ records; it is data_dirthe corekeeps it
~/.local/bin/sandcastlethe installed binary, which the core and the gate’s preparation runinstallkeeps it
~/.config/containers/systemd/sandcastle-link.networkthe link network’s unitinstallremoves it
~/.config/containers/systemd/sandcastle-keep.containerthe keep’s unitinstallremoves it
~/.config/containers/systemd/sandcastle-gate.containerthe gate’s unitinstallremoves it
~/.config/systemd/user/sandcastle.servicethe core’s unitinstallremoves it
~/.config/systemd/user/sandcastle.targetthe target that groups the threeinstallremoves it
~/.config/containers/systemd/sandcastle-*.container.d/your drop-insyoukeeps them
$XDG_RUNTIME_DIR/sandcastle-keep/keep.sockthe keep’s socket, the core’s only way to itthe keepgone when the keep stops
$XDG_RUNTIME_DIR/sandcastle-gate/the gate’s smokescreen.yaml and acl.yaml, its list of namesthe gate’s preparationgone 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.

NameWhat it is
sandcastle.targetgroups the three services; enabled, so it starts with the machine
sandcastle.servicethe core
sandcastle-keep.service, container sandcastle-keepthe keep
sandcastle-gate.service, container sandcastle-gatethe gate
sandcastle-linkthe network of the keep and the gate, with no way out
sandcastle-egressthe 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

NetworkDefaultFlag of install
sandcastle-link10.89.250.0/24--link
sandcastle-egress10.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/subuid and in /etc/subgid.
  • The cpu, memory and pids cgroup controllers delegated to your user.
  • Two free IPv4 /24 ranges 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