From c96273993b1b0cb84a13e226c94b4dbe2dc9a5f8 Mon Sep 17 00:00:00 2001 From: dimakis Date: Tue, 15 Sep 2026 08:53:03 +0100 Subject: [PATCH 1/6] fix(approvals): harden question response handling --- frontend/src/styles/global.css | 6 +++++ .../harness/__tests__/user-questions.test.ts | 19 ++++++++++++++ packages/harness/src/permission-handler.ts | 19 ++++++++++---- .../protocol/__tests__/ws-schemas-v2.test.ts | 11 ++++++++ .../protocol/__tests__/ws-schemas.test.ts | 10 ++++++++ packages/protocol/src/ws-schemas-v2.ts | 5 +++- packages/protocol/src/ws-schemas.ts | 5 +++- server/__tests__/ws-handler-v2.test.ts | 25 ++++++++++++++++++- server/index.ts | 17 ++++++++++++- server/ws-handler-v2.ts | 25 +++++++++++++++++-- 10 files changed, 131 insertions(+), 11 deletions(-) diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index a098e2a12..107cd01e9 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -3312,6 +3312,12 @@ textarea:focus { max-height: 72dvh; overflow: hidden; } +.perm-banner--elevated { + border-top-color: var(--warning); +} +.perm-banner--unknown { + border-top-color: var(--text-dim); +} .perm-banner-heading { display: flex; justify-content: space-between; diff --git a/packages/harness/__tests__/user-questions.test.ts b/packages/harness/__tests__/user-questions.test.ts index f1c7d5741..95d203986 100644 --- a/packages/harness/__tests__/user-questions.test.ts +++ b/packages/harness/__tests__/user-questions.test.ts @@ -146,6 +146,25 @@ it('shows complete approval arguments rather than the notification summary', asy } }); +it('caps approval display input without truncating the SDK resolution input', async () => { + const { applyTierOverrides } = await import('../src/tool-tiers.js'); + applyTierOverrides({ Bash: 'unknown' }); + try { + const { handler, sent, abort } = setup(); + const command = 'x'.repeat(20_000); + const result = handler('Bash', { command }, { signal: abort.signal, toolUseID: 'b-large' }); + await vi.waitFor(() => expect(sent[0]).toBeDefined()); + expect(sent[0].toolInput).toBe(`${command.slice(0, 10_000)}\n[… truncated]`); + resolvePending(sent[0].permId as string, 'once'); + await expect(result).resolves.toMatchObject({ + behavior: 'allow', + updatedInput: { command }, + }); + } finally { + applyTierOverrides({}); + } +}); + it('keeps an unresolved question replayable when a transport closes during send', async () => { const { getPendingRequestsBySession } = await import('../src/permissions.js'); const { handler, registry, abort } = setup(); diff --git a/packages/harness/src/permission-handler.ts b/packages/harness/src/permission-handler.ts index 7e501d87a..44fade393 100644 --- a/packages/harness/src/permission-handler.ts +++ b/packages/harness/src/permission-handler.ts @@ -36,6 +36,19 @@ export const UserQuestionsSchema = z .max(4) .refine((questions) => new Set(questions.map((q) => q.question)).size === questions.length); +const PERMISSION_INPUT_MAX_CHARS = 10_000; +const PERMISSION_INPUT_TRUNCATED = '\n[… truncated]'; + +function permissionDisplayInput(toolName: string, input: Record): string { + const full = + toolName === 'Bash' && typeof input.command === 'string' + ? input.command + : JSON.stringify(input, null, 2); + return full.length > PERMISSION_INPUT_MAX_CHARS + ? full.slice(0, PERMISSION_INPUT_MAX_CHARS) + PERMISSION_INPUT_TRUNCATED + : full; +} + function transportSend(transport: SessionTransport, data: Record): void { try { if (transport.isOpen()) transport.send(data); @@ -207,11 +220,7 @@ export function buildPermissionHandler( const request: PermissionRequest = { permId, toolName, - toolInput: questions - ? '' - : toolName === 'Bash' && typeof _toolInput.command === 'string' - ? _toolInput.command - : JSON.stringify(_toolInput, null, 2), + toolInput: questions ? '' : permissionDisplayInput(toolName, _toolInput), title: opts.title, description: opts.description, displayName: opts.displayName, diff --git a/packages/protocol/__tests__/ws-schemas-v2.test.ts b/packages/protocol/__tests__/ws-schemas-v2.test.ts index d88dd8c07..60ec1dac4 100644 --- a/packages/protocol/__tests__/ws-schemas-v2.test.ts +++ b/packages/protocol/__tests__/ws-schemas-v2.test.ts @@ -194,6 +194,17 @@ describe('v2 interrupt / stop / permission_response / set_mode', () => { expect(r.success).toBe(true); }); + it('rejects whitespace-only question answers', () => { + const r = V2PermissionResponseMessage.safeParse({ + type: 'permission_response', + sessionId: 'sess-1', + permId: 'p1', + decision: 'once', + answers: { question: [' '] }, + }); + expect(r.success).toBe(false); + }); + it('accepts set_mode with sessionId', () => { const r = V2SetModeMessage.safeParse({ type: 'set_mode', diff --git a/packages/protocol/__tests__/ws-schemas.test.ts b/packages/protocol/__tests__/ws-schemas.test.ts index 4ca9fa0bb..36755db99 100644 --- a/packages/protocol/__tests__/ws-schemas.test.ts +++ b/packages/protocol/__tests__/ws-schemas.test.ts @@ -67,6 +67,16 @@ describe('WS message schemas', () => { expect(result.success).toBe(true); }); + it('rejects whitespace-only permission answers', () => { + const result = IncomingWsMessage.safeParse({ + type: 'permission_response', + permId: 'p1', + decision: 'once', + answers: { question: [' '] }, + }); + expect(result.success).toBe(false); + }); + it('accepts set_mode', () => { const result = IncomingWsMessage.safeParse({ type: 'set_mode', mode: 'auto' }); expect(result.success).toBe(true); diff --git a/packages/protocol/src/ws-schemas-v2.ts b/packages/protocol/src/ws-schemas-v2.ts index 4af25ed4a..b2674da68 100644 --- a/packages/protocol/src/ws-schemas-v2.ts +++ b/packages/protocol/src/ws-schemas-v2.ts @@ -119,7 +119,10 @@ export const V2PermissionResponseMessage = z.object({ permId: z.string(), decision: z.enum(['once', 'always', 'deny']).optional(), answers: z - .record(z.string().min(1).max(4000), z.array(z.string().min(1).max(4000)).min(1).max(9)) + .record( + z.string().trim().min(1).max(4000), + z.array(z.string().trim().min(1).max(4000)).min(1).max(9), + ) .optional(), }); diff --git a/packages/protocol/src/ws-schemas.ts b/packages/protocol/src/ws-schemas.ts index cce4face5..1f3d8040b 100644 --- a/packages/protocol/src/ws-schemas.ts +++ b/packages/protocol/src/ws-schemas.ts @@ -53,7 +53,10 @@ export const PermissionResponseMessage = z.object({ permId: z.string(), decision: z.enum(['once', 'always', 'deny']).optional(), answers: z - .record(z.string().min(1).max(4000), z.array(z.string().min(1).max(4000)).min(1).max(9)) + .record( + z.string().trim().min(1).max(4000), + z.array(z.string().trim().min(1).max(4000)).min(1).max(9), + ) .optional(), traceparent, }); diff --git a/server/__tests__/ws-handler-v2.test.ts b/server/__tests__/ws-handler-v2.test.ts index 991c516a1..ccc821a95 100644 --- a/server/__tests__/ws-handler-v2.test.ts +++ b/server/__tests__/ws-handler-v2.test.ts @@ -50,7 +50,11 @@ import { } from '../chat.js'; import { setSkillPolicy, clearSkillPolicy } from '../skill-policy.js'; import { resolveSlashCommand } from '../slash-commands.js'; -import { denyPendingBySession, getPendingRequestsBySession } from '../permissions.js'; +import { + denyPendingBySession, + getPendingRequestsBySession, + resolvePending, +} from '../permissions.js'; import { handleHello, @@ -1531,6 +1535,25 @@ describe('handlePermissionResponseV2', () => { ), ).not.toThrow(); }); + + it('keeps an invalid response retryable and reports the rejection', () => { + vi.mocked(resolvePending).mockReturnValueOnce(false); + const ctx = createContext(); + const transport = mockTransport(); + ctx.connRegistry.register('c1', transport); + + handlePermissionResponseV2( + 'c1', + { type: 'permission_response', sessionId: 'sess-1', permId: 'p1', decision: 'once' }, + ctx, + ); + + expect(transport.sent).toContainEqual({ + type: 'error', + sessionId: 'sess-1', + error: 'Permission response was invalid or expired. Review the prompt and try again.', + }); + }); }); // ─── handleReconnect — no running field in summary ────────────────────────── diff --git a/server/index.ts b/server/index.ts index 023b61b7b..42f446ee8 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1121,12 +1121,27 @@ function handleChatWs( 'ws.decision': msg.decision || 'deny', }, () => { - resolvePending( + const resolved = resolvePending( msg.permId, msg.decision || 'deny', msg.answers, registry.get(clientId)?.sessionId, ); + if (!resolved) { + try { + transport.send({ + type: 'error', + error: + 'Permission response was invalid or expired. Review the prompt and try again.', + }); + } catch (error) { + log.warn('permission response error delivery failed', { + clientId, + permId: msg.permId, + error, + }); + } + } }, contextFromTraceparent(traceparent), ); diff --git a/server/ws-handler-v2.ts b/server/ws-handler-v2.ts index d3143830b..7a3d57b7d 100644 --- a/server/ws-handler-v2.ts +++ b/server/ws-handler-v2.ts @@ -914,7 +914,7 @@ export function handleInterruptV2( export function handlePermissionResponseV2( connectionId: string, msg: PermissionMsg, - _ctx: V2HandlerContext, + ctx: V2HandlerContext, ): void { withSpan( 'ws.permission_response', @@ -924,7 +924,28 @@ export function handlePermissionResponseV2( 'ws.permId': msg.permId, }, () => { - resolvePending(msg.permId, msg.decision ?? 'deny', msg.answers, msg.sessionId); + const resolved = resolvePending( + msg.permId, + msg.decision ?? 'deny', + msg.answers, + msg.sessionId, + ); + if (!resolved) { + try { + ctx.connRegistry.get(connectionId)?.transport.send({ + type: 'error', + sessionId: msg.sessionId, + error: 'Permission response was invalid or expired. Review the prompt and try again.', + }); + } catch (error) { + log.warn('permission response error delivery failed', { + connectionId, + sessionId: msg.sessionId, + permId: msg.permId, + error, + }); + } + } log.info('permission_response', { connectionId, sessionId: msg.sessionId, From 9ccb1b58e5cfd715ee259b6ab4dc3fb0c5f3c6ba Mon Sep 17 00:00:00 2001 From: dimakis Date: Tue, 15 Sep 2026 09:25:09 +0100 Subject: [PATCH 2/6] fix(approvals): make rejected responses safely retryable --- frontend/src/components/ChatArea.tsx | 1 + frontend/src/components/PermissionBanner.tsx | 7 +++++++ .../__tests__/PermissionBanner.test.tsx | 6 ++++++ frontend/src/styles/global.css | 7 +++++++ frontend/src/types/ws-messages.ts | 8 ++++++++ .../client/__tests__/messages-slice.test.ts | 16 ++++++++++++++++ .../client/__tests__/protocol-parser.test.ts | 11 +++++++++++ packages/client/src/protocol-parser.ts | 8 ++++++++ packages/client/src/slices/messages.ts | 13 +++++++++++++ .../harness/__tests__/user-questions.test.ts | 10 ++++------ packages/harness/src/permission-handler.ts | 18 ++++++++++++------ .../protocol/__tests__/ws-schemas-v2.test.ts | 13 +++++++++++++ packages/protocol/__tests__/ws-schemas.test.ts | 13 +++++++++++++ packages/protocol/src/types.ts | 1 + packages/protocol/src/ws-schemas-v2.ts | 11 +++++++---- packages/protocol/src/ws-schemas.ts | 11 +++++++---- server/__tests__/ws-handler-v2.test.ts | 3 ++- server/index.ts | 3 ++- server/ws-handler-v2.ts | 3 ++- 19 files changed, 140 insertions(+), 23 deletions(-) diff --git a/frontend/src/components/ChatArea.tsx b/frontend/src/components/ChatArea.tsx index f86d58f29..0ce7bfb53 100644 --- a/frontend/src/components/ChatArea.tsx +++ b/frontend/src/components/ChatArea.tsx @@ -255,6 +255,7 @@ export function ChatArea({ displayName={permission.displayName} tier={permission.tier} approvalScope={permission.approvalScope} + responseError={permission.responseError} onRespond={onPermissionRespond} /> )} diff --git a/frontend/src/components/PermissionBanner.tsx b/frontend/src/components/PermissionBanner.tsx index d911f31a8..d7c3e3bfe 100644 --- a/frontend/src/components/PermissionBanner.tsx +++ b/frontend/src/components/PermissionBanner.tsx @@ -11,6 +11,7 @@ interface Props { displayName?: string; tier?: ToolTier; approvalScope?: 'session' | 'conversation'; + responseError?: string; expiresAt?: number; questions?: UserQuestion[]; onRespond: ( @@ -36,6 +37,7 @@ export function PermissionBanner({ displayName, tier, approvalScope, + responseError, questions, expiresAt, onRespond, @@ -184,6 +186,11 @@ export function PermissionBanner({ )}
+ {responseError && ( +

+ {responseError} +

+ )} {questions ? (