-
Notifications
You must be signed in to change notification settings - Fork 0
Code Sandbox Architecture
Mosaic's code execution system runs JavaScript and Python in a secure, sandboxed environment. All code executes in a Web Worker with strict security restrictions, preventing access to network, filesystem, and browser APIs.
Main Thread Web Worker
┌──────────────┐ ┌─────────────────────────┐
│ codeRunner │───postMessage──►│ codeWorker.ts │
│ (interface) │ │ │
│ │◄──postMessage──│ JS: new Function() │
│ runCode() │ │ Python: Pyodide WASM │
│ replExec() │ │ │
│ replReset() │ │ Sandbox restrictions: │
└──────────────┘ │ - No fetch/XHR/WS │
│ - No localStorage/DOM │
│ - No import() │
│ - Allowlisted globals │
│ - 30s timeout │
└─────────────────────────┘
- Isolation: The worker runs in a separate global scope — no access to the main thread's DOM, stores, or API keys
- No blocking: Long-running code doesn't block the UI (the canvas remains interactive)
- Abort support: The worker can be terminated or messages aborted without affecting the main app
- Security boundary: Even if malicious code breaks out of the sandbox, it's still confined to the worker scope
App start
│
├── Worker NOT created (lazy initialization)
│
├── User clicks "Run" on code block
│ │
│ ├── Is worker alive? → No → CreateWorker()
│ │ ├── Create Blob URL from worker source
│ │ ├── new Worker(blobURL)
│ │ ├── URL.revokeObjectURL(blobURL)
│ │ └── Worker loads Pyodide (if Python)
│ │
│ ├── postMessage({ exec_id, lang, code })
│ ├── Wait for messages (timeout 30s)
│ ├── Return result or error
│ └── Worker stays alive (for next execution)
│
├── User clicks "Run" again
│ └── Reuse existing worker (faster, no init)
│
├── User closes Python REPL
│ └── replReset() — clears Python globals
│
└── App unmount
└── terminateWorker() — kills worker
The worker is created lazily — no resources are consumed until the user first runs code. Python's Pyodide runtime (~12 MB) is only loaded when the first Python execution is requested.
| Resource | Approx Size | Loaded When |
|---|---|---|
| Worker shell (JS) | ~5 KB | First code execution |
| Pyodide WASM runtime | ~12 MB | First Python execution |
| Python packages (numpy, etc.) | ~8 MB each | On first import |
| User code output | Variable | Per execution |
For performance, Pyodide and imported packages remain cached in the worker's memory across executions. Only replReset() clears the Python state. The worker stays alive until the app closes, avoiding repeated Pyodide load times.
Messages between the main thread and worker follow a strict protocol:
{
exec_id: string; // Unique execution ID (UUID)
type: 'execute' | 'repl_exec' | 'repl_reset' | 'terminate';
lang: 'javascript' | 'python';
code: string;
}{
exec_id: string; // Matches the request's exec_id
type: 'stdout' | 'result' | 'error' | 'ready';
data: string; // Output text
}| Message Type | When Sent | Data |
|---|---|---|
stdout |
During execution (console.log/print) | Captured output text |
result |
Execution completed successfully | Return value or "undefined" |
error |
Execution threw an error | Error message and stack trace |
ready |
Worker initialized and Pyodide loaded | "ready" |
1. Main thread: postMessage({ exec_id: "abc123", lang: "javascript", code: "..." })
2. Worker: Execute code
3. Worker: postMessage({ exec_id: "abc123", type: "stdout", data: "Hello" })
4. Worker: postMessage({ exec_id: "abc123", type: "result", data: "undefined" })
5. Main thread: Resolve promise with collected output/result
JavaScript code is executed using new Function():
const fn = new Function(...sandboxGlobals.keys(), code);
return fn(...sandboxGlobals.values());This creates a function with explicit parameters for each allowed global. Any reference to a non-param variable throws a ReferenceError.
const SANDBOX_GLOBALS = [
// Primitives
'Array', 'Object', 'String', 'Number', 'Boolean',
'BigInt', 'Symbol', 'undefined', 'null', 'NaN', 'Infinity',
// Collections
'Map', 'Set', 'WeakMap', 'WeakSet',
// Typed arrays
'ArrayBuffer', 'Uint8Array', 'Int8Array', 'Uint16Array', 'Int16Array',
'Uint32Array', 'Int32Array', 'Float32Array', 'Float64Array',
// Math & time
'Math', 'Date', 'parseInt', 'parseFloat', 'isNaN', 'isFinite',
// JSON
'JSON', 'structuredClone',
// ReGExp
'RegExp', 'Error', 'TypeError', 'RangeError', 'SyntaxError',
// Async
'Promise',
// Utilities
'console', 'Reflect', 'Proxy',
// Encoding
'decodeURI', 'encodeURI', 'decodeURIComponent', 'encodeURIComponent',
// Timers (limited)
'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval',
];Any attempt to use these APIs throws a ReferenceError:
| Category | Blocked APIs |
|---|---|
| Network |
fetch, XMLHttpRequest, WebSocket, EventSource
|
| Workers |
Worker, SharedWorker, ServiceWorker
|
| Storage |
localStorage, sessionStorage, indexedDB, caches, cookie
|
| DOM |
document, window, self, globalThis, navigator, location, history
|
| I/O |
open, close, print, prompt, alert, confirm
|
| Messaging |
postMessage, BroadcastChannel, MessageChannel
|
| Dynamic import |
import(), importScripts()
|
| File system |
FileReader, FileWriter, showOpenFilePicker
|
-
setTimeoutandsetIntervalare allowed but limited to the worker's event loop - They cannot access the main thread's DOM or stores
- Animation-related APIs (
requestAnimationFrame) are not available in workers
Python code runs via Pyodide v0.26.2 — a build of CPython compiled to WebAssembly. Pyodide is loaded from the CDN on first use.
async function initPyodide(): Promise<void> {
if (pyodideInstance) return; // Already initialized
// Step 1: Load Pyodide WASM from CDN
// The script tag is injected with integrity checking
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/pyodide/v0.26.2/full/pyodide.js';
script.integrity = 'sha384-...';
document.head.appendChild(script);
await new Promise((resolve) => (script.onload = resolve));
// Step 2: Initialize Pyodide
pyodideInstance = await globalThis.loadPyodide({
indexURL: 'https://cdn.jsdelivr.net/pyodide/v0.26.2/full/',
});
// Step 3: Apply security patches
await patchNetworkAccess();
await blockPlatformModules();
await setupPackageAllowlist();
}The initialization:
- Downloads the Pyodide JavaScript loader (~1 MB)
- Downloads the Python WASM binary (~12 MB)
- Initializes the CPython interpreter in WebAssembly
- Patches security-sensitive modules
- Sets up the package allowlist
This takes 2-5 seconds on first load. Subsequent executions are instant.
User Python Code
│
▼
Pyodide.pyodide.runPython(code)
│
├── CPython bytecode compiler (WASM)
├── CPython bytecode interpreter (WASM)
├── stdout/stderr redirected to JS
└── Result converted to JS object
Pyodide bridges Python and JavaScript:
- Python objects can be accessed from JavaScript
- JavaScript objects can be passed to Python
- stdout/stderr is captured via Python's
sys.stdoutredirection - Errors are caught and converted to Python tracebacks
# This works:
import numpy as np
print(np.array([1,2,3]).mean())
# This is blocked:
import os # Most os functions raise errors
import socket # Not available in WASM
# This is restricted:
import requests # Only allowlisted hostsOnly these packages can be installed via pip install:
numpy
pandas
scipy
matplotlib
sympy
requests
beautifulsoup4
lxml
Pillow
markdown
jinja2
pyyaml
toml
colorama
tqdm
The allowlist is enforced by wrapping the Pyodide loadPackage and micropip install functions.
Python's network access is patched to only allow specific CDN hosts:
cdn.jsdelivr.net
pyodide-cdn2.iodide.io
files.pythonhosted.org
How it works:
- The worker patches Python's
urllibandhttp.clientmodules - Any request to a non-allowlisted host is blocked with a SecurityError
-
fetchin JavaScript (for Pyodide module loading) is similarly restricted
This ensures:
- Pyodide can load its runtime from CDN
- pip can install allowlisted packages from PyPI
- User code cannot make arbitrary network requests
Platform-specific C extensions that could provide system access are blocked:
msvcrt (Windows console I/O)
termios (Unix terminal I/O)
fcntl (Unix file control)
mmap (Memory mapping)
ctypes (C foreign function interface) — partially restricted
Python's stdout and stderr are captured via Pyodide's built-in redirection:
import sys
from io import StringIO
# Mosaic captures:
print("This goes to stdout")
sys.stderr.write("This goes to stderr")Both streams are sent back to the main thread as stdout messages.
runCode(lang, code)
│
├── Create execution ID
├── Set timeout (30 seconds)
├── postMessage to worker
├── Wait for messages:
│ ├── stdout → collect in output buffer
│ ├── result → resolve promise with output
│ └── error → reject promise with error
├── On timeout → abort & reject
└── Return { output, error?, executionTime? }
replExec(code) replReset()
│ │
├── Same as runCode ├── Clears Python globals
├── But persists └── Returns fresh environment
│ state in worker
│ (global variables,
│ imported modules)
│
└── State persists
across calls
const TIMEOUT_MS = 30000;
const timeoutId = setTimeout(() => {
worker.terminate(); // Kill the worker
worker = createWorker(); // Create a new one
reject(new Error('Execution timed out'));
}, TIMEOUT_MS);If code exceeds 30 seconds:
- The worker is terminated (killed entirely)
- A new worker is created (clean state)
- The promise rejects with a timeout error
- The node shows an error state with retry option
| Threat | Mitigation |
|---|---|
| Code reads API keys from localStorage | Worker has no access to main thread's localStorage |
| Code makes unauthorized API calls | CSP blocks connect-src; worker's fetch is patched |
| Code accesses filesystem | Worker has no filesystem access |
| Code consumes unlimited CPU | 30-second timeout, worker can be terminated |
| Code reads other canvas data | Worker has no access to stores or DOM |
| Code breaks out of Function() sandbox | Still confined to Worker scope (no DOM access) |
Mosaic uses multiple layers of security:
- Tauri CSP — Browser-level content security policy restricts network access
- Web Worker isolation — Separate global scope, no DOM access
- JavaScript Function sandbox — Explicit allowlist of globals
- Python restrictions — Allowlisted packages, blocked modules
- CDN allowlist — Network fetch restricted to trusted hosts
- Pyodide integrity — Module loading includes integrity checks
- Execution timeout — Prevents infinite loops from hanging the app
- Confirmation dialog — User must confirm AI-generated code execution
interface CodeRunner {
runCode(lang: CodeLanguage, code: string): Promise<CodeResult>;
replExec(code: string): Promise<CodeResult>;
replReset(): Promise<void>;
terminateWorker(): void;
}
interface CodeResult {
output: string; // All captured stdout
error?: string; // Error message (if failed)
executionTime?: number; // Duration in ms
}
type CodeLanguage = 'javascript' | 'python';import { runCode, replExec, replReset } from '@/utils/codeRunner';
// One-shot JavaScript
const result = await runCode('javascript', `
const numbers = [1, 2, 3, 4, 5];
console.log('Sum:', numbers.reduce((a, b) => a + b));
`);
console.log(result.output); // "Sum: 15"
// One-shot Python
const pyResult = await runCode('python', `
import numpy as np
arr = np.array([1, 2, 3, 4, 5])
print(f"Mean: {arr.mean():.2f}")
`);
console.log(pyResult.output); // "Mean: 3.00"
// Persistent REPL
await replExec('x = 42');
const replResult = await replExec('print(x * 2)');
console.log(replResult.output); // "84"
// Reset REPL
await replReset();- Tutorial - Running Code Inline — Step-by-step code execution walkthrough
- Security Model — Complete security overview (CSP, sandboxing, validation)
- PythonTerminal component in Component API Reference — REPL widget details
Mosaic — Branch, explore, and run code inline — an infinite canvas for AI conversations.
Built with Tauri, React, and Mistral AI.
GitHub Repository |
Report an Issue |
Releases
- Canvas and Node System
- LLM Provider Integration
- RAG System Guide
- Advanced AI Features
- Keyboard Shortcuts and UI Reference
- Tutorial - Branching and Parallel Exploration
- Tutorial - Using RAG with Documents
- Tutorial - Advanced Features in Practice
- Tutorial - Customizing Mosaic