diff --git a/packages/core/src/permissions/permission-manager.test.ts b/packages/core/src/permissions/permission-manager.test.ts index 5ee73ee0e28..8166529c064 100644 --- a/packages/core/src/permissions/permission-manager.test.ts +++ b/packages/core/src/permissions/permission-manager.test.ts @@ -2743,6 +2743,45 @@ describe('PermissionManager', () => { expect(await pm.isToolEnabled('structured_output')).toBe(true); }); + it('plan-mode lifecycle tools are exempt from the allowlist (#9827)', async () => { + // Under the exact reporter configuration the plan-mode system reminder + // still instructs the model to call exit_plan_mode, so the sanctioned + // plan-flow tools must stay registered. Same synthetic-system-tool + // exemption class as structured_output. + pm = new PermissionManager( + makeConfig({ + permissionsAllow: [ + 'ReadFile', + 'WriteFile', + 'Edit', + 'Grep', + 'Glob', + 'ListFiles', + 'Shell', + 'WebFetch', + ], + }), + ); + pm.initialize(); + expect(pm.isPermissionsAllowListActive()).toBe(true); + expect(await pm.isToolEnabled('exit_plan_mode')).toBe(true); + expect(await pm.isToolEnabled('enter_plan_mode')).toBe(true); + expect(await pm.isToolEnabled('ask_user_question')).toBe(true); + // Unrelated unlisted built-ins stay gated. + expect(await pm.isToolEnabled('send_message')).toBe(false); + }); + + it('a whole-tool deny rule still wins over the plan-mode exemption', async () => { + pm = new PermissionManager( + makeConfig({ + permissionsAllow: ['read_file'], + permissionsDeny: ['exit_plan_mode'], + }), + ); + pm.initialize(); + expect(await pm.isToolEnabled('exit_plan_mode')).toBe(false); + }); + it('session-granted allow rules extend membership but never activate the allowlist', async () => { // No configured allow rules → allowlist must stay inactive even after // a mid-session "Always allow" / skill allowedTools grant, or one diff --git a/packages/core/src/permissions/permission-manager.ts b/packages/core/src/permissions/permission-manager.ts index b28fa529c65..36f51b8aed8 100644 --- a/packages/core/src/permissions/permission-manager.ts +++ b/packages/core/src/permissions/permission-manager.ts @@ -632,6 +632,22 @@ export class PermissionManager { 'monitor', ]); + /** + * Synthetic plan-mode lifecycle tools that must stay registered even under + * an active `permissions.allow` registry allowlist. The plan-mode system + * reminder instructs the model to present its plan by calling + * `exit_plan_mode`, and `enter_plan_mode` / `ask_user_question` are the + * sanctioned plan-flow entry and clarification tools; dropping their + * schemas makes the plan flow impossible to complete (#9827). They belong + * to the same exemption class as `structured_output` and the "synthetic + * system tools" the CORE_TOOLS docstring names — deny rules still apply. + */ + private static readonly PLAN_LIFECYCLE_TOOLS: ReadonlySet = new Set([ + ToolNames.EXIT_PLAN_MODE, + ToolNames.ENTER_PLAN_MODE, + ToolNames.ASK_USER_QUESTION, + ]); + /** * Check if a tool is a core tool subject to the coreTools allowlist check. */ @@ -675,9 +691,14 @@ export class PermissionManager { // - `structured_output`: the synthetic terminal contract for // `--json-schema` runs; removing it leaves such runs with no way to // finish (deny rules still apply to it). + // - Plan-mode lifecycle tools (`exit_plan_mode` / `enter_plan_mode` / + // `ask_user_question`): the plan-mode system reminder tells the model + // to call `exit_plan_mode` to present a plan, so their schemas must + // reach the model for the sanctioned plan flow to complete (#9827). if ( this.permissionsAllowListActive && canonicalName !== ToolNames.STRUCTURED_OUTPUT && + !PermissionManager.PLAN_LIFECYCLE_TOOLS.has(canonicalName) && !canonicalName.startsWith('mcp__') && !this.isCoveredByAllowRule(canonicalName) ) {