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..ad762a1 --- /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. 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.", + ], + }, + { + 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. 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. +

+
+ +
+ {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 77a05d7..c7b5dd7 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"; @@ -493,7 +494,12 @@ export function ViewerShell() {

Agent Render

- +
+ + Security + + +
@@ -762,7 +768,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."}

@@ -810,12 +816,13 @@ export function ViewerShell() {

{step}

))} -
+

Security

-

- The payload never leaves the URL hash. Rendering is entirely client-side. + Read the security page +

+ Fragment payloads stay out of the static host request path, but links are not secret-safe.

-
+

Hosting

@@ -826,6 +833,11 @@ export function ViewerShell() { )} + +

); 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(); }); }); diff --git a/tests/e2e/viewer.spec.ts b/tests/e2e/viewer.spec.ts index c3762b2..000b0f1 100644 --- a/tests/e2e/viewer.spec.ts +++ b/tests/e2e/viewer.spec.ts @@ -21,10 +21,23 @@ 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(); }); +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", 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(); +}); + test("creates, copies, and previews a generated homepage link", async ({ page }) => { await waitForViewerState(page, "empty");