diff --git a/packages/types/src/__tests__/cloud.test.ts b/packages/types/src/__tests__/cloud.test.ts index 7a6cebd8a51..be8d631ce0a 100644 --- a/packages/types/src/__tests__/cloud.test.ts +++ b/packages/types/src/__tests__/cloud.test.ts @@ -2,10 +2,12 @@ import { organizationCloudSettingsSchema, + organizationDefaultSettingsSchema, organizationFeaturesSchema, organizationSettingsSchema, userSettingsConfigSchema, type OrganizationCloudSettings, + type OrganizationDefaultSettings, type OrganizationFeatures, type OrganizationSettings, type UserSettingsConfig, @@ -481,3 +483,38 @@ describe("userSettingsConfigSchema with llmEnhancedFeaturesEnabled", () => { expect(result.data?.llmEnhancedFeaturesEnabled).toBe(true) }) }) + +describe("organizationDefaultSettingsSchema with disabledTools", () => { + it("should accept disabledTools as an array of valid tool names", () => { + const input: OrganizationDefaultSettings = { + disabledTools: ["execute_command", "browser_action"], + } + const result = organizationDefaultSettingsSchema.safeParse(input) + expect(result.success).toBe(true) + expect(result.data?.disabledTools).toEqual(["execute_command", "browser_action"]) + }) + + it("should accept empty disabledTools array", () => { + const input: OrganizationDefaultSettings = { + disabledTools: [], + } + const result = organizationDefaultSettingsSchema.safeParse(input) + expect(result.success).toBe(true) + expect(result.data?.disabledTools).toEqual([]) + }) + + it("should accept omitted disabledTools", () => { + const input: OrganizationDefaultSettings = {} + const result = organizationDefaultSettingsSchema.safeParse(input) + expect(result.success).toBe(true) + expect(result.data?.disabledTools).toBeUndefined() + }) + + it("should reject invalid tool names in disabledTools", () => { + const input = { + disabledTools: ["not_a_real_tool"], + } + const result = organizationDefaultSettingsSchema.safeParse(input) + expect(result.success).toBe(false) + }) +}) diff --git a/packages/types/src/cloud.ts b/packages/types/src/cloud.ts index 206a5647b3e..2de8ce9168c 100644 --- a/packages/types/src/cloud.ts +++ b/packages/types/src/cloud.ts @@ -101,6 +101,7 @@ export const organizationDefaultSettingsSchema = globalSettingsSchema terminalShellIntegrationDisabled: true, terminalShellIntegrationTimeout: true, terminalZshClearEolMark: true, + disabledTools: true, }) // Add stronger validations for some fields. .merge( diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 11b9fe148d1..fce48cfb5d5 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -13,6 +13,7 @@ import { experimentsSchema } from "./experiment.js" import { telemetrySettingsSchema } from "./telemetry.js" import { modeConfigSchema } from "./mode.js" import { customModePromptsSchema, customSupportPromptsSchema } from "./mode.js" +import { toolNamesSchema } from "./tool.js" import { languagesSchema } from "./vscode.js" /** @@ -232,6 +233,12 @@ export const globalSettingsSchema = z.object({ * @default true */ showWorktreesInHomeScreen: z.boolean().optional(), + + /** + * List of native tool names to globally disable. + * Tools in this list will be excluded from prompt generation and rejected at execution time. + */ + disabledTools: z.array(toolNamesSchema).optional(), }) export type GlobalSettings = z.infer diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index fa2f04c0e5d..51c7fa49d5e 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -334,6 +334,7 @@ export type ExtensionState = Pick< | "maxGitStatusFiles" | "requestDelaySeconds" | "showWorktreesInHomeScreen" + | "disabledTools" > & { version: string clineMessages: ClineMessage[] diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index c22c369b42d..c183d51ca53 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -335,7 +335,7 @@ export async function presentAssistantMessage(cline: Task) { // Fetch state early so it's available for toolDescription and validation const state = await cline.providerRef.deref()?.getState() - const { mode, customModes, experiments: stateExperiments } = state ?? {} + const { mode, customModes, experiments: stateExperiments, disabledTools } = state ?? {} const toolDescription = (): string => { switch (block.name) { @@ -625,11 +625,20 @@ export async function presentAssistantMessage(cline: Task) { const includedTools = rawIncludedTools?.map((tool) => resolveToolAlias(tool)) try { + const toolRequirements = + disabledTools?.reduce( + (acc: Record, tool: string) => { + acc[tool] = false + return acc + }, + {} as Record, + ) ?? {} + validateToolUse( block.name as ToolName, mode ?? defaultModeSlug, customModes ?? [], - {}, + toolRequirements, block.params, stateExperiments, includedTools, diff --git a/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts b/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts new file mode 100644 index 00000000000..8c6d7ede172 --- /dev/null +++ b/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts @@ -0,0 +1,80 @@ +// npx vitest run core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts + +import type OpenAI from "openai" + +import { filterNativeToolsForMode } from "../filter-tools-for-mode" + +function makeTool(name: string): OpenAI.Chat.ChatCompletionTool { + return { + type: "function", + function: { + name, + description: `${name} tool`, + parameters: { type: "object", properties: {} }, + }, + } as OpenAI.Chat.ChatCompletionTool +} + +describe("filterNativeToolsForMode - disabledTools", () => { + const nativeTools: OpenAI.Chat.ChatCompletionTool[] = [ + makeTool("execute_command"), + makeTool("read_file"), + makeTool("write_to_file"), + makeTool("browser_action"), + makeTool("apply_diff"), + ] + + it("removes tools listed in settings.disabledTools", () => { + const settings = { + disabledTools: ["execute_command", "browser_action"], + } + + const result = filterNativeToolsForMode(nativeTools, "code", undefined, undefined, undefined, settings) + + const resultNames = result.map((t) => (t as any).function.name) + expect(resultNames).not.toContain("execute_command") + expect(resultNames).not.toContain("browser_action") + expect(resultNames).toContain("read_file") + expect(resultNames).toContain("write_to_file") + expect(resultNames).toContain("apply_diff") + }) + + it("does not remove any tools when disabledTools is empty", () => { + const settings = { + disabledTools: [], + } + + const result = filterNativeToolsForMode(nativeTools, "code", undefined, undefined, undefined, settings) + + const resultNames = result.map((t) => (t as any).function.name) + expect(resultNames).toContain("execute_command") + expect(resultNames).toContain("read_file") + expect(resultNames).toContain("write_to_file") + expect(resultNames).toContain("browser_action") + expect(resultNames).toContain("apply_diff") + }) + + it("does not remove any tools when disabledTools is undefined", () => { + const settings = {} + + const result = filterNativeToolsForMode(nativeTools, "code", undefined, undefined, undefined, settings) + + const resultNames = result.map((t) => (t as any).function.name) + expect(resultNames).toContain("execute_command") + expect(resultNames).toContain("read_file") + }) + + it("combines disabledTools with other setting-based exclusions", () => { + const settings = { + browserToolEnabled: false, + disabledTools: ["execute_command"], + } + + const result = filterNativeToolsForMode(nativeTools, "code", undefined, undefined, undefined, settings) + + const resultNames = result.map((t) => (t as any).function.name) + expect(resultNames).not.toContain("execute_command") + expect(resultNames).not.toContain("browser_action") + expect(resultNames).toContain("read_file") + }) +}) diff --git a/src/core/prompts/tools/filter-tools-for-mode.ts b/src/core/prompts/tools/filter-tools-for-mode.ts index 5560fe9bc6d..c034b972d6a 100644 --- a/src/core/prompts/tools/filter-tools-for-mode.ts +++ b/src/core/prompts/tools/filter-tools-for-mode.ts @@ -296,6 +296,13 @@ export function filterNativeToolsForMode( allowedToolNames.delete("browser_action") } + // Remove tools that are explicitly disabled via the disabledTools setting + if (settings?.disabledTools?.length) { + for (const toolName of settings.disabledTools) { + allowedToolNames.delete(toolName) + } + } + // Conditionally exclude access_mcp_resource if MCP is not enabled or there are no resources if (!mcpHub || !hasAnyMcpResources(mcpHub)) { allowedToolNames.delete("access_mcp_resource") diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index d5e9aa0cfb6..f4e41c1bfd7 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1787,6 +1787,7 @@ export class Task extends EventEmitter implements TaskLike { experiments: state?.experiments, apiConfiguration, browserToolEnabled: state?.browserToolEnabled ?? true, + disabledTools: state?.disabledTools, modelInfo, includeAllToolsWithRestrictions: false, }) @@ -3888,6 +3889,7 @@ export class Task extends EventEmitter implements TaskLike { experiments: state?.experiments, apiConfiguration, browserToolEnabled: state?.browserToolEnabled ?? true, + disabledTools: state?.disabledTools, modelInfo, includeAllToolsWithRestrictions: false, }) @@ -4102,6 +4104,7 @@ export class Task extends EventEmitter implements TaskLike { experiments: state?.experiments, apiConfiguration, browserToolEnabled: state?.browserToolEnabled ?? true, + disabledTools: state?.disabledTools, modelInfo, includeAllToolsWithRestrictions: false, }) @@ -4266,6 +4269,7 @@ export class Task extends EventEmitter implements TaskLike { experiments: state?.experiments, apiConfiguration, browserToolEnabled: state?.browserToolEnabled ?? true, + disabledTools: state?.disabledTools, modelInfo, includeAllToolsWithRestrictions: supportsAllowedFunctionNames, }) diff --git a/src/core/task/build-tools.ts b/src/core/task/build-tools.ts index 0206df71c44..ab74f9443ca 100644 --- a/src/core/task/build-tools.ts +++ b/src/core/task/build-tools.ts @@ -23,6 +23,7 @@ interface BuildToolsOptions { experiments: Record | undefined apiConfiguration: ProviderSettings | undefined browserToolEnabled: boolean + disabledTools?: string[] modelInfo?: ModelInfo /** * If true, returns all tools without mode filtering, but also includes @@ -88,6 +89,7 @@ export async function buildNativeToolsArrayWithRestrictions(options: BuildToolsO experiments, apiConfiguration, browserToolEnabled, + disabledTools, modelInfo, includeAllToolsWithRestrictions, } = options @@ -102,6 +104,7 @@ export async function buildNativeToolsArrayWithRestrictions(options: BuildToolsO const filterSettings = { todoListEnabled: apiConfiguration?.todoListEnabled ?? true, browserToolEnabled: browserToolEnabled ?? true, + disabledTools, modelInfo, } diff --git a/src/core/tools/__tests__/validateToolUse.spec.ts b/src/core/tools/__tests__/validateToolUse.spec.ts index 87aa1594208..b4622096ab8 100644 --- a/src/core/tools/__tests__/validateToolUse.spec.ts +++ b/src/core/tools/__tests__/validateToolUse.spec.ts @@ -163,6 +163,15 @@ describe("mode-validator", () => { // Even in code mode which allows all tools, disabled requirement should take precedence expect(isToolAllowedForMode("apply_diff", codeMode, [], requirements)).toBe(false) }) + + it("prioritizes requirements over ALWAYS_AVAILABLE_TOOLS", () => { + // Tools in ALWAYS_AVAILABLE_TOOLS (switch_mode, new_task, etc.) should still + // be blockable via toolRequirements / disabledTools + const requirements = { switch_mode: false, new_task: false, attempt_completion: false } + expect(isToolAllowedForMode("switch_mode", codeMode, [], requirements)).toBe(false) + expect(isToolAllowedForMode("new_task", codeMode, [], requirements)).toBe(false) + expect(isToolAllowedForMode("attempt_completion", codeMode, [], requirements)).toBe(false) + }) }) }) @@ -200,5 +209,50 @@ describe("mode-validator", () => { it("handles undefined requirements gracefully", () => { expect(() => validateToolUse("apply_diff", codeMode, [], undefined)).not.toThrow() }) + + it("blocks tool when disabledTools is converted to toolRequirements", () => { + const disabledTools = ["execute_command", "browser_action"] + const toolRequirements = disabledTools.reduce( + (acc: Record, tool: string) => { + acc[tool] = false + return acc + }, + {} as Record, + ) + + expect(() => validateToolUse("execute_command", codeMode, [], toolRequirements)).toThrow( + 'Tool "execute_command" is not allowed in code mode.', + ) + expect(() => validateToolUse("browser_action", codeMode, [], toolRequirements)).toThrow( + 'Tool "browser_action" is not allowed in code mode.', + ) + }) + + it("allows non-disabled tools when disabledTools is converted to toolRequirements", () => { + const disabledTools = ["execute_command"] + const toolRequirements = disabledTools.reduce( + (acc: Record, tool: string) => { + acc[tool] = false + return acc + }, + {} as Record, + ) + + expect(() => validateToolUse("read_file", codeMode, [], toolRequirements)).not.toThrow() + expect(() => validateToolUse("write_to_file", codeMode, [], toolRequirements)).not.toThrow() + }) + + it("handles empty disabledTools array converted to toolRequirements", () => { + const disabledTools: string[] = [] + const toolRequirements = disabledTools.reduce( + (acc: Record, tool: string) => { + acc[tool] = false + return acc + }, + {} as Record, + ) + + expect(() => validateToolUse("execute_command", codeMode, [], toolRequirements)).not.toThrow() + }) }) }) diff --git a/src/core/tools/validateToolUse.ts b/src/core/tools/validateToolUse.ts index 3579fde32cf..ab261af722e 100644 --- a/src/core/tools/validateToolUse.ts +++ b/src/core/tools/validateToolUse.ts @@ -126,7 +126,19 @@ export function isToolAllowedForMode( experiments?: Record, includedTools?: string[], // Opt-in tools explicitly included (e.g., from modelInfo) ): boolean { - // Always allow these tools + // Check tool requirements first — explicit disabling takes priority over everything, + // including ALWAYS_AVAILABLE_TOOLS. This ensures disabledTools works consistently + // at both the filtering layer and the execution-time validation layer. + if (toolRequirements && typeof toolRequirements === "object") { + if (tool in toolRequirements && !toolRequirements[tool]) { + return false + } + } else if (toolRequirements === false) { + // If toolRequirements is a boolean false, all tools are disabled + return false + } + + // Always allow these tools (unless explicitly disabled above) if (ALWAYS_AVAILABLE_TOOLS.includes(tool as any)) { return true } @@ -147,16 +159,6 @@ export function isToolAllowedForMode( } } - // Check tool requirements if any exist - if (toolRequirements && typeof toolRequirements === "object") { - if (tool in toolRequirements && !toolRequirements[tool]) { - return false - } - } else if (toolRequirements === false) { - // If toolRequirements is a boolean false, all tools are disabled - return false - } - const mode = getModeBySlug(modeSlug, customModes) if (!mode) { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 84cc76825f7..b598a27c272 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2037,6 +2037,7 @@ export class ClineProvider maxOpenTabsContext, maxWorkspaceFiles, browserToolEnabled, + disabledTools, telemetrySetting, showRooIgnoredFiles, enableSubfolderRules, @@ -2174,6 +2175,7 @@ export class ClineProvider maxWorkspaceFiles: maxWorkspaceFiles ?? 200, cwd, browserToolEnabled: browserToolEnabled ?? true, + disabledTools, telemetrySetting, telemetryKey, machineId, @@ -2416,6 +2418,7 @@ export class ClineProvider maxOpenTabsContext: stateValues.maxOpenTabsContext ?? 20, maxWorkspaceFiles: stateValues.maxWorkspaceFiles ?? 200, browserToolEnabled: stateValues.browserToolEnabled ?? true, + disabledTools: stateValues.disabledTools, telemetrySetting: stateValues.telemetrySetting || "unset", showRooIgnoredFiles: stateValues.showRooIgnoredFiles ?? false, enableSubfolderRules: stateValues.enableSubfolderRules ?? false,