ClouisleClouisle

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 directory

When 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...>
  1. Namespace & Process Confinement:
    • Unshared Namespaces: --unshare-user, --unshare-pid, --unshare-ipc, and --unshare-uts create an isolated environment with its own process tree (PID 1 mapping), IPC communication boundary, and isolated hostname (clouisle-sandbox).
    • Cascade Process Reaping: --die-with-parent guarantees that all child processes inside the sandbox are instantly reaped if the supervising Celery worker exits or times out.
  2. Read-Only System Roots & Workspace Mapping (RUNTIME_READONLY_ROOTS):
    • Read-Only System Roots: /usr, /bin, /sbin, /lib, /lib64, and /etc are 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 /tmp directory mounted at /tmp (--bind <workspace>/tmp /tmp).
    • Logical Path Auto-Rewriting: _logical_workspace_path and _map_workspace_value automatically rewrite command arguments and environment paths so tasks only ever see /workspace/....
  3. Container Runtimes & CJK Font Support:
    • Runtimes: Pre-installed Python 3.13 (managed via uv) and Node.js 22.
    • CJK Fonts Included: The clouisle-sandbox-worker image packages fontconfig, fonts-wqy-zenhei, and fonts-wqy-microhei with fc-cache -fv. Because /usr/share/fonts is 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").

Supported Runtimes

RuntimeBase Environment
PythonPython 3.13 with standard library and configured packages
JavaScriptNode.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:

LimitDefaultAllowed rangeNotes
timeout_seconds301-600Per-task execution timeout in seconds; on expiry the process group is reaped with SIGTERM → SIGKILL
disk_mb10240-102400 (also capped by SANDBOX_MAX_DISK_MB, default 8192)Workspace disk quota
max_stdout_kb25616-8192Stdout cap; excess is truncated with ...<truncated> appended
max_stderr_kb25616-8192Stderr 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_ADMIN added 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 keeps allowPrivilegeEscalation=false and seccomp=unconfined (sandbox worker only).
  • Clusters that prohibit seccomp=unconfined must 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:

DistributionRestrictionFix
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 kernelsUser 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 --system

Notes:

  • 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_ADMIN then 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 variableDefaultEffect
SANDBOX_RUNTIME_ENABLEDtrueWhen 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_ENABLEDtrueOn a failed sandbox job or a gateway exception, fall back to the in-process legacy runner
SANDBOX_FILESYSTEM_ISOLATION_ENABLEDfalse in code; true in images/Compose/Helm/K8sWrap executable payloads in a Bubblewrap mount namespace
SANDBOX_FILESYSTEM_ISOLATION_BINARYbwrap in code; /usr/bin/bwrap in deploymentsBubblewrap 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.

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:

How is this guide?

On this page