[sandbox audit] Pin the sandbox server binary by digest and verify it before running it - #4837
Draft
Wauplin wants to merge 1 commit into
Draft
[sandbox audit] Pin the sandbox server binary by digest and verify it before running it#4837Wauplin wants to merge 1 commit into
Wauplin wants to merge 1 commit into
Conversation
|
The docs for this PR live here. All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update. |
…nning it
Every sandbox job ran a bootstrap that fetched a *mutable* public object,
`chmod +x`ed it and `exec`ed it -- as root, as PID 1, holding the derived
sandbox token:
d=/tmp/.sbx-server
if command -v wget ...; then wget -q -O "$d" "$SBX_SERVER_URL"
...
chmod +x "$d"
exec "$d"
`$SBX_SERVER_URL` was `/buckets/huggingface/sbx-server/resolve/sbx-server`,
the alias the publish workflow overwrites on every release. Nothing in the
chain checked a version, a digest or a signature, so the client had no way
to know which bytes it had just made PID 1. A single write to that bucket
path reaches every sandbox of every user on their next cold start. Nothing
suggests that happened -- the point is that the design offered no guarantee
about the next fetch, even though the immutable artefact it needed was
already being published alongside the alias.
Related: `wait_ready` polled `/health` and read nothing out of it. A pool
host keeps the binary it downloaded at boot for up to 24h, so a client and a
server from different releases meet routinely, and the per-sandbox
capability token added recently is exactly the kind of change that fails
quietly -- a 403 on an unrelated route, several calls later.
What changed:
- `SANDBOX_SERVER_SHA256` and `SANDBOX_SERVER_VERSION` pin one exact server
build. `SBX_SERVER_URL` now names the digest-addressed bucket object
(`sbx-server-<sha256>`), not the alias: a fetch that names the content it
wants cannot be answered with different content.
- The bootstrap verifies the download against that digest and refuses to run
it on a mismatch. The check happens **before** `chmod +x` -- an unverified
file that is already executable is one slip away from being executed.
- `sha256sum` is not guaranteed in an arbitrary image, so `openssl dgst
-sha256` is the second try. With neither, the job **refuses** rather than
running unverified code. `SBX_ALLOW_UNVERIFIED_SERVER=1` (passed through
`env=`) is the only way past it, and it can only skip a *missing tool*,
never a failed comparison.
- The bucket-mount fallback (for images with no wget or curl) reads the
digest-named file and goes through the same check. It previously read the
alias, so leaving it alone would have left the hole open on exactly the
images least likely to notice.
- `wait_ready` reads `protocol` from `/health` and raises a `SandboxError`
naming host recycling when the server is older than this client needs.
Permissive in one direction only: a newer server that still reports a
protocol this client knows is accepted; an older one, or one that reports
none, is refused.
On failing closed: a survey of the plausible base images found nothing that
lacks both hash tools. `python:3.12` and `node:22` have sha256sum and
openssl; `python:3.12-slim` has both but neither wget nor curl;
`ubuntu:26.04`, `alpine:3.22` and `busybox` have sha256sum. So the default
is expected to cost nothing in practice, and the escape hatch is there for
the image we have not thought of.
Validation:
`PYTHONPATH=src pytest tests/test_sandbox.py -q` -> 64 passed (+9).
`ruff check` / `ruff format --check` clean; `ty check src` reports the same
4 pre-existing diagnostics as on the base.
The bootstrap was also run by hand -- the real musl binary, real images, a
local `python3 -m http.server`, never the real bucket -- across 14 shapes.
Every good case exec'd (`sbx-server 0.6.0 listening on 0.0.0.0:49912`);
every corrupted case exited 1 without exec'ing:
1 python:3.12 wget, good download exit=0 EXEC'D
2 python:3.12 wget, CORRUPTED download exit=1 did NOT exec
3 python:3.12 curl only, good exit=0 EXEC'D
4 python:3.12 curl only, CORRUPTED exit=1 did NOT exec
5 python:3.12 no wget/curl, mount good exit=0 EXEC'D
6 python:3.12 no wget/curl, mount CORRUPT exit=1 did NOT exec
7 python:3.12 no sha256sum/openssl exit=1 did NOT exec
8 python:3.12 no hash tool + opt-out exit=0 EXEC'D
9 python:3.12 openssl only, good exit=0 EXEC'D
10 python:3.12 openssl only, CORRUPTED exit=1 did NOT exec
11 alpine:3.22 busybox, good exit=0 EXEC'D
12 alpine:3.22 busybox, CORRUPTED exit=1 did NOT exec
13 ubuntu:26.04 no dl tool, mount good exit=0 EXEC'D
14 ubuntu:26.04 no dl tool, mount CORRUPT exit=1 did NOT exec
A mismatch is diagnosed rather than silent, which matters because
`_tail_job_logs` is what surfaces it in the `SandboxError`:
sbx: sandbox server digest mismatch: got de7f7486...,
sbx: expected 501290ea...
sbx: refusing to run it. Upgrade huggingface_hub if this client is
sbx: pinned to a digest that is no longer published.
Not validated: no live HF Jobs run. The digest constant is also a local
musl build of the companion server branch, so it has to be replaced with
the digest the server's publish workflow prints before this can be merged --
until then `sbx-server-<sha256>` does not exist in the bucket and every
`create()` fails closed, which is the correct failure but not a shippable
one.
Behaviour changes:
- Every server release now needs a matching client release. That is the
trade that makes the artefact verifiable, and the protocol check is what
makes the transition legible instead of mysterious.
- A job on an image with neither `sha256sum` nor `openssl` now fails to
start unless `SBX_ALLOW_UNVERIFIED_SERVER=1` is passed.
- The bootstrap script's text changed, and pool-host admission compares a
candidate job's command against it -- so hosts booted by an older client
are no longer adopted. Intended, and the reason the protocol check exists.
Companion server change: sandbox-server branch
`security/pin-and-verify-binary` (linked from the PR description).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Wauplin
force-pushed
the
security/pin-and-verify-binary
branch
from
September 8, 2026 15:13
1e9cdfe to
9b2f804
Compare
Wauplin
changed the base branch from
security/host-adoption-admission-control
to
security/opaque-process-ids
September 8, 2026 15:13
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Companion server PR: huggingface/sandbox-server#24 — these two have to land together, server first. See "Rollout".
What was wrong
Every sandbox job ran this at startup, as root, as PID 1, holding the derived sandbox token:
$SBX_SERVER_URLwas…/buckets/huggingface/sbx-server/resolve/sbx-server— the mutable alias that the server's publish workflow overwrites on every release. Nothing in the chain checked a version, a digest or a signature, so the client had no way to know which bytes it had just made PID 1 of a machine holding a user credential. A single write to that bucket path reaches every sandbox of every user on their next cold start.Nothing suggests that happened. What bothers me is that the design already published the immutable artefact it needed (
sbx-server-<commit>, right next to the alias) and simply didn't consume it.Related, and it compounds the blast radius:
wait_readypolled/healthand read nothing out of it. A pool host keeps the binary it downloaded at boot for up to 24h, so a client and a server from different releases meet routinely — and the per-sandbox capability token from #4832 is exactly the kind of change that fails quietly under that, as a 403 on an unrelated route several calls later.What changed
Pin.
SANDBOX_SERVER_SHA256andSANDBOX_SERVER_VERSIONname one exact server build, next toDEFAULT_IMAGE.SBX_SERVER_URLnow points at the digest-addressed objectsbx-server-<sha256>, not the alias: a fetch that names the content it wants cannot be answered with different content.Verify, and fail closed. The bootstrap checks the download against that digest and refuses on a mismatch. Specifics that matter:
chmod +x. An unverified file that is already executable is one slip away from being executed.sha256sumis not guaranteed in an arbitrary image, soopenssl dgst -sha256is the second try, and both feed the same string comparison so the two paths cannot drift.SBX_ALLOW_UNVERIFIED_SERVER=1, passed throughenv=, is the only way past, and it can only skip a missing tool — never a failed comparison.Negotiate.
wait_readyreadsprotocolfrom/healthand raises aSandboxErrornaming host recycling when the server is older than this client needs. Permissive in one direction only: a newer server that still reports a protocol this client knows is accepted (it is declaring it kept serving it); an older one, or one that reports none at all, is refused.versionis no use for this — it moves for a doc fix as readily as for a protocol break.On failing closed
The plan was to fail closed and then measure the cost, so: nothing plausible lacks both hash tools.
Every one of them ships
sha256sum. The default should cost nothing in practice; the escape hatch is for the image we haven't thought of.Validation
+9 cases: that the pin is a real sha256, that the URL names the digest rather than the alias, that verification precedes
chmod +x, that the opt-out is reachable only after both tools are missing, and five on the protocol check (match, newer, older, absent, non-integer).ruff checkandruff format --checkare clean;ty check srcreports the same 4 pre-existing diagnostics as the base.The bootstrap was also run by hand — the real musl binary, real images, a local
python3 -m http.server, never the real bucket. Exec is detected by the server's own startup line; the good cases exit 0 after the idle watchdog fires, so "exec'd" and "refused" are never ambiguous.SBX_ALLOW_UNVERIFIED_SERVER=1A good case:
A corrupted one — diagnosed, not silent, which matters because
_tail_job_logsis what surfaces it inside theSandboxError:And the no-tool refusal:
Not validated
Sandbox.create()with the pinned digest, plus one with a deliberately wrong digest asserting the job fails fast with the digest error in its logs, is still owed before merge.Rollout — do not merge this first
SANDBOX_SERVER_SHA256currently holds the digest of a local musl build of the companion server branch. That object does not exist in the bucket, so as it stands everycreate()would fail closed with a 404 on the download. Correct behaviour, unshippable state. Order:sbx-server-<sha256>and prints the digest in its run summary.SANDBOX_SERVER_SHA256here with that digest, re-run the live checks above, then merge.Behaviour changes
sha256sumnoropensslnow fails to start unlessSBX_ALLOW_UNVERIFIED_SERVER=1is passed.commandagainst it — so hosts booted by an older client are no longer adopted, and the pool boots its own instead. Intended, and part of the same story as the protocol check.Follow-ups, deliberately not folded in
wait_ready, which is only called on a job this client just booted._connect_hostand the pool-cache warm path build a transport without probing/healthat all, so an adopted old host is currently caught by the command comparison above rather than by the protocol field. Extending the check to adoption means one extra request per candidate host, so it wants its own PR — happy to open the issue.create()time when the requested image is known to ship neither hash tool. The survey above suggests that set is empty, so I left it out.Note
High Risk
Changes what runs as root with the sandbox token at job startup and ties client/server releases to pinned digests; mis-timed rollout or digest mismatch blocks all sandbox creates until hub and server publish align.
Overview
Sandbox jobs no longer download and run the mutable
sbx-serverbucket alias as PID 1. The client pinsSANDBOX_SERVER_VERSION/SANDBOX_SERVER_SHA256, pointsSBX_SERVER_URLatsbx-server-<digest>, and extends the shell bootstrap to hash-check the binary beforechmod +x(withsha256sum/openssl, fail-closed, and optionalSBX_ALLOW_UNVERIFIED_SERVER=1only when both tools are missing). The bucket-mount fallback copies the digest-named object through the same check.wait_readynow readsprotocolfrom/healthvia_check_server_protocol: accepts servers at or aboveSANDBOX_SERVER_PROTOCOL(2), rejects older or undeclared wire contracts with an error that tells users to recycle pool hosts.Tests add
TestServerBinaryPinningandTestProtocolNegotiation, and the fake server serves unauthenticated/healthwith configurableprotocol.Reviewed by Cursor Bugbot for commit 9b2f804. Bugbot is set up for automated code reviews on this repo. Configure here.