diff --git a/.gitignore b/.gitignore
index 0c12749..d348f14 100644
--- a/.gitignore
+++ b/.gitignore
@@ -98,3 +98,8 @@ docs/docs.backup.json
**/.cache/
perf-results.json
+
+# Playwright
+test-results/
+playwright-report/
+libs/browser/e2e/fixtures/enclave-browser-bundle.mjs
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..e88eed3
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,13 @@
+# CLAUDE.md
+
+## Verification
+
+After every code change, the following commands must all pass before considering the work complete:
+
+```bash
+pnpm lint
+pnpm format:check
+pnpm build
+```
+
+Run these from the repository root. Use `npx nx` to target individual projects (e.g., `npx nx build browser`).
diff --git a/apps/browser-demo/.gitignore b/apps/browser-demo/.gitignore
new file mode 100644
index 0000000..81112ee
--- /dev/null
+++ b/apps/browser-demo/.gitignore
@@ -0,0 +1,2 @@
+# Generated enclave bundle
+vendor/enclave-browser-bundle.mjs
diff --git a/apps/browser-demo/eslint.config.mjs b/apps/browser-demo/eslint.config.mjs
new file mode 100644
index 0000000..a23f0f2
--- /dev/null
+++ b/apps/browser-demo/eslint.config.mjs
@@ -0,0 +1,8 @@
+import baseConfig from '../../eslint.config.mjs';
+
+export default [
+ ...baseConfig,
+ {
+ ignores: ['vendor/**'],
+ },
+];
diff --git a/apps/browser-demo/index.html b/apps/browser-demo/index.html
new file mode 100644
index 0000000..884661d
--- /dev/null
+++ b/apps/browser-demo/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Browser Enclave Demo
+
+
+
+
+
+
diff --git a/apps/browser-demo/package.json b/apps/browser-demo/package.json
new file mode 100644
index 0000000..24e3bec
--- /dev/null
+++ b/apps/browser-demo/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "browser-demo",
+ "version": "0.0.1",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "prebuild": "node scripts/build-enclave-bundle.mjs",
+ "dev": "vite",
+ "build": "vite build"
+ },
+ "dependencies": {
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
+ },
+ "devDependencies": {
+ "@types/react": "^19.0.0",
+ "@types/react-dom": "^19.0.0",
+ "@vitejs/plugin-react": "^4.5.2",
+ "esbuild": "^0.25.0",
+ "typescript": "~5.9.3",
+ "vite": "^6.3.5"
+ }
+}
diff --git a/apps/browser-demo/project.json b/apps/browser-demo/project.json
new file mode 100644
index 0000000..a99a3c2
--- /dev/null
+++ b/apps/browser-demo/project.json
@@ -0,0 +1,28 @@
+{
+ "name": "browser-demo",
+ "sourceRoot": "apps/browser-demo/src",
+ "projectType": "application",
+ "targets": {
+ "prebuild": {
+ "executor": "nx:run-commands",
+ "options": {
+ "command": "node apps/browser-demo/scripts/build-enclave-bundle.mjs"
+ }
+ },
+ "serve": {
+ "executor": "nx:run-commands",
+ "dependsOn": ["prebuild"],
+ "options": {
+ "command": "npx vite --config apps/browser-demo/vite.config.ts"
+ }
+ },
+ "build": {
+ "executor": "nx:run-commands",
+ "dependsOn": ["prebuild"],
+ "options": {
+ "command": "npx vite build --config apps/browser-demo/vite.config.ts"
+ },
+ "outputs": ["{workspaceRoot}/dist/apps/browser-demo"]
+ }
+ }
+}
diff --git a/apps/browser-demo/scripts/buffer-shim.mjs b/apps/browser-demo/scripts/buffer-shim.mjs
new file mode 100644
index 0000000..ada14ad
--- /dev/null
+++ b/apps/browser-demo/scripts/buffer-shim.mjs
@@ -0,0 +1,13 @@
+/**
+ * Minimal Buffer shim for the browser test bundle.
+ * Only provides Buffer.byteLength which is used by @enclave-vm/ast's size-check.
+ */
+const encoder = new TextEncoder();
+
+if (typeof globalThis.Buffer === 'undefined') {
+ globalThis.Buffer = {
+ byteLength(str) {
+ return encoder.encode(String(str)).byteLength;
+ },
+ };
+}
diff --git a/apps/browser-demo/scripts/build-enclave-bundle.mjs b/apps/browser-demo/scripts/build-enclave-bundle.mjs
new file mode 100644
index 0000000..16ed3fe
--- /dev/null
+++ b/apps/browser-demo/scripts/build-enclave-bundle.mjs
@@ -0,0 +1,30 @@
+/**
+ * Build a self-contained ESM bundle of @enclave-vm/browser for the demo app.
+ *
+ * Inlines all dependencies (ast, acorn, astring, zod) into a single ESM file.
+ * Output goes to vendor/ so Vite can import it as a normal module.
+ */
+
+import { build } from 'esbuild';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const root = path.resolve(__dirname, '../../..');
+
+await build({
+ entryPoints: [path.resolve(root, 'libs/browser/src/index.ts')],
+ bundle: true,
+ format: 'esm',
+ outfile: path.resolve(__dirname, '../vendor/enclave-browser-bundle.mjs'),
+ platform: 'browser',
+ target: 'es2022',
+ external: [],
+ alias: {
+ '@enclave-vm/ast': path.resolve(root, 'libs/ast/src/index.ts'),
+ },
+ inject: [path.resolve(__dirname, 'buffer-shim.mjs')],
+ sourcemap: false,
+ minify: false,
+ logLevel: 'info',
+});
diff --git a/apps/browser-demo/src/App.css b/apps/browser-demo/src/App.css
new file mode 100644
index 0000000..9d3f820
--- /dev/null
+++ b/apps/browser-demo/src/App.css
@@ -0,0 +1,488 @@
+/* ── Reset & Base ── */
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+:root {
+ --bg: #1a1a2e;
+ --bg-panel: #16213e;
+ --bg-input: #0f172a;
+ --bg-hover: #1e3a5f;
+ --text: #eee;
+ --text-dim: #94a3b8;
+ --accent: #4fc3f7;
+ --accent-hover: #81d4fa;
+ --success: #81c784;
+ --warning: #ffb74d;
+ --error: #ef5350;
+ --border: #334155;
+ --radius: 6px;
+}
+
+html,
+body {
+ height: 100%;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ background: var(--bg);
+ color: var(--text);
+ line-height: 1.5;
+}
+
+#root {
+ height: 100%;
+}
+
+/* ── App Layout ── */
+.app {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ max-width: 1440px;
+ margin: 0 auto;
+ padding: 16px 24px;
+}
+
+.app-header {
+ text-align: center;
+ padding-bottom: 16px;
+ border-bottom: 1px solid var(--border);
+ margin-bottom: 16px;
+}
+
+.app-header h1 {
+ font-size: 1.5rem;
+ color: var(--accent);
+ font-weight: 600;
+}
+
+.app-subtitle {
+ color: var(--text-dim);
+ font-size: 0.85rem;
+}
+
+.app-main {
+ display: grid;
+ grid-template-columns: 240px 1fr 360px;
+ gap: 16px;
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+}
+
+/* ── Panels ── */
+.left-panel,
+.right-panel {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ overflow-y: auto;
+}
+
+.center-panel {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ min-height: 0;
+}
+
+/* ── Section Label ── */
+.section-label {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ font-size: 0.75rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--text-dim);
+ margin-bottom: 6px;
+ font-weight: 600;
+}
+
+/* ── Security Picker ── */
+.security-picker {
+ background: var(--bg-panel);
+ padding: 12px;
+ border-radius: var(--radius);
+ border: 1px solid var(--border);
+}
+
+.segmented-buttons {
+ display: flex;
+ gap: 0;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ overflow: hidden;
+}
+
+.seg-btn {
+ flex: 1;
+ padding: 6px 2px;
+ background: transparent;
+ color: var(--text-dim);
+ border: none;
+ cursor: pointer;
+ font-size: 0.7rem;
+ font-weight: 500;
+ transition:
+ background-color 0.15s,
+ color 0.15s;
+}
+
+.seg-btn:not(:last-child) {
+ border-right: 1px solid var(--border);
+}
+
+.seg-btn:hover:not(:disabled) {
+ background: var(--bg-hover);
+}
+
+.seg-btn.active {
+ background: var(--accent);
+ color: #000;
+ font-weight: 700;
+}
+
+.seg-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.security-desc {
+ display: block;
+ font-size: 0.7rem;
+ color: var(--text-dim);
+ margin-top: 6px;
+}
+
+/* ── Tool Config ── */
+.tool-config {
+ background: var(--bg-panel);
+ padding: 12px;
+ border-radius: var(--radius);
+ border: 1px solid var(--border);
+}
+
+.toggle-label {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 0.75rem;
+ color: var(--text);
+ text-transform: none;
+ letter-spacing: normal;
+ font-weight: normal;
+ cursor: pointer;
+}
+
+.toggle-label input[type='checkbox'] {
+ accent-color: var(--accent);
+}
+
+.tool-list {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ margin-top: 8px;
+}
+
+.tool-item {
+ display: flex;
+ flex-direction: column;
+ padding: 6px 8px;
+ background: var(--bg-input);
+ border-radius: 4px;
+ font-size: 0.75rem;
+}
+
+.tool-item code {
+ color: var(--accent);
+ font-weight: 500;
+}
+
+.tool-desc {
+ color: var(--text-dim);
+ font-size: 0.7rem;
+}
+
+/* ── Examples ── */
+.examples {
+ background: var(--bg-panel);
+ padding: 12px;
+ border-radius: var(--radius);
+ border: 1px solid var(--border);
+}
+
+.example-group {
+ margin-bottom: 8px;
+}
+
+.example-category {
+ display: block;
+ font-size: 0.65rem;
+ text-transform: uppercase;
+ color: var(--text-dim);
+ margin-bottom: 4px;
+ letter-spacing: 0.05em;
+}
+
+.example-btn {
+ display: inline-block;
+ padding: 3px 8px;
+ margin: 2px;
+ background: var(--bg-input);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 0.72rem;
+ transition:
+ border-color 0.15s,
+ color 0.15s;
+}
+
+.example-btn:hover:not(:disabled) {
+ border-color: var(--accent);
+ color: var(--accent);
+}
+
+.example-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+/* ── Code Editor ── */
+.code-editor {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+
+.code-textarea {
+ flex: 1;
+ min-height: 200px;
+ padding: 12px;
+ background: var(--bg-input);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', Menlo, Consolas, monospace;
+ font-size: 0.85rem;
+ line-height: 1.6;
+ resize: none;
+ outline: none;
+ tab-size: 2;
+}
+
+.code-textarea:focus {
+ border-color: var(--accent);
+}
+
+.code-textarea:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: -2px;
+}
+
+.code-textarea:disabled {
+ opacity: 0.6;
+}
+
+/* ── Execution Controls ── */
+.execution-controls {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.run-btn {
+ padding: 8px 32px;
+ background: var(--accent);
+ color: #000;
+ border: none;
+ border-radius: var(--radius);
+ font-weight: 700;
+ font-size: 0.9rem;
+ cursor: pointer;
+ transition: background-color 0.15s;
+}
+
+.run-btn:hover:not(:disabled) {
+ background: var(--accent-hover);
+}
+
+.run-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.status-indicator {
+ font-size: 0.75rem;
+ font-weight: 500;
+}
+
+.status-ready {
+ color: var(--success);
+}
+
+.status-loading {
+ color: var(--warning);
+}
+
+.status-initializing {
+ color: var(--warning);
+}
+
+.status-error {
+ color: var(--error);
+}
+
+/* ── Result Display ── */
+.result-display {
+ background: var(--bg-panel);
+ padding: 12px;
+ border-radius: var(--radius);
+ border: 1px solid var(--border);
+}
+
+.result-empty,
+.console-empty,
+.stats-empty {
+ color: var(--text-dim);
+ font-size: 0.8rem;
+ font-style: italic;
+}
+
+.result-content {
+ padding: 8px;
+ border-radius: 4px;
+}
+
+.result-success {
+ border-left: 3px solid var(--success);
+ background: rgba(129, 199, 132, 0.08);
+}
+
+.result-error {
+ border-left: 3px solid var(--error);
+ background: rgba(239, 83, 80, 0.08);
+}
+
+.result-error-name {
+ color: var(--error);
+ font-weight: 700;
+ font-size: 0.85rem;
+ margin-bottom: 4px;
+}
+
+.result-pre {
+ font-family: 'SF Mono', 'Fira Code', Menlo, Consolas, monospace;
+ font-size: 0.8rem;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ margin: 0;
+}
+
+/* ── Console Output ── */
+.console-output {
+ background: var(--bg-panel);
+ padding: 12px;
+ border-radius: var(--radius);
+ border: 1px solid var(--border);
+}
+
+.console-entries {
+ max-height: 200px;
+ overflow-y: auto;
+}
+
+.console-entry {
+ display: flex;
+ gap: 8px;
+ padding: 3px 6px;
+ font-family: 'SF Mono', 'Fira Code', Menlo, Consolas, monospace;
+ font-size: 0.75rem;
+ border-bottom: 1px solid rgba(51, 65, 85, 0.4);
+}
+
+.console-level {
+ font-weight: 600;
+ flex-shrink: 0;
+}
+
+.console-msg {
+ overflow-wrap: anywhere;
+}
+
+.console-log .console-level {
+ color: var(--text-dim);
+}
+
+.console-info .console-level {
+ color: var(--accent);
+}
+
+.console-warn .console-level {
+ color: var(--warning);
+}
+
+.console-error .console-level {
+ color: var(--error);
+}
+
+/* ── Stats Panel ── */
+.stats-panel {
+ background: var(--bg-panel);
+ padding: 12px;
+ border-radius: var(--radius);
+ border: 1px solid var(--border);
+}
+
+.stats-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr 1fr;
+ gap: 8px;
+}
+
+.stat-item {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 8px;
+ background: var(--bg-input);
+ border-radius: 4px;
+}
+
+.stat-label {
+ font-size: 0.65rem;
+ text-transform: uppercase;
+ color: var(--text-dim);
+ letter-spacing: 0.03em;
+}
+
+.stat-value {
+ font-size: 1.1rem;
+ font-weight: 700;
+ color: var(--accent);
+ font-family: 'SF Mono', Menlo, Consolas, monospace;
+}
+
+/* ── Responsive ── */
+@media (max-width: 960px) {
+ .app-main {
+ grid-template-columns: 1fr;
+ overflow-y: auto;
+ }
+
+ .left-panel,
+ .right-panel {
+ overflow-y: visible;
+ }
+
+ .code-textarea {
+ min-height: 150px;
+ }
+}
diff --git a/apps/browser-demo/src/App.tsx b/apps/browser-demo/src/App.tsx
new file mode 100644
index 0000000..e25beff
--- /dev/null
+++ b/apps/browser-demo/src/App.tsx
@@ -0,0 +1,124 @@
+import { useState, useCallback, useMemo } from 'react';
+import { useEnclave } from './hooks/use-enclave';
+import { useConsoleCapture } from './hooks/use-console-capture';
+import { createToolHandler, mockTools } from './data/mock-tools';
+import { CodeEditor } from './components/CodeEditor';
+import { SecurityLevelPicker } from './components/SecurityLevelPicker';
+import { ExampleSnippets } from './components/ExampleSnippets';
+import { ToolHandlerConfig } from './components/ToolHandlerConfig';
+import { ExecutionControls } from './components/ExecutionControls';
+import { ConsoleOutput } from './components/ConsoleOutput';
+import { ResultDisplay } from './components/ResultDisplay';
+import { StatsPanel } from './components/StatsPanel';
+import type { SecurityLevel, ConsoleEntry, ExecutionStats, DemoExecutionResult } from './types';
+
+export function App() {
+ const [code, setCode] = useState('return 2 + 2;');
+ const [securityLevel, setSecurityLevel] = useState('STANDARD');
+ const [toolsEnabled, setToolsEnabled] = useState(false);
+ const [running, setRunning] = useState(false);
+ const [result, setResult] = useState(null);
+ const [consoleEntries, setConsoleEntries] = useState([]);
+ const [stats, setStats] = useState(null);
+
+ const toolHandler = useMemo(() => (toolsEnabled ? createToolHandler(mockTools) : undefined), [toolsEnabled]);
+
+ const {
+ ready,
+ loading: enclaveLoading,
+ error: enclaveError,
+ run,
+ } = useEnclave({
+ securityLevel,
+ toolHandler,
+ });
+
+ const { startCapture, stopCapture } = useConsoleCapture();
+
+ const resetExecutionState = useCallback(() => {
+ setResult(null);
+ setConsoleEntries([]);
+ setStats(null);
+ }, []);
+
+ const handleRun = useCallback(async () => {
+ if (!ready || running) return;
+ setRunning(true);
+ resetExecutionState();
+
+ try {
+ startCapture();
+ const execResult = await run(code);
+ setResult(execResult);
+ if (execResult.stats) {
+ setStats({
+ duration: execResult.stats.duration,
+ toolCallCount: execResult.stats.toolCallCount,
+ iterationCount: execResult.stats.iterationCount,
+ });
+ }
+ } catch (err) {
+ const zeroStats = { duration: 0, toolCallCount: 0, iterationCount: 0 };
+ setResult({
+ success: false,
+ error: {
+ name: err instanceof Error ? err.name : 'AppError',
+ message: err instanceof Error ? err.message : String(err),
+ },
+ stats: zeroStats,
+ });
+ setStats(zeroStats);
+ } finally {
+ let captured: ConsoleEntry[] = [];
+ try {
+ captured = stopCapture();
+ } catch (_err) {
+ /* stopCapture failure should not block UI reset */
+ }
+ setConsoleEntries(captured);
+ setRunning(false);
+ }
+ }, [ready, running, code, run, startCapture, stopCapture, resetExecutionState]);
+
+ const handleExampleSelect = useCallback(
+ (exampleCode: string) => {
+ setCode(exampleCode);
+ resetExecutionState();
+ },
+ [resetExecutionState],
+ );
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/browser-demo/src/components/CodeEditor.tsx b/apps/browser-demo/src/components/CodeEditor.tsx
new file mode 100644
index 0000000..103f46a
--- /dev/null
+++ b/apps/browser-demo/src/components/CodeEditor.tsx
@@ -0,0 +1,21 @@
+interface CodeEditorProps {
+ value: string;
+ onChange: (value: string) => void;
+ disabled?: boolean;
+}
+
+export function CodeEditor({ value, onChange, disabled }: CodeEditorProps) {
+ return (
+
+
+
+ );
+}
diff --git a/apps/browser-demo/src/components/ConsoleOutput.tsx b/apps/browser-demo/src/components/ConsoleOutput.tsx
new file mode 100644
index 0000000..0505009
--- /dev/null
+++ b/apps/browser-demo/src/components/ConsoleOutput.tsx
@@ -0,0 +1,38 @@
+import type { ConsoleEntry } from '../types';
+
+interface ConsoleOutputProps {
+ entries: ConsoleEntry[];
+}
+
+const LEVEL_CLASS: Record = {
+ log: 'console-log',
+ info: 'console-info',
+ warn: 'console-warn',
+ error: 'console-error',
+};
+
+function formatArg(arg: unknown): string {
+ if (typeof arg === 'string') return arg;
+ try {
+ return JSON.stringify(arg, null, 2);
+ } catch {
+ return String(arg);
+ }
+}
+
+export function ConsoleOutput({ entries }: ConsoleOutputProps) {
+ return (
+
+
+
+ {entries.length === 0 &&
No console output
}
+ {entries.map((entry) => (
+
+ [{entry.level.toUpperCase()}]
+ {entry.args.map(formatArg).join(' ')}
+
+ ))}
+
+
+ );
+}
diff --git a/apps/browser-demo/src/components/ExampleSnippets.tsx b/apps/browser-demo/src/components/ExampleSnippets.tsx
new file mode 100644
index 0000000..0fe89c9
--- /dev/null
+++ b/apps/browser-demo/src/components/ExampleSnippets.tsx
@@ -0,0 +1,36 @@
+import { examples } from '../data/examples';
+
+interface ExampleSnippetsProps {
+ onSelect: (code: string) => void;
+ disabled?: boolean;
+}
+
+const CATEGORIES = ['Basic', 'Async', 'Tools', 'Security'] as const;
+
+export function ExampleSnippets({ onSelect, disabled }: ExampleSnippetsProps) {
+ return (
+
+
+ {CATEGORIES.map((category) => {
+ const items = examples.filter((e) => e.category === category);
+ if (items.length === 0) return null;
+ return (
+
+ {category}
+ {items.map((ex) => (
+
+ ))}
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/browser-demo/src/components/ExecutionControls.tsx b/apps/browser-demo/src/components/ExecutionControls.tsx
new file mode 100644
index 0000000..b600c16
--- /dev/null
+++ b/apps/browser-demo/src/components/ExecutionControls.tsx
@@ -0,0 +1,30 @@
+interface ExecutionControlsProps {
+ onRun: () => void;
+ running: boolean;
+ ready: boolean;
+ enclaveLoading: boolean;
+ enclaveError: string | null;
+}
+
+export function ExecutionControls({ onRun, running, ready, enclaveLoading, enclaveError }: ExecutionControlsProps) {
+ const canRun = ready && !running;
+
+ const status = enclaveLoading ? 'loading' : enclaveError ? 'error' : ready ? 'ready' : 'initializing';
+
+ const statusText = enclaveLoading
+ ? 'Loading enclave...'
+ : enclaveError
+ ? `Error: ${enclaveError}`
+ : ready
+ ? 'Ready'
+ : 'Initializing...';
+
+ return (
+
+
+ {statusText}
+
+ );
+}
diff --git a/apps/browser-demo/src/components/ResultDisplay.tsx b/apps/browser-demo/src/components/ResultDisplay.tsx
new file mode 100644
index 0000000..4cea0c8
--- /dev/null
+++ b/apps/browser-demo/src/components/ResultDisplay.tsx
@@ -0,0 +1,45 @@
+import type { DemoExecutionResult } from '../types';
+
+interface ResultDisplayProps {
+ result: DemoExecutionResult | null;
+}
+
+function formatValue(value: unknown): string {
+ if (value === undefined) return 'undefined';
+ if (value === null) return 'null';
+ if (typeof value === 'string') return value;
+ try {
+ return JSON.stringify(value, null, 2);
+ } catch {
+ return String(value);
+ }
+}
+
+export function ResultDisplay({ result }: ResultDisplayProps) {
+ if (!result) {
+ return (
+
+
+
Run code to see results
+
+ );
+ }
+
+ const isError = !result.success;
+
+ return (
+
+
+
+ {isError ? (
+ <>
+
{result.error?.name ?? 'Error'}
+
{result.error?.message ?? 'Unknown error'}
+ >
+ ) : (
+
{formatValue(result.value)}
+ )}
+
+
+ );
+}
diff --git a/apps/browser-demo/src/components/SecurityLevelPicker.tsx b/apps/browser-demo/src/components/SecurityLevelPicker.tsx
new file mode 100644
index 0000000..e141281
--- /dev/null
+++ b/apps/browser-demo/src/components/SecurityLevelPicker.tsx
@@ -0,0 +1,38 @@
+import type { SecurityLevel } from '../types';
+
+const LEVELS: SecurityLevel[] = ['STRICT', 'SECURE', 'STANDARD', 'PERMISSIVE'];
+
+const DESCRIPTIONS: Record = {
+ STRICT: 'Max restrictions, 1K iterations, 5s timeout',
+ SECURE: 'Balanced security, 5K iterations, 15s timeout',
+ STANDARD: 'Default, 10K iterations, unbounded loops',
+ PERMISSIVE: 'Minimal restrictions, 100K iterations',
+};
+
+interface SecurityLevelPickerProps {
+ value: SecurityLevel;
+ onChange: (level: SecurityLevel) => void;
+ disabled?: boolean;
+}
+
+export function SecurityLevelPicker({ value, onChange, disabled }: SecurityLevelPickerProps) {
+ return (
+
+
+
+ {LEVELS.map((level) => (
+
+ ))}
+
+
{DESCRIPTIONS[value]}
+
+ );
+}
diff --git a/apps/browser-demo/src/components/StatsPanel.tsx b/apps/browser-demo/src/components/StatsPanel.tsx
new file mode 100644
index 0000000..652389d
--- /dev/null
+++ b/apps/browser-demo/src/components/StatsPanel.tsx
@@ -0,0 +1,31 @@
+import type { ExecutionStats } from '../types';
+
+interface StatsPanelProps {
+ stats: ExecutionStats | null;
+}
+
+export function StatsPanel({ stats }: StatsPanelProps) {
+ return (
+
+
+ {!stats ? (
+
No execution stats yet
+ ) : (
+
+
+ Duration
+ {stats.duration}ms
+
+
+ Tool Calls
+ {stats.toolCallCount}
+
+
+ Iterations
+ {stats.iterationCount}
+
+
+ )}
+
+ );
+}
diff --git a/apps/browser-demo/src/components/ToolHandlerConfig.tsx b/apps/browser-demo/src/components/ToolHandlerConfig.tsx
new file mode 100644
index 0000000..97b5805
--- /dev/null
+++ b/apps/browser-demo/src/components/ToolHandlerConfig.tsx
@@ -0,0 +1,31 @@
+import { mockTools } from '../data/mock-tools';
+
+interface ToolHandlerConfigProps {
+ enabled: boolean;
+ onToggle: (enabled: boolean) => void;
+ disabled?: boolean;
+}
+
+export function ToolHandlerConfig({ enabled, onToggle, disabled }: ToolHandlerConfigProps) {
+ return (
+
+
+ Tool Handler
+
+
+ {enabled && (
+
+ {mockTools.map((tool) => (
+
+ {tool.name}
+ {tool.description}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/apps/browser-demo/src/data/examples.ts b/apps/browser-demo/src/data/examples.ts
new file mode 100644
index 0000000..ad3c01e
--- /dev/null
+++ b/apps/browser-demo/src/data/examples.ts
@@ -0,0 +1,64 @@
+import type { ExampleSnippet } from '../types';
+
+export const examples: ExampleSnippet[] = [
+ {
+ label: 'Arithmetic',
+ category: 'Basic',
+ code: 'return 2 + 2;',
+ description: 'Simple addition — returns 4',
+ },
+ {
+ label: 'Strings',
+ category: 'Basic',
+ code: 'return "hello".toUpperCase();',
+ description: 'String transformation',
+ },
+ {
+ label: 'Loop',
+ category: 'Basic',
+ code: 'let sum = 0;\nfor (let i = 0; i < 1000; i++) sum += i;\nreturn sum;',
+ description: 'Sum 0..999 in a loop — returns 499500',
+ },
+ {
+ label: 'Console',
+ category: 'Basic',
+ code: 'console.log("info message");\nconsole.warn("warning message");\nconsole.error("error message");\nreturn "check console";',
+ description: 'Logs to the sandboxed console',
+ },
+ {
+ label: 'Delay',
+ category: 'Async',
+ code: 'await new Promise(r => setTimeout(r, 100));\nreturn "done after 100ms";',
+ description: 'Async/await with setTimeout',
+ },
+ {
+ label: 'Single Tool Call',
+ category: 'Tools',
+ code: 'const result = await callTool("math:add", { a: 1, b: 2 });\nreturn result;',
+ description: 'Calls the math:add mock tool',
+ },
+ {
+ label: 'Chain Tools',
+ category: 'Tools',
+ code: 'const sum = await callTool("math:add", { a: 10, b: 20 });\nconst reversed = await callTool("string:reverse", { text: String(sum) });\nreturn { sum, reversed };',
+ description: 'Chains math:add then string:reverse',
+ },
+ {
+ label: 'eval() Blocked',
+ category: 'Security',
+ code: 'eval("1+1");',
+ description: 'eval is blocked by AST validation',
+ },
+ {
+ label: 'Prototype Escape',
+ category: 'Security',
+ code: 'const obj = {};\nreturn obj.constructor;',
+ description: 'Constructor access is blocked',
+ },
+ {
+ label: 'Infinite Loop',
+ category: 'Security',
+ code: 'while (true) {\n // runs forever\n}',
+ description: 'Hits the iteration limit',
+ },
+];
diff --git a/apps/browser-demo/src/data/mock-tools.ts b/apps/browser-demo/src/data/mock-tools.ts
new file mode 100644
index 0000000..9003139
--- /dev/null
+++ b/apps/browser-demo/src/data/mock-tools.ts
@@ -0,0 +1,48 @@
+import type { MockTool } from '../types';
+
+export const mockTools: MockTool[] = [
+ {
+ name: 'math:add',
+ description: 'Adds two numbers (a + b)',
+ handler: async (args) => {
+ const a = Number(args.a ?? 0);
+ const b = Number(args.b ?? 0);
+ return a + b;
+ },
+ },
+ {
+ name: 'string:reverse',
+ description: 'Reverses a string',
+ handler: async (args) => {
+ const text = String(args.text ?? '');
+ return text.split('').reverse().join('');
+ },
+ },
+ {
+ name: 'data:fetch',
+ description: 'Returns mock data for a given key',
+ handler: async (args) => {
+ const key = String(args.key ?? 'default');
+ const data: Record = {
+ users: [
+ { id: 1, name: 'Alice' },
+ { id: 2, name: 'Bob' },
+ ],
+ config: { theme: 'dark', version: '1.0' },
+ default: { message: 'No data found' },
+ };
+ return data[key] ?? data['default'];
+ },
+ },
+];
+
+export function createToolHandler(tools: MockTool[]) {
+ const toolMap = new Map(tools.map((t) => [t.name, t]));
+ return async (toolName: string, args: Record) => {
+ const tool = toolMap.get(toolName);
+ if (!tool) {
+ throw new Error(`Unknown tool: ${toolName}`);
+ }
+ return tool.handler(args);
+ };
+}
diff --git a/apps/browser-demo/src/enclave-loader.ts b/apps/browser-demo/src/enclave-loader.ts
new file mode 100644
index 0000000..7445d3c
--- /dev/null
+++ b/apps/browser-demo/src/enclave-loader.ts
@@ -0,0 +1,15 @@
+/**
+ * Lazy loader for the pre-built @enclave-vm/browser bundle.
+ * The bundle is built by esbuild into vendor/enclave-browser-bundle.mjs
+ * and imported as a normal module by Vite.
+ */
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+let cached: Promise | null = null;
+
+export async function loadEnclaveModule() {
+ if (!cached) {
+ cached = import('../vendor/enclave-browser-bundle.mjs');
+ }
+ return cached;
+}
diff --git a/apps/browser-demo/src/hooks/use-console-capture.ts b/apps/browser-demo/src/hooks/use-console-capture.ts
new file mode 100644
index 0000000..c6c1f6b
--- /dev/null
+++ b/apps/browser-demo/src/hooks/use-console-capture.ts
@@ -0,0 +1,59 @@
+import { useRef, useCallback } from 'react';
+import type { ConsoleEntry, ConsoleLevel } from '../types';
+
+const ENCLAVE_PREFIX = '[Enclave]';
+
+let nextId = 0;
+
+export function useConsoleCapture() {
+ const entriesRef = useRef([]);
+ const originalsRef = useRef void> | null>(null);
+
+ const startCapture = useCallback(() => {
+ // If already capturing, restore originals first to avoid losing real console references
+ if (originalsRef.current) {
+ const levels: ConsoleLevel[] = ['log', 'info', 'warn', 'error'];
+ for (const level of levels) {
+ console[level] = originalsRef.current[level];
+ }
+ originalsRef.current = null;
+ }
+
+ entriesRef.current = [];
+
+ const levels: ConsoleLevel[] = ['log', 'info', 'warn', 'error'];
+ const originals = {} as Record void>;
+
+ for (const level of levels) {
+ originals[level] = console[level].bind(console);
+ console[level] = (...args: unknown[]) => {
+ // Always pass through to real console
+ originals[level](...args);
+ // Capture only [Enclave] prefixed messages
+ if (args.length > 0 && args[0] === ENCLAVE_PREFIX) {
+ entriesRef.current.push({
+ id: nextId++,
+ level,
+ args: args.slice(1),
+ timestamp: Date.now(),
+ });
+ }
+ };
+ }
+
+ originalsRef.current = originals;
+ }, []);
+
+ const stopCapture = useCallback((): ConsoleEntry[] => {
+ if (originalsRef.current) {
+ const levels: ConsoleLevel[] = ['log', 'info', 'warn', 'error'];
+ for (const level of levels) {
+ console[level] = originalsRef.current[level];
+ }
+ originalsRef.current = null;
+ }
+ return [...entriesRef.current];
+ }, []);
+
+ return { startCapture, stopCapture };
+}
diff --git a/apps/browser-demo/src/hooks/use-enclave.ts b/apps/browser-demo/src/hooks/use-enclave.ts
new file mode 100644
index 0000000..f76c72f
--- /dev/null
+++ b/apps/browser-demo/src/hooks/use-enclave.ts
@@ -0,0 +1,78 @@
+import { useState, useEffect, useRef, useCallback } from 'react';
+import { loadEnclaveModule } from '../enclave-loader';
+import type { SecurityLevel } from '../types';
+
+interface UseEnclaveOptions {
+ securityLevel: SecurityLevel;
+ toolHandler?: (toolName: string, args: Record) => Promise;
+}
+
+export function useEnclave({ securityLevel, toolHandler }: UseEnclaveOptions) {
+ const [ready, setReady] = useState(false);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const enclaveRef = useRef(null);
+ const toolHandlerRef = useRef(toolHandler);
+ toolHandlerRef.current = toolHandler;
+
+ useEffect(() => {
+ let disposed = false;
+
+ async function init() {
+ setLoading(true);
+ setReady(false);
+ setError(null);
+
+ // Dispose previous instance
+ if (enclaveRef.current) {
+ try {
+ enclaveRef.current.dispose();
+ } catch {
+ /* ignore */
+ }
+ enclaveRef.current = null;
+ }
+
+ try {
+ const mod = await loadEnclaveModule();
+ if (disposed) return;
+
+ enclaveRef.current = new mod.BrowserEnclave({
+ securityLevel,
+ toolHandler: toolHandlerRef.current,
+ });
+ setReady(true);
+ } catch (err) {
+ if (!disposed) {
+ setError(err instanceof Error ? err.message : String(err));
+ }
+ } finally {
+ if (!disposed) setLoading(false);
+ }
+ }
+
+ init();
+
+ return () => {
+ disposed = true;
+ if (enclaveRef.current) {
+ try {
+ enclaveRef.current.dispose();
+ } catch {
+ /* ignore */
+ }
+ enclaveRef.current = null;
+ }
+ };
+ }, [securityLevel, toolHandler]);
+
+ const run = useCallback(async (code: string) => {
+ if (!enclaveRef.current) {
+ throw new Error('Enclave not ready');
+ }
+ return enclaveRef.current.run(code);
+ }, []);
+
+ return { ready, loading, error, run };
+}
diff --git a/apps/browser-demo/src/main.tsx b/apps/browser-demo/src/main.tsx
new file mode 100644
index 0000000..43114f1
--- /dev/null
+++ b/apps/browser-demo/src/main.tsx
@@ -0,0 +1,11 @@
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+import { App } from './App';
+import './App.css';
+
+// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+createRoot(document.getElementById('root')!).render(
+
+
+ ,
+);
diff --git a/apps/browser-demo/src/types.ts b/apps/browser-demo/src/types.ts
new file mode 100644
index 0000000..21764c3
--- /dev/null
+++ b/apps/browser-demo/src/types.ts
@@ -0,0 +1,36 @@
+export type ConsoleLevel = 'log' | 'info' | 'warn' | 'error';
+
+export interface ConsoleEntry {
+ id: number;
+ level: ConsoleLevel;
+ args: unknown[];
+ timestamp: number;
+}
+
+export type SecurityLevel = 'STRICT' | 'SECURE' | 'STANDARD' | 'PERMISSIVE';
+
+export interface ExampleSnippet {
+ label: string;
+ category: 'Basic' | 'Async' | 'Tools' | 'Security';
+ code: string;
+ description: string;
+}
+
+export interface MockTool {
+ name: string;
+ description: string;
+ handler: (args: Record) => Promise;
+}
+
+export interface ExecutionStats {
+ duration: number;
+ toolCallCount: number;
+ iterationCount: number;
+}
+
+export interface DemoExecutionResult {
+ success: boolean;
+ value?: unknown;
+ error?: { name: string; message: string; code?: string };
+ stats?: ExecutionStats;
+}
diff --git a/apps/browser-demo/tsconfig.app.json b/apps/browser-demo/tsconfig.app.json
new file mode 100644
index 0000000..42c9fa9
--- /dev/null
+++ b/apps/browser-demo/tsconfig.app.json
@@ -0,0 +1,12 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "../../dist/out-tsc",
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "jsx": "react-jsx",
+ "types": ["vite/client"]
+ },
+ "include": ["src/**/*.ts", "src/**/*.tsx"],
+ "exclude": ["**/*.spec.ts", "**/*.test.ts"]
+}
diff --git a/apps/browser-demo/tsconfig.json b/apps/browser-demo/tsconfig.json
new file mode 100644
index 0000000..478b12f
--- /dev/null
+++ b/apps/browser-demo/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "files": [],
+ "include": [],
+ "references": [{ "path": "./tsconfig.app.json" }],
+ "compilerOptions": {
+ "esModuleInterop": true,
+ "jsx": "react-jsx"
+ }
+}
diff --git a/apps/browser-demo/vite.config.ts b/apps/browser-demo/vite.config.ts
new file mode 100644
index 0000000..d58a049
--- /dev/null
+++ b/apps/browser-demo/vite.config.ts
@@ -0,0 +1,15 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+
+export default defineConfig({
+ root: __dirname,
+ plugins: [react()],
+ server: {
+ port: 4200,
+ open: true,
+ },
+ build: {
+ outDir: '../../dist/apps/browser-demo',
+ emptyOutDir: true,
+ },
+});
diff --git a/docs/docs.json b/docs/docs.json
index ac3a985..8518a22 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -100,6 +100,16 @@
"enclave/core-libraries/enclave-vm/configuration"
]
},
+ {
+ "group": "@enclave-vm/browser",
+ "icon": "browser",
+ "pages": [
+ "enclave/core-libraries/enclave-browser/overview",
+ "enclave/core-libraries/enclave-browser/security-architecture",
+ "enclave/core-libraries/enclave-browser/configuration",
+ "enclave/core-libraries/enclave-browser/react-integration"
+ ]
+ },
{
"group": "@enclave-vm/ast",
"icon": "shield-check",
diff --git a/docs/enclave/core-libraries/enclave-browser/configuration.mdx b/docs/enclave/core-libraries/enclave-browser/configuration.mdx
new file mode 100644
index 0000000..10b2b3b
--- /dev/null
+++ b/docs/enclave/core-libraries/enclave-browser/configuration.mdx
@@ -0,0 +1,183 @@
+---
+title: 'Configuration'
+description: 'Complete configuration reference for @enclave-vm/browser'
+---
+
+This page documents all configuration options for `@enclave-vm/browser`.
+
+## Quick Example
+
+```ts
+import { BrowserEnclave } from '@enclave-vm/browser';
+
+const enclave = new BrowserEnclave({
+ // Security level preset
+ securityLevel: 'SECURE',
+
+ // Core limits
+ timeout: 10000,
+ maxToolCalls: 50,
+ maxIterations: 5000,
+ memoryLimit: 2 * 1024 * 1024, // 2MB
+
+ // Tool handler
+ toolHandler: async (name, args) => {
+ return executeToolSafely(name, args);
+ },
+
+ // Additional options
+ globals: { context: { userId: 'user-123' } },
+ validate: true,
+ transform: true,
+});
+```
+
+## Core Options
+
+| Option | Type | Default | Description |
+|--------|------|---------|-------------|
+| `securityLevel` | string | `'STANDARD'` | Preset: `STRICT`, `SECURE`, `STANDARD`, `PERMISSIVE` |
+| `preset` | string | `'agentscript'` | AST preset: `agentscript`, `strict`, `secure`, `standard`, `permissive` |
+| `timeout` | number | varies | Maximum execution time in milliseconds |
+| `maxToolCalls` | number | varies | Maximum tool calls per execution |
+| `maxIterations` | number | varies | Maximum loop iterations (per loop) |
+| `memoryLimit` | number | 1048576 | Memory limit in bytes (soft tracking) |
+| `toolHandler` | function | - | Async function that handles `callTool()` invocations |
+| `globals` | object | - | Additional globals available in the sandbox (JSON-serializable only) |
+| `validate` | boolean | `true` | Validate code with ast-guard before execution |
+| `transform` | boolean | `true` | Transform code before execution (AgentScript wrappers) |
+| `allowFunctionsInGlobals` | boolean | varies | Whether to allow functions in custom globals |
+
+## Console Limits
+
+| Option | Type | Default | Description |
+|--------|------|---------|-------------|
+| `maxConsoleOutputBytes` | number | varies | Maximum total console output in bytes |
+| `maxConsoleCalls` | number | varies | Maximum number of console calls |
+
+## Security Level Comparison
+
+All "varies" defaults above depend on the selected security level:
+
+| Setting | STRICT | SECURE | STANDARD | PERMISSIVE |
+|---------|--------|--------|----------|------------|
+| `timeout` | 5000 | 15000 | 30000 | 60000 |
+| `maxIterations` | 1000 | 5000 | 10000 | 100000 |
+| `maxToolCalls` | 10 | 50 | 100 | 1000 |
+| `maxConsoleOutputBytes` | 64KB | 256KB | 1MB | 10MB |
+| `maxConsoleCalls` | 100 | 500 | 1000 | 10000 |
+| `maxSanitizeDepth` | 5 | 10 | 20 | 50 |
+| `maxSanitizeProperties` | 50 | 100 | 500 | 1000 |
+| `sanitizeStackTraces` | true | true | false | false |
+| `blockTimingAPIs` | true | false | false | false |
+| `allowUnboundedLoops` | false | false | true | true |
+| `unicodeSecurityCheck` | true | true | false | false |
+| `allowFunctionsInGlobals` | false | false | false | true |
+| `secureProxy.blockConstructor` | true | true | true | false |
+| `secureProxy.blockPrototype` | true | true | true | true |
+| `secureProxy.blockLegacyAccessors` | true | true | true | true |
+| `secureProxy.proxyMaxDepth` | 5 | 10 | 15 | 20 |
+| `secureProxy.throwOnBlocked` | true | true | true | false |
+
+## Secure Proxy Configuration
+
+Override proxy behavior for the current security level:
+
+| Option | Type | Default | Description |
+|--------|------|---------|-------------|
+| `secureProxyConfig.blockConstructor` | boolean | varies | Block access to `.constructor` |
+| `secureProxyConfig.blockPrototype` | boolean | varies | Block access to `.__proto__` and `.prototype` |
+| `secureProxyConfig.blockLegacyAccessors` | boolean | varies | Block `__defineGetter__`, `__defineSetter__`, etc. |
+| `secureProxyConfig.proxyMaxDepth` | number | varies | Maximum nesting depth for proxy wrapping |
+| `secureProxyConfig.throwOnBlocked` | boolean | varies | Throw error vs return `undefined` on blocked access |
+
+```ts
+const enclave = new BrowserEnclave({
+ securityLevel: 'STANDARD',
+ secureProxyConfig: {
+ throwOnBlocked: false, // Return undefined instead of throwing
+ proxyMaxDepth: 5, // Limit proxy nesting
+ },
+});
+```
+
+## Double Iframe Configuration
+
+Configure the outer iframe security barrier:
+
+| Option | Type | Default | Description |
+|--------|------|---------|-------------|
+| `doubleIframe.enabled` | boolean | `true` | Enable double iframe isolation |
+| `doubleIframe.parentTimeoutBuffer` | number | `1000` | Extra timeout for outer iframe (ms) |
+| `doubleIframe.parentValidation.validateOperationNames` | boolean | `true` | Validate tool names |
+| `doubleIframe.parentValidation.allowedOperationPattern` | RegExp | - | Whitelist pattern for tool names |
+| `doubleIframe.parentValidation.blockedOperationPatterns` | RegExp[] | - | Blacklist patterns for tool names |
+| `doubleIframe.parentValidation.maxOperationsPerSecond` | number | `100` | Rate limit for tool calls |
+| `doubleIframe.parentValidation.blockSuspiciousSequences` | boolean | `true` | Detect multi-step attack patterns |
+| `doubleIframe.parentValidation.rapidEnumerationThreshold` | number | `30` | Same-operation repetition threshold |
+| `doubleIframe.parentValidation.rapidEnumerationOverrides` | object | `{}` | Per-operation threshold overrides |
+| `doubleIframe.parentValidation.suspiciousPatterns` | array | `[]` | Custom detection patterns |
+
+```ts
+const enclave = new BrowserEnclave({
+ doubleIframe: {
+ enabled: true,
+ parentTimeoutBuffer: 2000,
+ parentValidation: {
+ validateOperationNames: true,
+ allowedOperationPattern: /^[a-z]+:[a-z]+$/i,
+ blockedOperationPatterns: [/^admin:/i, /^system:/i],
+ maxOperationsPerSecond: 50,
+ blockSuspiciousSequences: true,
+ },
+ },
+});
+```
+
+### Built-in Suspicious Patterns
+
+These patterns are detected automatically when `blockSuspiciousSequences` is enabled:
+
+| Pattern | Description |
+|---------|-------------|
+| `EXFIL_LIST_SEND` | List/query operation followed by send/export |
+| `RAPID_ENUMERATION` | Same operation repeated beyond threshold in 5s window |
+| `CREDENTIAL_EXFIL` | Credential access followed by external operation |
+| `BULK_OPERATION` | Bulk/batch/mass/dump operations or unlimited queries |
+| `DELETE_AFTER_ACCESS` | Delete operation after data read (potential cover-up) |
+
+## Custom Globals
+
+Inject read-only data into the sandbox. Only JSON-serializable values are supported — functions cannot cross the iframe boundary.
+
+```ts
+const enclave = new BrowserEnclave({
+ globals: {
+ // These work (JSON-serializable)
+ config: { apiVersion: 'v2', maxRetries: 3 },
+ userId: 'user-123',
+ features: ['search', 'export'],
+
+ // These are silently skipped (not serializable)
+ // handler: () => {}, // function
+ // element: document.body, // DOM node
+ },
+});
+
+// In sandboxed code:
+// const version = config.apiVersion; // 'v2'
+// const id = userId; // 'user-123'
+```
+
+
+ Custom globals are only supported with the `agentscript` preset (the default). Using globals with other presets will throw an error.
+
+
+Each custom global is also available with a `__safe_` prefix (e.g., `config` and `__safe_config`), matching the pattern used by AgentScript's code transformation.
+
+## Related
+
+- [Overview](/core-libraries/enclave-browser/overview) - Getting started
+- [Security Architecture](/core-libraries/enclave-browser/security-architecture) - Isolation model details
+- [@enclave-vm/core Configuration](/core-libraries/enclave-vm/configuration) - Node.js configuration reference
+- [Security Levels](/core-libraries/enclave-vm/security-levels) - Security preset details
diff --git a/docs/enclave/core-libraries/enclave-browser/overview.mdx b/docs/enclave/core-libraries/enclave-browser/overview.mdx
new file mode 100644
index 0000000..409a91c
--- /dev/null
+++ b/docs/enclave/core-libraries/enclave-browser/overview.mdx
@@ -0,0 +1,131 @@
+---
+title: 'Overview'
+description: 'Secure JavaScript execution in the browser using double iframe isolation — no server required'
+---
+
+`@enclave-vm/browser` brings Enclave's defense-in-depth security model to the browser. Instead of Node.js VM contexts, it uses a **double iframe architecture** with CSP isolation, prototype freezing, and secure proxies to safely execute untrusted and LLM-generated code entirely client-side.
+
+
+
+ Block dangerous constructs before execution using ast-guard's AgentScript preset
+
+
+ Automatically transform code for safe execution with proxied functions and loop limits
+
+
+ Execute in nested iframe isolation with CSP, sandbox attributes, and secure proxies
+
+
+
+## When to Use Browser Enclave
+
+- **Client-side AI code execution** — Run LLM-generated code in the browser without a server round-trip
+- **No server required** — All sandboxing happens in the browser via iframe isolation
+- **Interactive code playgrounds** — Build code editors with live execution and tool integration
+- **Edge and offline scenarios** — Execute sandboxed code without network connectivity
+- **Rapid prototyping** — Let users experiment with AgentScript in the browser
+
+## Browser vs Node.js
+
+| Feature | `@enclave-vm/core` | `@enclave-vm/browser` |
+|---------|--------------------|-----------------------|
+| **Runtime** | Node.js (`vm` module) | Browser (iframe `srcdoc`) |
+| **Isolation** | Double VM nesting | Double iframe nesting |
+| **Network blocking** | VM context restriction | CSP `default-src 'none'` |
+| **Eval blocking** | `codeGeneration: { strings: false }` | CSP blocks `eval`/`Function` |
+| **Worker pool** | `worker_threads` adapter | Not available |
+| **AI scoring gate** | Supported | Not available |
+| **Reference sidecar** | Supported | Not available |
+| **Memory tracking** | V8 heap stats | Soft estimation (String/Array hooks) |
+| **AST validation** | Same (`@enclave-vm/ast`) | Same (`@enclave-vm/ast`) |
+| **Code transformation** | Same (AgentScript) | Same (AgentScript) |
+| **Security levels** | Same 4 levels | Same 4 levels |
+| **Tool system** | Same `callTool()` API | Same `callTool()` API |
+
+## Installation
+
+```bash
+npm install @enclave-vm/browser
+```
+
+## Quick Start
+
+```ts
+import { BrowserEnclave } from '@enclave-vm/browser';
+
+// Create an enclave with a tool handler
+const enclave = new BrowserEnclave({
+ securityLevel: 'STANDARD',
+ timeout: 10000,
+ maxToolCalls: 50,
+ toolHandler: async (toolName, args) => {
+ const response = await fetch(`/api/tools/${toolName}`, {
+ method: 'POST',
+ body: JSON.stringify(args),
+ });
+ return response.json();
+ },
+});
+
+// Execute AgentScript code
+const result = await enclave.run(`
+ const users = await callTool('users:list', { limit: 10 });
+ const active = users.filter(u => u.active);
+ return active.length;
+`);
+
+if (result.success) {
+ console.log('Result:', result.value);
+ console.log('Duration:', result.stats.duration, 'ms');
+} else {
+ console.error('Error:', result.error.message);
+}
+
+// Clean up
+enclave.dispose();
+```
+
+## Execution Results
+
+Every call to `run()` returns a structured result with success/error status and execution statistics:
+
+```ts
+interface ExecutionResult {
+ success: boolean;
+ value?: T; // Result value (if success)
+ error?: { // Error details (if failed)
+ name: string;
+ message: string;
+ code?: string;
+ stack?: string;
+ data?: Record;
+ };
+ stats: {
+ duration: number; // Execution time in ms
+ toolCallCount: number; // Number of tool calls made
+ iterationCount: number; // Number of loop iterations
+ startTime: number; // Epoch timestamp
+ endTime: number; // Epoch timestamp
+ };
+}
+```
+
+## Error Codes
+
+| Code | Meaning | Action |
+|------|---------|--------|
+| `VALIDATION_ERROR` | AST validation failed | Fix the code — blocked construct used |
+| `EXECUTION_TIMEOUT` | Execution exceeded timeout | Optimize or increase timeout |
+| `MAX_TOOL_CALLS` | Tool call limit exceeded | Reduce tool calls or increase limit |
+| `MAX_ITERATIONS` | Loop iteration limit exceeded | Reduce loops or increase limit |
+| `IFRAME_CREATE_FAILED` | Failed to create sandbox iframe | Check browser compatibility |
+| `EXECUTION_ABORTED` | Execution was manually aborted | Expected when calling `abort()` |
+| `ADAPTER_DISPOSED` | Adapter was disposed during execution | Avoid disposing during execution |
+| `ENCLAVE_ERROR` | Unexpected internal error | Check error message for details |
+
+## Related
+
+- [Security Architecture](/core-libraries/enclave-browser/security-architecture) - Double iframe isolation model
+- [Configuration](/core-libraries/enclave-browser/configuration) - All configuration options
+- [React Integration](/core-libraries/enclave-browser/react-integration) - Hooks and component patterns
+- [@enclave-vm/core Overview](/core-libraries/enclave-vm/overview) - Node.js sandbox
diff --git a/docs/enclave/core-libraries/enclave-browser/react-integration.mdx b/docs/enclave/core-libraries/enclave-browser/react-integration.mdx
new file mode 100644
index 0000000..1df3889
--- /dev/null
+++ b/docs/enclave/core-libraries/enclave-browser/react-integration.mdx
@@ -0,0 +1,417 @@
+---
+title: 'React Integration'
+description: 'React hooks, component patterns, and bundling considerations for @enclave-vm/browser'
+---
+
+`@enclave-vm/browser` integrates naturally with React applications. This guide covers hook patterns for lifecycle management, console capture, tool handler wiring, and a complete playground example.
+
+## Basic Hook Pattern
+
+The `useEnclave` hook manages the enclave lifecycle — dynamic import, initialization, re-creation on config changes, and cleanup on unmount:
+
+```tsx
+import { useState, useEffect, useRef, useCallback } from 'react';
+import type { BrowserEnclave, ExecutionResult, SecurityLevel } from '@enclave-vm/browser';
+
+interface UseEnclaveOptions {
+ securityLevel: SecurityLevel;
+ toolHandler?: (toolName: string, args: Record) => Promise;
+}
+
+export function useEnclave({ securityLevel, toolHandler }: UseEnclaveOptions) {
+ const [ready, setReady] = useState(false);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const enclaveRef = useRef(null);
+ const toolHandlerRef = useRef(toolHandler);
+ toolHandlerRef.current = toolHandler;
+
+ useEffect(() => {
+ let disposed = false;
+
+ async function init() {
+ setLoading(true);
+ setReady(false);
+ setError(null);
+
+ // Dispose previous instance
+ if (enclaveRef.current) {
+ enclaveRef.current.dispose();
+ enclaveRef.current = null;
+ }
+
+ try {
+ // Dynamic import for code splitting
+ const { BrowserEnclave } = await import('@enclave-vm/browser');
+ if (disposed) return;
+
+ enclaveRef.current = new BrowserEnclave({
+ securityLevel,
+ toolHandler: toolHandlerRef.current,
+ });
+ setReady(true);
+ } catch (err) {
+ if (!disposed) {
+ setError(err instanceof Error ? err.message : String(err));
+ }
+ } finally {
+ if (!disposed) setLoading(false);
+ }
+ }
+
+ init();
+
+ return () => {
+ disposed = true;
+ if (enclaveRef.current) {
+ enclaveRef.current.dispose();
+ enclaveRef.current = null;
+ }
+ };
+ }, [securityLevel]);
+
+ const run = useCallback(async (code: string): Promise> => {
+ if (!enclaveRef.current) {
+ throw new Error('Enclave not ready');
+ }
+ return enclaveRef.current.run(code);
+ }, []);
+
+ return { ready, loading, error, run };
+}
+```
+
+Key points:
+- **Dynamic import** keeps `@enclave-vm/browser` out of the initial bundle
+- **Ref for toolHandler** avoids re-creating the enclave when the handler identity changes
+- **Cleanup on unmount** calls `dispose()` to remove sandbox iframes
+- **Re-creates on `securityLevel` change** to apply new security configuration
+
+## Using the Hook
+
+```tsx
+function CodeRunner() {
+ const [code, setCode] = useState('return 1 + 1');
+ const [result, setResult] = useState('');
+ const { ready, loading, error, run } = useEnclave({
+ securityLevel: 'STANDARD',
+ });
+
+ const handleRun = async () => {
+ if (!ready) return;
+ try {
+ const res = await run(code);
+ if (res.success) {
+ setResult(`Result: ${JSON.stringify(res.value)} (${res.stats.duration}ms)`);
+ } else {
+ setResult(`Error: ${res.error?.message}`);
+ }
+ } catch (err) {
+ setResult(`Unexpected error: ${err instanceof Error ? err.message : String(err)}`);
+ }
+ };
+
+ if (loading) return Loading enclave...
;
+ if (error) return Failed to load: {error}
;
+
+ return (
+
+ );
+}
+```
+
+## Console Capture
+
+Sandbox console output is relayed to the host's `console` with an `[Enclave]` prefix. The `useConsoleCapture` hook intercepts these messages:
+
+```tsx
+import { useRef, useCallback } from 'react';
+
+type ConsoleLevel = 'log' | 'info' | 'warn' | 'error';
+
+interface ConsoleEntry {
+ id: number;
+ level: ConsoleLevel;
+ args: unknown[];
+ timestamp: number;
+}
+
+let nextId = 0;
+
+export function useConsoleCapture() {
+ const entriesRef = useRef([]);
+ const originalsRef = useRef void> | null>(null);
+
+ const startCapture = useCallback(() => {
+ // Restore previous originals if already capturing
+ if (originalsRef.current) {
+ for (const level of ['log', 'info', 'warn', 'error'] as ConsoleLevel[]) {
+ console[level] = originalsRef.current[level];
+ }
+ }
+
+ entriesRef.current = [];
+ const originals = {} as Record void>;
+
+ for (const level of ['log', 'info', 'warn', 'error'] as ConsoleLevel[]) {
+ originals[level] = console[level].bind(console);
+ console[level] = (...args: unknown[]) => {
+ originals[level](...args); // Always pass through
+ if (args[0] === '[Enclave]') {
+ entriesRef.current.push({
+ id: nextId++,
+ level,
+ args: args.slice(1), // Remove prefix
+ timestamp: Date.now(),
+ });
+ }
+ };
+ }
+
+ originalsRef.current = originals;
+ }, []);
+
+ const stopCapture = useCallback((): ConsoleEntry[] => {
+ if (originalsRef.current) {
+ for (const level of ['log', 'info', 'warn', 'error'] as ConsoleLevel[]) {
+ console[level] = originalsRef.current[level];
+ }
+ originalsRef.current = null;
+ }
+ return [...entriesRef.current];
+ }, []);
+
+ return { startCapture, stopCapture };
+}
+```
+
+Usage with the enclave hook:
+
+```tsx
+const { run } = useEnclave({ securityLevel: 'STANDARD' });
+const { startCapture, stopCapture } = useConsoleCapture();
+
+const handleRun = async () => {
+ startCapture();
+ try {
+ const result = await run('console.log("hello from sandbox"); return 42;');
+ } finally {
+ const entries = stopCapture();
+ // entries[0].args = ['hello from sandbox']
+ }
+};
+```
+
+## With Tool Handlers
+
+Wire up tool handlers that connect sandbox code to your application:
+
+```tsx
+function ToolExample() {
+ const toolHandler = useCallback(async (name: string, args: Record) => {
+ switch (name) {
+ case 'users:list':
+ return { items: [{ id: 1, name: 'Alice', active: true }] };
+ case 'users:get':
+ return { id: args.id, name: 'Alice', active: true };
+ default:
+ throw new Error(`Unknown tool: ${name}`);
+ }
+ }, []);
+
+ const { ready, run } = useEnclave({
+ securityLevel: 'SECURE',
+ toolHandler,
+ });
+
+ const handleRun = async () => {
+ const result = await run(`
+ const users = await callTool('users:list', { limit: 10 });
+ return users.items.filter(u => u.active).length;
+ `);
+ console.log(result.value); // 1
+ };
+
+ return ;
+}
+```
+
+## Security Level Picker
+
+A small component for switching security levels:
+
+```tsx
+import type { SecurityLevel } from '@enclave-vm/browser';
+
+const LEVELS: { value: SecurityLevel; label: string; description: string }[] = [
+ { value: 'STRICT', label: 'Strict', description: '5s timeout, 10 tool calls' },
+ { value: 'SECURE', label: 'Secure', description: '15s timeout, 50 tool calls' },
+ { value: 'STANDARD', label: 'Standard', description: '30s timeout, 100 tool calls' },
+ { value: 'PERMISSIVE', label: 'Permissive', description: '60s timeout, 1000 tool calls' },
+];
+
+function SecurityLevelPicker({
+ value,
+ onChange,
+}: {
+ value: SecurityLevel;
+ onChange: (level: SecurityLevel) => void;
+}) {
+ return (
+
+ );
+}
+```
+
+## Bundling Considerations
+
+- **ESM only**: `@enclave-vm/browser` is distributed as ESM. Ensure your bundler supports `import()`.
+- **Dynamic import**: Use `import('@enclave-vm/browser')` for code splitting. The library includes `@enclave-vm/ast` which adds to bundle size — dynamic import keeps it off the critical path.
+- **No Node.js dependencies**: The browser package has no `vm`, `worker_threads`, or other Node.js module dependencies.
+- **Dependencies**: Requires `@enclave-vm/ast` (for AST validation and code transformation) and `zod` (for message schema validation).
+
+## Browser Compatibility
+
+| Feature Required | Chrome | Firefox | Safari | Edge |
+|-----------------|--------|---------|--------|------|
+| `iframe srcdoc` | 20+ | 25+ | 6+ | 79+ |
+| `iframe sandbox` | 4+ | 17+ | 5+ | 79+ |
+| `postMessage` | 1+ | 6+ | 4+ | 79+ |
+| `Proxy` | 49+ | 18+ | 10+ | 79+ |
+| `TextEncoder` | 38+ | 18+ | 10.1+ | 79+ |
+| `crypto.randomUUID` | 92+ | 95+ | 15.4+ | 92+ |
+
+**Minimum recommended versions**: Chrome 67+, Firefox 63+, Safari 13+, Edge 79+
+
+
+ `crypto.randomUUID` has a built-in fallback using `Date.now()` for older browsers, so it is not a hard requirement.
+
+
+## Complete Example: Code Playground
+
+A full working example combining all patterns:
+
+```tsx
+import { useState, useCallback } from 'react';
+import type { SecurityLevel, ExecutionResult } from '@enclave-vm/browser';
+
+// Import hooks (see patterns above)
+import { useEnclave } from './hooks/use-enclave';
+import { useConsoleCapture } from './hooks/use-console-capture';
+
+function CodePlayground() {
+ const [code, setCode] = useState(`
+const data = await callTool('data:fetch', { query: 'active users' });
+console.log('Fetched', data.length, 'records');
+const sorted = data.sort((a, b) => b.score - a.score);
+return sorted.slice(0, 5);
+ `.trim());
+
+ const [securityLevel, setSecurityLevel] = useState('STANDARD');
+ const [result, setResult] = useState(null);
+ const [consoleOutput, setConsoleOutput] = useState([]);
+ const [running, setRunning] = useState(false);
+
+ const toolHandler = useCallback(async (name: string, args: Record) => {
+ // Simulate tool execution
+ if (name === 'data:fetch') {
+ return [
+ { name: 'Alice', score: 95 },
+ { name: 'Bob', score: 87 },
+ { name: 'Charlie', score: 92 },
+ ];
+ }
+ throw new Error(`Unknown tool: ${name}`);
+ }, []);
+
+ const { ready, loading, error, run } = useEnclave({ securityLevel, toolHandler });
+ const { startCapture, stopCapture } = useConsoleCapture();
+
+ const handleRun = async () => {
+ if (!ready || running) return;
+ setRunning(true);
+ setResult(null);
+ setConsoleOutput([]);
+
+ startCapture();
+ try {
+ const res = await run(code);
+ setResult(res);
+ } finally {
+ const entries = stopCapture();
+ setConsoleOutput(entries.map(e => `[${e.level}] ${e.args.join(' ')}`));
+ setRunning(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+ );
+}
+```
+
+## Related
+
+- [Overview](/core-libraries/enclave-browser/overview) - Getting started with browser enclave
+- [Configuration](/core-libraries/enclave-browser/configuration) - All configuration options
+- [EnclaveJS React](/enclavejs/react) - React bindings for streaming
diff --git a/docs/enclave/core-libraries/enclave-browser/security-architecture.mdx b/docs/enclave/core-libraries/enclave-browser/security-architecture.mdx
new file mode 100644
index 0000000..9eb1b46
--- /dev/null
+++ b/docs/enclave/core-libraries/enclave-browser/security-architecture.mdx
@@ -0,0 +1,265 @@
+---
+title: 'Security Architecture'
+description: 'Deep dive into the double iframe isolation model and 8 security layers protecting browser-based code execution'
+---
+
+`@enclave-vm/browser` implements defense-in-depth through a double iframe architecture. Each execution creates two nested iframes — an outer security barrier and an inner sandbox — with 8 distinct security layers that work together to prevent sandbox escapes.
+
+## Double Iframe Architecture
+
+```mermaid
+%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#e8a045', 'primaryTextColor': '#fff', 'primaryBorderColor': '#c78935', 'lineColor': '#c78935', 'secondaryColor': '#f0b865', 'tertiaryColor': '#fff5e6'}}}%%
+flowchart TB
+ subgraph host["Host Page"]
+ subgraph outer["Outer Iframe (Security Barrier)"]
+ subgraph inner["Inner Iframe (Sandbox)"]
+ code["User Code"]
+ end
+ end
+ end
+ style host fill:#fff5e6,stroke:#c78935,color:#333
+ style outer fill:#f0b865,stroke:#c78935,color:#333
+ style inner fill:#e8a045,stroke:#c78935,color:#fff
+ style code fill:#c78935,stroke:#c78935,color:#fff
+```
+
+- **Host Page**: Your application. Creates the outer iframe and handles tool calls.
+- **Outer Iframe**: Security barrier with rate limiting, pattern detection, and name filtering. Relays validated messages between host and inner iframe.
+- **Inner Iframe**: The actual sandbox. Contains prototype hardening, secure proxies, safe runtime wrappers, and the user code.
+
+## The 8 Security Layers
+
+```mermaid
+%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#e8a045', 'primaryTextColor': '#fff', 'primaryBorderColor': '#c78935', 'lineColor': '#c78935', 'secondaryColor': '#f0b865', 'tertiaryColor': '#fff5e6'}}}%%
+flowchart TD
+ L1["Layer 1: Content Security Policy"] --> L2["Layer 2: Iframe Sandbox Attribute"]
+ L2 --> L3["Layer 3: Outer Frame Validation"]
+ L3 --> L4["Layer 4: Dangerous Global Removal"]
+ L4 --> L5["Layer 5: Prototype Freezing"]
+ L5 --> L6["Layer 6: Secure Proxy"]
+ L6 --> L7["Layer 7: Safe Runtime Wrappers"]
+ L7 --> L8["Layer 8: Memory Tracking"]
+ style L1 fill:#e8a045,stroke:#c78935,color:#fff
+ style L2 fill:#e8a045,stroke:#c78935,color:#fff
+ style L3 fill:#f0b865,stroke:#c78935,color:#333
+ style L4 fill:#f0b865,stroke:#c78935,color:#333
+ style L5 fill:#e8a045,stroke:#c78935,color:#fff
+ style L6 fill:#e8a045,stroke:#c78935,color:#fff
+ style L7 fill:#f0b865,stroke:#c78935,color:#333
+ style L8 fill:#f0b865,stroke:#c78935,color:#333
+```
+
+### Layer 1: Content Security Policy
+
+Both iframes are created with a strict CSP via `` tag:
+
+```
+default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'
+```
+
+| Directive | Effect |
+|-----------|--------|
+| `default-src 'none'` | Blocks all network requests (fetch, XHR, WebSocket, images, fonts) |
+| `script-src 'unsafe-inline'` | Allows inline `
+