diff --git a/crates/llmvm-cli/src/coordinator.rs b/crates/llmvm-cli/src/coordinator.rs
index c524197..01c7601 100644
--- a/crates/llmvm-cli/src/coordinator.rs
+++ b/crates/llmvm-cli/src/coordinator.rs
@@ -766,7 +766,14 @@ async fn auth_middleware(
// verify a coord is up without holding the shared secret.
// Health responses are non-sensitive — uptime, capability hash,
// and version — so the bypass does not leak secret state.
- if request.uri().path() == "/api/health" {
+ // The approval-console SHELL (`/console`) is exempt for the same reason as
+ // health: a browser navigation cannot carry an `Authorization: Bearer`
+ // header. The shell embeds no run data and no secret — all data reads and
+ // mutations happen via authed `fetch()` from its inline JS against the
+ // `/api/*` routes below, which stay behind this middleware — so serving the
+ // shell unauthenticated leaks nothing.
+ let path = request.uri().path();
+ if path == "/api/health" || path == "/console" {
return next.run(request).await;
}
// Sprint W6-A: when mTLS is required, every other route MUST
@@ -794,6 +801,178 @@ async fn auth_middleware(
next.run(request).await
}
+/// The operator approval console — a static, data-free HTML shell.
+///
+/// Security model (audited): the shell embeds ZERO run state and ZERO secrets.
+/// It is a token-entry form plus inline JS that calls the AUTHED `/api/runs`,
+/// `/api/runs/{id}` and `/api/runs/{id}/approve` endpoints. The operator's
+/// bearer token is held only in an in-memory input (cleared on unload) and sent
+/// ONLY in the request `Authorization` header — never in a URL, never in
+/// storage. All dynamic content is built with `textContent`/`createElement`
+/// (never `innerHTML`), so a hostile `run_id`/`step_id` cannot inject markup or
+/// script. Approvals additionally require the per-gate S9 token, typed by the
+/// human. No external/CDN assets — fully self-contained for offline operation.
+const CONSOLE_HTML: &str = r##"
+
+
+
+
+Approval Console — Boruna
+
+
+
+
Boruna Approval Console
+
Approve or reject workflow runs paused at a human approval gate. This page holds no data of its own — it reads the coordinator's authenticated API and submits your decision. It cannot start, cancel, or edit runs.
+
+
+
+
+
+
+
+
+
+
+"##;
+
+/// GET `/console` — serve the approval-console shell (see [`CONSOLE_HTML`]).
+///
+/// EXEMPT from [`auth_middleware`] (like `/api/health`): a browser navigation
+/// carries no `Authorization` header, so the shell must load without one. It
+/// leaks nothing — no run data, no secret — and the `/api/*` endpoints its JS
+/// calls stay behind the middleware. The response denies framing (clickjacking)
+/// and forbids caching.
+async fn handle_console() -> Response {
+ (
+ [
+ (axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8"),
+ (axum::http::header::CACHE_CONTROL, "no-store"),
+ (axum::http::header::X_FRAME_OPTIONS, "DENY"),
+ (
+ axum::http::header::CONTENT_SECURITY_POLICY,
+ // Fully self-contained, same-origin: allow only inline
+ // style/script and same-origin fetch; deny everything else,
+ // framing, and injection (defense-in-depth beyond the
+ // X-Frame-Options above).
+ "default-src 'none'; style-src 'unsafe-inline'; \
+ script-src 'unsafe-inline'; connect-src 'self'; \
+ frame-ancestors 'none'; base-uri 'none'",
+ ),
+ ],
+ CONSOLE_HTML,
+ )
+ .into_response()
+}
+
pub fn build_router(state: CoordinatorState) -> Router {
// Sprint 0.5-S2d: merge the dashboard's read-only routes
// (/, /runs/:id, /api/runs, /api/runs/:id) onto the
@@ -847,6 +1026,12 @@ pub fn build_router(state: CoordinatorState) -> Router {
// (PRAGMA quick_check would be too expensive; we just take
// and release the mutex guard).
.route("/api/health", get(handle_health))
+ // Sprint W6-B: operator approval console (HTML shell). Exempted from
+ // auth in `auth_middleware` because a browser navigation carries no
+ // bearer header; it holds no data and drives the authed `/api/*` routes
+ // above via fetch(). Added to `coord_router` (not the dashboard router)
+ // so it is served wherever the coordinator runs.
+ .route("/console", get(handle_console))
// The 8 MiB DefaultBodyLimit applies to coord routes
// ONLY (not dashboard routes) because Axum's per-
// router layer scoping means layers attached pre-merge
@@ -4066,6 +4251,68 @@ mod tests {
(status, bytes)
}
+ #[tokio::test]
+ async fn console_shell_served_without_bearer_and_carries_no_data() {
+ // The console SHELL must load without a bearer header (a browser
+ // navigation carries none) EVEN when a shared secret is set, must embed
+ // no run data (data is fetched client-side), and must deny framing +
+ // caching.
+ let mut state = fresh_state();
+ state.config.shared_secret = Some("s3cret".into());
+ {
+ let store = state.store.lock().unwrap();
+ store
+ .insert_run(&RunRow {
+ run_id: "leaky-run-id-xyz".into(),
+ workflow_name: "wf".into(),
+ workflow_hash: "h".into(),
+ status: RunStatus::Paused,
+ started_at_ms: 0,
+ updated_at_ms: 0,
+ policy_json: r#"{"default_allow":true}"#.into(),
+ metadata_json: "{}".into(),
+ })
+ .unwrap();
+ }
+ let app = build_router(state);
+ let req = Request::builder()
+ .method("GET")
+ .uri("/console")
+ .body(Body::empty())
+ .unwrap();
+ let resp = app.oneshot(req).await.unwrap();
+ assert_eq!(resp.status(), StatusCode::OK);
+ assert_eq!(resp.headers().get("x-frame-options").unwrap(), "DENY");
+ assert_eq!(resp.headers().get("cache-control").unwrap(), "no-store");
+ assert!(resp
+ .headers()
+ .get("content-security-policy")
+ .unwrap()
+ .to_str()
+ .unwrap()
+ .contains("frame-ancestors"));
+ let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
+ .await
+ .unwrap();
+ let html = String::from_utf8_lossy(&bytes);
+ assert!(html.contains("Boruna Approval Console"));
+ assert!(
+ !html.contains("leaky-run-id-xyz"),
+ "the shell must embed no run data"
+ );
+ }
+
+ #[tokio::test]
+ async fn api_runs_still_requires_bearer_when_secret_set() {
+ // Only the shell is exempt — the data endpoint the console reads stays
+ // behind auth.
+ let mut state = fresh_state();
+ state.config.shared_secret = Some("s3cret".into());
+ let app = build_router(state);
+ let (status, _) = get_request(&app, "/api/runs").await;
+ assert_eq!(status, StatusCode::UNAUTHORIZED);
+ }
+
#[tokio::test]
async fn get_blob_returns_bytes_for_referenced_hash() {
let (state, _dir) = fresh_state_with_blob_store();