Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 14 additions & 0 deletions charts/protector/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,11 @@ spec:
containerPort: {{ .Values.engine.falco.port }}
protocol: TCP
{{- end }}
{{- if and .Values.engine.enabled .Values.engine.dashboard.enabled }}
- name: dashboard
containerPort: {{ .Values.engine.dashboard.port }}
protocol: TCP
{{- end }}
{{- with .Values.livenessProbe }}
livenessProbe:
{{- toYaml . | nindent 12 }}
Expand Down Expand Up @@ -274,6 +279,15 @@ spec:
- name: PROTECTOR_FALCO_ADDR
value: "0.0.0.0:{{ .Values.engine.falco.port }}"
{{- end }}
{{- if .Values.engine.dashboard.enabled }}
# The read-only operator dashboard (ADR-0019). Zero-egress, in-cluster only —
# reads the engine's output state and serves a server-rendered view. Reach it
# through the cluster's own ingress/mesh; it terminates no TLS of its own.
- name: PROTECTOR_DASHBOARD_ADDR
value: "0.0.0.0:{{ .Values.engine.dashboard.port }}"
- name: PROTECTOR_CLUSTER_LABEL
value: {{ .Values.engine.dashboard.clusterLabel | quote }}
{{- end }}
{{- if $feeds }}
# KEV catalogue file the feed-fetcher sidecar writes into the shared emptyDir
# (JEF-238). FULL CISA KEV JSON — KevCatalog parses the canonical
Expand Down
21 changes: 21 additions & 0 deletions charts/protector/templates/engine-services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,24 @@ spec:
targetPort: falco-ingest
protocol: TCP
{{- end }}
{{- if and .Values.engine.enabled .Values.engine.dashboard.enabled }}
---
# The read-only operator dashboard (ADR-0019). ClusterIP only — a server-rendered,
# zero-egress view of the engine's output state. It terminates no TLS of its own; reach it
# through the cluster's own ingress/mesh (a NetworkPolicy / mesh authorization is the floor).
apiVersion: v1
kind: Service
metadata:
name: {{ include "protector.fullname" . }}-dashboard
labels:
{{- include "protector.labels" . | nindent 4 }}
spec:
type: ClusterIP
selector:
{{- include "protector.selectorLabels" . | nindent 4 }}
ports:
- name: dashboard
port: {{ .Values.engine.dashboard.port }}
targetPort: dashboard
protocol: TCP
{{- end }}
9 changes: 9 additions & 0 deletions charts/protector/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,15 @@ engine:
falco:
enabled: true
port: 9999
# Read-only operator dashboard (ADR-0019). A server-rendered, zero-egress view of the
# engine's output state (findings, verdicts, coverage). It reads only state the engine
# already holds and NEVER mutates it, so it is strictly observational. It terminates no
# TLS of its own — expose it through the cluster's ingress/mesh. ClusterIP service below.
dashboard:
enabled: true
port: 8080
# The cluster label shown in the dashboard's status strip.
clusterLabel: cluster
# Two exploitation-intel feeds drive the engine's exploit intel + model reasoning
# (ADR-0015): the CISA KEV catalogue (actively-exploited CVEs, known NOW) and the FIRST.org
# EPSS scores (the PREDICTIVE per-CVE exploitation probability — JEF-243). The engine NEVER
Expand Down
120 changes: 120 additions & 0 deletions docs/STYLEGUIDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# Protector dashboard — style guide

The dashboard is an operator's **situation board**, not a marketing page. This is the source
of truth for its visual system: the design tokens, the component→token map, and the
accessibility gate the tests assert. Pairs with [`dashboard-v3-design.md`](dashboard-v3-design.md)
(the IA/UX). **Light theme:** a clean, high-contrast light surface with dark ink — dense and
calm by default, loud only on a real breach.

## Principles

1. **Honesty over reassurance.** The overall green/all-clear is shown ONLY when the model has
*affirmatively cleared everything it is looking at* — judging AND covered AND zero breaches
AND zero entries still awaiting judgement AND zero uncertain. If any entry is still awaiting
or uncertain, the model isn't yet sure, so the posture is the elevated **"watching"** state
(calm but not green), never all-clear. Unknown and awaiting are never green. (Test-enforced —
see the gate.)
2. **Posture ≠ severity.** The model's verdict is the loud channel; CVE severity is a cooler,
subordinate channel. A wall of critical CVEs must never look like a wall of breaches.
3. **Meaning never by colour alone.** Every status carries colour **+ glyph + word**.
4. **Density with discipline.** Monospace/tabular for all machine data so columns align; one
restrained type scale and a small palette so density never becomes noise.
5. **Calm by default, loud on breach.** Saturated colour is a scarce resource on the light
surface — spent only on a real breach.

