diff --git a/cli/src/claude/session.ts b/cli/src/claude/session.ts index 975bcb2da..2106d312f 100644 --- a/cli/src/claude/session.ts +++ b/cli/src/claude/session.ts @@ -78,6 +78,13 @@ export class Session extends AgentSessionBase { this.permissionMode = mode; }; + // Override base getPermissionMode to return the Claude-narrow type. Safe + // because the only writer (setPermissionMode above) accepts only Claude + // PermissionMode, so the field cannot hold a foreign flavor value. + getPermissionMode(): PermissionMode | undefined { + return this.permissionMode as PermissionMode | undefined; + } + setModel = (model: SessionModel): void => { this.model = model; }; diff --git a/cli/src/claude/utils/permissionHandler.test.ts b/cli/src/claude/utils/permissionHandler.test.ts index 062de88b3..cc55b58b4 100644 --- a/cli/src/claude/utils/permissionHandler.test.ts +++ b/cli/src/claude/utils/permissionHandler.test.ts @@ -5,6 +5,7 @@ import type { Session } from '../session'; function createFakeSession() { const queueItems: { message: string; mode: unknown }[] = []; + let permissionMode: string | undefined; const session = { client: { @@ -18,7 +19,10 @@ function createFakeSession() { queueItems.push({ message, mode }); }), }, - setPermissionMode: vi.fn(), + setPermissionMode: vi.fn((mode: string) => { + permissionMode = mode; + }), + getPermissionMode: vi.fn(() => permissionMode), } as unknown as Session; return { session, queueItems }; @@ -105,4 +109,35 @@ describe('PermissionHandler — YOLO plan mode', () => { expect(result.behavior).toBe('allow'); expect(queueItems).toHaveLength(0); }); + + // Regression: turn-in-progress switch from default to bypassPermissions via + // SetSessionConfig RPC updates session.setPermissionMode but doesn't go + // through handler.handleModeChange. The next canCallTool must reflect the + // new mode. See issue #735. + it('reflects session permission mode changes between tool calls', async () => { + const { session } = createFakeSession(); + const handler = new PermissionHandler(session); + handler.handleModeChange('default'); + + // Simulate RPC handler in runClaude updating the session directly, + // bypassing handler.handleModeChange (as happens on web dropdown change). + session.setPermissionMode('bypassPermissions'); + + handler.onMessage({ + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'tool_use', id: 'tc-4', name: 'Bash', input: { command: 'ls' } }], + }, + } as any); + + const result = await handler.handleToolCall( + 'Bash', + { command: 'ls' }, + { permissionMode: 'bypassPermissions' } as any, + { signal: new AbortController().signal } + ); + + expect(result.behavior).toBe('allow'); + }); }); diff --git a/cli/src/claude/utils/permissionHandler.ts b/cli/src/claude/utils/permissionHandler.ts index ea0016e9f..275760ece 100644 --- a/cli/src/claude/utils/permissionHandler.ts +++ b/cli/src/claude/utils/permissionHandler.ts @@ -164,14 +164,22 @@ export class PermissionHandler extends BasePermissionHandler(); private allowedBashLiterals = new Set(); private allowedBashPrefixes = new Set(); - private permissionMode: PermissionMode = 'default'; private onPermissionRequestCallback?: (toolCallId: string) => void; constructor(session: Session) { super(session.client); this.session = session; } - + + // Read from the session so mid-turn updates via SetSessionConfig RPC + // (which writes through session.setPermissionMode) are picked up by the + // next canCallTool check. Previously this was a stored field updated + // only on batch boundaries, so web dropdown changes during a turn were + // silently ignored — see issue #735. + private get permissionMode(): PermissionMode { + return this.session.getPermissionMode() ?? 'default'; + } + /** * Set callback to trigger when permission request is made */ @@ -180,7 +188,6 @@ export class PermissionHandler extends BasePermissionHandler