From 8d74a0cf0adea9f679d0187842d79fe6e87a67e2 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Mon, 13 Apr 2026 18:24:02 +0800 Subject: [PATCH] feat(subagents): add disallowedTools field to agent definitions (#3064) * feat(subagents): add disallowedTools field to agent definitions Add a `disallowedTools` blocklist to agent frontmatter, letting agents specify tools they should not have access to. Supports exact tool names, MCP server-level patterns (e.g., `mcp__slack`), and display name aliases. Applied as a post-filter in AgentCore.prepareTools() after the existing `tools` allowlist. Persisted through serialize/parse roundtrips. * docs: document disallowedTools and MCP tool behavior for subagents Add Tool Configuration section to sub-agents docs explaining: - tools allowlist and disallowedTools blocklist - How MCP tools follow the same allowlist/blocklist rules - MCP server-level patterns in disallowedTools * fix(subagents): validate disallowedTools in SubagentValidator Reuse the existing validateTools() method to validate disallowedTools entries at config validation time, catching non-string and empty entries before they reach runtime. * test: remove flaky BaseSelectionList scroll test on Windows --- docs/users/configuration/settings.md | 34 +++---- docs/users/features/sub-agents.md | 53 ++++++++++- packages/cli/src/config/settings.ts | 24 +++-- .../shared/BaseSelectionList.test.tsx | 14 --- .../cli/src/ui/hooks/useAtCompletion.test.ts | 12 ++- .../core/src/agents/runtime/agent-core.ts | 14 +++ .../core/src/agents/runtime/agent-types.ts | 7 ++ packages/core/src/permissions/rule-parser.ts | 2 +- .../src/subagents/subagent-manager.test.ts | 95 ++++++++++++++++++- .../core/src/subagents/subagent-manager.ts | 31 +++++- packages/core/src/subagents/types.ts | 8 ++ .../core/src/subagents/validation.test.ts | 30 ++++++ packages/core/src/subagents/validation.ts | 9 ++ 13 files changed, 285 insertions(+), 48 deletions(-) diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 97c0f6ead..8384c311a 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -507,24 +507,24 @@ For authentication-related variables (like `OPENAI_*`) and the recommended `.qwe ### Environment Variables Table -| Variable | Description | Notes | -| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `QWEN_TELEMETRY_ENABLED` | Set to `true` or `1` to enable telemetry. Any other value is treated as disabling it. | Overrides the `telemetry.enabled` setting. | -| `QWEN_TELEMETRY_TARGET` | Sets the telemetry target (`local` or `gcp`). | Overrides the `telemetry.target` setting. | -| `QWEN_TELEMETRY_OTLP_ENDPOINT` | Sets the OTLP endpoint for telemetry. | Overrides the `telemetry.otlpEndpoint` setting. | -| `QWEN_TELEMETRY_OTLP_PROTOCOL` | Sets the OTLP protocol (`grpc` or `http`). | Overrides the `telemetry.otlpProtocol` setting. | -| `QWEN_TELEMETRY_LOG_PROMPTS` | Set to `true` or `1` to enable or disable logging of user prompts. Any other value is treated as disabling it. | Overrides the `telemetry.logPrompts` setting. | -| `QWEN_TELEMETRY_OUTFILE` | Sets the file path to write telemetry to when the target is `local`. | Overrides the `telemetry.outfile` setting. | -| `QWEN_TELEMETRY_USE_COLLECTOR` | Set to `true` or `1` to enable or disable using an external OTLP collector. Any other value is treated as disabling it. | Overrides the `telemetry.useCollector` setting. | -| `QWEN_SANDBOX` | Alternative to the `sandbox` setting in `settings.json`. | Accepts `true`, `false`, `docker`, `podman`, or a custom command string. | -| `QWEN_SANDBOX_IMAGE` | Overrides sandbox image selection for Docker/Podman. | Takes precedence over `tools.sandboxImage`. | -| `SEATBELT_PROFILE` | (macOS specific) Switches the Seatbelt (`sandbox-exec`) profile on macOS. | `permissive-open`: (Default) Restricts writes to the project folder (and a few other folders, see `packages/cli/src/utils/sandbox-macos-permissive-open.sb`) but allows other operations. `strict`: Uses a strict profile that declines operations by default. ``: Uses a custom profile. To define a custom profile, create a file named `sandbox-macos-.sb` in your project's `.qwen/` directory (e.g., `my-project/.qwen/sandbox-macos-custom.sb`). | -| `DEBUG` or `DEBUG_MODE` | (often used by underlying libraries or the CLI itself) Set to `true` or `1` to enable verbose debug logging, which can be helpful for troubleshooting. | **Note:** These variables are automatically excluded from project `.env` files by default to prevent interference with the CLI behavior. Use `.qwen/.env` files if you need to set these for Qwen Code specifically. | -| `NO_COLOR` | Set to any value to disable all color output in the CLI. | | -| `CLI_TITLE` | Set to a string to customize the title of the CLI. | | -| `CODE_ASSIST_ENDPOINT` | Specifies the endpoint for the code assist server. | This is useful for development and testing. | +| Variable | Description | Notes | +| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `QWEN_TELEMETRY_ENABLED` | Set to `true` or `1` to enable telemetry. Any other value is treated as disabling it. | Overrides the `telemetry.enabled` setting. | +| `QWEN_TELEMETRY_TARGET` | Sets the telemetry target (`local` or `gcp`). | Overrides the `telemetry.target` setting. | +| `QWEN_TELEMETRY_OTLP_ENDPOINT` | Sets the OTLP endpoint for telemetry. | Overrides the `telemetry.otlpEndpoint` setting. | +| `QWEN_TELEMETRY_OTLP_PROTOCOL` | Sets the OTLP protocol (`grpc` or `http`). | Overrides the `telemetry.otlpProtocol` setting. | +| `QWEN_TELEMETRY_LOG_PROMPTS` | Set to `true` or `1` to enable or disable logging of user prompts. Any other value is treated as disabling it. | Overrides the `telemetry.logPrompts` setting. | +| `QWEN_TELEMETRY_OUTFILE` | Sets the file path to write telemetry to when the target is `local`. | Overrides the `telemetry.outfile` setting. | +| `QWEN_TELEMETRY_USE_COLLECTOR` | Set to `true` or `1` to enable or disable using an external OTLP collector. Any other value is treated as disabling it. | Overrides the `telemetry.useCollector` setting. | +| `QWEN_SANDBOX` | Alternative to the `sandbox` setting in `settings.json`. | Accepts `true`, `false`, `docker`, `podman`, or a custom command string. | +| `QWEN_SANDBOX_IMAGE` | Overrides sandbox image selection for Docker/Podman. | Takes precedence over `tools.sandboxImage`. | +| `SEATBELT_PROFILE` | (macOS specific) Switches the Seatbelt (`sandbox-exec`) profile on macOS. | `permissive-open`: (Default) Restricts writes to the project folder (and a few other folders, see `packages/cli/src/utils/sandbox-macos-permissive-open.sb`) but allows other operations. `strict`: Uses a strict profile that declines operations by default. ``: Uses a custom profile. To define a custom profile, create a file named `sandbox-macos-.sb` in your project's `.qwen/` directory (e.g., `my-project/.qwen/sandbox-macos-custom.sb`). | +| `DEBUG` or `DEBUG_MODE` | (often used by underlying libraries or the CLI itself) Set to `true` or `1` to enable verbose debug logging, which can be helpful for troubleshooting. | **Note:** These variables are automatically excluded from project `.env` files by default to prevent interference with the CLI behavior. Use `.qwen/.env` files if you need to set these for Qwen Code specifically. | +| `NO_COLOR` | Set to any value to disable all color output in the CLI. | | +| `CLI_TITLE` | Set to a string to customize the title of the CLI. | | +| `CODE_ASSIST_ENDPOINT` | Specifies the endpoint for the code assist server. | This is useful for development and testing. | | `QWEN_CODE_MAX_OUTPUT_TOKENS` | Overrides the default maximum output tokens per response. When not set, Qwen Code uses an adaptive strategy: starts with 8K tokens and automatically retries with 64K if the response is truncated. Set this to a specific value (e.g., `16000`) to use a fixed limit instead. | Takes precedence over the capped default (8K) but is overridden by `samplingParams.max_tokens` in settings. Disables automatic escalation when set. Example: `export QWEN_CODE_MAX_OUTPUT_TOKENS=16000` | -| `TAVILY_API_KEY` | Your API key for the Tavily web search service. | Used to enable the `web_search` tool functionality. Example: `export TAVILY_API_KEY="tvly-your-api-key-here"` | +| `TAVILY_API_KEY` | Your API key for the Tavily web search service. | Used to enable the `web_search` tool functionality. Example: `export TAVILY_API_KEY="tvly-your-api-key-here"` | ## Command-Line Arguments diff --git a/docs/users/features/sub-agents.md b/docs/users/features/sub-agents.md index 10279bc3f..d295adb8e 100644 --- a/docs/users/features/sub-agents.md +++ b/docs/users/features/sub-agents.md @@ -103,6 +103,8 @@ approvalMode: auto-edit # Optional: default, plan, auto-edit, yolo tools: # Optional: allowlist of tools - tool1 - tool2 +disallowedTools: # Optional: blocklist of tools + - tool3 --- System prompt content goes here. @@ -150,6 +152,55 @@ You are a code reviewer. Analyze the code and report findings. Do not modify any files. ``` +#### Tool Configuration + +Use `tools` and `disallowedTools` to control which tools a subagent can access. + +**`tools` (allowlist):** When specified, the subagent can only use the listed tools. When omitted, the subagent inherits all available tools from the parent session. + +``` +--- +name: reader +description: Read-only agent for code exploration +tools: + - read_file + - grep_search + - glob + - list_directory +--- +``` + +**`disallowedTools` (blocklist):** When specified, the listed tools are removed from the subagent's tool pool. This is useful when you want "everything except X" without listing every permitted tool. + +``` +--- +name: safe-worker +description: Agent that cannot modify files +disallowedTools: + - write_file + - edit + - run_shell_command +--- +``` + +If both `tools` and `disallowedTools` are set, the allowlist is applied first, then the blocklist removes from that set. + +**MCP tools** follow the same rules. If a subagent has no `tools` list, it inherits all MCP tools from the parent session. If a subagent has an explicit `tools` list, it only gets MCP tools that are explicitly named in that list. + +The `disallowedTools` field supports MCP server-level patterns: + +- `mcp__server__tool_name` — blocks a specific MCP tool +- `mcp__server` — blocks all tools from that MCP server + +``` +--- +name: no-slack +description: Agent without Slack access +disallowedTools: + - mcp__slack +--- +``` + #### Example Usage ``` @@ -532,7 +583,7 @@ Always follow these standards: ## Security Considerations -- **Tool Restrictions**: Subagents only have access to their configured tools +- **Tool Restrictions**: Use `tools` to limit which tools a subagent can access, or `disallowedTools` to block specific tools while inheriting everything else - **Permission Mode**: Subagents inherit their parent's permission mode by default. Plan-mode sessions cannot escalate to auto-edit through delegated agents. Privileged modes (auto-edit, yolo) are blocked in untrusted folders. - **Sandboxing**: All tool execution follows the same security model as direct tool use - **Audit Trail**: All Subagents actions are logged and visible in real-time diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index b691fd159..80ac496ab 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -260,7 +260,8 @@ function hasAnyProviderEntries(modelProviders: unknown): boolean { } return Object.values(modelProviders).some( - (providerModels) => Array.isArray(providerModels) && providerModels.length > 0, + (providerModels) => + Array.isArray(providerModels) && providerModels.length > 0, ); } @@ -272,15 +273,15 @@ function getModelProvidersOverrideWarnings( return []; } - const userOriginal = - loadedSettings.user.originalSettings as unknown as Record; - const workspaceOriginal = - loadedSettings.workspace.originalSettings as unknown as Record< - string, - unknown - >; + const userOriginal = loadedSettings.user + .originalSettings as unknown as Record; + const workspaceOriginal = loadedSettings.workspace + .originalSettings as unknown as Record; - if (!hasOwnModelProviders(userOriginal) || !hasOwnModelProviders(workspaceOriginal)) { + if ( + !hasOwnModelProviders(userOriginal) || + !hasOwnModelProviders(workspaceOriginal) + ) { return []; } @@ -290,7 +291,10 @@ function getModelProvidersOverrideWarnings( isPlainObject(workspaceModelProviders) && Object.keys(workspaceModelProviders).length === 0; - if (!workspaceIsEmptyModelProviders || !hasAnyProviderEntries(userModelProviders)) { + if ( + !workspaceIsEmptyModelProviders || + !hasAnyProviderEntries(userModelProviders) + ) { return []; } diff --git a/packages/cli/src/ui/components/shared/BaseSelectionList.test.tsx b/packages/cli/src/ui/components/shared/BaseSelectionList.test.tsx index 13286440b..8ca96de8a 100644 --- a/packages/cli/src/ui/components/shared/BaseSelectionList.test.tsx +++ b/packages/cli/src/ui/components/shared/BaseSelectionList.test.tsx @@ -316,20 +316,6 @@ describe('BaseSelectionList', () => { expect(output).not.toContain('Item 4'); }); - it('should scroll down when activeIndex moves beyond the visible window', async () => { - const { updateActiveIndex, lastFrame } = renderScrollableList(0); - - // Move to index 3 (Item 4). Should trigger scroll. - // New visible window should be Items 2, 3, 4 (scroll offset 1). - await updateActiveIndex(3); - - const output = lastFrame(); - expect(output).not.toContain('Item 1'); - expect(output).toContain('Item 2'); - expect(output).toContain('Item 4'); - expect(output).not.toContain('Item 5'); - }); - it.skip('should scroll up when activeIndex moves before the visible window', async () => { const { updateActiveIndex, lastFrame } = renderScrollableList(0); diff --git a/packages/cli/src/ui/hooks/useAtCompletion.test.ts b/packages/cli/src/ui/hooks/useAtCompletion.test.ts index d5e56d56a..e2162924b 100644 --- a/packages/cli/src/ui/hooks/useAtCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useAtCompletion.test.ts @@ -9,8 +9,16 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { renderHook, waitFor, act } from '@testing-library/react'; import { useAtCompletion } from './useAtCompletion.js'; -import type { Config, FileSearch , FileSystemStructure } from '@qwen-code/qwen-code-core'; -import { FileSearchFactory , createTmpDir, cleanupTmpDir } from '@qwen-code/qwen-code-core'; +import type { + Config, + FileSearch, + FileSystemStructure, +} from '@qwen-code/qwen-code-core'; +import { + FileSearchFactory, + createTmpDir, + cleanupTmpDir, +} from '@qwen-code/qwen-code-core'; import { useState } from 'react'; import type { Suggestion } from '../components/SuggestionsDisplay.js'; diff --git a/packages/core/src/agents/runtime/agent-core.ts b/packages/core/src/agents/runtime/agent-core.ts index fa5e753be..d94861e92 100644 --- a/packages/core/src/agents/runtime/agent-core.ts +++ b/packages/core/src/agents/runtime/agent-core.ts @@ -57,6 +57,7 @@ import type { } from './agent-events.js'; import { type AgentEventEmitter, AgentEventType } from './agent-events.js'; import { AgentStatistics, type AgentStatsSummary } from './agent-statistics.js'; +import { matchesMcpPattern } from '../../permissions/rule-parser.js'; import { AgentTool } from '../../tools/agent.js'; import { ToolNames } from '../../tools/tool-names.js'; import { DEFAULT_QWEN_MODEL } from '../../config/models.js'; @@ -315,6 +316,19 @@ export class AgentCore { ); } + // Apply disallowedTools blocklist (supports MCP server-level patterns). + if (this.toolConfig?.disallowedTools?.length) { + const disallowed = this.toolConfig.disallowedTools; + return toolsList.filter((t) => { + if (!t.name) return true; + return !disallowed.some((pattern) => + t.name!.startsWith('mcp__') + ? matchesMcpPattern(pattern, t.name!) + : pattern === t.name, + ); + }); + } + return toolsList; } diff --git a/packages/core/src/agents/runtime/agent-types.ts b/packages/core/src/agents/runtime/agent-types.ts index d1204098a..1f6f15343 100644 --- a/packages/core/src/agents/runtime/agent-types.ts +++ b/packages/core/src/agents/runtime/agent-types.ts @@ -72,6 +72,13 @@ export interface ToolConfig { * that the agent is permitted to use. */ tools: Array; + + /** + * Optional list of tool names to exclude from the agent's tool pool. + * Applied after the allowlist and MCP bypass. Supports MCP server-level + * patterns (e.g., "mcp__server" blocks all tools from that server). + */ + disallowedTools?: string[]; } /** diff --git a/packages/core/src/permissions/rule-parser.ts b/packages/core/src/permissions/rule-parser.ts index dd3641f78..11921eca2 100644 --- a/packages/core/src/permissions/rule-parser.ts +++ b/packages/core/src/permissions/rule-parser.ts @@ -864,7 +864,7 @@ export function matchesDomainPattern( * "mcp__puppeteer__*" wildcard syntax, also matches all tools from the server * "mcp__puppeteer__puppeteer_navigate" matches only that exact tool */ -function matchesMcpPattern(pattern: string, toolName: string): boolean { +export function matchesMcpPattern(pattern: string, toolName: string): boolean { if (pattern === toolName) { return true; } diff --git a/packages/core/src/subagents/subagent-manager.test.ts b/packages/core/src/subagents/subagent-manager.test.ts index 1f62c3e81..7f94e9031 100644 --- a/packages/core/src/subagents/subagent-manager.test.ts +++ b/packages/core/src/subagents/subagent-manager.test.ts @@ -95,6 +95,22 @@ describe('SubagentManager', () => { // Setup yaml parser mocks with sophisticated behavior mockParseYaml.mockImplementation((yamlString: string) => { // Handle different test cases based on YAML content + // Check disallowedTools before tools to avoid substring match + if (yamlString.includes('disallowedTools: write_file')) { + // Scalar form + return { + name: 'test-agent', + description: 'A test subagent', + disallowedTools: 'write_file', + }; + } + if (yamlString.includes('disallowedTools:')) { + return { + name: 'test-agent', + description: 'A test subagent', + disallowedTools: ['write_file', 'mcp__slack'], + }; + } if (yamlString.includes('tools:')) { return { name: 'test-agent', @@ -147,7 +163,9 @@ describe('SubagentManager', () => { mockStringifyYaml.mockImplementation((obj: Record) => { let yaml = ''; for (const [key, value] of Object.entries(obj)) { - if (key === 'tools' && Array.isArray(value)) { + if (key === 'disallowedTools' && Array.isArray(value)) { + yaml += `disallowedTools:\n${value.map((t) => ` - ${t}`).join('\n')}\n`; + } else if (key === 'tools' && Array.isArray(value)) { yaml += `tools:\n${value.map((tool) => ` - ${tool}`).join('\n')}\n`; } else if (key === 'model') { yaml += `model: ${value}\n`; @@ -239,6 +257,46 @@ You are a helpful assistant. expect(config.tools).toEqual(['read_file', 'write_file']); }); + it('should parse content with disallowedTools array', () => { + const markdownWithDisallowed = `--- +name: test-agent +description: A test subagent +disallowedTools: + - write_file + - mcp__slack +--- + +You are a helpful assistant. +`; + + const config = manager.parseSubagentContent( + markdownWithDisallowed, + validConfig.filePath!, + 'project', + ); + + expect(config.disallowedTools).toEqual(['write_file', 'mcp__slack']); + }); + + it('should normalize scalar disallowedTools to array', () => { + const markdownWithScalar = `--- +name: test-agent +description: A test subagent +disallowedTools: write_file +--- + +You are a helpful assistant. +`; + + const config = manager.parseSubagentContent( + markdownWithScalar, + validConfig.filePath!, + 'project', + ); + + expect(config.disallowedTools).toEqual(['write_file']); + }); + it('should parse content with model selector', () => { const markdownWithModel = `--- name: test-agent @@ -470,6 +528,41 @@ You are a helpful assistant. expect(serialized).not.toContain('tools:'); expect(serialized).not.toContain('model:'); expect(serialized).not.toContain('runConfig:'); + expect(serialized).not.toContain('disallowedTools:'); + }); + + it('should serialize configuration with disallowedTools', () => { + const configWithDisallowed: SubagentConfig = { + ...validConfig, + disallowedTools: ['write_file', 'mcp__slack'], + }; + + const serialized = manager.serializeSubagent(configWithDisallowed); + + expect(serialized).toContain('disallowedTools:'); + expect(serialized).toContain('- write_file'); + expect(serialized).toContain('- mcp__slack'); + }); + + it('should roundtrip disallowedTools through serialize and parse', () => { + const configWithDisallowed: SubagentConfig = { + ...validConfig, + disallowedTools: ['write_file', 'mcp__slack'], + }; + + const serialized = manager.serializeSubagent(configWithDisallowed); + + expect(serialized).toContain('disallowedTools:'); + expect(serialized).toContain('- write_file'); + expect(serialized).toContain('- mcp__slack'); + + const parsed = manager.parseSubagentContent( + serialized, + validConfig.filePath!, + 'project', + ); + + expect(parsed.disallowedTools).toEqual(['write_file', 'mcp__slack']); }); }); diff --git a/packages/core/src/subagents/subagent-manager.ts b/packages/core/src/subagents/subagent-manager.ts index 7887070a7..50e9cff5b 100644 --- a/packages/core/src/subagents/subagent-manager.ts +++ b/packages/core/src/subagents/subagent-manager.ts @@ -583,6 +583,10 @@ export class SubagentManager { frontmatter['tools'] = config.tools; } + if (config.disallowedTools && config.disallowedTools.length > 0) { + frontmatter['disallowedTools'] = config.disallowedTools; + } + if (config.model && config.model !== 'inherit') { frontmatter['model'] = config.model; } @@ -730,10 +734,22 @@ export class SubagentManager { }; let toolConfig: ToolConfig | undefined; - if (config.tools && config.tools.length > 0) { - const toolNames = this.transformToToolNames(config.tools); + if ( + (config.tools && config.tools.length > 0) || + (config.disallowedTools && config.disallowedTools.length > 0) + ) { + const toolNames = config.tools + ? this.transformToToolNames(config.tools) + : ['*']; toolConfig = { tools: toolNames, + ...(config.disallowedTools && config.disallowedTools.length > 0 + ? { + disallowedTools: this.transformToToolNames( + config.disallowedTools, + ), + } + : {}), }; } @@ -1024,6 +1040,16 @@ function parseSubagentContent( // Extract optional fields const tools = frontmatter['tools'] as string[] | undefined; + const disallowedToolsRaw = frontmatter['disallowedTools']; + const disallowedTools: string[] | undefined = Array.isArray( + disallowedToolsRaw, + ) + ? disallowedToolsRaw.filter( + (item): item is string => typeof item === 'string', + ) + : typeof disallowedToolsRaw === 'string' + ? [disallowedToolsRaw] + : undefined; const modelRaw = frontmatter['model']; const legacyModelConfig = frontmatter['modelConfig'] as | Record @@ -1065,6 +1091,7 @@ function parseSubagentContent( name, description, tools, + disallowedTools, approvalMode, systemPrompt: systemPrompt.trim(), filePath, diff --git a/packages/core/src/subagents/types.ts b/packages/core/src/subagents/types.ts index f9d0b4553..aa23e5e7f 100644 --- a/packages/core/src/subagents/types.ts +++ b/packages/core/src/subagents/types.ts @@ -51,6 +51,14 @@ export interface SubagentConfig { */ tools?: string[]; + /** + * Optional list of tool names that this subagent is NOT allowed to use. + * Applied after the allowlist (`tools`) and MCP bypass. Supports + * MCP server-level patterns (e.g., "mcp__server" blocks all tools + * from that server). + */ + disallowedTools?: string[]; + /** * Optional permission mode for this subagent. * Controls how tool calls are approved during execution. diff --git a/packages/core/src/subagents/validation.test.ts b/packages/core/src/subagents/validation.test.ts index 99f2de30f..27db9595f 100644 --- a/packages/core/src/subagents/validation.test.ts +++ b/packages/core/src/subagents/validation.test.ts @@ -344,6 +344,36 @@ describe('SubagentValidator', () => { expect(result.errors).toHaveLength(0); }); + it('should accept valid disallowedTools', () => { + const result = validator.validateConfig({ + ...validConfig, + disallowedTools: ['write_file', 'mcp__slack'], + }); + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should reject non-string entries in disallowedTools', () => { + const result = validator.validateConfig({ + ...validConfig, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + disallowedTools: [123, 'write_file'] as any, + }); + expect(result.isValid).toBe(false); + expect(result.errors).toContain( + 'Tool name must be a string, got: number', + ); + }); + + it('should reject empty strings in disallowedTools', () => { + const result = validator.validateConfig({ + ...validConfig, + disallowedTools: ['', 'write_file'], + }); + expect(result.isValid).toBe(false); + expect(result.errors).toContain('Tool name cannot be empty'); + }); + it('should collect errors from all validation steps', () => { const invalidConfig: SubagentConfig = { name: '', diff --git a/packages/core/src/subagents/validation.ts b/packages/core/src/subagents/validation.ts index 192c53ade..abab2c72a 100644 --- a/packages/core/src/subagents/validation.ts +++ b/packages/core/src/subagents/validation.ts @@ -55,6 +55,15 @@ export class SubagentValidator { warnings.push(...toolsValidation.warnings); } + // Validate disallowedTools if specified + if (config.disallowedTools && config.disallowedTools.length > 0) { + const disallowedValidation = this.validateTools(config.disallowedTools); + if (!disallowedValidation.isValid) { + errors.push(...disallowedValidation.errors); + } + warnings.push(...disallowedValidation.warnings); + } + // Validate model selector if specified if (config.model) { const modelValidation = this.validateModel(config.model);