sanduk
'sanduk', pronounced SAN-dook, means 'box' in Arabic.
Run an agent inside a disposable container. The agent does its work, writes a report to a bind-mounted directory, and the container is deleted.
Today that means Claude Code on macOS through Apple's container. The container engine sits behind sanduk.runtime.Runtime and the agent CLI behind sanduk.agent, so Docker, Podman, and other agents are additive.
Four providers are supported: Anthropic, OpenAI, OpenRouter, and any OpenAI-compatible server, which includes a local llama-server. See Providers.
In its stronger mode the VM has no route off the host and never holds the API key: a host-side relay injects the credential, and the container gets a per-run token that is worthless anywhere else. Against a local model there is no key to hold, and nothing leaves the machine at all.
Requirements
-
Mac with Apple silicon, macOS 26 or later (
containerrequires both) -
container1.2.0 or later -
Python 3.11 or later, and
uv -
An API key for the provider you pick, in that provider's variable:
ANTHROPIC_API_KEY,OPENAI_API_KEY,OPENROUTER_API_KEY.--provider openai-compatneeds none.
Apple's container runs Linux containers as lightweight VMs. There is no such thing as a macOS container here; anything needing Xcode or the macOS toolchain cannot be the workload.
The image is node:22-slim plus git, ripgrep, curl, jq, and python3. The agent can only run what is in it. Without an interpreter it falls back to hand-tracing and still writes a confident report, so check whether the findings say they were reproduced. There is no C, Go, or Rust toolchain: point --image at your own, or --containerfile at one to build.
Quickstart
make sync
make image
export ANTHROPIC_API_KEY=sk-ant-...
make run TASK='Summarise every Python file here.' WORK=./work
Or directly, which is where all the flags live:
uv run sanduk run 'Summarise every Python file here.' -w ./work --proxy
uv run sanduk --help
Two modes
| default | --proxy |
--proxy + local model |
|
|---|---|---|---|
| Host filesystem | VM isolation | VM isolation | VM isolation |
| API key location | inside the VM | host only; VM holds a run token | there is no key |
| Egress | unrestricted | none | none |
| Agent can POST your source anywhere | yes | no | no |
| Which endpoints the agent may call | all | the provider's, matched exactly | the provider's, matched exactly |
| Leaves the machine | yes | the prompt and every file read | nothing |
| Record of what it sent upstream | none | --log-bodies |
--log-bodies |
The default mode is filesystem isolation and nothing more. --proxy is where the containment is.
With a local model the relay is no longer protecting a credential, because there is not one. What it still does is hold the agent to an exact path allowlist, enforce the model and token policy where the container cannot edit it, and record what was sent.
Commands
sanduk run <task> run an agent in a disposable container
sanduk build build the agent's image
sanduk shell interactive shell in that image
sanduk ps list sanduk containers
sanduk stop stop them, leaving them on disk
sanduk clean stop and delete them
sanduk destroy clean, plus the image and the network
sanduk system status whether the engine is ready
sanduk list agents what each registered handler speaks
sanduk list providers the URL an agent must be given, per provider
sanduk list runtimes engines, and whether each is installed
Every container-engine call sanduk makes goes through Runtime, so the Makefile names no engine and --runtime selects one for any of these.
Providers
--provider anthropic api.anthropic.com ANTHROPIC_API_KEY
--provider openai api.openai.com OPENAI_API_KEY
--provider openrouter openrouter.ai/api/v1 OPENROUTER_API_KEY
--provider openai-compat --upstream, no key needed OPENAI_API_KEY if set
A provider record names the upstream, the auth header, the variable its key comes from, the path the preflight checks, and a route table. The route table maps each allowed path to the wire protocol spoken there, so one structure is the egress allowlist, the field --max-tokens-cap clamps, and the names the usage line reads.
The protocol hangs off the route, not the provider, because OpenAI serves Responses and Chat Completions on two paths of one host. It is declared rather than detected because Anthropic Messages and OpenAI Responses both report input_tokens and output_tokens; a response body cannot tell them apart.
A local model:
llama-server -m ~/.models/some-model.gguf --port 8080 --alias local-model
sanduk run 'Review this.' -w ./repo --proxy \
--provider openai-compat --upstream http://127.0.0.1:8080 --model local-model
--upstream takes scheme://host:port and no path. Plaintext http:// to anything but a loopback address is refused, because the relay writes the real key into every forwarded request; --insecure-upstream overrides. OpenRouter's /api/v1 prefix lives in the allowlist, not the upstream.
The relay only forwards. It does not translate between protocols, so the agent has to speak the provider's own API. Claude Code speaks Anthropic Messages only, so --provider openai|openrouter|openai-compat needs --agent hax; the pairing is refused before anything is built rather than 404'd by the relay later.
--agent-key-env and --agent-base-url-env name the variables the agent reads inside the container. They default to the provider's. They are separate because sanduk reads the key on the host under one name and the container may want another, which is what makes an arbitrary agent a matter of two flags rather than a new module.
Agents
--agent claude Claude Code anthropic only
--agent hax hax every provider
A handler says which image carries the agent, what flags drive it headlessly, which variables it reads its endpoint from, and how to read its JSON stream. Nothing else about a run differs, so a third agent is a class in your own package, advertised in the sanduk.agents entry-point group or named directly as --agent mypkg.handlers:MyAgent. See docs/agents.md.
sanduk run 'Review this.' -w ./repo --proxy --agent hax \
--provider openrouter --model anthropic/claude-sonnet-5
hax is a static C binary with no approval gate, which suits a container that is already the boundary. The image carries no language runtime. --allowed-tools and --permission-mode are Claude Code flags and are refused rather than dropped.
How --proxy works
-
sanduk-netis created with--internal: no route off the host. -
vmnet only creates the host bridge while a container is attached, so a placeholder container is started first and torn down at the end.
-
The relay binds the bridge gateway only, so it is unreachable from Wi-Fi or LAN.
-
The container is given the provider's base-URL variable and a per-run token as its key variable. Both arrive through the child process environment, so neither appears in
ps, andcontainer inspectshows the token, not the key. -
The relay checks the token in the header that provider authenticates with, checks the path against an exact allowlist, applies any model or token policy, swaps in the real key, and streams the response back. Every credential header the container sent is dropped, not only the one this provider uses: a header that means nothing to one API is the key for another.
-
Every relayed call logs its token counts:
in=,cache_write=,cache_read=,out=. A counter the protocol does not report is left out rather than printed as zero, and a completion that reports none at all logsusage=?rather than a line that looks ordinary. The relay narrows the client'sAccept-Encodingtogzipto read them, because the API answers in brotli whenever a client offers it and nothing in the standard library decodes brotli. For OpenAI-shaped providers it also addsstream_options.include_usage, without which a streamed response carries no counts at all.
Flags worth knowing
--allow-model claude-opus-5 and --max-tokens-cap N are enforced on the host, where the container cannot edit them. The cap clamps whichever field the protocol uses: max_tokens, max_completion_tokens, or max_output_tokens. Claude Code asks for max_tokens: 64000 on every call, so a cap below that silently truncates every request.
--log-bodies records each request body: a digest line per call, full JSON under --log-dir (default ./sanduk-logs, deliberately outside the bind mount so the agent cannot read or edit its own audit trail). Bodies contain the system prompt and every file the agent has read.
--dry-run prints the container run command and exits. --keep leaves the container for inspection, and warns that container inspect then exposes the token.
Layout
src/sanduk/
cli.py flags, lifecycle, teardown
runtime.py container engines; ContainerSpec; `apple` and `docker`
providers.py provider records, wire protocols, route tables
agent.py the agent strategy: interface, registry, plugin loading
agents/ the shipped handlers: claude.py, hax.py
proxy.py the host-side relay
preflight.py key validation, macOS firewall check
resources/
Containerfile.claude
Containerfile.hax
A second agent is an Agent subclass in any package; see docs/agents.md. A third engine is a Runtime subclass and a RUNTIMES entry. It must supply four things: the CLI name, the verb that deletes a container (rm, not delete), how network inspect reports the gateway, and whether the host bridge needs a placeholder container to exist at all.
--runtime docker needs a daemon on this kernel, not one in a VM. Docker Desktop, Colima and Lima keep the bridge inside the VM, so the relay cannot bind the gateway; the run stops at the bind with that reason rather than listening somewhere the container cannot reach. --runtime apple is the macOS path.
Make targets
make help lists all of them. The ones you need:
make sync Resolve and install the environment
make test Fast suite: no containers, no API calls, no key needed
make test-container Integration suite: boots real containers
make test-all Both
make qa lint-check, format-check, typecheck, test
make image Build the agent image if missing
make image-rebuild Force a rebuild
make run TASK='...' WORK=./dir ARGS='--effort max'
make run-proxy Same, with no egress and the key held on the host
make shell Interactive shell in the image
make ps / make logs Containers / recorded request bodies
make stop Stop sanduk containers, leave them on disk
make clean Delete them and build scratch. Keeps work/ and logs
make distclean clean, plus .venv and tool caches
make destroy clean, plus the image, the network, and the logs
make system-start / system-stop / system-status
make system-stop stops Apple's container service for everything on the machine, not just sanduk.
CI
.github/workflows/ci.yml runs lint, format and types once, the fast suite across ubuntu-latest and macos-latest on the declared Python bounds, and the integration suite on Linux against a real Docker daemon. That last job is not redundancy: a native daemon puts the bridge on the host kernel, so it is the only place --proxy can be proved. Neither Apple's engine nor Docker Desktop can, and both are what you have locally.
Testing
The fast suite makes no API calls and needs no key: the relay is exercised against a local fake upstream, and the preflight is monkeypatched.
scripts/sanduk.py carries its own copy of the relay, so every relay test runs twice, once against each copy. That compares behaviour rather than source, which an AST comparison could no longer do once the package's relay grew providers the script does not have.
The integration suite boots real VMs and proves the relay by the 401 an invalid key earns from the real endpoint, which is itself proof the request arrived.
make test-live talks to real providers, and most of it costs nothing. The bad-key tests reach Anthropic, OpenAI, and OpenRouter with no credential at all, since refusing an invalid key needs no valid one. Point LLAMA_SERVER at a local llama-server and the whole openai-compat path runs for free.
Of the two completion tests, OpenRouter needs only a key: it defaults to openrouter/free, a router that picks a free model at random. A pinned :free id works too, but those rotate out of the catalogue, which fails the test for a reason unrelated to sanduk. OpenAI has no free tier, so it stays opt-in through OPENAI_MODEL and nothing is spent unless a model is named.
LLAMA_SERVER=http://127.0.0.1:8080 make test-live
OPENAI_MODEL=<a cheap model> make test-live
Nothing runs on its own. There is no CI here, and pyproject.toml deselects both the container and the live suites, so make test is the only one that runs unasked.
Measured on this setup
| Bad key, host preflight | 0.27s |
| Bad key, no preflight | 174s of in-container retry backoff |
| Agent run, 3 files, 7 turns | 35.4s wall, 23.2s of it upstream |
| Review of one 59-line file, 10 turns | $0.55, 148s wall |
| Claude Code's system prompt and tool schemas | 22,993 tokens |
| That prefix written cold, as a share of one run | 25% of its cost |
| The same prefix on a second run inside the cache TTL | read, not written: 23% cheaper |
Container direct egress on sanduk-net |
000 |
| Real keys in the container, with all three exported | 0 of 3 |
OpenRouter /api/v1/models, no credential |
200 |
OpenRouter /api/v1/key, no credential |
401 |
Streamed llama-server usage, no stream_options |
none reported |
The same, with include_usage injected |
full counts, cached included |
Known traps
The macOS application firewall silently drops connections to a binary set to "Block incoming connections", so the agent's first API call hangs until --timeout rather than failing. Homebrew's Python is shipped blocked on at least one machine; uv's interpreters are signed and auto-allowed. --proxy runs a preflight that names the exact socketfilterfw --unblockapp command when it sees an explicit block. It cannot detect an interpreter that will merely prompt.
AF_UNIX paths cap at 104 bytes on macOS, which matters if you point --log-dir somewhere deep.
A path allowlist is per provider and matched exactly. OpenRouter's endpoint is /api/v1/chat/completions; the /v1/chat/completions in OpenAI's documentation will 403, and the rejection is legible only in the proxy log.
Metadata
Release files for sanduk 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| sanduk-0.2.0.tar.gz | 74.3 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| sanduk-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 116.8 kB
Release files / sanduk-0.2.0.tar.gz
| Download URL | sanduk-0.2.0.tar.gz |
|---|---|
| Size | 74.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
71b35b032ce028acb8f9b711cd65b0200f4517408d5a003f0ef446c68e201d86
|
|
BLAKE2b-256 checksum How to use checksums |
58abaeea36cdf749bf50b1f9fbf27fc0aef365c13df895eb06768252db284900
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.7
|
Release files / sanduk-0.2.0-py3-none-any.whl
| Download URL | sanduk-0.2.0-py3-none-any.whl |
|---|---|
| Size | 42.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
33259d4bb26b48209ad2c638c94dd40adc78d60acf91c322f6ce3554b5df2fc0
|
|
BLAKE2b-256 checksum How to use checksums |
e4aae5f4ebcdbfa91fbb1b67b6c38012eddbec6bae8f5d99606e6decc5a7808d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.7
|
Security for sanduk 0.2.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.