Code Sandbox
Run Python and JavaScript code safely in isolated Bubblewrap namespaces
The code sandbox is Clouisle's secure, isolated execution environment for running user-supplied Python and JavaScript code within workflows, agents, and tools. Sandbox tasks are submitted to a dedicated Celery worker (consuming only the sandbox queue, separate from the main worker's four queues), where each task runs inside its own Bubblewrap user + mount namespace and is constrained by timeouts, output limits, and disk checks.
The supplied sandbox-worker deployment runs as root (user: "0" / runAsUser: 0) with CAP_SYS_ADMIN added on top of the runtime default cap set and seccomp=unconfined — it is not a rootless container. Isolation comes from the per-task Bubblewrap user + mount namespace, not from de-privileging the worker itself; the escape consequences and hardening options are covered under "Hardening" below.
Deep-Dive Bubblewrap Namespace & Mount Layout
Agent/Workflow → API → Celery Queue (sandbox) → Sandbox Worker
↓
Bubblewrap process
↓
/workspace → current job/session directoryWhen filesystem isolation is enabled (SANDBOX_FILESYSTEM_ISOLATION_ENABLED=true), the sandbox launcher wraps executions in a dedicated bwrap process configured with strict namespace isolation:
bwrap \
--unshare-user --unshare-pid --unshare-ipc --unshare-uts \
--die-with-parent --hostname clouisle-sandbox \
--ro-bind /usr /usr --ro-bind /bin /bin --ro-bind /lib /lib --ro-bind /etc /etc \
--dir /proc --dev /dev \
--bind <host_job_dir> /workspace \
--bind <host_job_dir>/tmp /tmp \
--chdir /workspace \
-- <command> <args...>- Namespace & Process Confinement:
- Unshared Namespaces:
--unshare-user,--unshare-pid,--unshare-ipc, and--unshare-utscreate an isolated environment with its own process tree (PID 1 mapping), IPC communication boundary, and isolated hostname (clouisle-sandbox). - Cascade Process Reaping:
--die-with-parentguarantees that all child processes inside the sandbox are instantly reaped if the supervising Celery worker exits or times out.
- Unshared Namespaces:
- Read-Only System Roots & Workspace Mapping (
RUNTIME_READONLY_ROOTS):- Read-Only System Roots:
/usr,/bin,/sbin,/lib,/lib64, and/etcare mounted as--ro-bind, ensuring code cannot modify system binaries, Python site-packages, or OS configuration. - Standard Workspace Mapping: The host job directory is mapped directly to
/workspace, with an isolated/tmpdirectory mounted at/tmp(--bind <workspace>/tmp /tmp). - Logical Path Auto-Rewriting:
_logical_workspace_pathand_map_workspace_valueautomatically rewrite command arguments and environment paths so tasks only ever see/workspace/....
- Read-Only System Roots:
- Container Runtimes & CJK Font Support:
- Runtimes: Pre-installed Python 3.13 (managed via
uv) and Node.js 22. - CJK Fonts Included: The
clouisle-sandbox-workerimage packagesfontconfig,fonts-wqy-zenhei, andfonts-wqy-microheiwithfc-cache -fv. Because/usr/share/fontsis exposed via read-only bind mount, sandbox scripts generating charts (matplotlib,seaborn,Pillow) render Chinese, Japanese, and Korean text flawlessly without missing-character boxes ("tofu").
- Runtimes: Pre-installed Python 3.13 (managed via
Supported Runtimes
| Runtime | Base Environment |
|---|---|
| Python | Python 3.13 with standard library and configured packages |
| JavaScript | Node.js 22 with core modules and configured packages |
Usage in the Platform
- Code tool: create reusable code utilities from Admin Console > Capabilities > Code. Tools can install exact-pinned packages and declare artifacts under
/workspace; saved tools can be called by agents and workflows (see Custom HTTP and Code Tools). - Workflow code node: embed code directly in workflow graphs. The code node receives input variables and returns results to downstream nodes (see Workflow Nodes).
- Agent-level execution: agents invoke code tools via function calling; the LLM decides when to run code based on the task.
Execution Limits
Limits come from SandboxLimits, and the effective value is whichever bound trips first:
| Limit | Default | Allowed range | Notes |
|---|---|---|---|
timeout_seconds | 30 | 1-600 | Per-task execution timeout in seconds; on expiry the process group is reaped with SIGTERM → SIGKILL |
disk_mb | 1024 | 0-102400 (also capped by SANDBOX_MAX_DISK_MB, default 8192) | Workspace disk quota |
max_stdout_kb | 256 | 16-8192 | Stdout cap; excess is truncated with ...<truncated> appended |
max_stderr_kb | 256 | 16-8192 | Stderr cap, same truncation |
A second, independent timeout applies on the caller side: the direct execution endpoint POST /api/v1/tools/execute-code defaults timeout to 30 with a range of 1-60, and Agent chat calls to a code tool are bounded by STREAM_TOOL_TIMEOUT_CODE (default 60 seconds). A saved tool's limits.timeout_seconds takes precedence over the caller-supplied value (the caller value only fills in when the config omits it). Whichever bound trips first decides the outcome: a sandbox-internal timeout fails the task as an execution timeout, while the outer bound surfaces as a tool-call timeout.
Artifact paths must live under /workspace; artifacts default to 10MB per file and 10MB in total (SANDBOX_ARTIFACT_MAX_FILE_SIZE_MB / SANDBOX_ARTIFACT_MAX_TOTAL_SIZE_MB). See Tools & Skills → Sandbox Boundaries for the full limits.
Security Model
- The task payload executes inside a fresh Bubblewrap user + mount namespace, never in the worker's own namespaces.
- The supplied deployments run the sandbox worker as root with
CAP_SYS_ADMINadded to the runtime default cap set: the image's non-root user has empty effective capabilities, and a privileged worker can create user namespaces even on hosts that gate unprivileged user namespaces. The worker keepsallowPrivilegeEscalation=falseandseccomp=unconfined(sandbox worker only). - Clusters that prohibit
seccomp=unconfinedmust provide a Localhost seccomp profile allowing the required namespace/mount syscalls. - Only the current workspace and its temporary directory are writable inside the task namespace; the dependency cache and required runtime directories are read-only.
- The child receives a filtered environment rather than the worker's full process environment.
- Session workspaces are cleaned after TTL expiry.
Host Kernel Requirements
Bubblewrap creates a new user namespace with unshare(CLONE_NEWUSER). The supplied deployments (Docker Compose, Helm, Kubernetes) run the worker as root with CAP_SYS_ADMIN, so user namespace creation is privileged and works even on hosts that restrict non-privileged user namespaces — no host sysctl changes are required.
Custom deployments that keep the worker non-root rely on the host kernel permitting unprivileged user namespaces; otherwise every sandbox job fails with:
bwrap: No permissions to create new namespace, likely because the kernel does not allow non-privileged user namespaces.Several common host distributions restrict this by default, and seccomp=unconfined does not help here because the restriction is enforced below the container seccomp profile:
| Distribution | Restriction | Fix |
|---|---|---|
| Ubuntu 23.10+ | AppArmor blocks user namespaces for unprivileged processes (kernel.apparmor_restrict_unprivileged_userns=1) | sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 |
| Debian / older kernels | User namespace cloning disabled (kernel.unprivileged_userns_clone=0) | sysctl -w kernel.unprivileged_userns_clone=1 |
Check the current state and verify that a user namespace can actually be created:
sysctl kernel.apparmor_restrict_unprivileged_userns kernel.unprivileged_userns_clone 2>/dev/null
unshare -U true && echo "user namespaces OK"Make the change persistent:
echo 'kernel.apparmor_restrict_unprivileged_userns=0' > /etc/sysctl.d/99-clouisle-userns.conf
sysctl --systemNotes:
- The sysctl is a host/node-level setting. In Kubernetes it cannot be set per pod: apply it to every node (custom node image,
/etc/sysctl.d/on self-managed nodes, or the equivalent node bootstrap for managed clusters). - Enabling unprivileged user namespaces is the standard prerequisite for rootless containers (Bubblewrap, Flatpak, Podman).
Hardening: Contain the Worker's CAP_SYS_ADMIN
The sandbox task runs in a fresh Bubblewrap user + mount namespace, so it cannot directly reach the worker container's capabilities. If a task ever escapes Bubblewrap, however, it lands inside the worker container as root with CAP_SYS_ADMIN. On a default Docker daemon the container shares the host's initial user namespace, which makes that capability host-user-namespace-scoped and exposes well-known escape chains (cgroup release_agent, remounting /proc to write kernel.core_pattern, sysctl writes) in principle.
- Docker Compose: enable daemon user namespace remapping (
"userns-remap": "default"in/etc/docker/daemon.json) to place every container in a nested user namespace —CAP_SYS_ADMINthen only applies to the container's own user namespace and the host-escape chains no longer work. The sandbox worker still creates its Bubblewrap user namespace (privileged inside the remapped namespace), so sandbox functionality is unaffected. Existing named volumes need an ownership reset, and all containers on the daemon are remapped. - Kubernetes: daemon-level remapping does not apply; rely on NetworkPolicy for outbound sandbox traffic, keep Bubblewrap updated and monitor its CVEs, or use node-level user namespace support where the cluster provides it.
Runtime Switches and Fallback Behaviour
Four switches govern the sandbox path, and the defaults favour "the code always runs" over "isolation is always in effect" — production deployments should confirm each one:
| Environment variable | Default | Effect |
|---|---|---|
SANDBOX_RUNTIME_ENABLED | true | When false, the sandbox gateway is skipped entirely and code runs in-process inside the API/worker (no isolation at all); debugging only |
SANDBOX_LEGACY_FALLBACK_ENABLED | true | On a failed sandbox job or a gateway exception, fall back to the in-process legacy runner |
SANDBOX_FILESYSTEM_ISOLATION_ENABLED | false in code; true in images/Compose/Helm/K8s | Wrap executable payloads in a Bubblewrap mount namespace |
SANDBOX_FILESYSTEM_ISOLATION_BINARY | bwrap in code; /usr/bin/bwrap in deployments | Bubblewrap executable name or absolute path |
Why SANDBOX_LEGACY_FALLBACK_ENABLED deserves attention: when the sandbox gateway returns a failure or raises, the execute_code code path silently re-runs the payload with the in-process legacy runner and only leaves a warning in the server log. So a dead sandbox worker, a missing bwrap, or an unconsumed queue does not mean "code refused to run" by default — it means "code ran in-process without isolation", while the caller receives a successful-looking result.
To fail closed, set it to false: a sandbox failure is returned to the caller instead of degrading. Security-sensitive deployments should turn it off and alert on sandbox job failure rates.
Beware the opposite trap as well: when filesystem isolation is enabled, a missing bwrap, a missing workspace root, or a task cwd escaping the workspace raises SandboxIsolationError and fails the job — the launcher itself never degrades to unisolated execution. But SANDBOX_LEGACY_FALLBACK_ENABLED=true upstream still catches that failure and degrades anyway, so enabling isolation without disabling the fallback does not guarantee isolation is in effect.
Related Configuration
Sandbox environment variables are listed in the Environment Variables Reference. Enabling filesystem isolation requires SANDBOX_FILESYSTEM_ISOLATION_ENABLED=true with SANDBOX_FILESYSTEM_ISOLATION_BINARY pointing at the Bubblewrap executable (/usr/bin/bwrap by default in Compose/Helm). The sandbox-worker in Docker Compose and Helm defaults to the root + CAP_SYS_ADMIN + seccomp=unconfined security configuration; for bwrap user namespace errors during deployment, see Troubleshooting.
See also:
- Tools & Skills — built-in tools and sandbox boundaries
- Custom HTTP and Code Tools — configuring the code tool
- Workflow Nodes — code node integration
- Environment Variables Reference — sandbox environment variables
How is this guide?