Every AI-generated safety verdict is an opinion — confident, usually correct, but still a prediction about what code will do. The only way to know for certain is to run it. The sandbox is the part of Auralis that does exactly that, in an environment small enough that a wrong prediction costs nothing.
An AI safety review is an opinion. The only way to know what code actually does is to run it. Auralis's pipeline ends with exactly that — a sandboxed execution step that runs the code under controlled conditions and reports back what really happened, console output included.
fetch, no XMLHttpRequest, and a hard wall-clock timeout. console output is captured and streamed back to the UI.
Why run the code at all?
Static analysis — regex rules and an LLM reading source — is good at spotting intent. It's much weaker at confirming behaviour. A snippet that looks benign can still throw, infinite-loop, or produce output the reasoning stage didn't anticipate. Conversely, a snippet the AI flags as "possibly risky" might just be a recursive Fibonacci function that's slow, not dangerous.
Rather than asking the model to simulate execution in its head, Auralis lets the browser do it — inside a sandbox tight enough that "what's the worst that could happen" has a boring answer.
Architecture: a Worker with nothing to lose
The sandbox runs entirely client-side — no server round trip, no shared execution environment between users. Each run spins up a fresh dedicated Web Worker, executes one snippet, and is torn down immediately after. Nothing persists between runs.
Phase 1 — Spinning up an empty Worker
Blob-sourced worker
The worker's source is built as a string and loaded via a Blob URL — no separate file to serve, and the runtime is identical for every execution.
Stripped global scope
Before user code runs, the worker deletes fetch, XMLHttpRequest, importScripts, and WebSocket from its own global scope. Workers have no DOM by default, so this closes the remaining network and module-loading paths.
Proxied console
A fake console object collects every log, warn, and error call into an array of strings instead of writing anywhere, then ships that array back in the result message.
// Built once, reused as the source for every sandbox run const WORKER_SRC = ` self.fetch = undefined; self.XMLHttpRequest = undefined; self.importScripts = undefined; self.WebSocket = undefined; const logs = []; const fmt = (a) => a.map(v => typeof v === 'object' ? JSON.stringify(v) : String(v)).join(' '); const sandboxConsole = { log: (...a) => logs.push(fmt(a)), warn: (...a) => logs.push('WARN: ' + fmt(a)), error: (...a) => logs.push('ERR: ' + fmt(a)), }; self.onmessage = ({ data }) => { try { const run = new Function('console', data.code); run(sandboxConsole); self.postMessage({ ok: true, logs }); } catch (err) { self.postMessage({ ok: false, logs, error: String(err) }); } };`; const blob = new Blob([WORKER_SRC], { type: 'application/javascript' }); const workerUrl = URL.createObjectURL(blob);
Phase 2 — Running it, and reaping it
Every run gets a fresh Worker instance and a hard deadline. If the worker doesn't respond before the timeout, it's terminated outright — there's no graceful cancellation for JavaScript, so termination is the only tool that reliably stops an infinite loop. The promise wrapping the worker resolves either way, with whatever logs were captured before the cutoff.
- Success: worker posts
{ ok: true, logs }and is terminated immediately - Runtime error: worker posts
{ ok: false, logs, error }with the partial logs collected before the throw - Timeout: the host terminates the worker after 5s and reports
{ ok: false, error: 'Execution timed out' }
worker.terminate() kills the thread regardless of what it's doing.
function runInSandbox(code, { timeoutMs = 5000 } = {}) { return new Promise((resolve) => { const worker = new Worker(workerUrl); const timer = setTimeout(() => { worker.terminate(); resolve({ ok: false, logs: [], error: 'Execution timed out' }); }, timeoutMs); worker.onmessage = ({ data }) => { clearTimeout(timer); worker.terminate(); resolve(data); }; worker.postMessage({ code }); }); }
Safe Mode gates
The sandbox is the last stage of a three-gate pipeline, and how strictly the earlier gates are enforced is configurable per session via Safe Mode. The same sandbox runtime is used in all three modes — what changes is whether code is allowed to reach it.
| Mode | Regex pre-scan hit | AI reasoning stage | Sandbox runs when |
|---|---|---|---|
| Strict | Blocks immediately | Skipped if blocked | No regex hits at all |
| Balanced | Routes to AI review | Required for flagged code | Clean, or AI confidence above threshold |
| Permissive | Advisory only | Always runs, non-blocking | Unless AI flags a critical risk |
Wiring it into the pipeline
The regex pre-scan and AI reasoning stages — covered in Auralis migrates fully to Groq — run first and stream their verdicts to the UI: intent classification, complexity score, a confidence percentage, and a list of risk signals. The sandbox call only fires after that verdict clears the active Safe Mode, and its result is appended to the same session record.
From the user's perspective this looks like one continuous action — paste code, hit run — but under the hood it's three independent systems agreeing in sequence: a synchronous regex scan, a streamed LLM call, and a sandboxed execution. Each one can veto the next.
Edge cases that bit us
A sandbox that only handles the happy path isn't a sandbox, it's a demo. A few things that broke during development:
Async code outlives the message. new Function runs synchronously, so a snippet that calls setTimeout or returns a Promise posts its result message before the callback fires. Any console.log inside that callback happens after postMessage — and after terminate(), so it's silently lost. We accept this as a known limitation rather than holding the worker open indefinitely.
Not everything survives postMessage. The structured clone algorithm throws on functions, and JSON.stringify throws on circular references. The console proxy wraps every argument in a try/catch and falls back to String(value) — so a circular object prints as [object Object] instead of crashing the whole run.
Errors thrown after partial output. A script that logs five lines and then throws on the sixth should show those five lines plus the error — not just the error. The proxy console pushes into the logs array immediately, so it's populated regardless of how the script ends, and both the success and error branches send it back.
What we learned
new Function alone is not a sandbox. It creates a new scope, but that scope still closes over the surrounding global object — in a page, that's window. The actual isolation boundary is the Worker, which gets its own global (self) with no reference back to the page that spawned it.
One worker per run, not a pool. We initially reused a single long-lived worker for performance. But globals set by one run — a variable left on self, a monkey-patched method — were still visible to the next run on the same worker. A fresh Worker per execution costs a few milliseconds of startup and removes an entire class of cross-session leakage bugs.
Five seconds is a UX decision, not a security one. The timeout doesn't make anything safer — terminate() at 500ms would be equally safe. It's there so a runaway loop fails fast instead of leaving the "Run" button spinning. We picked 5s after watching real snippets: legitimate recursive or loop-heavy examples almost always finish well under that.
JSON.stringify is good enough. We considered shipping a richer object-formatting library so logged objects look like a real devtools console. In practice, almost every snippet logs strings, numbers, and plain objects or arrays — JSON.stringify with a try/catch fallback covers the overwhelming majority, for a fraction of the bundle size.
What's next for the sandbox
The current sandbox is JavaScript-only, matching what Auralis can statically analyze most precisely. Extending it to Python via a WASM runtime like Pyodide is the most-requested next step — the same gating model (regex → AI reasoning → Safe Mode → sandbox) carries over unchanged, only the worker's internals differ.
We're also looking at per-run memory accounting. Workers don't expose a hard memory cap directly, but we can sample performance.memory where available and terminate runs that balloon past a threshold — the same "terminate, don't negotiate" philosophy applied to memory instead of time.
The sandbox is live in Auralis today, in the Sandbox panel. Paste something that should infinite-loop and watch it get cut off at exactly 5 seconds.