Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
4d9576a
feat(cockpit): add D3 read-only dashboard host
LogicDuke Aug 24, 2026
46530c7
fix(cockpit): show finding review bindings
LogicDuke Aug 24, 2026
4b7d033
Merge pull request #56 from LogicDuke/repair/d3-codex-f1-finding-binding
LogicDuke Aug 24, 2026
97d89d8
test(cockpit): harden D3 import scanner
LogicDuke Aug 24, 2026
e10be20
Merge pull request #57 from LogicDuke/repair/d3-cr-f1-import-scanner
LogicDuke Aug 24, 2026
7627ff9
test(cockpit): close D3 scanner residual gaps
LogicDuke Aug 25, 2026
654ded0
test(cockpit): close D3 scanner comment gaps
LogicDuke Aug 26, 2026
24efb0a
test(cockpit): close D3 re-export comment gap
LogicDuke Aug 26, 2026
a36e445
test(cockpit): consolidate ESM scanner invariants
LogicDuke Aug 26, 2026
2cf9e14
test(cockpit): close template scanner review gaps
LogicDuke Aug 27, 2026
aed7376
test(cockpit): harden control-context scanning
LogicDuke Aug 27, 2026
2a3ab33
test(cockpit): handle postfix and for-await scanner contexts
LogicDuke Aug 27, 2026
68df198
test(cockpit): preserve restricted-statement regex context
LogicDuke Aug 27, 2026
df2a7e2
test(cockpit): replace heuristic scanner with TypeScript AST
LogicDuke Aug 27, 2026
64112a5
Merge pull request #58 from LogicDuke/repair/d3-cr-f2-f3-scanner-resi…
LogicDuke Aug 27, 2026
1f039a9
test(cockpit): confine relative host imports
LogicDuke Aug 27, 2026
6ac6a4d
test(cockpit): align confinement with Node URL resolution
LogicDuke Aug 27, 2026
b742caf
Merge pull request #62 from LogicDuke/repair/d3-policy-f1-url-dot-seg…
LogicDuke Aug 27, 2026
10ef706
test(cockpit): harden host import purity
LogicDuke Aug 27, 2026
4149a94
test(cockpit): keep re-entry fixture outside root
LogicDuke Aug 27, 2026
f3e1ac2
test(cockpit): reject host symlink entries
LogicDuke Aug 28, 2026
7d1de84
test(cockpit): close host purity capabilities
LogicDuke Aug 28, 2026
dcab190
test(cockpit): make static key resolution total
LogicDuke Aug 28, 2026
5b0f9e8
test(cockpit): count import-equals bindings
LogicDuke Aug 28, 2026
7b781e0
Merge pull request #61 from LogicDuke/repair/d3-policy-f1-relative-im…
LogicDuke Aug 28, 2026
5ae2b78
fix(cockpit): preserve finding reviewer attribution
LogicDuke Aug 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions docs/architecture/D3-cockpit-dashboard-host.md
Original file line number Diff line number Diff line change
@@ -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.<x>`, 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 `<script>` and `onerror` payloads render only as inert text.
- **No secrets, paths, or shell** — no `process.env` read, no filesystem path in
the page, no subprocess, no Git command.

## Stage A explicitly, and what it is not

Stage A renders **deterministic local fixture data only**. It is explicitly
**not live**: not GitHub, not agent output, not the real repository HEAD. The
fixture SHAs are obvious placeholders and the page is banner-labelled
`READ ONLY · STAGE A · FIXTURE DATA` so it cannot be mistaken for a live
observation. Later stages (real domain read-model state; read-only GitHub
adapter observations) are out of D3's scope.

## Boundaries preserved

- No dependency on the open PR #10 process-transport stack; D3 starts from
verified `main` only.
- No PR008 (Autoflow/Policy roadmap) scope consumed.
- Zero mutation endpoints, loopback-only, GET-only, no external assets, no
telemetry, no analytics, no new runtime dependency.

## Modules

| Module | Responsibility |
| --- | --- |
| `src/cockpit-host/server.ts` | `node:http` host: routing, security headers, fail-closed page build |
| `src/cockpit-host/render.ts` | Pure snapshot + projection → escaped HTML |
| `src/cockpit-host/escape.ts` | HTML-entity escaping |
| `src/cockpit-host/styles.ts` | Compiled-in stylesheet served at `/styles.css` |
| `src/cockpit-host/fixtures/stage-a.ts` | Deterministic Stage-A fixture (typed `unknown`) |

## Launch

npm run cockpit

builds `src/**` to `dist/` and starts the host, printing the exact loopback URL.
The user opens it manually; the host starts no browser and holds no shell.

## Tests

`tests/cockpit-host/` covers fixture-passes-D1, fail-closed on malformed input,
adversarial escaping of hostile prose, read-only self-identification, honest
Autoflow/tree-SHA notices, GET/405/404 routing, loopback binding, security
headers, the absence of any mutation route, and host import purity.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"typecheck": "tsc --noEmit",
"lint": "eslint .",
"test": "vitest run",
"cockpit": "npm run build && node dist/cockpit-host/server.js",
"verify": "npm run typecheck && npm run lint && npm test && npm run build"
},
"devDependencies": {
Expand Down
30 changes: 30 additions & 0 deletions src/cockpit-host/escape.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* HTML-entity escaping for the Cockpit D3 dashboard host.
*
* Every dynamic value the host interpolates into server-rendered HTML — all of
* it originating from a D1-validated {@link CockpitSnapshot} — is passed through
* {@link escapeHtml} first. The host emits no `innerHTML`, no `document.write`,
* no inline event handlers, and no client-side script at all, so escaping here
* is the single place untrusted prose becomes markup-safe text.
*
* This module is pure string transformation: no I/O, no network, no process
* access, no imports.
*/

