Running LLM-Generated Code in a Chrome Extension: An MV3 CSP Adventure
Chrome Manifest V3 has one commandment above all others: thou shalt not eval. No new Function, no dynamic code strings, anywhere in an extension's privileged contexts. The policy killed a generation of extensions and made MV3 migration famously painful.
Now consider Scrapewright's core requirement: it must execute scraping code written by an LLM at runtime — arbitrary, generated-after-install, string-shaped JavaScript — inside a Chrome extension. On its face, that's the exact thing MV3 exists to prevent. The repo is a working solution to this puzzle, and the path it takes says a lot about Chrome's actual security model (as opposed to the documented one).
The map of where code may run
An MV3 extension has, broadly, these execution contexts, each with its own CSP:
| Context | eval/new Function? |
Privileges |
|---|---|---|
Service worker (background.js) |
No | Full chrome.* API |
| Extension pages (popup, options) | No | Full chrome.* API |
| Content scripts (in the page) | No | DOM access + limited messaging |
| Declared sandbox page | Yes | None — isolated origin |
The escape hatch is the last row: pages listed under the manifest's sandbox key are loaded in a sandboxed iframe with a unique opaque origin — no chrome.* access, no same-origin relationship with anything, DOM-only. In exchange for that quarantine, Chrome relaxes their CSP to allow eval and new Function. It's the sanctioned container for "code I don't trust": plugins, math-expression evaluators, REPLs.
That's the doctrine. The practice is where it gets interesting.
The relay chain
Scrapewright's LLM-generated step scripts can't just sit in a sandbox and compute — they need to touch the DOM of an arbitrary, already-open tab (click, extract, hover, scroll). The sandbox can't do that directly (no privileges), and the tab's own page CSP forbids injecting evaluators into it (many sites also CSP away unsafe-eval, and injecting code into the page means living inside the page's threat model anyway).
So a request relay chain bridges the two sides:
LLM step script (sandbox iframe)
│ "$extract('.item .price')"
▼ postMessage → DOM_REQUEST
Offscreen document (offscreen.js — owns the sandbox iframe)
▼ chrome.runtime message
Service worker (background.js — has chrome.tabs)
▼ chrome.tabs.sendMessage
Content script in the target tab (content-script.js)
▼ performs the DOM op
└── response flows back the same way → sandbox resolves the promise
Design choices worth stealing:
- Why an offscreen document at all? MV3 service workers can't own iframes; DOM-holding contexts need a document.
chrome.offscreengives the extension a hidden page that survives SW suspensions — a stable host for the sandbox iframe and for atabIdStackthat maps concurrent-looking DOM requests back to their originating tabs. - Why not inject generated code into the target page? Because then your code runs under the page's CSP and the page's mutation surface. In the sandbox, generated code is a prisoner that can only ask guards (via message passing) to act on the world. LLM hallucination or malicious page content can't escalate through that boundary.
- Response dedup and timeouts. Each
DOM_REQUESTcarries an id; responses are deduped across the relay (a content-script reply can arrive both directly and via a background rebroadcast), and a timeout path tears the stack down so a hung tab can't leak stack slots. Reading that code is a lesson in defensive async plumbing.
The philosophical bit
What this architecture really demonstrates is that MV3's CSP isn't a prohibition on dynamic code — it's a routing requirement: dynamic code must live in a context that has no authority, so that capability flows only through explicit, auditable message channels. The sandbox prisoner never handles a capability it shouldn't; every privileged act (chrome.tabs, chrome.debugger, storage) stays in code that was static at review time and is covered by the manifest's declared permissions.
For an LLM-driven system that's more than hygiene — it's the containment story. Generated code is expected to be occasionally wrong (that's the medium). Wrong code in the sandbox fails a DOM request. Wrong code with chrome.debugger access would be a different class of incident. The architecture assumes its own generator is fallible, and the blast radius is the point.
Go read it
The chain is small enough to hold in your head in an afternoon — sandbox.js (the evaluator), offscreen.js + lib/offscreen-executor.js (the host/relay), background.js (the bridge), content-script.js (the executor in the page) — and the repo's whitepaper walks it module by module. If you're building anything MV3 that touches user- or model-authored code, this is prior art worth an hour: github.com/singhand-labs/scrapewright (GPLv3, plain JS, no build step between you and the code).