fix: bound sandbox resource use on host and in container - #6
Merged
Merged
Conversation
Commands run through `execute()` are agent-controlled, so their output and lifetime have to be bounded. Several paths were unbounded: - `run_docker` buffered all stdout/stderr in host memory via `capture_output=True`; `max_output_bytes` was only applied afterwards, so `dd if=/dev/zero | cat` could exhaust host RAM. Output is now streamed in chunks and the process killed at the cap. - The per-command timeout only killed the host-side `docker exec` client; the command kept running in the container, burning its CPU and PID budget. Each exec now records its process group (docker exec is a session leader, so descendants keep the pgid) and timeouts and cap kills reap the whole group. - Reaped children became zombies because PID 1 was `sleep infinity`; the container now runs with `--init`. - `docker info` and `docker run` had no timeout, so a wedged daemon hung the constructor forever, and a timed-out `docker run` leaked its container. Both are bounded and failed starts are cleaned up. - `atexit.register(self.close)` held a strong reference, so an abandoned sandbox was never collected and its container and temp dir survived until process exit. The hook now holds a weakref and is unregistered on close. - `max_output_bytes` is validated like the other limits. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
andybbruno
added a commit
that referenced
this pull request
Sep 14, 2026
fix: bound sandbox resource use on host and in container
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.
Addresses the Corridor finding about unbounded output capture in
_run_docker, plus related resource-exhaustion issues found while fixing it. All verified against real Docker.The reported finding (valid)
run_dockerusedsubprocess.run(capture_output=True), which buffers the entire output ofdocker execin host memory. Themax_output_bytescap only ran afterwards, and the container's--memorylimit does not apply to the host-side pipe.Output is now streamed through
Popenwith a reader thread per stream, read in 64 KB chunks, and the process is killed as soon as a stream reaches the cap.DockerRunResultcarries atruncatedflag so the backend can tell a capped command from a completed one.Reproduction,
dd if=/dev/zero bs=1M count=100000 | catin a real container:Before, that streamed 100 GB into the host process. The sandbox stays usable afterwards.
Note
The finding's alternative remediation — "enable the existing capture-offload path by default, it already uses
head -c" — does not apply. There is no such path in this repo;_docker.pyhad only the singlesubprocess.runcall.Related issues found while fixing it
Timed-out commands kept running in the container. The timeout killed the host-side
docker execclient only. Verified before the fix — after a 2s timeout onsh -c 'while true; do :; done' & sleep 30:Same shape as the reported finding, bounded to the container rather than the host: a few timed-out loops permanently consume the 0.5 CPU, and repeats exhaust
--pids-limit 128until the sandbox is unusable.The fix relies on a verified property of
docker exec: each exec is its own session and process-group leader, and descendants keep that PGID even when reparented to PID 1. Each exec now records its PID, and timeouts (and cap kills) reap the group withkill -9 -<pgid>. Onlyshbuiltins are used, and the pid file lives in the container's/tmp, never the shared dir.Killed processes became zombies. Only visible after the fix above: PID 1 was
sleep infinity, which neverwait()s, so each reaped orphan held a PID slot forever — a slower version of the same exhaustion. The container now runs with--init. Post-fixpsshows no residue after either a timeout or a cap kill.Unbounded startup hangs.
docker infoanddocker runhad no timeout, so a wedged daemon hung theDockerSandbox()constructor indefinitely. Both are bounded now, and a failed or timed-out start does a best-effortdocker rm -f— a timed-outdocker runcould previously leak the container it had just created.atexit.register(self.close)leaked every sandbox. The bound method kept a strong reference, so an abandoned sandbox was never collected and__del__never fired; its container and temp dir survived until process exit. The hook now holds a weakref and is unregistered onclose(). Verified:del sandbox; gc.collect()removes the container.Minor:
max_output_bytesis validated like the other limits (0previously produced silently empty output), and a deadif ...: passbranch inclose()is gone.Checked, not a problem
ln -s /etc/passwd /shared/x, and the host-side file tools read that same directory — butFilesystemBackend._resolve_pathresolves then enforcesrelative_to(root), and reads useO_NOFOLLOW.image/extra_run_argsinjection. Developer-controlled, not agent-controlled, and passed as argv without a shell.Not changed, worth knowing
The container runs as root with default capabilities. Defaulting to
--user,--cap-drop=ALLor--security-opt=no-new-privilegeswould break legitimate workloads (apt install,pip installinto system paths), and all three are reachable throughextra_run_argstoday. On Linux this also means container-written files in the shared dir are root-owned, soshutil.rmtreeon close can silently fail (ignore_errors=True) and leave temp dirs behind. An opt-in hardening flag would be a reasonable follow-up.Testing
48 tests pass (7 new, covering the capped read path, process-group reaping on both timeout and cap,
--init, failed-start cleanup and the atexit weakref); ruff check and format clean. The new helper tests exercise the realPopenpath against localshcommands, so they need no Docker.The
uv.lockchange is an unrelated version sync (0.0.2 → 0.1.1) that was already in the working tree.🤖 Generated with Claude Code