[sandbox audit] Bind the sandbox pool cache to the endpoint, credential and namespace that wrote it - #4838
Conversation
The local pool cache is not passive data. `SandboxPool.connect()` rebuilds a pool from `$HF_HOME/sandbox/pools/<pool-id>.json` with no HTTP at all, `_seed_hosts_from_cache` turns each entry into a `_SandboxServer` from the cached `base_url` and `nonce`, and the first thing that happens to it is an authenticated `POST /v1/sandboxes` carrying the HF bearer and the token derived for that nonce. Two problems followed from that. The file was keyed by pool id alone, and its `namespace` field was read as configuration rather than compared against what the caller asked for -- `namespace=cache.namespace if namespace is None else namespace`. So a pool cached for org-A and then reached with `connect(pool, namespace="org-B")` routed the workload, and its env, to org-A's hosts. That needs no attacker: one credential and two orgs is an ordinary state of affairs, and nothing in the flow ever contradicts the file. Nor was the cache bound to the endpoint or the credential, so two users sharing a machine shared each other's entries. And the URL was taken on trust. A cache file (or anything that can write `$HF_HOME`) could name `http://127.0.0.1:1234` and receive both credentials on the first create -- with `follow_redirects=True` on the client, a legitimate-looking HTTPS URL could bounce them elsewhere too. `verified=False` only means "drop the host if it fails"; by then the credentials have been sent. What changed: - The cache lives at `pools/<context>/<pool-id>.json`, where `<context>` is a digest of endpoint, credential fingerprint and namespace. The same digest is stored in the payload and re-checked on read, so an entry moved or copied into a matching directory is still a miss. The credential appears only as a `sha256` fingerprint; the token is never written. Fingerprinting is local on purpose: resolving the credential to a user id would cost a `whoami` and defeat the point of the cache. - `connect()` therefore honours `namespace=` instead of inheriting the cached one, and a mismatch on any of the three is a plain cache miss (debug log, cold path, never an exception). - A cached `base_url` is only used if it is the HTTPS jobs-proxy URL of the job that same entry names, reusing the reasoning already applied to a discovered host's URL -- both now go through `_server_host_rejection`. An entry naming anything else is dropped from the file. - Every field is type- and range-checked on read. `dataclass` enforces nothing, so a well-shaped file with `"capacity": "50"` used to parse happily and blow up much later on `host.capacity - host.live`, as an uncaught `TypeError` inside `create()`. It is now the documented miss. - `follow_redirects=False` on the credentialed client. There is no redirect in this protocol; the proxy serves the in-job server. - Freshness carve-out: an entry written by this principal within 15 minutes is still credited with no round-trip (the `pool create` -> `create --pool` case the cache exists for). Past that, `inspect_job` has to confirm the job is running, labelled for this pool, carrying that nonce, adoptable, and on that URL before the transport is built. Local hygiene in the same pass, all of it in the same "the file is not authenticated, so don't hand it authority" spirit: - cache dirs `0700` (tightened even if they predate this), and the atomic write goes through `tempfile.mkstemp` -- `0600` regardless of umask, unguessable name, removed on any failure -- instead of a predictable `<name>.<pid>.tmp` opened at `0666 & ~umask`. - `files.download()` writes to an `O_EXCL|O_NOFOLLOW`, `0600` temp file next to the destination and `os.replace`s it into place. Before, it wrote in place: an interrupted download left a truncated file at the final name, and `open(path, "wb")` followed a symlink planted there. `rename` does not follow symlinks, so such a destination is now replaced rather than written through. - `files.upload()` opens the file once and both `fstat`s and streams that descriptor, instead of `stat`ing the path and reopening it. Validation (`PYTHONPATH=src python -m pytest tests/test_sandbox.py -q`): 83 passed, including new cases for cross-principal / cross-namespace / cross-endpoint misses, moved and renamed files, mistyped fields, URL admission, the freshness carve-out, redirect handling and the two transfer paths. Two of them assert against a recording server that a cache naming `http://127.0.0.1:<port>`, and a cross-namespace `connect()`, result in zero requests reaching it. `ruff check` and `ruff format --check` are clean on the three files. Latency, modelling a 100 ms round-trip and timing `connect(pool) + create()` (median of 5, cross-process CLI shape): fresh cache hit 141 ms before / 141 ms after, cold path 560 ms before / 557 ms after, and an entry past the TTL 249 ms (one added round-trip). The common case is unchanged; only stale entries pay. Behaviour changes: existing cache files are all misses (new key, new layout, `version` 2), so the first run after upgrading takes the cold path once. A pool cached under an explicit `namespace=` is only found again when the same `namespace=` is passed. A rotated credential reads as a different principal and gets a miss. Anyone relying on a partial file being left behind by a failed download no longer gets one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
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. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit ee78958. Configure here.
| pool_cache_path(pool_id, context).unlink(missing_ok=True) | ||
| return | ||
| for path in _pools_dir().glob(f"*/{_check_pool_id(pool_id)}.json"): | ||
| path.unlink(missing_ok=True) |
There was a problem hiding this comment.
Glob chars wipe other pool caches
Low Severity
Without a context, delete_pool_cache builds a glob pattern from pool_id itself. Metacharacters in that id therefore match other pools' files, so deleting one pool can unlink unrelated cache entries.
Reviewed by Cursor Bugbot for commit ee78958. Configure here.


