Metadata-Version: 2.4
Name: jsbox
Version: 0.1.0
License-File: LICENSE
Summary: Embedded JavaScript for Python, powered by QuickJS (rquickjs) and Rust.
Keywords: javascript,quickjs,pyo3,sandbox
Maintainer-email: BMSuisse <dominik.peter@bmsuisse.ch>
License-Expression: MIT
Requires-Python: >=3.10
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
Project-URL: Repository, https://github.com/bmsuisse/jsbox

<div align="center">

# jsbox

**A small JavaScript sandbox for Python**

`jsbox` embeds [QuickJS][quickjs] (via [rquickjs][rquickjs] and [PyO3][pyo3]) in Python.
Run untrusted, LLM-generated JavaScript with hard limits and nothing but the functions you hand it.

[![CI](https://github.com/bmsuisse/jsbox/actions/workflows/workflow.yml/badge.svg)][ci]
[![PyPI](https://img.shields.io/pypi/v/jsbox.svg)][pypi]
[![License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)

</div>

## 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.

```python
from jsbox import Runtime

rt = Runtime()
rt.bind_function("add", lambda a, b: a + b)
print(rt.eval("add(2, 3)"))  # 5
```

## Get started

```bash
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

```python
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`) |

```python
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.

```python
print(rt.eval("(async () => { await null; return 7 })()"))  # 7
```

### ES modules

```python
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.

```python
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

```python
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`).

### 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> [#n]`. The
message of your Python exception (paths, secrets, internals) never reaches the guest.

## 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), `max_value_bytes` (16 MB per conversion). Pass `0` to `memory_limit` or
  `max_stack_size` to disable them.
- **Timeouts you cannot swallow.** `timeout=` interrupts the interpreter loop; `try/catch` in the
  guest cannot catch it, and it also covers property getters hit while converting the result.
  **It cannot interrupt a single long native operation**: a catastrophic regular expression
  (`/^(a+)+$/.test('a'.repeat(40) + 'b')`) runs to completion, and some builtins (huge `BigInt`
  powers) overshoot the deadline. See below.
- **Host errors are not guest-readable**, and a swallowed host error is never blamed on a later,
  unrelated JS error.
- **Isolated runtimes.** Two `Runtime`s share nothing; a `Runtime` is 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 runaway regular expression: **for untrusted code, run the `Runtime` in
a process you can kill** (or reject patterns up front). A built-in hard kill is the open item.

## Limitations

- `TextEncoder`/`TextDecoder` (UTF-8) and `btoa`/`atob` are built in and native.
- `Runtime(buffer=True)` adds a small Node-style `Buffer` (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`, or `Date`/`Map`/`Set` conversion 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 `i64` cannot be sent in.

## Development

```bash
python -m venv .venv && .venv/bin/pip install maturin pytest pytest-asyncio hypothesis
.venv/bin/maturin develop --release
.venv/bin/pytest
```

[quickjs]: https://bellard.org/quickjs/
[rquickjs]: https://github.com/DelSkayn/rquickjs
[pyo3]: https://pyo3.rs/
[pypi]: https://pypi.org/project/jsbox/
[ci]: https://github.com/bmsuisse/jsbox/actions/workflows/workflow.yml

