fix: allow web fetch approvals in plan mode

This commit is contained in:
LaZzyMan 2026-03-31 16:30:44 +08:00
parent 1b1a029fd7
commit ee08ae9e7e
4 changed files with 170 additions and 37 deletions

View file

@ -346,6 +346,71 @@ describe('Session', () => {
);
});
it('allows info confirmation tools in plan mode', async () => {
const executeSpy = vi.fn().mockResolvedValue({
llmContent: 'ok',
returnDisplay: 'ok',
});
const onConfirmSpy = vi.fn().mockResolvedValue(undefined);
const invocation = {
params: {
url: 'https://example.com/docs',
prompt: 'Summarize the docs',
},
getDefaultPermission: vi.fn().mockResolvedValue('ask'),
getConfirmationDetails: vi.fn().mockResolvedValue({
type: 'info',
title: 'Confirm Web Fetch',
prompt: 'Allow fetching docs?',
urls: ['https://example.com/docs'],
onConfirm: onConfirmSpy,
}),
getDescription: vi.fn().mockReturnValue('Fetch docs'),
toolLocations: vi.fn().mockReturnValue([]),
execute: executeSpy,
};
const tool = {
name: 'web_fetch',
kind: core.Kind.Fetch,
build: vi.fn().mockReturnValue(invocation),
};
mockToolRegistry.getTool.mockReturnValue(tool);
mockConfig.getApprovalMode = vi.fn().mockReturnValue(ApprovalMode.PLAN);
mockConfig.getPermissionManager = vi.fn().mockReturnValue(null);
mockChat.sendMessageStream = vi.fn().mockResolvedValue(
(async function* () {
yield {
type: core.StreamEventType.CHUNK,
value: {
functionCalls: [
{
id: 'call-info-plan',
name: 'web_fetch',
args: {
url: 'https://example.com/docs',
prompt: 'Summarize the docs',
},
},
],
},
};
})(),
);
await session.prompt({
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: 'research the docs first' }],
});
expect(mockClient.requestPermission).toHaveBeenCalled();
expect(onConfirmSpy).toHaveBeenCalledWith(
core.ToolConfirmationOutcome.ProceedOnce,
{ answers: undefined },
);
expect(executeSpy).toHaveBeenCalled();
});
it('returns permission error for disabled tools (L1 isToolEnabled check)', async () => {
const executeSpy = vi.fn();
const invocation = {

View file

@ -674,22 +674,6 @@ export class Session implements SessionContext {
const approvalMode = this.config.getApprovalMode();
const isPlanMode = approvalMode === ApprovalMode.PLAN;
// PLAN mode: block non-read-only tools
if (
isPlanMode &&
!isExitPlanModeTool &&
!isAskUserQuestionTool &&
needsConfirmation
) {
return earlyErrorResponse(
new Error(
`Plan mode is active. The tool "${fc.name}" cannot be executed because it modifies the system. ` +
'Please use the exit_plan_mode tool to present your plan and exit plan mode before making changes.',
),
fc.name,
);
}
if (finalPermission === 'deny') {
return earlyErrorResponse(
new Error(
@ -702,14 +686,30 @@ export class Session implements SessionContext {
}
let didRequestPermission = false;
let confirmationDetails: ToolCallConfirmationDetails | undefined;
if (needsConfirmation) {
const confirmationDetails =
confirmationDetails =
await invocation.getConfirmationDetails(abortSignal);
// Centralised rule injection (for display and persistence)
injectPermissionRulesIfMissing(confirmationDetails, pmCtx);
if (
isPlanMode &&
!isExitPlanModeTool &&
!isAskUserQuestionTool &&
confirmationDetails.type !== 'info'
) {
return earlyErrorResponse(
new Error(
`Plan mode is active. The tool "${fc.name}" cannot be executed because it modifies the system. ` +
'Please use the exit_plan_mode tool to present your plan and exit plan mode before making changes.',
),
fc.name,
);
}
const messageBus = this.config.getMessageBus?.();
const hooksEnabled = this.config.getEnableHooks?.() ?? false;
let hookHandled = false;