diff --git a/app/lab/start/page.tsx b/app/lab/start/page.tsx
new file mode 100644
index 00000000..12a84183
--- /dev/null
+++ b/app/lab/start/page.tsx
@@ -0,0 +1,28 @@
+import type { Metadata } from 'next';
+import { FirstRunFlow } from '@/components/first-run-flow';
+
+/**
+ * PUBLIC BETA. A guided walkthrough for three kinds of visitor, linked from the home page and kept out of search
+ * results while it is in beta. It was a production 404 until the owner authorized this launch on 2026-11-08.
+ */
+export const metadata: Metadata = {
+ title: 'Public beta: try, test, report',
+ robots: { index: false, follow: false },
+};
+
+export default function LabStartPage() {
+ return (
+
+
+
+ Public beta. This walkthrough may change. The checks on this page use saved runs, and what you type stays in your browser.
+
+
Check your AI's claims against the evidence.
+
+ TrustShell checks the kinds of claims it can read, such as whether a GitHub Actions run passed. It does not see everything your AI did. Try it on a saved example, test it on your own work, then read the report. No account and no key.
+
+
+
+
+ );
+}
diff --git a/app/page.tsx b/app/page.tsx
index c9fbcc9e..1b9918c5 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -76,6 +76,9 @@ export default function Home() {
Install TrustShell
+
+ Try the guided walkthrough
+
See what it prints
diff --git a/components/first-run-chat.tsx b/components/first-run-chat.tsx
new file mode 100644
index 00000000..d50891e5
--- /dev/null
+++ b/components/first-run-chat.tsx
@@ -0,0 +1,268 @@
+'use client';
+
+import { useEffect, useRef, useState, type ReactNode } from 'react';
+import { ChevronRight } from 'lucide-react';
+import { PRIMARY, SECONDARY, Status } from '@/components/test-agent-flow';
+import type { Go, StepId } from '@/components/first-run-flow';
+import { EXIT_MEANING, RUNS, SENTENCES, STATUS_MEANING, STATUS_WORDS, type RunId } from '@/lib/first-run';
+import type { SentenceResult } from '@/lib/first-run-check';
+
+type Checked = { text: string; run: RunId; result: SentenceResult; own: boolean };
+
+/** Said where it matters, in the same words: the limit a visitor is most likely to assume away. */
+export function Limit({ children }: { children: ReactNode }) {
+ return
{children}
;
+}
+
+export function Legend() {
+ return (
+
+ {STATUS_WORDS.map((w) => (
+
+
+
+
+
{STATUS_MEANING[w]}
+
+ ))}
+
+ );
+}
+
+export function LegendToggle() {
+ return (
+
+
+
+ What do these words mean?
+
+
+
+
+
+ );
+}
+
+/** The checker and both saved runs, or null if either could not be fetched. */
+async function prepare() {
+ try {
+ const lib = await import('@/lib/first-run-check');
+ const runs = await lib.loadSavedRuns();
+ return runs ? { runs, checkSentence: lib.checkSentence } : null;
+ } catch {
+ return null;
+ }
+}
+
+const INPUT = 'w-full rounded-lg border border-slate-600 bg-slate-950 px-3 py-2 text-sm text-white';
+
+function Receipt({ checked, full }: { checked: Checked; full?: boolean }) {
+ const { record, tally, exit } = checked.result;
+ const o = record.observation;
+ const heading = useRef(null);
+ // A new result takes focus so it is announced and scrolled into view; opening the Report step does not need it.
+ useEffect(() => {
+ if (!full) heading.current?.focus();
+ }, [checked, full]);
+ return (
+
+
+ What the check found
+
+
“{checked.text}”
+
+ Compared with GitHub’s record of run {o.run_id} in {o.owner}/{o.repo}, saved {o.fetchedAt.slice(0, 10)}. GitHub reports it finished with conclusion {o.conclusion ?? 'none'}.{' '}
+
+ Open this run on GitHub
+
+
+ {tally.SUPPORTED} supported, {tally.CONTRADICTED} contradicted, {tally.NOT_CHECKED} not checked. {EXIT_MEANING[exit]} The command line would exit {exit}.
+
These are things an AI might say about real runs of an automated check on GitHub. Pick one. The checker the TrustShell command line uses runs here in your browser, against GitHub’s record of that run.
+ TrustShell cannot see your conversations with an AI. It sees only what goes through it. On this page that means the sentence you pick or type, and it stays in your browser.
+
No receipt yet. Check a sentence first, and its receipt will appear here.
+
+
+ )}
+
+
What the words mean
+
+
+ This check is only about those two saved runs. It cannot read your AI chats, and it cannot tell you whether something an AI said about the world is true.
+
+
What you can do next
+
If an AI builds software for you on GitHub, you can check what it said about your own runs.
+
+
+ For a sentence that is not about a GitHub run,{' '}
+
+ the second-opinion check
+ {' '}
+ asks other AI models. That is a different check, and your sentence leaves this device.
+
+
+
+ );
+}
diff --git a/components/first-run-flow.tsx b/components/first-run-flow.tsx
new file mode 100644
index 00000000..3d826326
--- /dev/null
+++ b/components/first-run-flow.tsx
@@ -0,0 +1,304 @@
+'use client';
+
+import { useEffect, useRef, useState, type ReactNode } from 'react';
+import { ChevronRight } from 'lucide-react';
+import { InstallTabs } from '@/components/home-install';
+import { BUTTON, Command, PRIMARY, SECONDARY, PathOne, PathTwo, ReportBody, ReportFileInput, useReportLoader } from '@/components/test-agent-flow';
+import { ChatPath, Legend, LegendToggle, Limit } from '@/components/first-run-chat';
+import { COMMANDS, EXAMPLE_CHECK, EXAMPLE_DEMO, EXAMPLE_RECORDED, LAUNCHER, MCP_CONFIG, MCP_CONFIG_WINDOWS } from '@/lib/first-run';
+
+export type Who = 'chat' | 'vibe' | 'dev';
+export type StepId = 'try' | 'test' | 'report';
+export type Go = (s: StepId) => void;
+
+const STEPS: { id: StepId; label: string }[] = [
+ { id: 'try', label: 'Try' },
+ { id: 'test', label: 'Test' },
+ { id: 'report', label: 'Report' },
+];
+
+const FORK: { who: Who; title: string; body: string }[] = [
+ { who: 'chat', title: 'I chat with AI', body: 'See a real check in this page. Nothing to install, no account, no key.' },
+ { who: 'vibe', title: 'An AI builds software for me', body: 'It says the checks passed. Compare that with GitHub’s record of the run.' },
+ { who: 'dev', title: 'I build or run agents', body: 'Run the CLI, connect the MCP server, set a Guard policy and inspect Trust Reports.' },
+];
+
+const TITLES: Record> = {
+ chat: { try: 'See a sentence get checked', test: 'Try your own words', report: 'Read the receipt' },
+ vibe: { try: 'See a false claim get caught', test: 'Check your own run', report: 'Read the report' },
+ dev: { try: 'Run it once, offline', test: 'Wire it in', report: 'Inspect the Trust Report' },
+};
+
+function Example({ text }: { text: string }) {
+ return (
+
+
When an AI pushes code, GitHub Actions can run your checks and keep a record. TrustShell reads that record and sets it beside what the AI said.
+
+ This one line checks a false claim about a real run. Paste it into a terminal. It needs Node 22.13 or newer. Once npx has fetched the package, it contacts only api.github.com.{' '}
+
+ What is a terminal?
+
+
+
+
+ A successful run is not proof the code is correct. It shows that one run succeeded, and it does not show which tests ran.
+
+
+
+ );
+}
+
+function VibeTest({ go }: { go: Go }) {
+ return (
+
+
+
+
Public GitHub Actions runs only, for now.
+
Some wording comes back NOT_CHECKED, and one run never supports “All tests pass”. NOT_CHECKED is never a pass.
+
Exit 0 means every claim was supported. Exit 1 means one was contradicted or the run failed. Exit 3 means something was not checked.
+
+
The check leaves its record in a folder called evidence. Turn that into a report, in the same folder:
+
+
+
+ );
+}
+
+function DevTry({ go }: { go: Go }) {
+ return (
+
+
One command seals a sample receipt and verifies it on your machine. The first run makes an install key in your TrustShell home. No account, and once npx has fetched the package, nothing leaves the machine.
+
+
+ Five of the six claims are NOT_CHECKED, and the command still exits 0. An owner’s own signature shows the record is whole and who sealed it. It does not show the action was right.
+
+
+ );
+}
+
+function DevTest({ go }: { go: Go }) {
+ return (
+
+ TrustShell sees only what goes through trustshell run, the MCP server and the SDK’s gated functions. It cannot see an agent’s direct edits, its own network calls or commands in another terminal.
+
+
+
On Windows, npm and npx cannot be started through trustshell run: the policy allows them and they then fail to start. Start the script they wrap, pinned the same way.
Paste this into your MCP client’s settings file. npx fetches the package, so there is nothing to install first.
+
+
If your client reports it cannot find npx on Windows, use this form:
+
+
The inspect_run tool reads a public run from api.github.com, with no model and no key. The check_claim and verify_output tools send text to hosted checkers, so that text leaves your machine.
+
With a Guard policy in the server’s TRUSTSHELL_HOME, every tool call is decided against it first. With no policy there, the call runs.
Run this. It works the same in bash, zsh and PowerShell. It contacts only api.github.com, and your text stays on your machine.
-
+
) : (
Fill in both fields to get the command.
@@ -219,7 +225,7 @@ function PathOne() {
);
}
-function PathTwo() {
+export function PathTwo() {
return (
@@ -230,7 +236,13 @@ function PathTwo() {
.
-
Use a fresh TRUSTSHELL_HOME for each test, then, in your project:
+
Use a fresh TRUSTSHELL_HOME for each test. In bash or zsh:
+
+
In PowerShell:
+
+
The steps below run a script called test.js. Use your own, or make one that fails on purpose:
+
+
Then, in your project:
{PATH_TWO_COMMANDS.map((c, i) => (
@@ -244,31 +256,14 @@ function PathTwo() {
);
}
-export function TestAgentFlow() {
- const [step, setStep] = useState('try');
- const [path, setPath] = useState<1 | 2>(1);
+/** Opening a Trust Report: the shipped example, or a file the visitor picks. The file is read here and sent nowhere. */
+export function useReportLoader() {
const [report, setReport] = useState(null);
const [isExample, setIsExample] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
- const heading = useRef(null);
- const first = useRef(true);
-
- // A step change moves focus to the new heading, so keyboard and screen-reader users land on it.
- useEffect(() => {
- if (first.current) {
- first.current = false;
- return;
- }
- heading.current?.focus();
- }, [step]);
-
- const go = (s: StepId) => {
- setError(null);
- setStep(s);
- };
- const loadExample = async () => {
+ const loadExample = async (): Promise => {
setLoading(true);
setError(null);
try {
@@ -278,9 +273,10 @@ export function TestAgentFlow() {
if (!parsed.ok) throw new Error(parsed.why);
setReport(parsed.report);
setIsExample(true);
- setStep('review');
+ return true;
} catch {
setError('The example could not be loaded. Check your connection and try again.');
+ return false;
} finally {
setLoading(false);
}
@@ -311,6 +307,46 @@ export function TestAgentFlow() {
}
};
+ return { report, isExample, loading, error, setError, loadExample, pickFile };
+}
+
+export function ReportFileInput({ onPick }: { onPick: (e: ChangeEvent) => void }) {
+ return (
+
+
+
+
The file is read in this page and is not sent anywhere.
+
+ );
+}
+
+export function TestAgentFlow() {
+ const [step, setStep] = useState('try');
+ const [path, setPath] = useState<1 | 2>(1);
+ const { report, isExample, loading, error, setError, loadExample, pickFile } = useReportLoader();
+ const heading = useRef(null);
+ const first = useRef(true);
+
+ // A step change moves focus to the new heading, so keyboard and screen-reader users land on it.
+ useEffect(() => {
+ if (first.current) {
+ first.current = false;
+ return;
+ }
+ heading.current?.focus();
+ }, [step]);
+
+ const go = (s: StepId) => {
+ setError(null);
+ setStep(s);
+ };
+
+ const showExample = async () => {
+ if (await loadExample()) setStep('review');
+ };
+
const current = STEPS.findIndex((s) => s.id === step);
const title = STEPS[current]?.label ?? '';
@@ -349,7 +385,7 @@ export function TestAgentFlow() {
{step === 'try' ? (
See a real report before you install anything. No account, no key. It comes from a test task where the agent said it was finished and the test said otherwise.
The file is read in this page and is not sent anywhere.
-
+
{report ? (
<>
{isExample ?
This is the example report, not yours.
: null}
@@ -383,7 +413,7 @@ export function TestAgentFlow() {
>
) : (
-
+
Use the example report
)}
diff --git a/lib/first-run-check.ts b/lib/first-run-check.ts
new file mode 100644
index 00000000..1b677da6
--- /dev/null
+++ b/lib/first-run-check.ts
@@ -0,0 +1,50 @@
+/**
+ * The check the chat path runs, and it is the real one. checkSentence calls buildRecord, the function `trustshell
+ * check` calls, against what GitHub reported about a real run, saved by that command. tests/first-run.test.ts holds
+ * it to the CLI's own output. Nothing is sent anywhere: the only difference is that the run was read earlier.
+ *
+ * Loaded on demand, because action-evidence.ts imports node:crypto and the bundler replaces that with a polyfill of
+ * about 140 KB gzipped, for a digest this page never computes.
+ * ponytail: lazy import keeps that off the other two paths. Move digestRecord out of action-evidence.ts if this goes live.
+ */
+import { ACTION_EVIDENCE_SCHEMA, buildRecord, claimsExitCode, tallyClaims, type ActionEvidenceRecord, type ClaimTally, type Observation } from '../src/lib/action-evidence';
+import { RUNS, type RunId } from './first-run';
+
+export type SavedRun = { ok: true; observation: Observation } | { ok: false };
+
+/** A saved run must be the record `trustshell check --out` wrote, or the page says it could not load one. */
+export function parseSavedRun(text: string): SavedRun {
+ try {
+ const record = (JSON.parse(text) as { record?: Partial }).record;
+ if (record?.schema === ACTION_EVIDENCE_SCHEMA && record.observation?.platform === 'github-actions') return { ok: true, observation: record.observation };
+ } catch {
+ /* not JSON: the same answer as any other file that is not a saved run */
+ }
+ return { ok: false };
+}
+
+/** Both saved runs, or null: a page with one of two runs would offer sentences it cannot check. */
+export async function loadSavedRuns(fetcher: typeof fetch = fetch): Promise | null> {
+ try {
+ const [failed, passed] = await Promise.all(
+ (['failed', 'passed'] as const).map(async (id) => {
+ const res = await fetcher(RUNS[id].file);
+ return res.ok ? parseSavedRun(await res.text()) : ({ ok: false } as const);
+ }),
+ );
+ return failed?.ok && passed?.ok ? { failed: failed.observation, passed: passed.observation } : null;
+ } catch {
+ return null;
+ }
+}
+
+export interface SentenceResult {
+ record: ActionEvidenceRecord;
+ tally: ClaimTally;
+ exit: number;
+}
+
+export function checkSentence(text: string, observation: Observation): SentenceResult {
+ const record = buildRecord(observation, text);
+ return { record, tally: tallyClaims(record.claims), exit: claimsExitCode(record) };
+}
diff --git a/lib/first-run.ts b/lib/first-run.ts
new file mode 100644
index 00000000..a70f5570
--- /dev/null
+++ b/lib/first-run.ts
@@ -0,0 +1,81 @@
+/**
+ * What the guided walkthrough shows, in one place: the commands, the saved runs and what each verdict word means.
+ *
+ * THE COMMANDS ARE RUN, NOT WRITTEN FROM MEMORY. Each string here was executed against the published package in
+ * an empty folder with no keys before it went on the page. If one stops working, fix the page.
+ *
+ * This file holds no logic the page's first load has to pay for. The checker itself is in lib/first-run-check.ts.
+ */
+
+export const RELEASE = '1.9.1';
+const PACKAGE = `@hyperdag/trustshell@${RELEASE}`;
+export const LAUNCHER = `npx -y ${PACKAGE}`;
+
+export type RunId = 'failed' | 'passed';
+
+export const RUNS: Record = {
+ failed: { id: '42502305639', url: 'https://github.com/DealAppSeo/trustshell/actions/runs/42502305639', file: '/examples/run-failed-record.json', label: 'The run that failed' },
+ passed: { id: '39619591025', url: 'https://github.com/DealAppSeo/trustshell/actions/runs/39619591025', file: '/examples/run-passed-record.json', label: 'The run that passed' },
+};
+
+export const SENTENCES: { run: RunId; text: string }[] = [
+ { run: 'failed', text: 'The check workflow succeeded, run 42502305639.' },
+ { run: 'passed', text: 'The check workflow succeeded, run 39619591025.' },
+ { run: 'passed', text: 'All tests pass.' },
+];
+
+export const COMMANDS = {
+ tryCheck: `${LAUNCHER} check ${RUNS.failed.url} --claims "The check workflow succeeded, run ${RUNS.failed.id}." --out evidence`,
+ makeReport: `${LAUNCHER} trust-report --out report --record evidence/record.json`,
+ verifyReportNpx: `${LAUNCHER} trust-report verify report --root 0x`,
+ demo: `${LAUNCHER} demo`,
+ verifyReport: 'trustshell trust-report verify report --root 0x',
+} as const;
+
+const MCP_ARGS = ['-y', '-p', PACKAGE, 'trustshell-mcp'];
+const mcpConfig = (command: string, args: string[]) => JSON.stringify({ mcpServers: { trustshell: { command, args } } }, null, 2);
+export const MCP_CONFIG = mcpConfig('npx', MCP_ARGS);
+/** A client that starts `npx` with no shell gets ENOENT on Windows: npx is npx.cmd there. Measured on 1.9.1. */
+export const MCP_CONFIG_WINDOWS = mcpConfig('cmd', ['/c', 'npx', ...MCP_ARGS]);
+
+/** Real output from 1.9.1, trimmed to the lines that decide. Re-record it rather than edit it. */
+export const EXAMPLE_RECORDED = 'Example: real output from @hyperdag/trustshell 1.9.1, recorded 9 Nov 2026 UTC, trimmed.';
+
+export const EXAMPLE_CHECK = `### Claims: 1 supported · 1 contradicted · 0 not checked
+
+- **CONTRADICTED** \`The check workflow succeeded\`
+ - **Why** run 42502305639 (attempt 1, commit f569e43ec807) finished with conclusion "failure", not "success".
+- **SUPPORTED** \`run 42502305639\`
+ - **Why** The cited run is the inspected run, 42502305639.
+
+exit 1`;
+
+export const EXAMPLE_DEMO = `trustshell demo — the offline receipt loop, start to finish:
+
+ 1. SEALED a sample receipt with this install's own key (provenance: owner-self-signed).
+ 2. VERIFIED it here, offline, under that key:
+
+ There is no single verdict. Six separate claims, each with its own gaps:
+ Integrity VERIFIED The record is whole: its hash matches its contents, and a trusted key sealed it.
+ Authenticity NOT_CHECKED The action was authorized by the agent’s accountable owner.
+ Observation NOT_CHECKED An outcome was observed (e.g. a payment settling on chain).
+ Correctness NOT_CHECKED The content was verified against its source.
+ Authorization NOT_CHECKED The kernel permitted this action under the policy it was given.
+ Containment NOT_CHECKED The tool ran only under an ALLOW decision.
+
+exit 0`;
+
+export const STATUS_WORDS = ['SUPPORTED', 'CONTRADICTED', 'NOT_CHECKED', 'COMMAND_PASSED'] as const;
+
+export const STATUS_MEANING: Record<(typeof STATUS_WORDS)[number], string> = {
+ SUPPORTED: 'The record matches this exact statement, and nothing wider. A run that succeeded supports “this run succeeded”, and does not show the code is correct.',
+ CONTRADICTED: 'The record says otherwise. The reason beside it names what was compared.',
+ NOT_CHECKED: 'Nothing could be compared, so this is neither a pass nor a failure. It is never a pass.',
+ COMMAND_PASSED: 'A command you paired with this claim exited 0. That is all it shows: a test run that selected nothing also exits 0.',
+};
+
+export const EXIT_MEANING: Record = {
+ 0: 'Every claim was supported, on a finished run that passed.',
+ 1: 'A claim was contradicted, or the run itself failed.',
+ 3: 'Something was not checked, and that is never a pass.',
+};
diff --git a/lib/trust-report-view.ts b/lib/trust-report-view.ts
index 4b3661e4..7d416851 100644
--- a/lib/trust-report-view.ts
+++ b/lib/trust-report-view.ts
@@ -77,8 +77,8 @@ export const isRunUrl = (s: string): boolean => RUN_URL.test(s.trim());
*/
export const isShellSafe = (s: string): boolean => s.length > 0 && !/["“”„!`$\\\r\n]/.test(s);
-export function inspectCommand(url: string, claim: string): string {
- return `npx @hyperdag/trustshell@latest check ${url.trim()} --claims "${claim.trim()}" --out evidence`;
+export function inspectCommand(url: string, claim: string, launcher = 'npx @hyperdag/trustshell@latest'): string {
+ return `${launcher} check ${url.trim()} --claims "${claim.trim()}" --out evidence`;
}
/** The same commands as examples/own-agent/README.md, which an automated test holds this list to. */
@@ -88,3 +88,12 @@ export const PATH_TWO_COMMANDS = [
"trustshell run --tool node '--' node test.js",
'trustshell trust-report --out report --bind "test.js=All tests pass"',
] as const;
+
+/** A fresh folder for each test, so the report holds only this task. The same two lines as the example guide. */
+export const PATH_TWO_HOME = {
+ bash: 'export TRUSTSHELL_HOME="$PWD/.trust-run"',
+ powershell: '$env:TRUSTSHELL_HOME = "$PWD\\.trust-run"',
+} as const;
+
+/** One line, the same in bash, zsh and PowerShell, for a visitor with no test script yet. It fails on purpose. */
+export const SAMPLE_TEST_SCRIPT = `node -e "require('fs').writeFileSync('test.js','console.log(1); process.exit(1)')"`;
diff --git a/public/examples/run-failed-record.json b/public/examples/run-failed-record.json
new file mode 100644
index 00000000..19ad0a4f
--- /dev/null
+++ b/public/examples/run-failed-record.json
@@ -0,0 +1,116 @@
+{
+ "record": {
+ "schema": "dev.trustshell.action-evidence/0.1-draft",
+ "observation": {
+ "platform": "github-actions",
+ "host": "api.github.com",
+ "url": "https://github.com/DealAppSeo/trustshell/actions/runs/42502305639",
+ "owner": "DealAppSeo",
+ "repo": "trustshell",
+ "run_id": 42502305639,
+ "run_attempt": 1,
+ "workflow": {
+ "name": "check",
+ "path": ".github/workflows/check.yml"
+ },
+ "head_sha": "f569e43ec8072f7530c2da8d25bacf28c30c510f",
+ "status": "completed",
+ "conclusion": "failure",
+ "check": {
+ "verdict": "FAILED",
+ "results": [
+ {
+ "ok": true,
+ "label": "A workflow run with this id exists",
+ "detail": "check · completed"
+ },
+ {
+ "ok": true,
+ "label": "The run finished",
+ "detail": "status: completed"
+ },
+ {
+ "ok": false,
+ "label": "The run succeeded",
+ "detail": "GitHub reports conclusion: failure"
+ },
+ {
+ "ok": false,
+ "label": "Every job in the run passed",
+ "detail": "1 of 1 jobs did not pass: check"
+ },
+ {
+ "ok": true,
+ "label": "GitHub reports the commit this run ran on",
+ "detail": "f569e43ec8072f7530c2da8d25bacf28c30c510f"
+ }
+ ]
+ },
+ "jobs": {
+ "http_status": 200,
+ "total": 1,
+ "items": [
+ {
+ "name": "check",
+ "status": "completed",
+ "conclusion": "failure",
+ "head_sha": "f569e43ec8072f7530c2da8d25bacf28c30c510f",
+ "run_attempt": 1
+ }
+ ]
+ },
+ "fetchedAt": "2026-11-09T04:48:53.138Z",
+ "freshness": "LIVE"
+ },
+ "claims": [
+ {
+ "claim": "The check workflow succeeded",
+ "sentence": "The check workflow succeeded, run 42502305639.",
+ "template": "gha.run_succeeded",
+ "status": "CONTRADICTED",
+ "evidence_fields": [
+ "/observation/status",
+ "/observation/conclusion",
+ "/observation/check/verdict",
+ "/observation/jobs"
+ ],
+ "scope": "Whether workflow run 42502305639 (attempt 1, commit f569e43ec807) concluded success with every job read. Not which tests it ran, not any other workflow or check on the commit, and not whether the change works.",
+ "reason": "run 42502305639 (attempt 1, commit f569e43ec807) finished with conclusion \"failure\", not \"success\"."
+ },
+ {
+ "claim": "run 42502305639",
+ "sentence": "The check workflow succeeded, run 42502305639.",
+ "template": "gha.run_id",
+ "status": "SUPPORTED",
+ "evidence_fields": [
+ "/observation/owner",
+ "/observation/repo",
+ "/observation/run_id"
+ ],
+ "scope": "Run identity only: whether the run this claim cites is the inspected run. Not what the run shows.",
+ "reason": "The cited run is the inspected run, 42502305639."
+ }
+ ],
+ "assurance": {
+ "cryptographic_integrity": "NOT_CHECKED",
+ "observation": "PLATFORM_REPORTED"
+ },
+ "does_not_prove": [
+ "This is what GitHub can confirm. It is not a judgement about whether the code is correct.",
+ "It cannot show work done outside this repository, or on a branch that was never pushed.",
+ "A passing test suite proves the tests that exist passed — not that the right tests exist.",
+ "It does not establish who or what authored the change.",
+ "SUPPORTED means the platform record matches the narrow scope written next to the claim. It is not a finding that the claim is true in any broader sense.",
+ "A successful workflow run shows that run succeeded. It does not show which tests ran, that all tests or all checks passed, or that a change works.",
+ "Only this one workflow run was read. Other workflows, check suites and status checks on the same commit were not.",
+ "Every observed fact is GitHub reporting on its own run (PLATFORM_REPORTED). Nothing was re-run or independently observed.",
+ "Broader security: not checked. A CI conclusion is not a security review.",
+ "The record is not signed. Its SHA-256 shows whether the record changed after it was made, not who made it."
+ ]
+ },
+ "digest": {
+ "algorithm": "sha-256",
+ "canonicalization": "RFC 8785",
+ "value": "0b286af912951f1a4c28733ce76a525d48c79f90c36e3a32dfb39ddf8605b745"
+ }
+}
diff --git a/public/examples/run-passed-record.json b/public/examples/run-passed-record.json
new file mode 100644
index 00000000..f137b02c
--- /dev/null
+++ b/public/examples/run-passed-record.json
@@ -0,0 +1,116 @@
+{
+ "record": {
+ "schema": "dev.trustshell.action-evidence/0.1-draft",
+ "observation": {
+ "platform": "github-actions",
+ "host": "api.github.com",
+ "url": "https://github.com/DealAppSeo/trustshell/actions/runs/39619591025",
+ "owner": "DealAppSeo",
+ "repo": "trustshell",
+ "run_id": 39619591025,
+ "run_attempt": 1,
+ "workflow": {
+ "name": "check",
+ "path": ".github/workflows/check.yml"
+ },
+ "head_sha": "392797a69b6a3e57406cef210a8471585667e3af",
+ "status": "completed",
+ "conclusion": "success",
+ "check": {
+ "verdict": "COMPLETE",
+ "results": [
+ {
+ "ok": true,
+ "label": "A workflow run with this id exists",
+ "detail": "check · completed"
+ },
+ {
+ "ok": true,
+ "label": "The run finished",
+ "detail": "status: completed"
+ },
+ {
+ "ok": true,
+ "label": "The run succeeded",
+ "detail": "GitHub reports conclusion: success"
+ },
+ {
+ "ok": true,
+ "label": "Every job in the run passed",
+ "detail": "1 job"
+ },
+ {
+ "ok": true,
+ "label": "GitHub reports the commit this run ran on",
+ "detail": "392797a69b6a3e57406cef210a8471585667e3af"
+ }
+ ]
+ },
+ "jobs": {
+ "http_status": 200,
+ "total": 1,
+ "items": [
+ {
+ "name": "check",
+ "status": "completed",
+ "conclusion": "success",
+ "head_sha": "392797a69b6a3e57406cef210a8471585667e3af",
+ "run_attempt": 1
+ }
+ ]
+ },
+ "fetchedAt": "2026-11-09T04:49:11.678Z",
+ "freshness": "LIVE"
+ },
+ "claims": [
+ {
+ "claim": "The check workflow succeeded",
+ "sentence": "The check workflow succeeded, run 39619591025.",
+ "template": "gha.run_succeeded",
+ "status": "SUPPORTED",
+ "evidence_fields": [
+ "/observation/status",
+ "/observation/conclusion",
+ "/observation/check/verdict",
+ "/observation/jobs"
+ ],
+ "scope": "Whether workflow run 39619591025 (attempt 1, commit 392797a69b6a) concluded success with every job read. Not which tests it ran, not any other workflow or check on the commit, and not whether the change works.",
+ "reason": "GitHub reports conclusion \"success\" for run 39619591025 (attempt 1, commit 392797a69b6a); 1 of 1 job concluded success. The line cites this run."
+ },
+ {
+ "claim": "run 39619591025",
+ "sentence": "The check workflow succeeded, run 39619591025.",
+ "template": "gha.run_id",
+ "status": "SUPPORTED",
+ "evidence_fields": [
+ "/observation/owner",
+ "/observation/repo",
+ "/observation/run_id"
+ ],
+ "scope": "Run identity only: whether the run this claim cites is the inspected run. Not what the run shows.",
+ "reason": "The cited run is the inspected run, 39619591025."
+ }
+ ],
+ "assurance": {
+ "cryptographic_integrity": "NOT_CHECKED",
+ "observation": "PLATFORM_REPORTED"
+ },
+ "does_not_prove": [
+ "This is what GitHub can confirm. It is not a judgement about whether the code is correct.",
+ "It cannot show work done outside this repository, or on a branch that was never pushed.",
+ "A passing test suite proves the tests that exist passed — not that the right tests exist.",
+ "It does not establish who or what authored the change.",
+ "SUPPORTED means the platform record matches the narrow scope written next to the claim. It is not a finding that the claim is true in any broader sense.",
+ "A successful workflow run shows that run succeeded. It does not show which tests ran, that all tests or all checks passed, or that a change works.",
+ "Only this one workflow run was read. Other workflows, check suites and status checks on the same commit were not.",
+ "Every observed fact is GitHub reporting on its own run (PLATFORM_REPORTED). Nothing was re-run or independently observed.",
+ "Broader security: not checked. A CI conclusion is not a security review.",
+ "The record is not signed. Its SHA-256 shows whether the record changed after it was made, not who made it."
+ ]
+ },
+ "digest": {
+ "algorithm": "sha-256",
+ "canonicalization": "RFC 8785",
+ "value": "7aa6d20bd2f976a3f2a67e39cbdc337e72ef4734eab87261632a80e887e8925c"
+ }
+}
diff --git a/public/examples/trust-report-example.json b/public/examples/trust-report-example.json
index f2fc25b5..695b1c75 100644
--- a/public/examples/trust-report-example.json
+++ b/public/examples/trust-report-example.json
@@ -1,15 +1,15 @@
{
- "schema": "dev.trustshell.trust-report/0.1-draft",
+ "schema": "dev.trustshell.trust-report/0.2-draft",
"title": "Agent said it was finished",
- "generated_at": "2026-11-08T23:32:31.789Z",
- "generator": "trustshell 1.9.0-dev.0",
+ "generated_at": "2026-11-09T05:10:14.167Z",
+ "generator": "trustshell 1.9.1",
"inputs": {
"binds": [
{
"claim": "All tests pass",
"decided_by": "test.js",
"tool": null,
- "script_sha256": "ee55955c74d5ee8cbb7941e1265fbb863fa48e0350a5acb4b9777fcd1f2e907d"
+ "script_sha256": "1198b5309026be775becdbea6cea6a789c8a54563f9e047fc87c817021e4ac2c"
},
{
"claim": "Lint is clean",
@@ -31,8 +31,8 @@
"head_sha": "f569e43ec8072f7530c2da8d25bacf28c30c510f",
"conclusion": "failure",
"verdict": "FAILED",
- "fetched_at": "2026-11-08T23:32:16.234Z",
- "digest": "aeb38b64eacef755727f2ed449116cff4d63d6a07bdd8108c513e6c63837b723",
+ "fetched_at": "2026-11-09T05:09:23.591Z",
+ "digest": "aa083d586915c256fb0bba4f20f66e9ac328d15c9650d95199e3da2016a3b7c0",
"digest_matches": true,
"exit": 1,
"claims": [
@@ -52,7 +52,7 @@
"guard": {
"policy": {
"state": "ok",
- "sha256": "22996d766f99f6a7bcfa20c65491d14e6b558ee191a332f76ae27d961c0e54c7",
+ "sha256": "837923a2991f42278fb51ef0bd4276b8075491d0f6fb1b2c57e70bd84d181f7f",
"nonce": "2",
"signed": false,
"actions": [
@@ -74,20 +74,35 @@
},
"ledger": {
"verdict": "INTACT",
- "count": 5,
+ "count": 8,
"breaks": []
},
"commands": [
{
"seq": 3,
- "ts": "2026-11-08T23:32:31.000Z",
+ "ts": "2026-11-09T05:10:11.000Z",
"action": "tool:node",
- "script_sha256": "ee55955c74d5ee8cbb7941e1265fbb863fa48e0350a5acb4b9777fcd1f2e907d",
+ "script_sha256": "1198b5309026be775becdbea6cea6a789c8a54563f9e047fc87c817021e4ac2c",
"command_sha256": "fb82de7f5d562611e57524e1bd8e6af029e3f4163414556328a23a42ddcbae44",
"decision": "ALLOWED",
"reasons": [],
- "result": "exited 1 after 67 ms",
+ "result": "exited 1 after 162 ms",
"exit_code": 1
+ },
+ {
+ "seq": 7,
+ "ts": "2026-11-09T05:10:13.000Z",
+ "action": "tool:python",
+ "script_sha256": null,
+ "command_sha256": "3f92891cb6172c7c34ff0b1c9d214f70d08b8e21fa7fcbbafdf60bfce3e2494a",
+ "decision": "DENIED",
+ "reasons": [
+ "the owner policy does not list tool:python",
+ "python is an interpreter and no script file was resolved; an allowed interpreter runs only a script the policy pins by sha256 (trustshell guard allow tool:python --script pins one)",
+ "the script cannot be resolved to one regular file (ENOENT); an allowed interpreter runs only a script the policy pins by sha256"
+ ],
+ "result": "never started",
+ "exit_code": null
}
],
"mediated": [
@@ -119,7 +134,8 @@
"next_actions": [
"Do not accept \"The check workflow succeeded\". GitHub's record disagrees: https://github.com/DealAppSeo/trustshell/actions/runs/42502305639",
"Do not accept \"All tests pass\". test.js says otherwise. Fix it or ask the agent why, then run it again through trustshell run.",
- "1 claim was not checked, which is not a pass. To decide one: say it as one plain sentence that cites the run (\"The check workflow succeeded, run 123.\"), or pair it with a command using --bind."
+ "1 claim was not checked, which is not a pass. To decide one: say it as one plain sentence that cites the run (\"The check workflow succeeded, run 123.\"), or pair it with a command using --bind.",
+ "The policy refused tool:python: the owner policy does not list tool:python. If that was what you wanted, nothing to do. If the agent needed it, run trustshell guard allow tool:python, then repeat the task."
],
"exit": 1
}
diff --git a/tests/e2e/first-run-walk.mjs b/tests/e2e/first-run-walk.mjs
new file mode 100644
index 00000000..06814526
--- /dev/null
+++ b/tests/e2e/first-run-walk.mjs
@@ -0,0 +1,435 @@
+/**
+ * The public beta at /lab/start, in a real browser, on a desktop and a phone: the route answering 200 on production,
+ * the home page link, every path and step, the loading, empty and error states, the keyboard path, tap targets,
+ * sideways scrolling, accessibility (axe-core, with a control that proves it can fail), and that nothing leaves the
+ * site or is sent.
+ *
+ * node tests/e2e/first-run-walk.mjs [screenshot dir]
+ * WALK_BASE=https://www.trustshell.dev node tests/e2e/first-run-walk.mjs [screenshot dir]
+ *
+ * With no WALK_BASE it makes a production build (VERCEL_ENV=production) and serves it. With WALK_BASE it walks that
+ * deployed site instead. Not in gating CI (needs a browser and a build).
+ */
+import { readFileSync, mkdtempSync, writeFileSync, mkdirSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { chromiumExecutablePath, LAUNCH_ARGS, loadPlaywrightOrExit, spawnNext } from './chromium-path.mjs';
+
+const { chromium } = await loadPlaywrightOrExit();
+const PORT = 3109;
+const live = process.env.WALK_BASE?.replace(/\/+$/, '');
+const base = live ?? `http://127.0.0.1:${PORT}`;
+const PAGE = `${base}/lab/start`;
+const SAVED_RUNS = '**/examples/run-*-record.json';
+const REPORT = '**/examples/trust-report-example.json';
+const FLOW = '[data-testid="first-run-flow"]';
+const shots = process.argv[2];
+if (shots) mkdirSync(shots, { recursive: true });
+
+const results = [];
+const check = (name, pass, note = '') => {
+ results.push({ name, pass, note });
+ console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${note ? ` — ${note}` : ''}`);
+};
+
+let app;
+const stop = () => {
+ try {
+ app?.kill('SIGKILL');
+ } catch {
+ /* gone */
+ }
+};
+if (!live) {
+ try {
+ const probe = await fetch(`${base}/`, { signal: AbortSignal.timeout(2000) });
+ if (probe) {
+ console.error(`NOT_CHECKED: port ${PORT} is already serving; this suite will not test a server it did not start.`);
+ process.exit(2);
+ }
+ } catch {
+ /* nothing listening: the state we want */
+ }
+
+ // The route's production behaviour is decided at build time, so the whole walk runs against a production build.
+ const env = { ...process.env, VERCEL_ENV: 'production' };
+ const build = spawnNext(['build'], { env, stdio: 'ignore' });
+ const code = await new Promise((r) => build.on('exit', r));
+ if (code !== 0) {
+ console.error(`FATAL: next build exited ${code}`);
+ process.exit(1);
+ }
+ app = spawnNext(['start', '--port', String(PORT)], { env, stdio: 'ignore' });
+ process.on('exit', stop);
+ const deadline = Date.now() + 90_000;
+ for (;;) {
+ try {
+ if ((await fetch(`${base}/`)).ok) break;
+ } catch {
+ /* not up yet */
+ }
+ if (Date.now() > deadline) {
+ console.error('FATAL: the app did not start');
+ process.exit(1);
+ }
+ await new Promise((r) => setTimeout(r, 500));
+ }
+}
+
+const statusOf = async (path) => (await fetch(`${base}${path}`)).status;
+check('production: /lab/start answers 200', (await statusOf('/lab/start')) === 200);
+check('production: control, the preview-only /lab/test-agent still answers 404', (await statusOf('/lab/test-agent')) === 404);
+for (const path of ['/', '/devs', '/check']) check(`production: ${path} answers 200`, (await statusOf(path)) === 200);
+
+const dir = mkdtempSync(join(tmpdir(), 'ts-first-run-'));
+const file = (name, text) => {
+ const p = join(dir, name);
+ writeFileSync(p, text);
+ return p;
+};
+const goodReport = readFileSync(join(process.cwd(), 'public', 'examples', 'trust-report-example.json'), 'utf8');
+const axeSource = readFileSync(join(process.cwd(), 'node_modules', 'axe-core', 'axe.min.js'), 'utf8');
+
+async function axe(page, label, scope = FLOW) {
+ await page.evaluate(axeSource);
+ const v = await page.evaluate(async (flow) => {
+ const r = await axe.run(flow, { resultTypes: ['violations'] });
+ return r.violations.map((x) => `${x.id}(${x.impact}):${x.nodes.length}`);
+ }, scope);
+ check(`axe ${label}`, v.length === 0, v.join(' '));
+}
+
+async function noSideScroll(page, label) {
+ const m = await page.evaluate(() => ({ scroll: document.documentElement.scrollWidth, width: window.innerWidth }));
+ check(`no sideways scroll ${label}`, m.scroll <= m.width, `${m.scroll} vs ${m.width}`);
+}
+
+async function targets(page, label) {
+ const small = await page.evaluate((flow) => {
+ const root = document.querySelector(flow);
+ return [...root.querySelectorAll('button, summary, input:not([type=radio]), textarea, label:has(input[type=radio])')]
+ .filter((el) => el.getClientRects().length > 0)
+ .map((el) => ({ h: Math.round(el.getBoundingClientRect().height), what: el.textContent?.slice(0, 30) || el.id }))
+ .filter((x) => x.h < 44);
+ }, FLOW);
+ check(`tap targets at least 44px ${label}`, small.length === 0, JSON.stringify(small));
+}
+
+async function focusVisibleOnKeyboard(page, label) {
+ const ok = await page.evaluate((flow) => {
+ const el = document.querySelector(`${flow} button`);
+ el.focus();
+ return el.matches(':focus');
+ }, FLOW);
+ check(`${label}: a button takes keyboard focus`, ok);
+}
+
+async function walk(label, viewport) {
+ const context = await browser.newContext({ viewport, permissions: ['clipboard-read', 'clipboard-write'] });
+ const page = await context.newPage();
+ const errors = [];
+ const external = [];
+ const sent = [];
+ page.on('pageerror', (e) => errors.push(String(e)));
+ page.on('request', (r) => {
+ if (!r.url().startsWith(base) && !r.url().startsWith('data:')) external.push(r.url());
+ if (r.method() !== 'GET') sent.push(`${r.method()} ${r.url()}`);
+ });
+ const flow = page.getByTestId('first-run-flow');
+ const step = () => flow.getAttribute('data-step');
+ const who = () => flow.getAttribute('data-who');
+ const stepButton = (name) => page.getByRole('navigation', { name: 'Steps' }).getByRole('button', { name });
+ // Focus that lands behind the sticky site header is focus a visitor cannot see.
+ const focusClear = () => page.evaluate(() => document.activeElement.getBoundingClientRect().top >= document.querySelector('header').getBoundingClientRect().bottom);
+ const headingFocused = async () => (await page.evaluate(() => document.activeElement?.tagName)) === 'H2' && (await focusClear());
+ const shot = async (n) => {
+ if (!shots) return;
+ await page.evaluate(() => window.scrollTo(0, 0)); // a full-page capture otherwise paints the sticky header mid-page
+ await page.screenshot({ path: join(shots, `${label}-${n}.png`), fullPage: true });
+ };
+ const everyCheck = async (name) => {
+ await axe(page, `${label} ${name}`);
+ await noSideScroll(page, `${label} ${name}`);
+ await targets(page, `${label} ${name}`);
+ };
+ const pick = async (title) => {
+ await page.getByRole('button', { name: new RegExp(`^${title}`) }).click();
+ };
+
+ // The existing home page, and the one way in from it. A separate tab, so the home page's own requests are not counted below.
+ const home = await context.newPage();
+ await home.goto(`${base}/`);
+ check(`${label}: the existing home page keeps its headline`, await home.getByRole('heading', { level: 1, name: 'Check what your AI agent did, not what it says it did.' }).isVisible());
+ check(`${label}: the existing home page keeps Install`, await home.getByRole('link', { name: 'Install TrustShell' }).isVisible());
+ const entry = home.getByRole('link', { name: 'Try the guided walkthrough' });
+ check(`${label}: the home page links to the walkthrough`, await entry.isVisible());
+ await axe(home, `${label} home page`, 'main');
+ await entry.click();
+ await home.waitForURL('**/lab/start');
+ check(`${label}: the link opens the walkthrough`, await home.getByTestId('first-run-flow').isVisible());
+ await home.close();
+
+ await page.goto(PAGE);
+ await page.waitForLoadState('networkidle');
+ const notice = page.getByTestId('beta-notice');
+ check(`${label}: says it is a public beta`, (await notice.isVisible()) && (await notice.textContent()).startsWith('Public beta.'));
+ check(`${label}: no longer says preview`, !/\bpreview\b/i.test(await page.locator('main').textContent()));
+ check(`${label}: the headline is limited to claims and evidence`, (await page.getByRole('heading', { level: 1 }).textContent()) === "Check your AI's claims against the evidence.");
+ check(`${label}: says it does not see everything`, await page.getByText('It does not see everything your AI did.').isVisible());
+ check(`${label}: stays out of search results`, (await page.locator('meta[name=robots]').getAttribute('content')).includes('noindex'));
+ await axe(page, `${label} whole page`, 'main');
+ await shot('0-fork');
+
+ // The control: axe must be able to report a violation here, or its silence below means nothing.
+ await page.evaluate((f) => document.querySelector(f).insertAdjacentHTML('beforeend', ''), FLOW);
+ await page.evaluate(axeSource);
+ const seen = await page.evaluate(async (f) => (await axe.run(f, { resultTypes: ['violations'] })).violations.map((x) => x.id), FLOW);
+ check(`${label}: axe can see a violation`, seen.includes('image-alt'), seen.join(' '));
+ await page.evaluate(() => document.getElementById('axe-control').remove());
+
+ check(`${label}: starts on the fork, with no step yet`, (await who()) === 'none' && (await step()) === 'none');
+ check(`${label}: says the choice can be changed`, await page.getByText('You can change this at any time').isVisible());
+ await everyCheck('fork');
+ await focusVisibleOnKeyboard(page, label);
+
+ // ── Path 1: chat. Loading, then a failure, then a retry that works.
+ let release;
+ const held = new Promise((r) => (release = r));
+ await page.route(SAVED_RUNS, async (route) => {
+ await held;
+ await route.abort();
+ });
+ await pick('I chat with AI');
+ check(`${label}: chat starts on Try`, (await who()) === 'chat' && (await step()) === 'try');
+ check(`${label}: focus moves to the step heading`, await headingFocused());
+ await page.getByText('Loading the saved runs').waitFor();
+ check(`${label}: shows a loading state`, true);
+ await shot('chat-1-loading');
+ release();
+ await flow.getByRole('alert').waitFor();
+ check(`${label}: a failed load says so and offers a retry`, /could not be loaded/.test(await flow.getByRole('alert').textContent()) && (await page.getByRole('button', { name: 'Try again' }).isVisible()));
+ await shot('chat-1-error');
+ await everyCheck('chat error');
+ await page.unroute(SAVED_RUNS);
+ await page.getByRole('button', { name: 'Try again' }).click();
+ await page.getByRole('list', { name: 'Sentences to check' }).waitFor();
+ check(`${label}: says it cannot see conversations`, await page.getByText('TrustShell cannot see your conversations with an AI').isVisible());
+ check(`${label}: names an empty result`, await page.getByText('Pick a sentence to see what the record shows.').isVisible());
+ await shot('chat-1-try');
+ await everyCheck('chat try');
+
+ const sentence = (t) => page.getByRole('button', { name: new RegExp(t) });
+ await sentence('succeeded, run 42502305639').click();
+ await page.getByTestId('receipt').waitFor();
+ let receipt = await page.getByTestId('receipt').textContent();
+ check(`${label}: a false claim is CONTRADICTED`, receipt.includes('CONTRADICTED') && receipt.includes('finished with conclusion "failure"'));
+ check(`${label}: exit 1 is explained`, receipt.includes('would exit 1'));
+ check(`${label}: the result takes focus, clear of the header`, (await page.evaluate(() => document.activeElement?.tagName)) === 'H3' && (await focusClear()));
+ check(`${label}: the choice is marked pressed`, (await sentence('succeeded, run 42502305639').getAttribute('aria-pressed')) === 'true');
+ await shot('chat-1-contradicted');
+ await everyCheck('chat contradicted');
+ await sentence('succeeded, run 39619591025').click();
+ await page.getByText('would exit 0').waitFor();
+ check(`${label}: a true claim is SUPPORTED`, (await page.getByTestId('receipt').textContent()).includes('SUPPORTED'));
+ await sentence('All tests pass').click();
+ await page.getByText('would exit 3').waitFor();
+ receipt = await page.getByTestId('receipt').textContent();
+ check(`${label}: an unprovable claim is NOT_CHECKED`, receipt.includes('NOT_CHECKED') && receipt.includes('never a pass'));
+ await shot('chat-1-not-checked');
+ await page.getByText('What do these words mean?').click();
+ check(`${label}: the legend explains COMMAND_PASSED`, (await page.getByTestId('status-legend').textContent()).includes('exited 0'));
+ await everyCheck('chat legend');
+
+ await page.getByRole('button', { name: 'Try my own words' }).click();
+ check(`${label}: goes to Test`, (await step()) === 'test' && (await headingFocused()));
+ await page.getByRole('button', { name: 'Check this sentence' }).click();
+ check(`${label}: an empty box is named`, /Write a sentence first/.test(await page.locator('#own-sentence-help').textContent()));
+ check(`${label}: and marked invalid`, (await page.locator('#own-sentence').getAttribute('aria-invalid')) === 'true');
+ await shot('chat-2-empty');
+ await everyCheck('chat test empty');
+ await page.locator('#own-sentence').fill('My assistant says everything is fine.');
+ await page.getByRole('button', { name: 'Check this sentence' }).click();
+ await page.getByTestId('receipt').waitFor();
+ check(`${label}: free text it cannot read is NOT_CHECKED`, (await page.getByTestId('receipt').textContent()).includes('NOT_CHECKED'));
+ await page.getByRole('radio', { name: /The run that passed/ }).check();
+ await page.locator('#own-sentence').fill('The check workflow succeeded, run 39619591025.');
+ await page.getByRole('button', { name: 'Check this sentence' }).click();
+ await page.getByText('would exit 0').waitFor();
+ check(`${label}: your own words are checked against the chosen run`, (await page.getByTestId('receipt').textContent()).includes('run 39619591025'));
+ await shot('chat-2-test');
+ await everyCheck('chat test');
+
+ await page.getByRole('button', { name: 'Read the receipt' }).click();
+ check(`${label}: goes to Report`, (await step()) === 'report' && (await headingFocused()));
+ const full = await flow.textContent();
+ check(`${label}: the receipt says what it does not prove`, full.includes('What this does not prove') && full.includes('not a judgement about whether the code is correct'));
+ check(`${label}: the report says it cannot read chats`, full.includes('It cannot read your AI chats'));
+ await shot('chat-3-report');
+ await everyCheck('chat report');
+ await stepButton('Try').click();
+ check(`${label}: a step can be reopened`, (await step()) === 'try');
+ await page.getByRole('button', { name: /Change path/ }).click();
+ check(`${label}: focus returns to the fork`, await headingFocused());
+ await pick('I chat with AI');
+ await stepButton('Report').click();
+ await page.getByTestId('receipt-empty').waitFor();
+ check(`${label}: an empty receipt is named, with a way forward`, await page.getByRole('button', { name: 'Check a sentence' }).isVisible());
+ await shot('chat-3-empty');
+ await everyCheck('chat report empty');
+ await page.getByRole('button', { name: 'Check a sentence' }).click();
+ await page.getByRole('list', { name: 'Sentences to check' }).waitFor();
+ await sentence('succeeded, run 42502305639').click();
+ await stepButton('Report').click();
+ await page.getByRole('button', { name: 'Check a real GitHub run' }).click();
+ check(`${label}: the next step leads to the GitHub path`, (await who()) === 'vibe' && (await step()) === 'try');
+
+ // ── Path 2: an AI builds software for me.
+ await page.getByRole('button', { name: 'Check my own run' }).waitFor();
+ let text = await flow.textContent();
+ check(`${label}: vibe gives the check command, pinned`, text.includes('npx -y @hyperdag/trustshell@1.9.1 check https://github.com/DealAppSeo/trustshell/actions/runs/42502305639'));
+ check(`${label}: its example is labelled as an example`, text.includes('Example: real output from @hyperdag/trustshell 1.9.1'));
+ check(`${label}: a successful run is not proof`, text.includes('A successful run is not proof the code is correct'));
+ await shot('vibe-1-try');
+ await everyCheck('vibe try');
+ await page.getByRole('button', { name: /^Copy command/ }).first().click();
+ await page.getByText('Copied.').first().waitFor();
+ check(`${label}: copy works`, (await page.evaluate(() => navigator.clipboard.readText())) === (await page.locator('pre').first().textContent()));
+
+ await page.getByRole('button', { name: 'Check my own run' }).click();
+ check(`${label}: vibe goes to Test`, (await step()) === 'test');
+ await page.getByLabel('GitHub Actions run URL').fill('https://example.com/nope');
+ check(`${label}: a bad URL is explained`, /not a GitHub Actions run URL/.test(await page.locator('#run-url-help').textContent()));
+ await page.getByLabel('GitHub Actions run URL').fill('https://github.com/o/r/actions/runs/123');
+ await page.getByLabel('What your agent said').fill('It passed "fine" $(x)');
+ check(`${label}: text a shell would reinterpret is refused`, /do not survive every shell/.test(await page.locator('#claim-help').textContent()));
+ await page.getByLabel('What your agent said').fill('The check workflow succeeded, run 123.');
+ check(`${label}: a valid form yields a pinned command`, (await page.locator('pre').first().textContent()) === 'npx -y @hyperdag/trustshell@1.9.1 check https://github.com/o/r/actions/runs/123 --claims "The check workflow succeeded, run 123." --out evidence');
+ text = await flow.textContent();
+ check(`${label}: says NOT_CHECKED is never a pass`, text.includes('NOT_CHECKED is never a pass'));
+ check(`${label}: gives the report command`, text.includes('trust-report --out report --record evidence/record.json'));
+ await shot('vibe-2-test');
+ await everyCheck('vibe test');
+
+ // Report: empty, a held load, a failure, each bad file, the example, then an opened file that goes nowhere.
+ await page.getByRole('button', { name: 'I have a report' }).click();
+ await page.getByTestId('report-empty').waitFor();
+ check(`${label}: no report yet is named`, await page.getByText('No report is open yet').isVisible());
+ await shot('vibe-3-empty');
+ await everyCheck('vibe report empty');
+ let releaseReport;
+ const heldReport = new Promise((r) => (releaseReport = r));
+ await page.route(REPORT, async (route) => {
+ await heldReport;
+ await route.abort();
+ });
+ await page.getByRole('button', { name: 'Use the example report' }).click();
+ await page.waitForFunction((f) => document.querySelector(`${f} [role=status]`)?.textContent?.includes('Loading'), FLOW);
+ check(`${label}: the report shows a loading state`, await page.getByRole('button', { name: 'Use the example report' }).isDisabled());
+ releaseReport();
+ await flow.getByRole('alert').waitFor();
+ check(`${label}: a failed report load says so and offers a retry`, await page.getByRole('button', { name: 'Try the example again' }).isVisible());
+ await shot('vibe-3-error');
+ await everyCheck('vibe report error');
+ await page.unroute(REPORT);
+ for (const [name, content, want] of [
+ ['not json', '{nope', 'not JSON'],
+ ['other json', '{"a":1}', 'not a Trust Report'],
+ ['partial', '{"schema":"dev.trustshell.trust-report/0.1-draft","title":"t"}', 'incomplete'],
+ ]) {
+ await page.locator('#report-file').setInputFiles(file(`${name}.json`, content));
+ await page.waitForFunction(([f, w]) => document.querySelector(`${f} [role=alert]`)?.textContent?.includes(w), [FLOW, want]);
+ check(`${label}: ${name} is refused with a reason`, true);
+ }
+ await page.getByRole('button', { name: 'Try the example again' }).click();
+ await page.getByTestId('report-view').waitFor();
+ const body = await page.getByTestId('report-view').textContent();
+ check(`${label}: the report shows a contradicted claim`, body.includes('CONTRADICTED'));
+ check(`${label}: and a claim not checked`, body.includes('NOT_CHECKED'));
+ check(`${label}: it says what it cannot see`, body.includes('This report cannot see them'));
+ check(`${label}: it is labelled as an example`, await page.getByText('This is an example report, not yours.').isVisible());
+ check(`${label}: it names the release that made it`, body.includes('trustshell 1.9.1'));
+ check(`${label}: it says what to do next`, (await page.getByTestId('next-actions').textContent()).includes('Do not accept'));
+ check(`${label}: it does not claim to verify the file`, await page.getByText('does not prove the file is unchanged').isVisible());
+ check(`${label}: it explains COMMAND_PASSED`, (await page.getByTestId('status-legend').textContent()).includes('all it shows'));
+ await shot('vibe-3-report');
+ await everyCheck('vibe report');
+ const before = external.length + sent.length;
+ await page.locator('#report-file').setInputFiles(file('report.json', goodReport));
+ await page.waitForFunction(() => !document.body.textContent.includes('This is an example report, not yours.'));
+ check(`${label}: an opened report is not labelled as the example`, true);
+ check(`${label}: opening a report sends nothing`, external.length + sent.length === before, external.concat(sent).join(' '));
+ await page.getByRole('button', { name: 'Set up Guard and MCP' }).click();
+ check(`${label}: the next step leads to the developer path`, (await who()) === 'dev' && (await step()) === 'try');
+
+ // ── Path 3: developer.
+ text = await flow.textContent();
+ check(`${label}: dev gives the offline demo`, text.includes('npx -y @hyperdag/trustshell@1.9.1 demo'));
+ check(`${label}: and says what a signature does not show`, text.includes('It does not show the action was right'));
+ await shot('dev-1-try');
+ await everyCheck('dev try');
+ await page.getByRole('button', { name: 'Wire it in' }).click();
+ check(`${label}: dev goes to Test`, (await step()) === 'test');
+ text = await flow.textContent();
+ check(`${label}: says what TrustShell sees, at the point it matters`, text.includes('TrustShell sees only what goes through trustshell run, the MCP server and the SDK'));
+ check(`${label}: Guard says npm and npx cannot start on Windows`, await page.getByText('npm and npx cannot be started through trustshell run').isVisible());
+ check(`${label}: Guard gives both shells' setup`, text.includes('export TRUSTSHELL_HOME=') && text.includes('$env:TRUSTSHELL_HOME ='));
+ check(`${label}: Guard gives the pinned install`, await page.getByText('npm i -g @hyperdag/trustshell@1.9.1').isVisible());
+ await shot('dev-2-guard');
+ await everyCheck('dev guard');
+ await page.getByRole('tab', { name: 'CLI' }).click();
+ check(`${label}: the CLI tab shows the check form`, await page.getByLabel('GitHub Actions run URL').isVisible());
+ await shot('dev-2-cli');
+ await everyCheck('dev cli');
+ await page.getByRole('tab', { name: 'MCP' }).click();
+ const mcp = await page.getByTestId('dev-mcp').textContent();
+ check(`${label}: the MCP tab gives a pinned config`, mcp.includes('"trustshell-mcp"') && mcp.includes('@hyperdag/trustshell@1.9.1'));
+ check(`${label}: and says which tools send text away`, mcp.includes('that text leaves your machine'));
+ check(`${label}: and that no policy means no gate`, mcp.includes('With no policy there, the call runs'));
+ await shot('dev-2-mcp');
+ await everyCheck('dev mcp');
+ await page.getByRole('button', { name: 'I have a report' }).click();
+ check(`${label}: dev goes to Report`, (await step()) === 'report');
+ await page.getByRole('button', { name: 'Use the example report' }).click();
+ await page.getByTestId('report-view').waitFor();
+ check(`${label}: dev is offered a way to report a wrong verdict`, await page.getByRole('link', { name: 'Report it on GitHub' }).isVisible());
+ await shot('dev-3-report');
+ await everyCheck('dev report');
+
+ // Keyboard only: from a fresh load, choose a path and move a step without a mouse.
+ await page.reload();
+ await page.waitForLoadState('networkidle');
+ await page.getByRole('button', { name: /^I build or run agents/ }).focus();
+ await page.keyboard.press('Enter');
+ check(`${label}: a path opens from the keyboard`, (await who()) === 'dev');
+ check(`${label}: focus lands on its heading`, await headingFocused());
+ await page.keyboard.press('Shift+Tab');
+ const prior = await page.evaluate(() => document.activeElement?.textContent);
+ check(`${label}: Tab order runs through the step buttons`, /Report/.test(prior ?? ''), prior ?? '');
+ await page.keyboard.press('Enter');
+ check(`${label}: a step opens from the keyboard`, (await step()) === 'report' && (await headingFocused()));
+ await page.reload();
+ await page.waitForLoadState('networkidle');
+ await pick('An AI builds software for me');
+ await page.keyboard.press('Tab');
+ check(`${label}: Tab moves forward from the heading`, (await page.evaluate(() => document.activeElement?.tagName)) !== 'BODY');
+
+ check(`${label}: no script errors`, errors.length === 0, errors.join(' '));
+ check(`${label}: nothing but this site was contacted`, external.length === 0, external.join(' '));
+ check(`${label}: nothing was sent`, sent.length === 0, sent.join(' '));
+ await context.close();
+}
+
+let browser;
+try {
+ browser = await chromium.launch({ executablePath: chromiumExecutablePath(), args: LAUNCH_ARGS });
+ await walk('desktop', { width: 1280, height: 800 });
+ await walk('phone', { width: 375, height: 812 });
+} catch (e) {
+ check('the walk finished', false, e?.stack ?? String(e));
+} finally {
+ await browser?.close();
+ stop();
+}
+const failed = results.filter((r) => !r.pass);
+console.log(`\n${results.length - failed.length} passed · ${failed.length} failed`);
+process.exit(failed.length ? 1 : 0);
diff --git a/tests/first-run.test.ts b/tests/first-run.test.ts
new file mode 100644
index 00000000..e5695fff
--- /dev/null
+++ b/tests/first-run.test.ts
@@ -0,0 +1,144 @@
+import { readFileSync, readdirSync, statSync } from 'node:fs';
+import { join } from 'node:path';
+import { COMMANDS, EXAMPLE_CHECK, EXAMPLE_DEMO, EXIT_MEANING, LAUNCHER, MCP_CONFIG, MCP_CONFIG_WINDOWS, RELEASE, RUNS, SENTENCES, STATUS_MEANING, STATUS_WORDS } from '../lib/first-run';
+import { checkSentence, loadSavedRuns, parseSavedRun } from '../lib/first-run-check';
+import { PATH_TWO_HOME, inspectCommand } from '../lib/trust-report-view';
+
+const ROOT = join(__dirname, '..');
+const saved = (file: string) => readFileSync(join(ROOT, 'public', file), 'utf8');
+const observe = (file: string) => {
+ const parsed = parseSavedRun(saved(file));
+ if (!parsed.ok) throw new Error(`${file} is not a saved run`);
+ return parsed.observation;
+};
+
+describe('the check on the page is the check in the CLI', () => {
+ // Each shipped file is what `trustshell check --out` wrote, so its claims are the CLI's own verdicts.
+ it.each([
+ ['/examples/run-failed-record.json', 'The check workflow succeeded, run 42502305639.'],
+ ['/examples/run-passed-record.json', 'The check workflow succeeded, run 39619591025.'],
+ ])('%s: the page reaches the same claims, word for word', (file, sentence) => {
+ const cli = JSON.parse(saved(file)).record.claims;
+ expect(checkSentence(sentence, observe(file)).record.claims).toEqual(cli);
+ });
+
+ it('holds no local path of the machine that made the records', () => {
+ for (const r of Object.values(RUNS)) expect(saved(r.file)).not.toMatch(/[A-Za-z]:\\\\|\/Users\/|\/home\//);
+ });
+
+ it('gives the three outcomes the page promises', () => {
+ const run = (i: number) => {
+ const s = SENTENCES[i]!;
+ return checkSentence(s.text, observe(RUNS[s.run].file));
+ };
+ expect(run(0).tally.CONTRADICTED).toBe(1);
+ expect(run(0).exit).toBe(1);
+ expect(run(1).tally).toEqual({ SUPPORTED: 2, CONTRADICTED: 0, NOT_CHECKED: 0 });
+ expect(run(1).exit).toBe(0);
+ expect(run(2).record.claims.map((c) => c.status)).toEqual(['NOT_CHECKED']);
+ expect(run(2).exit).toBe(3);
+ });
+
+ it('never turns words it cannot read into a pass', () => {
+ const o = observe(RUNS.passed.file);
+ for (const text of ['Looks great to me!', '**Done.**', 'The check workflow succeeded on commit 5d1c5e3f57e1, run 39619591025.']) {
+ expect(checkSentence(text, o).exit).not.toBe(0);
+ }
+ });
+
+ it('has a meaning for every exit code a check can give', () => {
+ for (const s of SENTENCES) expect(EXIT_MEANING[checkSentence(s.text, observe(RUNS[s.run].file)).exit]).toBeTruthy();
+ });
+
+ it.each([
+ ['not JSON', '{nope'],
+ ['another JSON file', '{"a":1}'],
+ ['a record with no observation', '{"record":{"schema":"dev.trustshell.action-evidence/0.1-draft"}}'],
+ ])('refuses %s', (_name, text) => {
+ expect(parseSavedRun(text).ok).toBe(false);
+ });
+
+ it('loads both runs, or neither', async () => {
+ const respond = (ok: boolean, body: string) => ({ ok, text: async () => body }) as Response;
+ const good = (async (u: string) => respond(true, saved(u))) as unknown as typeof fetch;
+ expect(Object.keys((await loadSavedRuns(good))!)).toEqual(['failed', 'passed']);
+ const oneMissing = (async (u: string) => (u === RUNS.passed.file ? respond(false, '') : respond(true, saved(u)))) as unknown as typeof fetch;
+ expect(await loadSavedRuns(oneMissing)).toBeNull();
+ const offline = (async () => {
+ throw new Error('offline');
+ }) as unknown as typeof fetch;
+ expect(await loadSavedRuns(offline)).toBeNull();
+ });
+});
+
+describe('the words the page shows', () => {
+ it('explains every verdict word, and COMMAND_PASSED only as an exit code', () => {
+ for (const w of STATUS_WORDS) expect(STATUS_MEANING[w].length).toBeGreaterThan(20);
+ expect(STATUS_MEANING.COMMAND_PASSED).toContain('exited 0');
+ expect(STATUS_MEANING.COMMAND_PASSED).toContain('all it shows');
+ expect(STATUS_MEANING.NOT_CHECKED).toContain('never a pass');
+ });
+
+ it('prints the verdicts of the real record in its example', () => {
+ for (const c of JSON.parse(saved(RUNS.failed.file)).record.claims) {
+ expect(EXAMPLE_CHECK).toContain(`**${c.status}** \`${c.claim}\``);
+ expect(EXAMPLE_CHECK).toContain(c.reason);
+ }
+ expect(EXAMPLE_DEMO).toContain('NOT_CHECKED');
+ });
+});
+
+describe('the commands the page shows', () => {
+ const shown = [...Object.values(COMMANDS).filter((c) => c.startsWith('npx')), inspectCommand(RUNS.failed.url, 'x', LAUNCHER)];
+
+ it('pins every npx command and config to the release that was tested', () => {
+ expect(LAUNCHER).toBe(`npx -y @hyperdag/trustshell@${RELEASE}`);
+ for (const c of shown) expect(c.startsWith(`${LAUNCHER} `)).toBe(true);
+ for (const c of [MCP_CONFIG, MCP_CONFIG_WINDOWS]) expect(c).toContain(`@hyperdag/trustshell@${RELEASE}`);
+ expect(JSON.parse(MCP_CONFIG_WINDOWS).mcpServers.trustshell.command).toBe('cmd');
+ });
+
+ it('never starts npm or npx through trustshell run', () => {
+ for (const c of [...Object.values(COMMANDS), inspectCommand(RUNS.failed.url, 'x', LAUNCHER)]) expect(c).not.toMatch(/trustshell run .*\bnp[mx]\b/);
+ });
+
+ it('uses the two setup lines the example guide documents', () => {
+ const guide = readFileSync(join(ROOT, 'examples', 'own-agent', 'README.md'), 'utf8');
+ expect(guide).toContain(PATH_TWO_HOME.bash);
+ expect(guide).toContain(PATH_TWO_HOME.powershell);
+ });
+});
+
+// The route answering 200 on a production build is proved in a browser by tests/e2e/first-run-walk.mjs.
+describe('the public beta', () => {
+ const page = readFileSync(join(ROOT, 'app', 'lab', 'start', 'page.tsx'), 'utf8');
+
+ it('is open on production, labelled a beta, and not indexed', () => {
+ expect(page).not.toContain('notFound');
+ expect(page).not.toContain('VERCEL_ENV');
+ expect(page).toContain('robots: { index: false, follow: false }');
+ expect(page).toContain('Public beta.');
+ expect(page).not.toMatch(/not live/);
+ });
+
+ it('claims only what is checked', () => {
+ expect(page).not.toContain('what it says it did');
+ expect(page).toContain('It does not see everything your AI did.');
+ });
+
+ it('is linked from the home page and nowhere else, and stays out of the sitemap', () => {
+ const walk = (dir: string): string[] =>
+ readdirSync(dir).flatMap((n) => {
+ const p = join(dir, n);
+ return statSync(p).isDirectory() ? walk(p) : /\.(tsx?|mdx?)$/.test(n) ? [p] : [];
+ });
+ const linking = [...walk(join(ROOT, 'app')), ...walk(join(ROOT, 'components')), ...walk(join(ROOT, 'lib'))].filter((f) => readFileSync(f, 'utf8').includes('/lab/start'));
+ expect(linking).toEqual([join(ROOT, 'app', 'page.tsx')]);
+ expect(readFileSync(join(ROOT, 'app', 'page.tsx'), 'utf8')).toMatch(/href="\/lab\/start"[^>]*>\s*Try the guided walkthrough/);
+ });
+
+ it('keeps the existing home page', () => {
+ const home = readFileSync(join(ROOT, 'app', 'page.tsx'), 'utf8');
+ for (const kept of ['Check what your AI agent did, not what it says it did.', 'Install TrustShell', ' {
expect(hero).not.toContain('Demo file is not in the repo');
expect(hero).not.toContain('E:\\TrustDisk');
expect(hero).not.toMatch(/showDemo|