Skip to main content

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-rs is 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 wasmtime updated. 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 enforcedRuntime(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 Context is its own isolated wasm instance — separate linear memory, no shared globals/modules. Still, use one Runtime per 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_id that 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

quickjs_rs-0.2.1.tar.gz (826.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

quickjs_rs-0.2.1-py3-none-any.whl (797.4 kB view details)

Uploaded Python 3

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

Hashes for quickjs_rs-0.2.1.tar.gz
Algorithm Hash digest
SHA256 1b5b91d18cfd2160e3e382abd577067429b650d671784672b3fdfe287041bea3
MD5 6253dbe26432b059ab1935cf38a1e848
BLAKE2b-256 61189c46d8bac3df8db7277c90d31371dad7e8b94d1aa7ef4f2e7c42f64219dd

See more details on using hashes here.

Provenance

The following attestation bundles were made for quickjs_rs-0.2.1.tar.gz:

Publisher: release.yml on langchain-ai/quickjs-rs

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for quickjs_rs-0.2.1-py3-none-any.whl
Algorithm Hash digest
SHA256 e6947894eadacbf01be481d44f0d8bca6e3ab500154dca43063ace88edc0ad5e
MD5 6a52f8479256391b3991991358efe56c
BLAKE2b-256 c7dc9fd8eed0e0deea5bc217e0f46b4e6cc41901e7f5db3224901156ecc4cc60

See more details on using hashes here.

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

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page