[sandbox audit] Give each pooled sandbox its own capability token - #18
Draft
Wauplin wants to merge 2 commits into
Draft
[sandbox audit] Give each pooled sandbox its own capability token#18Wauplin wants to merge 2 commits into
Wauplin wants to merge 2 commits into
Conversation
`SBX_TOKEN` was one value per server process, and in host mode it
authorized every route for every sandbox on the host plus the pool
management routes. There was nothing narrower to hand out, so the only
credential a pooled sandbox's operator could be given was one that also
addressed its siblings and could delete the pool. The documentation
described it as per-sandbox; it was per-job.
Each sandbox now gets a random 256-bit token at creation, returned by
`POST /v1/sandboxes` and recoverable with the host token via
`GET /v1/sandboxes/{id}/token` (so reconnecting to a sandbox this client
did not create stays stateless, with no local state to copy).
Authorization in host mode now distinguishes:
- pool lifecycle and token recovery are management operations, and need the
host token;
- a per-sandbox route accepts that sandbox's own token, and no other's.
Token recovery is deliberately management-gated: if a sandbox's own token
could read `/v1/sandboxes/<other>/token`, the scoping would be trivially
bypassable.
The host token is still accepted on per-sandbox routes while
`SBX_COMPAT_HOST_TOKEN=1` (the default). Every job downloads the server
binary fresh from a mutable path, so a hard break would break every client
that predates this change the moment it is published. The compat direction
is a management credential having authority over the sandboxes it created --
not a sandbox credential reaching a sibling, which is what this closes --
and `SBX_COMPAT_HOST_TOKEN=0` refuses it for deployments that want the
narrow rule today.
Token and id generation now propagate an error instead of falling back to a
timestamp when `/dev/urandom` cannot be read. That fallback was survivable
for an id; for a credential it would mean a predictable one, so a failed
create is the better outcome.
Validation:
- 26 unit tests, including that a sibling's token is refused, that the
compat window changes only the host token's reach and never makes it look
like a sandbox token, and that token recovery classifies as management.
- `scripts/token-scope-regression.sh`: root-container end-to-end with two
sandboxes. Verified to fail on the parent commit (9 failures: no token in
the create response, no recovery route, no scope enforcement) and to pass
here, including that `SBX_COMPAT_HOST_TOKEN=0` refuses the host token on
scoped routes while management still works.
- Driven end-to-end from the real Python client against this binary: A's
token cannot exec, read, write, delete or proxy into B, cannot create,
list or delete sandboxes, and cannot recover any token; the host token
recovers A's token and matches.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Scoping per-sandbox routes made an unknown host-mode route (a dedicated route reached in host mode) fail authorization with 403, pre-empting the route gate that should answer 404. Whether a route exists is not an authorization question: check the host credential there, so a valid caller gets an accurate 404 while an invalid one still gets 403 and learns nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Sep 8, 2026
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.
Why
SBX_TOKENwas one value per server process. In host mode it authorized every route forevery sandbox on the host, plus the pool management routes. So there was nothing narrower to
hand out: the only credential a single sandbox's operator could be given also addressed its
siblings and could delete the pool. The docs called it a per-sandbox token; it was per-job.
That mattered most for
proxy_headers, which the client hands to browsers and WebSocketclients — i.e. the credential most likely to end up somewhere you don't control was the
widest one available.
Approach
Each sandbox gets a random 256-bit token at creation, returned by
POST /v1/sandboxesandrecoverable with the host token via
GET /v1/sandboxes/{id}/token(so reconnecting to asandbox this client didn't create stays stateless — no local state to copy, which is the
property the whole nonce design exists to preserve).
Host-mode authorization now distinguishes two kinds of route:
POST/GET/DELETE /v1/sandboxesGET /v1/sandboxes/{id}/token/v1/sandboxes/{id}/…Token recovery is deliberately management-gated: if a sandbox's own token could read
/v1/sandboxes/<other>/token, the scoping would be trivially bypassable. There is a test forexactly that.
Also: id and token generation now propagate an error instead of falling back to a timestamp
when
/dev/urandomcannot be read. Survivable for an id; for a credential it would mean apredictable credential, so a failed create is the better outcome.
The compatibility window — read this bit
The host token is still accepted on per-sandbox routes while
SBX_COMPAT_HOST_TOKEN=1(the default). Every job downloads the server binary fresh from a mutable bucket path, so a
hard break would break every client predating this change the moment it is published.
I think that trade is right, and worth being precise about what it does and doesn't leave open:
finding.
created — which is what a management credential is for.
SBX_COMPAT_HOST_TOKEN=0refuses it today for deployments that want the narrow ruleimmediately, and is tested.
Retiring the default is a follow-up once clients have upgraded.
Validation
26 unit tests. A sibling's token is refused; the compat window changes only the host
token's reach and never makes it classify as a sandbox token; token recovery classifies as
management; the route→credential mapping is table-driven.
scripts/token-scope-regression.sh— root container, two sandboxes. Verified to failon the parent commit (9 failures: no token in the create response, no recovery route, no
scope enforcement) and to pass here, including the
SBX_COMPAT_HOST_TOKEN=0path.Driven end-to-end by the real Python client against this binary — the gap the audit
called out, since the client suite only ever talked to a fake that accepted the host token
everywhere:
Wire changes
POST /v1/sandboxesresponse gainstokenper sandbox (additive).GET /v1/sandboxes/{id}/token.SBX_COMPAT_HOST_TOKENenv var, documented in the README config table.