From ea68c491172f50ea99b7713581834921e8e819dd Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Fri, 6 Feb 2026 16:54:46 -0500 Subject: [PATCH 1/2] feat: add disabledTools setting to globally disable native tools Add a disabledTools field to GlobalSettings that allows disabling specific native tools by name. This enables cloud agents to be configured with restricted tool access. Schema: - Add disabledTools: z.array(toolNamesSchema).optional() to globalSettingsSchema - Add disabledTools to organizationDefaultSettingsSchema.pick() - Add disabledTools to ExtensionState Pick type Prompt generation (tool filtering): - Add disabledTools to BuildToolsOptions interface - Pass disabledTools through filterSettings to filterNativeToolsForMode() - Remove disabled tools from allowedToolNames set in filterNativeToolsForMode() Execution-time validation (safety net): - Extract disabledTools from state in presentAssistantMessage - Convert disabledTools to toolRequirements format for validateToolUse() Wiring: - Add disabledTools to ClineProvider getState() and getStateToPostToWebview() - Pass disabledTools to all buildNativeToolsArrayWithRestrictions() call sites EXT-778 --- packages/types/src/__tests__/cloud.test.ts | 37 +++++++++ packages/types/src/cloud.ts | 1 + packages/types/src/global-settings.ts | 7 ++ packages/types/src/vscode-extension-host.ts | 1 + .../presentAssistantMessage.ts | 13 ++- .../__tests__/filter-tools-for-mode.spec.ts | 80 +++++++++++++++++++ .../prompts/tools/filter-tools-for-mode.ts | 7 ++ src/core/task/Task.ts | 4 + src/core/task/build-tools.ts | 3 + .../tools/__tests__/validateToolUse.spec.ts | 45 +++++++++++ src/core/webview/ClineProvider.ts | 3 + 11 files changed, 199 insertions(+), 2 deletions(-) create mode 100644 src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts 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..db315bca118 100644 --- a/src/core/tools/__tests__/validateToolUse.spec.ts +++ b/src/core/tools/__tests__/validateToolUse.spec.ts @@ -200,5 +200,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/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, From 975c425d385a7e0fac9786f55eac0772d89cb1e0 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Fri, 6 Feb 2026 18:28:28 -0500 Subject: [PATCH 2/2] fix: check toolRequirements before ALWAYS_AVAILABLE_TOOLS Moves the toolRequirements check before the ALWAYS_AVAILABLE_TOOLS early-return in isToolAllowedForMode(). This ensures disabledTools can block always-available tools (switch_mode, new_task, etc.) at execution time, making the validation layer consistent with the filtering layer. --- .../tools/__tests__/validateToolUse.spec.ts | 9 +++++++ src/core/tools/validateToolUse.ts | 24 ++++++++++--------- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/src/core/tools/__tests__/validateToolUse.spec.ts b/src/core/tools/__tests__/validateToolUse.spec.ts index db315bca118..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) + }) }) }) 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) {