feat(security): POST /security-decision + relay reviewable banner fields

Two small server changes, one feature:

1. New POST /security-decision endpoint takes {tabId, decision} JSON
   and writes the per-tab decision file. Auth-gated like every other
   sidebar-agent control endpoint.

2. processAgentEvent relays the new reviewable/suspected_text/tabId
   fields on security_event through to the chat entry so the sidepanel
   banner can render [Allow] / [Block] buttons and the excerpt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-04-20 20:25:12 +08:00
parent a487205605
commit 26fd1b2825
+28 -1
View File
@@ -25,7 +25,7 @@ import {
runContentFilters, type ContentFilterResult,
markHiddenElements, getCleanTextWithStripping, cleanupHiddenMarkers,
} from './content-security';
import { generateCanary, injectCanary, getStatus as getSecurityStatus } from './security';
import { generateCanary, injectCanary, getStatus as getSecurityStatus, writeDecision } from './security';
import { handleSnapshot, SNAPSHOT_FLAGS } from './snapshot';
import {
initRegistry, validateToken as validateScopedToken, checkScope, checkDomain,
@@ -543,6 +543,11 @@ function processAgentEvent(event: any): void {
channel: event.channel,
tool: event.tool,
signals: event.signals,
// Reviewable flow fields — sidepanel renders [Allow] / [Block] buttons
// and the suspected text excerpt when reviewable=true.
reviewable: event.reviewable,
suspected_text: event.suspected_text,
tabId: event.tabId,
} as any);
return;
}
@@ -1966,6 +1971,28 @@ async function start() {
}
// Kill hung agent
// User's decision on a reviewable BLOCK (from the security banner).
// Writes ~/.gstack/security/decisions/tab-<id>.json that sidebar-agent
// polls. Accepts {tabId: number, decision: 'allow'|'block'} JSON body.
if (url.pathname === '/security-decision' && req.method === 'POST') {
if (!validateAuth(req)) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
}
const body = await req.json().catch(() => ({}));
const tabId = Number(body.tabId);
const decision = body.decision;
if (!Number.isFinite(tabId) || (decision !== 'allow' && decision !== 'block')) {
return new Response(JSON.stringify({ error: 'Invalid request' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
}
writeDecision({
tabId,
decision,
ts: new Date().toISOString(),
reason: typeof body.reason === 'string' ? body.reason.slice(0, 200) : undefined,
});
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
if (url.pathname === '/sidebar-agent/kill' && req.method === 'POST') {
if (!validateAuth(req)) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' } });