Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
107 changes: 107 additions & 0 deletions src/app/security/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className="app-shell min-h-screen">
<header className="nav-bar sticky top-0 z-30 flex items-center justify-between px-4 py-3 sm:px-8 sm:py-4 lg:px-12">
<Link href="/" className="nav-text-link">
Agent Render
</Link>
</header>

<div className="mx-auto grid w-full max-w-4xl gap-10 px-4 py-10 sm:px-8 sm:py-16 lg:px-12">
<section className="border-b border-[color:var(--border)] pb-10">
<p className="section-kicker">Public security notes</p>
<h1 className="font-display mt-4 text-4xl font-bold leading-tight sm:text-6xl">Security</h1>
<p className="mt-5 max-w-3xl text-base leading-8 text-[color:var(--text-muted)] sm:text-lg">
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.
</p>
</section>

<section className="bento-grid">
{sections.map((section) => (
<article key={section.title} className="bento-card px-5 py-6 sm:px-7 sm:py-7">
<h2 className="text-lg font-bold leading-7">{section.title}</h2>
<ul className="mt-4 grid gap-3 pl-5 text-sm leading-7 text-[color:var(--text-muted)] marker:text-[color:var(--accent)] sm:text-base sm:leading-8">
{section.body.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</article>
))}
</section>

<section className="border border-[color:var(--border)] px-5 py-6 sm:px-7 sm:py-7">
<p className="section-kicker">Reports</p>
<h2 className="mt-3 text-lg font-bold leading-7">Security contact</h2>
<p className="mt-4 text-sm leading-7 text-[color:var(--text-muted)] sm:text-base sm:leading-8">
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.
</p>
<a
href="https://github.com/baanish/agent-render/security/advisories/new"
rel="noreferrer"
target="_blank"
className="mt-4 inline-flex font-bold text-[color:var(--accent)]"
>
Open a private GitHub security advisory
</a>
</section>
</div>
</main>
);
}
24 changes: 18 additions & 6 deletions src/components/viewer-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -493,7 +494,12 @@ export function ViewerShell() {
<h1 className="font-display text-lg font-semibold tracking-[-0.03em] sm:text-xl">Agent Render</h1>
</a>

<ThemeToggle />
<div className="flex items-center gap-2 sm:gap-3">
<Link href="/security" className="nav-text-link">
Security
</Link>
<ThemeToggle />
</div>
</header>

<div className="mx-auto flex w-full max-w-7xl flex-col gap-8 px-4 pb-12 pt-6 sm:gap-16 sm:px-8 sm:pb-24 sm:pt-12 lg:gap-20 lg:px-12 lg:pt-16">
Expand Down Expand Up @@ -762,7 +768,7 @@ export function ViewerShell() {
<p className="mt-4 max-w-3xl text-sm leading-7 text-[color:var(--text-muted)] sm:mt-5 sm:text-base sm:leading-8">
{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."}
</p>
</div>

Expand Down Expand Up @@ -810,12 +816,13 @@ export function ViewerShell() {
<p className="mt-3 text-sm leading-7 text-[color:var(--text-muted)] sm:text-base sm:leading-8">{step}</p>
</div>
))}
<div className="bento-card px-5 py-6 sm:px-8 sm:py-8">
<Link href="/security" className="bento-card bento-link px-5 py-6 sm:px-8 sm:py-8">
<p className="section-kicker">Security</p>
<p className="mt-3 text-sm leading-7 text-[color:var(--text-muted)] sm:text-base sm:leading-8">
The payload never leaves the URL hash. Rendering is entirely client-side.
<span className="mt-3 block text-base font-semibold leading-6">Read the security page</span>
<p className="mt-2 text-sm leading-7 text-[color:var(--text-muted)] sm:text-base sm:leading-8">
Fragment payloads stay out of the static host request path, but links are not secret-safe.
</p>
</div>
</Link>
<div className="bento-card px-5 py-6 sm:px-8 sm:py-8">
<p className="section-kicker">Hosting</p>
<p className="mt-3 text-sm leading-7 text-[color:var(--text-muted)] sm:text-base sm:leading-8">
Expand All @@ -826,6 +833,11 @@ export function ViewerShell() {
</section>
</section>
)}

<footer className="site-footer print-hide-on-markdown">
<span>agent-render</span>
<Link href="/security">Security</Link>
</footer>
</div>
</main>
);
Expand Down
2 changes: 1 addition & 1 deletion tests/components/viewer-shell.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
15 changes: 14 additions & 1 deletion tests/e2e/viewer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
Loading