Sandboxed JavaScript execution for Python, via wasmtime + rquickjs.
Project description
quickjs-rs
Sandboxed JavaScript execution for Python.
JS runs inside a WebAssembly sandbox: quickjs-ng (a QuickJS fork) via rquickjs is compiled to wasm32-wasip1 and driven by wasmtime. The package is pure Python - one universal wheel that bundles the guest .wasm artifacts; the only runtime dependency is wasmtime. ES modules resolve through a host loader callback; inline TypeScript is type-stripped by a separate OXC-backed transform .wasm before QuickJS sees it.
[!WARNING]
quickjs-rsis experimental. Before putting this in production, you should read the Security guide.
Install
pip install quickjs-rs
uv add quickjs-rs
Ships as a single universal pure-Python wheel (py3-none-any) — the bundled
guest is platform-independent WebAssembly, and wasmtime supplies the
per-platform runtime. Requires Python 3.11+; runs anywhere wasmtime has a
wheel (Linux, macOS, Windows; x86_64 + arm64).
Quickstart
from quickjs_rs import Runtime
with Runtime() as rt:
with rt.new_context() as ctx:
assert ctx.eval("1 + 2") == 3
# Register a Python callable as a JS global.
@ctx.function
def greet(name: str) -> str:
return f"hi {name}"
assert ctx.eval("greet('world')") == "hi world"
Async + top-level await:
import asyncio
async def main():
with Runtime() as rt:
with rt.new_context() as ctx:
@ctx.function
async def fetch_thing() -> str:
await asyncio.sleep(0.01)
return "from python"
result = await ctx.eval_async("await fetch_thing()")
assert result == "from python"
asyncio.run(main())
ES modules
Supply modules through a host loader callback pair: normalize(base, specifier) resolves an import to a canonical name, and
load(name) returns the source. The host owns all resolution policy — there is
no built-in scope model.
import posixpath
from quickjs_rs import Runtime
sources = {
"@agent/config": "export const MAX_RETRIES = 3;",
"@agent/utils": "export { slugify } from './strings.js';",
"@agent/utils/strings.js":
"export const slugify = s => s.toLowerCase().replace(/ /g, '-');",
}
def normalize(base, spec):
if not spec.startswith("."):
return spec # bare name → canonical name
base_dir = base if "." not in posixpath.basename(base) else posixpath.dirname(base)
return posixpath.normpath(posixpath.join(base_dir, spec)) # relative → joined
with Runtime() as rt:
rt.set_module_loader(normalize=normalize, load=sources.get)
with rt.new_context() as ctx:
assert await ctx.eval_async("""
const { slugify } = await import("@agent/utils");
const { MAX_RETRIES } = await import("@agent/config");
slugify("Hello World") + '/' + MAX_RETRIES;
""") == "hello-world/3"
normalize is where sandboxing lives — return None to refuse a specifier.
TypeScript
Module sources whose canonical name ends in .ts, .mts, .cts, or .tsx are
type-stripped by the host transform adapter before evaluation. Enums,
namespaces, and parameter properties are transformed; plain type annotations
erase. No type checking — run tsc --noEmit separately for that.
# A canonical name ending in .ts/.tsx is stripped before QuickJS sees it.
ts_sources = {
"util.ts": """
export enum Mode { Strict = 1, Loose = 2 }
export function slug(s: string, mode: Mode): string {
return s.toLowerCase().replace(/ /g, mode === Mode.Strict ? '_' : '-');
}
""",
}
with Runtime() as rt:
rt.set_module_loader(load=ts_sources.get)
with rt.new_context() as ctx:
assert await ctx.eval_async(
"const { slug, Mode } = await import('util.ts');"
"slug('Hello World', Mode.Strict)"
) == "hello_world"
A TypeScript parse error surfaces as a module-load error rather than at eval.
Snapshots
A snapshot captures the entire guest heap — every object, the atom table, the job queue, closures, and pending promises — as a flat image, and reconstitutes it into a fresh context. Because it's the whole VM memory, aliasing and closure state survive exactly.
from quickjs_rs import Runtime, Snapshot
with Runtime() as rt:
with rt.new_context() as ctx:
ctx.eval("""
const shared = { count: 1 };
const a = shared, b = shared;
const counter = (() => { let n = 0; return () => ++n; })();
""")
payload = ctx.create_snapshot().to_bytes()
with Runtime() as rt2:
with rt2.new_context() as ctx2:
rt2.restore_snapshot(Snapshot.from_bytes(payload), ctx2)
assert ctx2.eval("a === b") is True # aliasing preserved
assert ctx2.eval("counter()") == 1 # closure state preserved
A snapshot must be taken at a quiescent point (no in-flight eval_async, no
pending async host calls); create_snapshot_async() is the async-context form.
Restore validates a fail-closed header — including a build_id that rejects a
snapshot taken from a different guest build — before writing the image. Treat
snapshot bytes as trusted input (see Security).
snap = await ctx.create_snapshot_async()
rt.restore_snapshot(snap, other_ctx, inject_globals=True)
Security
-
The WebAssembly sandbox is the isolation boundary. JS executes inside the guest's wasm linear memory; quickjs-ng never sees a host pointer and cannot read or write Python's address space. A bug in the JS engine is contained within the sandbox, not a path to host-memory compromise — this is the central reason JS runs in wasm rather than as a native extension.
-
The residual runtime-escape risk is wasmtime itself. wasmtime executes the guest in-process, so a vulnerability in wasmtime / Cranelift (the JIT) is the one path that could cross the sandbox boundary. Keep
wasmtimeupdated. For hostile multi-tenant workloads where you must defend against an active runtime-attacker, add process/container isolation on top and recycle on timeout/OOM — the sandbox raises the bar but does not replace defense in depth. -
Registered host callbacks are capability boundaries. Anything you expose to JS via
ctx.register(...)is reachable by the sandboxed code; treat every such callback as privileged when running untrusted JS. The sandbox contains the engine, not the capabilities you hand it. -
Resource limits are enforced —
Runtime(memory_limit=...)caps heap, a per-eval timeout interrupts runaway JS (the instance survives), and a runaway recursion is contained by the sandbox (it traps the wasm instance rather than the host; see the threat model for the wasi stack-check caveat). -
Each
Contextis its own isolated wasm instance — separate linear memory, no shared globals/modules. Still, use oneRuntimeper trust domain. -
Snapshots are trusted input. A whole-memory snapshot is an arbitrary guest heap image. Restore validates a fail-closed header (incl. a
build_idthat rejects a snapshot taken from a different guest build), but a same-build crafted image is not made safe by that check — do not restore snapshots from an untrusted source, and do not restore across guest builds.
Development
# Build the wasm guests (needs the Rust toolchain + the wasm target):
# rustup target add wasm32-wasip1
python scripts/build_guest.py # cargo build -> quickjs_rs/_guest.wasm + _transform.wasm
# Dev install (pure-Python package; wasm is bundled above).
pip install -e ".[dev]"
# Run tests, type-check, lint.
pytest
mypy quickjs_rs
ruff check
License
MIT. See LICENSE.
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file quickjs_rs-0.2.1.tar.gz.
File metadata
- Download URL: quickjs_rs-0.2.1.tar.gz
- Upload date:
- Size: 826.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1b5b91d18cfd2160e3e382abd577067429b650d671784672b3fdfe287041bea3
|
|
| MD5 |
6253dbe26432b059ab1935cf38a1e848
|
|
| BLAKE2b-256 |
61189c46d8bac3df8db7277c90d31371dad7e8b94d1aa7ef4f2e7c42f64219dd
|
Provenance
The following attestation bundles were made for quickjs_rs-0.2.1.tar.gz:
Publisher:
release.yml on langchain-ai/quickjs-rs
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
quickjs_rs-0.2.1.tar.gz -
Subject digest:
1b5b91d18cfd2160e3e382abd577067429b650d671784672b3fdfe287041bea3 - Sigstore transparency entry: 1915930725
- Sigstore integration time:
-
Permalink:
langchain-ai/quickjs-rs@20abbe3ac876acfeff30e571325bfd4e4d1633b5 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/langchain-ai
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@20abbe3ac876acfeff30e571325bfd4e4d1633b5 -
Trigger Event:
workflow_dispatch
-
Statement type:
File details
Details for the file quickjs_rs-0.2.1-py3-none-any.whl.
File metadata
- Download URL: quickjs_rs-0.2.1-py3-none-any.whl
- Upload date:
- Size: 797.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e6947894eadacbf01be481d44f0d8bca6e3ab500154dca43063ace88edc0ad5e
|
|
| MD5 |
6a52f8479256391b3991991358efe56c
|
|
| BLAKE2b-256 |
c7dc9fd8eed0e0deea5bc217e0f46b4e6cc41901e7f5db3224901156ecc4cc60
|
Provenance
The following attestation bundles were made for quickjs_rs-0.2.1-py3-none-any.whl:
Publisher:
release.yml on langchain-ai/quickjs-rs
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
quickjs_rs-0.2.1-py3-none-any.whl -
Subject digest:
e6947894eadacbf01be481d44f0d8bca6e3ab500154dca43063ace88edc0ad5e - Sigstore transparency entry: 1915930788
- Sigstore integration time:
-
Permalink:
langchain-ai/quickjs-rs@20abbe3ac876acfeff30e571325bfd4e4d1633b5 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/langchain-ai
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@20abbe3ac876acfeff30e571325bfd4e4d1633b5 -
Trigger Event:
workflow_dispatch
-
Statement type: