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, … | Pure-Python stdlib for scripts (see Limitations) 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#
import requests, import httpx and import httpx2 work out of the box.
They are Bashkit’s own compact implementations of the common API, written
on top of the same host bridge (not the upstream packages, which take
seconds to import in the sandbox). They are preloaded in the snapshot, so
importing them costs nothing.
import requests
r = requests.get("https://api.example.com/v1/items", params={"page": 2}, timeout=5)
r.raise_for_status()
print(r.json())
import httpx
with httpx.Client(base_url="https://api.example.com", headers={"X-Key": "..."}) as c:
print(c.post("/v1/items", json={"name": "a"}).status_code)
- requests:
get/post/put/patch/delete/head/request,Session(headers, params, auth, cookies, hooks),params,data,json,files(multipart),headers,cookies, basicauth,timeout,allow_redirects;Responsewithstatus_code,ok,reason,headers,content,text,json(),url,history,links,iter_content/iter_lines,raise_for_status(); the upstream exception classes (ConnectionError,ReadTimeout,HTTPError, …). - httpx (and
httpx2, the same module): the verb functions andstream(),ClientandAsyncClient(base_url,headers,params,cookies,auth,timeout,follow_redirects,event_hooks,transport=httpx.MockTransport(...)),Response,URL,Headers,QueryParams,Cookies,Timeout,BasicAuth,codesand the upstream exception classes. As in httpx, redirects are not followed unlessfollow_redirects=True. - Not supported: retries (
HTTPAdapter(max_retries=...)is accepted and ignored), proxies, client certificates, HTTP/2,OPTIONS(not an allowed method), digest auth, streaming uploads. Bodies are buffered, sostream=Trueanditer_*walk a body that is already complete.AsyncClientrequests run one at a time.
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 (plus Bashkit’s
own
requests/httpx, see HTTP).ctypes,numpyand 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,pydoc(help()) is not shipped, andbreakpoint()prints a notice and continues (no debugger). - 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. - Trimmed for scripts: the stdlib targets agents running file-processing
and glue scripts. Test, profiling and packaging tools (
unittest,doctest,cProfile,profile,trace,compileall,zipapp, …),dbm/shelve,plistlib,wave,netrc,cmd,tty/pty, andbz2/lzma/compression.zstd(no C codec in the guest) are not shipped.gzip,zipfile(deflate) andtarfile(plain or gzip) work. - 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.2 MB) are embedded,
plus the Wasmtime runtime. Pages are mapped on demand, so resident memory per
process is far smaller.