## Design tokens

### Colour — surfaces & ink (light)
| Token | Value | Use |
|---|---|---|
| `--bg` | `#FCFCFD` | page background |
| `--surface` | `#FFFFFF` | cards, table |
| `--surface-raised` | `#F4F6F8` | expanded row, header strip |
| `--surface-hover` | `#F0F2F5` | row hover |
| `--border` | `#E2E6EB` | hairlines, grid |
| `--ink-1` | `#1A1F26` | primary text (≈15:1 on `--surface`) |
| `--ink-2` | `#5A6473` | secondary text (≈6:1) |
| `--ink-3` | `#6B7280` | tertiary / muted (≈4.6:1) |

### Colour — posture (the model's `Verdict`; the loud channel)
| Token | Value | Glyph | Word | Rail |
|---|---|---|---|---|
| `--posture-breach` | `#D92D20` | ● filled | BREACH / EXPLOITABLE | 3px solid |
| `--posture-cleared` | `#067647` | ○ open | not exploitable | 2px solid |
| `--posture-uncertain` | `#B54708` | ◐ half | uncertain | 2px **dashed** |
| `--posture-awaiting` | `#9A6B2E` | ◌ dotted | awaiting judgement | 2px **dotted** |

Uncertain is a stronger amber-brown; **awaiting is a softer, muted ochre — slightly elevated,
clearly *below* uncertain's intensity**. Neither is ever the cleared green. An un-judged exposed
path reads as "pending the model's call / mild attention," not "fine" and not "uncertain." A
subtle ochre tint backs the awaiting row/chip (`--posture-awaiting-tint: #FBF6EC`). Dashed/dotted
rails make "not decided" texturally distinct even in greyscale.

### Colour — severity (CVE/`ScanFinding`; subordinate, muted)
| Token | Value | Cue |
|---|---|---|
| `--sev-critical` | `#B42318` | ▲▲ `CRIT` |
| `--sev-high` | `#B54708` | ▲ `HIGH` |
| `--sev-medium` | `#854A0E` | ◆ `MED` |
| `--sev-low` | `#6B7280` | · `LOW` |
| `--kev` | `#D92D20` (filled badge) | `KEV` — the one severity-side signal allowed to be loud |

### Colour — recency Δ, coverage, mode
| Token | Value | | Token | Value |
|---|---|---|---|---|
| `--delta-new` | `#1570EF` | | `--cov-present` | `#067647` |
| `--delta-up` | `#D92D20` | | `--cov-degraded` | `#B54708` |
| `--delta-down` | `#067647` | | `--cov-absent` | `#6B7280` |
| `--delta-restored` | `#6B7280` | | `--mode-shadow` | `#667085` |
| | | | `--mode-enforce` | `#1570EF` |

### Space (4px base)
`--space-1:4 · --space-2:8 · --space-3:12 · --space-4:16 · --space-6:24 · --space-8:32`

### Type
```
--font-ui: Inter, system-ui, sans-serif /* chrome, prose, labels */
--font-data: "JetBrains Mono", ui-monospace, monospace /* ALL data: keys, CVEs, paths, counts */
--text-display: 22/28 600 --text-h2: 16/24 600 --text-body: 14/20 400
--text-data: 13/18 400 --text-data-strong: 13/18 600
--text-micro: 11/16 500 /* uppercase, tracked: column headers, chip text */
```
Two weights only (400/600). Emphasis via weight + ink value, not a third weight.

### Geometry
`--row-h:36 · --radius-chip:3 · --radius-panel:6 · --rail-strong:3px · --rail-soft:2px · --focus-ring:2px solid #1570EF`

## Component → token map
| Component | Tokens |
|---|---|
| Page / app frame | `--bg`, `--font-ui` |
| Status strip | `--surface-raised`; posture-* count chips; green all-clear (`--posture-cleared`) only when judging+covered+nothing breach/awaiting/uncertain; elevated "watching" (judged+covered but still awaiting/uncertain) and the blind/warming banner use `--posture-awaiting`/`--posture-uncertain`, **never** `--posture-cleared` |
| Finding row | `--surface`, `--row-h`, `--border` grid; entry/path `--font-data` `--text-data` |
| Posture cell | `--posture-*` (rail style: solid=decisive, dashed=uncertain, dotted=awaiting) + glyph + word |
| Severity chip | `--sev-*` border (muted); KEV → `--kev` fill |
| Δ cell | `--delta-*` glyph, or `--ink-3` age text for `Unchanged` |
| Detail panel | `--surface-raised`, `--space-4`; verdict prose `--text-body`, posture left-rule |
| CVE table | `--font-data` `--text-micro`, right-aligned numerics |
| Coverage row | `--cov-*` dot + glyph + label; `weakens_decisions` + absent → amber keyline |
| Reversion log | `--posture-cleared` toned (a self-revert is the system working) |
| Empty states | `--ink-2`; posture-coloured only when honestly earned (model judging) |

