diff --git a/docs/developers/sdk-typescript.md b/docs/developers/sdk-typescript.md index ee6d1936778..dbe26362464 100644 --- a/docs/developers/sdk-typescript.md +++ b/docs/developers/sdk-typescript.md @@ -55,27 +55,27 @@ Creates a new query session with the Qwen Code. #### QueryOptions -| Option | Type | Default | Description | -| ------------------------ | -------------------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `cwd` | `string` | `process.cwd()` | The working directory for the query session. Determines the context in which file operations and commands are executed. | -| `model` | `string` | - | The AI model to use (e.g., `'qwen-max'`, `'qwen-plus'`, `'qwen-turbo'`). Takes precedence over `OPENAI_MODEL` and `QWEN_MODEL` environment variables. | -| `pathToQwenExecutable` | `string` | Auto-detected | Path to the Qwen Code executable. Supports multiple formats: `'qwen'` (native binary from PATH), `'/path/to/qwen'` (explicit path), `'/path/to/cli.js'` (Node.js bundle), `'node:/path/to/cli.js'` (force Node.js runtime), `'bun:/path/to/cli.js'` (force Bun runtime). If not provided, auto-detects from: `QWEN_CODE_CLI_PATH` env var, `~/.volta/bin/qwen`, `~/.npm-global/bin/qwen`, `/usr/local/bin/qwen`, `~/.local/bin/qwen`, `~/node_modules/.bin/qwen`, `~/.yarn/bin/qwen`. | -| `permissionMode` | `'default' \| 'plan' \| 'auto-edit' \| 'auto' \| 'yolo'` | `'default'` | Permission mode controlling tool execution approval. See [Permission Modes](#permission-modes) for details. | -| `canUseTool` | `CanUseTool` | - | Custom permission handler for tool execution approval. Invoked when a tool requires confirmation. Must respond within 60 seconds or the request will be auto-denied. See [Custom Permission Handler](#custom-permission-handler). | -| `env` | `Record` | - | Environment variables to pass to the Qwen Code process. Merged with the current process environment. | -| `systemPrompt` | `string \| QuerySystemPromptPreset` | - | System prompt configuration for the main session. Use a string to fully override the built-in Qwen Code system prompt, or a preset object to keep the built-in prompt and append extra instructions. | -| `mcpServers` | `Record` | - | MCP (Model Context Protocol) servers to connect. Supports external servers (stdio/SSE/HTTP) and SDK-embedded servers. External servers are configured with transport options like `command`, `args`, `url`, `httpUrl`, etc. SDK servers use `{ type: 'sdk', name: string, instance: Server }`. | -| `abortController` | `AbortController` | - | Controller to cancel the query session. Call `abortController.abort()` to terminate the session and cleanup resources. | -| `debug` | `boolean` | `false` | Enable debug mode for verbose logging from the CLI process. | -| `maxSessionTurns` | `number` | `-1` (unlimited) | Maximum number of conversation turns before the session automatically terminates. Must be an integer. A turn consists of a user message and an assistant response. | -| `coreTools` | `string[]` | - | Uses the legacy `coreTools` / CLI `--core-tools` allowlist semantics. If specified, only matching core tools are registered for the session. This is separate from `permissions.allow`, which auto-approves matching tool calls but does not restrict tool registration. Example: `['read_file', 'edit', 'run_shell_command']`. | -| `excludeTools` | `string[]` | - | Equivalent to `permissions.deny` in settings.json. Excluded tools return a permission error immediately. Takes highest priority over all other permission settings. Supports tool name aliases and pattern matching: tool name (`'write_file'`), shell command prefix (`'Bash(rm *)'`), or path patterns (`'Read(.env)'`, `'Edit(/src/**)'`). | -| `allowedTools` | `string[]` | - | Equivalent to `permissions.allow` in settings.json. Matching tools bypass `canUseTool` callback and execute automatically. Only applies when tool requires confirmation. Supports same pattern matching as `excludeTools`. Example: `['Bash(git status)', 'Bash(npm test)']`. | -| `authType` | `'openai' \| 'qwen-oauth'` | `'openai'` | Authentication type for the AI service. Qwen OAuth free tier was discontinued on 2026-04-15; new SDK setups should use OpenAI-compatible authentication or another supported provider. | -| `agents` | `SubagentConfig[]` | - | Configuration for subagents that can be invoked during the session. Subagents are specialized AI agents for specific tasks or domains. | -| `includePartialMessages` | `boolean` | `false` | When `true`, the SDK emits incomplete messages as they are being generated, allowing real-time streaming of the AI's response. | -| `resume` | `string` | - | Resume a previous session by providing its session ID. Equivalent to CLI's `--resume` flag. | -| `sessionId` | `string` | - | Specify a session ID for the new session. Ensures SDK and CLI use the same ID without resuming history. Equivalent to CLI's `--session-id` flag. | +| Option | Type | Default | Description | +| ------------------------ | -------------------------------------------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cwd` | `string` | `process.cwd()` | The working directory for the query session. Determines the context in which file operations and commands are executed. | +| `model` | `string` | - | The AI model to use (e.g., `'qwen-max'`, `'qwen-plus'`, `'qwen-turbo'`). Takes precedence over `OPENAI_MODEL` and `QWEN_MODEL` environment variables. | +| `pathToQwenExecutable` | `string` | Auto-detected | Path to the Qwen Code executable. Supports multiple formats: `'qwen'` (native binary from PATH), `'/path/to/qwen'` (explicit path), `'/path/to/cli.js'` (Node.js bundle), `'node:/path/to/cli.js'` (force Node.js runtime), `'bun:/path/to/cli.js'` (force Bun runtime). If not provided, auto-detects from: `QWEN_CODE_CLI_PATH` env var, `~/.volta/bin/qwen`, `~/.npm-global/bin/qwen`, `/usr/local/bin/qwen`, `~/.local/bin/qwen`, `~/node_modules/.bin/qwen`, `~/.yarn/bin/qwen`. | +| `permissionMode` | `'default' \| 'plan' \| 'auto-edit' \| 'auto' \| 'yolo'` | `'default'` | Permission mode controlling tool execution approval. See [Permission Modes](#permission-modes) for details. | +| `canUseTool` | `CanUseTool` | - | Custom permission handler for tool execution approval. Invoked when a tool requires confirmation. Must respond within 60 seconds or the request will be auto-denied. See [Custom Permission Handler](#custom-permission-handler). | +| `env` | `Record` | - | Environment variables to pass to the Qwen Code process. Merged with the current process environment. | +| `systemPrompt` | `string \| QuerySystemPromptPreset` | - | System prompt configuration for the main session. Use a string to fully override the built-in Qwen Code system prompt, or a preset object to keep the built-in prompt and append extra instructions. | +| `mcpServers` | `Record` | - | MCP (Model Context Protocol) servers to connect. Supports external servers (stdio/SSE/HTTP) and SDK-embedded servers. External servers are configured with transport options like `command`, `args`, `url`, `httpUrl`, etc. SDK servers use `{ type: 'sdk', name: string, instance: Server }`. | +| `abortController` | `AbortController` | - | Controller to cancel the query session. Call `abortController.abort()` to terminate the session and cleanup resources. | +| `debug` | `boolean` | `false` | Enable debug mode for verbose logging from the CLI process. | +| `maxSessionTurns` | `number` | `-1` (unlimited) | Maximum number of conversation turns before the session automatically terminates. Must be an integer. A turn consists of a user message and an assistant response. | +| `coreTools` | `string[]` | - | Uses the legacy `coreTools` / CLI `--core-tools` allowlist semantics. If specified, only matching core tools are registered for the session. This is separate from `permissions.allow` in settings.json, which also activates a registry-level allowlist at startup: when at least one valid allow rule is configured there (malformed entries do not count), built-in tools not covered by any allow or ask rule are not registered (MCP tools, the `--json-schema` `structured_output` contract, the plan-mode lifecycle tools, `task_stop`, `tool_search`, and the `computer_use__*` family are exempt; requires restart, #9827). The SDK `allowedTools` parameter cannot activate the allowlist on its own, but while the allowlist is active its rules are merged into the effective allow set and count toward coverage, keeping covered built-ins registered. Example: `['read_file', 'edit', 'run_shell_command']`. | +| `excludeTools` | `string[]` | - | Equivalent to `permissions.deny` in settings.json. Excluded tools return a permission error immediately. Takes highest priority over all other permission settings. Supports tool name aliases and pattern matching: tool name (`'write_file'`), shell command prefix (`'Bash(rm *)'`), or path patterns (`'Read(.env)'`, `'Edit(/src/**)'`). | +| `allowedTools` | `string[]` | - | Equivalent to `permissions.allow` in settings.json for auto-approval. Matching tools bypass `canUseTool` callback and execute automatically. Only applies when tool requires confirmation. Unlike `permissions.allow` in settings.json, this parameter alone does not activate the registry allowlist; however, while a settings-provided allowlist is active, `allowedTools` rules are merged into the effective allow set and count toward coverage, so covered built-ins stay registered. Supports same pattern matching as `excludeTools`. Example: `['Bash(git status)', 'Bash(npm test)']`. | +| `authType` | `'openai' \| 'qwen-oauth'` | `'openai'` | Authentication type for the AI service. Qwen OAuth free tier was discontinued on 2026-04-15; new SDK setups should use OpenAI-compatible authentication or another supported provider. | +| `agents` | `SubagentConfig[]` | - | Configuration for subagents that can be invoked during the session. Subagents are specialized AI agents for specific tasks or domains. | +| `includePartialMessages` | `boolean` | `false` | When `true`, the SDK emits incomplete messages as they are being generated, allowing real-time streaming of the AI's response. | +| `resume` | `string` | - | Resume a previous session by providing its session ID. Equivalent to CLI's `--resume` flag. | +| `sessionId` | `string` | - | Specify a session ID for the new session. Ensures SDK and CLI use the same ID without resuming history. Equivalent to CLI's `--session-id` flag. | > [!note] > For `coreTools`, aliases like `Read`, `Edit`, and `Bash` also work, but invocation specifiers such as `Bash(git *)` are stripped. `coreTools` restricts tool registration, not invocation patterns. diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index ece947f463c..9925ac76590 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -370,7 +370,7 @@ If you are experiencing performance issues with file searching (e.g., with `@` c | `tools.visible` | array of strings | Deferred tool names made visible at startup without requiring `tool_search`. Listed tools appear alongside core tools in the initial session. Merged as a union across scopes. | `undefined` | | | `tools.allowed` | array of strings | **Deprecated.** Use `permissions.allow` instead. Tool names that bypass the confirmation dialog. Automatically migrated to the `permissions` format on first load. | `undefined` | | | `tools.approvalMode` | string | Sets the default approval mode for tool usage. | `auto` | Possible values: `plan` (analyze only, do not modify files or execute commands), `default` (require approval before file edits or shell commands run), `auto-edit` (automatically approve file edits), `auto` (LLM classifier auto-approves safe actions, blocks risky ones), `yolo` (automatically approve all tool calls) | -| `tools.discoveryCommand` | string | Command to run for tool discovery. | `undefined` | | +| `tools.discoveryCommand` | string | Command to run for tool discovery. When the `permissions.allow` registry allowlist is active, a discovered tool is registered only if an allow or ask rule covers it; uncovered discovered tools are hidden from the model instead of being advertised and then rejected at runtime. | `undefined` | | | `tools.callCommand` | string | Defines a custom shell command for calling a specific tool that was discovered using `tools.discoveryCommand`. The shell command must meet the following criteria: It must take function `name` (exactly as in [function declaration](https://ai.google.dev/gemini-api/docs/function-calling#function-declarations)) as first command line argument. It must read function arguments as JSON on `stdin`, analogous to [`functionCall.args`](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference#functioncall). It must return function output as JSON on `stdout`, analogous to [`functionResponse.response.content`](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference#functionresponse). | `undefined` | | | `tools.useRipgrep` | boolean | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` | | | `tools.useBuiltinRipgrep` | boolean | Use the bundled ripgrep binary. When set to `false`, the system-level `rg` command will be used instead. This setting is only effective when `tools.useRipgrep` is `true`. | `true` | | @@ -416,11 +416,11 @@ The permissions system provides fine-grained control over which tools can run, w The first matching rule wins. Rules use the format `"ToolName"` or `"ToolName(specifier)"`. -| Setting | Type | Description | Default | -| ------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------- | ----------- | -| `permissions.allow` | array of strings | Rules for auto-approved tool calls (no confirmation needed). Merged across all scopes (user + project + system). | `undefined` | -| `permissions.ask` | array of strings | Rules for tool calls that always require user confirmation. Takes priority over `allow`. | `undefined` | -| `permissions.deny` | array of strings | Rules for blocked tool calls. Highest priority — overrides both `allow` and `ask`. | `undefined` | +| Setting | Type | Description | Default | +| ------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | +| `permissions.allow` | array of strings | Rules for auto-approved tool calls (no confirmation needed). Merged across all scopes (user + project + system). Also acts as a registry-level allowlist: when at least one valid allow rule is configured (malformed entries do not count, and auto-approval-only sources such as the `--allowed-tools` CLI flag do not activate it), built-in tools not covered by any allow or ask rule are not registered at all — they disappear from `/tools` and their schemas are never sent to the model (MCP tools, the `--json-schema` `structured_output` contract, the plan-mode lifecycle tools `exit_plan_mode` / `enter_plan_mode` / `ask_user_question`, `task_stop`, `tool_search`, and the deferred `computer_use__*` family are exempt). Removing a rule mid-session revokes its auto-approval immediately but does not deregister an already-registered tool; deregistration takes effect on restart. Exception: under the AUTO approval mode, dangerous allow rules are stashed rather than active, so a mid-session removal cannot touch the stash — when AUTO mode is exited the stashed rule is restored and auto-approves again until the session restarts. Requires restart. | `undefined` | +| `permissions.ask` | array of strings | Rules for tool calls that always require user confirmation. Takes priority over `allow`. Ask rules also count toward the registry allowlist: a tool covered by an ask rule stays registered even when an allowlist is active, so "always require confirmation" never silently becomes "tool unavailable". | `undefined` | +| `permissions.deny` | array of strings | Rules for blocked tool calls. Highest priority — overrides both `allow` and `ask`. A whole-tool deny rule (no specifier) also removes the tool from the registry — for built-in tools and tools found via `tools.discoveryCommand`. MCP tools are exempt (their registration path only honours `disabledTools`): hide them with the per-server `excludeTools` / `tools.disabled` filters instead. Deny rules still block MCP tool calls at runtime. | `undefined` | **Tool name aliases (any of these work in rules):** diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 5530fe65426..81b8ee64e6c 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -4038,6 +4038,92 @@ describe('loadCliConfig safe mode', () => { }); }); +describe('loadCliConfig registry allowlist wiring (#9827)', () => { + const originalArgv = process.argv; + + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); + vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); + vi.spyOn(process, 'cwd').mockReturnValue( + path.resolve(path.sep, 'home', 'user', 'project'), + ); + }); + + afterEach(() => { + process.argv = originalArgv; + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it('passes settings.permissions.allow as the registry allowlist', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(); + const settings: Settings = { + permissions: { + allow: ['ReadFile', 'Shell'], + }, + }; + const config = await loadCliConfig(settings, argv, undefined, []); + + expect(config.getRegistryAllowList()).toEqual(['ReadFile', 'Shell']); + }); + + it('does not treat --allowed-tools as a registry allowlist', async () => { + process.argv = ['node', 'script.js', '--allowed-tools', 'ReadFile']; + const argv = await parseArguments(); + const config = await loadCliConfig({}, argv, undefined, []); + + // Auto-approval grant only — the full toolset stays registered + expect(config.getPermissionsAllow()).toContain('ReadFile'); + expect(config.getRegistryAllowList()).toEqual([]); + }); + + it('does not treat the legacy tools.allowed key as a registry allowlist', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(); + const settings: Settings = { + tools: { + allowed: ['ShellTool'], + }, + }; + const config = await loadCliConfig(settings, argv, undefined, []); + + expect(config.getPermissionsAllow()).toContain('ShellTool'); + expect(config.getRegistryAllowList()).toEqual([]); + }); + + it('strips the registry allowlist in safe mode', async () => { + process.argv = ['node', 'script.js', '--safe-mode']; + const argv = await parseArguments(); + const settings: Settings = { + permissions: { + allow: ['ReadFile'], + }, + }; + const config = await loadCliConfig(settings, argv, undefined, []); + + expect(config.getRegistryAllowList()).toEqual([]); + }); + + it('strips the registry allowlist in bare mode', async () => { + // Mirror of the safe-mode test: bare mode drops settings + // `permissions.allow` from the merged allow rules, so an allowlist + // activated from the same settings would run with zero in-force + // membership rules and strip the bare registry's minimal toolset. + process.argv = ['node', 'script.js', '--bare']; + const argv = await parseArguments(); + const settings: Settings = { + permissions: { + allow: ['ReadFile'], + }, + }; + const config = await loadCliConfig(settings, argv, undefined, []); + + expect(config.getRegistryAllowList()).toEqual([]); + }); +}); + describe('loadCliConfig chatCompression', () => { const originalArgv = process.argv; diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 7624924c153..6379739b117 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -2198,6 +2198,16 @@ export async function loadCliConfig( allow: mergedAllow.length > 0 ? mergedAllow : undefined, ask: mergedAsk.length > 0 ? mergedAsk : undefined, deny: mergedDeny.length > 0 ? mergedDeny : undefined, + // Only `settings.permissions.allow` (never `--allowed-tools` nor the + // legacy `tools.allowed` key, which stay pure auto-approval grants) + // activates the registry-level allowlist that hides unlisted built-in + // tools from the model request (#9827). + registryAllowList: + bareMode || safeMode + ? undefined + : settings.permissions?.allow?.length + ? settings.permissions.allow + : undefined, autoMode: bareMode || safeMode ? undefined : settings.permissions?.autoMode, }, diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 3ebe8d81ac1..36f49b7f0d6 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -8231,6 +8231,244 @@ describe('Server Config (config.ts)', () => { }, ); + it.each([ + { label: 'an alias', entry: 'ListFiles' }, + { label: 'the canonical name', entry: ToolNames.LS }, + { label: 'a path specifier', entry: `${ToolNames.LS}(/src)` }, + { label: 'the Read meta-category', entry: 'Read' }, + ])( + 'registers list_directory when covered by permissions.allow via $label (#9827)', + async ({ entry }) => { + const settingsAllow = [entry]; + const config = new Config({ + ...baseParams, + coreTools: undefined, + permissions: { + allow: settingsAllow, + registryAllowList: settingsAllow, + }, + }); + await config.initialize(); + + const registerToolMock = ( + (await vi.importMock('../tools/tool-registry')) as { + ToolRegistry: { prototype: { registerFactory: Mock } }; + } + ).ToolRegistry.prototype.registerFactory; + expect( + (registerToolMock as Mock).mock.calls.map((call) => call[0]), + ).toContain(ToolNames.LS); + }, + ); + + it('does not register list_directory when an active permissions.allow does not cover it (#9827)', async () => { + // `edit` does not cover list_directory (it is not the Read + // meta-category), so the opt-in gate must stay closed even though the + // allowlist is active. + const settingsAllow = [ToolNames.EDIT]; + const config = new Config({ + ...baseParams, + coreTools: undefined, + permissions: { allow: settingsAllow, registryAllowList: settingsAllow }, + }); + await config.initialize(); + + const registerToolMock = ( + (await vi.importMock('../tools/tool-registry')) as { + ToolRegistry: { prototype: { registerFactory: Mock } }; + } + ).ToolRegistry.prototype.registerFactory; + expect( + (registerToolMock as Mock).mock.calls.map((call) => call[0]), + ).not.toContain(ToolNames.LS); + }); + + it('registers list_directory when covered only by an ask rule under an active allowlist (#9827)', async () => { + // Probe scenario: allow:['edit'] activates the allowlist, and + // ask:['ListFiles'] is the ONLY coverage for list_directory. + // `PermissionManager.isToolEnabled('list_directory')` returns true + // (membership counts ask rules — see isCoveredByAllowOrAskRule), so + // the opt-in gate must offer the tool to registerLazy too; otherwise + // the schema is never sent, the ask rule can never fire, and arriving + // calls fail TOOL_NOT_REGISTERED. + const settingsAllow = [ToolNames.EDIT]; + const config = new Config({ + ...baseParams, + coreTools: undefined, + permissions: { + allow: settingsAllow, + registryAllowList: settingsAllow, + ask: ['ListFiles'], + }, + }); + await config.initialize(); + + const registerToolMock = ( + (await vi.importMock('../tools/tool-registry')) as { + ToolRegistry: { prototype: { registerFactory: Mock } }; + } + ).ToolRegistry.prototype.registerFactory; + expect( + (registerToolMock as Mock).mock.calls.map((call) => call[0]), + ).toContain(ToolNames.LS); + }); + + it('does not register list_directory for an ask rule when the allowlist is inactive (#9827)', async () => { + // Ask coverage only gates registration while the registry allowlist + // is active; without it list_directory stays opt-in, so an ask rule + // alone must not change the default-disabled behaviour. + const config = new Config({ + ...baseParams, + coreTools: undefined, + permissions: { ask: ['ListFiles'] }, + }); + await config.initialize(); + + const registerToolMock = ( + (await vi.importMock('../tools/tool-registry')) as { + ToolRegistry: { prototype: { registerFactory: Mock } }; + } + ).ToolRegistry.prototype.registerFactory; + expect( + (registerToolMock as Mock).mock.calls.map((call) => call[0]), + ).not.toContain(ToolNames.LS); + }); + + it('registers list_directory when covered only by a merged (non-settings) allow rule under an active allowlist (#9827)', async () => { + // CLI-shaped wiring: settings `permissions.allow: ['Edit']` activates + // the allowlist, while `--allowed-tools ListFiles` (or the SDK + // `allowedTools` param) lands only in the merged allow set + // (`getPermissionsAllow()`), never in `getRegistryAllowList()`. + // `PermissionManager.isToolEnabled('list_directory')` returns true + // because it counts the merged set, so the opt-in gate must offer the + // tool to registerLazy too — otherwise it silently vanishes from + // `/tools` and the model request while arriving calls fail + // TOOL_NOT_REGISTERED. + const config = new Config({ + ...baseParams, + coreTools: undefined, + permissions: { + allow: [ToolNames.EDIT, 'ListFiles'], + registryAllowList: [ToolNames.EDIT], + }, + }); + await config.initialize(); + + const registerToolMock = ( + (await vi.importMock('../tools/tool-registry')) as { + ToolRegistry: { prototype: { registerFactory: Mock } }; + } + ).ToolRegistry.prototype.registerFactory; + expect( + (registerToolMock as Mock).mock.calls.map((call) => call[0]), + ).toContain(ToolNames.LS); + }); + + it('does not register list_directory when only the merged allow set covers it but no settings allow rule activates the allowlist (#9827)', async () => { + // `--allowed-tools ListFiles` alone reaches `getPermissionsAllow()` + // but not `getRegistryAllowList()`; only settings + // `permissions.allow` rules can ACTIVATE the allowlist, so without + // one the opt-in gate must stay closed and the default-disabled + // behaviour holds. + const config = new Config({ + ...baseParams, + coreTools: undefined, + permissions: { allow: ['ListFiles'], registryAllowList: [] }, + }); + await config.initialize(); + + const registerToolMock = ( + (await vi.importMock('../tools/tool-registry')) as { + ToolRegistry: { prototype: { registerFactory: Mock } }; + } + ).ToolRegistry.prototype.registerFactory; + expect( + (registerToolMock as Mock).mock.calls.map((call) => call[0]), + ).not.toContain(ToolNames.LS); + }); + + it('does not register list_directory when the settings allow list holds only empty entries (#9827)', async () => { + // `permissions.allow: ['']` is schema-valid but degenerate. + // `PermissionManager.initialize` computes activation through + // `parseRules`, which filters empty entries before parsing, so the + // allowlist stays inactive; the opt-in gate must agree — otherwise + // the ask branch would register list_directory while the permission + // system reports the allowlist inactive. + const config = new Config({ + ...baseParams, + coreTools: undefined, + permissions: { + allow: [''], + registryAllowList: [''], + ask: ['ListFiles'], + }, + }); + await config.initialize(); + + const registerToolMock = ( + (await vi.importMock('../tools/tool-registry')) as { + ToolRegistry: { prototype: { registerFactory: Mock } }; + } + ).ToolRegistry.prototype.registerFactory; + expect( + (registerToolMock as Mock).mock.calls.map((call) => call[0]), + ).not.toContain(ToolNames.LS); + }); + + it('does not crash when permissions arrays hold non-string entries (#9827)', async () => { + // Settings load performs no element-type validation (the schema + // declares only `type: 'array'`), and `PermissionManager.initialize`'s + // `parseRules` tolerates falsy entries like `[null]` in the same + // settings file. The opt-in gate scans the same arrays during + // `createToolRegistry`, so it must skip non-string entries the same + // way instead of throwing a `TypeError` and crashing startup. + const config = new Config({ + ...baseParams, + coreTools: undefined, + permissions: { + allow: [null, ToolNames.EDIT] as unknown as string[], + registryAllowList: [null, ToolNames.EDIT] as unknown as string[], + ask: [undefined, 'ListFiles'] as unknown as string[], + }, + }); + await expect(config.initialize()).resolves.not.toThrow(); + // The valid entries still take effect: the settings rule activates + // the allowlist and the ask rule (after the non-string entry is + // skipped) covers list_directory. + const registerToolMock = ( + (await vi.importMock('../tools/tool-registry')) as { + ToolRegistry: { prototype: { registerFactory: Mock } }; + } + ).ToolRegistry.prototype.registerFactory; + expect( + (registerToolMock as Mock).mock.calls.map((call) => call[0]), + ).toContain(ToolNames.LS); + }); + + it('keeps the gate closed when the settings allow list holds only non-string entries (#9827)', async () => { + // `[null]` alone carries no valid rule: `parseRules` filters it out, + // so `PermissionManager.initialize` reports the allowlist inactive — + // the opt-in gate must agree (and must not throw on the entry). + const config = new Config({ + ...baseParams, + coreTools: undefined, + permissions: { + allow: [null] as unknown as string[], + registryAllowList: [null] as unknown as string[], + ask: ['ListFiles'], + }, + }); + await expect(config.initialize()).resolves.not.toThrow(); + const registerToolMock = ( + (await vi.importMock('../tools/tool-registry')) as { + ToolRegistry: { prototype: { registerFactory: Mock } }; + } + ).ToolRegistry.prototype.registerFactory; + expect( + (registerToolMock as Mock).mock.calls.map((call) => call[0]), + ).not.toContain(ToolNames.LS); + }); + it('should ignore coreTools overrides in bare mode', async () => { const config = new Config({ ...baseParams, @@ -8567,6 +8805,122 @@ describe('Server Config (config.ts)', () => { expect(wasGrepToolRegistered).toBe(false); }); + // ── #9827: permissions.allow must shrink the tool schemas sent to the model ── + it('registers only allowlisted tools when permissions.allow is set (#9827)', async () => { + const settingsAllow = [ + 'ReadFile', + 'WriteFile', + 'Edit', + 'Grep', + 'Glob', + 'ListFiles', + 'Shell', + 'WebFetch', + ]; + const params: ConfigParameters = { + ...baseParams, + useRipgrep: false, + coreTools: undefined, + // Mirrors the CLI wiring: merged allow list + the settings-sourced + // subset that activates the registry allowlist. + permissions: { allow: settingsAllow, registryAllowList: settingsAllow }, + }; + const config = new Config(params); + await config.initialize(); + + const registerToolMock = ( + (await vi.importMock('../tools/tool-registry')) as { + ToolRegistry: { prototype: { registerFactory: Mock } }; + } + ).ToolRegistry.prototype.registerFactory; + + const registered = (registerToolMock as Mock).mock.calls.map( + (call) => call[0], + ) as string[]; + + // Allowlisted tools are registered + expect(registered).toContain(ToolNames.READ_FILE); + expect(registered).toContain(ToolNames.WRITE_FILE); + expect(registered).toContain(ToolNames.EDIT); + expect(registered).toContain(ToolNames.GREP); + expect(registered).toContain(ToolNames.GLOB); + expect(registered).toContain(ToolNames.SHELL); + expect(registered).toContain(ToolNames.WEB_FETCH); + + // Unlisted built-ins are NOT registered, so their schemas are never + // sent to the model. The reporter's grammar-breaking tools must all + // be absent; non-core built-ins are gated too. + expect(registered).not.toContain(ToolNames.SEND_MESSAGE); + expect(registered).not.toContain(ToolNames.UPDATE_GOAL); + expect(registered).not.toContain(ToolNames.GET_GOAL); + expect(registered).not.toContain(ToolNames.LOOP_WAKEUP); + expect(registered).not.toContain(ToolNames.READ_MCP_RESOURCE); + expect(registered).not.toContain(ToolNames.AGENT); + expect(registered).not.toContain(ToolNames.TODO_WRITE); + expect(registered).not.toContain(ToolNames.SKILL); + // monitor stays registered: the "Shell" allow rule covers it so the + // shell tool cannot be bypassed by switching to monitor. + expect(registered).toContain(ToolNames.MONITOR); + }); + + it('registers the full built-in set when no permissionsAllow is set (#9827 regression guard)', async () => { + const params: ConfigParameters = { + ...baseParams, + useRipgrep: false, + coreTools: undefined, + }; + const config = new Config(params); + await config.initialize(); + + const registerToolMock = ( + (await vi.importMock('../tools/tool-registry')) as { + ToolRegistry: { prototype: { registerFactory: Mock } }; + } + ).ToolRegistry.prototype.registerFactory; + + const registered = (registerToolMock as Mock).mock.calls.map( + (call) => call[0], + ) as string[]; + + // Without an allowlist nothing is gated at registry level + expect(registered).toContain(ToolNames.SEND_MESSAGE); + expect(registered).toContain(ToolNames.UPDATE_GOAL); + expect(registered).toContain(ToolNames.AGENT); + expect(registered).toContain(ToolNames.TODO_WRITE); + expect(registered).toContain(ToolNames.READ_FILE); + }); + + it('permissions.allow keeps the --exclude-tools (deny) path working (#9827)', async () => { + const settingsAllow = ['ReadFile', 'Shell']; + const params: ConfigParameters = { + ...baseParams, + useRipgrep: false, + coreTools: undefined, + permissions: { + allow: settingsAllow, + registryAllowList: settingsAllow, + deny: ['Shell'], + }, + }; + const config = new Config(params); + await config.initialize(); + + const registerToolMock = ( + (await vi.importMock('../tools/tool-registry')) as { + ToolRegistry: { prototype: { registerFactory: Mock } }; + } + ).ToolRegistry.prototype.registerFactory; + + const registered = (registerToolMock as Mock).mock.calls.map( + (call) => call[0], + ) as string[]; + + expect(registered).toContain(ToolNames.READ_FILE); + // deny wins over allowlist membership + expect(registered).not.toContain(ToolNames.SHELL); + expect(registered).not.toContain(ToolNames.SEND_MESSAGE); + }); + describe('with minified tool class names', () => { beforeEach(() => { Object.defineProperty( diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 82e62d5a34f..35517db7a52 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -112,7 +112,10 @@ import { createDenialState, resetDenialState, } from '../permissions/denialTracking.js'; -import { parseRule } from '../permissions/rule-parser.js'; +import { + parseRule, + toolMatchesRuleToolName, +} from '../permissions/rule-parser.js'; import { SubagentManager } from '../subagents/subagent-manager.js'; import type { SubagentConfig } from '../subagents/types.js'; import { BackgroundTaskRegistry } from '../agents/background-tasks.js'; @@ -1057,6 +1060,21 @@ export interface ConfigParameters { allow?: string[]; ask?: string[]; deny?: string[]; + /** + * The subset of `allow` that comes from `settings.permissions.allow` + * (never `--allowed-tools`, the SDK `allowedTools` param, or the + * legacy `tools.allowed` key). When it contains at least one valid + * rule, the registry-level allowlist activates: built-in tools not + * covered by any allow or ask rule are excluded from registration, so + * their schemas are never sent to the model (MCP tools, the + * `--json-schema` `structured_output` contract, the plan-mode + * lifecycle tools, and the `computer_use__*` family are exempt) + * (#9827). Only this subset can ACTIVATE the allowlist; while it is + * active, `--allowed-tools` / SDK `allowedTools` rules are merged + * into the effective allow set and still count toward coverage, + * keeping covered built-ins registered. + */ + registryAllowList?: string[]; /** Settings consumed by the AUTO approval mode classifier. */ autoMode?: AutoModeSettings; }; @@ -1972,6 +1990,7 @@ export class Config { private readonly permissionsAllow: string[]; private readonly permissionsAsk: string[]; private readonly permissionsDeny: string[]; + private readonly permissionsRegistryAllowList: string[]; private readonly permissionsAutoMode: AutoModeSettings; private readonly toolDiscoveryCommand: string | undefined; private readonly toolCallCommand: string | undefined; @@ -2314,6 +2333,8 @@ export class Config { this.permissionsAllow = params.permissions?.allow || []; this.permissionsAsk = params.permissions?.ask || []; this.permissionsDeny = params.permissions?.deny || []; + this.permissionsRegistryAllowList = + params.permissions?.registryAllowList || []; this.permissionsAutoMode = params.permissions?.autoMode ?? {}; this.toolInvocationGuard = params.toolInvocationGuard; this.toolDiscoveryCommand = params.toolDiscoveryCommand; @@ -5660,6 +5681,17 @@ export class Config { return this.permissionsAsk; } + /** + * Returns the allow rules that come from `settings.permissions.allow` + * only — never `--allowed-tools` / the SDK `allowedTools` param (merged + * into `getPermissionsAllow()` above) nor the legacy `tools.allowed` + * key. Consumed by `PermissionManager` to decide whether the + * registry-level allowlist is active (#9827). + */ + getRegistryAllowList(): string[] { + return this.permissionsRegistryAllowList; + } + /** * Returns the merged deny-rules for PermissionManager. * @@ -7102,18 +7134,74 @@ export class Config { /** * Whether the built-in `list_directory` tool is enabled. Opt-in: the tool - * is disabled by default and turns on either through the - * `tools.listDirectory.enabled` setting or by being explicitly listed in - * the `coreTools` allowlist. Entries are normalised with `parseRule` — the - * same parser `PermissionManager` uses to build its allowlist — so alias - * forms (`ListFiles`) and specifier forms (`list_directory(/src)`) match. + * is disabled by default and turns on through the + * `tools.listDirectory.enabled` setting, by being explicitly listed in the + * `coreTools` allowlist, or by being covered by an allow OR ask rule while + * the `permissions.allow` registry allowlist is active (#9827). Coverage + * scans the merged allow set (`getPermissionsAllow()` — settings + + * `--allowed-tools` + SDK `allowedTools` + legacy `tools.allowed`) so it + * counts exactly what `PermissionManager.isToolEnabled()` counts, while + * activation still comes only from `settings.permissions.allow` rules + * (`getRegistryAllowList()`), ignoring empty/whitespace-only entries the + * same way `PermissionManager.initialize`'s `parseRules` does (and + * skipping non-string entries, which settings load never type-validates). + * Entries are + * normalised with `parseRule` — the same parser `PermissionManager` uses — + * so alias forms (`ListFiles`) and specifier forms (`list_directory(/src)`) + * match; the check honours meta-categories (`Read`) via + * `toolMatchesRuleToolName`, matching the coverage semantics of the + * registry gate itself (`isCoveredByAllowOrAskRule`, which counts ask + * rules too). */ isLsToolEnabled(): boolean { if (this.lsToolEnabled) return true; - return ( + if ( this.getCoreTools()?.some( (name) => parseRule(name).toolName === ToolNames.LS, - ) ?? false + ) ?? + false + ) { + return true; + } + // `permissions.allow` registry allowlist (#9827): without these branches + // an allowlisted tool passes `PermissionManager.isToolEnabled()` but the + // registry never registers it, so it silently vanishes from `/tools` and + // the model request while calls to it fail with TOOL_NOT_REGISTERED. + const coveredByPermissionRule = (raw: string): boolean => { + // Mirror the `parseRules` guard: settings load performs no + // element-type validation (the schema declares only `type: 'array'`), + // so a stray non-string/empty entry must be skipped here, never + // crash registry construction (#9827). + if (typeof raw !== 'string' || raw.trim() === '') return false; + const rule = parseRule(raw); + return ( + !rule.invalid && toolMatchesRuleToolName(rule.toolName, ToolNames.LS) + ); + }; + // Activation comes only from settings `permissions.allow` rules and + // requires at least one non-empty valid entry — exactly how + // `PermissionManager.initialize` computes it (`parseRules` filters empty + // entries before parsing, and `parseRule('')` carries no `invalid` flag), + // so a degenerate `[""]` leaves the allowlist inactive in both places. + // The `typeof` guard mirrors that filter for non-string entries too: + // `PermissionManager.initialize` tolerates them in the same settings + // file, so this gate must not become a new startup crash (#9827). + const allowListActive = this.getRegistryAllowList().some( + (raw) => + typeof raw === 'string' && raw.trim() !== '' && !parseRule(raw).invalid, + ); + if (!allowListActive) return false; + // Coverage mirrors `PermissionManager.isToolEnabled`: the merged allow + // set and ask rules both count while the allowlist is active, so a tool + // the permission system reports as enabled is genuinely offered to + // `registerLazy` (#9827). Ask-only coverage counts for exactly the same + // reason it counts in `PermissionManager.isCoveredByAllowOrAskRule` — + // otherwise the ask rule could never fire and arriving calls would fail + // TOOL_NOT_REGISTERED. Gating both on the allowlist actually being + // active keeps the default opt-in behaviour when it is not. + return ( + this.getPermissionsAllow().some(coveredByPermissionRule) || + this.getPermissionsAsk().some(coveredByPermissionRule) ); } diff --git a/packages/core/src/core/coreToolScheduler.test.ts b/packages/core/src/core/coreToolScheduler.test.ts index cfd08de4cca..8b58cf82779 100644 --- a/packages/core/src/core/coreToolScheduler.test.ts +++ b/packages/core/src/core/coreToolScheduler.test.ts @@ -42,6 +42,8 @@ import { SkillTool } from '../tools/skill.js'; import { StructuredToolError } from '../tools/priorReadEnforcement.js'; import { ToolNames, ToolNamesMigration } from '../tools/tool-names.js'; import { ExitPlanModeTool } from '../tools/exitPlanMode.js'; +import { createMemoryScopedAgentConfig } from '../memory/memory-scoped-agent-config.js'; +import type { PermissionManager } from '../permissions/permission-manager.js'; import type { CompletedToolCall, ExecutingToolCall, @@ -834,6 +836,11 @@ describe('CoreToolScheduler', () => { promptId: string, fallbackOwner?: string, ) => string; + permissionManager?: { + isToolEnabled: (name: string) => Promise; + findMatchingDenyRule: (ctx: unknown) => string | undefined; + isPermissionsAllowListActive: () => boolean; + }; }) { const ensureTool = vi.fn( async (name: string) => @@ -866,6 +873,7 @@ describe('CoreToolScheduler', () => { setApprovalMode: options.setApprovalMode ?? vi.fn(), getPermissionsAllow: () => [], getPermissionsDeny: options.getPermissionsDeny ?? (() => undefined), + getPermissionManager: () => options.permissionManager, getContentGeneratorConfig: () => ({ model: 'test-model', authType: 'gemini', @@ -3520,6 +3528,377 @@ describe('CoreToolScheduler', () => { expect(ensureTool).not.toHaveBeenCalled(); }); + it('attributes a registry-allowlist miss to permissions.allow, not a deny rule (#9827)', async () => { + // When isToolEnabled rejects because the tool is not covered by the + // active permissions.allow allowlist, findMatchingDenyRule finds + // nothing — nothing was ever asked or declined. The error must point + // at the real config knob instead of citing a nonexistent deny rule. + const execute = vi.fn().mockResolvedValue({ + llmContent: 'sent', + returnDisplay: 'sent', + }); + const toolsByName = new Map([ + [ + ToolNames.SEND_MESSAGE, + new MockTool({ name: ToolNames.SEND_MESSAGE, execute }), + ], + ]); + const permissionManager = { + isToolEnabled: vi.fn().mockResolvedValue(false), + findMatchingDenyRule: vi.fn().mockReturnValue(undefined), + isPermissionsAllowListActive: vi.fn().mockReturnValue(true), + isCoveredByAllowOrAskRule: vi.fn().mockReturnValue(false), + }; + const { scheduler, onAllToolCallsComplete } = + createSchedulerForLegacyToolTests({ + toolsByName, + permissionManager, + }); + + await scheduler.schedule( + [ + { + callId: 'allowlist-miss', + name: ToolNames.SEND_MESSAGE, + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-allowlist-miss', + }, + ], + new AbortController().signal, + ); + + expect(onAllToolCallsComplete).toHaveBeenCalled(); + const completedCalls = onAllToolCallsComplete.mock + .calls[0][0] as ToolCall[]; + const completedCall = completedCalls[0]; + expect(completedCall.status).toBe('error'); + if (completedCall.status === 'error') { + expect(completedCall.response.errorType).toBe( + ToolErrorType.EXECUTION_DENIED, + ); + const message = completedCall.response.error?.message ?? ''; + expect(message).toContain('permissions.allow'); + expect(message).toContain(ToolNames.SEND_MESSAGE); + expect(message).not.toContain('declined'); + expect(message).not.toContain('deny rule'); + } + expect(execute).not.toHaveBeenCalled(); + }); + + it('lets a matching deny rule win over the allowlist-miss attribution (#9827)', async () => { + // The deny-rule arm comes FIRST in the message branch, and it must: + // a tool rejected by a deny rule is by definition not covered by any + // allow/ask rule, so under an active allowlist BOTH the deny arm and + // the allowlist-miss arm could fire. The denial is real here — + // something was declined — so the message must cite the matching deny + // rule, not the "not covered by permissions.allow" attribution (whose + // remediation would be wrong: an allow rule cannot override a deny + // rule, the only fix is removing the deny rule itself). + const execute = vi.fn().mockResolvedValue({ + llmContent: 'sent', + returnDisplay: 'sent', + }); + const toolsByName = new Map([ + [ + ToolNames.SEND_MESSAGE, + new MockTool({ name: ToolNames.SEND_MESSAGE, execute }), + ], + ]); + const permissionManager = { + isToolEnabled: vi.fn().mockResolvedValue(false), + findMatchingDenyRule: vi.fn().mockReturnValue(ToolNames.SEND_MESSAGE), + // Arm the allowlist-miss branch too so this test pins the + // if/else-if ORDERING, not just the deny arm in isolation. + isPermissionsAllowListActive: vi.fn().mockReturnValue(true), + isCoveredByAllowOrAskRule: vi.fn().mockReturnValue(false), + }; + const { scheduler, onAllToolCallsComplete } = + createSchedulerForLegacyToolTests({ + toolsByName, + permissionManager, + }); + + await scheduler.schedule( + [ + { + callId: 'deny-rule-beats-allowlist', + name: ToolNames.SEND_MESSAGE, + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-deny-rule-beats-allowlist', + }, + ], + new AbortController().signal, + ); + + expect(onAllToolCallsComplete).toHaveBeenCalled(); + const completedCalls = onAllToolCallsComplete.mock + .calls[0][0] as ToolCall[]; + const completedCall = completedCalls[0]; + expect(completedCall.status).toBe('error'); + if (completedCall.status === 'error') { + expect(completedCall.response.errorType).toBe( + ToolErrorType.EXECUTION_DENIED, + ); + const message = completedCall.response.error?.message ?? ''; + expect(message).toBe( + `Qwen Code requires permission to use "${ToolNames.SEND_MESSAGE}", but that permission was declined. Matching deny rule: "${ToolNames.SEND_MESSAGE}".`, + ); + expect(message).not.toContain('permissions.allow'); + } + expect(execute).not.toHaveBeenCalled(); + }); + + it('lets a matching deny rule win over the generic declined fallback without an active allowlist (#9827)', async () => { + // Without an active allowlist only the deny arm and the generic + // fallback arm can fire; the matching rule must still surface so the + // user sees WHICH configured rule declined the call instead of the + // bare "permission was declined" message. + const execute = vi.fn().mockResolvedValue({ + llmContent: 'sent', + returnDisplay: 'sent', + }); + const toolsByName = new Map([ + [ + ToolNames.SEND_MESSAGE, + new MockTool({ name: ToolNames.SEND_MESSAGE, execute }), + ], + ]); + const permissionManager = { + isToolEnabled: vi.fn().mockResolvedValue(false), + findMatchingDenyRule: vi.fn().mockReturnValue(ToolNames.SEND_MESSAGE), + isPermissionsAllowListActive: vi.fn().mockReturnValue(false), + }; + const { scheduler, onAllToolCallsComplete } = + createSchedulerForLegacyToolTests({ + toolsByName, + permissionManager, + }); + + await scheduler.schedule( + [ + { + callId: 'deny-rule-beats-fallback', + name: ToolNames.SEND_MESSAGE, + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-deny-rule-beats-fallback', + }, + ], + new AbortController().signal, + ); + + expect(onAllToolCallsComplete).toHaveBeenCalled(); + const completedCalls = onAllToolCallsComplete.mock + .calls[0][0] as ToolCall[]; + const completedCall = completedCalls[0]; + expect(completedCall.status).toBe('error'); + if (completedCall.status === 'error') { + expect(completedCall.response.errorType).toBe( + ToolErrorType.EXECUTION_DENIED, + ); + const message = completedCall.response.error?.message ?? ''; + expect(message).toBe( + `Qwen Code requires permission to use "${ToolNames.SEND_MESSAGE}", but that permission was declined. Matching deny rule: "${ToolNames.SEND_MESSAGE}".`, + ); + expect(message).toContain('Matching deny rule'); + } + expect(execute).not.toHaveBeenCalled(); + }); + + it('keeps the legacy declined message when a covered tool is rejected by another gate under an active allowlist (#9827)', async () => { + // While the allowlist is active, a COVERED tool can still fail + // isToolEnabled through a different gate — the witness is the legacy + // coreTools allowlist (`allow: ['Edit']` + `coreTools: ['read_file']`: + // `edit` passes the allowlist gate but fails the coreTools check). + // There "not covered by any permissions.allow rule" is factually wrong + // and its remediation a no-op, so the message must fall back to the + // generic declined one instead of citing the allowlist. + const execute = vi.fn().mockResolvedValue({ + llmContent: 'sent', + returnDisplay: 'sent', + }); + const toolsByName = new Map([ + [ToolNames.EDIT, new MockTool({ name: ToolNames.EDIT, execute })], + ]); + const permissionManager = { + isToolEnabled: vi.fn().mockResolvedValue(false), + findMatchingDenyRule: vi.fn().mockReturnValue(undefined), + isPermissionsAllowListActive: vi.fn().mockReturnValue(true), + isCoveredByAllowOrAskRule: vi.fn().mockReturnValue(true), + }; + const { scheduler, onAllToolCallsComplete } = + createSchedulerForLegacyToolTests({ + toolsByName, + permissionManager, + }); + + await scheduler.schedule( + [ + { + callId: 'covered-other-gate', + name: ToolNames.EDIT, + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-covered-other-gate', + }, + ], + new AbortController().signal, + ); + + expect(onAllToolCallsComplete).toHaveBeenCalled(); + const completedCalls = onAllToolCallsComplete.mock + .calls[0][0] as ToolCall[]; + const completedCall = completedCalls[0]; + expect(completedCall.status).toBe('error'); + if (completedCall.status === 'error') { + expect(completedCall.response.errorType).toBe( + ToolErrorType.EXECUTION_DENIED, + ); + expect(completedCall.response.error?.message).toBe( + 'Qwen Code requires permission to use "edit", but that permission was declined.', + ); + const message = completedCall.response.error?.message ?? ''; + expect(message).not.toContain('permissions.allow'); + } + expect(execute).not.toHaveBeenCalled(); + }); + + it('keeps the legacy declined message when the tool is disabled without an active allowlist (#9827)', async () => { + const execute = vi.fn().mockResolvedValue({ + llmContent: 'sent', + returnDisplay: 'sent', + }); + const toolsByName = new Map([ + [ + ToolNames.SEND_MESSAGE, + new MockTool({ name: ToolNames.SEND_MESSAGE, execute }), + ], + ]); + const permissionManager = { + isToolEnabled: vi.fn().mockResolvedValue(false), + findMatchingDenyRule: vi.fn().mockReturnValue(undefined), + isPermissionsAllowListActive: vi.fn().mockReturnValue(false), + }; + const { scheduler, onAllToolCallsComplete } = + createSchedulerForLegacyToolTests({ + toolsByName, + permissionManager, + }); + + await scheduler.schedule( + [ + { + callId: 'disabled-no-allowlist', + name: ToolNames.SEND_MESSAGE, + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-disabled-no-allowlist', + }, + ], + new AbortController().signal, + ); + + expect(onAllToolCallsComplete).toHaveBeenCalled(); + const completedCalls = onAllToolCallsComplete.mock + .calls[0][0] as ToolCall[]; + const completedCall = completedCalls[0]; + expect(completedCall.status).toBe('error'); + if (completedCall.status === 'error') { + expect(completedCall.response.error?.message).toBe( + 'Qwen Code requires permission to use "send_message", but that permission was declined.', + ); + } + expect(execute).not.toHaveBeenCalled(); + }); + + it('keeps a memory-scoped shim rejection on the pre-#9827 declined message instead of throwing (#9827)', async () => { + // Production installs the memory-scoped PermissionManager shim via + // `as unknown as PermissionManager` (memory-scoped-agent-config.ts); + // the cast used to hide that the shim lacked + // `isPermissionsAllowListActive`, so a shim-rejected call under an + // active allowlist threw TypeError in the message branch below and + // surfaced as an UNHANDLED_EXCEPTION tool error instead of the + // designed permission error. The shim also lacks + // `isCoveredByAllowOrAskRule`, so coverage is unknown for it and the + // fallback must stay on the pre-#9827 declined message — never the + // allowlist attribution, which would be wrong for a COVERED tool + // rejected by a different gate (e.g. the legacy coreTools allowlist). + // Drive the REAL shim through the scheduler to pin the end-to-end + // path. + const execute = vi.fn().mockResolvedValue({ + llmContent: 'sent', + returnDisplay: 'sent', + }); + const toolsByName = new Map([ + [ + ToolNames.SEND_MESSAGE, + new MockTool({ name: ToolNames.SEND_MESSAGE, execute }), + ], + ]); + const basePm = { + isToolEnabled: vi.fn().mockResolvedValue(false), + findMatchingDenyRule: vi.fn().mockReturnValue(undefined), + hasMatchingAskRule: vi.fn().mockReturnValue(false), + hasRelevantRules: vi.fn().mockReturnValue(false), + evaluate: vi.fn().mockResolvedValue('deny'), + isPermissionsAllowListActive: vi.fn().mockReturnValue(true), + }; + const scopedConfig = createMemoryScopedAgentConfig( + { + getPermissionManager: () => basePm as unknown as PermissionManager, + } as Config, + os.tmpdir(), + ); + const shimPm = scopedConfig.getPermissionManager(); + if (!shimPm) { + throw new Error( + 'createMemoryScopedAgentConfig must install a PermissionManager', + ); + } + const { scheduler, onAllToolCallsComplete } = + createSchedulerForLegacyToolTests({ + toolsByName, + permissionManager: shimPm as unknown as { + isToolEnabled: (name: string) => Promise; + findMatchingDenyRule: (ctx: unknown) => string | undefined; + isPermissionsAllowListActive: () => boolean; + }, + }); + + await scheduler.schedule( + [ + { + callId: 'shim-allowlist-miss', + name: ToolNames.SEND_MESSAGE, + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-shim-allowlist-miss', + }, + ], + new AbortController().signal, + ); + + expect(onAllToolCallsComplete).toHaveBeenCalled(); + const completedCalls = onAllToolCallsComplete.mock + .calls[0][0] as ToolCall[]; + const completedCall = completedCalls[0]; + expect(completedCall.status).toBe('error'); + if (completedCall.status === 'error') { + expect(completedCall.response.errorType).toBe( + ToolErrorType.EXECUTION_DENIED, + ); + const message = completedCall.response.error?.message ?? ''; + expect(message).toBe( + 'Qwen Code requires permission to use "send_message", but that permission was declined.', + ); + expect(message).not.toContain('permissions.allow'); + expect(message).not.toContain('UNHANDLED_EXCEPTION'); + } + expect(execute).not.toHaveBeenCalled(); + }); + it('preserves cancellation when permission evaluation resolves after abort', async () => { toolSpanRecords.length = 0; const abortController = new AbortController(); diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index 4f79123a781..d86ae260042 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -2470,10 +2470,36 @@ export class CoreToolScheduler { const matchingRule = pm.findMatchingDenyRule({ toolName: canonicalName, }); - const ruleInfo = matchingRule - ? ` Matching deny rule: "${matchingRule}".` - : ''; - const permissionErrorMessage = `Qwen Code requires permission to use "${reqInfo.name}", but that permission was declined.${ruleInfo}`; + let permissionErrorMessage: string; + if (matchingRule) { + permissionErrorMessage = `Qwen Code requires permission to use "${reqInfo.name}", but that permission was declined. Matching deny rule: "${matchingRule}".`; + } else if ( + pm.isPermissionsAllowListActive() && + // Only attribute the miss to `permissions.allow` when the tool + // is genuinely uncovered. While the allowlist is active a + // COVERED tool can still be rejected by a different gate — + // e.g. the legacy `coreTools` (`--core-tools` / `tools.core`) + // allowlist — and there the "add a permissions.allow rule" + // advice is factually wrong and a no-op (#9827). The optional + // call keeps scoped PermissionManager shims (installed via + // `as unknown as PermissionManager`, e.g. + // memory-scoped-agent-config.ts) from throwing until they grow + // the delegation; when coverage is unknown the fallback stays + // on the pre-#9827 message rather than risk the wrong + // attribution (#9827). + (typeof pm.isCoveredByAllowOrAskRule === 'function' + ? !pm.isCoveredByAllowOrAskRule(canonicalName) + : false) + ) { + // The tool was rejected by the `permissions.allow` registry + // allowlist, not by any deny rule: it is not covered by an + // allow/ask rule and so was never registered. Point at the + // real config knob instead of a denial that never happened + // (nothing was ever asked or declined on this path). + permissionErrorMessage = `"${reqInfo.name}" is not covered by any permissions.allow rule in the active registry allowlist, so the tool is not available. Add a rule covering it to settings permissions.allow (or permissions.ask) and restart to re-enable it.`; + } else { + permissionErrorMessage = `Qwen Code requires permission to use "${reqInfo.name}", but that permission was declined.`; + } newToolCalls.push({ status: 'error', request: reqInfo, diff --git a/packages/core/src/memory/memory-scoped-agent-config.test.ts b/packages/core/src/memory/memory-scoped-agent-config.test.ts index a1978aab330..6bfd3dda3ea 100644 --- a/packages/core/src/memory/memory-scoped-agent-config.test.ts +++ b/packages/core/src/memory/memory-scoped-agent-config.test.ts @@ -580,6 +580,32 @@ describe('createMemoryScopedAgentConfig', () => { }), ).resolves.toBe('deny'); }); + + it('delegates isPermissionsAllowListActive to the base PM so the scheduler message branch never throws (#9827)', () => { + // The scheduler's permission-denied message branch calls + // `isPermissionsAllowListActive()` on whatever + // `getPermissionManager()` returns. Before this shim exposed the + // method, a shim-rejected call under an active allowlist threw + // `TypeError: pm.isPermissionsAllowListActive is not a function` + // instead of producing the designed permission error. + const basePm: Pick = { + isPermissionsAllowListActive: vi.fn().mockReturnValue(true), + }; + const active = permissionManager( + createMemoryScopedAgentConfig( + { + getPermissionManager: () => basePm as PermissionManager, + } as Config, + projectRoot, + ), + ); + expect(active.isPermissionsAllowListActive()).toBe(true); + + const withoutBase = permissionManager( + createMemoryScopedAgentConfig({} as Config, projectRoot), + ); + expect(withoutBase.isPermissionsAllowListActive()).toBe(false); + }); }); describe('isAllowedMemoryPath with a symlinked project root', () => { diff --git a/packages/core/src/memory/memory-scoped-agent-config.ts b/packages/core/src/memory/memory-scoped-agent-config.ts index a1cd672b48d..75caccd4e56 100644 --- a/packages/core/src/memory/memory-scoped-agent-config.ts +++ b/packages/core/src/memory/memory-scoped-agent-config.ts @@ -28,6 +28,7 @@ type MemoryScopedPermissionManager = Pick< | 'findMatchingDenyRule' | 'hasMatchingAskRule' | 'hasRelevantRules' + | 'isPermissionsAllowListActive' | 'isToolEnabled' >; @@ -401,6 +402,14 @@ export function createMemoryScopedAgentConfig( } return true; }, + // The scheduler's permission-denied message branch calls this on + // whatever `getPermissionManager()` returns (#9827). Without the + // delegation a shim-rejected call under an active allowlist threw + // `TypeError: ... is not a function` instead of reaching the + // designed permission error. + isPermissionsAllowListActive(): boolean { + return basePm?.isPermissionsAllowListActive() ?? false; + }, }; const scopedConfig = Object.create(config) as Config; diff --git a/packages/core/src/memory/skillReviewAgentPlanner.test.ts b/packages/core/src/memory/skillReviewAgentPlanner.test.ts index 0839a3f05ca..0e0b6615e38 100644 --- a/packages/core/src/memory/skillReviewAgentPlanner.test.ts +++ b/packages/core/src/memory/skillReviewAgentPlanner.test.ts @@ -18,6 +18,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Config } from '../config/config.js'; +import type { PermissionManager } from '../permissions/permission-manager.js'; import { AUTO_SKILL_DIR_PREFIX, buildTaskPrompt, @@ -144,6 +145,36 @@ describe('skillReviewAgentPlanner — write_file collision deny (#4437)', () => expect(decision).toBe('allow'); }); + it('delegates isPermissionsAllowListActive to the base PM so the scheduler message branch never throws (#9827)', () => { + // The scheduler's permission-denied message branch calls + // `isPermissionsAllowListActive()` on whatever + // `getPermissionManager()` returns. Before this shim exposed the + // method, a shim-rejected call under an active allowlist threw + // `TypeError: pm.isPermissionsAllowListActive is not a function` + // instead of producing the designed permission error. + const basePm: Pick = { + isPermissionsAllowListActive: vi.fn().mockReturnValue(true), + }; + const scoped = createSkillScopedAgentConfig( + { + getProjectRoot: () => projectRoot, + getPermissionManager: () => basePm as PermissionManager, + } as unknown as Config, + projectRoot, + ); + const pm = scoped.getPermissionManager(); + if (!pm) { + throw new Error( + 'createSkillScopedAgentConfig must install a PermissionManager', + ); + } + expect(pm.isPermissionsAllowListActive()).toBe(true); + + // No base PM: the shim reports the allowlist as inactive instead of + // throwing. + expect(scopedPm(projectRoot).isPermissionsAllowListActive()).toBe(false); + }); + it('denies write_file when the directory name is already archived', async () => { const directoryName = 'auto-skill-retired'; await fs.mkdir( diff --git a/packages/core/src/memory/skillReviewAgentPlanner.ts b/packages/core/src/memory/skillReviewAgentPlanner.ts index 63cac2ea7db..05bbeb0e284 100644 --- a/packages/core/src/memory/skillReviewAgentPlanner.ts +++ b/packages/core/src/memory/skillReviewAgentPlanner.ts @@ -51,6 +51,7 @@ type SkillScopedPermissionManager = Pick< | 'findMatchingDenyRule' | 'hasMatchingAskRule' | 'hasRelevantRules' + | 'isPermissionsAllowListActive' | 'isToolEnabled' >; @@ -254,6 +255,14 @@ export function createSkillScopedAgentConfig( if (basePm) return basePm.isToolEnabled(toolName); return true; }, + // The scheduler's permission-denied message branch calls this on + // whatever `getPermissionManager()` returns (#9827). Without the + // delegation a shim-rejected call under an active allowlist threw + // `TypeError: ... is not a function` instead of reaching the + // designed permission error. + isPermissionsAllowListActive(): boolean { + return basePm?.isPermissionsAllowListActive() ?? false; + }, }; const scopedConfig = Object.create(config) as Config; diff --git a/packages/core/src/permissions/permission-manager.test.ts b/packages/core/src/permissions/permission-manager.test.ts index 15964b89746..37694cd08e9 100644 --- a/packages/core/src/permissions/permission-manager.test.ts +++ b/packages/core/src/permissions/permission-manager.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -24,10 +24,24 @@ import { buildPermissionRules, getRuleDisplayName, buildHumanReadableRuleLabel, + TOOL_NAME_ALIASES, } from './rule-parser.js'; import { PermissionManager } from './permission-manager.js'; import type { PermissionManagerConfig } from './permission-manager.js'; import { normalizeToolNameForProvider } from '../utils/tool-name-utils.js'; +import { ToolNames, ToolDisplayNames } from '../tools/tool-names.js'; + +const debugLoggerMock = vi.hoisted(() => ({ + isEnabled: vi.fn().mockReturnValue(false), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +})); + +vi.mock('../utils/debugLogger.js', () => ({ + createDebugLogger: () => debugLoggerMock, +})); // ─── resolveToolName ───────────────────────────────────────────────────────── @@ -83,6 +97,41 @@ describe('resolveToolName', () => { }); }); +// ─── resolveToolName exhaustiveness (#9827) ───────────────────────────────── + +describe('resolveToolName exhaustiveness (#9827)', () => { + // Every built-in tool's canonical name AND display name must resolve + // through TOOL_NAME_ALIASES. A tool added to tool-names.ts without a + // matching rule-parser alias entry silently never matches any permission + // rule — the exact #9827 bug class — and under the registry allowlist + // such a missed entry now also breaks allowlist coverage (the rule + // parses valid, activates the allowlist, yet covers nothing). Let drift + // fail CI instead of failing silently for a user. + it.each( + Object.entries(ToolDisplayNames).map(([key, displayName]) => ({ + key, + displayName, + canonicalName: ToolNames[key as keyof typeof ToolNames], + })), + )( + 'covers $key ($displayName -> $canonicalName)', + ({ displayName, canonicalName }) => { + expect(canonicalName).toBeDefined(); + // The canonical name itself is a valid rule spelling. + expect(resolveToolName(canonicalName)).toBe(canonicalName); + // The /tools display name — the spelling users copy into rules — + // must resolve to the canonical tool. + expect(resolveToolName(displayName)).toBe(canonicalName); + }, + ); + + it('registers every canonical tool name in the alias map', () => { + for (const canonicalName of Object.values(ToolNames)) { + expect(TOOL_NAME_ALIASES[canonicalName]).toBe(canonicalName); + } + }); +}); + // ─── getSpecifierKind ──────────────────────────────────────────────────────── describe('getSpecifierKind', () => { @@ -1630,6 +1679,13 @@ function makeConfig( projectRoot: string; cwd: string; approvalMode: string; + /** + * Settings-sourced allow rules (the registry-allowlist activator). + * Defaults to `permissionsAllow`, mirroring the CLI path where the + * merged list is built from settings; pass [] to simulate rules that + * come only from `--allowed-tools` / the SDK `allowedTools` param. + */ + registryAllowList: string[]; }> = {}, ): PermissionManagerConfig { return { @@ -1637,6 +1693,7 @@ function makeConfig( getPermissionsAsk: () => opts.permissionsAsk, getPermissionsDeny: () => opts.permissionsDeny, getCoreTools: () => opts.coreTools, + getRegistryAllowList: () => opts.registryAllowList ?? opts.permissionsAllow, getProjectRoot: () => opts.projectRoot ?? '/project', getCwd: () => opts.cwd ?? '/project', getApprovalMode: () => opts.approvalMode ?? 'default', @@ -2529,15 +2586,18 @@ describe('PermissionManager', () => { expect(await pm.isToolEnabled('run_shell_command')).toBe(false); // in list but denied }); - it('permissionsAllow alone does NOT restrict unlisted tools (not a whitelist)', async () => { - // This verifies the previous incorrect behavior is gone: permissionsAllow - // only means "auto-approve", it does NOT block unlisted tools. + it('permissionsAllow acts as a registry-level allowlist (docs migration semantic)', async () => { + // Per docs/users/configuration/settings.md the `tools.core` whitelist + // migrates to `permissions.allow`: "unlisted tools are disabled at + // registry level". Tools not covered by any allow rule must therefore + // not be registered — which is what keeps their schemas out of the + // model request (#9827). pm = new PermissionManager( makeConfig({ permissionsAllow: ['read_file'] }), ); pm.initialize(); expect(await pm.isToolEnabled('read_file')).toBe(true); - expect(await pm.isToolEnabled('run_shell_command')).toBe(true); // not denied, just unreviewed + expect(await pm.isToolEnabled('run_shell_command')).toBe(false); // unlisted → not registered }); // Non-core tools bypass coreTools allowlist @@ -2606,6 +2666,510 @@ describe('PermissionManager', () => { }); }); + describe('permissions.allow registry allowlist (#9827)', () => { + it('unlisted built-in tools are disabled, including non-core ones', async () => { + // The reporter's configuration from #9827: only these tools may be + // registered; send_message / update_goal / loop_wakeup / + // read_mcp_resource (whose large maxLength schemas break llama.cpp + // grammar compilation) must NOT reach the model request. + pm = new PermissionManager( + makeConfig({ + permissionsAllow: [ + 'ReadFile', + 'WriteFile', + 'Edit', + 'Grep', + 'Glob', + 'ListFiles', + 'Shell', + 'WebFetch', + ], + }), + ); + pm.initialize(); + expect(pm.isPermissionsAllowListActive()).toBe(true); + // Listed (via display-name aliases) + expect(await pm.isToolEnabled('read_file')).toBe(true); + expect(await pm.isToolEnabled('write_file')).toBe(true); + expect(await pm.isToolEnabled('edit')).toBe(true); + expect(await pm.isToolEnabled('grep_search')).toBe(true); + expect(await pm.isToolEnabled('glob')).toBe(true); + expect(await pm.isToolEnabled('list_directory')).toBe(true); + expect(await pm.isToolEnabled('run_shell_command')).toBe(true); + expect(await pm.isToolEnabled('web_fetch')).toBe(true); + // Unlisted — core AND non-core built-ins alike + expect(await pm.isToolEnabled('send_message')).toBe(false); + expect(await pm.isToolEnabled('update_goal')).toBe(false); + expect(await pm.isToolEnabled('get_goal')).toBe(false); + expect(await pm.isToolEnabled('loop_wakeup')).toBe(false); + expect(await pm.isToolEnabled('read_mcp_resource')).toBe(false); + expect(await pm.isToolEnabled('agent')).toBe(false); + expect(await pm.isToolEnabled('todo_write')).toBe(false); + // monitor stays registered: "Shell" rules cover it on purpose so the + // shell tool can't be bypassed by switching to monitor (same + // meta-category semantic as runtime rule matching). + expect(await pm.isToolEnabled('monitor')).toBe(true); + }); + + it('no allow rules → allowlist inactive, all tools stay enabled', async () => { + pm = new PermissionManager(makeConfig({})); + pm.initialize(); + expect(pm.isPermissionsAllowListActive()).toBe(false); + expect(await pm.isToolEnabled('read_file')).toBe(true); + expect(await pm.isToolEnabled('send_message')).toBe(true); + expect(await pm.isToolEnabled('agent')).toBe(true); + expect(await pm.isToolEnabled('run_shell_command')).toBe(true); + }); + + it('specifier allow rules keep their tool registered', async () => { + // "Bash(npm test)" is an auto-approval grant for one command; the + // shell tool itself must stay in the registry (other invocations + // still go through the normal approval flow). + pm = new PermissionManager( + makeConfig({ permissionsAllow: ['Bash(npm test)'] }), + ); + pm.initialize(); + expect(await pm.isToolEnabled('run_shell_command')).toBe(true); + expect(await pm.isToolEnabled('read_file')).toBe(false); + }); + + it('meta-category rules cover their tool families', async () => { + pm = new PermissionManager(makeConfig({ permissionsAllow: ['Read'] })); + pm.initialize(); + expect(await pm.isToolEnabled('read_file')).toBe(true); + expect(await pm.isToolEnabled('grep_search')).toBe(true); + expect(await pm.isToolEnabled('glob')).toBe(true); + expect(await pm.isToolEnabled('list_directory')).toBe(true); + expect(await pm.isToolEnabled('zoom_image')).toBe(true); + expect(await pm.isToolEnabled('edit')).toBe(false); + // "Bash" covers monitor (same runtime matching semantic) + pm = new PermissionManager(makeConfig({ permissionsAllow: ['Bash'] })); + pm.initialize(); + expect(await pm.isToolEnabled('monitor')).toBe(true); + }); + + it('ask rules keep their tool registered under an active allowlist (#9827)', async () => { + // `allow: ["ReadFile"]` + `ask: ["Shell"]` is a natural "auto-approve + // reads, always confirm shell" posture. The ask rule expresses "this + // tool must stay usable, with confirmation", so the shell family must + // NOT be silently deregistered just because no ALLOW rule covers it — + // otherwise "always require user confirmation" becomes "tool + // unavailable" and the ask rule can never fire. + pm = new PermissionManager( + makeConfig({ + permissionsAllow: ['ReadFile'], + permissionsAsk: ['Shell'], + }), + ); + pm.initialize(); + expect(pm.isPermissionsAllowListActive()).toBe(true); + expect(await pm.isToolEnabled('read_file')).toBe(true); + expect(await pm.isToolEnabled('run_shell_command')).toBe(true); + // "Shell" ask rule covers monitor too (same meta-category semantic). + expect(await pm.isToolEnabled('monitor')).toBe(true); + // The ask rule still governs the runtime decision. + expect(await pm.evaluate({ toolName: 'run_shell_command' })).toBe('ask'); + // A tool covered by neither an allow nor an ask rule stays gated. + expect(await pm.isToolEnabled('send_message')).toBe(false); + }); + + it('ask-only rules never activate the allowlist (#9827)', async () => { + // Complementary activation boundary to the membership test above: + // `permissions.ask: ["Shell"]` with NO allow rules is a natural + // "always confirm shell" posture. Ask rules count toward membership + // under an ACTIVE allowlist, but they must never ACTIVATE it — + // otherwise that posture would flip isPermissionsAllowListActive() + // to true and deregister every unlisted built-in: "always confirm" + // silently becomes "most tools vanish from the model". + pm = new PermissionManager(makeConfig({ permissionsAsk: ['Shell'] })); + pm.initialize(); + expect(pm.isPermissionsAllowListActive()).toBe(false); + expect(await pm.isToolEnabled('run_shell_command')).toBe(true); + expect(await pm.isToolEnabled('send_message')).toBe(true); + // The ask rule still governs the runtime decision. + expect(await pm.evaluate({ toolName: 'run_shell_command' })).toBe('ask'); + }); + + it('removing a startup ask rule mid-session keeps its tools registered (#9827)', async () => { + // Same monotonic-membership contract as allow rules: removing an ask + // rule live must not deregister a running tool. + pm = new PermissionManager( + makeConfig({ + permissionsAllow: ['ReadFile'], + permissionsAsk: ['Shell'], + }), + ); + pm.initialize(); + expect(await pm.isToolEnabled('run_shell_command')).toBe(true); + expect(pm.removePersistentRule('Shell', 'ask')).toBe(true); + expect(await pm.isToolEnabled('run_shell_command')).toBe(true); + expect(await pm.isToolEnabled('send_message')).toBe(false); + }); + + it('deny rules still win over allowlist membership', async () => { + pm = new PermissionManager( + makeConfig({ + permissionsAllow: ['Shell'], + permissionsDeny: ['Shell'], + }), + ); + pm.initialize(); + expect(await pm.isToolEnabled('run_shell_command')).toBe(false); + }); + + it('deny via display name removes the tool from the registry', async () => { + // The second half of #9827: rules copied from /tools display names + // (SendMessage, UpdateGoal, ...) used to silently never match. + pm = new PermissionManager( + makeConfig({ permissionsDeny: ['SendMessage', 'UpdateGoal'] }), + ); + pm.initialize(); + expect(await pm.isToolEnabled('send_message')).toBe(false); + expect(await pm.isToolEnabled('update_goal')).toBe(false); + expect(await pm.isToolEnabled('read_file')).toBe(true); + }); + + it('MCP tools are exempt from the allowlist', async () => { + pm = new PermissionManager( + makeConfig({ permissionsAllow: ['read_file'] }), + ); + pm.initialize(); + expect( + await pm.isToolEnabled('mcp__markitdown__convert_to_markdown'), + ).toBe(true); + expect(await pm.isToolEnabled('mcp__puppeteer__navigate')).toBe(true); + }); + + it('structured_output is exempt from the allowlist', async () => { + // The synthetic --json-schema terminal contract must survive an + // active allowlist, same exemption as under --core-tools. + pm = new PermissionManager( + makeConfig({ permissionsAllow: ['read_file'] }), + ); + pm.initialize(); + expect(await pm.isToolEnabled('structured_output')).toBe(true); + }); + + it('computer_use__* tools are exempt from the allowlist (#9827)', async () => { + // The generated cua-driver family (35 tools, enabled by default) has + // no alias entry, meta-category, or wildcard rule form — its wire + // names churn on every cua-driver version bump — and every member is + // shouldDefer=true, so the schemas never enter the eager model + // request. The gate must not silently deregister the family whenever + // the allowlist is active; the legacy tools.core gate never dropped + // these either (non-core tools bypassed it). + pm = new PermissionManager( + makeConfig({ permissionsAllow: ['ReadFile', 'Shell'] }), + ); + pm.initialize(); + expect(pm.isPermissionsAllowListActive()).toBe(true); + expect(await pm.isToolEnabled('computer_use__click')).toBe(true); + expect(await pm.isToolEnabled('computer_use__type_text')).toBe(true); + expect(await pm.isToolEnabled('computer_use__get_window_state')).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 computer_use exemption', async () => { + pm = new PermissionManager( + makeConfig({ + permissionsAllow: ['read_file'], + permissionsDeny: ['computer_use__click'], + }), + ); + pm.initialize(); + expect(await pm.isToolEnabled('computer_use__click')).toBe(false); + expect(await pm.isToolEnabled('computer_use__type_text')).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('task_stop is exempt from the allowlist (#9827)', async () => { + // Under the exact reporter configuration run_shell_command is listed + // and its model-facing copy says to use task_stop to stop a + // background command (and not to use broad process-name kills); the + // background-promotion result instructs `task_stop({ task_id })` + // verbatim. task_stop is shouldDefer=true (task-stop.ts), the same + // deferred-schema property the computer_use__* exemption cites, so + // gating it buys nothing for the schema-shrink goal and only strips + // the sanctioned stop flow. + pm = new PermissionManager( + makeConfig({ + permissionsAllow: [ + 'ReadFile', + 'WriteFile', + 'Edit', + 'Grep', + 'Glob', + 'ListFiles', + 'Shell', + 'WebFetch', + ], + }), + ); + pm.initialize(); + expect(pm.isPermissionsAllowListActive()).toBe(true); + expect(await pm.isToolEnabled('run_shell_command')).toBe(true); + expect(await pm.isToolEnabled('task_stop')).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 task_stop exemption', async () => { + pm = new PermissionManager( + makeConfig({ + permissionsAllow: ['read_file'], + permissionsDeny: ['task_stop'], + }), + ); + pm.initialize(); + expect(await pm.isToolEnabled('task_stop')).toBe(false); + }); + + it('tool_search is exempt from the allowlist (#9827)', async () => { + // When ToolSearch is missing from the registry, client.ts + // (`resolveDeferredToolsForReminder`) eagerly force-reveals every + // registered deferred tool (all mcp__* and the deferred + // computer_use__* family) into the eager model request, and + // `preloadDeferredToolsWithinBudget` early-returns without it. Under + // the canonical narrow allowlist this would invert the schema-shrink + // goal into maximal schema bloat for exactly the deferred families + // the other exemptions preserve for ToolSearch discoverability. + pm = new PermissionManager( + makeConfig({ permissionsAllow: ['Bash(npm test)'] }), + ); + pm.initialize(); + expect(pm.isPermissionsAllowListActive()).toBe(true); + expect(await pm.isToolEnabled('run_shell_command')).toBe(true); + expect(await pm.isToolEnabled('tool_search')).toBe(true); + // Unrelated unlisted built-ins stay gated. + expect(await pm.isToolEnabled('read_file')).toBe(false); + }); + + it('a whole-tool deny rule still wins over the tool_search exemption', async () => { + // Explicit denial (e.g. the deepseek prefix-cache path pushes + // 'tool_search' into mergedDeny) must still remove it. + pm = new PermissionManager( + makeConfig({ + permissionsAllow: ['read_file'], + permissionsDeny: ['tool_search'], + }), + ); + pm.initialize(); + expect(await pm.isToolEnabled('tool_search')).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 + // approval would permission-error every other tool mid-session. + pm = new PermissionManager(makeConfig({})); + pm.initialize(); + pm.addSessionAllowRule('edit'); + expect(pm.isPermissionsAllowListActive()).toBe(false); + expect(await pm.isToolEnabled('read_file')).toBe(true); + expect(await pm.isToolEnabled('edit')).toBe(true); + }); + + it('session-granted allow rules flip the runtime gate under an active allowlist', async () => { + // Skill allowedTools grants (applySkillAllowedTools) and "Always + // allow" choices extend allowlist membership, so the runtime + // permission gate stops rejecting their tools when the session + // started with configured allow rules. + // + // Narrowed contract (#9827): this only asserts the + // PermissionManager-level half. A grant still cannot REGISTER a tool + // the startup allowlist skipped — registry composition is + // restart-scoped (see the caveat-warning tests below and + // `isCoveredByAllowRule`). + pm = new PermissionManager( + makeConfig({ permissionsAllow: ['read_file'] }), + ); + pm.initialize(); + expect(await pm.isToolEnabled('cron_create')).toBe(false); + pm.addSessionAllowRule('cron_create'); + expect(await pm.isToolEnabled('cron_create')).toBe(true); + // Unrelated tools stay gated + expect(await pm.isToolEnabled('send_message')).toBe(false); + }); + + it('warns once that a session grant cannot register a startup-skipped tool (#9827)', async () => { + debugLoggerMock.warn.mockClear(); + pm = new PermissionManager( + makeConfig({ permissionsAllow: ['read_file'] }), + ); + pm.initialize(); + pm.addSessionAllowRule('run_shell_command'); + pm.addSessionAllowRule('cron_create'); + // Once per session, not once per grant. + expect(debugLoggerMock.warn).toHaveBeenCalledTimes(1); + expect(debugLoggerMock.warn).toHaveBeenCalledWith( + expect.stringContaining('cannot register a tool'), + ); + }); + + it('does not log the restart caveat when the allowlist is inactive (#9827)', async () => { + debugLoggerMock.warn.mockClear(); + pm = new PermissionManager(makeConfig({})); + pm.initialize(); + expect(pm.isPermissionsAllowListActive()).toBe(false); + pm.addSessionAllowRule('run_shell_command'); + expect(debugLoggerMock.warn).not.toHaveBeenCalled(); + }); + + it('removing a startup allow rule mid-session keeps its tools registered (restart-scoped, #9827)', async () => { + // Registry membership is monotonic for the session: activation and + // registration are snapshotted at startup ("Requires restart"), so a + // mid-session removal (/permissions or a qwen serve live settings + // sync) must not hard-block tools that were legitimately registered. + // Pre-fix, every call failed EXECUTION_DENIED citing no deny rule. + pm = new PermissionManager( + makeConfig({ permissionsAllow: ['Shell', 'read_file'] }), + ); + pm.initialize(); + expect(pm.isPermissionsAllowListActive()).toBe(true); + expect(await pm.isToolEnabled('run_shell_command')).toBe(true); + expect(await pm.isToolEnabled('monitor')).toBe(true); + + expect(pm.removePersistentRule('Shell', 'allow')).toBe(true); + // Still a registry member... + expect(await pm.isToolEnabled('run_shell_command')).toBe(true); + expect(await pm.isToolEnabled('monitor')).toBe(true); + // ...but the removal revokes auto-approval at runtime (the call + // falls back to the normal confirmation flow instead of being + // permission-errored). + expect(await pm.evaluate({ toolName: 'run_shell_command' })).toBe( + 'default', + ); + // Unlisted tools stay gated. + expect(await pm.isToolEnabled('send_message')).toBe(false); + }); + + it('removing ALL startup allow rules mid-session keeps registered tools enabled (#9827)', async () => { + pm = new PermissionManager( + makeConfig({ permissionsAllow: ['Shell', 'ReadFile'] }), + ); + pm.initialize(); + expect(pm.removePersistentRule('Shell', 'allow')).toBe(true); + expect(pm.removePersistentRule('ReadFile', 'allow')).toBe(true); + expect(pm.isPermissionsAllowListActive()).toBe(true); + expect(await pm.isToolEnabled('run_shell_command')).toBe(true); + expect(await pm.isToolEnabled('read_file')).toBe(true); + // A tool never covered at startup stays gated. + expect(await pm.isToolEnabled('send_message')).toBe(false); + }); + + it('AUTO-mode-stripped allow rules still activate the allowlist and keep membership', async () => { + // Starting in AUTO strips dangerous allow rules from runtime + // evaluation; they are still configured rules, so their tools must + // remain registry members instead of vanishing. + pm = new PermissionManager( + makeConfig({ + permissionsAllow: ['Bash', 'read_file'], + approvalMode: 'auto', + }), + ); + pm.initialize(); + expect(pm.isPermissionsAllowListActive()).toBe(true); + expect(await pm.isToolEnabled('run_shell_command')).toBe(true); + expect(await pm.isToolEnabled('read_file')).toBe(true); + expect(await pm.isToolEnabled('send_message')).toBe(false); + }); + + it('combines with the coreTools allowlist', async () => { + // Both gates apply: a tool must be covered by permissions.allow AND + // pass the legacy coreTools whitelist. + pm = new PermissionManager( + makeConfig({ + permissionsAllow: ['read_file', 'edit'], + coreTools: ['read_file'], + }), + ); + pm.initialize(); + expect(await pm.isToolEnabled('read_file')).toBe(true); + expect(await pm.isToolEnabled('edit')).toBe(false); // allowed, but not in coreTools + expect(await pm.isToolEnabled('glob')).toBe(false); + }); + + it('display-name allowlist entries resolve through aliases', async () => { + pm = new PermissionManager( + makeConfig({ permissionsAllow: ['SendMessage'] }), + ); + pm.initialize(); + expect(await pm.isToolEnabled('send_message')).toBe(true); + expect(await pm.isToolEnabled('read_file')).toBe(false); + }); + + it('allow rules from --allowed-tools / SDK allowedTools do not activate the allowlist', async () => { + // Simulates the CLI/SDK wiring: the merged allow list contains the + // rules, but none of them come from settings.permissions.allow, so + // they stay pure auto-approval grants and every tool remains + // registered (#9827 — the reporter confirmed --exclude-tools works; + // the auto-approve-only contract of --allowed-tools must be kept). + pm = new PermissionManager( + makeConfig({ + permissionsAllow: ['Bash(npm test)'], + registryAllowList: [], + }), + ); + pm.initialize(); + expect(pm.isPermissionsAllowListActive()).toBe(false); + expect(await pm.isToolEnabled('run_shell_command')).toBe(true); + expect(await pm.isToolEnabled('read_file')).toBe(true); + expect(await pm.isToolEnabled('send_message')).toBe(true); + }); + + it('a malformed settings allow rule alone does not activate the allowlist', async () => { + // A typo must not gate the whole toolset. + pm = new PermissionManager( + makeConfig({ permissionsAllow: ['Bash(git commit'] }), + ); + pm.initialize(); + expect(pm.isPermissionsAllowListActive()).toBe(false); + expect(await pm.isToolEnabled('read_file')).toBe(true); + expect(await pm.isToolEnabled('send_message')).toBe(true); + }); + }); + describe('session rules', () => { beforeEach(() => { pm = new PermissionManager(makeConfig({})); diff --git a/packages/core/src/permissions/permission-manager.ts b/packages/core/src/permissions/permission-manager.ts index 8e53833e099..f6aa226b820 100644 --- a/packages/core/src/permissions/permission-manager.ts +++ b/packages/core/src/permissions/permission-manager.ts @@ -26,6 +26,7 @@ import { findDangerousAllowRules, isDangerousAllowRule, } from './dangerousRules.js'; +import { ToolNames } from '../tools/tool-names.js'; import type { PermissionCheckContext, PermissionDecision, @@ -88,6 +89,19 @@ export interface PermissionManagerConfig { * (e.g. `"Bash"` to block all shell commands) instead. */ getCoreTools?(): string[] | undefined; + + /** + * Returns the allow rules sourced from `settings.permissions.allow` only + * (NOT `--allowed-tools`, the SDK `allowedTools` param, or the legacy + * `tools.allowed` key — those stay pure auto-approval grants). + * + * When this list contains at least one valid rule, the registry-level + * allowlist activates: built-in tools not covered by ANY in-force allow + * rule are excluded from registration, matching the documented migration + * semantic of the legacy `tools.core` whitelist ("unlisted tools are + * disabled at registry level", #9827). + */ + getRegistryAllowList?(): string[] | undefined; } /** @@ -141,6 +155,63 @@ export class PermissionManager { */ private coreToolsAllowList: Set | null = null; + /** + * Whether the `permissions.allow` registry allowlist is active. + * + * Snapshotted once in `initialize()`: the allowlist activates only when + * `settings.permissions.allow` (exposed via `getRegistryAllowList()`) + * contains at least one VALID rule. Pure auto-approval sources — + * `--allowed-tools`, the SDK `allowedTools` param, the legacy + * `tools.allowed` key — deliberately do NOT activate it; they keep their + * documented "bypass the confirmation dialog" semantics (#9827). + * + * Activation is not re-evaluated later: rules granted mid-session + * ("Always allow", skill `allowedTools`, `/permissions` writes) extend + * allowlist MEMBERSHIP but must never activate the allowlist under a + * running session, or approving one tool would suddenly + * permission-error every tool not on the list. Registry composition is + * a startup decision, consistent with the "Requires restart" semantics + * of the other tool-availability settings. + */ + private permissionsAllowListActive = false; + + /** + * Frozen snapshot of the allow rules in force at startup, captured at + * the end of `initialize()`. + * + * Registry membership must be monotonic within a session: activation and + * registration are snapshotted at startup ("Requires restart"), so + * REMOVING an allow rule mid-session — `/permissions` → + * `removePersistentRule`, or a `qwen serve` settings edit → + * `syncLivePermissionManagers` — must not hard-block a tool that was + * legitimately registered (it is still listed in `/tools` and its schema + * is still sent to the model; blocking every call with EXECUTION_DENIED + * until restart contradicts the restart-scoped contract). Removals take + * effect at restart; membership is the union of this frozen startup set + * and the live rule set, so mid-session GRANTS still extend coverage + * (#9827). + */ + private startupAllowRules: PermissionRule[] = []; + + /** + * Frozen snapshot of the ask rules in force at startup. + * + * Ask rules count toward registry-allowlist membership: a tool the user + * configured to always be prompted for must stay usable, so "always ask" + * must not silently become "unregistered" whenever an allowlist is + * active (#9827). Membership is monotonic within the session for the + * same reason as `startupAllowRules`: removing an ask rule mid-session + * must not deregister a tool that was legitimately registered. + */ + private startupAskRules: PermissionRule[] = []; + + /** + * Set once the restart caveat for session allow-rule grants under an + * active registry allowlist has been logged, so repeated skill + * `allowedTools` grants do not pile identical warnings into the log. + */ + private sessionGrantAllowlistCaveatLogged = false; + constructor(private readonly config: PermissionManagerConfig) {} /** @@ -176,6 +247,27 @@ export class PermissionManager { if (this.config.getApprovalMode?.() === 'auto') { this.stripDangerousRulesForAutoMode(); } + + // Snapshot the `permissions.allow` registry allowlist activation. + // Only `settings.permissions.allow` rules activate it (see the + // `permissionsAllowListActive` field). Requiring at least one VALID + // rule keeps a malformed entry from gating the entire toolset. + this.permissionsAllowListActive = parseRules( + this.config.getRegistryAllowList?.() ?? [], + ).some((rule) => !rule.invalid); + + // Freeze the startup allow-rule set AFTER the AUTO-mode strip above so + // stripped (stashed) rules count toward membership too. Registry + // membership is the union of this frozen set and the live rule set — + // monotonic within the session, removals take effect at restart (see + // `startupAllowRules`, #9827). + this.startupAllowRules = this.getEffectiveAllowRules(); + + // Ask rules count toward membership too (see + // `isCoveredByAllowOrAskRule`); freeze them for the same + // restart-scoped monotonicity. AUTO mode only strips allow rules, so + // the live ask set is never stashed. + this.startupAskRules = this.getEffectiveAskRules(); } // --------------------------------------------------------------------------- @@ -590,6 +682,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. */ @@ -600,18 +708,92 @@ export class PermissionManager { /** * Determine whether a tool should be present in the tool registry. * - * A tool is disabled (returns false) when a `deny` rule without a specifier - * (i.e. a whole-tool deny) matches. Specifier-based deny rules such as - * `"Bash(rm -rf *)"` do NOT remove the tool from the registry – they only - * deny specific invocations at runtime. + * A tool is disabled (returns false) when: + * - the `permissions.allow` registry allowlist is active and the tool is + * not covered by any allow or ask rule (see + * `isPermissionsAllowListActive`; ask rules keep their tool registered + * so "always require confirmation" never silently becomes "tool + * unavailable"), or + * - a `deny` rule without a specifier (i.e. a whole-tool deny) matches. + * + * Specifier-based deny rules such as `"Bash(rm -rf *)"` do NOT remove the + * tool from the registry – they only deny specific invocations at runtime. + * Likewise, specifier-based allow rules such as `"Bash(npm test)"` DO keep + * the tool in the registry — the allowlist is tool-level, not + * invocation-level. * * Non-core tools (MCP, Skill, Agent, etc.) skip the coreTools allowlist * check because they are dynamically discovered or essential for system - * operation. + * operation, but they ARE subject to the `permissions.allow` registry + * allowlist (except MCP tools, `structured_output`, and the + * `computer_use__*` family, see below) — that is the documented migration + * semantic of the legacy `tools.core` whitelist ("unlisted tools are + * disabled at registry level") and the only way to keep e.g. + * `send_message` / `update_goal` schemas out of the model request + * (#9827). */ async isToolEnabled(toolName: string): Promise { const canonicalName = resolveToolName(toolName); + // `permissions.allow` registry allowlist: when the session starts with + // at least one allow rule, any built-in tool not covered by an allow + // or ask rule is never registered, so its schema is not sent to the + // model. Exempt: + // - MCP tools (`mcp__*`): dynamically discovered and filtered via the + // per-server `includeTools` / `excludeTools` and `tools.disabled` + // knobs instead — same bypass the legacy coreTools allowlist had. + // - `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). + // - `task_stop`: registered tools advertise it to the model — + // `run_shell_command`'s schema says to use `task_stop` to stop a + // background command (and not to use broad process-name kills), and + // the background-promotion result instructs `task_stop({ task_id })` + // verbatim. It is `shouldDefer=true` (task-stop.ts), the exact + // property the computer_use__* exemption below cites: deferred + // schemas never enter the eager model request, so gating it buys + // nothing for the schema-shrink goal and only strips the sanctioned + // stop flow while the tool that advertises it stays listed (#9827). + // - Computer Use tools (`computer_use__*`): the generated cua-driver + // surface (35 tools, `computerUseEnabled` defaults to true) has no + // alias entry, meta-category, or wildcard rule form — the wire names + // churn on every cua-driver version bump (see tool-names.ts), so no + // concise allow rule can keep the family listed. Every member is + // `shouldDefer=true`, so the schemas never enter the eager model + // request anyway: gating them buys nothing for the schema-shrink + // goal and only strips capability, including ToolSearch + // discoverability. The legacy `tools.core` gate never dropped them + // either (non-core tools bypassed it) (#9827). + // - `tool_search`: the deferred-tool discovery surface itself. When + // ToolSearch is absent from the registry, client.ts + // (`resolveDeferredToolsForReminder`) eagerly force-reveals EVERY + // registered deferred tool — all `mcp__*` tools and the deferred + // `computer_use__*` family — into the eager model request, and + // `preloadDeferredToolsWithinBudget` early-returns without it, so + // gating tool_search under a narrow allowlist inverts the + // schema-shrink goal into maximal schema bloat for exactly the + // deferred families the exemptions above preserve for ToolSearch + // discoverability. tool_search itself is never `shouldDefer` + // (tool-search.ts), so its own schema cost is unchanged by keeping + // it listed. Pre-#9827 it always bypassed the legacy coreTools gate + // as a non-core tool (#9827). + if ( + this.permissionsAllowListActive && + canonicalName !== ToolNames.STRUCTURED_OUTPUT && + !PermissionManager.PLAN_LIFECYCLE_TOOLS.has(canonicalName) && + canonicalName !== ToolNames.TASK_STOP && + canonicalName !== ToolNames.TOOL_SEARCH && + !canonicalName.startsWith('mcp__') && + !canonicalName.startsWith('computer_use__') && + !this.isCoveredByAllowOrAskRule(canonicalName) + ) { + return false; + } + // Non-core tools bypass coreTools allowlist check if (!this.isCoreTool(canonicalName)) { const decision = await this.evaluate({ toolName: canonicalName }); @@ -633,6 +815,84 @@ export class PermissionManager { return decision !== 'deny'; } + /** + * Whether the `permissions.allow` registry allowlist is active for this + * session. See the `permissionsAllowListActive` field for the activation + * contract (snapshot at `initialize()`, restart-scoped). + */ + isPermissionsAllowListActive(): boolean { + return this.permissionsAllowListActive; + } + + /** + * All allow rules currently in force: persistent + session + any rules + * the AUTO-mode strip moved to the stash (they are configured rules, + * merely suspended for runtime auto-approval purposes). + */ + private getEffectiveAllowRules(): PermissionRule[] { + return [ + ...this.sessionRules.allow, + ...this.persistentRules.allow, + ...(this.strippedAllowRules?.session ?? []), + ...(this.strippedAllowRules?.persistent ?? []), + ]; + } + + /** + * All ask rules currently in force: persistent + session. AUTO mode + * strips only allow rules (the stash in `strippedAllowRules`), so ask + * rules are never suspended and no stash applies here. + */ + private getEffectiveAskRules(): PermissionRule[] { + return [...this.sessionRules.ask, ...this.persistentRules.ask]; + } + + /** + * Registry-membership check for the `permissions.allow` allowlist: true + * when any in-force allow OR ask rule mentions the tool. Ask rules count + * because they express "this tool must stay usable, with confirmation" — + * a tool covered only by an ask rule must not be silently deregistered + * whenever an allowlist is active, or the documented "always require + * user confirmation" would become "tool unavailable" and the ask rule + * could never fire (#9827). Tool-name matching is specifier-agnostic + * (`Bash(npm test)` keeps `run_shell_command` registered) and honours + * meta-categories (`Read` covers grep/glob/..., `Bash` covers monitor) + * via `toolMatchesRuleToolName`. + * + * Membership is monotonic within the session: the union of the frozen + * startup rule sets (`startupAllowRules` / `startupAskRules`) and the + * live rule sets. Removing a STARTUP rule mid-session therefore never + * deregisters an already-registered tool (removals take effect at + * restart, matching the documented "Requires restart" contract), while + * rules granted mid-session — skill `allowedTools`, "Always allow", + * `/permissions` writes — extend membership live even though they can + * never ACTIVATE the allowlist (#9827). + * + * Caveat: extending membership flips this runtime predicate only. A + * mid-session grant can never RESTORE a tool that the startup allowlist + * skipped at REGISTRATION — the registry is built once in + * `Config.initialize` and `ensureTool` returns undefined for factories + * that were never stored, so such a call still fails TOOL_NOT_REGISTERED + * until the rule is added to settings `permissions.allow` and the + * session restarts (#9827). + * + * Public so the scheduler can tell an allowlist miss (tool genuinely + * uncovered) apart from a rejection by a different gate — e.g. the + * legacy `coreTools` allowlist — for a tool that IS covered, where + * "add a permissions.allow rule" advice would be a no-op (#9827). + */ + isCoveredByAllowOrAskRule(toolName: string): boolean { + const canonicalName = resolveToolName(toolName); + const covered = (rule: PermissionRule): boolean => + !rule.invalid && toolMatchesRuleToolName(rule.toolName, canonicalName); + return ( + this.startupAllowRules.some(covered) || + this.getEffectiveAllowRules().some(covered) || + this.startupAskRules.some(covered) || + this.getEffectiveAskRules().some(covered) + ); + } + /** * Find the first deny rule that matches the given context. * Returns the raw rule string if found, or undefined if no deny rule matches. @@ -944,6 +1204,12 @@ export class PermissionManager { * Add a session-level allow rule (in-memory, cleared when the session ends). * Used when the user clicks "Always allow for this session". * + * Under an active `permissions.allow` registry allowlist the grant + * auto-approves matching calls and extends allowlist MEMBERSHIP, but it + * cannot REGISTER a tool the startup allowlist skipped — registry + * composition is restart-scoped. The first such grant logs a caveat + * pointing at the restart path (#9827). + * * @param raw - The raw rule string, e.g. "Bash(git status)". */ addSessionAllowRule(raw: string): void { @@ -955,6 +1221,18 @@ export class PermissionManager { ); return; } + if ( + this.permissionsAllowListActive && + !this.sessionGrantAllowlistCaveatLogged + ) { + this.sessionGrantAllowlistCaveatLogged = true; + debugLogger.warn( + 'Session allow rule granted while the permissions.allow registry allowlist is active: ' + + 'the grant auto-approves matching calls, but it cannot register a tool the startup ' + + 'allowlist skipped at registration — an unavailable tool stays unavailable until the ' + + 'rule is added to settings permissions.allow and the session restarts (#9827).', + ); + } // AUTO mode invariant: while dangerous allow rules are stripped, // any newly added allow rule that is itself dangerous must be // stashed alongside the strip rather than made active. Without diff --git a/packages/core/src/permissions/rule-parser.ts b/packages/core/src/permissions/rule-parser.ts index 66847e42cff..f9487adf13a 100644 --- a/packages/core/src/permissions/rule-parser.ts +++ b/packages/core/src/permissions/rule-parser.ts @@ -156,6 +156,104 @@ export const TOOL_NAME_ALIASES: Readonly> = { Monitor: 'monitor', MonitorTool: 'monitor', + // Send Message tool (teams) + send_message: 'send_message', + SendMessage: 'send_message', + SendMessageTool: 'send_message', + + // Goal tools — the display name of get_goal is "Goal" (see ToolDisplayNames) + get_goal: 'get_goal', + Goal: 'get_goal', + GetGoal: 'get_goal', + update_goal: 'update_goal', + UpdateGoal: 'update_goal', + UpdateGoalTool: 'update_goal', + + // Save Memory tool + save_memory: 'save_memory', + SaveMemory: 'save_memory', + SaveMemoryTool: 'save_memory', + + // Ask User Question tool + ask_user_question: 'ask_user_question', + AskUserQuestion: 'ask_user_question', + AskUserQuestionTool: 'ask_user_question', + + // Cron tools + cron_create: 'cron_create', + CronCreate: 'cron_create', + cron_list: 'cron_list', + CronList: 'cron_list', + cron_delete: 'cron_delete', + CronDelete: 'cron_delete', + + // Loop wakeup tool + loop_wakeup: 'loop_wakeup', + LoopWakeup: 'loop_wakeup', + LoopWakeupTool: 'loop_wakeup', + + // Create Sub Session tool + create_sub_session: 'create_sub_session', + CreateSubSession: 'create_sub_session', + CreateSubSessionTool: 'create_sub_session', + + // List Agents tool + list_agents: 'list_agents', + ListAgents: 'list_agents', + ListAgentsTool: 'list_agents', + + // Task lifecycle tools (teams) + task_stop: 'task_stop', + TaskStop: 'task_stop', + task_create: 'task_create', + TaskCreate: 'task_create', + task_update: 'task_update', + TaskUpdate: 'task_update', + task_list: 'task_list', + TaskList: 'task_list', + + // Team tools + team_create: 'team_create', + TeamCreate: 'team_create', + team_delete: 'team_delete', + TeamDelete: 'team_delete', + team_plan_approval: 'team_plan_approval', + TeamPlanApproval: 'team_plan_approval', + + // Image generation tool + image_gen: 'image_gen', + ImageGen: 'image_gen', + ImageGenTool: 'image_gen', + + // Tool search tool + tool_search: 'tool_search', + ToolSearch: 'tool_search', + ToolSearchTool: 'tool_search', + + // Structured output (synthetic --json-schema contract) + structured_output: 'structured_output', + StructuredOutput: 'structured_output', + + // Worktree tools + enter_worktree: 'enter_worktree', + EnterWorktree: 'enter_worktree', + exit_worktree: 'exit_worktree', + ExitWorktree: 'exit_worktree', + + // Workflow / artifact tools + workflow: 'workflow', + Workflow: 'workflow', + artifact: 'artifact', + Artifact: 'artifact', + record_artifact: 'record_artifact', + RecordArtifact: 'record_artifact', + request_shutdown: 'request_shutdown', + RequestShutdown: 'request_shutdown', + + // Display image tool + display_image: 'display_image', + DisplayImage: 'display_image', + // Legacy edit tool name replace: 'edit', }; diff --git a/packages/core/src/skills/types.ts b/packages/core/src/skills/types.ts index b8b8b614be5..ebaa9b44b01 100644 --- a/packages/core/src/skills/types.ts +++ b/packages/core/src/skills/types.ts @@ -45,7 +45,10 @@ export interface SkillConfig { * allow rule, so matching tool calls are auto-approved instead of prompting. * * This is an additive grant only: it never hides or restricts the tools the - * model can see. Malformed entries are ignored. See `applySkillAllowedTools`. + * model can see. Under an active `permissions.allow` registry allowlist it + * still cannot register a tool the startup allowlist skipped — that tool + * needs the rule in settings `permissions.allow` plus a restart (#9827). + * Malformed entries are ignored. See `applySkillAllowedTools`. */ allowedTools?: string[]; diff --git a/packages/core/src/tools/skill-utils.ts b/packages/core/src/tools/skill-utils.ts index df3caee31d1..00892df2c87 100644 --- a/packages/core/src/tools/skill-utils.ts +++ b/packages/core/src/tools/skill-utils.ts @@ -264,6 +264,14 @@ ${escapeXml(entry.description)} * auto-approved for the rest of the session instead of prompting. This is an * additive grant only — it never hides or restricts the tools the model sees. * + * Caveat under an active `permissions.allow` registry allowlist (#9827): the + * grant flips the runtime permission predicate, but it can never REGISTER a + * tool that the startup allowlist skipped at registration — the registry is + * built once in `Config.initialize`, so such a tool still fails + * TOOL_NOT_REGISTERED until the rule is added to settings `permissions.allow` + * and the session restarts. `PermissionManager.addSessionAllowRule` logs this + * caveat on the first such grant. + * * No-ops when there is no permission manager or nothing to grant. */ export function applySkillAllowedTools( diff --git a/packages/core/src/tools/tool-registry.test.ts b/packages/core/src/tools/tool-registry.test.ts index fcf221a4d3b..7b3f1e932af 100644 --- a/packages/core/src/tools/tool-registry.test.ts +++ b/packages/core/src/tools/tool-registry.test.ts @@ -9,6 +9,7 @@ import type { Mocked } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { ConfigParameters } from '../config/config.js'; import { Config, ApprovalMode } from '../config/config.js'; +import { PermissionManager } from '../permissions/permission-manager.js'; import { ToolRegistry, DiscoveredTool } from './tool-registry.js'; import { DiscoveredMCPTool } from './mcp-tool.js'; import { ExitPlanModeTool } from './exitPlanMode.js'; @@ -1027,6 +1028,213 @@ describe('ToolRegistry', () => { }); }); + it('does not register command-discovered tools the permissions.allow registry allowlist does not cover (#9827)', async () => { + // Without the registration-side gate the uncovered tool stays + // registered and its schema is still sent to the model, yet every + // invocation is rejected EXECUTION_DENIED by the runtime scheduler + // gate — advertised-then-rejected. Not covered → not registered, + // matching the registerLazy path built-ins go through. + const pm = new PermissionManager({ + getPermissionsAllow: () => ['covered_discovered_tool'], + getPermissionsAsk: () => [], + getPermissionsDeny: () => [], + getCoreTools: () => undefined, + getRegistryAllowList: () => ['covered_discovered_tool'], + getProjectRoot: () => '/test/dir', + getCwd: () => '/test/dir', + getApprovalMode: () => 'default', + }); + pm.initialize(); + expect(pm.isPermissionsAllowListActive()).toBe(true); + vi.spyOn(config, 'getPermissionManager').mockReturnValue(pm); + mockConfigGetToolDiscoveryCommand.mockReturnValue('my-discovery-command'); + + const declarations: FunctionDeclaration[] = [ + { + name: 'covered_discovered_tool', + description: 'Covered by an allow rule', + parametersJsonSchema: { type: 'object', properties: {} }, + }, + { + name: 'uncovered_discovered_tool', + description: 'Not covered by any allow rule', + parametersJsonSchema: { type: 'object', properties: {} }, + }, + ]; + + const mockSpawn = vi.mocked(spawn); + const mockChildProcess = { + stdout: { on: vi.fn() }, + stderr: { on: vi.fn() }, + on: vi.fn(), + }; + mockSpawn.mockReturnValue(mockChildProcess as any); + mockChildProcess.stdout.on.mockImplementation((event, callback) => { + if (event === 'data') { + callback( + Buffer.from( + JSON.stringify([{ function_declarations: declarations }]), + ), + ); + } + return mockChildProcess as any; + }); + mockChildProcess.on.mockImplementation((event, callback) => { + if (event === 'close') { + callback(0); + } + return mockChildProcess as any; + }); + + await toolRegistry.discoverAllTools(); + + expect(toolRegistry.getTool('covered_discovered_tool')).toBeDefined(); + expect(toolRegistry.getTool('uncovered_discovered_tool')).toBeUndefined(); + }); + + it('removes a command-discovered tool hit by a whole-tool deny rule even under an active permissions.allow allowlist (#9827)', async () => { + // settings.md pins the sibling semantic of the discovery gate: "A + // whole-tool deny rule (no specifier) also removes the tool from + // the registry — for built-in tools and tools found via + // tools.discoveryCommand". The denied tool below IS covered by an + // allow rule, so the allowlist gate alone would have kept it + // registered — only the deny branch of isToolEnabled can reject + // it, which is exactly what this test pins. + const pm = new PermissionManager({ + getPermissionsAllow: () => [ + 'allowed_discovered_tool', + 'denied_discovered_tool', + ], + getPermissionsAsk: () => [], + getPermissionsDeny: () => ['denied_discovered_tool'], + getCoreTools: () => undefined, + getRegistryAllowList: () => [ + 'allowed_discovered_tool', + 'denied_discovered_tool', + ], + getProjectRoot: () => '/test/dir', + getCwd: () => '/test/dir', + getApprovalMode: () => 'default', + }); + pm.initialize(); + expect(pm.isPermissionsAllowListActive()).toBe(true); + vi.spyOn(config, 'getPermissionManager').mockReturnValue(pm); + mockConfigGetToolDiscoveryCommand.mockReturnValue('my-discovery-command'); + + const declarations: FunctionDeclaration[] = [ + { + name: 'allowed_discovered_tool', + description: 'Covered by an allow rule', + parametersJsonSchema: { type: 'object', properties: {} }, + }, + { + name: 'denied_discovered_tool', + description: 'Covered by an allow rule AND a whole-tool deny rule', + parametersJsonSchema: { type: 'object', properties: {} }, + }, + ]; + + const mockSpawn = vi.mocked(spawn); + const mockChildProcess = { + stdout: { on: vi.fn() }, + stderr: { on: vi.fn() }, + on: vi.fn(), + }; + mockSpawn.mockReturnValue(mockChildProcess as any); + mockChildProcess.stdout.on.mockImplementation((event, callback) => { + if (event === 'data') { + callback( + Buffer.from( + JSON.stringify([{ function_declarations: declarations }]), + ), + ); + } + return mockChildProcess as any; + }); + mockChildProcess.on.mockImplementation((event, callback) => { + if (event === 'close') { + callback(0); + } + return mockChildProcess as any; + }); + + await toolRegistry.discoverAllTools(); + + expect(toolRegistry.getTool('allowed_discovered_tool')).toBeDefined(); + expect(toolRegistry.getTool('denied_discovered_tool')).toBeUndefined(); + }); + + it('keeps a command-discovered tool covered by an ask rule registered under an active permissions.allow allowlist (#9827)', async () => { + // settings.md pins the other sibling semantic: a tool covered by + // an ask rule "stays registered even when an allowlist is active, + // so 'always require confirmation' never silently becomes 'tool + // unavailable'". The uncovered control tool in the same discovery + // run proves the gate is genuinely active, so the ask-covered + // registration cannot be a gate-bypass artefact. + const pm = new PermissionManager({ + getPermissionsAllow: () => ['allowed_discovered_tool'], + getPermissionsAsk: () => ['asked_discovered_tool'], + getPermissionsDeny: () => [], + getCoreTools: () => undefined, + getRegistryAllowList: () => ['allowed_discovered_tool'], + getProjectRoot: () => '/test/dir', + getCwd: () => '/test/dir', + getApprovalMode: () => 'default', + }); + pm.initialize(); + expect(pm.isPermissionsAllowListActive()).toBe(true); + vi.spyOn(config, 'getPermissionManager').mockReturnValue(pm); + mockConfigGetToolDiscoveryCommand.mockReturnValue('my-discovery-command'); + + const declarations: FunctionDeclaration[] = [ + { + name: 'allowed_discovered_tool', + description: 'Covered by an allow rule', + parametersJsonSchema: { type: 'object', properties: {} }, + }, + { + name: 'asked_discovered_tool', + description: 'Covered by an ask rule', + parametersJsonSchema: { type: 'object', properties: {} }, + }, + { + name: 'uncovered_discovered_tool', + description: 'Not covered by any allow or ask rule', + parametersJsonSchema: { type: 'object', properties: {} }, + }, + ]; + + const mockSpawn = vi.mocked(spawn); + const mockChildProcess = { + stdout: { on: vi.fn() }, + stderr: { on: vi.fn() }, + on: vi.fn(), + }; + mockSpawn.mockReturnValue(mockChildProcess as any); + mockChildProcess.stdout.on.mockImplementation((event, callback) => { + if (event === 'data') { + callback( + Buffer.from( + JSON.stringify([{ function_declarations: declarations }]), + ), + ); + } + return mockChildProcess as any; + }); + mockChildProcess.on.mockImplementation((event, callback) => { + if (event === 'close') { + callback(0); + } + return mockChildProcess as any; + }); + + await toolRegistry.discoverAllTools(); + + expect(toolRegistry.getTool('allowed_discovered_tool')).toBeDefined(); + expect(toolRegistry.getTool('asked_discovered_tool')).toBeDefined(); + expect(toolRegistry.getTool('uncovered_discovered_tool')).toBeUndefined(); + }); + it('strips Qwen-internal daemon secrets from the discovery and tool-call child env (#6601)', async () => { const originalServerToken = process.env['QWEN_SERVER_TOKEN']; const originalDaemonToken = process.env['QWEN_DAEMON_TOKEN']; diff --git a/packages/core/src/tools/tool-registry.ts b/packages/core/src/tools/tool-registry.ts index aeccbba72ee..362727b279e 100644 --- a/packages/core/src/tools/tool-registry.ts +++ b/packages/core/src/tools/tool-registry.ts @@ -694,11 +694,33 @@ export class ToolRegistry { } } // register each function as a tool + // + // The same PermissionManager gate that createToolRegistry applies to + // built-ins (via registerLazy) applies here too. Without it, a + // discovered tool not covered by an active `permissions.allow` + // registry allowlist would stay registered and advertised to the + // model while the runtime scheduler gate rejects every invocation + // with EXECUTION_DENIED — advertised-then-rejected (#9827). Gating + // at registration keeps both sides of the gate consistent: an + // uncovered tool is hidden from the model instead of always + // failing. Whole-tool deny rules benefit from the same consistency + // ("a whole-tool deny rule also removes the tool from the registry", + // settings.md). Deny rules still apply at runtime regardless. + const permissionManager = this.config.getPermissionManager?.(); for (const func of functions) { if (!func.name) { debugLogger.warn('Discovered a tool with no name. Skipping.'); continue; } + if (permissionManager) { + const toolEnabled = await permissionManager.isToolEnabled(func.name); + if (!toolEnabled) { + debugLogger.info( + `Discovered tool "${func.name}" skipped: not enabled by the permission manager (permissions.allow registry allowlist or whole-tool deny rule, #9827).`, + ); + continue; + } + } const parameters = func.parametersJsonSchema && typeof func.parametersJsonSchema === 'object' && diff --git a/packages/sdk-typescript/README.md b/packages/sdk-typescript/README.md index 6132c3e14e7..f98f51b12ee 100644 --- a/packages/sdk-typescript/README.md +++ b/packages/sdk-typescript/README.md @@ -52,27 +52,27 @@ Creates a new query session with the Qwen Code. #### QueryOptions -| Option | Type | Default | Description | -| ------------------------ | -------------------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `cwd` | `string` | `process.cwd()` | The working directory for the query session. Determines the context in which file operations and commands are executed. | -| `model` | `string` | - | The AI model to use (e.g., `'qwen-max'`, `'qwen-plus'`, `'qwen-turbo'`). Takes precedence over `OPENAI_MODEL` and `QWEN_MODEL` environment variables. | -| `pathToQwenExecutable` | `string` | Auto-detected | Path to the Qwen Code executable. Supports multiple formats: `'qwen'` (native binary from PATH), `'/path/to/qwen'` (explicit path), `'/path/to/cli.js'` (Node.js bundle), `'node:/path/to/cli.js'` (force Node.js runtime), `'bun:/path/to/cli.js'` (force Bun runtime). If not provided, auto-detects from: `QWEN_CODE_CLI_PATH` env var, `~/.volta/bin/qwen`, `~/.npm-global/bin/qwen`, `/usr/local/bin/qwen`, `~/.local/bin/qwen`, `~/node_modules/.bin/qwen`, `~/.yarn/bin/qwen`. | -| `permissionMode` | `'default' \| 'plan' \| 'auto-edit' \| 'auto' \| 'yolo'` | `'default'` | Permission mode controlling tool execution approval. See [Permission Modes](#permission-modes) for details. | -| `canUseTool` | `CanUseTool` | - | Custom permission handler for tool execution approval. Invoked when a tool requires confirmation. Must respond within 60 seconds or the request will be auto-denied. See [Custom Permission Handler](#custom-permission-handler). | -| `env` | `Record` | - | Environment variables to pass to the Qwen Code process. Merged with the current process environment. | -| `systemPrompt` | `string \| QuerySystemPromptPreset` | - | System prompt configuration for the main session. Use a string to fully override the built-in Qwen Code system prompt, or a preset object to keep the built-in prompt and append extra instructions. | -| `mcpServers` | `Record` | - | MCP (Model Context Protocol) servers to connect. Supports external servers (stdio/SSE/HTTP) and SDK-embedded servers. External servers are configured with transport options like `command`, `args`, `url`, `httpUrl`, etc. SDK servers use `{ type: 'sdk', name: string, instance: Server }`. | -| `abortController` | `AbortController` | - | Controller to cancel the query session. Call `abortController.abort()` to terminate the session and cleanup resources. | -| `debug` | `boolean` | `false` | Enable debug mode for verbose logging from the CLI process. | -| `maxSessionTurns` | `number` | `-1` (unlimited) | Maximum number of conversation turns before the session automatically terminates. Must be an integer. A turn consists of a user message and an assistant response. | -| `coreTools` | `string[]` | - | Uses the legacy `coreTools` / CLI `--core-tools` allowlist semantics. If specified, only matching core tools are registered for the session. This is separate from `permissions.allow`, which auto-approves matching tool calls but does not restrict tool registration. Example: `['read_file', 'edit', 'run_shell_command']`. | -| `excludeTools` | `string[]` | - | Equivalent to `permissions.deny` in settings.json. Excluded tools return a permission error immediately. Takes highest priority over all other permission settings. Supports tool name aliases and pattern matching: tool name (`'write_file'`), shell command prefix (`'Bash(rm *)'`), or path patterns (`'Read(.env)'`, `'Edit(/src/**)'`). | -| `allowedTools` | `string[]` | - | Equivalent to `permissions.allow` in settings.json. Matching tools bypass `canUseTool` callback and execute automatically. Only applies when tool requires confirmation. Supports same pattern matching as `excludeTools`. Example: `['Bash(git status)', 'Bash(npm test)']`. | -| `authType` | `'openai' \| 'qwen-oauth'` | `'openai'` | Authentication type for the AI service. Qwen OAuth free tier was discontinued on 2026-04-15; new SDK setups should use OpenAI-compatible authentication or another supported provider. | -| `agents` | `SubagentConfig[]` | - | Configuration for subagents that can be invoked during the session. Subagents are specialized AI agents for specific tasks or domains. | -| `includePartialMessages` | `boolean` | `false` | When `true`, the SDK emits incomplete messages as they are being generated, allowing real-time streaming of the AI's response. | -| `resume` | `string` | - | Resume a previous session by providing its session ID. Equivalent to CLI's `--resume` flag. | -| `sessionId` | `string` | - | Specify a session ID for the new session. Ensures SDK and CLI use the same ID without resuming history. Equivalent to CLI's `--session-id` flag. | +| Option | Type | Default | Description | +| ------------------------ | -------------------------------------------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cwd` | `string` | `process.cwd()` | The working directory for the query session. Determines the context in which file operations and commands are executed. | +| `model` | `string` | - | The AI model to use (e.g., `'qwen-max'`, `'qwen-plus'`, `'qwen-turbo'`). Takes precedence over `OPENAI_MODEL` and `QWEN_MODEL` environment variables. | +| `pathToQwenExecutable` | `string` | Auto-detected | Path to the Qwen Code executable. Supports multiple formats: `'qwen'` (native binary from PATH), `'/path/to/qwen'` (explicit path), `'/path/to/cli.js'` (Node.js bundle), `'node:/path/to/cli.js'` (force Node.js runtime), `'bun:/path/to/cli.js'` (force Bun runtime). If not provided, auto-detects from: `QWEN_CODE_CLI_PATH` env var, `~/.volta/bin/qwen`, `~/.npm-global/bin/qwen`, `/usr/local/bin/qwen`, `~/.local/bin/qwen`, `~/node_modules/.bin/qwen`, `~/.yarn/bin/qwen`. | +| `permissionMode` | `'default' \| 'plan' \| 'auto-edit' \| 'auto' \| 'yolo'` | `'default'` | Permission mode controlling tool execution approval. See [Permission Modes](#permission-modes) for details. | +| `canUseTool` | `CanUseTool` | - | Custom permission handler for tool execution approval. Invoked when a tool requires confirmation. Must respond within 60 seconds or the request will be auto-denied. See [Custom Permission Handler](#custom-permission-handler). | +| `env` | `Record` | - | Environment variables to pass to the Qwen Code process. Merged with the current process environment. | +| `systemPrompt` | `string \| QuerySystemPromptPreset` | - | System prompt configuration for the main session. Use a string to fully override the built-in Qwen Code system prompt, or a preset object to keep the built-in prompt and append extra instructions. | +| `mcpServers` | `Record` | - | MCP (Model Context Protocol) servers to connect. Supports external servers (stdio/SSE/HTTP) and SDK-embedded servers. External servers are configured with transport options like `command`, `args`, `url`, `httpUrl`, etc. SDK servers use `{ type: 'sdk', name: string, instance: Server }`. | +| `abortController` | `AbortController` | - | Controller to cancel the query session. Call `abortController.abort()` to terminate the session and cleanup resources. | +| `debug` | `boolean` | `false` | Enable debug mode for verbose logging from the CLI process. | +| `maxSessionTurns` | `number` | `-1` (unlimited) | Maximum number of conversation turns before the session automatically terminates. Must be an integer. A turn consists of a user message and an assistant response. | +| `coreTools` | `string[]` | - | Uses the legacy `coreTools` / CLI `--core-tools` allowlist semantics. If specified, only matching core tools are registered for the session. This is separate from `permissions.allow` in settings.json, which also activates a registry-level allowlist at startup: when at least one valid allow rule is configured there (malformed entries do not count), built-in tools not covered by any allow or ask rule are not registered (MCP tools, the `--json-schema` `structured_output` contract, the plan-mode lifecycle tools, `task_stop`, `tool_search`, and the `computer_use__*` family are exempt; requires restart, #9827). The SDK `allowedTools` parameter cannot activate the allowlist on its own, but while the allowlist is active its rules are merged into the effective allow set and count toward coverage, keeping covered built-ins registered. Example: `['read_file', 'edit', 'run_shell_command']`. | +| `excludeTools` | `string[]` | - | Equivalent to `permissions.deny` in settings.json. Excluded tools return a permission error immediately. Takes highest priority over all other permission settings. Supports tool name aliases and pattern matching: tool name (`'write_file'`), shell command prefix (`'Bash(rm *)'`), or path patterns (`'Read(.env)'`, `'Edit(/src/**)'`). | +| `allowedTools` | `string[]` | - | Equivalent to `permissions.allow` in settings.json for auto-approval. Matching tools bypass `canUseTool` callback and execute automatically. Only applies when tool requires confirmation. Unlike `permissions.allow` in settings.json, this parameter alone does not activate the registry allowlist; however, while a settings-provided allowlist is active, `allowedTools` rules are merged into the effective allow set and count toward coverage, so covered built-ins stay registered. Supports same pattern matching as `excludeTools`. Example: `['Bash(git status)', 'Bash(npm test)']`. | +| `authType` | `'openai' \| 'qwen-oauth'` | `'openai'` | Authentication type for the AI service. Qwen OAuth free tier was discontinued on 2026-04-15; new SDK setups should use OpenAI-compatible authentication or another supported provider. | +| `agents` | `SubagentConfig[]` | - | Configuration for subagents that can be invoked during the session. Subagents are specialized AI agents for specific tasks or domains. | +| `includePartialMessages` | `boolean` | `false` | When `true`, the SDK emits incomplete messages as they are being generated, allowing real-time streaming of the AI's response. | +| `resume` | `string` | - | Resume a previous session by providing its session ID. Equivalent to CLI's `--resume` flag. | +| `sessionId` | `string` | - | Specify a session ID for the new session. Ensures SDK and CLI use the same ID without resuming history. Equivalent to CLI's `--session-id` flag. | > [!tip] > If you need to configure `coreTools`, `excludeTools`, or `allowedTools`, it is **strongly recommended** to read the [permissions configuration documentation](../../docs/users/configuration/settings.md#permissions) first, especially the **Tool name aliases** and **Rule syntax examples** sections. Rule patterns such as `Bash(git *)`, `Read(.env)`, and `Edit(/src/**)` apply to `excludeTools` and `allowedTools`; `coreTools` accepts aliases but strips invocation specifiers. diff --git a/packages/sdk-typescript/src/types/types.ts b/packages/sdk-typescript/src/types/types.ts index 7b44e36632c..3b1b223719d 100644 --- a/packages/sdk-typescript/src/types/types.ts +++ b/packages/sdk-typescript/src/types/types.ts @@ -401,9 +401,18 @@ export interface QueryOptions { /** * Uses the legacy `coreTools` / CLI `--core-tools` allowlist semantics. - * If specified, only matching core tools are registered for the session. - * This is separate from `permissions.allow`, which auto-approves matching - * tool calls but does not restrict tool registration. + * If specified, only matching core tools are registered for the session + * (non-core built-ins such as `send_message` are unaffected). + * Separately, `permissions.allow` in settings.json (requires restart) + * activates a registry-level allowlist: when at least one valid allow + * rule is configured there (malformed entries do not count), built-in + * tools not covered by any allow or ask rule are not registered either + * (MCP tools, the `--json-schema` `structured_output` contract, the + * plan-mode lifecycle tools, `task_stop`, `tool_search`, and the + * `computer_use__*` family are exempt) (#9827). The SDK `allowedTools` + * parameter cannot activate the allowlist on its own, but while the + * allowlist is active its rules are merged into the effective allow set + * and count toward coverage, keeping covered built-ins registered. * Aliases like 'Read', 'Edit', and 'Bash' also work but resolve to single * tools. Specifiers like 'Bash(git *)' are stripped; `coreTools` restricts * tool registration, not invocation. @@ -431,7 +440,7 @@ export interface QueryOptions { excludeTools?: string[]; /** - * Equivalent to `permissions.allow` in settings.json. + * Equivalent to `permissions.allow` in settings.json for auto-approval. * List of tools that are allowed to run without confirmation. * * **Behavior:** @@ -440,6 +449,14 @@ export interface QueryOptions { * - Checked after `excludeTools` but before `canUseTool` callback * - Does not override `permissionMode: 'plan'` (plan mode blocks all write tools) * - Has no effect in `permissionMode: 'yolo'` (already auto-approved) + * - Alone does NOT restrict tool registration: this parameter maps to the + * CLI `--allowed-tools` flag and cannot activate the registry allowlist + * by itself. While a settings-provided `permissions.allow` allowlist is + * active, however, these rules are merged into the effective allow set + * and count toward coverage, so covered built-ins stay registered + * (#9827). To hide unlisted built-in tools from the model request, set + * `permissions.allow` in settings.json (requires restart); that key + * activates the registry allowlist * * **Pattern matching:** * - Tool name: `'write_file'`