A tiny, host-agnostic sandbox for running commands and moving files in an isolated, disposable Docker container.
Project description
dockersandbox
A tiny, host-agnostic sandbox for running commands and moving files in an isolated, disposable Docker container.
dockersandbox gives you one small async interface — a Sandbox — that knows how to:
provision a container from an image → run arbitrary commands in it → move files in and out → tear it down
…and deliberately nothing else. It knows nothing about git, Python, or any particular workload. If the image has a tool installed, exec(["that-tool", …]) runs it. Building a repo, running a test suite, transcoding a video, querying a database, compiling Rust — it's all just "run a command in a box."
Your code is written against the Sandbox interface, never against Docker. In production you hand it a DockerSandbox; in tests you hand it a FakeSandbox. The code that uses it doesn't change a line.
Table of contents
- Why this exists
- Install
- 30-second tour
- The mental model
- How it actually works
- The security model
- What "generic" does and doesn't cover
- Recipes (git, coding agents, CI)
- Testing without Docker
- API reference
- The sandbox image
- Deployment notes
- FAQ
- License
Why this exists
When you want a program (especially an LLM-driven agent) to run commands or modify files, you need somewhere that's isolated (it can't touch your machine), disposable (throw it away after), and credential-safe (secrets can't leak into logs or process listings).
You could wire your code straight to the Docker SDK. But then your business logic is coupled to Docker (untestable without a daemon), swapping substrate later means a rewrite, and the fiddly security-critical bits (injecting secrets without leaking them, avoiding shell injection, guaranteed teardown) get reinvented, badly, in every project.
dockersandbox hides all of that behind one seam and gets the fiddly bits right once.
Install
pip install dockersandbox
From source (for hacking on it):
git clone https://github.com/opxyc/dockersandbox
cd dockersandbox
pip install -e ".[dev]"
You'll also need a Docker daemon reachable from your process, and an image to run. An example image is provided:
docker build -t dockersandbox-example:latest sandbox-image
30-second tour
import asyncio
from dockersandbox import DockerSandbox
async def main():
box = DockerSandbox()
try:
await box.start(image="dockersandbox-example:latest")
await box.write_file("data.txt", "one\ntwo\nthree\n")
print(await box.exec(["wc", "-l", "data.txt"])) # ExecResult(exit_code=0, stdout='3 …')
await box.exec(["python3", "-c", "print(6*7)"]) # anything the image has
print(await box.read_file("data.txt"))
finally:
await box.stop() # always tear down
asyncio.run(main())
Runnable versions live in examples/: hello_exec.py (generic, real Docker), coding_agent.py (the agent pattern, no Docker/API key), and git_repo.py (the git recipe).
The mental model
There is exactly one abstraction: Sandbox (a typing.Protocol). Everything is an implementation of it.
your code (agent / CI / git recipe / anything)
│
depends on ▼ (the Sandbox protocol — never on Docker)
┌─────────────────────────────────────────┐
│ Sandbox │
│ start / stop / exec / read_file / │
│ write_file / list_files / put_file / │
│ add_secret / remove_container / … │
└─────────────────────────────────────────┘
▲ ▲
implemented by implemented by
┌────────────────┐ ┌──────────────────┐
│ DockerSandbox │ │ FakeSandbox │
│ (real: a │ │ (in-memory: │
│ container) │ │ a dict FS) │
└────────────────┘ └──────────────────┘
Because Sandbox is runtime_checkable, isinstance(FakeSandbox(), Sandbox) is True — the fake is structurally a drop-in for the real thing.
Lifecycle. The invariant: always stop() in a finally, so an exploded run never leaks a container.
start(image, env=…) provision the container (idle) + prepare the workdir
│
[ exec / write_file / read_file / list_files / put_file ] ← as many as you like, any order
│
stop() force-remove the container (idempotent)
Everything domain-specific — cloning a repo, running a build, an agent loop — is a consumer of these primitives, built on top. See Recipes.
How it actually works
All the real behaviour is in src/dockersandbox/docker.py — heavily commented. The essentials:
One container, sleep infinity. start() launches a container whose only command is sleep infinity — it idles. We then "exec into" it repeatedly (like docker exec) to run whatever the workload needs. stop() force-removes it. This is docker run -d … sleep infinity followed by a series of docker exec.
The Docker SDK is synchronous, but the API is async. docker-py blocks, so every SDK call is wrapped in asyncio.to_thread(...) — the event loop is never blocked. Timeouts use asyncio.wait_for.
Host-agnostic through the environment. The client is built by docker.from_env(), which reads DOCKER_HOST / DOCKER_TLS_VERIFY / DOCKER_CERT_PATH. Point those at a local daemon in dev or a remote TLS daemon in prod and nothing in the code changes. Tests inject a fake client instead.
File writes are base64'd, never shell-interpolated. write_file sends content as base64 and decodes it inside the container with a fixed script whose path/content arrive as positional args — arbitrary (possibly binary) content is never spliced into a shell string.
Secret files go in via the container API. put_file streams a tar into the container (put_archive), so a credential file's content never appears in any process's argument list.
The security model
dockersandbox closes the common holes for whatever secrets your workload needs:
| Threat | Mitigation |
|---|---|
Secret in a process listing (ps, /proc) |
Register it with add_secret() and place secret files with put_file(), which streams a tar in via the Docker API — the content never becomes a command argument. |
| Secret echoed into logs / output / errors | Every string that leaves the sandbox — command stdout/stderr and every exception message — is passed through scrub_secrets, replacing each registered secret with ***. (Empty secrets are skipped, so it never turns output into all-***.) |
| Shell injection via paths / content | No shell string to inject into — commands are always list[str] argv, and file content is base64'd and passed positionally. |
| Leaked container after a crash | stop() force-removes in a finally; remove_container(id) / container_exists(id) let a supervisor reap orphans by id. |
Two things this library intentionally does not do — layer them on for hostile workloads:
- Network egress control. A container has whatever network the daemon gives it. If you run untrusted code, run the daemon with a locked-down network (or a fully offline image).
- Resource limits. CPU/memory/pids caps are a daemon/orchestrator concern; set them there.
What "generic" does and doesn't cover
Does: anything expressible as run a command that starts, does work, and exits — builds, test suites, linters, formatters, codegen, file conversions, one-shot scripts, DB queries via a client, package installs, and (via a recipe) git workflows. If the image has the tool, exec runs it.
Doesn't, without extra work:
- Long-running servers.
execreturns when the command exits. To start a server and talk to it, background it (exec(["sh","-c","mytool serve &"])) and poll —execwon't "hold" a process for you. - Interactive TTY / streaming output.
execreturns the full result at the end; there's no live stream or PTY. (Both are addable onDockerSandboxif you need them.) - Getting large binaries out.
read_filereturns text. For big/binary artifacts, add aget_archivecounterpart toput_file.
These are deliberate scope choices to keep the core tiny — all three are small additions if your use case needs them.
Recipes (git, coding agents, CI)
Domain workflows live in examples/ as copy-adaptable recipes, not baked into the core.
Git (clone / diff / push) — examples/git_repo.py. A GitRepo helper that drives git through the generic primitives and uses the core's secret features for the token:
from dockersandbox import DockerSandbox
from git_repo import GitRepo, GitAuth # the recipe
box = DockerSandbox()
try:
await box.start(image="dockersandbox-example:latest")
repo = GitRepo(box) # git built ON the sandbox
await repo.clone(url, GitAuth("oauth2", token), ref="main")
await box.write_file("NOTES.md", "hi\n")
if (await repo.diff()).strip():
await repo.publish(branch="feature/x", message="add notes")
finally:
await box.stop()
AI coding agent — examples/coding_agent.py. An agent never touches Docker; it's given four tools, each a thin wrapper over a Sandbox method:
async def list_files(box, path="."): return await box.list_files(path)
async def read_file(box, path): return await box.read_file(path)
async def write_file(box, path, text): await box.write_file(path, text); return f"wrote {path}"
async def run_command(box, argv): return await box.exec(argv)
Register those as your agent's tools, drive it in a loop, and (for a coding agent) layer the GitRepo recipe around it to open a PR. Add your own guardrails in the host — a wall-clock timeout (asyncio.wait_for), an iteration cap, a max-diff check after each write.
CI / build step — just exec the build and inspect the result:
await box.start(image="rust:slim")
# ... put_file / write_file your sources, or clone with the git recipe ...
result = await box.exec(["cargo", "test"], timeout=600)
print(result.exit_code, result.stdout[-2000:])
Testing without Docker
Two independent, daemon-free ways to test:
1. FakeSandbox — for testing code that uses a sandbox. In-memory dict filesystem, programmable exec. Seed a repo, drive your agent/CI/git code against it, assert on box.exec_calls, box.put_files, etc.
from dockersandbox import FakeSandbox, ExecResult
box = FakeSandbox(
files={"README.md": "# demo\n"},
exec_handler=lambda argv: ExecResult(0, "2 passed", "") if "pytest" in argv else ExecResult(0, "", ""),
)
await box.start(image="x")
await box.write_file("new.py", "x = 1\n")
assert "new.py" in await box.list_files(".")
2. Inject a fake Docker client — for testing DockerSandbox itself. DockerSandbox(client=…) accepts any object mimicking the small SDK slice it uses. tests/test_docker_sandbox.py uses this to assert the exact argv sent and to prove put_file streams a tar rather than passing content on a command line.
Run the suite:
pip install -e ".[dev]"
pytest
API reference
Everything is importable from the top-level package: from dockersandbox import ....
Sandbox (Protocol) — all methods async except add_secret
| Method | Description |
|---|---|
add_secret(value) |
Register a sensitive value; it's scrubbed to *** in all later output/errors. |
start(*, image, env=None) |
Provision the container (idle) and prepare the workdir. |
stop() |
Tear the sandbox down. Idempotent. |
exec(cmd, *, timeout=None, workdir=None, env=None) -> ExecResult |
Run cmd (a list[str]). |
write_file(path, content) |
Write text to path (workdir-relative). Injection-safe; for secrets use put_file. |
read_file(path) -> str |
Return the contents of path. |
list_files(path=".") -> list[str] |
File paths under path (recursive, excludes .git). |
put_file(path, content, *, mode=0o644) |
Place a file at an absolute path via the container API — content never in an argv. |
remove_container(id) |
Force-remove a container by id (orphan reaper). Idempotent. |
container_exists(id) -> bool |
Whether a container id still exists (reaper liveness probe). |
Data type
ExecResult(exit_code: int, stdout: str, stderr: str)— a command's outcome.
Implementations
DockerSandbox(*, client=None, workdir="/workspace", secrets=None)— the real one.clientdefaults todocker.from_env();container_idis readable afterstart().FakeSandbox(*, files=None, exec_handler=None, secrets=None)— in-memory, plus assertion fields (started,stopped,exec_calls,put_files,start_args,removed,existing_container_ids).
Errors
All subclass SandboxError (which is not the builtin RuntimeError): SandboxProvisionError, SandboxExecError, SandboxTimeoutError. Every message is secret-scrubbed before it's raised. Note a command exiting non-zero is not an error — it's a normal ExecResult with exit_code != 0; errors are for substrate-level failures.
Helper
scrub_secrets(text, secrets) -> str— replace each value insecretswith***(falsy values skipped).
The sandbox image
DockerSandbox runs one container per session from an image you supply via image=. The image is where you decide what the sandbox can do — whatever you install, exec can run. The library itself needs only a POSIX sh with printf/base64/find/cat/mkdir (present in any base image).
The example (sandbox-image/Dockerfile) is a general-purpose dev image (git + Python + Node + build tools). Swap it for rust:slim, an ffmpeg image, a DB-client image — whatever your workload needs:
docker build -t dockersandbox-example:latest sandbox-image
DockerSandbox execs as the image's default user (root in the example) and put_file targets absolute paths; adjust paths for a non-root image.
Deployment notes
- Where your code runs vs. where work happens. Your process (agent loop, orchestration) runs on the host; commands run inside the container, reached only through the Docker API. No code is copied into the sandbox.
- Running your app in a container ("Docker-out-of-Docker"). Mount the host Docker socket into your app container (
-v /var/run/docker.sock:/var/run/docker.sock) and sandboxes become siblings of your app on the host daemon, not nested. Usual pattern; note it grants your app control of the host daemon. - Remote daemon. Set
DOCKER_HOST=tcp://…,DOCKER_TLS_VERIFY=1,DOCKER_CERT_PATH=…and the same code drives a remote sandbox host unchanged. - Concurrency. Each
DockerSandboxmanages one container. Run many concurrently; cap the number yourself to bound load.
FAQ
Is git required? No — that was the whole point of this refactor. Git is just one recipe in examples/. The core needs only a POSIX shell in the image.
Can I stream logs / use a TTY / pull out binaries? Not out of the box — see What "generic" doesn't cover. Each is a small addition to DockerSandbox.
Why async? So one process can drive many sandboxes without a thread per run, and to fit cleanly inside async servers and agent loops. The blocking SDK calls are offloaded to threads internally.
Can I swap Docker for Firecracker / gVisor / a cloud sandbox? Yes — write another implementation of the Sandbox protocol and your calling code is unchanged.
License
MIT — see LICENSE.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file dockersandbox-0.1.0.tar.gz.
File metadata
- Download URL: dockersandbox-0.1.0.tar.gz
- Upload date:
- Size: 23.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.4
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5b29bd217519d6e671f526262a504414939949f5ce91ec6b6cce4565e99b6fb1
|
|
| MD5 |
3fb571a8820d6eb65c9ca5f057f4cca3
|
|
| BLAKE2b-256 |
1a10ccb2026067593b0146fcaf050c9b032b6a870e5c5038e79d36672dfaed49
|
File details
Details for the file dockersandbox-0.1.0-py3-none-any.whl.
File metadata
- Download URL: dockersandbox-0.1.0-py3-none-any.whl
- Upload date:
- Size: 19.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.4
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c6521a5f6b9d2996cedad24f365b806c171f6d9701468b772e64edb0fe76db29
|
|
| MD5 |
d6002d6c810f1897d45d2ef8d925afa1
|
|
| BLAKE2b-256 |
4230a5946406298039f2dec86bdc23c8ac1f06c51d549fa58546270ba760a52a
|