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.