Skip to content
shippy

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

ActorTrusted 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 boxRunning recipes and publishing what its own sandbox produced.
A worker machineRunning the recipes the box leases to it, within what pools and audits bound.
A clientNothing by the box. And the client doesn't trust the box's answers with its file system either.
The operatorEverything.

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 /proc of 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.toml names 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/exec slots are shared in turns, and each principal's running and waiting requests are capped. One more is answered 429.
  • 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).

Go deeper