## Accessibility gate (test-enforced)
1. **Contrast:** body/status text ≥ **4.5:1** on its surface; chips/rails/glyphs ≥ **3:1**.
2. **Meaning not by colour alone:** every posture / severity / Δ / coverage state renders a
non-empty **glyph + text label** in addition to colour. (Assert each enum→(glyph,label).)
3. **Honest-calm invariant:** the overall green/all-clear resolves to `--posture-cleared`/green
ONLY when the model has affirmatively cleared everything — `model_judging == true` AND not
`warming_up` AND **covered** AND **zero breaches AND zero awaiting AND zero uncertain**. If
`model_judging == false` OR `warming_up`, the honest blind/warming banner renders. If the
model is judging+covered but **any** entry is still awaiting or uncertain, the strip renders
the elevated **"watching"** state (`--posture-awaiting` toned, calm — *not* green): the model
hasn't finished, so quiet is not clearance. `Verdict::Uncertain` and awaiting (`None`) never
map to the cleared/green token.
4. **No implied-absent blanks:** any empty evidence/coverage field renders explicit
"none"/"unknown".
5. **Escaping:** all untrusted free-text (verdict prose, CVE/finding titles, model prompts,
node keys) is HTML-escaped at render (maud auto-escape; no `PreEscaped` outside an audited
allowlist).
6. **Keyboard/semantics:** findings are a real `<table>`; row expanders are `<button>`s with
`aria-expanded`; focus order = visual order; focus ring always visible.
108 changes: 108 additions & 0 deletions docs/adr/0019-dashboard-v3-presentation-architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# ADR-0019 — Dashboard v3: the presentation architecture (view_model / component / page split, honesty invariants, light theme)

**Status:** Accepted

## Context

The engine is autonomous and **shadow-by-default**: it proposes, it never acts until armed
(ADR-0016 — presentation is a view, never a gate). The operator is a solo (or very small team)
platform/security engineer who checks in periodically. They have three real jobs: **trust
calibration** (is the engine's judgement sound enough to arm a class?), **triage** (which judged
breaches are real?), and **coverage** (is the engine actually equipped to decide right now?).

Two prior dashboards failed for the same root reason — the *information architecture* was wrong,
and editing an IA doesn't fix it. The specific failures this design exists to prevent:

1. **Burying "why."** The model's verdict is the product; it must be one glance + one click
away, never a separate destination.
2. **Calm-when-blind.** A quiet green screen while the model is down or warming reads as "safe"
when it actually means "we haven't looked." This is the cardinal sin.

The agreed v3 design (`docs/dashboard-v3-design.md`) is the synthesis of nine independent
clean-room design passes that converged on the same shape — strong evidence the IA is dictated
by the engine's data model (`engine/src/engine/state/`), not copied from a prior UI. The visual
system (`docs/STYLEGUIDE.md`) is a **light-theme** token system. This ADR records the
architectural decisions that realize them.

## Decision

### 1. Server-rendered, zero-egress, light theme

