A safe AI widget is a small capability system
I let a model write interactive JavaScript. The hard engineering question was what that code could do after it reached a browser.
A widget that renders a sentence is not the same thing as a widget that can execute code. Once an AI-assisted feature can run JavaScript, its security depends on the document the model can author, the capabilities available to that document, the browser boundary, the version that was reviewed, and the exact version that is served.
I designed the path as a sequence of constraints: narrow the language we accept, analyze the code in context, run it inside a browser sandbox, keep the trusted bridge separate from authored content, test the actual browser behavior, and make serving a separate human-controlled state.
Start with the capability, not a promise
The first useful question is not whether the model can write “safe code.” It is what the widget needs to accomplish. Does it need to draw a chart? Read a public value? Send data to a service? Open a new window? Every capability that is not required is another route the generated code might use.
Our widget CSP starts from default-src 'none'. It allows inline script and style for the constrained artifact, data images, and denies connections, forms, base changes, objects, nested frames and workers. The iframe sandbox allows scripts but leaves out allow-same-origin, which gives the child an opaque origin. That is a deliberate reduction in authority: the child cannot behave like a normal same-origin application just because it is displayed inside our product.
This boundary has an integration consequence. A sandboxed child’s message origin is null, so checking that the child’s event.origin equals our application hostname rejects legitimate messages. The parent instead checks the actual child window, the expected opaque origin, the widget identifier, the pinned version and the bounded height. The child separately checks that its parent is one of the permitted application origins. Both sides validate the handshake; neither trusts an arbitrary window message.
The trusted bridge is appended to the authored HTML and pinned with the stored version. The model does not get to rewrite the code that measures height or decides which parent may initialize the frame. A reviewed widget version therefore carries the bridge that was reviewed with it. Updating a shared helper does not silently rewrite old stored documents.
Static analysis is a useful boundary, not the only boundary
A keyword blacklist looks attractive because it is easy to explain. It is also easy to evade with aliases, nested scopes and ordinary JavaScript syntax. The validator uses lexical scope analysis to understand bindings and a closed vocabulary of allowed constructors and methods. It rejects unresolved names and capabilities that are outside the widget’s narrow contract.
That changed how we handled a probe. An earlier static check accepted an RTCPeerConnection constructor. The probe was parsed; it was not executed as an exfiltration test. We used that finding to strengthen the allowed vocabulary and preserve the browser’s independent network boundary. The distinction matters: finding a suspicious construct in source is useful evidence about a validator, but it is not proof that data left a browser.
Here is the shape of the browser boundary:
const frame = document.createElement('iframe');
frame.setAttribute('sandbox', 'allow-scripts');
frame.setAttribute('referrerpolicy', 'no-referrer');
frame.srcdoc = reviewedHtml + pinnedBridge;
window.addEventListener('message', event => {
if (event.source !== frame.contentWindow) return;
if (event.origin !== 'null') return;
if (event.data?.type !== 'p51-widget-height') return;
if (event.data?.widgetId !== widgetId) return;
if (event.data?.version !== reviewedVersion) return;
if (!Number.isInteger(event.data?.height)) return;
if (event.data.height < 100 || event.data.height > 4000) return;
frame.style.height = `${event.data.height}px`;
});
This is an explanatory sketch of the checks, not the complete production implementation. The actual bridge measures visible content, handles an opaque child origin and reports height. The validator also refuses viewport-sized content that could grow with the frame itself. No single static check makes arbitrary JavaScript harmless; the browser sandbox and content policy remain essential if static analysis misses a construction.
A report loop can become a security bug
Sizing the iframe exposed a different kind of failure. The child measures its content and reports a height to the parent. If the widget sizes itself from the viewport, a report can make the parent frame taller; the larger viewport then makes the child content taller; that report grows the frame again.
We tried a runtime strike guard. It clipped legitimate animated growth, and a trailing report could still leave the frame in a misleading state. A second version latched too early during quick opens. The final bridge removes the strike guard. The validator refuses viewport-height units and constructor-string helpers, the bridge reports changes immediately, and the parent clamps the reported height to the configured range. Runtime-created strings still meet the clamp; they do not get to expand the frame without bound.
The reason to include those failed intermediate designs is not to dramatize a mistake. Each changed the boundary in a different way: the guard reduced availability, while the final design puts the limit on content semantics and the parent’s accepted value. For interactive UI, a security rule that makes normal animation unusable will be bypassed or worked around. The boundary has to preserve the intended product behavior while refusing the dangerous loop.
Test the browser path, not only the validator
The widget path includes authoring, validation, rendering, review, staging, serving and retirement. I test the real browser path because parser tests cannot establish how an iframe, CSP and message handshake behave together.
The historical evidence records five repetitions, 45 runner tests and 900 Chrome scenarios. The author’s deliberate control set detected and restored 38 of 38 injected faults. That is evidence that those controls worked in that test cohort; it is not 38 independent security findings or a model-accuracy score. A later platform follow-up reports 1,076 tests across the platform candidate, not 1,076 widget attacks.
For each stored version I want the authored document hash, bridge hash, CSP, review decision and serving state to refer to the same artifact. A version that passed a browser matrix is not automatically the version currently served. A source candidate is not a production deployment. Revocation also needs a clear path: when we block a version, the serving route should stop returning it, and the old artifact should remain available for investigation without remaining active.
The practical review exercise is to map every capability from input to serving. For each arrow, ask what data can cross, which identity authorizes it, which code is trusted, and what evidence proves the deployed version matches the reviewed one. Then try the browser behaviors the policy is meant to prevent and include benign animations and quick open/close transitions in the same matrix.
Keep the claims tied to the version
This architecture gives me several independent controls: a constrained authoring grammar, a scope-aware validator, a restrictive browser policy, a pinned bridge, a real browser test matrix and a serving decision. Each one addresses a different failure mode. Their value comes from the composition, not from calling any one layer a firewall.
When one layer changes, I re-evaluate the whole path. A new JavaScript helper can change what the validator admits. A new bridge can change runtime behavior. A deployment can serve a different stored version than the one in a test report. The version identifier and hashes are how I keep those facts connected.
If you are adding model-authored interactivity, start with the capability inventory. Remove any browser or network power the feature does not need, and make the exact reviewed version the only version that can be served.
How this was verified
The architecture and code claims above are tied to pinned commits f35f67c3842dd2ae2942ed66e10b407a227a3d0b, fe71d6daa5e4 and 48a4a963d63d65fa919d3a014773a7708a95d8f2 in the p51-platform repository. The bridge and evidence bytes were read from those Git objects and matched the dossier’s SHA-256 values. The five-run and test-count figures are historical receipts; this writing pass did not rerun the browser suite or establish a current live served version. The RTCPeerConnection probe was parsed, not executed as exfiltration. The article describes a versioned security design, not a universal claim that arbitrary JavaScript is safe.