/** The five characters that can break out of HTML text or a double-quoted attribute. */
const HTML_ESCAPES: Readonly<Record<string, string>> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
};

/**
* Escape `&`, `<`, `>`, `"`, and `'` so the returned string renders as inert
* text in both HTML body and double-quoted attribute contexts. A value that
* contains none of them is returned unchanged.
*/
export function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, (character) => HTML_ESCAPES[character] ?? character);
}
179 changes: 179 additions & 0 deletions src/cockpit-host/fixtures/stage-a.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
/**
* Deterministic Stage-A fixture for the Cockpit D3 dashboard host.
*
* This is **not live data**. It is a hand-authored snapshot shaped like the
* JSON a future collector would emit, deliberately typed as `unknown`: the host
* must pass it through D1's `readCockpitSnapshot()` before rendering any value,
* exactly as it would treat real collector output. Nothing here is trusted by
* virtue of living in the source tree.
*
* The fixture intentionally includes hostile prose (script tags, an
* `onerror` image payload, raw `&`/`<`/`>`/quotes) in reviewer-controlled title
* and message fields. Those strings are legitimate bounded text to D1, so they
* pass validation and reach the renderer, where they must appear only as inert
* escaped text — the property the host's escaping tests assert.
*
* The SHAs are obvious fixtures (`c0ffee…`, `dead0…`), not any real repository
* HEAD, so the page can never be mistaken for a live observation. Evidence bound
* to the observed HEAD projects as `CURRENT`; evidence bound to the older SHA
* projects as `STALE`, giving D2 a non-trivial mix to display.
*/

/** The fixture's observed HEAD. Evidence bound here projects CURRENT. */
const HEAD_SHA = 'c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00';
/** An older commit. Evidence bound here projects STALE. */
const OLD_SHA = 'dead0000dead0000dead0000dead0000dead0000';

export const STAGE_A_FIXTURE: unknown = {
schemaVersion: 1,
repository: {
repositoryId: 'LogicDuke/agentbridge',
observedHeadSha: HEAD_SHA,
defaultBranchRef: 'refs/heads/main',
},
provenance: {
collectorId: 'cockpit-stage-a-fixture',
observedAt: '2026-08-24T12:00:00.000Z',
},
pullRequests: [
{
pullRequestId: 'pr-42',
headSha: HEAD_SHA,
baseRef: 'refs/heads/main',
state: 'open',
title: 'Harden login authentication path',
},
{
pullRequestId: 'pr-43',
headSha: OLD_SHA,
baseRef: 'refs/heads/main',
state: 'merged',
// Hostile title: must render as inert escaped text.
title: '<b>Bold</b> & "quotes" <script>alert(2)</script>',
},
],
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: "<script>alert('xss-title')</script> SQL injection in login handler",
message:
'"><img src=x onerror=alert(1)> 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',
},
],
};
Loading
Loading