nsjail Plugins

The nsjail tier provides the highest flexibility by running plugins as isolated child processes. Any executable -- Python scripts, Ruby gems, compiled Go binaries, shell scripts -- can be a bext plugin as long as it speaks the JSON-over-stdio IPC protocol. This is a Pro feature.

Isolation Layers

bext applies isolation progressively based on what the host system supports:

Layer Requirement What It Does
Process isolation Always Separate process, piped stdio, wall-clock timeout
PID namespace Linux + root Plugin cannot see or signal other processes
NET namespace Linux + root Plugin gets an isolated network stack (no host network access)
MOUNT namespace Linux + root Plugin gets a minimal filesystem view
Cgroup limits Linux + root + cgroups v2 CPU and memory hard limits
seccomp-bpf Linux + root + eBPF feature Syscall allowlisting

Without root on Linux (or on macOS), plugins still run as separate processes with piped stdio and configurable wall-clock timeouts. This is sufficient for many use cases.

IPC Protocol

bext communicates with plugin processes via JSON lines on stdin/stdout. Each lifecycle event is a single JSON line sent to the child's stdin; the child responds with a single JSON line on stdout. stderr is captured as log output.

Host to Plugin (stdin)

{"method": "onServerStart", "params": {"config": {"threshold": 100}}}
{"method": "onRequestComplete", "params": {"path": "/api/users", "status": 200, "render_time_us": 1250}}
{"method": "onCacheWrite", "params": {"key": "page:/about", "tags": ["page", "about"]}}
{"method": "cleanup", "params": null}

Plugin to Host (stdout)

{"ok": true}
{"ok": true}
{"error": "connection refused"}

An empty line or missing response is treated as {"ok": true} -- hooks are optional.

Configuration

[plugins.fraud-detector]
sandbox = "nsjail"
path = "./plugins/fraud-detector/main.py"
priority = 650

[plugins.fraud-detector.sandbox]
max_memory_mb = 128
max_time_secs = 30
storage_quota_kb = 10240
interpreter = "python3"  # Optional: auto-detected from shebang or extension

[plugins.fraud-detector.config]
model_path = "./models/fraud-v3.bin"
threshold = 0.85

Interpreter Detection

If interpreter is not set, bext detects it automatically:

1. Shebang -- reads the first line. #!/usr/bin/env python3 resolves to python3. #!/usr/bin/ruby resolves to /usr/bin/ruby. 2. File extension -- .py maps to python3, .rb to ruby, .js to node, .sh to sh. 3. Default -- /bin/sh.

For compiled binaries (no extension, ELF header), the file is executed directly.

Resource Limits

Memory

With cgroups v2, set a hard memory limit:

[plugins.my-plugin.sandbox]
max_memory_mb = 256

The kernel OOM-kills the plugin process if it exceeds this limit. bext detects the termination and logs it as a plugin crash. The plugin is restarted on the next lifecycle event.

CPU

Wall-clock timeouts prevent runaway plugins:

[plugins.my-plugin.sandbox]
max_time_secs = 10

If the plugin does not respond within the timeout, bext sends SIGKILL and returns an error for that hook invocation. Maximum line length for IPC messages is 10 MB to prevent memory exhaustion from malicious output.

Storage

Each plugin has a scoped storage directory at data/plugins/<plugin-name>/. The storage quota is enforced by bext (not the OS):

[plugins.my-plugin.sandbox]
storage_quota_kb = 10240  # 10 MB

Complete Example: Python Analytics Plugin

#!/usr/bin/env python3
"""bext lifecycle plugin that sends analytics to a data warehouse."""





config = {}
buffer = []
BATCH_SIZE = 50

def handle_message(msg):
    method = msg.get("method")
    params = msg.get("params", {})

    if method == "onServerStart":
        global config
        config = params.get("config", {})
        return {"ok": True}

    elif method == "onRequestComplete":
        buffer.append({
            "path": params.get("path"),
            "status": params.get("status"),
            "render_time_us": params.get("render_time_us"),
            "cache_status": params.get("cache_status"),
        })
        if len(buffer) >= BATCH_SIZE:
            flush()
        return {"ok": True}

    elif method == "cleanup":
        flush()
        return {"ok": True}

    return {"ok": True}

def flush():
    global buffer
    if not buffer or "endpoint" not in config:
        return
    payload = json.dumps({"events": buffer}).encode()
    req = urllib.request.Request(
        config["endpoint"],
        data=payload,
        headers={"Content-Type": "application/json"},
    )
    try:
        urllib.request.urlopen(req, timeout=5)
    except Exception as e:
        print(f"flush error: {e}", file=sys.stderr)
    buffer = []

# Main IPC loop
for line in sys.stdin:
    line = line.strip()
    if not line:
        continue
    try:
        msg = json.loads(line)
        result = handle_message(msg)
    except Exception as e:
        result = {"error": str(e)}
    print(json.dumps(result), flush=True)

Configure it:

[plugins.analytics-warehouse]
sandbox = "nsjail"
path = "./plugins/analytics/main.py"

[plugins.analytics-warehouse.sandbox]
max_memory_mb = 64
max_time_secs = 15

[plugins.analytics-warehouse.config]
endpoint = "https://warehouse.example.com/ingest"

Use Cases

The nsjail tier is the right choice when:

- Multi-tenant plugins -- customers upload their own scripts. Process isolation prevents them from accessing each other's data or the host system.

- Existing tools -- you want to integrate a Python ML model, a Ruby DSL, or a shell script pipeline without rewriting in JS or Rust.

- Untrusted code -- with full namespace isolation, even a malicious plugin cannot escape its sandbox.

- Heavy dependencies -- plugins that need system libraries (NumPy, OpenCV, FFmpeg) that cannot be compiled to WASM.

Limitations

- Startup overhead -- ~10 ms per lifecycle call due to process spawn (compared to < 1 ms for QuickJS/WASM). The plugin process stays alive between calls to amortize this cost.

- No middleware capability -- nsjail plugins support lifecycle hooks only, not per-request middleware. For request-path interception, use QuickJS or WASM.

- Linux-only namespace isolation -- on macOS and non-root Linux, you get process isolation and timeouts but not namespace/cgroup/seccomp hardening.