Skip to content

Commit bd13fac

Browse files
swear01HAPI
andauthored
fix(claude): apply mid-turn permission mode changes to canCallTool (#764)
`PermissionHandler` stored its own `permissionMode` field and only updated it inside `handleModeChange`, which is called when a new batch is pulled from the queue. The `SetSessionConfig` RPC (web dropdown changes) updates `runClaude.ts`'s `currentPermissionMode` and the session keepalive metadata, but never reaches the handler — so switching to Yolo mid-turn left `canCallTool` checking the stale mode and still prompting for approval. Closes #735. Drop the stored field and read live from `session.getPermissionMode()`, mirroring how the OpenCode permission handler already works. Override `Session.getPermissionMode()` in `claude/session.ts` to return the Claude-narrow `PermissionMode`, sound because the matching `setPermissionMode` setter only accepts that subset. via [HAPI](https://hapi.run) Co-authored-by: HAPI <noreply@hapi.run>
1 parent 3056460 commit bd13fac

3 files changed

Lines changed: 53 additions & 5 deletions

File tree

cli/src/claude/session.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,13 @@ export class Session extends AgentSessionBase<EnhancedMode> {
7878
this.permissionMode = mode;
7979
};
8080

81+
// Override base getPermissionMode to return the Claude-narrow type. Safe
82+
// because the only writer (setPermissionMode above) accepts only Claude
83+
// PermissionMode, so the field cannot hold a foreign flavor value.
84+
getPermissionMode(): PermissionMode | undefined {
85+
return this.permissionMode as PermissionMode | undefined;
86+
}
87+
8188
setModel = (model: SessionModel): void => {
8289
this.model = model;
8390
};

cli/src/claude/utils/permissionHandler.test.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { Session } from '../session';
55

66
function createFakeSession() {
77
const queueItems: { message: string; mode: unknown }[] = [];
8+
let permissionMode: string | undefined;
89

910
const session = {
1011
client: {
@@ -18,7 +19,10 @@ function createFakeSession() {
1819
queueItems.push({ message, mode });
1920
}),
2021
},
21-
setPermissionMode: vi.fn(),
22+
setPermissionMode: vi.fn((mode: string) => {
23+
permissionMode = mode;
24+
}),
25+
getPermissionMode: vi.fn(() => permissionMode),
2226
} as unknown as Session;
2327

2428
return { session, queueItems };
@@ -105,4 +109,35 @@ describe('PermissionHandler — YOLO plan mode', () => {
105109
expect(result.behavior).toBe('allow');
106110
expect(queueItems).toHaveLength(0);
107111
});
112+
113+
// Regression: turn-in-progress switch from default to bypassPermissions via
114+
// SetSessionConfig RPC updates session.setPermissionMode but doesn't go
115+
// through handler.handleModeChange. The next canCallTool must reflect the
116+
// new mode. See issue #735.
117+
it('reflects session permission mode changes between tool calls', async () => {
118+
const { session } = createFakeSession();
119+
const handler = new PermissionHandler(session);
120+
handler.handleModeChange('default');
121+
122+
// Simulate RPC handler in runClaude updating the session directly,
123+
// bypassing handler.handleModeChange (as happens on web dropdown change).
124+
session.setPermissionMode('bypassPermissions');
125+
126+
handler.onMessage({
127+
type: 'assistant',
128+
message: {
129+
role: 'assistant',
130+
content: [{ type: 'tool_use', id: 'tc-4', name: 'Bash', input: { command: 'ls' } }],
131+
},
132+
} as any);
133+
134+
const result = await handler.handleToolCall(
135+
'Bash',
136+
{ command: 'ls' },
137+
{ permissionMode: 'bypassPermissions' } as any,
138+
{ signal: new AbortController().signal }
139+
);
140+
141+
expect(result.behavior).toBe('allow');
142+
});
108143
});

cli/src/claude/utils/permissionHandler.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -164,14 +164,22 @@ export class PermissionHandler extends BasePermissionHandler<PermissionResponse,
164164
private allowedTools = new Set<string>();
165165
private allowedBashLiterals = new Set<string>();
166166
private allowedBashPrefixes = new Set<string>();
167-
private permissionMode: PermissionMode = 'default';
168167
private onPermissionRequestCallback?: (toolCallId: string) => void;
169168

170169
constructor(session: Session) {
171170
super(session.client);
172171
this.session = session;
173172
}
174-
173+
174+
// Read from the session so mid-turn updates via SetSessionConfig RPC
175+
// (which writes through session.setPermissionMode) are picked up by the
176+
// next canCallTool check. Previously this was a stored field updated
177+
// only on batch boundaries, so web dropdown changes during a turn were
178+
// silently ignored — see issue #735.
179+
private get permissionMode(): PermissionMode {
180+
return this.session.getPermissionMode() ?? 'default';
181+
}
182+
175183
/**
176184
* Set callback to trigger when permission request is made
177185
*/
@@ -180,7 +188,6 @@ export class PermissionHandler extends BasePermissionHandler<PermissionResponse,
180188
}
181189

182190
handleModeChange(mode: PermissionMode) {
183-
this.permissionMode = mode;
184191
this.session.setPermissionMode(mode);
185192
}
186193

@@ -215,7 +222,6 @@ export class PermissionHandler extends BasePermissionHandler<PermissionResponse,
215222

216223
// Update permission mode
217224
if (response.mode) {
218-
this.permissionMode = response.mode;
219225
this.session.setPermissionMode(response.mode);
220226
}
221227

0 commit comments

Comments
 (0)