From 9beea0b583bb85e722b2dfca23a08705ead995d0 Mon Sep 17 00:00:00 2001 From: Brian H Date: Mon, 5 Oct 2026 13:45:38 +0000 Subject: [PATCH 1/4] feat(playbook): Assurance view (requirements, drawer, diagram, audit) + session-only token + opt-in public UI Playb00k Assurance tab over the /assurance/* routes: - requirement table with the five assurance states shown as label + symbol (never colour alone), state chips with counts, server-side filters (state/status/owner/gap), source-kind badges; - detail drawer with the whole thread (obligations, element, control + implementation path, case, revision-bound evidence with which revision moved, links resolved/dangling, gaps); - run a case from the page: explicit confirm step, runs at the revision shown, refusals explained; - diagram panel (authenticated blob, fit/2x/3x zoom in a scrolling frame, open full size); - audit panel (hash-chain status, decisions, denials). Setup gains an optional access token kept in sessionStorage only. lib/assurance.js is the client. Server: KR0KI_PUBLIC_UI (default off) serves only the static /playbook GET routes without a token, because a browser navigation cannot send Authorization; data routes still require one. Tested both ways (default behind the gateway; opt-in public; data/POST/lookalike paths still 401). Verified in a real browser against a real server (gateway on, real baseline and evidence): 401 -> Setup token -> list, drawer, diagram, audit, and a VC-A02 run flipping the row from stale to verified. Fixtures are the server's real JSON. Playb00k tests 206; cargo workspace 50 suites; fmt and clippy -D warnings clean. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01GgXQRGsprSZc2oXGNj9n8X --- AGENTS.md | 1 + crates/kr0ki-server/src/gateway.rs | 44 ++ crates/kr0ki-server/src/main.rs | 11 +- crates/kr0ki-server/tests/assurance_a06.rs | 58 ++ .../tests/common/assurance_harness.rs | 6 + playbook/src/App.assurance.test.js | 119 ++++ playbook/src/App.test.js | 2 +- playbook/src/App.vue | 23 +- playbook/src/components/AssuranceView.test.js | 479 ++++++++++++++++ playbook/src/components/AssuranceView.vue | 509 ++++++++++++++++++ playbook/src/components/Setup.vue | 32 +- .../__fixtures__/assurance/README.md | 4 + .../__fixtures__/assurance/audit.json | 1 + .../assurance/evidence-KR-A06.json | 1 + .../__fixtures__/assurance/list.json | 1 + .../assurance/requirement-KR-A06.json | 1 + .../__fixtures__/assurance/trace-KR-A06.json | 1 + .../__fixtures__/assurance/view.json | 1 + playbook/src/lib/__tests__/assurance.test.js | 137 +++++ playbook/src/lib/assurance.js | 121 +++++ 20 files changed, 1548 insertions(+), 4 deletions(-) create mode 100644 playbook/src/App.assurance.test.js create mode 100644 playbook/src/components/AssuranceView.test.js create mode 100644 playbook/src/components/AssuranceView.vue create mode 100644 playbook/src/components/__fixtures__/assurance/README.md create mode 100644 playbook/src/components/__fixtures__/assurance/audit.json create mode 100644 playbook/src/components/__fixtures__/assurance/evidence-KR-A06.json create mode 100644 playbook/src/components/__fixtures__/assurance/list.json create mode 100644 playbook/src/components/__fixtures__/assurance/requirement-KR-A06.json create mode 100644 playbook/src/components/__fixtures__/assurance/trace-KR-A06.json create mode 100644 playbook/src/components/__fixtures__/assurance/view.json create mode 100644 playbook/src/lib/__tests__/assurance.test.js create mode 100644 playbook/src/lib/assurance.js diff --git a/AGENTS.md b/AGENTS.md index aaa301f..f2bed7d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -99,6 +99,7 @@ crates/ | `KR0KI_SYSMLV2_BASE_URL` | unset | Live SysML-v2 client test target (`just sysml-api-up` serves one on `127.0.0.1:9000`; `just test-live-sysml`) | | `OPENAI_API_URL` | (in `.env`) | The agent's / `/health`'s LLM. Boot default is Qwen3.8 NEO-CODER + mmproj (vision) on `127.0.0.1:8002/v1` via `deploy/systemd/b00t-hive-inference-heretic-neo-coder.service`; Qwen3.6 units are disabled | | `KR0KI_IDENTITIES_FILE` | unset | Per-identity bearer tokens (stored as SHA-256) and capability grants (`render`, `model.read`, `model.commit`, `assurance.read`, `assurance.propose`, `assurance.verify`, `audit.read`, `ui.steer`, `admin`). When set it **replaces** the shared `KR0KI_AUTH_TOKEN`; every route is classified in `kr0ki-server/src/gateway.rs` and an unclassified route needs `admin`. A tool's visibility or annotations are never what enforces anything | +| `KR0KI_PUBLIC_UI` | unset (off) | `1`/`true`: serve the static `/playbook` UI **without a token** (a browser navigation cannot send an `Authorization` header, so with per-identity grants on the page could not otherwise load). GET only, exact static routes only (`gateway::PUBLIC_UI_PATTERNS`); every data route still needs a token. Enter the token under Playb00k Setup (kept in `sessionStorage` only). Other Playb00k tabs still call the server without a token, so with grants on they need an `anonymous`-style open server or will show auth errors; the **Assurance** tab sends the token | | `KR0KI_AUDIT_FILE` | `./.kr0ki-audit/audit.jsonl` when identities are set | Hash-chained, append-only audit log (one record per invocation, permitted or denied). If a record cannot be written the request is refused (503) | | `KR0KI_ASSURANCE_BASELINE` / `KR0KI_REPO_ROOT` / `KR0KI_EVIDENCE_DIR` / `KR0KI_ASSURANCE_PREFIX` | `docs/assurance/kr0ki.assurance.toml` / `.` / `./.kr0ki-evidence` / `kr0ki:assurance:` | Enable `/assurance/*` (the assurance thread) and where its baseline, repository and evidence live | | `KR0KI_SYSML_MCP_URL` | unset | SysML v2 MCP sidecar (`containers/kr0ki-sysml-mcp`, `127.0.0.1:8790`); enables `/sysml/{validate,parse,symbols,summary}` and the `sysml_mcp` health check | diff --git a/crates/kr0ki-server/src/gateway.rs b/crates/kr0ki-server/src/gateway.rs index 665812c..4905893 100644 --- a/crates/kr0ki-server/src/gateway.rs +++ b/crates/kr0ki-server/src/gateway.rs @@ -311,6 +311,16 @@ pub const ROUTES: &[(&str, &str, Option<&str>)] = &[ ("GET", "/assurance/skills/:name", Some("assurance.read")), ]; +/// The static Playb00k UI. With `KR0KI_PUBLIC_UI` on these `GET` routes skip authentication and audit, because a +/// browser navigation cannot send an `Authorization` header, so the page could not otherwise load. They serve +/// built JavaScript and example metadata only; every data route stays behind the gateway. +pub const PUBLIC_UI_PATTERNS: &[&str] = &[ + "/playbook", + "/playbook/", + "/playbook/*path", + "/playbook/api/examples.json", +]; + /// The grant a `(method, pattern)` requires. Unknown → `admin` (deny by default). pub fn required_grant(method: &str, pattern: Option<&str>) -> Option<&'static str> { let Some(pattern) = pattern else { @@ -343,6 +353,7 @@ pub struct Gateway { audit: Option>, tools: BTreeMap<(String, String), &'static str>, model_revision: Option Option + Send + Sync>>, + public_ui: bool, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -358,9 +369,17 @@ impl Gateway { audit, tools: tool_names(), model_revision: None, + public_ui: false, } } + /// Serve the static Playb00k UI (`PUBLIC_UI_PATTERNS`) without a token. Off by default: an operator opts in. + #[allow(dead_code)] // used by main.rs and tests/assurance_a06.rs; other test crates include this file without it + pub fn with_public_ui(mut self, on: bool) -> Self { + self.public_ui = on; + self + } + /// How to learn "the model revision in play" for routes that name none in the request /// (the assurance routes evaluate against the service's current model revision). pub fn with_model_revision( @@ -476,6 +495,17 @@ pub async fn gateway_middleware(req: Request, next: Next, gateway: Arc) .extensions() .get::() .map(|m| m.as_str().to_string()); + // Opt-in: the static UI loads without a token (a browser navigation cannot send one). GET only, + // and only the exact patterns listed; everything else, including a POST to a /playbook path, is + // authenticated and audited as usual. + if gateway.public_ui + && method == Method::GET + && pattern + .as_deref() + .is_some_and(|p| PUBLIC_UI_PATTERNS.contains(&p)) + { + return next.run(req).await; + } let transport = match req .headers() .get("x-kr0ki-transport") @@ -621,6 +651,20 @@ mod tests { ); } + #[test] + fn the_public_ui_patterns_are_only_static_playbook_gets_and_hold_no_grant() { + for p in PUBLIC_UI_PATTERNS { + assert!(p.starts_with("/playbook"), "{p}"); + let row = ROUTES.iter().find(|(m, rp, _)| *m == "GET" && rp == p); + assert!(row.is_some(), "{p} is not a registered GET route"); + assert_eq!(row.unwrap().2, None, "{p} must need no grant"); + } + // Nothing that serves model or assurance data may ever be listed. + assert!(PUBLIC_UI_PATTERNS + .iter() + .all(|p| !p.contains("assurance") && !p.contains("model") && !p.contains("audit"))); + } + #[test] fn grants_in_the_table_are_all_known() { for (m, p, g) in ROUTES { diff --git a/crates/kr0ki-server/src/main.rs b/crates/kr0ki-server/src/main.rs index 9833a07..9ba2b7f 100644 --- a/crates/kr0ki-server/src/main.rs +++ b/crates/kr0ki-server/src/main.rs @@ -2,6 +2,8 @@ //! //! Config (env): //! KR0KI_BIND default 0.0.0.0:8787 +//! KR0KI_PUBLIC_UI `1`/`true`: serve the static /playbook UI without a token (a browser navigation cannot +//! send one); data routes still need it. Default off. //! KR0KI_CACHE_DIR default ./.kr0ki-cache //! KR0KI_BACKEND_URL default http://127.0.0.1:8010 (private SECURE-mode Kroki) //! KR0KI_AUTH_TOKEN if set, require `Authorization: Bearer ` on every @@ -198,7 +200,14 @@ async fn main() -> anyhow::Result<()> { .unwrap_or_else(|_| "kr0ki:assurance:".into()), }) }); - let mut gateway = Gateway::new(auth_mode, audit.clone()); + let public_ui = matches!( + std::env::var("KR0KI_PUBLIC_UI").as_deref(), + Ok("1") | Ok("true") + ); + if public_ui { + tracing::warn!("KR0KI_PUBLIC_UI is on: the static /playbook UI is served without a token (data routes still need one)"); + } + let mut gateway = Gateway::new(auth_mode, audit.clone()).with_public_ui(public_ui); if let Some(rt) = &assurance { let service = rt.service.clone(); gateway = gateway.with_model_revision(move || service.model_revision()); diff --git a/crates/kr0ki-server/tests/assurance_a06.rs b/crates/kr0ki-server/tests/assurance_a06.rs index ba5fae8..c8ba7d7 100644 --- a/crates/kr0ki-server/tests/assurance_a06.rs +++ b/crates/kr0ki-server/tests/assurance_a06.rs @@ -216,3 +216,61 @@ async fn resource_reads_pass_through_the_same_gateway_as_the_equivalent_http_req .unwrap(); assert!(p.contains("trace_requirement with id=KR-A06"), "{prompt}"); } + +#[tokio::test] +async fn the_static_ui_needs_a_token_unless_the_operator_opts_in_and_data_never_does() { + // Default: nothing is public, exactly as before. + let h = start("a06-ui-default").await; + let (s, _) = http( + Method::GET, + &format!("{}/playbook/", h.base), + "", + None, + None, + ) + .await; + assert_eq!( + s, 401, + "the static UI is behind the gateway unless KR0KI_PUBLIC_UI is on" + ); + + // Opted in: the static UI loads without a token... + let h = start_with("a06-ui-public", true).await; + let resp = reqwest::get(format!("{}/playbook/", h.base)).await.unwrap(); + assert_ne!( + resp.status().as_u16(), + 401, + "the static UI must load without a token when opted in" + ); + // ...but every data route still needs one, whatever its path looks like. + for path in [ + "/assurance/requirements", + "/assurance/audit", + "/assurance/view?format=svg", + "/mcp/tools", + "/model/projects", + "/playbook-not-a-route/x", + ] { + let resp = reqwest::get(format!("{}{path}", h.base)).await.unwrap(); + assert_eq!( + resp.status().as_u16(), + 401, + "{path} must still require a token" + ); + } + // A POST to a /playbook path is not the static UI. + let resp = reqwest::Client::new() + .post(format!("{}/playbook/x", h.base)) + .send() + .await + .unwrap(); + assert_eq!(resp.status().as_u16(), 401); + // Public static reads are not audited (they are not tool invocations). + assert!(h + .audit + .read_all() + .unwrap() + .iter() + .all(|r| !r.path.starts_with("/playbook/") + || r.decision == kr0ki_core::audit_log::Decision::Deny)); +} diff --git a/crates/kr0ki-server/tests/common/assurance_harness.rs b/crates/kr0ki-server/tests/common/assurance_harness.rs index da9ec19..480bb1b 100644 --- a/crates/kr0ki-server/tests/common/assurance_harness.rs +++ b/crates/kr0ki-server/tests/common/assurance_harness.rs @@ -64,6 +64,11 @@ pub struct Harness { /// Start the server. `tag` keeps concurrent tests' files apart. pub async fn start(tag: &str) -> Harness { + start_with(tag, false).await +} + +/// `public_ui`: serve the static /playbook routes without a token. +pub async fn start_with(tag: &str, public_ui: bool) -> Harness { let tmp = std::env::temp_dir().join(format!("kr0ki-gw-{tag}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&tmp); std::fs::create_dir_all(&tmp).unwrap(); @@ -128,6 +133,7 @@ pub async fn start(tag: &str) -> Harness { let svc = assurance_service.clone(); let gateway = Arc::new( Gateway::new(AuthMode::Identities(identities), Some(audit.clone())) + .with_public_ui(public_ui) .with_model_revision(move || svc.model_revision()), ); let app = router_with_gateway(state, gateway, contract); diff --git a/playbook/src/App.assurance.test.js b/playbook/src/App.assurance.test.js new file mode 100644 index 0000000..1a58246 --- /dev/null +++ b/playbook/src/App.assurance.test.js @@ -0,0 +1,119 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' +import { Quasar } from 'quasar' +import App from './App.vue' + +const catalog = { useCases: ['process flow'], types: [{ id: 'flowchart', name: 'Flowchart', syntax: 'd2', exampleId: 'e1', useCases: ['process flow'], blurb: 'Steps.', samplePrompt: 'p' }] } +const examples = [{ id: 'e1', format: 'd2', title: 'Flow', description: 'd', input_kind: 'code', source: 'a -> b', outputs: ['svg'] }] +const StoryStub = { name: 'StoryB00k', props: { planner: Boolean, threadId: String }, template: '
' } +// The real view is covered by AssuranceView.test.js; here we only check what App hands it. +const AssuranceStub = { + name: 'AssuranceView', + props: { rendererUrl: String, token: String }, + template: '
', +} + +beforeEach(() => { + sessionStorage.clear() + localStorage.clear() + Element.prototype.scrollIntoView = vi.fn() + vi.stubGlobal('CSS', { escape: (s) => s }) + vi.stubGlobal('fetch', vi.fn(async (url) => { + const u = String(url) + if (u.includes('/api/catalog')) return { ok: true, json: async () => catalog } + if (u.includes('examples')) return { ok: true, json: async () => examples } + return { ok: false, status: 404, json: async () => ({}), text: async () => '' } + })) +}) +afterEach(() => { vi.unstubAllGlobals(); document.body.replaceChildren() }) + +async function mountApp() { + const w = mount(App, { global: { plugins: [[Quasar, {}]], stubs: { StoryB00k: StoryStub, AssuranceView: AssuranceStub } }, attachTo: document.body }) + await flushPromises() + return w +} +const t = (w, id) => w.find(`[data-testid="${id}"]`) + +describe('the Assurance tab', () => { + it('is in the menu and opens the assurance view with the renderer url', async () => { + localStorage.setItem('kr0ki:rendererUrl', 'http://k.test:8787') + const w = await mountApp() + expect(t(w, 'tab-assurance').text()).toBe('Assurance') + expect(w.find('.assurance-stub').exists()).toBe(false) + await t(w, 'tab-assurance').trigger('click') + const stub = w.find('.assurance-stub') + expect(stub.exists()).toBe(true) + expect(stub.attributes('data-url')).toBe('http://k.test:8787') + expect(t(w, 'tab-assurance').classes()).toContain('active') + }) + + it('hides the gallery hero while the assurance view is open', async () => { + const w = await mountApp() + expect(w.find('.hero').exists()).toBe(true) + await t(w, 'tab-assurance').trigger('click') + expect(w.find('.hero').exists()).toBe(false) + }) + + it('leaves no assurance view behind when the person goes elsewhere', async () => { + const w = await mountApp() + await t(w, 'tab-assurance').trigger('click') + await t(w, 'tab-gallery').trigger('click') + expect(w.find('.assurance-stub').exists()).toBe(false) + }) +}) + +describe('the access token', () => { + it('starts from the session, not from persistent storage', async () => { + sessionStorage.setItem('kr0ki:token', 'tok-from-session') + const w = await mountApp() + await t(w, 'tab-assurance').trigger('click') + expect(w.find('.assurance-stub').attributes('data-token')).toBe('tok-from-session') + }) + + it('ignores a token left in localStorage (it must never persist)', async () => { + localStorage.setItem('kr0ki:token', 'tok-in-local-storage') + const w = await mountApp() + await t(w, 'tab-assurance').trigger('click') + expect(w.find('.assurance-stub').attributes('data-token')).toBe('') + }) + + it('saving it in Setup keeps it in sessionStorage only and hands it to the view', async () => { + const w = await mountApp() + await t(w, 'tab-setup').trigger('click') + const input = t(w, 'kr0ki-token') + expect(input.attributes('type')).toBe('password') + await input.setValue(' tok-new-secret ') + await w.find('form.setup__form').trigger('submit') + await flushPromises() + expect(sessionStorage.getItem('kr0ki:token')).toBe('tok-new-secret') + expect(JSON.stringify({ ...localStorage })).not.toContain('tok-new-secret') + await t(w, 'tab-assurance').trigger('click') + expect(w.find('.assurance-stub').attributes('data-token')).toBe('tok-new-secret') + }) + + it('clearing it removes it from the session', async () => { + sessionStorage.setItem('kr0ki:token', 'tok-old') + const w = await mountApp() + await t(w, 'tab-setup').trigger('click') + await t(w, 'kr0ki-token').setValue('') + await w.find('form.setup__form').trigger('submit') + await flushPromises() + expect(sessionStorage.getItem('kr0ki:token')).toBeNull() + }) + + it('can be revealed and hidden while typing', async () => { + const w = await mountApp() + await t(w, 'tab-setup').trigger('click') + expect(t(w, 'kr0ki-token').attributes('type')).toBe('password') + await t(w, 'toggle-token').trigger('click') + expect(t(w, 'kr0ki-token').attributes('type')).toBe('text') + await t(w, 'toggle-token').trigger('click') + expect(t(w, 'kr0ki-token').attributes('type')).toBe('password') + }) + + it('tells the person it is session-only', async () => { + const w = await mountApp() + await t(w, 'tab-setup').trigger('click') + expect(w.text()).toContain('this browser session only') + }) +}) diff --git a/playbook/src/App.test.js b/playbook/src/App.test.js index f5ed0bd..1129407 100644 --- a/playbook/src/App.test.js +++ b/playbook/src/App.test.js @@ -36,7 +36,7 @@ describe('App navigation', () => { it('has Planner and Projects as top-level menu items after Gallery; the gallery is home', async () => { const w = await mountApp() const labels = w.findAll('.view-tab').map((b) => b.text()) - expect(labels).toEqual(['Gallery', 'Planner', 'Projects', 'Code Editor', 'Agent', 'Setup']) + expect(labels).toEqual(['Gallery', 'Planner', 'Projects', 'Assurance', 'Code Editor', 'Agent', 'Setup']) expect(w.find('[data-testid="tab-gallery"]').classes()).toContain('active') expect(w.find('.gallery').exists()).toBe(true) w.unmount() diff --git a/playbook/src/App.vue b/playbook/src/App.vue index 3d394da..b7eb9a6 100644 --- a/playbook/src/App.vue +++ b/playbook/src/App.vue @@ -7,6 +7,7 @@ import Setup from './components/Setup.vue' import CatalogTree from './components/CatalogTree.vue' import PlannerView from './components/PlannerView.vue' import ProjectsView from './components/ProjectsView.vue' +import AssuranceView from './components/AssuranceView.vue' import { connectUiBridge, plannerSessionId } from './lib/uiBridge.js' import { loadLspUrls, saveLspUrls } from './lib/lsp.js' @@ -44,6 +45,20 @@ const llmUrl = ref(localStorage.getItem('kr0ki:llmUrl') || `http://${window.loca const llmKey = ref(localStorage.getItem('kr0ki:llmKey') || '') const llmModel = ref(localStorage.getItem('kr0ki:llmModel') || 'gpt-4o') +// Access token for a server with per-identity grants. SESSION storage on purpose: it is cleared when the tab closes and is never +// written next to the persistent settings. It is only ever sent as an Authorization header (see lib/assurance.js). +const kr0kiToken = ref(readSession('kr0ki:token')) +function readSession(key) { + try { return sessionStorage.getItem(key) || '' } catch { return '' } +} +function updateKr0kiToken(token) { + kr0kiToken.value = token + try { + if (token) sessionStorage.setItem('kr0ki:token', token) + else sessionStorage.removeItem('kr0ki:token') + } catch { /* storage unavailable: the token still works for this page load */ } +} + function updateRendererUrl(url) { rendererUrl.value = url localStorage.setItem('kr0ki:rendererUrl', url) @@ -267,6 +282,9 @@ onMounted(async () => { + @@ -288,7 +306,7 @@ onMounted(async () => {
-
+

IAC / CODE → PROCEDURAL DIAGRAM → KROKI → SVG / PNG

Diagram-as-code, rendered.

@@ -319,8 +337,10 @@ onMounted(async () => { { @update:output-format="updateOutputFormat" /> + structuredClone(x) +const refusal = (status, error, message) => new AssuranceError(status, { error, message }) + +/** A fake API client whose answers are the real fixtures; override any method. */ +function fakeClient(over = {}) { + return { + list: vi.fn(async (f = {}) => { + const l = clone(list) + const rows = l.requirements.filter((r) => (!f.state || r.assurance === f.state) && (!f.owner || r.owner === f.owner) && (!f.status || r.status === f.status) && (!f.gap || r.gaps.some((g) => g.gap === f.gap))) + return { ...l, total: rows.length, requirements: rows } + }), + requirement: vi.fn(async () => clone(detailA06)), + trace: vi.fn(async () => clone(traceA06)), + evidence: vi.fn(async () => clone(evidenceA06)), + viewSvg: vi.fn(async () => new Blob([''], { type: 'image/svg+xml' })), + audit: vi.fn(async () => clone(audit)), + verify: vi.fn(async (caseId) => ({ revisions: {}, case_id: caseId, result: 'pass', detail: '9 tests passed', evidence_keys: [] })), + ...over, + } +} +async function mountView(client = fakeClient(), props = {}) { + const w = mount(AssuranceView, { props: { client, rendererUrl: 'http://k.test:8787', ...props }, attachTo: document.body }) + await flushPromises() + return w +} +const t = (w, id) => w.find(`[data-testid="${id}"]`) + +beforeEach(() => { + globalThis.URL.createObjectURL = vi.fn(() => 'blob:fake-diagram') + globalThis.URL.revokeObjectURL = vi.fn() +}) +afterEach(() => document.body.replaceChildren()) + +describe('the requirements table', () => { + it('shows the revisions the answer was computed at, shortened', async () => { + const w = await mountView() + const revs = t(w, 'revisions').text() + expect(revs).toContain('0213bc9c3286') + expect(revs).toContain(shortRev(list.revisions.implementation_revision)) + expect(revs).toContain('abb46f1005+dirty.') // the dirty marker stays visible: a dirty tree is a different revision + }) + + it('lists every requirement with its state as a label and a symbol, not colour alone', async () => { + const w = await mountView() + expect(w.findAll('tbody tr')).toHaveLength(8) + const s = t(w, 'state-KR-A06') + expect(s.text()).toContain('result stale') + expect(s.text()).toContain('⟳') + expect(s.classes()).toContain('st-stale') + expect(t(w, 'count').text()).toContain('8 of 8') + }) + + it('counts requirements per state in the chips', async () => { + const w = await mountView() + expect(t(w, 'chip-stale').text()).toContain('8') + expect(t(w, 'chip-verified').text()).toContain('0') + expect(w.findAll('[data-testid^="chip-"]')).toHaveLength(5) + }) + + it('keeps "satisfied · untested" and "verified" visibly different', async () => { + const mixed = clone(list) + mixed.requirements[0].assurance = 'verified' + mixed.requirements[1].assurance = 'satisfied_untested' + const w = await mountView(fakeClient({ list: vi.fn(async () => mixed) })) + const a = t(w, 'state-KR-A01') + const b = t(w, 'state-KR-A02') + expect(a.text()).not.toBe(b.text()) + expect(a.text()).toContain('✓ verified') + expect(b.text()).toContain('satisfied · untested') + expect(a.classes()).not.toEqual(b.classes()) + }) + + it('shows the source kind so binding obligations, policy and guidance stay distinguishable', async () => { + const w = await mountView() + expect(t(w, 'row-KR-A06').text()).toContain('policy') + }) + + it('lists each requirement\'s gaps', async () => { + const w = await mountView() + expect(t(w, 'row-KR-A01').find('.gaps').text()).toContain('stale') + }) +}) + +describe('filters are applied by the server, so only what is needed is loaded', () => { + it('a state chip asks the server for that state only', async () => { + const c = fakeClient() + const w = await mountView(c) + await t(w, 'chip-verified').trigger('click') + await flushPromises() + expect(c.list).toHaveBeenLastCalledWith({ state: 'verified', status: '', owner: '', gap: '' }) + expect(t(w, 'empty').exists()).toBe(true) + expect(t(w, 'count').text()).toContain('0 of 8') + expect(t(w, 'count').text()).toContain('filtered on the server') + expect(t(w, 'chip-verified').attributes('aria-pressed')).toBe('true') + }) + + it('clicking the active chip again clears it', async () => { + const c = fakeClient() + const w = await mountView(c) + await t(w, 'chip-stale').trigger('click') + await flushPromises() + expect(w.findAll('tbody tr')).toHaveLength(8) + await t(w, 'chip-stale').trigger('click') + await flushPromises() + expect(t(w, 'chip-stale').attributes('aria-pressed')).toBe('false') + expect(t(w, 'count').text()).not.toContain('filtered') + }) + + it('offers only values that exist, and filters by them', async () => { + const c = fakeClient() + const w = await mountView(c) + const owners = t(w, 'filter-owner').findAll('option').map((o) => o.text()) + expect(owners).toEqual(['any', 'kr0ki-maintainers']) + await t(w, 'filter-gap').setValue('stale') + await flushPromises() + expect(c.list).toHaveBeenLastCalledWith(expect.objectContaining({ gap: 'stale' })) + await t(w, 'clear-filters').trigger('click') + await flushPromises() + expect(t(w, 'clear-filters').attributes('disabled')).toBeDefined() + }) +}) + +describe('the detail drawer shows the whole thread', () => { + async function open() { + const c = fakeClient() + const w = await mountView(c) + await t(w, 'open-KR-A06').trigger('click') + await flushPromises() + return { w, c } + } + + it('loads the requirement, its trace and its evidence', async () => { + const { c } = await open() + expect(c.requirement).toHaveBeenCalledWith('KR-A06') + expect(c.trace).toHaveBeenCalledWith('KR-A06') + expect(c.evidence).toHaveBeenCalledWith({ requirement: 'KR-A06' }) + }) + + it('shows the statement, source obligations with their kind, element, control and case', async () => { + const { w } = await open() + expect(t(w, 'statement').text()).toContain('tool gateway shall deny') + const obligations = t(w, 'obligations').text() + expect(obligations).toContain('policy') + expect(obligations).toContain('guidance') // the MCP security text + expect(t(w, 'elements').text()).toContain('KrOKiAssurance::ToolGateway') + expect(t(w, 'controls').text()).toContain('CTL-A06') + expect(t(w, 'controls').text()).toContain('crates/kr0ki-server/src/gateway.rs') + expect(t(w, 'cases').text()).toContain('VC-A06') + expect(t(w, 'cases').text()).toContain('cargo test -p kr0ki-server --test assurance_a06') + }) + + it('shows evidence as revision-bound and says which revision moved when it is stale', async () => { + const { w } = await open() + const ev = t(w, 'evidence').text() + expect(ev).toContain('pass') + expect(ev).toContain('stale') + expect(ev).toContain('implementation changed') + expect(ev).toContain('artifact intact') + expect(t(w, 'stale-VC-A06').exists()).toBe(true) + }) + + it('shows links as resolved at the current model revision', async () => { + const { w } = await open() + expect(t(w, 'links').text()).toContain('KrOKiAssurance::ToolGateway') + expect(t(w, 'links').text()).toContain('resolved') + }) + + it('flags a control with no recorded implementation as a gap, not a blank', async () => { + const d = clone(detailA06) + d.controls[0].implementation = '' + const w = await mountView(fakeClient({ requirement: vi.fn(async () => d) })) + await t(w, 'open-KR-A06').trigger('click') + await flushPromises() + expect(t(w, 'no-impl').text()).toContain('implementation gap') + }) + + it('says plainly when nothing has ever been run', async () => { + const d = clone(detailA06) + d.thread.evidence = [] + d.thread.assurance = 'satisfied_untested' + const w = await mountView(fakeClient({ requirement: vi.fn(async () => d) })) + await t(w, 'open-KR-A06').trigger('click') + await flushPromises() + expect(t(w, 'no-evidence').text()).toContain('never been run') + expect(t(w, 'detail-state').text()).toContain('satisfied · untested') + }) + + it('reports dangling links with the reason', async () => { + const tr = clone(traceA06) + tr.links[0] = { ...tr.links[0], status: 'dangling', reason: '`X` does not exist at model revision sha256:aa' } + const w = await mountView(fakeClient({ trace: vi.fn(async () => tr) })) + await t(w, 'open-KR-A06').trigger('click') + await flushPromises() + expect(t(w, 'links').text()).toContain('dangling') + expect(t(w, 'links').text()).toContain('does not exist at model revision') + }) + + it('shows statement lint findings', async () => { + const d = clone(detailA06) + d.statement_lints = ['vague or open term `TBD`'] + const w = await mountView(fakeClient({ requirement: vi.fn(async () => d) })) + await t(w, 'open-KR-A06').trigger('click') + await flushPromises() + expect(t(w, 'lints').text()).toContain('TBD') + }) + + it('closes, and a failing detail load does not break the list', async () => { + const c = fakeClient({ requirement: vi.fn(async () => { throw refusal(404, 'not_found', 'unknown requirement `KR-A06`') }) }) + const w = await mountView(c) + await t(w, 'open-KR-A06').trigger('click') + await flushPromises() + expect(t(w, 'detail-error').exists()).toBe(true) + expect(w.findAll('tbody tr')).toHaveLength(8) + await t(w, 'close-detail').trigger('click') + expect(t(w, 'detail').exists()).toBe(false) + }) + + it('discards a slow answer for a requirement the person has already moved away from', async () => { + let releaseFirst + const slow = new Promise((r) => { releaseFirst = r }) + const first = clone(detailA06) + const second = clone(detailA06) + second.requirement.id = 'KR-A01' + second.requirement.statement = 'The requirement adapter shall preserve identifiers.' + const requirement = vi.fn((id) => (id === 'KR-A06' ? slow.then(() => first) : Promise.resolve(second))) + const w = await mountView(fakeClient({ requirement })) + await t(w, 'open-KR-A06').trigger('click') + await t(w, 'open-KR-A01').trigger('click') + await flushPromises() + releaseFirst() + await flushPromises() + expect(t(w, 'statement').text()).toContain('requirement adapter') + }) +}) + +describe('running a case is explicit', () => { + async function open(client = fakeClient()) { + const w = await mountView(client) + await t(w, 'open-KR-A06').trigger('click') + await flushPromises() + return w + } + + it('asks first, naming what will run and where, and runs nothing until confirmed', async () => { + const c = fakeClient() + const w = await open(c) + await t(w, 'run-VC-A06').trigger('click') + expect(c.verify).not.toHaveBeenCalled() + expect(t(w, 'cases').text()).toContain('runs the command above on the server') + expect(t(w, 'confirm-run-VC-A06').exists()).toBe(true) + }) + + it('cancel runs nothing', async () => { + const c = fakeClient() + const w = await open(c) + await t(w, 'run-VC-A06').trigger('click') + await w.findAll('button').find((b) => b.text() === 'Cancel').trigger('click') + expect(c.verify).not.toHaveBeenCalled() + expect(t(w, 'run-VC-A06').exists()).toBe(true) + }) + + it('confirming runs the case at the implementation revision the view is showing, then refreshes', async () => { + const c = fakeClient() + const w = await open(c) + const listCalls = c.list.mock.calls.length + await t(w, 'run-VC-A06').trigger('click') + await t(w, 'confirm-run-VC-A06').trigger('click') + await flushPromises() + expect(c.verify).toHaveBeenCalledWith('VC-A06', detailA06.revisions.implementation_revision) + expect(t(w, 'run-notice').text()).toContain('VC-A06: pass') + expect(t(w, 'run-notice').classes()).toContain('ok') + expect(c.list.mock.calls.length).toBeGreaterThan(listCalls) // the view re-read the server + }) + + it('a failing case is shown as a failure, not a success', async () => { + const c = fakeClient({ verify: vi.fn(async () => ({ case_id: 'VC-A06', result: 'fail', detail: '2 tests failed, 5 passed' })) }) + const w = await open(c) + await t(w, 'run-VC-A06').trigger('click') + await t(w, 'confirm-run-VC-A06').trigger('click') + await flushPromises() + expect(t(w, 'run-notice').classes()).toContain('bad') + expect(t(w, 'run-notice').text()).toContain('fail') + }) + + it('a refusal (the repository moved on) is explained and nothing is claimed', async () => { + const c = fakeClient({ verify: vi.fn(async () => { throw refusal(409, 'revision_mismatch', 'you asked to verify X, the repository is at Y') }) }) + const w = await open(c) + await t(w, 'run-VC-A06').trigger('click') + await t(w, 'confirm-run-VC-A06').trigger('click') + await flushPromises() + expect(t(w, 'run-notice').text()).toContain('moved on') + expect(t(w, 'run-notice').classes()).toContain('bad') + }) + + it('a missing grant is explained', async () => { + const c = fakeClient({ verify: vi.fn(async () => { throw refusal(403, 'forbidden', 'identity `reader` has no `assurance.verify` grant') }) }) + const w = await open(c) + await t(w, 'run-VC-A06').trigger('click') + await t(w, 'confirm-run-VC-A06').trigger('click') + await flushPromises() + expect(t(w, 'run-notice').text()).toContain('assurance.verify') + }) +}) + +describe('errors are explained, not swallowed', () => { + const listFails = (e) => fakeClient({ list: vi.fn(async () => { throw e }) }) + + it('401: tells the person to add a token under Setup', async () => { + const w = await mountView(listFails(refusal(401, 'unauthorized', 'missing or invalid bearer token'))) + expect(t(w, 'error').text()).toContain('access token') + expect(t(w, 'error').text()).toContain('Setup') + expect(w.findAll('tbody tr')).toHaveLength(0) + }) + + it('403: names the grant that is missing', async () => { + const w = await mountView(listFails(refusal(403, 'forbidden', 'identity `x` has no `assurance.read` grant'))) + expect(t(w, 'error').text()).toContain('assurance.read') + }) + + it('503: says the assurance thread is not enabled on this server', async () => { + const w = await mountView(listFails(refusal(503, 'assurance_not_configured', 'x'))) + expect(t(w, 'error').text()).toContain('not enabled') + }) + + it('a network failure is reported as such', async () => { + const w = await mountView(listFails(new TypeError('Failed to fetch'))) + expect(t(w, 'error').text()).toContain('Could not reach') + }) + + it('the error is announced to assistive technology', async () => { + const w = await mountView(listFails(refusal(500, 'x', 'boom'))) + expect(t(w, 'error').attributes('role')).toBe('alert') + }) +}) + +describe('the diagram', () => { + it('renders lazily, only when the tab is opened', async () => { + const c = fakeClient() + const w = await mountView(c) + expect(c.viewSvg).not.toHaveBeenCalled() + await t(w, 'a-tab-diagram').trigger('click') + await flushPromises() + expect(c.viewSvg).toHaveBeenCalledTimes(1) + expect(t(w, 'diagram-img').attributes('src')).toBe('blob:fake-diagram') + expect(t(w, 'diagram-img').attributes('alt')).toContain('Assurance thread diagram') + }) + + it('explains the two claims in its legend', async () => { + const w = await mountView() + await t(w, 'a-tab-diagram').trigger('click') + await flushPromises() + const legend = t(w, 'diagram-panel').text() + expect(legend).toContain('satisfaction assertion') + expect(legend).toContain('evidence') + }) + + it('shows a render failure instead of an empty box', async () => { + const w = await mountView(fakeClient({ viewSvg: vi.fn(async () => { throw refusal(502, 'render_failed', 'backend down') }) })) + await t(w, 'a-tab-diagram').trigger('click') + await flushPromises() + expect(t(w, 'diagram-error').text()).toContain('backend down') + expect(t(w, 'diagram-img').exists()).toBe(false) + }) + + it('can be zoomed inside a scrolling frame, and opened at full size', async () => { + const w = await mountView() + await t(w, 'a-tab-diagram').trigger('click') + await flushPromises() + expect(t(w, 'diagram-img').attributes('style')).toContain('width: 100%') + expect(t(w, 'zoom-100').attributes('aria-pressed')).toBe('true') + await t(w, 'zoom-300').trigger('click') + expect(t(w, 'diagram-img').attributes('style')).toContain('width: 300%') + expect(t(w, 'zoom-300').attributes('aria-pressed')).toBe('true') + expect(t(w, 'zoom-100').attributes('aria-pressed')).toBe('false') + expect(w.find('.frame').attributes('tabindex')).toBe('0') // keyboard-scrollable + expect(t(w, 'open-full').attributes('href')).toBe('blob:fake-diagram') + expect(t(w, 'open-full').attributes('rel')).toBe('noopener') + }) + + it('releases the object URL on unmount', async () => { + const w = await mountView() + await t(w, 'a-tab-diagram').trigger('click') + await flushPromises() + w.unmount() + expect(globalThis.URL.revokeObjectURL).toHaveBeenCalledWith('blob:fake-diagram') + }) +}) + +describe('the audit panel', () => { + async function openAudit(client = fakeClient()) { + const w = await mountView(client) + await t(w, 'a-tab-audit').trigger('click') + await flushPromises() + return w + } + + it('shows the records and says the chain verifies', async () => { + const c = fakeClient() + const w = await openAudit(c) + expect(c.audit).toHaveBeenCalledWith({ decision: '', caller: '', phase: 'decision' }) + expect(t(w, 'chain').text()).toContain('Hash chain verifies') + expect(t(w, 'chain').text()).toContain('17 record(s)') + expect(w.findAll('[data-testid="audit-table"] tbody tr').length).toBe(audit.records.length) + }) + + it('shows a denial as a denial, with the reason', async () => { + const w = await openAudit() + const text = t(w, 'audit-table').text() + expect(text).toContain('deny') + expect(text).toContain('model.commit') + }) + + it('says loudly when the chain is broken', async () => { + const broken = clone(audit) + broken.chain = { ok: false, line: 4, seq: 3, reason: 'prev_hash does not match the previous record' } + const w = await openAudit(fakeClient({ audit: vi.fn(async () => broken) })) + expect(t(w, 'chain').text()).toContain('BROKEN at line 4') + expect(t(w, 'chain').classes()).toContain('bad') + }) + + it('filters by decision on the server', async () => { + const c = fakeClient() + const w = await openAudit(c) + await t(w, 'audit-decision').setValue('deny') + await flushPromises() + expect(c.audit).toHaveBeenLastCalledWith({ decision: 'deny', caller: '', phase: 'decision' }) + }) + + it('explains that reading the audit log needs its own grant', async () => { + const w = await openAudit(fakeClient({ audit: vi.fn(async () => { throw refusal(403, 'forbidden', 'identity `reader` has no `audit.read` grant') }) })) + expect(t(w, 'audit-error').text()).toContain('audit.read') + expect(t(w, 'audit-table').exists()).toBe(false) + }) + + it('does not load the log until the tab is opened', async () => { + const c = fakeClient() + await mountView(c) + expect(c.audit).not.toHaveBeenCalled() + }) +}) + +describe('credentials', () => { + it('never renders the token anywhere in the page', async () => { + const w = await mountView(fakeClient(), { token: 'tok-super-secret' }) + await t(w, 'open-KR-A06').trigger('click') + await t(w, 'a-tab-audit').trigger('click') + await flushPromises() + expect(w.html()).not.toContain('tok-super-secret') + }) + + it('builds its own client from the renderer url and token when none is injected', async () => { + const calls = [] + globalThis.fetch = vi.fn(async (url, init) => { calls.push({ url, init }); return { ok: true, status: 200, json: async () => ({ ...list }) } }) + const w = mount(AssuranceView, { props: { rendererUrl: 'http://k.test:8787', token: 'tok-abc' }, attachTo: document.body }) + await flushPromises() + expect(calls[0].url).toBe('http://k.test:8787/assurance/requirements') + expect(calls[0].init.headers.Authorization).toBe('Bearer tok-abc') + expect(w.html()).not.toContain('tok-abc') + }) + + it('re-reads when the token changes', async () => { + const c = fakeClient() + const w = await mountView(c) + const before = c.list.mock.calls.length + await w.setProps({ token: 'tok-new' }) + await flushPromises() + expect(c.list.mock.calls.length).toBeGreaterThan(before) + }) +}) diff --git a/playbook/src/components/AssuranceView.vue b/playbook/src/components/AssuranceView.vue new file mode 100644 index 0000000..7a3a27b --- /dev/null +++ b/playbook/src/components/AssuranceView.vue @@ -0,0 +1,509 @@ + + + + + diff --git a/playbook/src/components/Setup.vue b/playbook/src/components/Setup.vue index 22543eb..25923cd 100644 --- a/playbook/src/components/Setup.vue +++ b/playbook/src/components/Setup.vue @@ -4,16 +4,20 @@ import { LANGUAGES, isWsUrl, lspFormats } from '../lib/lsp.js' const props = defineProps({ rendererUrl: { type: String, default: '' }, + // Optional bearer token for a kr0ki server that has per-identity grants on. Session-only: App keeps it in sessionStorage. + kr0kiToken: { type: String, default: '' }, agentUrl: { type: String, default: '' }, // Optional language servers: { format: ws:// URL } lspUrls: { type: Object, default: () => ({}) }, }) -const emit = defineEmits(['update:rendererUrl', 'update:llmUrl', 'update:llmKey', 'update:llmModel', 'update:agentUrl', 'update:outputFormat', 'update:lspUrls']) +const emit = defineEmits(['update:kr0kiToken', 'update:rendererUrl', 'update:llmUrl', 'update:llmKey', 'update:llmModel', 'update:agentUrl', 'update:outputFormat', 'update:lspUrls']) // Local state for form inputs const localRendererUrl = ref(props.rendererUrl) const localAgentUrl = ref(props.agentUrl) +const localToken = ref(props.kr0kiToken) +const showToken = ref(false) const llmUrl = ref('') const llmKey = ref('') const llmModel = ref('') @@ -56,6 +60,9 @@ import { watch } from 'vue' watch(() => props.rendererUrl, (newVal) => { localRendererUrl.value = newVal }) +watch(() => props.kr0kiToken, (newVal) => { + localToken.value = newVal +}) function saveSettings() { // Save LLM settings to localStorage @@ -67,6 +74,8 @@ function saveSettings() { // Emit updates emit('update:rendererUrl', localRendererUrl.value) + // Deliberately NOT written to localStorage with the other settings: the token lives for this browser session only. + emit('update:kr0kiToken', localToken.value.trim()) emit('update:llmUrl', llmUrl.value) emit('update:llmKey', llmKey.value) emit('update:llmModel', llmModel.value) @@ -232,6 +241,25 @@ async function testAgent() { /> The kr0ki server that renders diagrams. Defaults to the current host. +