From 78fc2bd8f7213fb41d5286824b7bf49e10733973 Mon Sep 17 00:00:00 2001 From: Rohit C Prasad Date: Sat, 15 Aug 2026 10:38:58 -0700 Subject: [PATCH] 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. --- surfaces/gui/e2e/artifacts.spec.ts | 38 +++++++++++++++++++++++ surfaces/gui/e2e/fixtures.ts | 36 +++++++++++++++++++++ surfaces/gui/src/components/RightRail.tsx | 35 +++++++++++++++++++-- 3 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 surfaces/gui/e2e/artifacts.spec.ts diff --git a/surfaces/gui/e2e/artifacts.spec.ts b/surfaces/gui/e2e/artifacts.spec.ts new file mode 100644 index 00000000..0bcddcfe --- /dev/null +++ b/surfaces/gui/e2e/artifacts.spec.ts @@ -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(); +}); diff --git a/surfaces/gui/e2e/fixtures.ts b/surfaces/gui/e2e/fixtures.ts index 0fd085d7..0675eaa7 100644 --- a/surfaces/gui/e2e/fixtures.ts +++ b/surfaces/gui/e2e/fixtures.ts @@ -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: [ + "

Security review

", + '
script did not run
', + "", + '', + ].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]); diff --git a/surfaces/gui/src/components/RightRail.tsx b/surfaces/gui/src/components/RightRail.tsx index ac3ea669..ab6302bf 100644 --- a/surfaces/gui/src/components/RightRail.tsx +++ b/surfaces/gui/src/components/RightRail.tsx @@ -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 +// (). 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 = + '"; + +function sandboxHtml(html: string): string { + return ARTIFACT_CSP + html; +} + function ArtifactViewer({ sessionId, artifact, @@ -349,6 +365,20 @@ function ArtifactViewer({ )} + {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. + + )} {/* 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"). */}