feat(ui): wire security banner to security_event + interactivity

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) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-04-19 19:18:57 +08:00
parent a9f702a715
commit ffb064afda
+101
View File
@@ -107,6 +107,102 @@ let agentText = ''; // Accumulated text
// repeat rendering on reconnect or tab switch (server replays from disk) // repeat rendering on reconnect or tab switch (server replays from disk)
const renderedEntryIds = new Set(); 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 = `<span class="security-banner-layer-name">${label}</span><span class="security-banner-layer-score">${score}</span>`;
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) { function addChatEntry(entry) {
// Dedup by entry ID — prevent repeat rendering on reconnect/replay // Dedup by entry ID — prevent repeat rendering on reconnect/replay
if (entry.id !== undefined) { if (entry.id !== undefined) {
@@ -228,6 +324,11 @@ function handleAgentEvent(entry) {
return; return;
} }
if (entry.type === 'security_event') {
showSecurityBanner(entry);
return;
}
if (entry.type === 'agent_error') { if (entry.type === 'agent_error') {
// Suppress timeout errors that fire after agent_done (cleanup noise) // Suppress timeout errors that fire after agent_done (cleanup noise)
if (entry.error && entry.error.includes('Timed out') && !agentContainer) { if (entry.error && entry.error.includes('Timed out') && !agentContainer) {