Threat model
The bar shippy sets for the box: one untrusted client must not be able to poison another client, whether through a build script, a procedural macro, a dependency, a recipe or anything it uploads. This page is a plain-language map of how. The full threat model names the code and the test behind every line.
Who is involved
| Actor | Trusted for |
|---|---|
| Trusted principal (CI on a protected branch) | The results it uploads, as they are. |
| Untrusted principal (a developer, an agent, a fork's CI) | Nothing. Every byte it uploads, every field of a recipe, and the code its recipe runs are treated as the attacker's. |
| The box | Running recipes and publishing what its own sandbox produced. |
| A worker machine | Running the recipes the box leases to it, within what pools and audits bound. |
| A client | Nothing by the box. And the client doesn't trust the box's answers with its file system either. |
| The operator | Everything. |
A trusted principal or root on the box is out of scope: both can publish what they like by design.
What is being protected
- Public results. Poisoning one is code execution on every machine that builds that crate.
- Blobs. A blob whose bytes changed would be a wrong input to every later run.
- Each principal's claims and the sources they uploaded.
- The box itself: its CPU, memory, processes and storage.
- A client's file system outside its build directories.
The four promises
Loading diagram...
1. Public results come from the sandbox
- Untrusted uploads are claims. Only verified or trusted results are published.
- The sandbox holds the recipe's declared inputs by digest. An input left out simply isn't there, so the run fails.
- A run is published only under a manifest that says what the run itself read: every file and variable its compiler named, each variable with the value the sandbox had, and none it did not read. A build script's run is published under the variables and files its script named. No manifest names a
SHIPPY_*variable. - The outputs of a result are the files its run wrote. An input declared at an output's name is refused.
- The compiler is one the box installed. A recipe's text only selects among installed toolchains, and a recipe that links must name the box's own linker.
- Results the sandbox made are filed under their key and the box's system image, so a box with different system files doesn't serve them.
- A client takes a result only when the result's own dep-info names no variable that has another value on the client's machine, whatever manifest led to it, and never prefers a manifest for the files it lists.
2. Nobody can tamper with what others read
- Inputs are bound read-only. The sandbox sees no store, no home, no network, four files of
/etc, and a/procof its own. - A system call filter refuses mounts, namespaces, tracing, BPF, io_uring and every socket family but Unix.
- A run ends only after its namespace-init pidfd confirms the whole namespace is gone. A per-run cgroup that cannot be emptied fails the run.
- Outputs are opened once without following links, checked as regular files, and hashed while copied into fresh store files that the sandbox never had open. Every path in a recipe or result is checked: no
.., and outputs only below the target directory. - A client restores only below its target directory, never following a link, and of a crate's result only the files that crate's compiler writes.
- A repository a client builds decides nothing beyond its own build. Its
shippy.tomlnames no server for the client's token, sources or results, its[env]and the cargo configuration in it name no proxy, store or setting of shippy's, and it sets no limit for the local store other projects share. A file of the machine that a crate reads through a link out of the checkout, or by an absolute path, is sent nowhere, nor is anything compiled from it. - A file in a target directory is no command. What shippy compiles again after a build, for the incremental state of restored crates, is what that user's own compiler wrappers recorded and the local store vouches for; it is given no token and no way to the box, and it follows no link out of the target directory.
3. Private stays private
- A claim is returned only to its principal.
- A blob is served only to a principal that sent it or may see a result naming it. Otherwise it is answered as missing.
- A compilation is given an input only for a principal that may read it. The box asks when the request arrives and again when the run places its inputs, and answers the same about a blob it has from someone else as about one it lacks.
- A client's request to the box declares only the files and variables its own machine knows the crate reads, never the ones a manifest someone else listed names, and never a
SHIPPY_*variable. The bearer token is in no compiler's or build script's environment.
4. Nobody can starve the box
- Every sandbox run has limits on wall time, processes, memory, CPU, open files and bytes written (a cgroup per run where the box can create one).
/v1/execslots are shared in turns, and each principal's running and waiting requests are capped. One more is answered429.- Each untrusted principal may hold a share of the store; past it, uploads are refused with
507. The lists of results it sends count for what their files weigh. - Claim re-runs go to the principal with the fewest in progress, so a thousand claims from one principal delay another's by one re-run.
- Comparing an uploaded library with the public one takes a few megabytes of memory whatever the archive holds. Four comparisons run at once, and one principal's uploads are compared one at a time.
Known limits
So treat the box like a CI runner for forks: no secrets on it besides the tokens file, and nothing on its network that trusts it. For defence in depth, a box can lease every run to worker machines and run no client code itself (exec.local = false).