From 2175f59522c9a199026a7bc9964face14b16fba3 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Fri, 27 Mar 2026 15:29:38 -0500 Subject: [PATCH] fix(contracts): add isWorkspaceEvent guard + close routeLiveInteractionEvent exhaustiveness gap (#2878) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes two contract violations found in audit (closes #2875): 1. `isWorkspaceEvent()` type guard added next to WorkspaceEvent type definition. Applied at stream.onmessage JSON.parse boundary — replaces unsafe `as WorkspaceEvent` cast with validated parse + explicit error path for malformed payloads. 2. `routeLiveInteractionEvent()` switch extended with explicit cases for all three previously unhandled WorkspaceEvent variants: - bridge_status: handled upstream with early return, never reaches router - live_state_invalidation: handled upstream via handleLiveStateInvalidation - extension_error: terminal line produced by summarizeEvent, no live state update needed --- web/lib/gsd-workspace-store.tsx | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/web/lib/gsd-workspace-store.tsx b/web/lib/gsd-workspace-store.tsx index d22c16dc0..a34d91cf1 100644 --- a/web/lib/gsd-workspace-store.tsx +++ b/web/lib/gsd-workspace-store.tsx @@ -470,6 +470,10 @@ export type WorkspaceEvent = | TurnEndEvent | ({ type: Exclude; [key: string]: unknown } & Record) +export function isWorkspaceEvent(value: unknown): value is WorkspaceEvent { + return value !== null && typeof value === "object" && typeof (value as Record).type === "string" +} + export interface WorkspaceCommandResponse { type: "response" command: string @@ -4866,8 +4870,15 @@ export class GSDWorkspaceStore { stream.onmessage = (message) => { try { - const payload = JSON.parse(message.data) as WorkspaceEvent - this.handleEvent(payload) + const parsed: unknown = JSON.parse(message.data) + if (!isWorkspaceEvent(parsed)) { + this.patchState({ + lastClientError: "Malformed event received from stream", + terminalLines: withTerminalLine(this.state.terminalLines, createTerminalLine("error", "Malformed event received from stream")), + }) + return + } + this.handleEvent(parsed) } catch (error) { const text = normalizeClientError(error) this.patchState({ @@ -4945,6 +4956,15 @@ export class GSDWorkspaceStore { case "tool_execution_end": this.handleToolExecutionEnd(event as ToolExecutionEndEvent) break + case "bridge_status": + // Handled upstream in handleEvent with early return — never reaches here + break + case "live_state_invalidation": + // Handled upstream in handleEvent via handleLiveStateInvalidation — no live interaction state update needed + break + case "extension_error": + // Terminal line produced by summarizeEvent — no live interaction state update needed + break } }