mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-02 03:35:09 +02:00
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:
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user