From 4e27171549ada554d375a1f8b625c943a8ac73a2 Mon Sep 17 00:00:00 2001 From: Nikola Katsarov Date: Fri, 17 Jul 2026 16:31:22 +0300 Subject: [PATCH 1/2] feat(coord): browser approval console (token-gated, read+decide) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds GET /console — an operator approval console served by the coordinator: a static, data-free HTML shell (inline CSS/JS, no CDN) that lists runs paused at an approval gate and lets an operator approve/reject them from a browser. Security model (built against a dedicated security review): - The shell is EXEMPT from auth (like /api/health) because a browser navigation carries no bearer header. It embeds ZERO run data and ZERO secrets. - All data reads (/api/runs, /api/runs/{id}) and the mutation (/api/runs/{id}/approve) go through the EXISTING authed endpoints via fetch() with an operator-supplied bearer token held only in memory and sent only in the Authorization header — never in a URL or storage; cleared on unload. - All dynamic DOM is built with textContent/createElement (never innerHTML), so a hostile run_id/step_id cannot inject markup or script. - Approvals still require the per-gate S9 token, typed by the human. - Response denies framing (X-Frame-Options: DENY + CSP frame-ancestors 'none') and caching (Cache-Control: no-store). Scope: approve/reject only. Trigger-arbitrary-workflow is deliberately out of scope (higher blast radius). +2 security tests (shell served without bearer / carries no data / has headers; /api/runs still 401s without bearer). --- crates/llmvm-cli/src/coordinator.rs | 243 +++++++++++++++++++++++++++- 1 file changed, 242 insertions(+), 1 deletion(-) diff --git a/crates/llmvm-cli/src/coordinator.rs b/crates/llmvm-cli/src/coordinator.rs index c524197..ccb42ad 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,172 @@ 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.

+ +
+ 1 · Connect + + +
+ + Kept in memory only; sent solely in the request Authorization header — never stored or placed in the URL. +
+
+ +
+
+ + + + + +"##; + +/// 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, + "frame-ancestors '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 +1020,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 +4245,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(); From dd7c815cacdaf581388fc0aed56918d368aa11a8 Mon Sep 17 00:00:00 2001 From: Nikola Katsarov Date: Fri, 17 Jul 2026 16:55:10 +0300 Subject: [PATCH 2/2] harden(coord): tighten console CSP to default-src 'none' allowlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the security re-review's one non-blocking suggestion: the console response now sends a full CSP (default-src 'none'; style/script 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none') instead of just frame-ancestors. The page is fully self-contained same-origin, so this only denies exfiltration/injection avenues — no functional change. String-only edit; the header test still asserts frame-ancestors is present. --- crates/llmvm-cli/src/coordinator.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/llmvm-cli/src/coordinator.rs b/crates/llmvm-cli/src/coordinator.rs index ccb42ad..01c7601 100644 --- a/crates/llmvm-cli/src/coordinator.rs +++ b/crates/llmvm-cli/src/coordinator.rs @@ -959,7 +959,13 @@ async fn handle_console() -> Response { (axum::http::header::X_FRAME_OPTIONS, "DENY"), ( axum::http::header::CONTENT_SECURITY_POLICY, - "frame-ancestors 'none'", + // 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,