fix: enforce plan mode restrictions in ACP sessions (issue #1806)

- Add plan mode enforcement in Session.runTool to block write tools
- Align ACP behavior with CoreToolScheduler plan mode logic
- Add test case to verify write tools are blocked in plan mode
- Fixes #1806
This commit is contained in:
LaZzyMan 2026-02-12 11:29:02 +08:00
parent fb9f3fb4dc
commit 785d0ef5b7
2 changed files with 107 additions and 0 deletions

View file

@ -648,6 +648,101 @@ function setupAcpTest(
}
});
it('blocks write tools in plan mode (issue #1806)', async () => {
const rig = new TestRig();
rig.setup('acp plan mode enforcement');
const toolCallEvents: Array<{
toolName: string;
status: string;
error?: string;
}> = [];
const { sendRequest, cleanup, stderr, sessionUpdates } = setupAcpTest(rig, {
permissionHandler: () => ({ optionId: 'proceed_once' }),
});
try {
await sendRequest('initialize', {
protocolVersion: 1,
clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } },
});
await sendRequest('authenticate', { methodId: 'openai' });
const newSession = (await sendRequest('session/new', {
cwd: rig.testDir!,
mcpServers: [],
})) as { sessionId: string };
// Set mode to 'plan'
const setModeResult = (await sendRequest('session/set_mode', {
sessionId: newSession.sessionId,
modeId: 'plan',
})) as { modeId: string };
expect(setModeResult.modeId).toBe('plan');
// Try to create a file - this should be blocked by plan mode
const promptResult = await sendRequest('session/prompt', {
sessionId: newSession.sessionId,
prompt: [
{
type: 'text',
text: 'Create a file called test.txt with content "Hello World"',
},
],
});
expect(promptResult).toBeDefined();
// Give time for tool calls to be processed
await delay(2000);
// Collect tool call events from session updates
sessionUpdates.forEach((update) => {
if (update.update?.sessionUpdate === 'tool_call_update') {
const toolUpdate = update.update as {
sessionUpdate: string;
toolName?: string;
status?: string;
error?: { message?: string };
};
if (toolUpdate.toolName) {
toolCallEvents.push({
toolName: toolUpdate.toolName,
status: toolUpdate.status ?? 'unknown',
error: toolUpdate.error?.message,
});
}
}
});
// Verify that if write_file was attempted, it was blocked
const writeFileEvents = toolCallEvents.filter(
(e) => e.toolName === 'write_file',
);
// If the LLM tried to call write_file in plan mode, it should have been blocked
if (writeFileEvents.length > 0) {
const blockedEvent = writeFileEvents.find(
(e) => e.status === 'error' && e.error?.includes('Plan mode'),
);
expect(blockedEvent).toBeDefined();
expect(blockedEvent?.error).toContain('Plan mode is active');
}
// Verify the file was NOT created
const fs = await import('fs');
const path = await import('path');
const testFilePath = path.join(rig.testDir!, 'test.txt');
const fileExists = fs.existsSync(testFilePath);
expect(fileExists).toBe(false);
} catch (e) {
if (stderr.length) console.error('Agent stderr:', stderr.join(''));
throw e;
} finally {
await cleanup();
}
});
it('receives usage metadata in agent_message_chunk updates', async () => {
const rig = new TestRig();
rig.setup('acp usage metadata');

View file

@ -516,6 +516,18 @@ export class Session implements SessionContext {
? await invocation.shouldConfirmExecute(abortSignal)
: false;
// Check for plan mode enforcement - block non-read-only tools
const isPlanMode = this.config.getApprovalMode() === ApprovalMode.PLAN;
if (isPlanMode && !isExitPlanModeTool && confirmationDetails) {
// In plan mode, block any tool that requires confirmation (write operations)
return errorResponse(
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.',
),
);
}
if (confirmationDetails) {
const content: acp.ToolCallContent[] = [];