Skip to main content

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

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. exec returns when the command exits. To start a server and talk to it, background it (exec(["sh","-c","mytool serve &"])) and poll — exec won't "hold" a process for you.
  • Interactive TTY / streaming output. exec returns the full result at the end; there's no live stream or PTY. (Both are addable on DockerSandbox if you need them.)
  • Getting large binaries out. read_file returns text. For big/binary artifacts, add a get_archive counterpart to put_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 agentexamples/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. client defaults to docker.from_env(); container_id is readable after start().
  • 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 in secrets with *** (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 DockerSandbox manages 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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

dockersandbox-0.1.0.tar.gz (23.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

dockersandbox-0.1.0-py3-none-any.whl (19.2 kB view details)

Uploaded Python 3

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

Hashes for dockersandbox-0.1.0.tar.gz
Algorithm Hash digest
SHA256 5b29bd217519d6e671f526262a504414939949f5ce91ec6b6cce4565e99b6fb1
MD5 3fb571a8820d6eb65c9ca5f057f4cca3
BLAKE2b-256 1a10ccb2026067593b0146fcaf050c9b032b6a870e5c5038e79d36672dfaed49

See more details on using hashes here.

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

Hashes for dockersandbox-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 c6521a5f6b9d2996cedad24f365b806c171f6d9701468b772e64edb0fe76db29
MD5 d6002d6c810f1897d45d2ef8d925afa1
BLAKE2b-256 4230a5946406298039f2dec86bdc23c8ac1f06c51d549fa58546270ba760a52a

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page