feat: add disabledTools setting to globally disable native tools (#11277)

* 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

* 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.
This commit is contained in:
Daniel 2026-02-06 18:51:18 -05:00 committed by GitHub
parent ca7e3b6161
commit 6d2459c7e8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 221 additions and 13 deletions

View file

@ -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)
})
})

View file

@ -101,6 +101,7 @@ export const organizationDefaultSettingsSchema = globalSettingsSchema
terminalShellIntegrationDisabled: true,
terminalShellIntegrationTimeout: true,
terminalZshClearEolMark: true,
disabledTools: true,
})
// Add stronger validations for some fields.
.merge(

View file

@ -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<typeof globalSettingsSchema>

View file

@ -334,6 +334,7 @@ export type ExtensionState = Pick<
| "maxGitStatusFiles"
| "requestDelaySeconds"
| "showWorktreesInHomeScreen"
| "disabledTools"
> & {
version: string
clineMessages: ClineMessage[]

View file

@ -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<string, boolean>, tool: string) => {
acc[tool] = false
return acc
},
{} as Record<string, boolean>,
) ?? {}
validateToolUse(
block.name as ToolName,
mode ?? defaultModeSlug,
customModes ?? [],
{},
toolRequirements,
block.params,
stateExperiments,
includedTools,

View file

@ -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")
})
})

View file

@ -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")

View file

@ -1787,6 +1787,7 @@ export class Task extends EventEmitter<TaskEvents> 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<TaskEvents> 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<TaskEvents> 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<TaskEvents> implements TaskLike {
experiments: state?.experiments,
apiConfiguration,
browserToolEnabled: state?.browserToolEnabled ?? true,
disabledTools: state?.disabledTools,
modelInfo,
includeAllToolsWithRestrictions: supportsAllowedFunctionNames,
})

View file

@ -23,6 +23,7 @@ interface BuildToolsOptions {
experiments: Record<string, boolean> | 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,
}

View file

@ -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<string, boolean>, tool: string) => {
acc[tool] = false
return acc
},
{} as Record<string, boolean>,
)
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<string, boolean>, tool: string) => {
acc[tool] = false
return acc
},
{} as Record<string, boolean>,
)
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<string, boolean>, tool: string) => {
acc[tool] = false
return acc
},
{} as Record<string, boolean>,
)
expect(() => validateToolUse("execute_command", codeMode, [], toolRequirements)).not.toThrow()
})
})
})

View file

@ -126,7 +126,19 @@ export function isToolAllowedForMode(
experiments?: Record<string, boolean>,
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) {

View file

@ -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,