The dashboard is server-rendered with **maud**, served same-origin from the engine process.
The security graph and evidence never leave the cluster (the zero-egress invariant): the CSS and
JS are bundled via `include_str!` (no third-party CDN), and the only client-side network call is
a poll to the same-origin `/fragment`. The theme is **light** — a clean, high-contrast light
surface with dark ink; calm by default, loud only on a real breach. It terminates no TLS of its
own (it is reached through the cluster's ingress/mesh) and is **read-only**: it snapshots the
engine's output state and never mutates it, so a bad render can never affect the engine.

### 2. The React-like split (view_model → components → page → routes)

The module `engine/src/engine/dashboard/` follows the repo's canonical UI pattern:

- **`view_model/`** shapes `engine::`/`state::` domain state into plain `Props` (owned strings,
numbers, small presentation enums). It is the **only** layer that touches domain types.
- **`components/`** are pure `maud` renderers (`Props -> Markup`). A component imports **only**
`maud` and `view_model::props` — never a domain type. This boundary is guard-tested.
- **`page.rs`** composes components into full pages and the `/fragment` live region, and owns
the persistent status strip + the 4-tab nav shell.
- **`mod.rs`** wires the axum routes (`/`, `/fragment`, `/assets/dashboard.css`,
`/assets/dashboard.js`), holds `DashboardState` (the shared read-only `Arc` handles), and
serves it (`serve_dashboard`) behind `PROTECTOR_DASHBOARD_ADDR`, wired from the watch loop.

The split keeps the honesty rules in one tested place (the view_model mapping), lets the
presentation be compiled and tested without the engine, and prevents the monolith regrowth that
sank the prior dashboards. Every file stays under the 1,000-line cap (CLAUDE.md).

### 3. The information architecture

A **primary view + secondary views, with one persistent status strip** — not a single page (one
page recreates the unreadable monolith), and not flat peers (the Findings question dominates):

| Surface | Question | Priority |
|---|---|---|
| **Status strip** (persistent, every view) | Is "quiet" really "blind"? + freshness | always-on |
| **Findings** (primary / landing) | Is anything a breach, and can I trust the call? | P0 — built |
| **Trust** (would-have-acted) | If I armed it, what would it have done? | P1 — phase 2 |
| **Readiness** (coverage detail) | Is the engine equipped to decide? | P1 — phase 2 |
| **Activity** (audit) | What did it actually do, and did it undo it? | P2 — phase 2 |

Findings drill **in place** (row → `<details>` detail panel), keyed by URL fragment; the live
poll preserves scroll/expansion. The **default sort is urgency, not severity** (ADR-0016):
corroborated-live → model-promoted → escalations → awaiting → cleared (collapsed tail). A
critical CVE on an unreachable workload is low-urgency; CVE severity is evidence in the drawer,
never the headline sort.

### 4. The honesty model — three orthogonal axes

The status strip carries three axes that must **never collapse into one signal**:
**breach vs safe** (the verdict), **decided vs awaiting** (has the model judged this?), and
**covered vs blind** (is the model up and are the feeds loaded?). **Green/calm is honest only
while `model_judging == true`.** When the model is warming or not answering, exposed paths are
*unjudged, not cleared*, and the UI says so in the matching non-green register. `Uncertain` and
`Awaiting` are never green.

## Invariants (test-enforced)

1. `!model_judging` or `warming_up` ⇒ the status strip never renders the green/all-clear path;
the honest banner renders instead. (Render tests.)
2. `Verdict::Uncertain` and awaiting (`None`) never map to the cleared/green token. (View_model
+ render tests.)
3. Empty evidence/coverage renders an explicit "none"/"unknown", never a blank.
4. Components import no `engine::`/`state::` domain type (the view_model/component boundary).
(Source guard.)
5. No inline `<style>`/`style=` — every visual is a class mapped to a STYLEGUIDE token. (Source
guard.)
6. All untrusted free-text (verdict prose, CVE/finding titles, model prompts, node keys) is
HTML-escaped at render (maud auto-escape). (Render test.)
7. No source file exceeds 1,000 lines. (`file_size_guard`.)

## Consequences

- The presentation can be unit-tested at the props boundary and the render boundary with no
engine and no HTTP — fast, deterministic honesty guards.
- New UI work composes small components against props; it cannot regrow a monolith or smuggle a
domain type into the render layer (the guards fail the build).
- Phase 1 ships the platform + shared components + the **Findings** view end-to-end; the Trust /
Readiness / Activity tabs are labelled phase-2 placeholders so the nav exists and is honest
about what is coming. The data for all four already lives in `state::`.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,6 @@ Copy [`0000-template.md`](0000-template.md) to start one.
| [0016](0016-severity-vs-urgency.md) | The breach model: prove chains, enrich them, the model decides and isolates until clear | Accepted (amended by 0017) |
| [0017](0017-isolation-persists-on-the-breach-condition.md) | Isolation persists on the breach condition: chain ∧ enrichment fingerprint (revert keys on `entry_fingerprint`) | Accepted |
| [0018](0018-operator-configured-redacted-breach-notifier.md) | The breach notifier is the one sanctioned outbound path: operator-configured, off by default, redacted by default | Accepted |
| [0019](0019-dashboard-v3-presentation-architecture.md) | Dashboard v3: server-rendered (maud), zero-egress, light-theme presentation — the view_model/component/page split + the honesty invariants | Accepted |

See also [`../VISION.md`](../VISION.md) for the longer-form narrative this ADR realizes.
Loading
Loading