Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions packages/types/src/__tests__/cloud.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

import {
organizationCloudSettingsSchema,
organizationDefaultSettingsSchema,
organizationFeaturesSchema,
organizationSettingsSchema,
userSettingsConfigSchema,
type OrganizationCloudSettings,
type OrganizationDefaultSettings,
type OrganizationFeatures,
type OrganizationSettings,
type UserSettingsConfig,
Expand Down Expand Up @@ -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)
})
})
1 change: 1 addition & 0 deletions packages/types/src/cloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ export const organizationDefaultSettingsSchema = globalSettingsSchema
terminalShellIntegrationDisabled: true,
terminalShellIntegrationTimeout: true,
terminalZshClearEolMark: true,
disabledTools: true,
})
// Add stronger validations for some fields.
.merge(
Expand Down
7 changes: 7 additions & 0 deletions packages/types/src/global-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

/**
Expand Down Expand Up @@ -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>
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,7 @@ export type ExtensionState = Pick<
| "maxGitStatusFiles"
| "requestDelaySeconds"
| "showWorktreesInHomeScreen"
| "disabledTools"
> & {
version: string
clineMessages: ClineMessage[]
Expand Down
13 changes: 11 additions & 2 deletions src/core/assistant-message/presentAssistantMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
80 changes: 80 additions & 0 deletions src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
7 changes: 7 additions & 0 deletions src/core/prompts/tools/filter-tools-for-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
4 changes: 4 additions & 0 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
Expand Down Expand Up @@ -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,
})
Expand Down Expand Up @@ -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,
})
Expand Down Expand Up @@ -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,
})
Expand Down
3 changes: 3 additions & 0 deletions src/core/task/build-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -88,6 +89,7 @@ export async function buildNativeToolsArrayWithRestrictions(options: BuildToolsO
experiments,
apiConfiguration,
browserToolEnabled,
disabledTools,
modelInfo,
includeAllToolsWithRestrictions,
} = options
Expand All @@ -102,6 +104,7 @@ export async function buildNativeToolsArrayWithRestrictions(options: BuildToolsO
const filterSettings = {
todoListEnabled: apiConfiguration?.todoListEnabled ?? true,
browserToolEnabled: browserToolEnabled ?? true,
disabledTools,
modelInfo,
}

Expand Down
54 changes: 54 additions & 0 deletions src/core/tools/__tests__/validateToolUse.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
})

Expand Down Expand Up @@ -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()
})
})
})
24 changes: 13 additions & 11 deletions src/core/tools/validateToolUse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2037,6 +2037,7 @@ export class ClineProvider
maxOpenTabsContext,
maxWorkspaceFiles,
browserToolEnabled,
disabledTools,
telemetrySetting,
showRooIgnoredFiles,
enableSubfolderRules,
Expand Down Expand Up @@ -2174,6 +2175,7 @@ export class ClineProvider
maxWorkspaceFiles: maxWorkspaceFiles ?? 200,
cwd,
browserToolEnabled: browserToolEnabled ?? true,
disabledTools,
telemetrySetting,
telemetryKey,
machineId,
Expand Down Expand Up @@ -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,
Expand Down
Loading