From ed7cac833c77c41b7c7ad2cd9ba92f9a81162f91 Mon Sep 17 00:00:00 2001 From: Aanish Bhirud <47579874+baanish@users.noreply.github.com> Date: Tue, 5 May 2026 15:19:07 -0400 Subject: [PATCH 1/5] docs: add public security page --- src/app/globals.css | 38 ++++++++++++ src/app/security/page.tsx | 107 ++++++++++++++++++++++++++++++++ src/components/viewer-shell.tsx | 20 ++++-- tests/e2e/viewer.spec.ts | 13 ++++ 4 files changed, 174 insertions(+), 4 deletions(-) create mode 100644 src/app/security/page.tsx diff --git a/src/app/globals.css b/src/app/globals.css index c69e4d5..4b6e41e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -198,6 +198,23 @@ select { border-bottom: 1px solid var(--border); } +.nav-text-link { + color: var(--text-muted); + font-size: 0.9rem; + font-weight: 600; + line-height: 1.4; + transition: color 150ms ease; +} + +.nav-text-link:hover { + color: var(--text-primary); +} + +.nav-text-link:focus-visible { + outline: 2px solid color-mix(in srgb, var(--accent) 48%, transparent); + outline-offset: 4px; +} + /* ── Full-bleed editorial sections ── */ .home-hero-section { padding-top: 2rem; @@ -244,6 +261,27 @@ select { } } +.site-footer { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 1rem; + border-top: 1px solid var(--border); + padding-top: 1.25rem; + color: var(--text-soft); + font-family: var(--font-mono), monospace; + font-size: 0.78rem; +} + +.site-footer a { + color: var(--text-muted); +} + +.site-footer a:hover { + color: var(--text-primary); +} + /* ── Bento grid — tonal separation via 1px gaps ── */ .bento-grid { display: grid; diff --git a/src/app/security/page.tsx b/src/app/security/page.tsx new file mode 100644 index 0000000..4b64966 --- /dev/null +++ b/src/app/security/page.tsx @@ -0,0 +1,107 @@ +import Link from "next/link"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Security - agent-render", + description: "Security notes for agent-render static artifact links, markdown rendering, Mermaid, CSP, and reports.", +}; + +const sections = [ + { + title: "What reaches the server", + body: [ + "Static mode sends HTML, CSS, and JavaScript to the browser. It does not send artifact payloads to the static host.", + "Artifact payloads stay in the URL fragment and are not sent to the static host as part of the HTTP request path, query string, or request body.", + "The server can still receive normal static asset requests, IP address, user agent, referrer headers, and access logs from the hosting layer.", + ], + }, + { + title: "What can still leak", + body: [ + "agent-render is zero-retention by host design. It is not a secret manager.", + "Artifact contents can still leak through copied URLs, browser history, bookmarks, screenshots, screen sharing, crash reports, extensions, referrer behavior, and future client-side analytics if someone adds them.", + "Do not put secrets, credentials, private keys, production tokens, or regulated data in artifact links.", + ], + }, + { + title: "Markdown and Mermaid", + body: [ + "Markdown artifacts are rendered as GitHub-flavored Markdown and passed through rehype-sanitize before display.", + "React Markdown is configured with skipHtml, so raw HTML embedded in markdown is skipped instead of rendered.", + "Mermaid diagrams are only rendered from fenced mermaid code blocks. Mermaid runs with securityLevel: \"strict\" and falls back to showing source text if rendering fails.", + ], + }, + { + title: "CSP and security headers", + body: [ + "The default static export does not require a runtime server. Configure Content-Security-Policy and other security headers at your static host or CDN.", + "Recommended headers include a restrictive Content-Security-Policy, Referrer-Policy, X-Content-Type-Options, Permissions-Policy, and HSTS when served over HTTPS.", + "If you loosen CSP for a custom deployment, review markdown, Mermaid, fonts, images, and script sources together before publishing.", + ], + }, + { + title: "Known limitations", + body: [ + "URL fragments are client-side, but they are still visible to the browser, local machine, extensions, and anyone who receives the link.", + "Self-hosted UUID mode is a different deployment mode and stores payloads server-side by design.", + "The viewer treats payloads as untrusted input, but the safest policy is to keep sensitive material out of links entirely.", + ], + }, +] as const; + +/** + * Public security page documenting the static host boundary and renderer safety posture. + * Keeps the copy direct and linkable for operators, reviewers, and security reports. + */ +export default function SecurityPage() { + return ( +
+
+ + Agent Render + +
+ +
+
+

Public security notes

+

Security

+

+ agent-render is a static artifact viewer. The core security boundary is simple: artifact data belongs in + the URL fragment and is decoded in the browser. +

+
+ +
+ {sections.map((section) => ( +
+

{section.title}

+
    + {section.body.map((item) => ( +
  • {item}
  • + ))} +
+
+ ))} +
+ +
+

Reports

+

Security contact

+

+ Report security issues through the GitHub repository. Use a private vulnerability report when available; + otherwise open a minimal issue asking for a private contact path and do not include exploit details in public. +

+ + Open a private GitHub security advisory + +
+
+
+ ); +} diff --git a/src/components/viewer-shell.tsx b/src/components/viewer-shell.tsx index da7a583..5da8403 100644 --- a/src/components/viewer-shell.tsx +++ b/src/components/viewer-shell.tsx @@ -2,6 +2,7 @@ import dynamic from "next/dynamic"; import Image from "next/image"; +import Link from "next/link"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { CSSProperties } from "react"; import type { LucideIcon } from "lucide-react"; @@ -481,7 +482,12 @@ export function ViewerShell() {

Agent Render

- +
+ + Security + + +
@@ -795,12 +801,13 @@ export function ViewerShell() {

{step}

))} -
+

