A small JavaScript sandbox for Python
jsbox embeds QuickJS (via rquickjs and PyO3) in Python.
Run untrusted, LLM-generated JavaScript with hard limits and nothing but the functions you hand it.
What this is
Agent frameworks increasingly need to run JavaScript the model itself wrote: a data
transform, a "compute the answer" snippet, a batch of tool calls. That code is untrusted by
construction. jsbox runs it in an in-process QuickJS runtime that has no filesystem, no
network, no require, no timers and no Node.js APIs. The guest can reach exactly what the host
bound, and it is stopped by a wall-clock timeout, a heap limit and a stack limit.
It is small (a ~0.7 MB wheel, ~150 µs to create a runtime, no Rust toolchain needed to install) and deliberately boring: an interpreter, not a JIT. If you need raw JavaScript speed, use a V8 based engine; if you need a tiny, fast-starting, hard-to-misuse sandbox, this is that.
from jsbox import Runtime
rt = Runtime()
rt.bind_function("add", lambda a, b: a + b)
print(rt.eval("add(2, 3)")) # 5
Get started
pip install jsbox # or: uv pip install jsbox
Python 3.10+ on Linux, macOS and Windows (one abi3 wheel per platform).
Tool calling with limits
from jsbox import JsTimeoutError, Runtime
rt = Runtime(memory_limit=16 * 1024 * 1024) # 16 MB heap
def get_weather(city: str) -> str:
return f"72F and sunny in {city}"
rt.bind_function("getWeather", get_weather)
# Model-generated JS calling back into your tool:
print(rt.eval("getWeather('Zurich')")) # 72F and sunny in Zurich
# Code that might not terminate is stopped, and the runtime stays usable:
try:
rt.eval("while (true) {}", timeout=2.0)
except JsTimeoutError:
print("runaway script killed after 2s")
print(rt.eval("1 + 1")) # 2
Data in, data out
Values are copied across the boundary; the guest never holds a reference to a Python object.
| JavaScript | Python |
|---|---|
null, undefined |
None |
boolean, string |
bool, str |
integral number, BigInt |
int (BigInt never wraps) |
other number |
float |
Array |
list (a tuple goes in as an array) |
plain Object |
dict (own enumerable keys, functions skipped) |
Uint8Array, ArrayBuffer |
bytes (bytes/bytearray go in as Uint8Array) |
rt.bind_object("config", {"debug": True, "tags": ["a", "b"], "blob": b"\x00\x01"})
print(rt.eval("config.tags.length")) # 2
print(rt.eval("({ok: true, rows: [1, 2, 3]})")) # {'ok': True, 'rows': [1, 2, 3]}
Anything else (functions, symbols, Date, non-str dict keys, cycles) raises instead of being
silently mangled.
Syntax check
rt.check_syntax(code) (or module=True) parses without running and raises JavaScriptError (SyntaxError) if invalid.
Promises
A script that returns a promise is settled by draining microtasks, so async/await works.
A promise that can never settle (there are no timers or I/O) is an error, not a hang.
print(rt.eval("(async () => { await null; return 7 })()")) # 7
ES modules
rt.add_static_module("math", "export const answer = 42;")
rt.add_static_module("app", "import {answer} from 'math'; export const x = answer + 1;")
print(rt.eval_module("app")) # {'x': 43}
Only modules you registered can be imported, by import or import(). There is no file or URL
loading.
Async host functions
Bound functions may be async def. Under eval_async they reach JS as Promises, run on your event
loop, and can be in flight concurrently (Promise.all). timeout= covers the whole call,
including time spent waiting on the host.
import asyncio
from jsbox import Runtime
async def fetch(x):
await asyncio.sleep(0.01)
return x * 2
async def main():
rt = Runtime()
rt.bind_function("fetch", fetch)
print(await rt.eval_async("Promise.all([1, 2, 3].map(x => fetch(x)))")) # [2, 4, 6]
asyncio.run(main())
A plain eval() that calls an async function raises TypeError: use eval_async.
console and unconvertible results
rt = Runtime(on_console=lambda level, args: print(level, args), lossy=True)
rt.eval("console.log('hi', [1, 2]); console.error('oops')") # log ['hi', [1, 2]] / error ['oops']
rt.eval("({ok: true, run() {}})") # {'ok': True, 'run': '[function]'}
Runtime(buffer=True) installs a small Node-style Buffer (a non-enumerable Uint8Array subclass, no I/O). JSZip and pptxgenjs detect it and UTF-8-encode through it, which makes a pptxgenjs write() about 20% faster; it is off by default so guests still see a non-Node environment.
console.* is a no-op without on_console. With lossy=True, functions, symbols and cycles in a
result become "[function]", "[symbol]", "[Circular]" instead of raising.
Vendored npm libraries
Browser bundles such as pptxgenjs, three, vega, d3, turf, dagre and bpmn-moddle
run unmodified: the host prepends the bundle (and a few byte-plumbing shims) as trusted source,
the guest calls the library, and bytes come back as bytes. The test suite runs these exact files
(tests/test_onehub_compat.py).
Hard kill
timeout= cannot interrupt one long native operation. For untrusted code that must not wedge the
host, run it in a child process that is killed at the deadline:
from jsbox import run_killable
run_killable("let x = 3n; for (;;) x = x * x", timeout=1.0) # JsTimeoutError after ~2 s, process gone
run_killable("[1, 2, 3].map(x => x * 2)", timeout=5, memory_limit=32 * 1024 * 1024) # [2, 4, 6]
It costs a process spawn (about 50-100 ms) and runs pure computation only: bound functions and
modules cannot cross the process boundary. Other keyword arguments go to Runtime. For host
functions, run your own worker process and kill it the same way.
Errors
| Situation | Python exception |
|---|---|
| JS throws, or a syntax error | JavaScriptError (.name, .message, .stack) |
timeout= exceeded |
JsTimeoutError |
| A bound Python function raises | the original exception, re-raised unchanged |
| Value cannot be converted | TypeError / ValueError |
JS that catches a host error sees only host function failed: <ExceptionClass>. The
message of your Python exception (paths, secrets, internals) never reaches the guest.
Examples
Runnable scripts live in examples/ (see its README).
Monty data crunching + three.js in jsbox
A model often writes two programs: Python for the data, JavaScript for what only the JS ecosystem
does well. Monty runs the Python half, jsbox the JavaScript
half, and neither can touch your files or network. Here Python computes a height field and
three.js turns it into a 3D model (pip install jsbox pydantic-monty; the three.js bundle and the
small _sandbox.py helper live in vendor/libs and examples/):
import asyncio
from pathlib import Path
from _sandbox import load_libs, new_runtime
from pydantic_monty import Monty
# 1. Model-written Python (runs in Monty) crunches the data: a 24x24 height field.
with Monty() as pool, pool.checkout() as session:
heights = session.feed_run("""
import math
[[round(math.sin(x / 3) * math.cos(y / 3) * 2, 3) for x in range(24)] for y in range(24)]
""")
# 2. Model-written JavaScript (runs in jsbox) turns it into a three.js scene and a .glb file.
rt = new_runtime()
load_libs(rt, "three-0.180.0-gltf.bundle.js")
rt.bind_object("heights", heights)
glb = asyncio.run(rt.eval_async("""
(async () => {
const n = heights.length, geo = new THREE.PlaneGeometry(10, 10, n - 1, n - 1);
const pos = geo.attributes.position;
heights.flat().forEach((h, i) => pos.setZ(i, h));
geo.computeVertexNormals();
const scene = new THREE.Scene();
scene.add(new THREE.Mesh(geo, new THREE.MeshStandardMaterial({ color: 0x3b8f5f })));
return await new Promise((ok, fail) => new GLTFExporter().parse(scene, ok, fail, { binary: true }));
})()
""", timeout=60))
Path("terrain.glb").write_bytes(glb)
print(f"terrain.glb: {len(glb)} bytes")
More of the same, all runnable: Vega-Lite charts, d3 force graphs, ECharts dashboards, turf maps,
three.js scenes and a pptxgenjs deck. See examples/.
Security model
There is no permission system to configure. A fresh Runtime can compute and nothing else; what
it can do is exactly what you bound.
- No ambient authority. No
require,process,fetch,setTimeout, file or network access. - Heap, stack and copy limits with safe defaults.
memory_limit(64 MB),max_stack_size(1 MB, at most 4 MB: a larger limit would let guest recursion overflow the native stack and crash the process, so it is rejected),max_value_bytes(16 MB, shared by all arguments of one host call orconsolecall, and per conversion of a result). Pass0tomemory_limitto disable the heap limit. - Timeouts you cannot swallow.
timeout=interrupts the interpreter loop;try/catchin the guest cannot catch it, and it also covers property getters hit while converting the result. There is no default timeout: withouttimeout=,while(true){}runs forever. The deadline is checked every few thousand interpreter operations, so it cannot interrupt a single long native operation, and a loop whose every iteration does heavy native work overshoots badly. Known cases:BigIntpowers,splicein a loop over million-element arrays, hugenormalize/replaceAll. (Catastrophic regular expressions do stop on time. The worst offenders are refused up front: array methods such assort/reverse/join/fillon arrays longer than 2^26 andBigInt()of strings over 32768 characters throw aRangeError. These guards are best-effort, for example an array-like object with alengthgetter can slip past them: userun_killablewhen the code is untrusted.) The same applies to slow host callbacks,consolecallbacks and conversion of huge arguments: the timeout fires once they return. - Host
BaseExceptions (KeyboardInterrupt,SystemExit) raised by a bound function stop the script and propagate to the caller; the guest cannot catch them. - One call at a time per
Runtime. Callingevalfrom inside a host function, or overlappingeval_asynccalls on oneRuntime, raisesRuntimeError. Use oneRuntimeper task. - Host errors are not guest-readable, and a swallowed host error is never blamed on a later, unrelated JS error.
- Isolated runtimes. Two
Runtimes share nothing; aRuntimeis bound to its thread.
What this is not: QuickJS is a C engine in your process. A memory-safety bug in it is a bug
in your process, so for hostile multi-tenant workloads also use OS-level isolation (a container
or a separate process). Bound host functions are privileged code you wrote, so keep them narrow.
A timeout cannot interrupt a Python callback that is itself stuck (it fires as soon as that
callback returns), nor a single long native operation: for untrusted code, run the Runtime in
a process you can kill (or reject patterns up front). run_killable is the built-in hard kill for pure computation.
Limitations
TextEncoder/TextDecoder(UTF-8) andbtoa/atobare built in and native.Runtime(buffer=True)adds a small Node-styleBuffer(UTF-8, latin1, base64, hex). It is off by default because it is ambient API surface; turn it on for JSZip-based libraries such as pptxgenjs, which then take their native UTF-8 path (about 20% faster on a 20-slide deck).- No timers (
setTimeout),fetch, orDate/Map/Setconversion yet. Libraries that only use timers to yield work with a microtask shim. - Interpreter speed: it is not a JIT. CPU-bound JavaScript runs roughly 4-8x slower than V8 (a
20-slide pptxgenjs deck ≈ 0.45 s vs 0.1 s;
fib(30)≈ 70 ms vs 10 ms). Startup and boundary crossings are where it wins: creating a runtime ≈ 0.2 ms, a complete host call (JS to Python and back) ≈ 0.5 µs,eval('1+1')≈ 2 µs. - Python ints outside
i64cannot be sent in. performance.now()andDateexpose a real clock and the host's timezone to the guest; strict mode turnsMap/Date/RegExpresults into{}(uselossy=Trueto get JSON-shaped values).- A
bind_objectdict key__proto__sets the bound object's prototype (it does not polluteObject.prototype).
Development
python -m venv .venv && .venv/bin/pip install maturin pytest pytest-asyncio hypothesis
.venv/bin/maturin develop --release
.venv/bin/pytest
Metadata
Release files for jsbox 0.1.3
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| jsbox-0.1.3.tar.gz | 2.5 MB | With provenance | Details for jsbox-0.1.3.tar.gz |
Built distributions (wheels)
Total release size: 7.8 MB
Release files / jsbox-0.1.3.tar.gz
| Download URL | jsbox-0.1.3.tar.gz |
|---|---|
| Size | 2.5 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
d4d7cd9cbc03d2808540279b9fd437c17751b0f3e2ca0c7f564819dd1980268a
|
|
BLAKE2b-256 checksum How to use checksums |
ce6de62047ec50fad865364aed5b22934e4740e0631bfce4d9395e19e3604c49
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 17, 2026.
Transparency logRelease files / jsbox-0.1.3-cp310-abi3-win_amd64.whl
| Download URL | jsbox-0.1.3-cp310-abi3-win_amd64.whl |
|---|---|
| Size | 775.8 kB |
| Tags | CPython 3.10 Windows x86-64 abi3 |
|
SHA-256 checksum How to use checksums |
733abd9d1460b7674802e7dcd2334ecb7779638efa348c5b2855178283c04e44
|
|
BLAKE2b-256 checksum How to use checksums |
211c924bf2ab50feccd5db1364725f982a10a203d6e51526f6bc0bca9bd0b512
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 17, 2026.
Transparency logRelease files / jsbox-0.1.3-cp310-abi3-musllinux_1_2_x86_64.whl
| Download URL | jsbox-0.1.3-cp310-abi3-musllinux_1_2_x86_64.whl |
|---|---|
| Size | 1.1 MB |
| Tags | CPython 3.10 Linux musl 1.2+ x86-64 abi3 |
|
SHA-256 checksum How to use checksums |
4c40bb3441a914e26b40795b7430b8f7d367de83418f4474bd35fb94ed635e36
|
|
BLAKE2b-256 checksum How to use checksums |
3c22124df70ac2732cf7c73aa0f18bf20ca29f4a257633af90777abc80120c9b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 17, 2026.
Transparency logRelease files / jsbox-0.1.3-cp310-abi3-musllinux_1_2_aarch64.whl
| Download URL | jsbox-0.1.3-cp310-abi3-musllinux_1_2_aarch64.whl |
|---|---|
| Size | 1.0 MB |
| Tags | CPython 3.10 Linux musl 1.2+ ARM64 abi3 |
|
SHA-256 checksum How to use checksums |
ab00f3bd002156da68f53ba8b991a673252b151ce13fa4a4da7fe59f3a2d5b66
|
|
BLAKE2b-256 checksum How to use checksums |
c04a3af991f11c1bb8fdeadfc294244c2aae78116db715f9f672ac9d7458fbe7
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 17, 2026.
Transparency logRelease files / jsbox-0.1.3-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
| Download URL | jsbox-0.1.3-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl |
|---|---|
| Size | 862.8 kB |
| Tags | CPython 3.10 Linux glibc 2.17+ x86-64 abi3 |
|
SHA-256 checksum How to use checksums |
1977c92bc77eb63e3966a095bddb6915b917adb93e6063672f1a75f97e49c49e
|
|
BLAKE2b-256 checksum How to use checksums |
399e247be9aa26e6681bd05552cdf1d4ed0f0314eb75fbd7895e493f7538a190
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 17, 2026.
Transparency logRelease files / jsbox-0.1.3-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
| Download URL | jsbox-0.1.3-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl |
|---|---|
| Size | 834.8 kB |
| Tags | CPython 3.10 Linux glibc 2.17+ ARM64 abi3 |
|
SHA-256 checksum How to use checksums |
8a59397b6bb51798c2916bd5dc090d2f18427a552822e88278f4c6b170def6b9
|
|
BLAKE2b-256 checksum How to use checksums |
d3843b0b9a7be0db6b785d27c69f521d65ed877de274831a0044e9a16b1f1409
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 17, 2026.
Transparency logRelease files / jsbox-0.1.3-cp310-abi3-macosx_11_0_arm64.whl
| Download URL | jsbox-0.1.3-cp310-abi3-macosx_11_0_arm64.whl |
|---|---|
| Size | 785.9 kB |
| Tags | CPython 3.10 abi3 macOS 11.0+ ARM64 |
|
SHA-256 checksum How to use checksums |
3a44d578939324c46ae44890ee9b6fcdc8d1062ce66ece28b9d88a216a60990d
|
|
BLAKE2b-256 checksum How to use checksums |
77952c8ccd8ea25c30b75f7b89137f7ce160f04a43a20ac74961432174f4e754
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 17, 2026.
Transparency logSecurity for jsbox 0.1.3
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 7 files in this release have been uploaded with provenance metadata.
Attestation for jsbox-0.1.3.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 17, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.