From ffb064afdabfbb25ff7e7b68ade6a28c21267095 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 19 Apr 2026 19:18:57 +0800 Subject: [PATCH] feat(ui): wire security banner to security_event + interactivity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds showSecurityBanner() and hideSecurityBanner() plus the addChatEntry routing for entry.type === 'security_event'. When the sidebar-agent emits a security_event (canary leak or ML BLOCK), the banner renders with: * Title ("Session terminated") * Subtitle with {domain} if present, otherwise generic * Expandable layer list — each row: SECURITY_LAYER_LABELS[layer] + confidence.toFixed(2) in mono. Readable + auditable — user can see which layer fired at what score Interactivity, wired once on DOMContentLoaded: * Close X → hideSecurityBanner() * Expand/collapse "What happened" → toggles details + aria-expanded + chevron rotation (200ms css transition already in place) * Escape key dismisses while banner is visible (a11y) No shield icon yet — that's a separate commit that will consume the `security` field now returned by /health. Co-Authored-By: Claude Opus 4.7 (1M context) --- extension/sidepanel.js | 101 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/extension/sidepanel.js b/extension/sidepanel.js index 089f1ccd..9a1c1585 100644 --- a/extension/sidepanel.js +++ b/extension/sidepanel.js @@ -107,6 +107,102 @@ let agentText = ''; // Accumulated text // repeat rendering on reconnect or tab switch (server replays from disk) const renderedEntryIds = new Set(); +// Security banner (variant A from /plan-design-review 2026-04-19). +// Renders on security_event — canary leaks, ML classifier BLOCK verdicts. +// Defense-in-depth trust UX — user sees WHICH layer fired at WHAT confidence. +const SECURITY_LAYER_LABELS = { + testsavant_content: 'Content ML', + transcript_classifier: 'Transcript ML', + aria_regex: 'ARIA pattern', + canary: 'Canary leak', +}; + +function showSecurityBanner(event) { + const banner = document.getElementById('security-banner'); + if (!banner) return; + + const title = document.getElementById('security-banner-title'); + const subtitle = document.getElementById('security-banner-subtitle'); + const layersEl = document.getElementById('security-banner-layers'); + const expandBtn = document.getElementById('security-banner-expand'); + const details = document.getElementById('security-banner-details'); + const chevron = banner.querySelector('.security-banner-chevron'); + + // Title + subtitle + if (title) title.textContent = 'Session terminated'; + if (subtitle) { + const fromDomain = event.domain ? ` from ${event.domain}` : ''; + subtitle.textContent = `— prompt injection detected${fromDomain}`; + } + + // Layer signals list (mono scores) + if (layersEl) { + layersEl.innerHTML = ''; + const rows = []; + // If we got a primary layer + confidence, show that first + if (event.layer) { + rows.push({ layer: event.layer, confidence: event.confidence ?? 1.0 }); + } + // Any additional signals the agent sent + if (Array.isArray(event.signals)) { + for (const s of event.signals) { + if (s.layer && !rows.some(r => r.layer === s.layer)) { + rows.push({ layer: s.layer, confidence: s.confidence ?? 0 }); + } + } + } + for (const row of rows) { + const label = SECURITY_LAYER_LABELS[row.layer] || row.layer; + const score = Number(row.confidence).toFixed(2); + const div = document.createElement('div'); + div.className = 'security-banner-layer'; + div.innerHTML = `${label}${score}`; + layersEl.appendChild(div); + } + } + + // Reset expand state on each render + if (expandBtn && details) { + expandBtn.setAttribute('aria-expanded', 'false'); + details.hidden = true; + if (chevron) chevron.style.transform = 'rotate(0deg)'; + } + + banner.style.display = 'block'; +} + +function hideSecurityBanner() { + const banner = document.getElementById('security-banner'); + if (banner) banner.style.display = 'none'; +} + +// Wire up banner interactivity once on load +document.addEventListener('DOMContentLoaded', () => { + const closeBtn = document.getElementById('security-banner-close'); + const expandBtn = document.getElementById('security-banner-expand'); + const banner = document.getElementById('security-banner'); + if (closeBtn) { + closeBtn.addEventListener('click', hideSecurityBanner); + } + if (expandBtn) { + expandBtn.addEventListener('click', () => { + const details = document.getElementById('security-banner-details'); + const chevron = banner && banner.querySelector('.security-banner-chevron'); + if (!details) return; + const open = !details.hidden; + details.hidden = open; + expandBtn.setAttribute('aria-expanded', String(!open)); + if (chevron) chevron.style.transform = open ? 'rotate(0deg)' : 'rotate(180deg)'; + }); + } + // Escape dismisses the banner (a11y) + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && banner && banner.style.display !== 'none') { + hideSecurityBanner(); + } + }); +}); + function addChatEntry(entry) { // Dedup by entry ID — prevent repeat rendering on reconnect/replay if (entry.id !== undefined) { @@ -228,6 +324,11 @@ function handleAgentEvent(entry) { return; } + if (entry.type === 'security_event') { + showSecurityBanner(entry); + return; + } + if (entry.type === 'agent_error') { // Suppress timeout errors that fire after agent_done (cleanup noise) if (entry.error && entry.error.includes('Timed out') && !agentContainer) {