AID-I-007: Client-Side AI Execution Isolation
Pillars: app
Lifecycle: operation
Scope boundary
Owns component-level confinement of a client-side AI model or runtime through Web Workers, sandboxed iframes, WASM capabilities, CSP, and minimal native bridges inside browsers, Electron shells, hybrid apps, or mobile runtimes. It does not own browser-agent task lifecycle or browser-managed and desktop-workspace state.
AID-I-008: AID-I-007 isolates the embedded client-side model or runtime component; AID-I-008 isolates browser-agent and desktop computer-use state by task or trust zone. Cookies, localStorage, IndexedDB, cache, history, downloads, clipboard, magic links, desktop profiles, and workspace residue belong to I-008 even when the model component is separately confined here.
AID-I-001: AID-I-007 confines a model inside an end-user client application; AID-I-001 isolates managed AI execution in containers, sandboxes, or microVMs. A client-side Worker or iframe boundary is not evidence that a server-side tool or model workload runs in the managed sandbox family.
AID-H-003.011: AID-I-007 confines the client-side model runtime and its access to browser, application, operating-system, and native-bridge capabilities after execution begins; AID-H-003.011 decides whether an entitled, freshly attested client may receive the decryption key for an exact protected model release. Client isolation does not authorize model delivery, and key-release admission does not prove post-release runtime confinement.
Implementation guidance
- AID-I-007-G001: Execute AI models in a dedicated Web Worker.
- AID-I-007-G002: Run untrusted models or their UI components in a sandboxed iframe.
- AID-I-007-G003: Leverage WebAssembly (WASM) runtimes for a capabilities-based sandbox.
- AID-I-007-G004: Utilize Content Security Policy (CSP) to restrict model data exfiltration and script execution.
- AID-I-007-G005: Enforce a minimal, allowlisted native bridge between the AI runtime and device/system capabilities (mobile, Electron, hybrid apps).
AID-I-007-G001
Security boundary and limits
A dedicated worker removes direct DOM authority and gives the parent a controllable message boundary. It is not a network or storage sandbox: a worker can use worker-available fetch, WebSocket, IndexedDB, and other APIs subject to the page's effective policy. Use AID-I-007-G004 and the surrounding runtime/network controls for egress. PASS here proves only that a verified worker artifact received no privileged handles and every parent/worker exchange followed the signed protocol.
Verified worker and model launcher
// File: verified-worker-launcher.js
// Inputs policy and evidenceClient come from trusted, non-model-controlled adapters.
// policy fields: workerUrl, workerSha256, runtimeUrl, runtimeSha256,
// modelUrl, modelSha256, maxArtifactBytes, maxInputValues,
// maxOutputValues, artifactFetchTimeoutMs, operationTimeoutMs, policySha256.
const textEncoder = new TextEncoder();
function hex(bytes) {
return Array.from(new Uint8Array(bytes))
.map((value) => value.toString(16).padStart(2, "0"))
.join("");
}
function requirePositivePolicyInteger(value, field) {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(field + " must be a signed positive safe integer");
}
}
function requireNonzeroSha256(value, field) {
if (!/^[0-9a-f]{64}$/.test(value) || /^0{64}$/.test(value)) {
throw new Error(field + " must be a nonzero lowercase SHA-256 digest");
}
}
async function fetchVerified(
urlText,
expectedSha256,
maxBytes,
fetchTimeoutMs
) {
requirePositivePolicyInteger(maxBytes, "maxArtifactBytes");
requirePositivePolicyInteger(fetchTimeoutMs, "artifactFetchTimeoutMs");
requireNonzeroSha256(expectedSha256, "artifact digest");
const requested = new URL(urlText, location.href);
if (
location.protocol !== "https:" ||
requested.protocol !== "https:" ||
requested.username ||
requested.password ||
requested.hash ||
requested.origin !== location.origin
) {
throw new Error("worker artifact must use the canonical application HTTPS origin");
}
if (typeof AbortSignal.timeout !== "function") {
throw new Error("deadline-capable AbortSignal is unavailable");
}
const response = await fetch(requested, {
cache: "no-store",
credentials: "omit",
redirect: "error",
signal: AbortSignal.timeout(fetchTimeoutMs)
});
if (!response.ok || !response.body || response.url !== requested.href) {
throw new Error("artifact fetch failed or redirected");
}
const reader = response.body.getReader();
const chunks = [];
let length = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
length += value.byteLength;
if (length > maxBytes) {
await reader.cancel();
throw new Error("artifact exceeds signed policy");
}
chunks.push(value);
}
const bytes = new Uint8Array(length);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
const observed = hex(await crypto.subtle.digest("SHA-256", bytes));
if (observed !== expectedSha256) {
throw new Error("artifact digest mismatch");
}
return bytes;
}
function randomId() {
const bytes = crypto.getRandomValues(new Uint8Array(32));
return hex(bytes);
}
function validateFiniteVector(value, maxValues) {
if (
!Array.isArray(value) ||
value.length === 0 ||
value.length > maxValues ||
value.some((item) => typeof item !== "number" || !Number.isFinite(item))
) {
throw new Error("invalid numeric vector");
}
return value;
}
function canonicalJson(value) {
if (value === null || typeof value === "boolean" || typeof value === "string") {
return JSON.stringify(value);
}
if (typeof value === "number" && Number.isFinite(value)) {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
return "[" + value.map(canonicalJson).join(",") + "]";
}
if (value && typeof value === "object") {
return "{" + Object.keys(value).sort().map((key) =>
JSON.stringify(key) + ":" + canonicalJson(value[key])
).join(",") + "}";
}
throw new Error("evidence value is not canonical JSON");
}
async function digestValue(value) {
const bytes = textEncoder.encode(canonicalJson(value));
return "sha256:" + hex(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)));
}
export async function startVerifiedWorker(policy, evidenceClient) {
requirePositivePolicyInteger(policy.maxArtifactBytes, "maxArtifactBytes");
requirePositivePolicyInteger(policy.maxInputValues, "maxInputValues");
requirePositivePolicyInteger(policy.maxOutputValues, "maxOutputValues");
requirePositivePolicyInteger(
policy.artifactFetchTimeoutMs,
"artifactFetchTimeoutMs"
);
requirePositivePolicyInteger(policy.operationTimeoutMs, "operationTimeoutMs");
requirePositivePolicyInteger(policy.generation, "generation");
if (policy.operationTimeoutMs > 2_147_483_647) {
throw new Error("operationTimeoutMs exceeds the browser timer range");
}
requireNonzeroSha256(policy.policySha256, "policySha256");
const [runtimeBytes, controllerBytes, modelBytes] = await Promise.all([
fetchVerified(
policy.runtimeUrl,
policy.runtimeSha256,
policy.maxArtifactBytes,
policy.artifactFetchTimeoutMs
),
fetchVerified(
policy.workerUrl,
policy.workerSha256,
policy.maxArtifactBytes,
policy.artifactFetchTimeoutMs
),
fetchVerified(
policy.modelUrl,
policy.modelSha256,
policy.maxArtifactBytes,
policy.artifactFetchTimeoutMs
)
]);
// ort.min.js and the controller are version-pinned, verified, and concatenated
// into one self-contained classic worker. The controller must not import code.
const workerBlob = new Blob(
[runtimeBytes, textEncoder.encode("\n"), controllerBytes],
{ type: "text/javascript" }
);
const workerObjectUrl = URL.createObjectURL(workerBlob);
const worker = new Worker(workerObjectUrl, { name: "verified-ai-worker" });
const channel = new MessageChannel();
const sessionId = randomId();
let nextSequence = 1;
let closed = false;
const pending = new Map();
const appendVerifiedEvidence = async (event) => {
const eventSha256 = await digestValue(event);
const receipt = await evidenceClient.appendAndVerify(event);
if (!receipt || receipt.eventSha256 !== eventSha256) {
throw new Error("worker evidence receipt does not bind the exact event");
}
return receipt;
};
const destroy = async (reason) => {
if (closed) return;
closed = true;
for (const item of pending.values()) {
clearTimeout(item.timer);
item.reject(new Error("worker terminated: " + reason));
}
pending.clear();
channel.port1.close();
worker.terminate();
URL.revokeObjectURL(workerObjectUrl);
await appendVerifiedEvidence({
type: "worker_terminated",
sessionId,
reason,
workerSha256: policy.workerSha256,
runtimeSha256: policy.runtimeSha256,
modelSha256: policy.modelSha256,
policySha256: policy.policySha256,
policyGeneration: policy.generation,
parentOrigin: location.origin,
terminalState: "TERMINATED"
});
};
worker.addEventListener("error", () => {
void destroy("worker_error");
});
worker.addEventListener("messageerror", () => {
void destroy("bootstrap_messageerror");
});
channel.port1.onmessage = async (event) => {
const message = event.data;
if (
!message ||
message.type !== "RESULT" ||
message.sessionId !== sessionId ||
!Number.isSafeInteger(message.sequence)
) {
void destroy("protocol_violation");
return;
}
const waiter = pending.get(message.sequence);
if (!waiter) {
void destroy("unknown_or_replayed_sequence");
return;
}
try {
const output = validateFiniteVector(
message.output, policy.maxOutputValues
);
const responseSha256 = await digestValue(output);
await appendVerifiedEvidence({
type: "worker_exchange",
sessionId,
sequence: message.sequence,
requestSha256: waiter.requestSha256,
responseSha256,
workerSha256: policy.workerSha256,
runtimeSha256: policy.runtimeSha256,
modelSha256: policy.modelSha256,
policySha256: policy.policySha256,
policyGeneration: policy.generation,
parentOrigin: location.origin,
terminalState: "ACTIVE"
});
pending.delete(message.sequence);
clearTimeout(waiter.timer);
waiter.resolve(output);
} catch (error) {
void destroy("invalid_output");
waiter.reject(error);
}
};
channel.port1.onmessageerror = () => {
void destroy("port_messageerror");
};
channel.port1.start();
worker.postMessage({
type: "BOOTSTRAP",
sessionId,
policy: {
maxInputValues: policy.maxInputValues,
maxOutputValues: policy.maxOutputValues
},
modelBytes: modelBytes.buffer
}, [channel.port2, modelBytes.buffer]);
await appendVerifiedEvidence({
type: "worker_started",
sessionId,
workerSha256: policy.workerSha256,
runtimeSha256: policy.runtimeSha256,
modelSha256: policy.modelSha256,
artifactFetchTimeoutMs: policy.artifactFetchTimeoutMs,
policySha256: policy.policySha256,
policyGeneration: policy.generation,
parentOrigin: location.origin
});
return {
sessionId,
async predict(input) {
if (closed) throw new Error("worker is closed");
const vector = validateFiniteVector(input, policy.maxInputValues);
const requestSha256 = await digestValue(vector);
const sequence = nextSequence++;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pending.delete(sequence);
reject(new Error("worker operation timed out"));
void destroy("operation_timeout");
}, policy.operationTimeoutMs);
pending.set(sequence, { resolve, reject, timer, requestSha256 });
channel.port1.postMessage({
type: "PREDICT", sessionId, sequence, input: vector
});
});
},
close: () => destroy("controller_close")
};
}
Self-contained worker controller
// File: verified-worker-controller.js
// Bundle and digest-pin this file. ort.min.js is concatenated before it.
"use strict";
let port = null;
let sessionId = null;
let session = null;
let limits = null;
let lastSequence = 0;
self.onmessage = async (event) => {
const message = event.data;
if (
port ||
!message ||
message.type !== "BOOTSTRAP" ||
event.ports.length !== 1 ||
!(message.modelBytes instanceof ArrayBuffer)
) {
self.close();
return;
}
sessionId = message.sessionId;
limits = message.policy;
port = event.ports[0];
try {
session = await self.ort.InferenceSession.create(message.modelBytes, {
executionProviders: ["wasm"]
});
} catch {
self.close();
return;
}
port.onmessage = async (requestEvent) => {
const request = requestEvent.data;
if (
!request ||
request.type !== "PREDICT" ||
request.sessionId !== sessionId ||
!Number.isSafeInteger(request.sequence) ||
request.sequence !== lastSequence + 1 ||
!Array.isArray(request.input) ||
request.input.length === 0 ||
request.input.length > limits.maxInputValues ||
request.input.some(
(item) => typeof item !== "number" || !Number.isFinite(item)
)
) {
self.close();
return;
}
lastSequence = request.sequence;
try {
const inputName = session.inputNames[0];
const tensor = new self.ort.Tensor(
"float32",
Float32Array.from(request.input),
[1, request.input.length]
);
const results = await session.run({ [inputName]: tensor });
const output = Array.from(results[session.outputNames[0]].data);
if (
output.length > limits.maxOutputValues ||
output.some((item) => !Number.isFinite(item))
) {
self.close();
return;
}
port.postMessage({
type: "RESULT",
sessionId,
sequence: request.sequence,
output
});
} catch {
self.close();
}
};
port.onmessageerror = () => self.close();
port.start();
};
Deployment and verification
Serve the artifacts from an authenticated application origin; resolve all URLs and limits from a signature-verified, versioned environment policy. The page CSP must explicitly allow the required worker/blob behavior and separately constrain egress. Never transfer DOM nodes, native bridge objects, storage objects, authentication material, or callbacks. Terminate the worker on navigation, logout, task close, timeout, sequence error, malformed clone, model failure, or policy change. Retain signed start/termination receipts plus every request/response digest and sequence, then replay the complete exchange population and negative tests. Do not claim network isolation from this worker boundary.
Do not activate without startup evidence
In startVerifiedWorker(), move the BOOTSTRAP transfer after the verified worker_started append. If that append or its exact-event readback fails, terminate the Worker, close both message ports, revoke the object URL, mark the launcher closed, and throw before any model bytes or request can be exchanged. The replacement sequence is:
try {
await appendVerifiedEvidence({
type: "worker_started",
sessionId,
workerSha256: policy.workerSha256,
runtimeSha256: policy.runtimeSha256,
modelSha256: policy.modelSha256,
artifactFetchTimeoutMs: policy.artifactFetchTimeoutMs,
policySha256: policy.policySha256,
policyGeneration: policy.generation,
parentOrigin: location.origin
});
} catch (error) {
closed = true;
channel.port1.close();
channel.port2.close();
worker.terminate();
URL.revokeObjectURL(workerObjectUrl);
throw new Error("worker startup evidence unavailable", {cause: error});
}
worker.postMessage({
type: "BOOTSTRAP", sessionId,
policy: {
maxInputValues: policy.maxInputValues,
maxOutputValues: policy.maxOutputValues
},
modelBytes: modelBytes.buffer
}, [channel.port2, modelBytes.buffer]);Remove the earlier pre-evidence worker.postMessage() block when applying this replacement; it must exist exactly once.
AID-I-007-G002
Opaque-origin channel rule
With sandbox="allow-scripts" and no allow-same-origin, the frame has an opaque origin. Therefore a parent must not compare event.origin with the widget URL or use that URL as the postMessage target; that common pattern does not work for this sandbox. Bootstrap exactly one fresh MessagePort to the current frame window using *, then treat possession of that port, not origin text, as the only capability. The port exposes a small typed protocol and no sensitive parent state.
Verified parent controller
// File: sandboxed-widget-controller.js
// policy is signature-verified outside model control and supplies:
// widgetShellUrl, widgetShellSha256, maxShellBytes, maxTextBytes,
// maxReplyBytes, artifactFetchTimeoutMs, operationTimeoutMs, policySha256.
function bytesToHex(bytes) {
return Array.from(new Uint8Array(bytes))
.map((value) => value.toString(16).padStart(2, "0"))
.join("");
}
function randomHex() {
return bytesToHex(crypto.getRandomValues(new Uint8Array(32)));
}
function requirePositivePolicyInteger(value, field) {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(field + " must be a signed positive safe integer");
}
}
function requireNonzeroSha256(value, field) {
if (!/^[0-9a-f]{64}$/.test(value) || /^0{64}$/.test(value)) {
throw new Error(field + " must be a nonzero lowercase SHA-256 digest");
}
}
async function verifiedUtf8(
urlText,
expectedDigest,
limit,
fetchTimeoutMs
) {
requirePositivePolicyInteger(limit, "maxShellBytes");
requirePositivePolicyInteger(fetchTimeoutMs, "artifactFetchTimeoutMs");
requireNonzeroSha256(expectedDigest, "widgetShellSha256");
const url = new URL(urlText, location.href);
if (
location.protocol !== "https:" ||
url.protocol !== "https:" ||
url.username ||
url.password ||
url.hash ||
url.origin !== location.origin
) {
throw new Error("widget shell must use the canonical application HTTPS origin");
}
if (typeof AbortSignal.timeout !== "function") {
throw new Error("deadline-capable AbortSignal is unavailable");
}
const response = await fetch(url, {
cache: "no-store",
credentials: "omit",
redirect: "error",
signal: AbortSignal.timeout(fetchTimeoutMs)
});
if (!response.ok || !response.body || response.url !== url.href) {
throw new Error("widget shell fetch failed or redirected");
}
const reader = response.body.getReader();
const chunks = [];
let total = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > limit) {
await reader.cancel();
throw new Error("widget shell exceeds signed policy");
}
chunks.push(value);
}
const bytes = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
const digest = bytesToHex(await crypto.subtle.digest("SHA-256", bytes));
if (digest !== expectedDigest) {
throw new Error("widget shell digest mismatch");
}
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
}
function validateText(value, maxBytes) {
if (
typeof value !== "string" ||
new TextEncoder().encode(value).byteLength > maxBytes
) {
throw new Error("text violates signed policy");
}
return value;
}
async function digestUtf8(value) {
const digest = await crypto.subtle.digest(
"SHA-256", new TextEncoder().encode(value)
);
return bytesToHex(new Uint8Array(digest));
}
export async function createSandboxedWidget(container, policy, evidenceClient) {
requirePositivePolicyInteger(policy.maxShellBytes, "maxShellBytes");
requirePositivePolicyInteger(policy.maxTextBytes, "maxTextBytes");
requirePositivePolicyInteger(policy.maxReplyBytes, "maxReplyBytes");
requirePositivePolicyInteger(
policy.artifactFetchTimeoutMs,
"artifactFetchTimeoutMs"
);
requirePositivePolicyInteger(policy.operationTimeoutMs, "operationTimeoutMs");
requirePositivePolicyInteger(policy.generation, "generation");
if (policy.operationTimeoutMs > 2_147_483_647) {
throw new Error("operationTimeoutMs exceeds the browser timer range");
}
requireNonzeroSha256(policy.policySha256, "policySha256");
const shell = await verifiedUtf8(
policy.widgetShellUrl,
policy.widgetShellSha256,
policy.maxShellBytes,
policy.artifactFetchTimeoutMs
);
const frame = document.createElement("iframe");
frame.setAttribute("sandbox", "allow-scripts");
frame.setAttribute("referrerpolicy", "no-referrer");
frame.setAttribute("allow", "");
frame.setAttribute("title", "Isolated AI output");
frame.srcdoc = shell;
const channel = new MessageChannel();
const channelId = randomHex();
let sequence = 0;
let nextResponseSequence = 1;
let chainHeadSha256 = "0".repeat(64);
let loadCount = 0;
let ready = false;
let closed = false;
const pending = new Map();
let resolveReady;
let rejectReady;
const readyPromise = new Promise((resolve, reject) => {
resolveReady = resolve;
rejectReady = reject;
});
const readyTimer = setTimeout(() => {
rejectReady(new Error("sandbox bootstrap timed out"));
void destroy("bootstrap_timeout");
}, policy.operationTimeoutMs);
const destroy = async (reason) => {
if (closed) return;
closed = true;
clearTimeout(readyTimer);
if (!ready) rejectReady(new Error("sandbox destroyed: " + reason));
for (const waiter of pending.values()) {
clearTimeout(waiter.timer);
waiter.reject(new Error("sandbox destroyed: " + reason));
}
pending.clear();
channel.port1.close();
frame.remove();
await evidenceClient.appendAndVerify({
type: "sandboxed_widget_destroyed",
channelId,
reason,
shellSha256: policy.widgetShellSha256,
artifactFetchTimeoutMs: policy.artifactFetchTimeoutMs,
policySha256: policy.policySha256
});
};
channel.port1.onmessage = async (event) => {
const message = event.data;
if (
!message ||
message.channelId !== channelId ||
!Number.isSafeInteger(message.sequence) ||
message.sequence !== (message.type === "READY" ? 0 : nextResponseSequence)
) {
void destroy("protocol_violation");
return;
}
if (message.type === "READY" && message.sequence === 0 && !ready) {
ready = true;
clearTimeout(readyTimer);
resolveReady();
return;
}
if (message.type !== "RENDERED") {
void destroy("unexpected_message_type");
return;
}
const waiter = pending.get(message.sequence);
if (!waiter) {
void destroy("unknown_or_replayed_sequence");
return;
}
try {
if (
!/^[0-9a-f]{64}$/.test(message.textDigest) ||
message.textDigest !== waiter.textDigest
) {
throw new Error("iframe returned a non-canonical or substituted digest");
}
const priorChainHeadSha256 = chainHeadSha256;
const nextChainHeadSha256 = await digestUtf8([
priorChainHeadSha256,
String(message.sequence),
waiter.textDigest,
String(waiter.textBytes),
channelId,
policy.policySha256,
String(policy.generation),
location.origin,
frame.getAttribute("sandbox") || ""
].join("\u0000"));
const receipt = await evidenceClient.appendAndVerify({
type: "sandboxed_widget_exchange",
channelId,
sequence: message.sequence,
textSha256: waiter.textDigest,
textBytes: waiter.textBytes,
priorChainHeadSha256,
chainHeadSha256: nextChainHeadSha256,
parentOrigin: location.origin,
iframeOrigin: "opaque",
sandbox: frame.getAttribute("sandbox"),
policySha256: policy.policySha256,
policyGeneration: policy.generation
});
if (!receipt || receipt.chainHeadSha256 !== nextChainHeadSha256) {
throw new Error("iframe evidence readback does not bind the digest chain");
}
chainHeadSha256 = nextChainHeadSha256;
nextResponseSequence += 1;
pending.delete(message.sequence);
clearTimeout(waiter.timer);
waiter.resolve(message.textDigest);
} catch (error) {
waiter.reject(error);
void destroy("invalid_reply");
}
};
channel.port1.onmessageerror = () => {
void destroy("message_deserialization_failure");
};
channel.port1.start();
frame.addEventListener("load", () => {
loadCount += 1;
if (loadCount !== 1 || !frame.contentWindow) {
void destroy("frame_navigation_or_replacement");
return;
}
// The target must be "*" because the sandboxed frame has an opaque origin.
// Safety comes from the fresh port and intentionally non-sensitive protocol.
frame.contentWindow.postMessage(
{ type: "BOOTSTRAP", channelId },
"*",
[channel.port2]
);
});
container.replaceChildren(frame);
await readyPromise;
await evidenceClient.appendAndVerify({
type: "sandboxed_widget_created",
channelId,
sandbox: frame.getAttribute("sandbox"),
shellSha256: policy.widgetShellSha256,
artifactFetchTimeoutMs: policy.artifactFetchTimeoutMs,
policySha256: policy.policySha256
});
return {
channelId,
async renderText(untrustedText) {
if (closed || !ready) throw new Error("sandbox is not ready");
const text = validateText(untrustedText, policy.maxTextBytes);
const textBytes = new TextEncoder().encode(text).byteLength;
const textDigest = await digestUtf8(text);
const requestSequence = ++sequence;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pending.delete(requestSequence);
reject(new Error("sandbox operation timed out"));
void destroy("operation_timeout");
}, policy.operationTimeoutMs);
pending.set(requestSequence, {
resolve, reject, timer, textDigest, textBytes
});
channel.port1.postMessage({
type: "RENDER_TEXT",
channelId,
sequence: requestSequence,
text
});
});
},
close: () => destroy("controller_close")
};
}
Digest-pinned frame shell
<!doctype html>
<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; connect-src 'none'; form-action 'none'; base-uri 'none'">
<style>html,body{margin:0}#output{white-space:pre-wrap;overflow-wrap:anywhere}</style>
<div id="output" aria-live="polite"></div>
<script>
"use strict";
let capabilityPort = null;
let channelId = null;
let lastSequence = 0;
const output = document.getElementById("output");
function digestHex(text) {
return crypto.subtle.digest(
"SHA-256", new TextEncoder().encode(text)
).then((buffer) => Array.from(new Uint8Array(buffer))
.map((value) => value.toString(16).padStart(2, "0"))
.join(""));
}
function bootstrap(event) {
const message = event.data;
if (
capabilityPort ||
event.source !== parent ||
!message ||
message.type !== "BOOTSTRAP" ||
typeof message.channelId !== "string" ||
event.ports.length !== 1
) {
return;
}
removeEventListener("message", bootstrap);
channelId = message.channelId;
capabilityPort = event.ports[0];
capabilityPort.onmessage = async (requestEvent) => {
const request = requestEvent.data;
if (
!request ||
request.type !== "RENDER_TEXT" ||
request.channelId !== channelId ||
!Number.isSafeInteger(request.sequence) ||
request.sequence !== lastSequence + 1 ||
typeof request.text !== "string"
) {
capabilityPort.close();
location.replace("about:blank");
return;
}
lastSequence = request.sequence;
// Never parse model output as HTML or script.
output.textContent = request.text;
capabilityPort.postMessage({
type: "RENDERED",
channelId,
sequence: request.sequence,
textDigest: await digestHex(request.text)
});
};
capabilityPort.onmessageerror = () => {
capabilityPort.close();
location.replace("about:blank");
};
capabilityPort.start();
capabilityPort.postMessage({ type: "READY", channelId, sequence: 0 });
}
addEventListener("message", bootstrap);
</script>
Deployment and evidence
The shell is trusted code whose bytes are pinned by signed policy; only the rendered model text is untrusted. Keep allow-same-origin, forms, popups, downloads, storage access, and top-navigation absent. Enforce an equivalent or stronger CSP as an HTTP header for URL-based shells; the meta policy shown here is for the self-contained srcdoc shell and cannot provide frame-ancestors. Do not send credentials, tokens, DOM/native handles, or privileged commands through the channel. Destroy the frame on any later load, task close, logout, timeout, malformed message, policy change, or parent navigation. Retain shell/policy digests, effective sandbox/CSP capture, ordered message digests, lifecycle receipts, and negative-test results. This step does not claim that a sandboxed frame is a complete egress boundary.
Verify safely
inspect effective sandbox flags and replay traffic while testing opaque origin, frame replacement, forged messages, navigation, popup, form, and storage.
Tear down on creation-evidence failure
The controller is not usable until the sandboxed_widget_created append is verified. Wrap that append in try/catch; on failure set closed, clear the readiness timer, reject every pending request, close both ports, and remove the frame before rethrowing. Do not call the normal evidence-writing destroy() as the only cleanup because the same sink may be unavailable.
try {
await evidenceClient.appendAndVerify({
type: "sandboxed_widget_created",
channelId,
sandbox: frame.getAttribute("sandbox"),
shellSha256: policy.widgetShellSha256,
artifactFetchTimeoutMs: policy.artifactFetchTimeoutMs,
policySha256: policy.policySha256,
policyGeneration: policy.generation
});
} catch (error) {
closed = true;
clearTimeout(readyTimer);
for (const waiter of pending.values()) {
clearTimeout(waiter.timer);
waiter.reject(new Error("sandbox creation evidence unavailable"));
}
pending.clear();
channel.port1.close();
try { channel.port2.close(); } catch {}
frame.remove();
throw error;
}Replace the earlier unguarded creation-evidence append with this block. No renderText() capability is returned until it succeeds.
AID-I-007-G003
Security boundary and limits
WebAssembly receives no browser, network, filesystem, clipboard, camera, DOM, or native capability unless the host imports one. Verify the exact module and import population before instantiation. This is a capability boundary, not a complete resource-exhaustion boundary: execute the module inside the verified Worker from #1 and terminate that worker on signed-policy timeout or resource breach. Do not claim instruction, CPU, or memory isolation that the browser API cannot independently prove.
Verified no-import module adapter
// File: wasm/verified-module.js
// Signed policy includes maxModuleBytes, artifactFetchTimeoutMs,
// maxObservedMemoryBytes, maxEntryArguments, and exact module bindings.
function hex(buffer) {
return Array.from(new Uint8Array(buffer))
.map((value) => value.toString(16).padStart(2, "0"))
.join("");
}
function requirePositivePolicyInteger(value, field) {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(field + " must be a signed positive safe integer");
}
}
function requireNonzeroSha256(value, field) {
if (!/^[0-9a-f]{64}$/.test(value) || /^0{64}$/.test(value)) {
throw new Error(field + " must be a nonzero lowercase SHA-256 digest");
}
}
async function fetchBounded(urlText, maxBytes, fetchTimeoutMs) {
requirePositivePolicyInteger(maxBytes, "maxModuleBytes");
requirePositivePolicyInteger(fetchTimeoutMs, "artifactFetchTimeoutMs");
const url = new URL(urlText, self.location.href);
if (
self.location.protocol !== "https:" ||
url.protocol !== "https:" ||
url.username ||
url.password ||
url.hash ||
url.origin !== self.location.origin
) {
throw new Error("WASM artifact must use the canonical worker HTTPS origin");
}
if (typeof AbortSignal.timeout !== "function") {
throw new Error("deadline-capable AbortSignal is unavailable");
}
const response = await fetch(url, {
cache: "no-store",
credentials: "omit",
redirect: "error",
signal: AbortSignal.timeout(fetchTimeoutMs)
});
if (!response.ok || !response.body || response.url !== url.href) {
throw new Error("WASM fetch failed or redirected");
}
const reader = response.body.getReader();
const chunks = [];
let size = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const bytes = new Uint8Array(value);
size += bytes.byteLength;
if (size > maxBytes) {
await reader.cancel();
throw new Error("WASM artifact exceeds signed policy");
}
chunks.push(bytes);
}
} finally {
reader.releaseLock();
}
const artifact = new Uint8Array(size);
let offset = 0;
for (const chunk of chunks) {
artifact.set(chunk, offset);
offset += chunk.byteLength;
}
return artifact;
}
function exactDescriptors(items) {
return items
.map((item) => ({
module: item.module,
name: item.name,
kind: item.kind
}))
.sort((left, right) =>
JSON.stringify(left).localeCompare(JSON.stringify(right))
);
}
export async function instantiateVerifiedWasm(policy, evidenceClient) {
requirePositivePolicyInteger(policy.maxModuleBytes, "maxModuleBytes");
requirePositivePolicyInteger(
policy.artifactFetchTimeoutMs,
"artifactFetchTimeoutMs"
);
requirePositivePolicyInteger(
policy.maxObservedMemoryBytes,
"maxObservedMemoryBytes"
);
requirePositivePolicyInteger(
policy.maxEntryArguments,
"maxEntryArguments"
);
requireNonzeroSha256(policy.moduleSha256, "moduleSha256");
requireNonzeroSha256(policy.policySha256, "policySha256");
const bytes = await fetchBounded(
policy.moduleUrl,
policy.maxModuleBytes,
policy.artifactFetchTimeoutMs
);
const observedDigest = hex(
await crypto.subtle.digest("SHA-256", bytes)
);
if (observedDigest !== policy.moduleSha256) {
throw new Error("WASM artifact digest mismatch");
}
if (!WebAssembly.validate(bytes)) {
throw new Error("WASM binary validation failed");
}
const module = new WebAssembly.Module(bytes);
const imports = exactDescriptors(WebAssembly.Module.imports(module));
const exports = exactDescriptors(WebAssembly.Module.exports(module));
if (
JSON.stringify(imports) !==
JSON.stringify(exactDescriptors(policy.allowedImports)) ||
JSON.stringify(exports) !==
JSON.stringify(exactDescriptors(policy.expectedExports))
) {
throw new Error("WASM import/export population differs from signed policy");
}
if (imports.length !== 0) {
throw new Error("this no-capability adapter refuses all host imports");
}
const instance = await WebAssembly.instantiate(module, {});
const entry = instance.exports[policy.entryExport];
if (typeof entry !== "function") {
throw new Error("signed-policy entry export is not callable");
}
const args = policy.entryArguments;
if (
!Array.isArray(args) ||
args.length > policy.maxEntryArguments ||
args.some((value) =>
typeof value !== "number" || !Number.isFinite(value)
)
) {
throw new Error("entry arguments violate signed policy");
}
const result = entry.apply(undefined, args);
if (typeof result !== "number" || !Number.isFinite(result)) {
throw new Error("WASM output is not one finite numeric value");
}
if (
instance.exports.memory instanceof WebAssembly.Memory &&
instance.exports.memory.buffer.byteLength > policy.maxObservedMemoryBytes
) {
throw new Error("observed WASM memory exceeds signed policy");
}
const event = {
type: "wasm_execution",
moduleSha256: observedDigest,
policySha256: policy.policySha256,
artifactFetchTimeoutMs: policy.artifactFetchTimeoutMs,
runtimeVersion: navigator.userAgent,
imports,
exports,
entryExport: policy.entryExport,
argumentCount: args.length,
maxEntryArguments: policy.maxEntryArguments,
maxObservedMemoryBytes: policy.maxObservedMemoryBytes,
outputSha256: hex(
await crypto.subtle.digest(
"SHA-256", new TextEncoder().encode(String(result))
)
),
observedMemoryBytes:
instance.exports.memory instanceof WebAssembly.Memory
? instance.exports.memory.buffer.byteLength
: 0,
outcome: "PASS"
};
const receipt = await evidenceClient.append(event);
const readback = await evidenceClient.read(receipt.receiptId);
if (readback.eventSha256 !== receipt.eventSha256) {
throw new Error("WASM evidence readback mismatch");
}
return { result, evidenceReceiptId: receipt.receiptId };
}
Capability-bearing variants
If the module requires imports, create a different reviewed adapter for one named capability set. Compare the complete WebAssembly.Module.imports() list with signed policy and provide only bounded functions that validate pointer/range, length, encoding, identity, rate state, destination, and result before reading or writing module memory. Never pass raw fetch, filesystem/native bridges, credentials, or unrestricted logging. Pin browser/runtime and module versions, run inside a fresh Worker, and retain module/policy digests, complete import/export sets, host-call population, resource observations, output digest, termination event, and independent readback. Exercise unexpected imports/exports, artifact redirect/substitution, malformed binary, oversized module, memory growth, trap, timeout, invalid pointer, host-call abuse, and evidence outage.
AID-I-007-G004
Concept:
Content Security Policy (CSP) lets you define which network endpoints scripts in this page are allowed to talk to (connect-src), which scripts can run (script-src), and more. A strict CSP makes it much harder for a compromised in-browser model to exfiltrate sensitive data to an attacker-controlled domain, or to inject arbitrary remote scripts. Vendor and checksum-verify the AI runtime in the build; do not add a public CDN to the runtime trust boundary.
Example CSP header
Header format: Send this as one HTTP header field value. Do not copy folded or multiline formatting into the actual response.
Content-Security-Policy: default-src 'none'; script-src 'sha256-BASE64_BUILD_SCRIPT_DIGEST'; style-src 'sha256-BASE64_BUILD_STYLE_DIGEST'; worker-src blob:; connect-src https://api.my-trusted-domain.com; img-src data:; frame-src 'none'; child-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none';
This policy says:
- Run only the build-generated script/style hashes; workers may use only the digest-verified blob URL created by AID-I-007-G001.
- Allow outbound fetch/WebSocket/XHR only to the exact trusted API origin; same-origin egress is not granted implicitly.
- Disallow being iframed elsewhere (
frame-ancestors 'none'), which helps protect your privileged parent app from clickjacking or hostile embedding.
Meta tag fallback (if you cannot set headers)
Prefer the HTTP header when possible. A meta-delivered policy cannot enforce directives such as frame-ancestors, so do not treat it as equivalent for clickjacking protection.
<head>
<meta http-equiv="Content-Security-Policy"
content="default-src 'none'; connect-src https://api.my-trusted-domain.com; script-src 'sha256-BASE64_BUILD_SCRIPT_DIGEST'; worker-src blob:; object-src 'none'; base-uri 'none'; form-action 'none';">
</head>
Detection tip: CSP can also emit violation reports (via report-to / report-uri). Treat repeated CSP violations from a given session as a potential sign of a compromised model trying to leak data.
Deployment verification: Generate the concrete script/style hashes and exact API origins from the signed build manifest; never ship the placeholders above. Fetch the deployed document through the same edge/CDN route users receive, read back the response header, and run browser probes that attempt unlisted same-origin and cross-origin fetch, script, worker, frame, form, object, base, and navigation paths. Missing response-header authority is an unresolved provider-evidence gap, not PASS; a meta-only policy cannot satisfy the response-header or frame-ancestors claim. Monitor CSP violation reports for attempted exfiltration or unexpected loads.
CSP does not provide a supported navigate-to directive. Keep model code in the DOM-less worker, and render untrusted output through AID-H-006.002. If a preview uses an iframe, omit allow-top-navigation, allow-popups and allow-forms from its sandbox; only the trusted parent navigation broker may open an approved destination. Verify blocked top-level navigation, popup and form submission through the actual worker/iframe boundary separately from CSP resource-fetch tests.
AID-I-007-G005
Bridge boundary
The renderer receives named functions, never ipcRenderer, arbitrary channel names, Node globals, callbacks containing the IPC event, filesystem paths, credentials, or a generic native-call function. Keep contextIsolation, renderer sandboxing, and nodeIntegration: false; validate the sender frame for every IPC call because child frames can also send IPC. A separately authenticated native broker applies signed capability policy, validation, redaction, rate state, and append-only evidence. This reference is Electron-specific; mobile/hybrid platforms need an equivalent broker and effective-permission readback.
Sandboxed window and main-process handler
// File: main.cjs
const path = require("node:path");
const { createHash } = require("node:crypto");
const { app, BrowserWindow, ipcMain, session } = require("electron");
// Injected at the composition root after signature verification.
const signedPolicy = require("./verified-native-bridge-policy.cjs");
const nativeBroker = require("./native-broker-client.cjs");
const evidence = require("./evidence-client.cjs");
app.enableSandbox();
const registeredWindows = new Map();
function canonicalFrameUrlSha256(value) {
const url = new URL(value);
if (
url.protocol !== "app:" ||
url.host !== "bundle" ||
url.pathname !== "/index.html" ||
url.username || url.password || url.search || url.hash
) {
throw new Error("main-frame URL is outside the signed application origin");
}
return "sha256:" + createHash("sha256").update(url.href, "utf8").digest("hex");
}
function exactTrustedFrame(frame, webContentsId) {
if (!frame || frame.top !== frame) return false;
const url = new URL(frame.url);
return (
registeredWindows.get(webContentsId) === frame.url &&
url.protocol === "app:" &&
url.host === "bundle" &&
url.pathname === "/index.html" &&
url.username === "" &&
url.password === "" &&
url.search === "" &&
url.hash === ""
);
}
function plainObject(value) {
return (
value !== null &&
typeof value === "object" &&
!Array.isArray(value) &&
Object.getPrototypeOf(value) === Object.prototype
);
}
function validateTelemetryRequest(value) {
if (
!plainObject(value) ||
!/^[0-9a-f]{32}$/.test(value.requestId) ||
typeof value.text !== "string" ||
Buffer.byteLength(value.text, "utf8") >
signedPolicy.maxTelemetryBytes ||
Object.keys(value).sort().join(",") !== "requestId,text"
) {
throw new Error("native bridge request violates signed policy");
}
return { requestId: value.requestId, text: value.text };
}
async function createWindow() {
const window = new BrowserWindow({
show: false,
webPreferences: {
preload: path.join(app.getAppPath(), "preload.cjs"),
contextIsolation: true,
nodeIntegration: false,
nodeIntegrationInWorker: false,
sandbox: true,
webSecurity: true,
allowRunningInsecureContent: false,
experimentalFeatures: false
}
});
const webContentsId = window.webContents.id;
window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
window.webContents.on("will-navigate", (event, target) => {
if (target !== "app://bundle/index.html") event.preventDefault();
});
window.webContents.on("will-attach-webview", (event) => {
event.preventDefault();
});
window.webContents.on("destroyed", () => {
registeredWindows.delete(webContentsId);
});
await window.loadURL("app://bundle/index.html");
const effectiveUrl = window.webContents.mainFrame.url;
if (
effectiveUrl !== "app://bundle/index.html" ||
canonicalFrameUrlSha256(effectiveUrl) !== signedPolicy.mainFrameUrlSha256
) {
window.destroy();
throw new Error("packaged application URL or signed digest mismatch");
}
registeredWindows.set(webContentsId, effectiveUrl);
window.show();
return window;
}
session.defaultSession.setPermissionCheckHandler(() => false);
session.defaultSession.setPermissionRequestHandler(
(_webContents, _permission, callback) => callback(false)
);
ipcMain.handle("secure-bridge:telemetry", async (event, raw) => {
if (!exactTrustedFrame(event.senderFrame, event.sender.id)) {
throw new Error("untrusted IPC sender");
}
const request = validateTelemetryRequest(raw);
const effectiveFrameUrlSha256 = canonicalFrameUrlSha256(event.senderFrame.url);
if (effectiveFrameUrlSha256 !== signedPolicy.mainFrameUrlSha256) {
throw new Error("effective main-frame digest differs from signed policy");
}
const principal = {
appId: signedPolicy.appId,
webContentsId: event.sender.id,
frameUrlSha256: effectiveFrameUrlSha256
};
const brokerReceipt = await nativeBroker.callAndVerify({
capability: "SEND_REDACTED_TELEMETRY",
principal,
request,
policySha256: signedPolicy.policySha256,
idempotencyKey: request.requestId
});
if (
brokerReceipt.capability !== "SEND_REDACTED_TELEMETRY" ||
brokerReceipt.requestId !== request.requestId ||
brokerReceipt.policySha256 !== signedPolicy.policySha256
) {
throw new Error("native broker receipt binding mismatch");
}
const eventReceipt = await evidence.appendAndVerify({
control: "AID-I-007",
guidanceRef: "AID-I-007-G005",
capability: brokerReceipt.capability,
requestId: request.requestId,
principal,
inputSha256: brokerReceipt.inputSha256,
redactedOutputSha256: brokerReceipt.redactedOutputSha256,
brokerReceiptSha256: brokerReceipt.receiptSha256,
policySha256: signedPolicy.policySha256,
outcome: "PASS"
});
return Object.freeze({
accepted: true,
requestId: request.requestId,
evidenceReceiptId: eventReceipt.receiptId
});
});
app.whenReady().then(createWindow);
app.on("window-all-closed", () => app.quit());
Minimal preload API
// File: preload.cjs
const { contextBridge, ipcRenderer } = require("electron");
function validateRendererInput(value) {
if (
!value ||
typeof value !== "object" ||
Array.isArray(value) ||
!/^[0-9a-f]{32}$/.test(value.requestId) ||
typeof value.text !== "string"
) {
throw new TypeError("invalid telemetry request");
}
return { requestId: value.requestId, text: value.text };
}
contextBridge.exposeInMainWorld("secureBridge", Object.freeze({
sendRedactedTelemetry(input) {
return ipcRenderer.invoke(
"secure-bridge:telemetry", validateRendererInput(input)
);
}
}));
Deployment and verification
Register the packaged app: protocol as standard/secure before use, sign and verify the application bundle and preload/main files, and generate verified-native-bridge-policy.cjs from a signed versioned policy at deployment. Use Electron fuses and package inspection to prove Node integration, CLI inspection, and unsafe debugging features are not re-enabled. The native broker uses mTLS workload identity, accepts only enumerated capabilities, independently resolves any native resource, enforces policy-derived size/rate/destination limits, strips secrets, and returns signed receipts; it never trusts renderer-supplied identity or path. Preserve effective BrowserWindow preferences, permission handlers, navigation/window-open probes, bundle/bridge/policy digests, complete IPC population, broker receipts, and independent evidence readback. Test subframe/sender spoofing, arbitrary channel, prototype and oversized input, navigation, popup/webview, permission requests, broker/evidence outage, replay, and packaged setting drift. Keep Electron current through the product patch process; PASS applies only to the measured packaged build and capability set.
Account for pre-broker denials
Every invocation of secure-bridge:telemetry belongs to the IPC population, including attempts rejected before nativeBroker.callAndVerify(). Add a main-process monotonic sequence and record a bounded observed sender/request shape; never serialize attacker-controlled payloads merely for logging.
let bridgeAttemptSequence = 0;
async function denyBeforeBroker(event, raw, reason) {
const sequence = ++bridgeAttemptSequence;
const shape = {
valueType: raw === null ? "null" : Array.isArray(raw) ? "array" : typeof raw,
keys: plainObject(raw) ? Object.keys(raw).sort().slice(0, 16) : [],
keyCount: plainObject(raw) ? Object.keys(raw).length : 0
};
const record = {
control: "AID-I-007",
guidanceRef: "AID-I-007-G005",
outcome: "DENY",
reason,
attemptSequence: sequence,
brokerInvoked: false,
webContentsId: event.sender.id,
observedFrameUrl: String(event.senderFrame?.url || ""),
requestShape: shape,
policySha256: signedPolicy.policySha256
};
const receipt = await evidence.appendAndVerify(record);
if (!receipt || receipt.attemptSequence !== sequence) {
throw new Error("bridge denial evidence unavailable");
}
throw new Error("native bridge request denied");
}
// Use before any broker call:
async function validateBeforeBroker(event, raw) {
if (!exactTrustedFrame(event.senderFrame, event.sender.id)) {
return denyBeforeBroker(event, raw, "UNTRUSTED_SENDER");
}
try {
return validateTelemetryRequest(raw);
} catch {
return denyBeforeBroker(event, raw, "INVALID_REQUEST_SCHEMA");
}
}
// Inside the IPC handler, before nativeBroker.callAndVerify():
const request = await validateBeforeBroker(event, raw);Replace the handler's corresponding early throws with these calls. Reconcile attemptSequence across allowed and denied terminal records; an evidence outage still denies the bridge and leaves the population unresolved.
Open-source tools
- Wasmtime
- Wasmer
- TensorFlow.js (browser inference runtime; isolation comes from worker, iframe, and CSP controls)
- ONNX Runtime Web (browser inference runtime; isolation comes from worker, iframe, and CSP controls)
- Android Open Source Project (application sandbox and permission enforcement)
Commercial / hosted tools
- Apple App Sandbox
- Microsoft Intune App Protection Policies (managed-app data boundaries; not a model sandbox)
- Android Enterprise Work Profile
- Samsung Knox Platform for Enterprise
Threat mappings
MITRE ATLAS
- AML.T0025 Exfiltration via Cyber Means (from client device)
- AML.T0037 Data from Local System (stealing browser/app state, session tokens, local storage)
- AML.T0053 AI Agent Tool Invocation (client-side isolation prevents compromised model from invoking host tools)
- AML.T0077 LLM Response Rendering (client-side sandboxing contains render-time attacks from model output)
- AML.T0112.000 Machine Compromise: Local AI Agent (client-side isolation constrains a compromised local agent before it can become full machine compromise)
- AML.T0112 Machine Compromise
- AML.T0113 Steal Web Session Cookie (sandboxing prevents hostile client-side AI from reading cookies or browser state)
- AML.T0114 AI Service Web Interface (CSP and capability isolation constrain browser/WebView relay channels)
- AML.T0089 Enterprise Environment Discovery (isolated client-side runtimes cannot enumerate other tabs, local application context, or the host OS)
MAESTRO
- Data Exfiltration (L2) (from the client)
- Lateral Movement (Cross-Layer) (isolation prevents compromised client model from accessing other apps)
- Compromised Framework Components (L3) (isolating hostile client-side JS/WASM runtimes)
- Data Leakage (Cross-Layer) (sandboxing prevents cross-app data exposure)
- Agent Tool Misuse (L7) (client-side sandboxing prevents tool misuse by compromised client model)
OWASP LLM Top 10 2026
- LLM01:2026 Prompt Injection (client sandboxing contains a prompt-injected model runtime)
- LLM02:2026 Sensitive Information Disclosure (client isolation blocks access to unrelated DOM, token, and application state)
- LLM03:2026 Excessive Agency (minimal native bridges constrain model access to device capabilities)
- LLM04:2026 Supply Chain (client isolation contains malicious downloaded model or runtime code)
- LLM10:2026 Improper Output Handling (client isolation and CSP contain model-generated active content)
OWASP ML Top 10 2023
- ML06:2023 AI Supply Chain Attacks (containing a malicious downloaded client-side model/runtime so it cannot pivot)
OWASP Top 10 for Agentic Applications 2026
- ASI05:2026 Unexpected Code Execution (RCE)
- ASI02:2026 Tool Misuse and Exploitation (client-side isolation prevents tool misuse by compromised model)
- ASI03:2026 Identity and Privilege Abuse (client-side isolation prevents identity/privilege abuse by compromised model)
NIST Adversarial Machine Learning 2025
- NISTAML.039 Compromising connected resources
Cisco Integrated AI Security and Safety Framework
- AISubtech-12.1.3 Unsafe System / Browser / File Execution (client-side runtime and native capabilities confine effects of model or tool coercion)
- AISubtech-14.1.2 Insufficient Access Controls (sandboxed renderer and narrow native broker restrict access to the host system)
- AISubtech-4.3.1 Schema Inconsistencies (typed capability ports and exact frame validation protect client communication boundaries)
- AISubtech-4.3.6 Cross-Origin Exploitation (typed capability ports and exact frame validation protect client communication boundaries)
- AISubtech-8.2.3 Data Exfiltration via Agent Tooling (client runtime data reach, IPC payloads and outbound destinations are restricted)
- AISubtech-9.1.1 Code Execution (client-side runtime and native capabilities confine effects of model or tool coercion)
- AISubtech-9.1.2 Unauthorized or Unsolicited System Access (client-side runtime and native capabilities confine effects of model or tool coercion)
- AISubtech-9.1.3 Unauthorized or Unsolicited Network Access (client-side runtime and native capabilities confine effects of model or tool coercion)
- AITech-12.1 Tool Exploitation (client-side runtime and native capabilities confine effects of model or tool coercion)
- AITech-12.2 Insecure Output Handling (model output is rendered as inert text inside an opaque-origin sandbox)
- AITech-14.1 Unauthorized Access (sandboxed renderer and narrow native broker restrict access to the host system)
- AITech-8.2 Data Exfiltration / Exposure (client runtime data reach, IPC payloads and outbound destinations are restricted)
- AITech-9.1 Model or Agentic System Manipulation (client-side runtime and native capabilities confine effects of model or tool coercion)
Google Secure AI Framework 2.0 - Risks
- SDD: Sensitive Data Disclosure (client-side sandboxing prevents data leakage to other browser tabs or apps)
- IIC: Insecure Integrated Component (client-side isolation restricts compromised model capabilities)
- RA: Rogue Actions (client-side sandbox prevents rogue model from accessing host system)
Databricks AI Security Framework 3.0
- Agents - Core 13.11: Unexpected RCE and Code Attacks (client-side sandboxing prevents code execution on host)
- Agents - Tools MCP Server 13.23: Data Exfiltration (CSP and sandbox restrictions block client-side exfiltration)
- Agents - Tools MCP Client 13.32: Client-Side Code Execution
- Agents - Tools MCP Client 13.30: Client-Side Data Leakage (client-side isolation prevents local data leakage)