From f1f75ae51c31d1fdbfc67a11ef9dd475c4a30995 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Zaczy=C5=84ski?= Date: Wed, 21 Oct 2026 13:51:12 +0000 Subject: [PATCH 1/2] Sample code for: Python Sandbox: Run Untrusted Code in Docker and MicroVMs Co-Authored-By: Claude Opus 5.5 --- python-sandbox/README.md | 85 ++++++++++++++++++++++++++++++ python-sandbox/compare.py | 30 +++++++++++ python-sandbox/microvm.py | 59 +++++++++++++++++++++ python-sandbox/requirements.txt | 1 + python-sandbox/rootfs/sbin/run-job | 10 ++++ python-sandbox/ruff.toml | 6 +++ python-sandbox/sandbox.py | 46 ++++++++++++++++ 7 files changed, 237 insertions(+) create mode 100644 python-sandbox/README.md create mode 100644 python-sandbox/compare.py create mode 100644 python-sandbox/microvm.py create mode 100644 python-sandbox/requirements.txt create mode 100755 python-sandbox/rootfs/sbin/run-job create mode 100644 python-sandbox/ruff.toml create mode 100644 python-sandbox/sandbox.py diff --git a/python-sandbox/README.md b/python-sandbox/README.md new file mode 100644 index 0000000000..8c8361a4f2 --- /dev/null +++ b/python-sandbox/README.md @@ -0,0 +1,85 @@ +# Python Sandbox: Run Untrusted Code in Docker and MicroVMs + +This folder provides the code examples for the Real Python tutorial [Python Sandbox: Run Untrusted Code in Docker and MicroVMs](https://realpython.com/python-sandbox/). + +## Prerequisites + +- A Linux host (the hostile snippet targets the Linux `/home` folder, and the shell commands assume a POSIX shell). On macOS or Windows, use a Linux virtual machine or WSL 2. +- Python 3.15 +- Docker (Podman works too, see the tutorial for the two small changes in `sandbox.py`) +- [RestrictedPython](https://pypi.org/project/RestrictedPython/) 8.5, for `compare.py` +- For `microvm.py` only: the [Firecracker](https://firecracker-microvm.github.io/) binary on your `PATH`, a guest kernel saved as `vmlinux`, and read/write access to `/dev/kvm` (hardware virtualization) + +If you run Docker or Podman rootless, make sure your host uses cgroups version 2, or the resource limits are ignored. + +## Setup + +Create and activate a virtual environment, then install the dependencies: + +```console +$ python -m venv venv/ +$ source venv/bin/activate +(venv) $ python -m pip install -r requirements.txt +``` + +Pull the container image up front so that the first sandboxed run doesn't spend its time budget on a download: + +```console +$ docker pull python:3.15-slim +``` + +## Files + +- `sandbox.py`: Defines `run_untrusted()`, which runs code in a locked-down Docker container with no network, a read-only root file system, resource caps, and a wall-clock timeout. +- `compare.py`: Runs the same hostile snippet three ways: `exec()` with restricted builtins, RestrictedPython, and the locked-down container. +- `microvm.py`: Defines `run_in_microvm()`, which runs code inside a Firecracker microVM with its own kernel. +- `rootfs/sbin/run-job`: The shell script that the microVM guest runs instead of an init system. + +## Usage + +### Docker Sandbox + +Import `run_untrusted()` from `sandbox.py`: + +```pycon +>>> from sandbox import run_untrusted +>>> run_untrusted("print('Hello from the sandbox')") +``` + +### Comparing the Three Tiers + +Run `compare.py` from the same folder as `sandbox.py`: + +```console +(venv) $ python compare.py +``` + +The first tier leaks your host's `/home` listing, the second refuses to compile the code, and the third shows the container's empty `/home` folder. + +### Firecracker MicroVM + +First, download a Firecracker release binary and a guest kernel for your CPU architecture by following Firecracker's [getting started guide](https://github.com/firecracker-microvm/firecracker/blob/main/docs/getting-started.md). Save the kernel as `vmlinux` in an empty project folder, copy `microvm.py` into it, and make sure your user can read and write `/dev/kvm`. + +Then, build the root file system image from the `python:3.15-slim` image inside that project folder. Export the image first, and copy `rootfs/sbin/run-job` from this folder into the exported tree afterward. In the Debian-based image, `/sbin` is a symbolic link to `/usr/sbin`, so exporting on top of an existing `rootfs/sbin/` folder won't work: + +```console +$ cid=$(docker create python:3.15-slim) +$ mkdir rootfs/ +$ docker export "$cid" | tar -x -C rootfs/ +$ docker rm "$cid" +$ mkdir rootfs/job/ +$ cp /path/to/python-sandbox/rootfs/sbin/run-job rootfs/sbin/run-job +$ chmod +x rootfs/sbin/run-job +$ truncate -s 300M rootfs.ext4 +$ mkfs.ext4 -q -d rootfs/ rootfs.ext4 +``` + +On some distributions, `mkfs.ext4` lives in `/usr/sbin/`, which may not be on a regular user's `PATH`. + +With `vmlinux` and `rootfs.ext4` next to `microvm.py`, call `run_in_microvm()`: + +```pycon +>>> from microvm import run_in_microvm +>>> run_in_microvm('print("Hello from the microVM")') +{'exit_code': 0, 'output': 'Hello from the microVM\n', 'error': ''} +``` diff --git a/python-sandbox/compare.py b/python-sandbox/compare.py new file mode 100644 index 0000000000..db9588ee00 --- /dev/null +++ b/python-sandbox/compare.py @@ -0,0 +1,30 @@ +import warnings + +from RestrictedPython import compile_restricted + +from sandbox import run_untrusted + +# RestrictedPython warns about print() calls, which don't matter here. +warnings.filterwarnings("ignore", category=SyntaxWarning) + +HOSTILE = ( + "os = [c for c in ().__class__.__base__.__subclasses__()" + " if c.__name__ == '_wrap_close'][0].__init__.__globals__\n" + "print(os['listdir']('/home'))\n" +) + +print("1. exec() with only print() in builtins:") +exec(HOSTILE, {"__builtins__": {"print": print}}) + +print("2. RestrictedPython refused to compile:") +try: + compile_restricted(HOSTILE, "", "exec") +except SyntaxError as error: + print(error.args[0][0]) + +print("3. Locked-down container:") +result = run_untrusted(HOSTILE) +if result["exit_code"] == 0: + print(result["stdout"].strip()) +else: + print(f"Sandbox failed ({result["exit_code"]}): {result["stderr"]}") diff --git a/python-sandbox/microvm.py b/python-sandbox/microvm.py new file mode 100644 index 0000000000..d1ecd07f2d --- /dev/null +++ b/python-sandbox/microvm.py @@ -0,0 +1,59 @@ +import json +import subprocess +import tempfile +from pathlib import Path + +KERNEL = Path("vmlinux").resolve() +ROOTFS = Path("rootfs.ext4").resolve() +BOOT_ARGS = "console=ttyS0 quiet reboot=k panic=1 init=/sbin/run-job" +START, END = "===JOB-START===", "===JOB-END===" + + +def drive(drive_id, path, is_root): + return { + "drive_id": drive_id, + "path_on_host": str(path), + "is_root_device": is_root, + "is_read_only": True, + } + + +def run_in_microvm(code, timeout=10): + with tempfile.TemporaryDirectory() as folder: + folder = Path(folder) + (folder / "job").mkdir() + (folder / "job" / "main.py").write_text(code) + job_image = folder / "job.ext4" + subprocess.run(["truncate", "-s", "8M", job_image], check=True) + subprocess.run( + ["mkfs.ext4", "-q", "-d", folder / "job", job_image], check=True + ) + config = { + "boot-source": { + "kernel_image_path": str(KERNEL), + "boot_args": BOOT_ARGS, + }, + "drives": [ + drive("rootfs", ROOTFS, is_root=True), + drive("job", job_image, is_root=False), + ], + "machine-config": {"vcpu_count": 1, "mem_size_mib": 256}, + } + config_path = folder / "vm_config.json" + config_path.write_text(json.dumps(config)) + try: + result = subprocess.run( + ["firecracker", "--no-api", "--config-file", config_path], + capture_output=True, + text=True, + errors="replace", + timeout=timeout, + ) + except subprocess.TimeoutExpired: + return {"exit_code": None, "output": "", "error": "timed out"} + console = result.stdout.replace("\r\n", "\n") + if START not in console or END not in console: + return {"exit_code": None, "output": console, "error": "no job output"} + output = console.split(START + "\n", 1)[1].split(END, 1)[0] + exit_code = int(console.split(END, 1)[1].split()[0]) + return {"exit_code": exit_code, "output": output, "error": ""} diff --git a/python-sandbox/requirements.txt b/python-sandbox/requirements.txt new file mode 100644 index 0000000000..b8b6e18e71 --- /dev/null +++ b/python-sandbox/requirements.txt @@ -0,0 +1 @@ +RestrictedPython==8.5 diff --git a/python-sandbox/rootfs/sbin/run-job b/python-sandbox/rootfs/sbin/run-job new file mode 100755 index 0000000000..5ec42461b2 --- /dev/null +++ b/python-sandbox/rootfs/sbin/run-job @@ -0,0 +1,10 @@ +#!/bin/sh +mount -t proc proc /proc +mount -t devtmpfs devtmpfs /dev 2>/dev/null +mount -t tmpfs tmpfs /tmp +mount -o ro /dev/vdb /job +echo "===JOB-START===" +setpriv --reuid=65534 --regid=65534 --clear-groups \ + /usr/local/bin/python /job/main.py +echo "===JOB-END=== $?" +echo b > /proc/sysrq-trigger diff --git a/python-sandbox/ruff.toml b/python-sandbox/ruff.toml new file mode 100644 index 0000000000..4a28a4da45 --- /dev/null +++ b/python-sandbox/ruff.toml @@ -0,0 +1,6 @@ +extend = "../pyproject.toml" + +[format] +# Keep the tutorial's nested same-quote f-string (PEP 701) in compare.py +# as published, instead of normalizing its inner quotes. +quote-style = "preserve" diff --git a/python-sandbox/sandbox.py b/python-sandbox/sandbox.py new file mode 100644 index 0000000000..dd9e2732c2 --- /dev/null +++ b/python-sandbox/sandbox.py @@ -0,0 +1,46 @@ +import subprocess +import uuid + +IMAGE = "python:3.15-slim" +RUN = ["docker", "run"] +REMOVE = ["docker", "rm", "--force"] +FLAGS = [ + "--network=none", + "--read-only", + "--ipc=none", + "--cap-drop=ALL", + "--security-opt=no-new-privileges", + "--user=65534:65534", + "--memory=128m", + "--memory-swap=128m", + "--cpus=1", + "--pids-limit=64", + "--log-driver=none", +] + + +def run_untrusted(code, timeout=5): + name = f"sandbox-{uuid.uuid4().hex[:12]}" + command = [*RUN, "--rm", "--interactive", f"--name={name}", *FLAGS] + try: + result = subprocess.run( + [*command, IMAGE, "python", "-"], + input=code, + capture_output=True, + text=True, + errors="replace", + timeout=timeout, + ) + except subprocess.TimeoutExpired: + cleanup = subprocess.run( + [*REMOVE, name], capture_output=True, text=True, timeout=30 + ) + if cleanup.returncode != 0: + if "no such container" not in cleanup.stderr.lower(): + raise RuntimeError(f"Couldn't remove {name}: {cleanup.stderr}") + return {"exit_code": None, "stdout": "", "stderr": "timed out"} + return { + "exit_code": result.returncode, + "stdout": result.stdout, + "stderr": result.stderr, + } From 094e21e4c4ee4a492e8ee5191d1fa995a7066ea8 Mon Sep 17 00:00:00 2001 From: realpython-bot Date: Wed, 21 Oct 2026 13:57:53 +0000 Subject: [PATCH 2/2] Unpack sandbox result instead of nesting quotes in f-string Ruff and the CMS preflight disagreed on quote style for the nested f-string in compare.py. Unpacking the result avoids nested quotes, so the folder-level ruff.toml override is no longer needed. Co-Authored-By: Claude Opus 5.5 --- python-sandbox/compare.py | 7 ++++--- python-sandbox/ruff.toml | 6 ------ 2 files changed, 4 insertions(+), 9 deletions(-) delete mode 100644 python-sandbox/ruff.toml diff --git a/python-sandbox/compare.py b/python-sandbox/compare.py index db9588ee00..624dcea76f 100644 --- a/python-sandbox/compare.py +++ b/python-sandbox/compare.py @@ -24,7 +24,8 @@ print("3. Locked-down container:") result = run_untrusted(HOSTILE) -if result["exit_code"] == 0: - print(result["stdout"].strip()) +exit_code, stdout, stderr = result.values() +if exit_code == 0: + print(stdout.strip()) else: - print(f"Sandbox failed ({result["exit_code"]}): {result["stderr"]}") + print(f"Sandbox failed ({exit_code}): {stderr}") diff --git a/python-sandbox/ruff.toml b/python-sandbox/ruff.toml deleted file mode 100644 index 4a28a4da45..0000000000 --- a/python-sandbox/ruff.toml +++ /dev/null @@ -1,6 +0,0 @@ -extend = "../pyproject.toml" - -[format] -# Keep the tutorial's nested same-quote f-string (PEP 701) in compare.py -# as published, instead of normalizing its inner quotes. -quote-style = "preserve"