fix(sandbox): authenticate staging tickets, atomic ticket claim, confine guest paths
Data-plane fixes from the 2026-10-22 sandbox lifecycle pentest (findings W3-F1 through W3-F6). Four changes, each with regression tests.
1. Staging tickets are no longer credential-free (High)
stage_upload and download_path inserted their internal staging blobs with token: "", and the stream handlers default a missing Authorization header to "" — so anyone who learned an internal ticket id could claim it over the network with no credentials at all and read guest files (or splice into a provider's upload).
Design choice: rather than carrying a token through CopyWork to the agent (new proto surface, and the token would sit in the durable work queue), staging claims are bound to the provider's identity. Staging blobs are marked internal, and claim_ticket refuses internal blobs outright: the only route to them is the provider link (agent_blob / the agent upload route), whose caller is authenticated by its enrolled mTLS certificate and fenced to the provider that owns the sandbox. As defense in depth the staging records also carry a real random token now, and an empty presented token can never authenticate any ticket — the invariant no longer depends on an insert site remembering a token.
2. Ticket claims are atomic (Medium)
claim_ticket validated a ticket without consuming it, so concurrent claims both passed and then raced on the ticket's one spool path: duplicate provider work, two writers splicing one upload file, and a spurious 500 for the loser of the upload race. Validation and the claim mark now happen in a single lock hold; the second claim gets a clean 404. The provider direction keeps its agent-flow semantics, but provider uploads get the same one-writer guard (begin_agent_write/end_agent_write): a second concurrent write for one ticket is refused with AlreadyExists instead of splicing the spool file. The guard is a concurrency fence only — a redelivered copy may still rewrite the blob sequentially once the first write ends.
3. Expiry is enforced at claim, not at finish (Low)
The sweeper deleted expired blob records and files unconditionally, so an upload claimed before expiry but still streaming when the TTL lapsed lost its bytes mid-transfer and finished with a 404. A successful claim now extends the ticket by a full TTL, and enqueueing a provider copy extends its blob to the copy's own work-timeout bound (copies may legitimately run longer than a ticket TTL). Unclaimed tickets still expire and are swept, as before.
4. Guest paths are confined (Critical/High, path-driver scope)
OpenStream only required a leading /, so a path like /../../host/... passed validation; the fake driver's guest() was a plain PathBuf::join, resolving it anywhere on the host — reading arbitrary host files (the service's client token and CA private key included) and extracting upload tars outside any sandbox. A guest-planted symlink gave the same escape for copy_out.
- Service side (driver-agnostic):
OpenStreamand the staging paths reject any guest path containing a..component (either separator spelling). A legitimate guest path never needs one. - Fake driver:
guest()resolves lexically under the sandbox root and refuses a path that would escape it;copy_in,copy_out, and the exec workdir propagate the refusal.copy_outadditionally refuses a source that canonicalizes outside the root and builds its tar withfollow_symlinks(false), so symlinks are stored as links and never dereferenced.
The SmolVM driver resolves guest paths in-guest and is unaffected; the service-side rejection is the cheap, shared layer.
Tests
cargo test -p preloop-sandbox: 225 passed, 0 failed (lib 122, conformance 57, mTLS 13, smolvm CLI 32, smolvm e2e 1). New regression tests: staging tickets unclaimable on the data plane, empty token never authenticates, a ticket claims exactly once, expiry enforced at claim with a full TTL for the winner, copies keep their blob alive for the work timeout, concurrent HTTP claims have exactly one winner (download and upload), OpenStream rejects .., fake-driver confinement for copy_in/copy_out/exec, and copy_out does not follow a planted symlink. cargo fmt and cargo clippy -p preloop-sandbox --all-targets clean (two pre-existing warnings in drivers/smolvm/cli.rs test code, untouched here).
Summary by cubic
Fixes four data-plane sandbox vulnerabilities found in the 2026-10-22 lifecycle pentest (W3-F1 through W3-F6), each with regression tests.
Data-plane tickets
- Staging tickets are now marked internal and minted with a real token; only the mTLS-authenticated provider link can reach them, and an empty token never authenticates any ticket.
- Claim validation and the single-use mark now happen in one lock hold, so a ticket claims exactly once and the provider upload route gets the same one-writer guard.
- A successful claim extends the ticket by a full TTL, and a provider copy extends its blob to the work timeout, so the sweeper can't delete an in-flight transfer's bytes.
Guest paths
- The service rejects any
..component inOpenStreamand the staging paths, for both separator spellings. - The fake driver now confines guest paths lexically under the sandbox root for
copy_in,copy_out, and exec;copy_outalso refuses sources that escape via symlink and stores symlinks as links rather than dereferencing them. - The SmolVM driver resolves guest paths in-guest and is unaffected beyond the shared service check.
106 new regression tests plus an existing test reformatted to pass the rustfmt gate; cargo fmt and cargo clippy -p preloop-sandbox --all-targets are clean.