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

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.