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/6] 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 2f7452fc3f6ab8ac6b620d5e33ea8daf4eb48d8c 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 2/6] docs: explain fragment payload URLs --- docs/url-fragments.md | 37 +++++++++++++ src/app/url-explainer/page.tsx | 98 +++++++++++++++++++++++++++++++++ src/components/viewer-shell.tsx | 14 ++++- 3 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 docs/url-fragments.md create mode 100644 src/app/url-explainer/page.tsx diff --git a/docs/url-fragments.md b/docs/url-fragments.md new file mode 100644 index 0000000..b703ff0 --- /dev/null +++ b/docs/url-fragments.md @@ -0,0 +1,37 @@ +# Why Does This URL Look Weird? + +agent-render links carry the artifact in the URL fragment: + +```text +https://agent-render.com/#agent-render=v1.arx.1. +``` + +Everything before `#` loads the static app. Everything after `#` is the artifact payload the browser decodes locally. + +## What the parts mean + +- `agent-render` tells the app this hash belongs to agent-render. +- `v1` is the payload format version. +- `arx` is the compression codec. +- `1` is the arx dictionary version. +- `` is the encoded artifact bundle. + +For non-arx links the shape is shorter: + +```text +#agent-render=v1.. +``` + +where `` is `plain`, `lz`, or `deflate`. + +## Why arx exists + +Artifacts can be bigger than a comfortable URL. `arx` keeps links shorter by applying an agent-render substitution dictionary, Brotli compression, and URL-safe binary-to-text encoding. The result can look strange because it is optimized for transport, not human reading. + +## Privacy tradeoff + +Fragments are useful because browsers do not send the part after `#` to the server during the initial page request. That means a static host can serve the viewer without receiving the artifact contents. + +That is not the same thing as absolute secrecy. Fragment links can still appear in browser history, copied URLs, screenshots, link previews or tools that inspect full URLs, and any client-side analytics added later. Treat the link as bearer access to the artifact. + +Use fragment links for quick static sharing. Use self-hosted UUID mode when the payload is too large, a chat app mangles long URLs, or you need short links and accept server-side storage. diff --git a/src/app/url-explainer/page.tsx b/src/app/url-explainer/page.tsx new file mode 100644 index 0000000..fd05a70 --- /dev/null +++ b/src/app/url-explainer/page.tsx @@ -0,0 +1,98 @@ +import type { Metadata } from "next"; +import Image from "next/image"; +import { ArrowLeft, Link2, ShieldCheck, Zap } from "lucide-react"; + +const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; +const iconPath = `${basePath}/icon.svg`; + +export const metadata: Metadata = { + title: "Why does this URL look weird? - agent-render", + description: "A plain-English explainer for agent-render fragment payload URLs, arx compression, and privacy tradeoffs.", +}; + +export default function UrlExplainerPage() { + return ( +
+
+ +
+ +
+ Agent Render +
+
+ +
+ + + Back to viewer + + +
+

URL explainer

+

+ Why does this URL look weird? +

+

+ The long part after #agent-render= is the artifact itself, compressed into the URL fragment so a static host can show it without receiving the content in the page request. +

+
+ +
+
+

The shape

+

+ https://agent-render.com/#agent-render=v1.arx.1.<compressed-payload> +

+

+ Everything before # loads the app. Everything after # stays in the browser and tells the app what to render. +

+
+ +
+ +

v1

+

+ The payload format version. It lets old and new links fail clearly instead of guessing. +

+
+ +
+ +

arx

+

+ The compression method. It uses an agent-render dictionary, Brotli compression, and URL-safe text encoding to keep rich artifacts linkable. +

+
+ +
+ +

Privacy

+

+ The static host does not receive fragment contents during the page request. The link is still not a secret: browser history, copied URLs, screenshots, logs from tools that inspect the full URL, and future client-side analytics can expose it. +

+
+
+ +
+

In 30 seconds

+
+

+ A normal page URL asks the server for a route. An agent-render URL also carries a compressed artifact after the hash mark. Browsers do not send that hash to the server in the initial request, so the static app loads first and then decodes the artifact locally. +

+

+ The weird-looking text is a transport format, not a tracking code. Shorter codecs like deflate and arx make markdown, code, diffs, CSV, and JSON fit into shareable links. +

+

+ Use fragment links for quick static sharing. Use the optional self-hosted UUID mode when the payload is too large, the target chat app mangles long links, or you need a short URL and accept server-side storage. +

+
+
+
+
+ ); +} diff --git a/src/components/viewer-shell.tsx b/src/components/viewer-shell.tsx index da7a583..56870ec 100644 --- a/src/components/viewer-shell.tsx +++ b/src/components/viewer-shell.tsx @@ -18,6 +18,7 @@ import { FileSpreadsheet, FileText, FolderKanban, + HelpCircle, Printer, ShieldCheck, Sparkles, @@ -62,6 +63,7 @@ const sampleCards = sampleLinks.map((link, index) => ({ })); const iconPath = `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/icon.svg`; +const urlExplainerPath = `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/url-explainer/`; const ecosystemLinks = [ { @@ -607,7 +609,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 single static link while the host stays out of the payload.

@@ -632,6 +634,10 @@ export function ViewerShell() {

#{PAYLOAD_FRAGMENT_KEY}=v1.<codec>.<payload>

+ + + Why does this URL look weird? +

Why

@@ -798,8 +804,12 @@ export function ViewerShell() {

Security

- The payload never leaves the URL hash. Rendering is entirely client-side. + The static host does not receive the URL hash, but copied links, browser history, screenshots, and client-side analytics can still expose it.

+ + Read the privacy tradeoff + +

Hosting

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 3/6] 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 4/6] 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 5/6] 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 6/6] 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(); });