Embedded CPython (WebAssembly)#
Bashkit can run real CPython 3.14 as python/python3. The interpreter is
compiled to WebAssembly (wasm32-wasip1), pre-initialized into a snapshot and
shipped inside the binary, so there is nothing to install and no network
access at build or run time. Each python3 call runs in a fresh, isolated
instance that can only reach the Bashkit virtual filesystem and its own
stdio.
See also:
- Embedded Python (Monty) - The lighter, Rust-native alternative
- Threat Model - Security considerations (TM-PY-CPY-*)
- Compatibility Reference - Bash feature support
knowledge/runtimes/cpython-wasm.md- Design, measurements and decisions
Quick start#
Enable the cpython cargo feature and register the builtins:
[dependencies]
bashkit = { version = "0.18.2", features = ["cpython"] }
use bashkit::Bash;
let mut bash = Bash::builder().cpython().build();
let r = bash
.exec("python3 -c 'import json, sys; print(json.dumps({\"v\": sys.version_info[:2]}))'")
.await?;
assert_eq!(r.stdout, "{\"v\": [3, 14]}\n");
No runtime opt-in variable is needed (unlike Monty’s
BASHKIT_ALLOW_INPROCESS_PYTHON): calling .cpython() is the opt-in, because
the guest never runs native code in your process.
The interpreter loads on the first call of a process: the first python3
takes about 19 ms on the reference machine, later calls about 4.4 ms. To move
that one-time cost out of the first request, call
bashkit::CPython::warm_up() at startup.
Why CPython instead of Monty#
Monty is a Python subset written in Rust and designed for code-mode execution: evaluate a snippet, call back into host functions, return a value. Scripts written by people and agents expect a Python command line and the standard library behind it. CPython provides that:
Monty (python feature) | CPython (cpython feature) | |
|---|---|---|
| Language | Subset (no classes, limited stdlib) | Full Python 3.14 |
| Stdlib | math, pathlib, os.getenv, sys, typing, … | Full pure-Python stdlib plus json, re, csv, sqlite3, zlib, hashlib, decimal, datetime, asyncio, … |
| CLI | -c, file, - | -c, -m, file, directory with __main__.py, -, stdin, -x, -W, -V, -h |
| Errors | Monty-specific text | CPython tracebacks, exit codes, sys.exit semantics |
| Isolation | In-process Rust interpreter | WebAssembly sandbox (memory-safe boundary), fresh instance per call |
| Start per call | ~15 µs | ~4.4 ms (first call in a process ~19 ms) |
| CPU-bound speed | Native | ~4-30x slower than Monty (interpreted wasm); ~4x slower with cpython-native |
| Host callbacks | Yes (external functions) | Not yet |
| HTTP | No | urllib.request, http.client through Bashkit’s egress allowlist |
Pick CPython when scripts need real Python behavior; pick Monty when you
need host function callbacks or microsecond start-up for tiny snippets.
Both can be compiled in: the builder method called last owns
python/python3.
What works#
python3 -c CODE,python3 FILE,python3 -m MODULE,python3 DIR(runsDIR/__main__.py),python3 -and programs piped on stdin.sys.argv,sys.exit, uncaught exceptions (exit 1, traceback on stderr),os._exit,atexit,input(), binary stdio viasys.stdout.buffer.- Exported shell variables in
os.environ;PWDbecomes the working directory, so relative paths resolve like in a real shell. open(),os,pathlib,shutil,glob,tempfile,sqlite3database files,gzip/zipfile/tarfileon the virtual filesystem. Files the script leaves open are flushed when it exits.- Local modules next to the script or in the working directory import as usual.
asyncio(single-threaded event loop, timers, queues, gather).
Limits#
use bashkit::{Bash, CPythonLimits};
use std::time::Duration;
let bash = Bash::builder()
.cpython_with_limits(
CPythonLimits::default()
.max_duration(Duration::from_secs(5)) // wall clock per call (default 30 s)
.max_memory(128 * 1024 * 1024) // guest memory (default 256 MB, max 1 GB)
.max_recursion(500) // sys.getrecursionlimit() (default 1000)
.max_output(1024 * 1024), // stdout + stderr bytes (default 16 MB)
)
.build();
- Time: a call past its deadline stops with exit code 124 and
python3: execution timed out .... A tighter BashkitExecutionLimitstimeout or cancellation wins. - Memory: allocation past the cap raises
MemoryErrorinside Python. - Output: bytes past the cap are dropped and stderr ends with
python3: output truncated at N bytes. - Files: Bashkit filesystem limits (file size, total bytes, file count)
apply; violations raise
OSError. - Concurrency: up to 512 CPython calls run at once per process; further calls wait for a free slot within their own deadline.
HTTP#
With the http_client feature and a network allowlist
(BashBuilder::network), urllib.request
and http.client work for http:// and https:// URLs. Each request is
handed to the host and goes through the same pipeline as curl: allowlist,
private-IP (SSRF) checks, credential injection, request signing, your
HttpTransport and the response size cap. TLS runs
on the host; the guest has no sockets.
let mut bash = Bash::builder()
.cpython()
.network(NetworkAllowlist::new().allow("https://api.example.com"))
.build();
bash.exec(r#"python3 -c '
import json, urllib.request
with urllib.request.urlopen("https://api.example.com/v1/items") as r:
print(json.load(r))
'"#).await?;
- A denied URL raises
URLError(orConnectionErrorfromhttp.client) with the same “access denied” textcurlprints; a timeout raisesTimeoutError. Without a network allowlist every request fails with “network access not configured”. - Redirects are followed by Python, so every hop is checked again.
- Methods: GET, POST, PUT, DELETE, HEAD, PATCH. Headers the host owns
(
Host,Content-Length,Transfer-Encoding,Connection, proxy headers) are set by the host; values with CR/LF are rejected. - Responses are buffered (up to the client’s
max_response_bytes), so streaming past that cap fails.sslcontexts,verify=False, client certificates and proxy settings are ignored: the host verifies TLS. CPythonLimits::max_http_requestscaps requests per call (default 100); a request’s timeout never outlasts the call’s deadline.
requests and httpx are not bundled yet.
Limitations#
- No subprocesses:
subprocess,os.system,os.fork,os.popenraiseOSError/AttributeError. Python cannot call back into the shell yet. - HTTP only, through Bashkit’s egress: see HTTP. Raw
socketconnections,ssland non-HTTP protocols are unavailable. - No threads:
threading.Thread.start()raisesRuntimeError;multiprocessingandconcurrent.futures.ProcessPoolExecutorare absent.asyncioworks. - No native extensions or pip: only the bundled stdlib.
ctypes,numpy,requestsand other third-party packages are unavailable;ssl,_hashlib(OpenSSL),tkinter,curses,readline,dbm.gnuare not built.hashlibstill provides md5, sha1, sha2, sha3 and blake2. - No interactive mode:
python3with no program reads one from stdin; there is no REPL, andpdbandpydoc(help()) are not shipped. - Stdlib is bytecode only: tracebacks through stdlib code show no source
line, and
inspect.getsource()fails on stdlib objects. Your own code keeps full tracebacks. Non-HTTP network clients and servers (smtplib,ftplib,http.server,xmlrpc, …) are not shipped since the guest has no sockets. - Symlinks are not followed, like everywhere in the Bashkit VFS.
errnonumbers are WASI’s (ENOENTis 44, not 2). Exception types (FileNotFoundError, …) and messages are correct; code comparinge.errno == errno.ENOENTworks because theerrnomodule matches.- Fixed hash seed:
hash()ofstr/bytesis the same in every call (the seed is baked into the snapshot).randomis re-seeded per call (on first use). - Deep C-level recursion (for example
reprof a list nested 100 000 levels) ends the call withpython3: fatal error: stack overflow in the interpreterinstead ofRecursionError. - CPU-bound code is slow: the guest runs on Wasmtime’s portable Pulley interpreter, roughly 4-30x slower than Monty and far slower than native CPython. Start-up, not throughput, is what this runtime is tuned for.
- No host callbacks (Monty’s external functions) or
ToolDefintegration yet. - Interpreter-start options (
-E,-I,-s,-S,-B,-u,-O,-q,-X ...) are accepted and ignored.
Native code (opt-in)#
By default the interpreter ships as portable Pulley bytecode, which needs no
executable memory. Enable cpython-native instead of cpython to compile it
to machine code for your target at build time:
bashkit = { version = "0.18.2", features = ["cpython-native"] }
Calls get 4-10x faster (print(1) ~1 ms instead of ~4 ms; CPU-bound code
~10x). In exchange the host must allow executable memory, and the first load
in a process takes ~47 ms, so call bashkit::CPython::warm_up() at startup.
Nothing is compiled at run time with either option.
Binary size#
The cpython feature adds about 45 MB to a binary: the precompiled
interpreter snapshot (~41 MB, mostly the pre-initialized 40 MB heap image so
it can be mapped copy-on-write) and the zipped stdlib bytecode (~3.5 MB) are embedded,
plus the Wasmtime runtime. Pages are mapped on demand, so resident memory per
process is far smaller.