bashkit 0.20.1

Awesomely fast virtual sandbox with bash and file system
Documentation
# 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)]./python.md - The lighter, Rust-native alternative
- [Threat Model]./threat-model.md - Security considerations (TM-PY-CPY-*)
- [Compatibility Reference]./compatibility.md - Bash feature support
- [`knowledge/runtimes/cpython-wasm.md`][spec] - Design, measurements and decisions

## Quick start

Enable the `cpython` cargo feature and register the builtins:

```toml
[dependencies]
bashkit = { version = "0.20.1", features = ["cpython"] }
```

```rust
use bashkit::Bash;

# #[tokio::main]
# async fn main() -> bashkit::Result<()> {
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");
# Ok(())
# }
```

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::warm_up()` at startup. On hosts where the linker or loader
cannot preserve 64 KiB module alignment, such as macOS, an aligned immutable
copy is cached once for the process; later calls reuse it.

## Why CPython instead of Monty

[Monty](./python.md) 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]#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`
  (runs `DIR/__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 via `sys.stdout.buffer`.
- Exported shell variables in `os.environ`; `PWD` becomes the working
  directory, so relative paths resolve like in a real shell.
- `open()`, `os`, `pathlib`, `shutil`, `glob`, `tempfile`, `sqlite3` database
  files, `gzip`/`zipfile`/`tarfile` on 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

```rust
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 Bashkit `ExecutionLimits`
  timeout or cancellation wins.
- **Memory**: allocation past the cap raises `MemoryError` inside 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.

```rust,ignore
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` (or `ConnectionError` from `http.client`)
  with the same "access denied" text `curl` prints; a timeout raises
  `TimeoutError`. 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. `ssl` contexts, `verify=False`, client
  certificates and proxy settings are ignored: the host verifies TLS.
- `CPythonLimits::max_http_requests` caps 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.

```python
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`, basic `auth`,
  `timeout`, `allow_redirects`; `Response` with `status_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 and
  `stream()`, `Client` and `AsyncClient` (`base_url`, `headers`, `params`,
  `cookies`, `auth`, `timeout`, `follow_redirects`, `event_hooks`,
  `transport=httpx.MockTransport(...)`), `Response`, `URL`, `Headers`,
  `QueryParams`, `Cookies`, `Timeout`, `BasicAuth`, `codes` and the
  upstream exception classes. As in httpx, redirects are not followed
  unless `follow_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, so
  `stream=True` and `iter_*` walk a body that is already complete.
  `AsyncClient` requests run one at a time.

## Limitations

- **No subprocesses**: `subprocess`, `os.system`, `os.fork`, `os.popen` raise
  `OSError`/`AttributeError`. Python cannot call back into the shell yet.
- **HTTP only, through Bashkit's egress**: see [HTTP]#http. Raw
  `socket` connections, `ssl` and non-HTTP protocols are unavailable.
- **No threads**: `threading.Thread.start()` raises `RuntimeError`;
  `multiprocessing` and `concurrent.futures.ProcessPoolExecutor` are absent.
  `asyncio` works.
- **No native extensions or pip**: only the bundled stdlib (plus Bashkit's
  own `requests`/`httpx`, see [HTTP]#http). `ctypes`, `numpy` and other
  third-party packages are unavailable; `ssl`,
  `_hashlib` (OpenSSL), `tkinter`, `curses`, `readline`, `dbm.gnu` are not
  built. `hashlib` still provides md5, sha1, sha2, sha3 and blake2.
- **No interactive mode**: `python3` with no program reads one from stdin;
  there is no REPL, `pydoc` (`help()`) is not shipped, and `breakpoint()`
  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`, and
  `bz2`/`lzma`/`compression.zstd` (no C codec in the guest) are not
  shipped. `gzip`, `zipfile` (deflate) and `tarfile` (plain or gzip) work.
- **Symlinks are not followed**, like everywhere in the Bashkit VFS.
- **`errno` numbers are WASI's** (`ENOENT` is 44, not 2). Exception types
  (`FileNotFoundError`, ...) and messages are correct; code comparing
  `e.errno == errno.ENOENT` works because the `errno` module matches.
- **Fixed hash seed**: `hash()` of `str`/`bytes` is the same in every call
  (the seed is baked into the snapshot). `random` is re-seeded per call (on first use).
- **Deep C-level recursion** (for example `repr` of a list nested 100 000
  levels) ends the call with `python3: fatal error: stack overflow in the
  interpreter` instead of `RecursionError`.
- **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 `ToolDef`
  integration 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:

```toml
bashkit = { version = "0.20.1", 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::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.

[spec]: https://github.com/everruns/bashkit/blob/main/knowledge/runtimes/cpython-wasm.md