Просмотр исходного кода

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.
Daniel 6 дней назад
Родитель
Сommit
6d2459c7e8

+ 37 - 0
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)
+	})
+})

+ 1 - 0
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(

+ 7 - 0
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<typeof globalSettingsSchema>

+ 1 - 0
packages/types/src/vscode-extension-host.ts

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

+ 11 - 2
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<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,

+ 80 - 0
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")
+	})
+})

+ 7 - 0
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")

+ 4 - 0
src/core/task/Task.ts

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

+ 3 - 0
src/core/task/build-tools.ts

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

+ 54 - 0
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<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()
+		})
 	})
 })

+ 13 - 11
src/core/tools/validateToolUse.ts

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

+ 3 - 0
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,