mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-10-19 12:43:39 +00:00
fix(core): make permissions.allow restrict the tool schemas sent to the model (#9829)
* fix(core): make permissions.allow restrict the tool schemas sent to the model (#9827) permissions.allow only auto-approved calls; it never gated tool registration, so the outgoing tools array kept every built-in schema even when an allowlist was configured — contradicting the settings docs migration table ("unlisted tools are disabled at registry level") and breaking backends like llama.cpp that compile all tool schemas into a single grammar. - Activate a registry-level allowlist when settings.permissions.allow has at least one valid rule: built-in tools not covered by any allow rule are no longer registered (absent from /tools and the API request). MCP tools and the structured_output contract stay exempt; session-granted rules ("always allow", skill allowedTools) extend membership but never activate the allowlist mid-session. --allowed-tools / SDK allowedTools / legacy tools.allowed keep their pure auto-approval semantics. - Complete the rule alias map so the display names shown by /tools (SendMessage, UpdateGoal, ...) match in allow/deny rules. * fix(core): honor permissions.allow in the list_directory opt-in gate (#9827) isLsToolEnabled() only read tools.listDirectory.enabled and the coreTools allowlist, so an explicitly allowlisted list_directory passed PermissionManager.isToolEnabled() but was never registered — absent from /tools and the model request, with calls failing TOOL_NOT_REGISTERED. This broke the documented tools.core -> permissions.allow migration equivalence for exactly this tool. Consult getRegistryAllowList() with the same coverage semantics the registry gate uses (toolMatchesRuleToolName, so Read / ListFiles / specifier forms all count). * fix(core): keep plan-mode lifecycle tools registered under the allowlist (#9827) The permissions.allow registry gate covered exit_plan_mode / enter_plan_mode / ask_user_question, so the exact reporter configuration unregistered them. The plan-mode system reminder still instructs the model to present its plan by calling exit_plan_mode, whose schema is then never sent, so the sanctioned plan flow cannot complete. Exempt the three plan-mode lifecycle tools alongside structured_output (same synthetic- system-tool class the CORE_TOOLS docstring names; deny rules still apply). * docs(sdk): correct allowedTools registry-allowlist contract (#9827) The JSDoc added for QueryOptions.allowedTools (and the coreTools block) claimed the SDK allowedTools param activates the registry allowlist and hides unlisted built-in schemas. It does not: ProcessTransport maps it to the CLI --allowed-tools flag, and this PR's CLI wiring builds registryAllowList only from settings.permissions.allow. Reword both JSDoc blocks and the two hand-maintained SDK doc pages (sdk-typescript.md, sdk-typescript/README.md) to the shipped contract: allowedTools stays a pure auto-approval grant; only permissions.allow in settings.json (requires restart) activates the registry allowlist. * docs(settings): note plan-mode lifecycle exemption in the allowlist (#9827) The permissions.allow registry-allowlist exemption list named only MCP tools and the structured_output contract. Add the plan-mode lifecycle tools (exit_plan_mode / enter_plan_mode / ask_user_question) exempted in b8ba258c40 so the documented exemption set matches the gate. * fix(core): exempt the computer_use__* family from the registry allowlist (#9827) * fix(core): gate command-discovered tools through the registry allowlist (#9827) * fix(core): make registry-allowlist membership monotonic within the session (#9827) * fix(core): narrow the skill allowedTools grant contract to restart-scoped registration (#9827) * fix(core): count ask rules toward registry-allowlist membership (#9827) A tool covered only by a permissions.ask rule was silently deregistered whenever the permissions.allow registry allowlist was active: allow ["ReadFile"] + ask ["Shell"] hid the whole shell family from the model, so the documented "always require user confirmation" silently became "tool unavailable" and the ask rule could never fire. Ask rules express "this tool must stay usable, with confirmation", so they now count toward registry membership (frozen at startup for the same restart-scoped monotonicity as allow rules). * docs(settings): note that ask rules keep tools registered under allowlist (#9827) * test(cli): pin registry-allowlist strip in bare mode (#9827) The wiring tests only covered the safe-mode half of registryAllowList: bareMode || safeMode ? undefined : ... — a mutant dropping the bareMode guard survived the suite and would activate the allowlist from settings while bare mode strips those same rules from the merged allow set, leaving the bare registry's minimal toolset ungated. Mirror the safe-mode test for --bare. * fix(core): attribute registry-allowlist misses to permissions.allow (#9827) An allowlist-miss rejection surfaced as "Qwen Code requires permission to use X, but that permission was declined" citing a deny rule that does not exist (findMatchingDenyRule finds nothing) and never mentioning permissions.allow. When no deny rule matched and the registry allowlist is active, emit a distinct message pointing at the real config knob. * test(core): pin resolveToolName coverage of every ToolNames entry (#9827) TOOL_NAME_ALIASES hand-maintains the canonical/display-name mappings that tool-names.ts declares; nothing enforced the sync, so a tool added to tool-names.ts without an alias entry would compile, pass every test, and silently never match a permission rule — the exact #9827 bug class, now with higher stakes since a missed entry also breaks allowlist coverage. Walk every ToolNames/ToolDisplayNames pair and assert it round-trips through resolveToolName. * fix(core): expose isPermissionsAllowListActive on scoped PM shims (#9827) * fix(core): honour ask-only list_directory coverage in the opt-in gate (#9827) * docs: align registry-allowlist contract wording across docs and JSDoc (#9827) * docs: scope settings.md removal and whole-tool-deny claims precisely (#9827) * fix(core): count merged allow coverage in the list_directory opt-in gate (#9827) isLsToolEnabled() scanned only the settings-sourced getRegistryAllowList() for allow coverage while PermissionManager.isToolEnabled() counts the merged allow set (settings + --allowed-tools + SDK allowedTools + legacy tools.allowed). Under an active allowlist, list_directory covered only by a merged rule passed isToolEnabled but was never offered to registerLazy — it vanished from /tools and the model request while calls failed TOOL_NOT_REGISTERED. Count the merged allow set for coverage (activation still requires a valid settings rule) and filter empty/whitespace-only entries from activation exactly like PermissionManager.initialize's parseRules does, so a degenerate [""] entry cannot activate the gate here while the permission system reports the allowlist inactive. * test(core): pin activation source and merged-allow coverage of the list_directory gate (#9827) Every existing isLsToolEnabled test fed the identical array as both allow and registryAllowList, so the settings-only vs merged-allow distinction was unpinned and the R4-1 divergence shipped uncovered. Add three cases shaped like the CLI wiring: coverage by a merged (non-settings) allow rule under an active allowlist registers the tool; merged-only coverage with no settings rule does not activate the allowlist; an empty settings entry ([""]) does not activate it either. * fix(core): attribute scheduler denials to permissions.allow only for uncovered tools (#9827) The allowlist-miss message fired for any disabled tool with no matching deny rule while the allowlist is active — including tools rejected by the legacy coreTools gate that ARE covered by an allow rule, where 'not covered by any permissions.allow rule' is wrong and the remediation a no-op. Expose isCoveredByAllowOrAskRule on PermissionManager and take the allowlist branch only when the tool is genuinely uncovered; covered tools fall back to the generic declined message. The optional call keeps scoped PermissionManager shims (installed via 'as unknown as PermissionManager') from throwing until they grow the delegation. * test(core): pin the covered-tool fallback for scheduler denial messages (#9827) Add a scheduler-level case where the allowlist is active, no deny rule matches, and the disabled tool IS covered by an allow rule (the legacy coreTools gate shape): the message must be the generic declined one, not the permissions.allow attribution. Also make the existing allowlist-miss stub explicit about coverage. * fix(core): register request_shutdown in the permission rule alias map (#9827) Merging origin/main brought ToolNames.REQUEST_SHUTDOWN (#9806) but no TOOL_NAME_ALIASES entry, which the resolveToolName exhaustiveness test added on this branch pins. Map request_shutdown / RequestShutdown so permission rules can address the tool. * fix(core): guard the list_directory allowlist gate against non-string rules (#9827) isLsToolEnabled()'s activation check and coverage scan called raw.trim() / parseRule(raw) directly while PermissionManager.initialize computes the same thing through parseRules, whose r && r.trim() filter skips falsy entries. Settings load performs no element-type validation (the schema declares only type: array), so a stray null in settings.permissions.allow/ask — or in the legacy tools.allowed key riding the merged coverage set — threw TypeError during createToolRegistry and crashed startup while PermissionManager.initialize tolerated the same settings file. Mirror the parseRules guard with a typeof check in both the activation check and the coverage predicate, and pin both arms (tolerated entries still activate/cover, a [null]-only list keeps the gate closed). * fix(core): exempt task_stop from the permissions.allow registry gate (#9827) task_stop satisfies the PR's own two written exemption criteria but was missing from the set: it is shouldDefer=true (task-stop.ts), the exact deferred-schema property the computer_use__* exemption cites, and it is advertised to the model by a registered tool's copy — 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. Under the reporter configuration the suite pins, run_shell_command stays listed while task_stop was gated out, so the sanctioned stop flow failed. Add the exemption and pin it next to the plan-mode exemption tests, including that a whole-tool deny rule still wins via the existing evaluate pass. * fix(core): keep shim denials on the pre-#9827 message when coverage is unknown (#9827) The optional isCoveredByAllowOrAskRule call's : true fallback routed shim-mediated rejections of COVERED tools into the new allowlist-attribution message, contradicting the comment above it ('they keep the pre-#9827 message meanwhile'). Both production shims (memory-scoped-agent-config.ts, skillReviewAgentPlanner.ts) Pick a partial interface without isCoveredByAllowOrAskRule, so for them the ternary always took the allowlist arm — telling the user a covered tool 'is not covered by any permissions.allow rule' when a different gate (e.g. the legacy coreTools allowlist) rejected it. Flip the fallback to false so unknown coverage stays on the pre-#9827 declined message, and update the shim test to pin that message instead of the allowlist one. * test(core): pin that ask-only rules never activate the allowlist (#9827) The suite pins ask rules counting toward allowlist membership, but nothing pins the complementary activation boundary: no test constructed a PermissionManager with only permissionsAsk (no permissionsAllow) and asserted the allowlist stays inactive. Current behavior is correct; this guards against a future edit folding ask rules into activation, which would turn an ask-only posture (permissions.ask: ["Shell"], no allow rules — a natural 'always confirm shell' config) into an active allowlist that deregisters every unlisted built-in. The nearest existing test ('no allow rules → allowlist inactive') uses no rules at all and would still pass. * fix(core): exempt tool_search from the permissions.allow registry gate (#9827) Under a narrow active allowlist, tool_search itself was gated out of the registry. Without ToolSearch, 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 — inverting the schema-shrink goal into maximal schema bloat for exactly the deferred families the other exemptions preserve for ToolSearch discoverability. Pre-#9827 tool_search always bypassed the legacy coreTools gate as a non-core tool. * test(core): pin the deny-rule arm's precedence in the scheduler permission message (#9827) The three-way message branch in CoreToolScheduler covers the allowlist-miss arm and the generic fallback arm, but every findMatchingDenyRule mock returned undefined, so the deny-rule arm — whose position FIRST in the if/else-if chain is what makes a real denial cite the matching rule instead of the allowlist attribution — had no scheduler-level coverage. Add two tests where findMatchingDenyRule returns a matching rule: one with the allowlist arm armed (active allowlist + uncovered tool) pinning the if/else-if ordering, one without an active allowlist pinning the deny arm over the generic declined fallback. Mutation-checked: disabling the deny arm fails both tests. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * test(core): pin deny/ask sibling semantics at the discovery gate (#9827) The discovery-gate test built its PermissionManager with EMPTY ask/deny lists, so the gate's two documented sibling semantics were unpinned: settings.md says a whole-tool deny rule removes a discovered tool from the registry even under an active allowlist, and an ask rule keeps a discovered tool registered ("always require confirmation" must never silently become "tool unavailable"). Add two discovery-gate tests with deny-covered and ask-covered PermissionManager configurations: the denied tool is also allow-covered so only the deny branch of isToolEnabled can reject it, and the ask test carries an uncovered control tool proving the gate is active in the same run. Mutation-checked: ignoring deny decisions fails the deny test only; dropping ask coverage from isCoveredByAllowOrAskRule fails the ask test only. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * docs: match the allowlist activation wording to the real predicate (#9827) Four surfaces said the permissions.allow registry allowlist activates "when at least one allow rule is configured", but PermissionManager.initialize computes activation as at least one VALID rule from settings.permissions.allow only (getRegistryAllowList): a malformed entry never activates it, and auto-approval-only sources such as the --allowed-tools CLI flag / the SDK allowedTools parameter never do either. Reword settings.md, the SDK docs, the sdk-typescript README and the coreTools JSDoc to the exact predicate, and complete their exemption lists with task_stop and tool_search, which isToolEnabled exempts but the docs did not name. Docs-only. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> --------- Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com> Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
parent
519e824dc7
commit
f2e593c26b
21 changed files with 2291 additions and 75 deletions
|
|
@ -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<string, string>` | - | 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<string, McpServerConfig>` | - | 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<string, string>` | - | 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<string, McpServerConfig>` | - | 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.
|
||||
|
|
|
|||
|
|
@ -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):**
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -8271,6 +8271,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,
|
||||
|
|
@ -8607,6 +8845,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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<boolean>;
|
||||
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',
|
||||
|
|
@ -3525,6 +3533,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<string, MockTool>([
|
||||
[
|
||||
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<string, MockTool>([
|
||||
[
|
||||
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<string, MockTool>([
|
||||
[
|
||||
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<string, MockTool>([
|
||||
[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<string, MockTool>([
|
||||
[
|
||||
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<string, MockTool>([
|
||||
[
|
||||
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<boolean>;
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<PermissionManager, 'isPermissionsAllowListActive'> = {
|
||||
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', () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<PermissionManager, 'isPermissionsAllowListActive'> = {
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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({}));
|
||||
|
|
|
|||
|
|
@ -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<string> | 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<string> = new Set([
|
||||
ToolNames.EXIT_PLAN_MODE,
|
||||
ToolNames.ENTER_PLAN_MODE,
|
||||
ToolNames.ASK_USER_QUESTION,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Check if a tool is a core tool subject to the coreTools allowlist check.
|
||||
*/
|
||||
|
|
@ -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<boolean> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -156,6 +156,104 @@ export const TOOL_NAME_ALIASES: Readonly<Record<string, string>> = {
|
|||
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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
||||
|
|
|
|||
|
|
@ -270,6 +270,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(
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
|
|
|
|||
|
|
@ -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' &&
|
||||
|
|
|
|||
|
|
@ -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<string, string>` | - | 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<string, McpServerConfig>` | - | 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<string, string>` | - | 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<string, McpServerConfig>` | - | 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.
|
||||
|
|
|
|||
|
|
@ -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'`
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue