Metadata-Version: 2.4
Name: dockersandbox
Version: 0.1.0
Summary: A tiny, host-agnostic sandbox for running commands and moving files in an isolated, disposable Docker container.
Project-URL: Homepage, https://github.com/opxyc/dockersandbox
Project-URL: Source, https://github.com/opxyc/dockersandbox
Project-URL: Issues, https://github.com/opxyc/dockersandbox/issues
Author: opxyc
License: MIT
License-File: LICENSE
Keywords: ci,coding-agent,container,docker,exec,isolation,sandbox
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development :: Build Tools
Classifier: Topic :: System :: Emulators
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: docker>=6.0
Provides-Extra: dev
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
Requires-Dist: pytest>=7.0; extra == 'dev'
Description-Content-Type: text/markdown

# 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](#why-this-exists)
- [Install](#install)
- [30-second tour](#30-second-tour)
- [The mental model](#the-mental-model)
- [How it actually works](#how-it-actually-works)
- [The security model](#the-security-model)
- [What "generic" does and doesn't cover](#what-generic-does-and-doesnt-cover)
- [Recipes (git, coding agents, CI)](#recipes-git-coding-agents-ci)
- [Testing without Docker](#testing-without-docker)
- [API reference](#api-reference)
- [The sandbox image](#the-sandbox-image)
- [Deployment notes](#deployment-notes)
- [FAQ](#faq)
- [License](#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

```bash
pip install dockersandbox
```

From source (for hacking on it):

```bash
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:

```bash
docker build -t dockersandbox-example:latest sandbox-image
```

## 30-second tour

```python
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/`](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](#recipes-git-coding-agents-ci).

## How it actually works

All the real behaviour is in [`src/dockersandbox/docker.py`](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/`](examples/) as copy-adaptable recipes, not baked into the core.

**Git (clone / diff / push)** — [`examples/git_repo.py`](examples/git_repo.py). A `GitRepo` helper that drives git through the generic primitives and uses the core's secret features for the token:

```python
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`](examples/coding_agent.py). An agent never touches Docker; it's given four tools, each a thin wrapper over a `Sandbox` method:

```python
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:

```python
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.

```python
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`](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:

```bash
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`](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:

```bash
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](#what-generic-does-and-doesnt-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](LICENSE).
