mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 03:30:40 +00:00
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
This commit is contained in:
parent
35420b03bc
commit
8d74a0cf0a
13 changed files with 285 additions and 48 deletions
|
|
@ -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. `<profile_name>`: Uses a custom profile. To define a custom profile, create a file named `sandbox-macos-<profile_name>.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. `<profile_name>`: Uses a custom profile. To define a custom profile, create a file named `sandbox-macos-<profile_name>.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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
const workspaceOriginal =
|
||||
loadedSettings.workspace.originalSettings as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const userOriginal = loadedSettings.user
|
||||
.originalSettings as unknown as Record<string, unknown>;
|
||||
const workspaceOriginal = loadedSettings.workspace
|
||||
.originalSettings as unknown as Record<string, unknown>;
|
||||
|
||||
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 [];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -72,6 +72,13 @@ export interface ToolConfig {
|
|||
* that the agent is permitted to use.
|
||||
*/
|
||||
tools: Array<string | FunctionDeclaration>;
|
||||
|
||||
/**
|
||||
* 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[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>) => {
|
||||
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']);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>
|
||||
|
|
@ -1065,6 +1091,7 @@ function parseSubagentContent(
|
|||
name,
|
||||
description,
|
||||
tools,
|
||||
disallowedTools,
|
||||
approvalMode,
|
||||
systemPrompt: systemPrompt.trim(),
|
||||
filePath,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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: '',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue