sanduk
'sanduk', pronounced SAN-dook, means 'box' in Arabic.
sanduk is a Python CLI and package that runs a coding agent inside a disposable container. The agent does its work and writes a report to a bind-mounted directory. Then the container is deleted.
Eight agents are available:
Two container engines are supported:
-
apple container on macOS
Each sits behind a registry -- an agent behind sanduk.agent.Agent, an engine behind sanduk.runtime.Runtime -- so another of either is one class. An agent can live in your own package and be found by entry point; see docs/agents.md. Podman is not implemented.
Four providers are supported: Anthropic, OpenAI, OpenRouter, and any OpenAI-compatible server, which includes a local llama-server. See Providers.
In sealed mode the container 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
-
A container engine, one of:
-
Apple's
container1.2.0 or later, which needs Apple silicon and macOS 26 or later -
docker, with a daemon on this kernel. The relayed modes need the bridge gateway to be an address this host can bind, which Docker Desktop, Colima and Lima do not give. Not the snap package: its confinement blocks every container sanduk starts, andrunrefuses it.
-
-
Python 3.11 or later.
uvas well, for a source checkout: the Makefile targets run through it -
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.
Each agent has its own image. Claude Code, codex, opencode and pi run on node:22-slim; hermes is a Python package, so its image is python:3.12-slim. hax, minima and prime-agent run on debian:trixie-slim without Node: hax and minima are static binaries, and prime-agent is a standalone binary whose Python kernel the build prepares. All carry 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: add it with a kit or recipe, point --image at your own image, or --containerfile at one to build.
Install
pip install sanduk
uv tool install sanduk and pipx install sanduk install it into its own environment, which suits a command-line tool. sanduk has no Python dependencies; it needs only a container engine.
The wheel carries a recipe per agent, so sanduk build works from a plain install with no checkout.
Quickstart
pip install sanduk
export OPENAI_API_KEY=sk-...
sanduk build # optional: run builds a missing image
sanduk run 'Summarise every Python file here.' -w ./work --mode sealed
sanduk --help
From a checkout, where the Makefile wraps the same commands:
git clone https://github.com/shakfu/sanduk.git && cd sanduk
make sync
make image
export OPENAI_API_KEY=sk-...
make run TASK='Summarise every Python file here.' WORK=./work
make targets call uv run sanduk, so an editable checkout and an installed copy take the same flags.
Three modes
--mode sets two properties: whether the host keeps the key, and whether the container has a route off it.
open (default) |
key-safe |
sealed |
|
|---|---|---|---|
| Host filesystem | container only | container only | container only |
| API key location | in the container | host only; the container holds a run token | host only; the container holds a run token |
| Egress | unrestricted | unrestricted | none |
| Agent can POST your source anywhere | yes | yes | no |
| Which model endpoints it may call | all | the provider's, matched exactly | the provider's, matched exactly |
Model policy (--allow-model, --max-tokens-cap) |
none | enforced on the host | enforced on the host |
| Record of what it sent upstream | none | --log-bodies, model calls only |
--log-bodies, every call |
open is filesystem isolation and nothing more. sealed is where the containment is: the network is created with --internal, so the relay on the bridge gateway is the only address the container can reach.
key-safe is the same relay on a routable network. It exists for runs that need npm install, pip install or git clone and must not hold your key. It buys credential protection and keeps the model policy; it buys no containment, and the audit trail stops being complete, because what the agent sends anywhere else never passes the relay.
--proxy still works as the old spelling of --mode sealed.
Against 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 every mode's network
sanduk system status whether the engine is ready (also start, stop)
sanduk list agents what each registered handler speaks
sanduk list providers the URL an agent must be given, per provider
sanduk list runtimes engines, whether each is installed, and the default
sanduk list recipes image recipes, each with its agent and kits
sanduk list kits kits of tools and skills, each with the hash a recipe pins
sanduk assistant add <dir> register a scheduled assistant
sanduk tell <name> <text> queue a message for its next wakeup
sanduk tick run every assistant that is due, once
sanduk serve tick on a loop, in the foreground
sanduk outbox what they have produced
sanduk runs wakeup history
The first group is one container and no memory of it. The second is Assistants: the same run, on a schedule, with state that outlives it.
Every container-engine call sanduk makes goes through Runtime, so the Makefile names no engine and --runtime selects one for any of these. Without --runtime, sanduk takes the first engine on PATH: apple then docker on macOS, docker elsewhere. An explicit --runtime is used as given.
Providers
--provider anthropic api.anthropic.com ANTHROPIC_API_KEY
--provider openai api.openai.com OPENAI_API_KEY (default)
--provider openrouter openrouter.ai/api/v1 OPENROUTER_API_KEY
--provider openai-compat --upstream, no key needed OPENAI_API_KEY if set
With --provider openai and no --model, the run selects gpt-5.6-luna. Other providers leave the model to the agent.
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 --mode sealed \
--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; codex speaks OpenAI Responses only. sanduk list agents prints what each handler speaks, and a pairing no protocol supports 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
--agent codex codex openai, openai-compat (default)
--agent hax hax every provider
--agent hermes hermes-agent openai-chat providers, --mode open only
--agent minima minima every provider
--agent opencode opencode every provider
--agent pi pi every provider
--agent prime prime-agent 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 output. Nothing else about a run differs, so another 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 --mode sealed --agent hax \
--provider openrouter --model anthropic/claude-sonnet-5
codex accepts only wire_api = "responses", so it pairs with openai or with an openai-compat server that answers /v1/responses. It has no base-URL variable: sanduk passes the endpoint as a -c model_providers... override, which is why argv is handed the run's wiring. Its own sandbox is disabled with --sandbox danger-full-access, since the container is the boundary and codex's sandbox would only stop the work; --skip-git-repo-check is passed because the bind mount is usually not a repository.
hax is a static C binary with no approval gate, which suits a container that is already the boundary. The image carries no Node; python3 is its only interpreter. --allowed-tools and --permission-mode are Claude Code flags and are refused rather than dropped.
hermes is the one agent that cannot use the relay. It ignores every endpoint override there is -- measured against 0.19.0 with a stub upstream inside the container, --base_url, OPENROUTER_BASE_URL and model.base_url in ~/.hermes/config.yaml all left the call going to openrouter.ai -- so check refuses key-safe and sealed by name rather than letting a run fail at its first call. --mode open is what remains: a disposable container and a bind mount, with your key inside it. --model is required and takes an OpenRouter id. It also prints prose rather than JSON, which is what Reader.line exists for, and it reports API calls rather than tokens, so its stats line says calls.
export OPENROUTER_API_KEY=sk-or-v1-...
sanduk run 'Summarise a.py.' -w ./work --agent hermes --provider openrouter \
--model deepseek/deepseek-v4-flash-0731 --mode open --max-turns 6
minima is a static Rust binary with no approval gate. sanduk runs it with --json --prompt, which needs minima 0.2.1 or later; the image installs 0.3.0, the first release that reads AGENTS.md and skills. Its --provider fixes the wire format, so sanduk names the minima provider that matches and moves the endpoint with MINIMA_BASE_URL. --model is required unless the provider has a default (openai does): a fresh container has no remembered model. --effort is refused along with the two Claude Code flags.
opencode takes its whole configuration from OPENCODE_CONFIG_CONTENT, so no opencode.json is written into the bind mount, where it would sit in your repository and be editable by the agent reading it. The provider block picks an npm driver by wire protocol: @ai-sdk/anthropic for Messages, @ai-sdk/openai-compatible for Chat Completions. Both are installed in the image, because the proxy network has no route to fetch one at runtime. --model is required unless the provider has a default (openai does): the config names one model.
pi speaks all three protocols the relay carries, and its provider block names which one with an api field. It reads providers from models.json in its config directory, not from a variable or a flag, so the image's entrypoint writes that file from SANDUK_MODELS_JSON and pi is never given the bind mount as a place to find one. --model is required unless the provider has a default (openai does), and --no-approve is passed so a .pi/settings.json in the mounted repository cannot steer the run. With --provider anthropic the base URL is the bare root: pi appends /v1/messages itself.
prime-agent is pi's CLI in PrimeIntellect's build: its npm release declares bin: prime-agent and depends on the @earendil-works/pi-* packages, so its handler is a subclass of pi's and inherits the reader, the argv and the protocols. Three things differ, each measured rather than read. Its provider block names the credential's variable bare where pi writes $NAME. It has no --no-approve, so a .prime/agent/settings.json in the mounted directory is read: that steers the run without widening the box, which is the container and the relay either way. And its only tool is a Python REPL, so the image carries the kernel; without it the agent answers by trying to install uv, which the proxy network has no route for. The image installs the standalone Linux binary from a checksummed archive, not the npm package, and prepares the kernel at build time. fd and rg come from Debian packages, not the unpinned downloads prime-agent otherwise makes; it accepts Debian's fdfind name for fd. The entrypoint defaults UV_OFFLINE=1: prime-agent installs its built-in Python skills from PyPI when its kernel starts, and in a sealed run uv retried for about 6 minutes before failing. Pass -e UV_OFFLINE=0 in key-safe or open to install them.
Kits and recipes
A recipe is a JSON description of an agent image: base image, agent, install sections and kits. sanduk renders it to one Containerfile. Each agent's image comes from a shipped recipe. A kit is a bundle of pinned tools and skills, such as docs: d2 and officecli, with a skill for each.
sanduk run 'Draw the module graph.' --agent claude --provider anthropic --kit docs add a kit, unpinned
sanduk run 'Draw the module graph.' --recipe claude-docs --provider anthropic a recipe that pins it
sanduk build --recipe claude-docs --dry-run print the resolved recipe and Containerfile
{
"name": "claude-docs",
"inherits": "claude",
"kits": [{"name": "docs", "sha256": "<sha256 of kit.json>"}]
}
- A recipe pins each kit by the SHA-256 of its
kit.json, assanduk list kitsprints it. A changed kit stops the build. kit.jsonpins every download and every skill file by its own hash, so the recipe's pin covers them too. A file a kit'scopytool takes from the kit directory is not pinned yet.- Recipes inherit by name, left to right. A child can
removeinherited kits, sections, env keys or section types. instructionsin a recipe is standing guidance for the agent: a path relative to the recipe, or{"text": "..."}. It is written read-only to the agent's user-levelCLAUDE.mdorAGENTS.md, so every run reads it beside any in the repository. A child's instructions follow its parents'. claude, codex, hax, minima, opencode, pi and prime support it; hermes refuses it.--bareis refused beside it.- A kit's skills land where the agent reads skills:
~/.claude/skillsfor claude,~/.agents/skillsfor codex, hax, opencode and pi,~/.config/minima/skillsfor minima,~/.hermes/skillsfor hermes. They are root-owned and read-only. Where prime reads them is unconfirmed, so a kit with skills is refused for it. - Everything is installed at build time, so a
sealedrun fetches nothing. A kit that needs the network at run time is refused undersealed. - The image tag is
sanduk-<recipe>:<hash>over everything the build reads. An edited recipe or kit builds a new image;sanduk destroydeletes every build of the recipe.
Your own recipes and kits go in ~/.config/sanduk/recipes/<name>.json and ~/.config/sanduk/kits/<name>/kit.json, or are named by path. A name is never looked up in the working directory, and a name shipped with sanduk cannot be replaced. assistant.toml takes recipe = "...". The build runs as root with network access, outside every mode, so a third-party kit or recipe has the power of a Containerfile. See docs/dev/kits.md for the format.
How the relay works
-
sanduk-netis created with--internal: no route off the host. A network already there is reused as it was created, so a sealed run refuses one Docker reports as routable, and refuses--proxy-network sanduk-openon any engine. -
Under
--runtime apple, vmnet creates the host bridge only while a container is attached, so a placeholder container is started first and torn down at the end. Docker needs none. -
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_usageto streamed Chat Completions requests, without which the response carries no counts at all. Responses streams report usage unasked and reject the field.
Flags worth knowing
--budget 2.50 stops a run after its calls have cost that much. The relay counts usage.cost, which OpenRouter returns on every response, and refuses the next call with a 402 once the total is past the ceiling. The ceiling is therefore crossed exactly once, by one call: what that call will cost is not knowable before it is made, and every estimate of it is wrong in one direction or the other -- a growing conversation makes each call dearer than the last, while cache reads make them cheaper. --max-tokens-cap is what bounds the size of the crossing call.
Holding that to one call means a budgeted run relays one call at a time. Without it every call already in flight has passed the same check, and an agent that opens five at once crosses by five. Reserving credit up front would keep the parallelism, but it needs a price for a call that has not been made, and the relay keeps no price table by design. A run that reads a response with no cost in it stops rather than counting the call as free: spending against a total known to be short is what the flag exists to prevent.
Only OpenRouter reports cost, so --budget is refused for the other providers rather than guessed from a price table this package would have to keep current, and it needs the relay, so --mode open is refused too. The run's total is printed at teardown, and the refusal reaches the agent as budget_exceeded with the two figures in it.
--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.
--timeout bounds one run (default 900s). Every duration sanduk takes reads the same way: a bare number is seconds, and a suffix of s, m, h or d multiplies it, so --timeout 15m and --timeout 900 are the same run. In a relayed mode the network holder is started for that long plus five minutes, because vmnet keeps the host bridge up only while a container is attached: if the holder went first, the relay's address would go with it. A timeout over a week is refused, as is a zero or negative one. One upstream call is capped separately at 900s by the relay.
--log-bodies records each request body: a digest line per call, full JSON under --log-dir (default ./sanduk-logs). A --log-dir inside -w or a --mount is refused, so the agent cannot read or edit its own audit trail; sanduk run -w . needs a --log-dir elsewhere. Bodies contain the system prompt and every file the agent has read.
--mount HOST:DEST[:ro] puts another host directory in the container, beside the one -w gives it. Repeatable, read-write unless :ro. A destination at or under /work is refused: it would shadow part of what -w put there, which is a run reading the wrong files rather than one that fails. Read-only is rendered as --mount type=bind,...,readonly because -v host:dest:ro is Docker's spelling alone.
--dry-run prints the engine's run command and exits. --keep leaves the container for inspection, and warns that inspecting it then exposes the token.
A run records the containers it owns, and its own pid, under $XDG_STATE_HOME/sanduk/runs (~/.local/state by default). Every run first deletes the containers of records whose owner process is gone. SIGKILL cannot be caught, so a killed run cannot delete its own container -- the next run does it, and until then the container is alive holding the run token. SIGTERM and SIGHUP are caught and tear down in place. --keep releases the record, so a container you asked to keep is never swept. A state directory that cannot be written stops the run before it starts.
Assistants
run is one container and keeps nothing. An assistant is a directory, a schedule and a mailbox around that same run.
~/assistants/triage/
assistant.toml what to run, and how often
brief.md the standing instruction
workspace/ bind-mounted at /work; where the agent keeps its memory
reports/ one task and one report per wakeup
# assistant.toml
agent = "pi" # or recipe = "claude-docs": the recipe names its agent
provider = "anthropic"
model = "claude-sonnet-5"
every = "30m" # an interval, not a cron expression
brief = "brief.md"
gate = "gate.sh" # optional: a non-zero exit skips the wakeup, unpaid
timeout = "15m" # or 900; a bare number is seconds
max_failures = 3
mode = "sealed" # open | key-safe | sealed, as above
approval = false # true: results wait for `sanduk approve` before delivery
mounts = ["../repo:/repo:ro"] # host paths are relative to this file
args = [] # extra `sanduk run` flags, verbatim
sanduk assistant add ~/assistants/triage register the directory
sanduk assistant list | show <name> schedule, failures, queue depth
sanduk assistant enable | disable <name> the operator's switch
sanduk tell <name> 'look at PR 12' queue a message for the next wakeup
sanduk tick run everything that is due, once
sanduk serve [--interval 60] the same, on a loop
sanduk outbox [--deliver CMD] read results, or pipe them somewhere
sanduk outbox --pending what is waiting for a decision
sanduk approve <id> | reject <id> decide it
sanduk runs wakeup history with exit codes
tick is what a scheduler calls:
*/10 * * * * cd ~/assistants && /opt/homebrew/bin/uv run sanduk tick
serve is the same pass on a loop, in the foreground, for when you would rather run one process than a cron entry -- under launchd, systemd, or a terminal. It sleeps until the next assistant is due, capped at --interval, and holds no container and no credential in between: it is a timer. SIGINT or SIGTERM between wakeups stops it after the pass in flight. A signal during a wakeup tears that wakeup down first and ends the pass there, rather than waking the next assistant with the signal already delivered; the interrupted wakeup does not count against the assistant's failures.
A wakeup is one sanduk run: the brief plus any queued messages become the task, workspace/ is the bind mount, and the report is copied to reports/<timestamp>.md. The container is deleted at the end like any other run, and the relay holds the key for exactly as long as the wakeup lasts.
What outlives a wakeup lives in SQLite at $XDG_STATE_HOME/sanduk/assistants.db (~/.local/state by default): the schedule, the claim that stops two processes running one assistant, the inbox and the outbox. Messages are consumed only by a wakeup that finished, so a failed one still has them. A failure backs the schedule off, doubling per consecutive failure, and disables the assistant at max_failures until you enable it again.
--deliver hands each undelivered result to a command on stdin, with SANDUK_ASSISTANT and SANDUK_RUN_ID in its environment. sanduk ships no platform adapters and holds no messaging credential.
approval = true holds every result until a person runs sanduk approve <id>; sanduk reject <id> keeps it from ever being sent, and neither decision undoes the other. Delivery is the gate because delivery is the only thing that leaves the box: what the agent does, it does inside a container that is deleted at the end of the wakeup.
mounts puts other host directories in the container, resolved from the config file's own directory, so an assistant can read a repository it must not write: mounts = ["../repo:/repo:ro"].
Two things to hold onto. mode defaults to sealed and should stay there: an unattended run is the one nobody is watching. And whatever the agent writes into workspace/ is read as instruction on the next wakeup, which is memory and also a channel between runs -- keep it somewhere you read the diffs.
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
recipes.py recipes: inheritance, kit pins, rendering, image tags
kits.py kits: tools, skills, and the hashes that pin them
sections.py install steps recipes and kits share, and their checks
catalog.py finding recipes and kits by name
runs.py which process owns which container; the orphan sweep
assistants.py identity, schedule, mailbox: the assistant commands
agents/ the shipped handlers: claude.py, codex.py, hax.py,
hermes.py, minima.py, opencode.py, pi.py, prime.py
proxy.py the host-side relay
preflight.py key validation, macOS firewall check
util.py subprocess, state-directory and reporting helpers
errors.py AgentboxError and its exit code
resources/
recipes/ claude.json, codex.json, hax.json, hermes.json,
minima.json, opencode.json, pi.json, prime.json, claude-docs.json
recipes/files/ entrypoints the pi and prime recipes copy in
kits/ docs/
Another 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.
Every container drops all Linux capabilities and runs under an init process. --runtime docker adds --security-opt no-new-privileges and --pids-limit 1024, which Apple's CLI has no flags for and which matter on a shared kernel; there each container is its own VM. The root filesystem stays writable, because every shipped agent writes under $HOME.
--oci-runtime NAME swaps the program Docker starts the container with: runsc for gVisor, or io.containerd.kata.v2 for a Kata VM. Either puts the agent off the host kernel. It is refused under --runtime apple. See docs/dev/microvms.md.
--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-live Live provider suite: needs LLAMA_SERVER, or a provider key
make test-agents One real run per agent; spends money
make test-all Every suite; paid agent runs still need AGENT_LIVE=1
make qa lint-check, format-check, typecheck, test
make image Build the agent image if missing
make image-rebuild Force a rebuild; `sanduk run -b` rebuilds before a run
make run TASK='...' WORK=./dir AGENT=minima ARGS='--model gpt-5.6-luna'
make run-proxy Same, sealed: no egress, 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 on every push to main and every pull request:
qa: lint, format and types, once.test: the fast suite onubuntu-latestandmacos-latest, on Python 3.11 and 3.14.docker: the integration suite on Linux against a real Docker daemon.gvisor: the same suite with containers started byrunsc.
The docker and gvisor jobs are the only places --mode sealed is proved. A native daemon puts the bridge on the host kernel; Apple's engine and Docker Desktop do not.
images runs weekly and on manual dispatch. It builds every agent's image and asks the agent for its version, because pinned agent releases change their flags and output without a commit here.
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.
The integration suite boots real containers 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. The same variable runs a streamed /v1/responses call, which checks that the relay adds no stream_options there and reads usage from the final event. LLAMA_SERVER runs that check against llama-server as well.
LLAMA_SERVER=http://127.0.0.1:8080 make test-live
OPENAI_MODEL=<a cheap model> make test-live
make test-agents does one real run per shipped agent: its image, a real model, the sealed relay (hermes runs open), and a REPORT.md. The task hashes a random nonce with python3, so the digest in the report shows code ran in the container. Every run spends money, so the suite skips unless AGENT_LIVE=1, which the target sets. An agent whose provider key is unset is skipped. claude uses Anthropic, codex OpenAI, and the rest OpenRouter, under --budget except hermes, whose --mode open cannot be capped; ANTHROPIC_MODEL, OPENAI_MODEL and OPENROUTER_MODEL override the models.
make test-agents
make test-agents ARGS='-k "minima or hax"'
pyproject.toml deselects the container, live and agent suites, so pytest and make test run only the fast suite.
Measured on this setup
The per-agent rows are one task and one local model, counted by the relay in a relayed mode. What they compare is each agent's fixed prompt overhead, not the quality of its answer.
| 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 |
| One task, one model, input tokens per run: pi | 4.8-5.1k |
| The same task: codex | 12.8-20.8k |
| The same task: opencode | 23.4k |
| The same task: prime-agent | 14.8k |
Container direct egress on sanduk-net (sealed) |
000 |
The same on sanduk-open (key-safe) |
401: it reached the provider |
| 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. A relayed mode 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.
Apple's builder (container 1.2.0) can copy a directory as an empty one: COPY dir/ dest/ did so in 5 of 5 clean builds unless the same build also copied a file by name. Recipes copy each file by name. A Containerfile of your own passed to --containerfile should too.
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.3.1
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.3.1.tar.gz | 197.8 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| sanduk-0.3.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 316.4 kB
Release files / sanduk-0.3.1.tar.gz
| Download URL | sanduk-0.3.1.tar.gz |
|---|---|
| Size | 197.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
868ba2f9e33ce57d67c16ce6dad4db1781236162d82d49603f7d24244b5d3c67
|
|
BLAKE2b-256 checksum How to use checksums |
b3365198b3a0839a0c53b4128e212fde67daae6d22f3b9b4a26fcf5cdef09eae
|
| 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.3.1-py3-none-any.whl
| Download URL | sanduk-0.3.1-py3-none-any.whl |
|---|---|
| Size | 118.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
4cc8f68ac5e17a1b458a0b7b3b68ecb5329c6f6511ab86798c61b2d9f8d9679c
|
|
BLAKE2b-256 checksum How to use checksums |
03305eb7a595f49ea20e4987a051e11d26e766d621bea916eb97e151f2338040
|
| 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.3.1
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.