Safe code execution: a toolbox image, and a sandbox sidecar.
Loomcycle's default runtime image is distroless. That is a good default: minimum surface area, no shell, no package manager, no compilers, nothing an attacker inside the container could reach for. It also produces one predictable friction point. An agent that wants to write a Python script and run it, or compile and test a Rust crate, or shell out to go build, has nowhere to do it. The Bash tool tries to exec /bin/sh and fails: there is no shell. The Bashbox in-process shell (RFC AJ) has native jq and awk in pure Go, but no external toolchain to shell out to. The agent is well-isolated but can't actually run code.
There are two reasonable answers to this, and they are appropriate to different threat models. The single-tenant developer who trusts their own agents wants a toolchain in the same container: same uid, same mount paths, drop-in image swap, no new plumbing. The multi-tenant operator who runs untrusted agents wants each code-execution session isolated in a fresh, network-less, hardened container, driven from the outside so a compromised toolchain never touches the runtime.
RFC BI P1 (v1.23.0) ships both. Neither replaces the distroless default. Both are opt-in, and they compose orthogonally: an operator can use the toolbox image AND wire the sandbox sidecar in the same deployment, for different agents.
The short version. Two answers, both opt-in, both additive to the distroless default. Answer 1: the loomcycle-toolbox image is the SAME loomcycle binary on a Debian base preloaded with a dev toolchain (Python, Go, Rust, C++, Node, plus git, gh, curl, and after v1.23.1 also jq, rsync, wget, unzip, sqlite3) at the same uid 65532 and mount paths, so it's a drop-in image swap that makes the existing Bash tool and Bashbox host-command fallback usable. Isolation is weak by design (code runs in loomcycle's own container), so single-tenant / trusted only. Published multi-arch as denngubsky/loomcycle-toolbox. Answer 2: for untrusted or multi-tenant code, a standalone builder sidecar runs each session in a dedicated ephemeral container with --network none, --read-only rootfs, tmpfs /work, --cap-drop=ALL, no-new-privileges, non-root, cpu/mem/pids capped, and a pluggable --runtime (runc → runsc → kata). Loomcycle stays distroless and drives it over HTTP-MCP; loomcycle NEVER runs a container engine itself. Six tools: sandbox_open, exec, write, read, close, list. One long-lived container per session, reused across a compile→test→fix loop. Constant-time bearer auth, principal-owned sessions (a leaked id from another principal never resolves; the P2 tenant seam), TTL GC plus boot orphan-sweep via a loomcycle.managed=1 label. Answer 3 wires it in: the sandbox bundle. Enable with LOOMCYCLE_PRESETS=base,sandbox; it registers the dev/sandbox agent + skill and the mcp_servers.sandbox block with Authorization: Bearer ${run.user_bearer:-${LOOMCYCLE_SANDBOX_TOKEN}} (per-run bearer when present, else a shared secret). Without the sidecar deployed, the mcp__sandbox__* tools are registered-but-unreachable and the MCP pool retries lazily. Additive: no breaking changes, no new wire RPCs, no schema migration. Adapters bump to 1.23.0 in lockstep with no code change. This is RFC BI P1 (single shared bearer); attested per-tenant identity, run-liveness-poll GC, and the Kata microVM tier are P2/P3 follow-ups.
Answer 1: the toolbox image
The most direct answer to "the distroless image has no toolchain" is "publish a version with one." That's the loomcycle-toolbox image. It is the SAME loomcycle binary, byte-identical, built from the same commit at the same tag. What differs is the base layer: Debian instead of distroless-static, with a dev toolchain baked in.
What ships in the image, as of v1.23.1:
| Language | Toolchain |
|---|---|
| Python | python3, pip, venv |
| Go | go (via golang) |
| Rust | cargo, rustc |
| C / C++ | gcc, g++, make, clang |
| Node | node, npm |
| Shell utils | bash, git, gh, curl, jq, rsync, wget, unzip, sqlite3 |
The uid is 65532, matching the distroless image's default. The mount paths are identical. An operator switching from denngubsky/loomcycle to denngubsky/loomcycle-toolbox changes one line in their Compose file or their Kubernetes manifest. Nothing else moves.
With the toolchain present, the existing Bash tool works. So does the Bashbox host-command fallback (the escape hatch that shells out to a host binary when Bashbox's pure-Go implementation doesn't cover the case). An agent can now run go test ./..., cargo build --release, or python3 script.py and get real output.
Isolation is weak by design. The toolchain runs inside loomcycle's own container. A compromised script has the same reach as the loomcycle binary itself. If your threat model is "I trust my own agents to run my own code," that reach is fine: the toolbox image gets you a usable Bash tool in one config change. If your threat model is "I don't trust the agent," the toolbox image is not the shape you want. The next section is.
The image is published multi-arch (linux/amd64 + linux/arm64) as denngubsky/loomcycle-toolbox:<version> with a latest tag. Operators are strongly encouraged to pin the version rather than track latest in production, matching the guidance for the plain loomcycle image.
The HOME-in-fallback-env fix (v1.23.1)
One quiet detail worth naming, because it caught operators after v1.23.0 shipped. Go, Cargo, npm, and pip all use the HOME env var to locate their caches ($HOME/.cache/go-build, $HOME/.cargo, $HOME/.npm, $HOME/.cache/pip). The Bashbox host-command fallback scrubs the child env to a minimal set (PATH only, by default) for security. Result: go build in a fresh session on the toolbox image logs "no cache dir" and rebuilds everything on every run. cargo build tries to write to /nonexistent/.cargo and fails.
The v1.23.1 docs pinned this: for the compiled-language toolchains, allow HOME in the fallback env with LOOMCYCLE_BASHBOX_FALLBACK_ALLOWED_ENV=HOME. python3 script.py, gcc, and clang don't need it (they don't cache); Go, Cargo, npm, pip do. The TOOLBOX_IMAGE.md operator guide has the fuller recommended fallback-command list and the compiled-binary caveat (running a compiled ./a.out via Bashbox fails because Bashbox doesn't exec arbitrary binaries; use go run or cargo run or fall through to the raw Bash tool).
Answer 2: the isolated sandbox
The multi-tenant case is different. If loomcycle is serving multiple tenants, and any of them can author an agent that runs code, the code must run somewhere that a compromise can't reach loomcycle's own container, other tenants' state, or the host. That requires isolation loomcycle cannot provide from inside its own container.
There is a Catch-22 here worth naming. The obvious answer, "let loomcycle spawn a container per code session," runs into two problems:
- Loomcycle's default image is distroless-nonroot. Rootless podman/docker/nerdctl needs a subuid range and
newuidmap; distroless-nonroot has neither. Running a container engine inside the loomcycle container is not going to work. - The alternative is to mount the host Docker socket inside loomcycle. That effectively grants root on the host to anyone who can reach loomcycle's process. Multi-tenant operators cannot ship that.
So loomcycle does not run a container engine. What v1.23.0 ships instead is a separate service (the builder sidecar) that owns the container engine, and loomcycle drives it over HTTP with MCP tool calls. The security seam is that loomcycle can request "please run this code" but has no privilege to reach containers directly. The sidecar has that privilege; loomcycle does not.
The sidecar container's own hardening
The sidecar ships as a container image built on quay.io/podman/stable (chosen for its rootless-container support). It runs as its own service; deployed alongside loomcycle. It in turn spawns per-session builder containers with the following flags on each one:
--network none: the builder container has no network. A malicious script cannot exfiltrate, cannot reach the host, cannot reach loomcycle, cannot reach other tenants.--read-only: the container's root filesystem is read-only.--tmpfs /work: an in-memory tmpfs is mounted at/work. The container's writable surface is bounded by the tmpfs size (configurable), and it is gone the moment the container exits.--cap-drop=ALL: every Linux capability is dropped. The container has the privileges of an ordinary process in an ordinary Linux user, minus everything the kernel would give a process by default.--security-opt no-new-privileges: setuid binaries can't gain new capabilities. A compromised toolchain can't privilege-escalate inside the container.- Non-root: the container runs as a non-root user.
- Cpu / mem / pids capped: resource limits enforced at the container level. A fork bomb hits a pids limit; a memory hog hits an OOM.
The most interesting flag is --runtime. It's pluggable, with a preferred order:
runc: the standard Linux container runtime. Fast startup, standard isolation.runsc(gVisor): a user-space kernel that intercepts syscalls. Strong isolation against kernel-exploit-based container escapes.kata: a VM-based runtime. Every container is its own microVM. Strongest isolation; slowest startup.
The operator picks. Sysbox is recommended for the sidecar's outer container (it makes the podman-in-container path work without privileged). If Sysbox isn't available, privileged on the sidecar's outer container is the documented fallback. Rack that up against your threat model.
The sidecar also ships a reference toolchain session image (the image the sidecar spawns per session). By v1.23.1 that image has the same utilities the toolbox image has, so an agent's compile-test-fix loop sees a similar environment either way.
How loomcycle talks to the sidecar
HTTP with the MCP protocol. The sidecar is an MCP server, and loomcycle registers it as an mcp_servers.sandbox block. Loomcycle sees six tools:
| Tool | What it does |
|---|---|
sandbox_open | Open a new session, get a session id. The sidecar spawns a per-session container. |
sandbox_exec | Run a command inside the session's container. Returns stdout, stderr, exit code. |
sandbox_write | Write a file into the session's tmpfs /work. |
sandbox_read | Read a file back from the session's tmpfs. |
sandbox_close | Explicitly close a session. The sidecar tears the container down. |
sandbox_list | List the sessions owned by the caller. |
One long-lived container per session, reused across many exec calls. That's important: a compile-test-fix loop is a natural sequence of write source; exec compile; exec test; exec fix if failed; exec test again, and reusing the container preserves the tmpfs, the toolchain caches (in memory), and the incremental build state (in the same tmpfs). A fresh container per exec would rebuild the world every time.
Auth is a constant-time bearer check. The sidecar has a SANDBOX_AUTH_TOKEN; loomcycle sends it. Sessions are principal-owned: the token includes the principal, and sandbox_open stamps that principal on the session record. Every subsequent op checks that the caller's principal matches the session's owner. A leaked session id (they're not secret) from another principal never resolves. That's the P2 tenant seam: sessions are already principal-owned, so extending it to per-tenant identity is a follow-up, not a redesign.
GC is TTL-based (the sidecar tears down sessions past their idle deadline) plus a boot-time orphan sweep. Every container the sidecar spawns is labelled loomcycle.managed=1; on boot, the sidecar lists containers with that label whose session record is missing and reaps them. So if the sidecar was killed mid-lifetime, the next start cleans up its own leftovers without touching containers it doesn't own.
Answer 3: the sandbox bundle
The sidecar is a service; loomcycle needs config to reach it. The sandbox bundle is that config, packaged so the operator enables it with one env var. The bundle:
- Registers the
dev/sandboxagent (a chat agent whose whole job is running code via the sandbox tools). - Ships a matching
team/sandbox-code-reviewskill so any agent that grantsSkillDefcan request it and load the sandbox how-to on demand. - Wires
mcp_servers.sandboxwith the sidecar's endpoint and auth:mcp_servers: sandbox: endpoint: ${LOOMCYCLE_SANDBOX_ENDPOINT} auth: Authorization: Bearer ${run.user_bearer:-${LOOMCYCLE_SANDBOX_TOKEN}}
Enable it with LOOMCYCLE_PRESETS=base,sandbox alongside the rest of your preset stack. Without the sidecar actually deployed, the mcp__sandbox__* tools are registered-but-unreachable: the MCP pool retries lazily and the agent sees a clean "unreachable" tool result rather than a boot failure. That means the bundle can be safely enabled ahead of deploying the sidecar, which matters for staged rollouts.
The auth shape (${run.user_bearer:-${LOOMCYCLE_SANDBOX_TOKEN}}) is worth noting. When the run carries a user bearer, that flows through; otherwise a shared operator secret (LOOMCYCLE_SANDBOX_TOKEN) is used. So the sidecar can tell "this call came from user X on tenant Y" when the caller has a bearer, and falls back to a single shared identity when it doesn't. That's the seam RFC BI P2 extends to attested per-tenant identity: today the sidecar trusts loomcycle to have authenticated the caller correctly; tomorrow the sidecar can verify the caller's identity independently.
Which one do I want?
A short decision table.
| If | Then |
|---|---|
Single-tenant deployment; you trust your agents; you want an agent to shell out to go test | Toolbox image. One line change. |
| Multi-tenant; you cannot trust every agent equally; agents run code that might be adversarial | Sandbox sidecar plus the bundle. |
| You want both on the same deployment (some agents trusted, some not) | Run the toolbox image AND the sidecar; grant different agents different tools. The two answers are orthogonal. |
| Your threat model is "adversarial code with kernel-exploit capability" | Sidecar with --runtime kata. Every session is its own microVM. |
| You're on a distroless-nonroot image and can't change base | Sandbox sidecar only. The toolbox path requires a base image swap. |
Additive on the wire
Everything in this release is additive. No new wire RPCs. No schema migration. The sandbox is an external MCP server plus a config bundle; the toolbox is a build/deploy artifact. @loomcycle/client and Python both bump to 1.23.0 in lockstep with no adapter code change: the adapter version tracks the runtime it targets, not any surface it needs to add.
Merged across #744 (toolbox image), #745 (builder sidecar), #746 (sandbox bundle), and the v1.23.1 utils + docs pass in #748.
What P2 and P3 look like
This is RFC BI P1. Three known follow-ups are queued:
P2: attested per-tenant identity. Today the sidecar authenticates on a single shared bearer (with the per-run bearer flowing through when the caller has one). Tomorrow the sidecar verifies the caller's tenant identity cryptographically, so a leak of the shared bearer can't be used to open a session against another tenant's context. The wire seam is already there (${run.user_bearer:-${LOOMCYCLE_SANDBOX_TOKEN}}); the enforcement is what changes.
P2b: run-liveness-poll GC. TTL GC handles the "session was abandoned" case. It doesn't handle the "loomcycle run died mid-session" case cleanly: the session might sit at TTL/2 before the sidecar realizes nobody's coming back. A poll from the sidecar to loomcycle asking "is run X still alive?" closes that gap.
P3: the Kata microVM tier as first-class. Kata is available in P1 via --runtime kata, but the sidecar's default toolchain image was designed around runc/runsc assumptions. A Kata-tier default image, deployment guide, and the operational cost trade-offs get their own release once P2 lands.
What else this unlocks
Two adjacent capabilities become straightforward once the sandbox sidecar is in place. Neither is shipped in v1.23; they're on the design board because the primitive is now there.
A run this code in your CI pattern: an agent writes a test, calls sandbox_open, writes the source, runs the test, and reports the exit code. That's the shape of a self-testing agent, and RFC BI P1 gets you the safe execution primitive for it.
A code review agent that actually runs the code: an agent reads a diff, spawns a sandbox, applies the diff to a checked-out copy of the source, runs the test suite, and reports failures. Same primitive. Different agent.
Companion reading: config-driven providers (RFC BF) for the local-model provider work in v1.21.0; turn-scoped cancellation (RFC BH) for the v1.22.0 chat primitive that composes cleanly with a long-running sandbox session; the Bashbox in-process shell (RFC AJ) for the pure-Go shell path that's the third leg of the "how does the agent run code" story; docs/TOOLBOX_IMAGE.md for the toolbox operator reference; and docs/SANDBOX.md for the sidecar operator guide.