What was wrong
The local pool cache is not passive data.
SandboxPool.connect()rebuilds a whole pool from$HF_HOME/sandbox/pools/<pool-id>.jsonwith no HTTP at all,_seed_hosts_from_cacheturns each entry into a_SandboxServerfrom the cachedbase_urlandnonce, and the first thing that happens to that transport is an authenticatedPOST /v1/sandboxescarrying the HF bearer and the token derived for that nonce. Two consequences:1. The cache was not bound to the context that wrote it. The key was the pool id alone, and the file's
namespacewas read as configuration rather than compared with what the caller asked for:So a pool cached for
org-Aand then reached withconnect(pool, namespace="org-B")ran the workload — and its env — on org-A's hosts, while everything the caller could see said org-B. That needs no attacker: one credential and two orgs is an ordinary state of affairs. The key also ignored the endpoint and the credential, so two users sharing a machine (or one user with two accounts) shared each other's entries.2. The URL was taken on trust. A cache file — or anything able to write
$HF_HOME— could namehttp://127.0.0.1:1234and receive both credentials on the first create, before any API call could contradict it.follow_redirects=Trueon that client made a well-formed HTTPS URL enough too, since httpx re-sendsAuthorizationon a same-scheme redirect.verified=Falseonly means "drop the host if it fails"; by then the credentials are gone.Alongside that, some local hygiene on the same files: the cache temp file was
<name>.<pid>.tmpat0666 & ~umaskin a default-mode directory;files.download()wrote in place, so an interrupted download left a truncated file at the final name andopen(path, "wb")followed a symlink planted at the destination;files.upload()stated the path and then reopened it. And a well-shaped file with"capacity": "50"parsed fine —dataclassenforces nothing — then failed much later onhost.capacity - host.liveas an uncaughtTypeErrorin the middle ofcreate().What changed
Cache binding:
pools/<context>/<pool-id>.json, where<context>is a digest of endpoint + credential fingerprint + namespace. The same digest is stored in the payload and re-checked on read, so an entry moved or copied into a matching directory is still a miss. The credential appears only as asha256fingerprint — the token is never written to disk. Fingerprinting is deliberately local: resolving the credential to a user id would cost awhoamion every read and defeat the point of the cache.connect()now honoursnamespace=instead of inheriting the cached one. Any mismatch on the three is a plain cache miss: debug log, cold path, never an exception.base_urlis used only if it is the HTTPS jobs-proxy URL of the job that same entry names. This reuses the reasoning already applied to a discovered host's URL — both go through the new_server_host_rejection(url, job_id=...)helper — so a cache file can only name a job the client could have identified by itself. Entries naming anything else are dropped from the file.boolis not a count, nonce looks like a nonce, job id can't contain a path separator), so theTypeErrorfailure mode becomes the documented miss.follow_redirects=Falseon the credentialed client. There is no redirect in this protocol; the Jobs proxy serves the in-job server directly.hf sandbox pool create→hf sandbox create --poolsequence the cache exists for. Past that,inspect_jobmust confirm the job is running, labelled for this pool, carrying that nonce, adoptable under the handle'sadopt_hostspolicy, and exposing exactly the cached URL before the transport is built. A definitive "no" prunes the entry; a transient failure just skips it.Local hardening:
0700(tightened even if they predate this), and the atomic write goes throughtempfile.mkstemp:0600regardless of umask, unguessable name, removed on any failure.files.download()writes to anO_EXCL|O_NOFOLLOW,0600temp file next to the destination andos.replaces it into place (both the small and the ranged path).renamedoes not follow symlinks, so a symlinked destination is replaced rather than written through, an interrupted download leaves nothing behind, and the temp name is unpredictable.files.upload()opens the file once and bothfstats and streams that same descriptor — no stat-then-reopen.Docs: the cache section of
concepts/sandbox.mddescribes the new key, the freshness rule and the URL admission, and its "keyed by pool id alone / an explicitnamespace=still uses the cached hosts" caveat is gone.Validation
New cases: cross-credential / cross-namespace / cross-endpoint misses; an entry copied into another context's directory, and a renamed one; mistyped and implausible fields (including that
connect()on a mistyped file raises the ordinary "no running host" rather thanTypeError); URL admission (loopback, plain HTTP, another job, another port, and a staging domain that must keep working); the freshness carve-out in all three directions (fresh → noinspect_job; aged → confirmed; aged-and-no-longer-backed → dropped and pruned, with a transient API failure only skipping);0600/0700modes and no temp file left behind; the redirect; download-over-a-symlink, failed download, and upload through a symlink.Two of them are the reproducers, asserted against a recording server: a cache naming
http://127.0.0.1:<port>and a cross-namespaceconnect()both result in zero requests reaching it.Latency, modelling a 100 ms round-trip and timing
connect(pool) + create()in the cross-process CLI shape (median of 5, same harness on both branches):The common case is unchanged; only entries the fast path can no longer be honest about pay.
Behaviour changes
version2), so the firstcreate --poolafter upgrading takes the cold path once. No migration: rewriting a legacy entry would mean trusting the very fields being bound.namespace=is only found again when the samenamespace=is passed —hf sandbox create --pool <id>wants the same--namespacethe pool was created with, or it takes the cold path and looks in your own namespace.upload()still follows a symlinked source:hf_hub_download()returns a symlink into the blobs directory, and refusing those would break a normal use. The TOCTOU fix here is the single descriptor; the source path is one the caller chose in their own filesystem.Adjacent, not folded in
upload()of a file above the parallel threshold still materializes it withf.read(). That is the separate full-file-buffering issue, and it belongs with the transfer-memory work rather than here.adopt_hostsfor hosts it recorded itself (a fresh entry is seeded without the policy check). Pre-existing, and arguably right — the entry was written by this principal — but worth a look whenadopt_hostsis next revisited.Note
High Risk
Changes authentication and credential routing for pool cache fast paths and sandbox HTTP clients; mistakes could break connects or, before this fix, leak tokens to attacker-chosen URLs.
Overview
Hardens the sandbox pool fast-path cache and related client behavior so local
$HF_HOMEfiles cannot steer HF bearer and host tokens to the wrong namespace, endpoint, or URL.The on-disk layout moves to
pools/<context>/<pool-id>.json(cache v2), where<context>fingerprints endpoint + credential + namespace;SandboxPool.connect()no longer inherits namespace from the file. Cached host URLs must match the entry’s job id (HTTPS jobs-proxy only); entries older than 15 minutes needinspect_jobbefore use. Corrupt or mistyped cache JSON is a cache miss, not a mid-create()error.Transport and file I/O: the sandbox
httpxclient setsfollow_redirects=False;files.download()writes via a private temp file and atomic replace (symlink-safe);files.upload()sizes and reads one open file descriptor. Cache dirs/files are tightened to0700/0600with safer atomic writes.Docs in
concepts/sandbox.mddescribe the new cache key, freshness rule, and trust model. Existing v1 cache files are ignored (one cold path after upgrade).Reviewed by Cursor Bugbot for commit ee78958. Bugbot is set up for automated code reviews on this repo. Configure here.