fix(core): keep plan-mode lifecycle tools registered under the allowlist (#9827)

The permissions.allow registry gate covered exit_plan_mode /
enter_plan_mode / ask_user_question, so the exact reporter configuration
unregistered them. The plan-mode system reminder still instructs the model
to present its plan by calling exit_plan_mode, whose schema is then never
sent, so the sanctioned plan flow cannot complete. Exempt the three
plan-mode lifecycle tools alongside structured_output (same synthetic-
system-tool class the CORE_TOOLS docstring names; deny rules still apply).
This commit is contained in:
jinjing.zzj committed 2026-08-24 10:49:03 +08:00
1 parent a554edb433
commit b8ba258c40
2 files changed
+60

No files matched your search

@@ -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
@@ -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<string> = 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)
) {