diff --git a/tests/runner-e2e/SECURITY.md b/tests/runner-e2e/SECURITY.md
index 30e2f2bc1a9..b7e7d31829e 100644
--- a/tests/runner-e2e/SECURITY.md
+++ b/tests/runner-e2e/SECURITY.md
@@ -292,6 +292,11 @@ latest pointers are mutable, and S3 versioning makes those updates recoverable.
## Public evidence boundary
+Ordinary `chat-features` catalog captures remain private Actions evidence, including
+the required genuine final-state PNG. They receive no public screenshot marker;
+canonical agent slugs and legitimate result navigation do not expand the public
+route allowlist. Pre-chat boundary and other suite capture guards remain unchanged.
+
CloudFront and GitHub Pages are public. Fixture identifiers, timing, token
usage, costs, normalized results, and trusted runner PNG screenshots are
expected public data. Each
diff --git a/tests/runner-e2e/chat-screenshot-capture.test.ts b/tests/runner-e2e/chat-screenshot-capture.test.ts
new file mode 100644
index 00000000000..1e5c4ad33a6
--- /dev/null
+++ b/tests/runner-e2e/chat-screenshot-capture.test.ts
@@ -0,0 +1,39 @@
+import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
+import { execFile } from "node:child_process";
+import { promisify } from "node:util";
+import path from "node:path";
+import os from "node:os";
+import { expect, it, vi } from "vitest";
+import { ordinaryChatCapture } from "./chat-screenshot-capture.js";
+import { packageEvidence } from "./evidence.js";
+import { isHistoricalBundlePathAllowed } from "./history-publish.js";
+import { isPublicRunnerScreenshotRoute } from "./screenshot-policy.js";
+const execFileAsync = promisify(execFile);
+it("retains catalog result and mandatory final-state captures privately without invoking the public route/marker callback", async () => {
+ const privateCapture = vi.fn(async () => {}), publicCapture = vi.fn(async () => { throw new Error("Public route guard would reject the canonical Morgan slug"); });
+ const capture = ordinaryChatCapture("chat-features", privateCapture, publicCapture);
+ await capture("chat-catalog-result", "Verified result", "chat-catalog-result.png");
+ await capture("final-state", "Verified final state", "final-state.png");
+ expect(privateCapture.mock.calls).toEqual([["chat-catalog-result", "chat-catalog-result.png"], ["final-state", "final-state.png"]]);
+ expect(publicCapture).not.toHaveBeenCalled();
+ expect(isPublicRunnerScreenshotRoute("http://127.0.0.1:3100/RUN/chats/morgan", { issuePrefix: "RUN", issueId: "source", issueIdentifier: "RUN-3", chatAgentId: "actual-agent-uuid" })).toBe(false);
+});
+it("preserves the exact public capture callback and its rejection for all other ordinary chat suites", async () => {
+ const privateCapture = vi.fn(async () => {}), publicCapture = vi.fn(async () => { throw new Error("Existing public guard rejection"); });
+ const capture = ordinaryChatCapture("chat", privateCapture, publicCapture);
+ expect(capture).toBe(publicCapture); await expect(capture("final", "Task", "final-state.png")).rejects.toThrow("Existing public guard rejection"); expect(privateCapture).not.toHaveBeenCalled();
+});
+it("keeps the required final-state file in real packaged Actions evidence while public history excludes its unmarked path", async () => {
+ const root = await mkdtemp(path.join(os.tmpdir(), "private-chat-capture-")), upload = `${root}-upload`;
+ try {
+ for (const dir of ["snapshots", "html-report", "blob-report"]) await mkdir(path.join(root, dir));
+ for (const [file, content] of Object.entries({ "result.json": "{}", "junit.xml": "