Security

-

+ Read the security page +

The payload never leaves the URL hash. Rendering is entirely client-side.

-
+

Hosting

@@ -811,6 +818,11 @@ export function ViewerShell() { )} + +

+ agent-render + Security +
); diff --git a/tests/e2e/viewer.spec.ts b/tests/e2e/viewer.spec.ts index 4756de0..0c3e693 100644 --- a/tests/e2e/viewer.spec.ts +++ b/tests/e2e/viewer.spec.ts @@ -18,6 +18,19 @@ test("renders the empty state", async ({ page }) => { await expect(page.getByText("Share artifacts in the URL, keep the server out of the payload.")).toBeVisible(); }); +test("links to the public security page", async ({ page }) => { + await waitForViewerState(page, "empty"); + + await page.getByRole("link", { name: "Security" }).first().click(); + + await expect(page).toHaveURL(/\/security\/?$/); + await expect(page.getByRole("heading", { name: "Security" })).toBeVisible(); + await expect(page.getByText("Static mode sends HTML, CSS, and JavaScript to the browser.")).toBeVisible(); + await expect(page.getByText("Artifact payloads stay in the URL fragment and are not sent to the static host")).toBeVisible(); + await expect(page.getByText("React Markdown is configured with skipHtml")).toBeVisible(); + await expect(page.getByText("Mermaid runs with securityLevel: \"strict\"")).toBeVisible(); +}); + test("creates, copies, and previews a generated homepage link", async ({ page }) => { await waitForViewerState(page, "empty"); From d3aafd63660551d48f47d51e61f0adf244356dd0 Mon Sep 17 00:00:00 2001 From: Aanish Bhirud <47579874+baanish@users.noreply.github.com> Date: Thu, 7 May 2026 21:10:10 -0400 Subject: [PATCH 2/5] fix: tighten security page copy --- src/app/security/page.tsx | 8 ++++---- src/components/viewer-shell.tsx | 2 +- tests/e2e/viewer.spec.ts | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/app/security/page.tsx b/src/app/security/page.tsx index 4b64966..ad762a1 100644 --- a/src/app/security/page.tsx +++ b/src/app/security/page.tsx @@ -10,8 +10,8 @@ const sections = [ { title: "What reaches the server", body: [ - "Static mode sends HTML, CSS, and JavaScript to the browser. It does not send artifact payloads to the static host.", - "Artifact payloads stay in the URL fragment and are not sent to the static host as part of the HTTP request path, query string, or request body.", + "Static mode sends HTML, CSS, and JavaScript to the browser. Artifact payloads are not sent to the static host as part of the initial page request.", + "Fragment payloads stay out of the HTTP request path, query string, and request body for the static host.", "The server can still receive normal static asset requests, IP address, user agent, referrer headers, and access logs from the hosting layer.", ], }, @@ -67,8 +67,8 @@ export default function SecurityPage() {

Public security notes

Security

- agent-render is a static artifact viewer. The core security boundary is simple: artifact data belongs in - the URL fragment and is decoded in the browser. + agent-render is a static artifact viewer. Its core host boundary is simple: artifact data lives in the + URL fragment, so the static host does not receive it as part of the initial page request.

diff --git a/src/components/viewer-shell.tsx b/src/components/viewer-shell.tsx index 5da8403..4fa1eb9 100644 --- a/src/components/viewer-shell.tsx +++ b/src/components/viewer-shell.tsx @@ -805,7 +805,7 @@ export function ViewerShell() {

Security

Read the security page

- The payload never leaves the URL hash. Rendering is entirely client-side. + Fragment payloads stay out of the static host request path, but links are not secret-safe.

diff --git a/tests/e2e/viewer.spec.ts b/tests/e2e/viewer.spec.ts index 0c3e693..4ae896f 100644 --- a/tests/e2e/viewer.spec.ts +++ b/tests/e2e/viewer.spec.ts @@ -24,9 +24,9 @@ test("links to the public security page", async ({ page }) => { await page.getByRole("link", { name: "Security" }).first().click(); await expect(page).toHaveURL(/\/security\/?$/); - await expect(page.getByRole("heading", { name: "Security" })).toBeVisible(); - await expect(page.getByText("Static mode sends HTML, CSS, and JavaScript to the browser.")).toBeVisible(); - await expect(page.getByText("Artifact payloads stay in the URL fragment and are not sent to the static host")).toBeVisible(); + await expect(page.getByRole("heading", { name: "Security", exact: true })).toBeVisible(); + await expect(page.getByText("Artifact payloads are not sent to the static host as part of the initial page request.")).toBeVisible(); + await expect(page.getByText("Fragment payloads stay out of the HTTP request path")).toBeVisible(); await expect(page.getByText("React Markdown is configured with skipHtml")).toBeVisible(); await expect(page.getByText("Mermaid runs with securityLevel: \"strict\"")).toBeVisible(); }); From ace5a81ad2745d36c40da27d1196790fcb4d1302 Mon Sep 17 00:00:00 2001 From: Aanish Bhirud <47579874+baanish@users.noreply.github.com> Date: Thu, 7 May 2026 21:12:52 -0400 Subject: [PATCH 3/5] fix: clarify fragment privacy copy --- src/components/viewer-shell.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/viewer-shell.tsx b/src/components/viewer-shell.tsx index 4fa1eb9..6f8521c 100644 --- a/src/components/viewer-shell.tsx +++ b/src/components/viewer-shell.tsx @@ -613,7 +613,7 @@ export function ViewerShell() { Share artifacts in the URL, keep the server out of the payload.

- View markdown, code, diffs, CSV, and JSON from a single static link. Nothing leaves the browser. + View markdown, code, diffs, CSV, and JSON from a fragment link the static host does not receive.

@@ -753,7 +753,7 @@ export function ViewerShell() {

{activeArtifact ? `${getArtifactSubtitle(activeArtifact)} selected.` - : "Select a fragment above to render it here. Everything stays in the URL."} + : "Select a fragment above to render it here. Payloads stay off the host request path, but links still need care."}

From c3dd043504a7b216143b58746b8c356ed8118f3f Mon Sep 17 00:00:00 2001 From: Aanish Bhirud <47579874+baanish@users.noreply.github.com> Date: Thu, 7 May 2026 22:16:37 -0400 Subject: [PATCH 4/5] Fix homepage security link assertion --- tests/components/viewer-shell.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/viewer-shell.test.tsx b/tests/components/viewer-shell.test.tsx index 060c0e7..2fd3238 100644 --- a/tests/components/viewer-shell.test.tsx +++ b/tests/components/viewer-shell.test.tsx @@ -27,7 +27,7 @@ describe("ViewerShell homepage", () => { expect(screen.getByText(/browser history, screenshots, copied messages, extensions/i)).toBeVisible(); expect(screen.getByRole("link", { name: /github/i })).toBeVisible(); expect(screen.getByRole("link", { name: /payload format docs/i })).toBeVisible(); - expect(screen.getByRole("link", { name: /security page/i })).toBeVisible(); + expect(screen.getByRole("link", { name: /safety.*security page/i })).toBeVisible(); expect(screen.getByRole("link", { name: /openclaw/i })).toBeVisible(); }); }); From 5f877164cbac80de499ddfc6b755ce4bcaa84025 Mon Sep 17 00:00:00 2001 From: Aanish Bhirud <47579874+baanish@users.noreply.github.com> Date: Thu, 7 May 2026 22:23:20 -0400 Subject: [PATCH 5/5] Fix homepage e2e security link assertion --- tests/e2e/viewer.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/viewer.spec.ts b/tests/e2e/viewer.spec.ts index e62dee5..000b0f1 100644 --- a/tests/e2e/viewer.spec.ts +++ b/tests/e2e/viewer.spec.ts @@ -21,7 +21,7 @@ test("renders the zero-retention homepage when no fragment is present", async ({ await expect(page.getByText(/browser history, screenshots, copied messages, extensions/i)).toBeVisible(); await expect(page.getByRole("link", { name: /github/i })).toBeVisible(); await expect(page.getByRole("link", { name: /payload format docs/i })).toBeVisible(); - await expect(page.getByRole("link", { name: /security page/i })).toBeVisible(); + await expect(page.getByRole("link", { name: /safety.*security page/i })).toBeVisible(); await expect(page.getByRole("link", { name: /openclaw/i })).toBeVisible(); });