dockersandbox 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,44 @@
1
+ # Publish dockersandbox to PyPI when a GitHub Release is published.
2
+ #
3
+ # Uses PyPI "Trusted Publishing" (OIDC) — no API token stored in the repo. Set it up once:
4
+ # PyPI → your project → Settings → Publishing → add a GitHub publisher:
5
+ # owner: opxyc
6
+ # repository: dockersandbox
7
+ # workflow: publish.yml
8
+ # environment: pypi
9
+ # Then cut a GitHub Release (tag e.g. v0.1.0) and this runs automatically.
10
+ name: publish
11
+
12
+ on:
13
+ release:
14
+ types: [published]
15
+
16
+ jobs:
17
+ build:
18
+ runs-on: ubuntu-latest
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+ - uses: actions/setup-python@v5
22
+ with:
23
+ python-version: "3.12"
24
+ - name: Build sdist + wheel
25
+ run: pipx run build
26
+ - name: Check metadata
27
+ run: pipx run twine check dist/*
28
+ - uses: actions/upload-artifact@v4
29
+ with:
30
+ name: dist
31
+ path: dist/
32
+
33
+ pypi:
34
+ needs: build
35
+ runs-on: ubuntu-latest
36
+ environment: pypi
37
+ permissions:
38
+ id-token: write # required for Trusted Publishing (OIDC) — this is what replaces a token
39
+ steps:
40
+ - uses: actions/download-artifact@v4
41
+ with:
42
+ name: dist
43
+ path: dist/
44
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,16 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+ .venv/
9
+ venv/
10
+
11
+ # Test / tooling caches
12
+ .pytest_cache/
13
+ .ruff_cache/
14
+ .mypy_cache/
15
+ .coverage
16
+ htmlcov/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 opxyc
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,327 @@
1
+ Metadata-Version: 2.4
2
+ Name: dockersandbox
3
+ Version: 0.1.0
4
+ Summary: A tiny, host-agnostic sandbox for running commands and moving files in an isolated, disposable Docker container.
5
+ Project-URL: Homepage, https://github.com/opxyc/dockersandbox
6
+ Project-URL: Source, https://github.com/opxyc/dockersandbox
7
+ Project-URL: Issues, https://github.com/opxyc/dockersandbox/issues
8
+ Author: opxyc
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: ci,coding-agent,container,docker,exec,isolation,sandbox
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Software Development :: Build Tools
20
+ Classifier: Topic :: System :: Emulators
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.10
23
+ Requires-Dist: docker>=6.0
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
26
+ Requires-Dist: pytest>=7.0; extra == 'dev'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # dockersandbox
30
+
31
+ **A tiny, host-agnostic sandbox for running commands and moving files in an isolated, disposable Docker container.**
32
+
33
+ `dockersandbox` gives you one small async interface — a `Sandbox` — that knows how to:
34
+
35
+ > provision a container from an image → run arbitrary commands in it → move files in and out → tear it down
36
+
37
+ …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."
38
+
39
+ 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.**
40
+
41
+ ---
42
+
43
+ ## Table of contents
44
+
45
+ - [Why this exists](#why-this-exists)
46
+ - [Install](#install)
47
+ - [30-second tour](#30-second-tour)
48
+ - [The mental model](#the-mental-model)
49
+ - [How it actually works](#how-it-actually-works)
50
+ - [The security model](#the-security-model)
51
+ - [What "generic" does and doesn't cover](#what-generic-does-and-doesnt-cover)
52
+ - [Recipes (git, coding agents, CI)](#recipes-git-coding-agents-ci)
53
+ - [Testing without Docker](#testing-without-docker)
54
+ - [API reference](#api-reference)
55
+ - [The sandbox image](#the-sandbox-image)
56
+ - [Deployment notes](#deployment-notes)
57
+ - [FAQ](#faq)
58
+ - [License](#license)
59
+
60
+ ---
61
+
62
+ ## Why this exists
63
+
64
+ 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).
65
+
66
+ 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.
67
+
68
+ `dockersandbox` hides all of that behind one seam and gets the fiddly bits right once.
69
+
70
+ ## Install
71
+
72
+ ```bash
73
+ pip install dockersandbox
74
+ ```
75
+
76
+ From source (for hacking on it):
77
+
78
+ ```bash
79
+ git clone https://github.com/opxyc/dockersandbox
80
+ cd dockersandbox
81
+ pip install -e ".[dev]"
82
+ ```
83
+
84
+ You'll also need a Docker daemon reachable from your process, and an image to run. An example image is provided:
85
+
86
+ ```bash
87
+ docker build -t dockersandbox-example:latest sandbox-image
88
+ ```
89
+
90
+ ## 30-second tour
91
+
92
+ ```python
93
+ import asyncio
94
+ from dockersandbox import DockerSandbox
95
+
96
+ async def main():
97
+ box = DockerSandbox()
98
+ try:
99
+ await box.start(image="dockersandbox-example:latest")
100
+ await box.write_file("data.txt", "one\ntwo\nthree\n")
101
+ print(await box.exec(["wc", "-l", "data.txt"])) # ExecResult(exit_code=0, stdout='3 …')
102
+ await box.exec(["python3", "-c", "print(6*7)"]) # anything the image has
103
+ print(await box.read_file("data.txt"))
104
+ finally:
105
+ await box.stop() # always tear down
106
+
107
+ asyncio.run(main())
108
+ ```
109
+
110
+ 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).
111
+
112
+ ## The mental model
113
+
114
+ There is exactly one abstraction: **`Sandbox`** (a `typing.Protocol`). Everything is an implementation of it.
115
+
116
+ ```
117
+ your code (agent / CI / git recipe / anything)
118
+
119
+ depends on ▼ (the Sandbox protocol — never on Docker)
120
+ ┌─────────────────────────────────────────┐
121
+ │ Sandbox │
122
+ │ start / stop / exec / read_file / │
123
+ │ write_file / list_files / put_file / │
124
+ │ add_secret / remove_container / … │
125
+ └─────────────────────────────────────────┘
126
+ ▲ ▲
127
+ implemented by implemented by
128
+ ┌────────────────┐ ┌──────────────────┐
129
+ │ DockerSandbox │ │ FakeSandbox │
130
+ │ (real: a │ │ (in-memory: │
131
+ │ container) │ │ a dict FS) │
132
+ └────────────────┘ └──────────────────┘
133
+ ```
134
+
135
+ Because `Sandbox` is `runtime_checkable`, `isinstance(FakeSandbox(), Sandbox)` is `True` — the fake is structurally a drop-in for the real thing.
136
+
137
+ Lifecycle. The invariant: **always `stop()` in a `finally`**, so an exploded run never leaks a container.
138
+
139
+ ```
140
+ start(image, env=…) provision the container (idle) + prepare the workdir
141
+
142
+ [ exec / write_file / read_file / list_files / put_file ] ← as many as you like, any order
143
+
144
+ stop() force-remove the container (idempotent)
145
+ ```
146
+
147
+ 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).
148
+
149
+ ## How it actually works
150
+
151
+ All the real behaviour is in [`src/dockersandbox/docker.py`](src/dockersandbox/docker.py) — heavily commented. The essentials:
152
+
153
+ **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`.
154
+
155
+ **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`.
156
+
157
+ **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.
158
+
159
+ **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.
160
+
161
+ **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.
162
+
163
+ ## The security model
164
+
165
+ `dockersandbox` closes the common holes for whatever secrets your workload needs:
166
+
167
+ | Threat | Mitigation |
168
+ | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
169
+ | 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. |
170
+ | 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-`***`.) |
171
+ | 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. |
172
+ | Leaked container after a crash | `stop()` force-removes in a `finally`; `remove_container(id)` / `container_exists(id)` let a supervisor reap orphans by id. |
173
+
174
+ Two things this library intentionally does **not** do — layer them on for hostile workloads:
175
+
176
+ - **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).
177
+ - **Resource limits.** CPU/memory/pids caps are a daemon/orchestrator concern; set them there.
178
+
179
+ ## What "generic" does and doesn't cover
180
+
181
+ **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.
182
+
183
+ **Doesn't, without extra work:**
184
+
185
+ - **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.
186
+ - **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.)
187
+ - **Getting large binaries out.** `read_file` returns text. For big/binary artifacts, add a `get_archive` counterpart to `put_file`.
188
+
189
+ These are deliberate scope choices to keep the core tiny — all three are small additions if your use case needs them.
190
+
191
+ ## Recipes (git, coding agents, CI)
192
+
193
+ Domain workflows live in [`examples/`](examples/) as copy-adaptable recipes, not baked into the core.
194
+
195
+ **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:
196
+
197
+ ```python
198
+ from dockersandbox import DockerSandbox
199
+ from git_repo import GitRepo, GitAuth # the recipe
200
+
201
+ box = DockerSandbox()
202
+ try:
203
+ await box.start(image="dockersandbox-example:latest")
204
+ repo = GitRepo(box) # git built ON the sandbox
205
+ await repo.clone(url, GitAuth("oauth2", token), ref="main")
206
+ await box.write_file("NOTES.md", "hi\n")
207
+ if (await repo.diff()).strip():
208
+ await repo.publish(branch="feature/x", message="add notes")
209
+ finally:
210
+ await box.stop()
211
+ ```
212
+
213
+ **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:
214
+
215
+ ```python
216
+ async def list_files(box, path="."): return await box.list_files(path)
217
+ async def read_file(box, path): return await box.read_file(path)
218
+ async def write_file(box, path, text): await box.write_file(path, text); return f"wrote {path}"
219
+ async def run_command(box, argv): return await box.exec(argv)
220
+ ```
221
+
222
+ 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.
223
+
224
+ **CI / build step** — just `exec` the build and inspect the result:
225
+
226
+ ```python
227
+ await box.start(image="rust:slim")
228
+ # ... put_file / write_file your sources, or clone with the git recipe ...
229
+ result = await box.exec(["cargo", "test"], timeout=600)
230
+ print(result.exit_code, result.stdout[-2000:])
231
+ ```
232
+
233
+ ## Testing without Docker
234
+
235
+ Two independent, daemon-free ways to test:
236
+
237
+ **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.
238
+
239
+ ```python
240
+ from dockersandbox import FakeSandbox, ExecResult
241
+
242
+ box = FakeSandbox(
243
+ files={"README.md": "# demo\n"},
244
+ exec_handler=lambda argv: ExecResult(0, "2 passed", "") if "pytest" in argv else ExecResult(0, "", ""),
245
+ )
246
+ await box.start(image="x")
247
+ await box.write_file("new.py", "x = 1\n")
248
+ assert "new.py" in await box.list_files(".")
249
+ ```
250
+
251
+ **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.
252
+
253
+ Run the suite:
254
+
255
+ ```bash
256
+ pip install -e ".[dev]"
257
+ pytest
258
+ ```
259
+
260
+ ## API reference
261
+
262
+ Everything is importable from the top-level package: `from dockersandbox import ...`.
263
+
264
+ ### `Sandbox` (Protocol) — all methods `async` except `add_secret`
265
+
266
+ | Method | Description |
267
+ | ------------------------------------------------------------------ | -------------------------------------------------------------------------------------- |
268
+ | `add_secret(value)` | Register a sensitive value; it's scrubbed to `***` in all later output/errors. |
269
+ | `start(*, image, env=None)` | Provision the container (idle) and prepare the workdir. |
270
+ | `stop()` | Tear the sandbox down. Idempotent. |
271
+ | `exec(cmd, *, timeout=None, workdir=None, env=None) -> ExecResult` | Run `cmd` (a `list[str]`). |
272
+ | `write_file(path, content)` | Write text to `path` (workdir-relative). Injection-safe; for _secrets_ use `put_file`. |
273
+ | `read_file(path) -> str` | Return the contents of `path`. |
274
+ | `list_files(path=".") -> list[str]` | File paths under `path` (recursive, excludes `.git`). |
275
+ | `put_file(path, content, *, mode=0o644)` | Place a file at an absolute `path` via the container API — content never in an argv. |
276
+ | `remove_container(id)` | Force-remove a container by id (orphan reaper). Idempotent. |
277
+ | `container_exists(id) -> bool` | Whether a container id still exists (reaper liveness probe). |
278
+
279
+ ### Data type
280
+
281
+ - **`ExecResult(exit_code: int, stdout: str, stderr: str)`** — a command's outcome.
282
+
283
+ ### Implementations
284
+
285
+ - **`DockerSandbox(*, client=None, workdir="/workspace", secrets=None)`** — the real one. `client` defaults to `docker.from_env()`; `container_id` is readable after `start()`.
286
+ - **`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`).
287
+
288
+ ### Errors
289
+
290
+ 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.
291
+
292
+ ### Helper
293
+
294
+ - **`scrub_secrets(text, secrets) -> str`** — replace each value in `secrets` with `***` (falsy values skipped).
295
+
296
+ ## The sandbox image
297
+
298
+ `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).
299
+
300
+ 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:
301
+
302
+ ```bash
303
+ docker build -t dockersandbox-example:latest sandbox-image
304
+ ```
305
+
306
+ `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.
307
+
308
+ ## Deployment notes
309
+
310
+ - **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.
311
+ - **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.
312
+ - **Remote daemon.** Set `DOCKER_HOST=tcp://…`, `DOCKER_TLS_VERIFY=1`, `DOCKER_CERT_PATH=…` and the same code drives a remote sandbox host unchanged.
313
+ - **Concurrency.** Each `DockerSandbox` manages one container. Run many concurrently; cap the number yourself to bound load.
314
+
315
+ ## FAQ
316
+
317
+ **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.
318
+
319
+ **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`.
320
+
321
+ **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.
322
+
323
+ **Can I swap Docker for Firecracker / gVisor / a cloud sandbox?** Yes — write another implementation of the `Sandbox` protocol and your calling code is unchanged.
324
+
325
+ ## License
326
+
327
+ MIT — see [LICENSE](LICENSE).