Skip to content

[sandbox audit] Bound what a transfer or a command's output costs the client - #4839

Draft
Wauplin wants to merge 1 commit into
security/bind-pool-cachefrom
security/bound-client-resources
Draft

[sandbox audit] Bound what a transfer or a command's output costs the client#4839
Wauplin wants to merge 1 commit into
security/bind-pool-cachefrom
security/bound-client-resources

Conversation

@Wauplin

@Wauplin Wauplin commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

[sandbox audit] — PR 5 of 9 in this repo's stack; merge in order.
Previous: #4838 · Next: #4840
Review only the commits this PR adds on top of its base; bases collapse to main as the stack lands.

Server counterpart: huggingface/sandbox-server#26 (which adds the truncate_to parameter
and the listing pagination this consumes).

Why

Four unbounded allocations in the caller's process, each proportional to something the
sandbox controls.

1. Output was accumulated even when a callback was consuming it. run() appended every
chunk to stdout_parts/stderr_parts regardless of on_stdout/on_stderr, so a streaming
consumer still paid full memory for the command's entire output — and a runaway command (a
loop printing to stdout) was an unbounded allocation with no ceiling.

2. A parallel read doubled peak memory. _read_ranges collected every chunk into a list
and the caller then b"".joined it, so a 2 GB file peaked at roughly 4 GB.

3. A parallel upload read the whole file into memoryf.read() above the threshold, so
10 GB resident for a 10 GB file.

4. And it left a stale tail. A ranged write doesn't truncate, so overwriting a larger file
with a smaller one left the old bytes past the new end.

Approach

  • capture_output=False hands output to the callbacks and drops it. The default now
    raises above a ceiling rather than being OOM-killed with no explanation, and the error names
    the flag.

    An explicit flag rather than "stop capturing when a callback is present": silently changing
    what result.stdout contains based on another argument is a footgun, and code that passes a
    callback and reads result.stdout today keeps working.

  • _read_ranges is a generator, yielding (offset, bytes) as ranges land. read() places
    each chunk into one preallocated buffer; download() seeks and writes it out. Both drop it
    immediately. read() also refuses above a ceiling and points at download().

  • upload() preads each range off the shared descriptor. pread rather than
    seek+read because the workers share one descriptor and a seeking read would race with
    its siblings — worth stating, since the obvious refactor is wrong here.

  • Each upload's final chunk sends the intended total size, which the server truncates to.

Plus: files.list() follows the server's new pagination cursor, so a caller still sees one
complete list however the directory is chunked on the wire.

The fake never served /files/list

So that path had no coverage at all — files.list() was never exercised against anything.
It does now, with pagination, which is how the cursor-following is tested.

Validation

88 tests pass, up from 83. New coverage: opting out of capture (callback still sees
everything, result deliberately empty); the runaway-output ceiling and the escape hatch its
error names; the read ceiling; cursor-following in list().

Behaviour changes

  • run() raises SandboxError above 64 MB of captured output instead of growing without
    bound. Anything that relied on unbounded capture was one runaway command from an OOM.
  • files.read() raises above 512 MB and points at download().
  • run() gains a capture_output keyword (default True, so existing calls are unaffected).

Note

Medium Risk
Behavior changes for very large command output or in-memory reads (now explicit errors), and ranged uploads depend on server truncate_to; defaults preserve existing run() capture semantics.

Overview
Adds client-side limits and streaming so sandbox workloads cannot grow the caller's process without bound.

Sandbox.run() gains capture_output (default True). When False, stdout/stderr go only to callbacks and stay empty on the result. When capturing, output above 64 MB raises SandboxError with guidance to stream or redirect.

SandboxFiles refuses read()/read_text() above 512 MB and points callers at download(). Parallel read, download, and upload no longer buffer whole files: ranged reads yield (offset, bytes) into a single buffer or seek-writes; large uploads use pread per range. Ranged writes send truncate_to on the last chunk so shrinking a file does not leave a stale tail (needs the paired server change). list() follows the server's paginated next cursor and returns one full directory listing.

Docs update pool resource limits wording (per-process rlimits and server-clamped max_procs/max_mem_mb). Tests cover capture opt-out, output ceiling, read ceiling, and list pagination.

Reviewed by Cursor Bugbot for commit 53871e5. Bugbot is set up for automated code reviews on this repo. Configure here.

…lient

Four unbounded allocations in the caller's process, each proportional to
something the sandbox controls.

**Output was accumulated even when a callback was consuming it.** `run()`
appended every chunk to `stdout_parts`/`stderr_parts` regardless, so a
streaming consumer still paid full memory for the command's entire output,
and a runaway command (a loop printing to stdout) was an unbounded
allocation. New `capture_output=False` hands output to the callbacks and
drops it; the default now raises above a ceiling instead of being
OOM-killed with no explanation, and the error names the flag to use.

An explicit flag rather than "stop capturing when a callback is present":
silently changing what `result.stdout` contains based on another argument is
a footgun, and code that passes a callback *and* reads `result.stdout` today
keeps working.

**A parallel read doubled peak memory.** `_read_ranges` collected every
chunk into a list and the caller then joined it, so a 2 GB file peaked at
about 4 GB. It is a generator now, yielding `(offset, bytes)` as ranges
land: `read()` places each chunk into one preallocated buffer, `download()`
seeks and writes it out, and both drop it immediately. `read()` also refuses
above a ceiling and points at `download()`, which streams to disk.

**A parallel upload read the whole file into memory.** `upload()` did
`f.read()` above the threshold -- 10 GB resident for a 10 GB file. Each
worker now `pread`s its own range off the shared descriptor (`pread`, not
`seek`+`read`, because the workers share the descriptor and a seeking read
would race with its siblings).

**And it left a stale tail.** A ranged write does not truncate, so
overwriting a larger file with a smaller one left the old bytes past the new
end. Each upload's final chunk now sends the intended total size, which the
server truncates to.

Also follows the server's new listing pagination, so `files.list()` still
returns one complete list however the directory is chunked on the wire --
and the fake server now serves `/files/list` at all, which it never did, so
that path had no coverage.

Validation: 88 tests pass, up from 83. New coverage for opting out of
capture, the runaway-output ceiling and its escape hatch, the read ceiling,
and cursor-following in `list()`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@bot-ci-comment

bot-ci-comment Bot commented Sep 8, 2026

Copy link
Copy Markdown

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.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 53871e5. Configure here.


def push(rng: tuple[int, int]) -> None:
offset, length = rng
self._put_range(path, offset, os.pread(fileno, length, offset), mode, total=size)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Large uploads crash on Windows

High Severity

_write_ranges_from_file calls os.pread, which exists only on Unix. On Windows, files.upload of any file above PARALLEL_THRESHOLD raises AttributeError instead of sending the file. The same module already special-cases Windows for download flags, and the CLI accepts Windows paths for this copy path.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 53871e5. Configure here.

"or redirect it to a file in the sandbox and download that."
)
captured += len(data)
parts.append(data)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Streaming CLI hits capture ceiling

Medium Severity

run() now raises once captured output exceeds 64 MB, and capture_output still defaults to True. hf sandbox exec already streams via on_stdout/on_stderr and only reads exit status from the result, so any command that prints more than 64 MB now fails even though the output is already being consumed.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 53871e5. Configure here.

@Wauplin Wauplin changed the title Bound what a transfer or a command's output costs the client [sandbox audit] Bound what a transfer or a command's output costs the client Sep 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant