diff --git a/docs/architecture/D3-cockpit-dashboard-host.md b/docs/architecture/D3-cockpit-dashboard-host.md new file mode 100644 index 0000000..f78acc7 --- /dev/null +++ b/docs/architecture/D3-cockpit-dashboard-host.md @@ -0,0 +1,129 @@ +# Cockpit Read-Only Dashboard Host (Cockpit D3) + +Status: V1 defaults, Stage A. Superseded only by an explicit architecture decision. + +## Scope + +D3 is the first *visible* Cockpit surface: a local HTTP host that renders one +deterministic fixture snapshot, validated through the existing D1/D2 contracts, +as a read-only browser page. + + Stage-A fixture (unknown) + -> readCockpitSnapshot() (D1: hostile-input validation) + -> projectCockpitEvidenceFreshness() (D2: freshness projection) + -> server-side HTML (every value escaped) + -> 127.0.0.1 GET-only browser view + +D3 is **presentation and observability only**. It is not authority, not policy, +not a collector, not persistence, and not an agent-control interface. + +## Why the host lives outside `src/cockpit/` + +D1 and D2 are pure: `tests/cockpit/architecture-invariants.test.ts` fails the +build if any file in `src/cockpit/` references `node:fs`, `node:http`, +`child_process`, `fetch(`, `process.`, Git, or an import outside the domain +kernel. A dashboard host needs `node:http`. Placing it in `src/cockpit/` would +break that invariant, so D3 is a **separate sibling module**, `src/cockpit-host/`, +that *imports* the pure Cockpit boundary and never modifies it. The host keeps +its own narrower purity invariant (`tests/cockpit-host/purity.test.ts`): no +subprocess, no environment access, no Git, and imports only Node builtins, +itself, or `../cockpit/`. + +## Ingestion boundary + +- **D1 is the only hostile-input normalizer.** The Stage-A fixture is typed + `unknown` and passes through `readCockpitSnapshot()` before any value is + rendered. If validation fails, the host **fails closed** — it throws and + refuses to serve rather than falling back to raw fixture data. +- **D2 is the only freshness projection.** Freshness state/reason/counts come + verbatim from `projectCockpitEvidenceFreshness()`; the host never re-derives + `CURRENT`/`STALE`/`INVALID`. +- The renderer accepts a validated `CockpitSnapshot`, never `unknown`, so a raw + caller object can never reach the page unvalidated. + +## Authority: none + +D3 has zero authority and no mutation surface of any kind: + +- **No repository write** — no filesystem write to the repo, no Git. +- **No GitHub write** — no adapter imported, no network egress. +- **No agent invocation, ExecutionPermit, or merge capability** — C1 authority + and the provider adapters are not imported and are unreachable. + +The absence is structural: there is no field, route, or imported function +through which a mutation could flow. The V1 read-only boundary is preserved and +**human merge authority remains external**. A reviewer recommendation is +rendered as a *claim*, never as permission. + +## Honest gap panels + +The dashboard shows only what the current read models project: + +- **Tree SHA — not projected.** D1 carries the observed HEAD only; there is no + tree SHA field. The host renders a capability notice, never an invented value. +- **Autoflow — not projected yet.** `WorkflowState` (status, revision, sequence, + invocations, human gate) has no Cockpit projection. Rendering a fixture + `WorkflowState` would manufacture orchestration state, so the Autoflow panel + shows an honest "not projected yet" notice. A real Autoflow view requires a + future **pure Cockpit D4 projection**; D3 does not import `WorkflowState`, + `workflow-transitions`, or `applyWorkflowEvent`. + +## HTTP security boundary + +- **Loopback only** — binds the literal `127.0.0.1`, never `0.0.0.0`, `::`, or a + resolvable hostname. +- **GET-only** — any other method returns `405 Method Not Allowed` with + `Allow: GET`; unknown routes return `404`. No mutation route, no request body, + no cookie, no session. +- **Strict headers on every response** — + `Content-Security-Policy: default-src 'none'; style-src 'self'; script-src 'none'; …` + and `X-Content-Type-Options: nosniff`. The page ships no client-side + JavaScript and no inline `style` attribute, so the policy needs no + `'unsafe-inline'` and no script source. +- **Untrusted text is always escaped** — every dynamic value (reviewer/finding + prose included) is HTML-entity-escaped before it enters the markup. No + `innerHTML`, no `document.write`, no inline event handler. Adversarial tests + assert that `', + }, + ], + evidence: [ + { + evidenceId: 'ev-ci-001', + kind: 'ci-result', + source: 'github', + commitSha: HEAD_SHA, + reference: 'gh-actions/run/1024', + observedAt: '2026-08-24T11:40:00.000Z', + }, + { + evidenceId: 'ev-review-002', + kind: 'code-review', + source: 'agent', + commitSha: HEAD_SHA, + reference: 'claude/review/pr-42', + observedAt: '2026-08-24T11:45:00.000Z', + }, + { + evidenceId: 'ev-sec-003', + kind: 'security-review', + source: 'local-verification', + commitSha: OLD_SHA, + reference: 'local/security-scan/pr-42', + observedAt: '2026-08-23T09:10:00.000Z', + }, + { + evidenceId: 'ev-test-004', + kind: 'test-result', + source: 'local-verification', + commitSha: HEAD_SHA, + reference: 'vitest/run/883', + observedAt: '2026-08-24T11:50:00.000Z', + }, + { + evidenceId: 'ev-human-005', + kind: 'human-decision', + source: 'human', + commitSha: HEAD_SHA, + reference: 'maintainer/gate/pr-42', + observedAt: '2026-08-24T11:55:00.000Z', + }, + { + evidenceId: 'ev-repo-006', + kind: 'repository-state', + source: 'github', + commitSha: OLD_SHA, + reference: 'gh/tree/dead0000', + observedAt: '2026-08-23T08:00:00.000Z', + }, + ], + findings: [ + { + findingId: 'f-001', + pullRequestId: 'pr-42', + reviewedCommitSha: OLD_SHA, + provider: 'claude', + reviewerId: 'claude-review-bot', + severity: 'blocking', + classification: 'security', + status: 'open', + // Hostile title + message: must render as inert escaped text. + title: " SQL injection in login handler", + message: + '"> Use parameterized queries & escape < > characters before rendering.', + filePath: 'src/auth/login.ts', + disposition: 'future-layer-obligation', + advisoryFreshness: 'STALE', + }, + { + findingId: 'f-002', + pullRequestId: 'pr-42', + reviewedCommitSha: HEAD_SHA, + provider: 'codex', + reviewerId: 'codex-review-bot', + severity: 'major', + classification: 'correctness', + status: 'open', + title: 'Off-by-one in session expiry comparison', + message: 'The expiry check uses <= where < is intended; sessions live one tick too long.', + filePath: 'src/auth/session.ts', + disposition: 'maintenance-observation', + advisoryFreshness: 'CURRENT', + }, + { + findingId: 'f-003', + pullRequestId: 'pr-43', + reviewedCommitSha: HEAD_SHA, + provider: 'claude', + reviewerId: 'claude-review-bot', + severity: 'minor', + classification: 'maintainability', + status: 'resolved', + title: 'Extract duplicated header-building logic', + message: 'Two handlers build the same response headers; factor into one helper.', + filePath: 'src/http/headers.ts', + disposition: 'optional-cleanup', + advisoryFreshness: null, + }, + { + findingId: 'f-004', + pullRequestId: 'pr-43', + reviewedCommitSha: HEAD_SHA, + provider: 'coderabbit', + reviewerId: 'coderabbit-bot', + severity: 'info', + classification: 'performance', + status: 'unknown', + title: 'Consider memoizing the freshness projection', + message: 'For large snapshots the projection could be cached per observed HEAD.', + filePath: null, + disposition: 'deferred', + advisoryFreshness: null, + }, + ], + repairJobs: [ + { + jobId: 'repair-001', + parentPullRequestId: 'pr-42', + findingId: 'f-001', + repairBranch: 'refs/heads/repair/f-001-login-injection', + repairAgentId: 'claude-repair-agent', + independentValidatorId: 'codex-independent-validator', + }, + ], +}; diff --git a/src/cockpit-host/render.ts b/src/cockpit-host/render.ts new file mode 100644 index 0000000..d99d06e --- /dev/null +++ b/src/cockpit-host/render.ts @@ -0,0 +1,317 @@ +/** + * Cockpit D3 — pure server-side HTML rendering. + * + * Turns a **D1-validated** {@link CockpitSnapshot} and its **D2** evidence + * freshness projection into a single self-contained HTML string. This module is + * pure and total: no I/O, no network, no process access, no clock, no mutation. + * It imports only the Cockpit read-model boundary and the local escaper. + * + * Every dynamic value is passed through {@link escapeHtml} before it enters the + * markup. The output contains no `"); + expect(html).not.toContain(''); + expect(html).not.toContain(''); + // They must appear in escaped form instead. + expect(html).toContain('<script>alert('xss-title')</script>'); + expect(html).toContain('<img src=x onerror=alert(1)>'); + }); + + it('does not dump the raw fixture object as JSON', () => { + // A raw JSON.stringify of the fixture would contain quoted property keys. + expect(html).not.toContain('"findingId":'); + expect(html).not.toContain('"repositoryId":'); + }); + + it('presents the authority legend without offering any action control', () => { + expect(html).toContain('Authority legend'); + expect(html).toContain('Human-only action'); + // No interactive authority surface exists anywhere in the markup. + expect(html).not.toContain(' { + expect(html).not.toContain(' { + expect(html).toContain('state-CURRENT'); + expect(html).toContain('state-STALE'); + }); +}); + +describe('Cockpit D3 finding binding (D3-CODEX-F1)', () => { + const html = buildDashboardHtml(); + + // Fixture SHAs. Each finding is reviewed against exactly one of these. + const HEAD_SHA = 'c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00'; + const OLD_SHA = 'dead0000dead0000dead0000dead0000dead0000'; + + // Isolate the Findings
so assertions are scoped to it, not to the + // whole page: the PR ids and reviewed-commit SHAs also legitimately appear in + // the Pull requests, Evidence, and Repair-jobs sections, so a page-wide + // `toContain` would pass even if the Findings table still omitted the binding. + const headingAt = html.indexOf('

Findings'); + const sectionStart = html.lastIndexOf('
', headingAt); + const sectionEnd = html.indexOf('
', headingAt); + const findingsSection = html.slice(sectionStart, sectionEnd); + + // The header row, and each finding's , scoped within the Findings section. + const headerRow = findingsSection.slice( + findingsSection.indexOf(''), + findingsSection.indexOf(''), + ); + const rowFor = (findingId: string): string => { + const rows = findingsSection.split(''); + const row = rows.find((candidate) => candidate.includes(`>${findingId}<`)); + expect(row, `row for ${findingId}`).toBeDefined(); + return row as string; + }; + + it('labels finding identity and reviewer attribution as distinct columns', () => { + expect(headerRow).toContain('PR'); + expect(headerRow).toContain('Reviewed commit'); + expect(headerRow).toContain('Provider'); + expect(headerRow).toContain('Reviewer'); + expect(headerRow).toContain('Advisory freshness'); + // "Reviewed commit" is the load-bearing label: it must not collapse to a + // bare "Commit", which would blur it against the repository Observed HEAD + // SHA and the PR Head SHA shown elsewhere on the page. + expect(headerRow).not.toContain('Commit'); + }); + + it('binds f-001 to pr-42 reviewed against the older (STALE) commit', () => { + const row = rowFor('f-001'); + expect(row).toContain('pr-42'); + expect(row).toContain(OLD_SHA); + }); + + it('binds f-002 to pr-42 reviewed against the observed HEAD commit', () => { + const row = rowFor('f-002'); + expect(row).toContain('pr-42'); + expect(row).toContain(HEAD_SHA); + }); + + it('binds an f-003/pr-43 finding to its PR and reviewed commit', () => { + const row = rowFor('f-003'); + expect(row).toContain('pr-43'); + expect(row).toContain(HEAD_SHA); + }); + + it('attributes claims from different providers to their exact reviewers', () => { + const claudeRow = rowFor('f-001'); + expect(claudeRow).toContain('claude'); + expect(claudeRow).toContain('claude-review-bot'); + + const codexRow = rowFor('f-002'); + expect(codexRow).toContain('codex'); + expect(codexRow).toContain('codex-review-bot'); + + const coderabbitRow = rowFor('f-004'); + expect(coderabbitRow).toContain('coderabbit'); + expect(coderabbitRow).toContain('coderabbit-bot'); + }); + + it('keeps two same-PR findings distinguishable by reviewed commit', () => { + // f-001 and f-002 are both pr-42 but were reviewed against different + // commits; the reviewed-commit binding, not advisory freshness, is what + // tells them apart. + expect(rowFor('f-001')).toContain(OLD_SHA); + expect(rowFor('f-002')).toContain(HEAD_SHA); + expect(rowFor('f-001')).not.toContain(HEAD_SHA); + }); + + it('keeps advisory freshness separate from reviewed-commit identity', () => { + // f-003 carries no advisory claim yet still shows its reviewed commit: + // freshness is not a substitute for commit identity. + const row = rowFor('f-003'); + expect(row).toContain(HEAD_SHA); + expect(row).toContain('no claim'); + }); + + it('renders both binding values as inert escaped text', () => { + // pullRequestId / reviewedCommitSha go through the same escaper as every + // other value; no raw markup or interactive control is introduced. + expect(findingsSection).not.toContain(' { + await new Promise((resolve) => { + // Listen on an ephemeral port on loopback to avoid clashing with the fixed port. + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + if (address === null || typeof address === 'string') { + throw new Error('expected an AddressInfo from server.address()'); + } + const info: AddressInfo = address; + boundAddress = info.address; + base = `http://127.0.0.1:${String(info.port)}`; +}); + +afterAll(async () => { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error === undefined) { + resolve(); + } else { + reject(error); + } + }); + }); +}); + +describe('Cockpit D3 host constants', () => { + it('binds the literal loopback address', () => { + expect(HOST).toBe('127.0.0.1'); + expect(boundAddress).toBe('127.0.0.1'); + }); + + it('declares a fixed non-privileged port', () => { + expect(PORT).toBeGreaterThanOrEqual(1024); + }); +}); + +describe('Cockpit D3 routes', () => { + it('GET / returns 200 HTML identifying read-only Stage-A data', async () => { + const response = await fetch(`${base}/`); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('text/html; charset=utf-8'); + const body = await response.text(); + expect(body).toContain('READ ONLY'); + expect(body).toContain('STAGE A'); + expect(body).toContain('Not projected yet'); + expect(body).toContain('Tree SHA'); + }); + + it('GET /styles.css returns 200 CSS', async () => { + const response = await fetch(`${base}/styles.css`); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('text/css; charset=utf-8'); + const body = await response.text(); + expect(body).toContain('body'); + }); + + it('POST / returns 405 with an Allow: GET header (no mutation route)', async () => { + const response = await fetch(`${base}/`, { method: 'POST' }); + expect(response.status).toBe(405); + expect(response.headers.get('allow')).toBe('GET'); + }); + + it('rejects every non-GET method with 405', async () => { + for (const method of ['PUT', 'DELETE', 'PATCH']) { + const response = await fetch(`${base}/`, { method }); + expect(response.status).toBe(405); + } + }); + + it('unknown route returns 404', async () => { + const response = await fetch(`${base}/does-not-exist`); + expect(response.status).toBe(404); + }); +}); + +describe('Cockpit D3 security headers', () => { + it('sends a strict CSP that blocks scripts and permits only the local stylesheet', async () => { + const response = await fetch(`${base}/`); + const csp = response.headers.get('content-security-policy') ?? ''; + expect(csp).toContain("default-src 'none'"); + expect(csp).toContain("script-src 'none'"); + expect(csp).toContain("style-src 'self'"); + }); + + it('sends X-Content-Type-Options: nosniff on every response', async () => { + const rootResponse = await fetch(`${base}/`); + expect(rootResponse.headers.get('x-content-type-options')).toBe('nosniff'); + const missingResponse = await fetch(`${base}/nope`); + expect(missingResponse.headers.get('x-content-type-options')).toBe('nosniff'); + }); +});