Skip to content

Commit 6d2459c

Browse files
authored
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.
1 parent ca7e3b6 commit 6d2459c

12 files changed

Lines changed: 221 additions & 13 deletions

File tree

packages/types/src/__tests__/cloud.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22

33
import {
44
organizationCloudSettingsSchema,
5+
organizationDefaultSettingsSchema,
56
organizationFeaturesSchema,
67
organizationSettingsSchema,
78
userSettingsConfigSchema,
89
type OrganizationCloudSettings,
10+
type OrganizationDefaultSettings,
911
type OrganizationFeatures,
1012
type OrganizationSettings,
1113
type UserSettingsConfig,
@@ -481,3 +483,38 @@ describe("userSettingsConfigSchema with llmEnhancedFeaturesEnabled", () => {
481483
expect(result.data?.llmEnhancedFeaturesEnabled).toBe(true)
482484
})
483485
})
486+
487+
describe("organizationDefaultSettingsSchema with disabledTools", () => {
488+
it("should accept disabledTools as an array of valid tool names", () => {
489+
const input: OrganizationDefaultSettings = {
490+
disabledTools: ["execute_command", "browser_action"],
491+
}
492+
const result = organizationDefaultSettingsSchema.safeParse(input)
493+
expect(result.success).toBe(true)
494+
expect(result.data?.disabledTools).toEqual(["execute_command", "browser_action"])
495+
})
496+
497+
it("should accept empty disabledTools array", () => {
498+
const input: OrganizationDefaultSettings = {
499+
disabledTools: [],
500+
}
501+
const result = organizationDefaultSettingsSchema.safeParse(input)
502+
expect(result.success).toBe(true)
503+
expect(result.data?.disabledTools).toEqual([])
504+
})
505+
506+
it("should accept omitted disabledTools", () => {
507+
const input: OrganizationDefaultSettings = {}
508+
const result = organizationDefaultSettingsSchema.safeParse(input)
509+
expect(result.success).toBe(true)
510+
expect(result.data?.disabledTools).toBeUndefined()
511+
})
512+
513+
it("should reject invalid tool names in disabledTools", () => {
514+
const input = {
515+
disabledTools: ["not_a_real_tool"],
516+
}
517+
const result = organizationDefaultSettingsSchema.safeParse(input)
518+
expect(result.success).toBe(false)
519+
})
520+
})

packages/types/src/cloud.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ export const organizationDefaultSettingsSchema = globalSettingsSchema
101101
terminalShellIntegrationDisabled: true,
102102
terminalShellIntegrationTimeout: true,
103103
terminalZshClearEolMark: true,
104+
disabledTools: true,
104105
})
105106
// Add stronger validations for some fields.
106107
.merge(

packages/types/src/global-settings.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { experimentsSchema } from "./experiment.js"
1313
import { telemetrySettingsSchema } from "./telemetry.js"
1414
import { modeConfigSchema } from "./mode.js"
1515
import { customModePromptsSchema, customSupportPromptsSchema } from "./mode.js"
16+
import { toolNamesSchema } from "./tool.js"
1617
import { languagesSchema } from "./vscode.js"
1718

1819
/**
@@ -232,6 +233,12 @@ export const globalSettingsSchema = z.object({
232233
* @default true
233234
*/
234235
showWorktreesInHomeScreen: z.boolean().optional(),
236+
237+
/**
238+
* List of native tool names to globally disable.
239+
* Tools in this list will be excluded from prompt generation and rejected at execution time.
240+
*/
241+
disabledTools: z.array(toolNamesSchema).optional(),
235242
})
236243

237244
export type GlobalSettings = z.infer<typeof globalSettingsSchema>

packages/types/src/vscode-extension-host.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,7 @@ export type ExtensionState = Pick<
334334
| "maxGitStatusFiles"
335335
| "requestDelaySeconds"
336336
| "showWorktreesInHomeScreen"
337+
| "disabledTools"
337338
> & {
338339
version: string
339340
clineMessages: ClineMessage[]

src/core/assistant-message/presentAssistantMessage.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -335,7 +335,7 @@ export async function presentAssistantMessage(cline: Task) {
335335

336336
// Fetch state early so it's available for toolDescription and validation
337337
const state = await cline.providerRef.deref()?.getState()
338-
const { mode, customModes, experiments: stateExperiments } = state ?? {}
338+
const { mode, customModes, experiments: stateExperiments, disabledTools } = state ?? {}
339339

340340
const toolDescription = (): string => {
341341
switch (block.name) {
@@ -625,11 +625,20 @@ export async function presentAssistantMessage(cline: Task) {
625625
const includedTools = rawIncludedTools?.map((tool) => resolveToolAlias(tool))
626626

627627
try {
628+
const toolRequirements =
629+
disabledTools?.reduce(
630+
(acc: Record<string, boolean>, tool: string) => {
631+
acc[tool] = false
632+
return acc
633+
},
634+
{} as Record<string, boolean>,
635+
) ?? {}
636+
628637
validateToolUse(
629638
block.name as ToolName,
630639
mode ?? defaultModeSlug,
631640
customModes ?? [],
632-
{},
641+
toolRequirements,
633642
block.params,
634643
stateExperiments,
635644
includedTools,
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// npx vitest run core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts
2+
3+
import type OpenAI from "openai"
4+
5+
import { filterNativeToolsForMode } from "../filter-tools-for-mode"
6+
7+
function makeTool(name: string): OpenAI.Chat.ChatCompletionTool {
8+
return {
9+
type: "function",
10+
function: {
11+
name,
12+
description: `${name} tool`,
13+
parameters: { type: "object", properties: {} },
14+
},
15+
} as OpenAI.Chat.ChatCompletionTool
16+
}
17+
18+
describe("filterNativeToolsForMode - disabledTools", () => {
19+
const nativeTools: OpenAI.Chat.ChatCompletionTool[] = [
20+
makeTool("execute_command"),
21+
makeTool("read_file"),
22+
makeTool("write_to_file"),
23+
makeTool("browser_action"),
24+
makeTool("apply_diff"),
25+
]
26+
27+
it("removes tools listed in settings.disabledTools", () => {
28+
const settings = {
29+
disabledTools: ["execute_command", "browser_action"],
30+
}
31+
32+
const result = filterNativeToolsForMode(nativeTools, "code", undefined, undefined, undefined, settings)
33+
34+
const resultNames = result.map((t) => (t as any).function.name)
35+
expect(resultNames).not.toContain("execute_command")
36+
expect(resultNames).not.toContain("browser_action")
37+
expect(resultNames).toContain("read_file")
38+
expect(resultNames).toContain("write_to_file")
39+
expect(resultNames).toContain("apply_diff")
40+
})
41+
42+
it("does not remove any tools when disabledTools is empty", () => {
43+
const settings = {
44+
disabledTools: [],
45+
}
46+
47+
const result = filterNativeToolsForMode(nativeTools, "code", undefined, undefined, undefined, settings)
48+
49+
const resultNames = result.map((t) => (t as any).function.name)
50+
expect(resultNames).toContain("execute_command")
51+
expect(resultNames).toContain("read_file")
52+
expect(resultNames).toContain("write_to_file")
53+
expect(resultNames).toContain("browser_action")
54+
expect(resultNames).toContain("apply_diff")
55+
})
56+
57+
it("does not remove any tools when disabledTools is undefined", () => {
58+
const settings = {}
59+
60+
const result = filterNativeToolsForMode(nativeTools, "code", undefined, undefined, undefined, settings)
61+
62+
const resultNames = result.map((t) => (t as any).function.name)
63+
expect(resultNames).toContain("execute_command")
64+
expect(resultNames).toContain("read_file")
65+
})
66+
67+
it("combines disabledTools with other setting-based exclusions", () => {
68+
const settings = {
69+
browserToolEnabled: false,
70+
disabledTools: ["execute_command"],
71+
}
72+
73+
const result = filterNativeToolsForMode(nativeTools, "code", undefined, undefined, undefined, settings)
74+
75+
const resultNames = result.map((t) => (t as any).function.name)
76+
expect(resultNames).not.toContain("execute_command")
77+
expect(resultNames).not.toContain("browser_action")
78+
expect(resultNames).toContain("read_file")
79+
})
80+
})

src/core/prompts/tools/filter-tools-for-mode.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,13 @@ export function filterNativeToolsForMode(
296296
allowedToolNames.delete("browser_action")
297297
}
298298

299+
// Remove tools that are explicitly disabled via the disabledTools setting
300+
if (settings?.disabledTools?.length) {
301+
for (const toolName of settings.disabledTools) {
302+
allowedToolNames.delete(toolName)
303+
}
304+
}
305+
299306
// Conditionally exclude access_mcp_resource if MCP is not enabled or there are no resources
300307
if (!mcpHub || !hasAnyMcpResources(mcpHub)) {
301308
allowedToolNames.delete("access_mcp_resource")

src/core/task/Task.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1787,6 +1787,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
17871787
experiments: state?.experiments,
17881788
apiConfiguration,
17891789
browserToolEnabled: state?.browserToolEnabled ?? true,
1790+
disabledTools: state?.disabledTools,
17901791
modelInfo,
17911792
includeAllToolsWithRestrictions: false,
17921793
})
@@ -3888,6 +3889,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
38883889
experiments: state?.experiments,
38893890
apiConfiguration,
38903891
browserToolEnabled: state?.browserToolEnabled ?? true,
3892+
disabledTools: state?.disabledTools,
38913893
modelInfo,
38923894
includeAllToolsWithRestrictions: false,
38933895
})
@@ -4102,6 +4104,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
41024104
experiments: state?.experiments,
41034105
apiConfiguration,
41044106
browserToolEnabled: state?.browserToolEnabled ?? true,
4107+
disabledTools: state?.disabledTools,
41054108
modelInfo,
41064109
includeAllToolsWithRestrictions: false,
41074110
})
@@ -4266,6 +4269,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
42664269
experiments: state?.experiments,
42674270
apiConfiguration,
42684271
browserToolEnabled: state?.browserToolEnabled ?? true,
4272+
disabledTools: state?.disabledTools,
42694273
modelInfo,
42704274
includeAllToolsWithRestrictions: supportsAllowedFunctionNames,
42714275
})

src/core/task/build-tools.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ interface BuildToolsOptions {
2323
experiments: Record<string, boolean> | undefined
2424
apiConfiguration: ProviderSettings | undefined
2525
browserToolEnabled: boolean
26+
disabledTools?: string[]
2627
modelInfo?: ModelInfo
2728
/**
2829
* If true, returns all tools without mode filtering, but also includes
@@ -88,6 +89,7 @@ export async function buildNativeToolsArrayWithRestrictions(options: BuildToolsO
8889
experiments,
8990
apiConfiguration,
9091
browserToolEnabled,
92+
disabledTools,
9193
modelInfo,
9294
includeAllToolsWithRestrictions,
9395
} = options
@@ -102,6 +104,7 @@ export async function buildNativeToolsArrayWithRestrictions(options: BuildToolsO
102104
const filterSettings = {
103105
todoListEnabled: apiConfiguration?.todoListEnabled ?? true,
104106
browserToolEnabled: browserToolEnabled ?? true,
107+
disabledTools,
105108
modelInfo,
106109
}
107110

src/core/tools/__tests__/validateToolUse.spec.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,15 @@ describe("mode-validator", () => {
163163
// Even in code mode which allows all tools, disabled requirement should take precedence
164164
expect(isToolAllowedForMode("apply_diff", codeMode, [], requirements)).toBe(false)
165165
})
166+
167+
it("prioritizes requirements over ALWAYS_AVAILABLE_TOOLS", () => {
168+
// Tools in ALWAYS_AVAILABLE_TOOLS (switch_mode, new_task, etc.) should still
169+
// be blockable via toolRequirements / disabledTools
170+
const requirements = { switch_mode: false, new_task: false, attempt_completion: false }
171+
expect(isToolAllowedForMode("switch_mode", codeMode, [], requirements)).toBe(false)
172+
expect(isToolAllowedForMode("new_task", codeMode, [], requirements)).toBe(false)
173+
expect(isToolAllowedForMode("attempt_completion", codeMode, [], requirements)).toBe(false)
174+
})
166175
})
167176
})
168177

@@ -200,5 +209,50 @@ describe("mode-validator", () => {
200209
it("handles undefined requirements gracefully", () => {
201210
expect(() => validateToolUse("apply_diff", codeMode, [], undefined)).not.toThrow()
202211
})
212+
213+
it("blocks tool when disabledTools is converted to toolRequirements", () => {
214+
const disabledTools = ["execute_command", "browser_action"]
215+
const toolRequirements = disabledTools.reduce(
216+
(acc: Record<string, boolean>, tool: string) => {
217+
acc[tool] = false
218+
return acc
219+
},
220+
{} as Record<string, boolean>,
221+
)
222+
223+
expect(() => validateToolUse("execute_command", codeMode, [], toolRequirements)).toThrow(
224+
'Tool "execute_command" is not allowed in code mode.',
225+
)
226+
expect(() => validateToolUse("browser_action", codeMode, [], toolRequirements)).toThrow(
227+
'Tool "browser_action" is not allowed in code mode.',
228+
)
229+
})
230+
231+
it("allows non-disabled tools when disabledTools is converted to toolRequirements", () => {
232+
const disabledTools = ["execute_command"]
233+
const toolRequirements = disabledTools.reduce(
234+
(acc: Record<string, boolean>, tool: string) => {
235+
acc[tool] = false
236+
return acc
237+
},
238+
{} as Record<string, boolean>,
239+
)
240+
241+
expect(() => validateToolUse("read_file", codeMode, [], toolRequirements)).not.toThrow()
242+
expect(() => validateToolUse("write_to_file", codeMode, [], toolRequirements)).not.toThrow()
243+
})
244+
245+
it("handles empty disabledTools array converted to toolRequirements", () => {
246+
const disabledTools: string[] = []
247+
const toolRequirements = disabledTools.reduce(
248+
(acc: Record<string, boolean>, tool: string) => {
249+
acc[tool] = false
250+
return acc
251+
},
252+
{} as Record<string, boolean>,
253+
)
254+
255+
expect(() => validateToolUse("execute_command", codeMode, [], toolRequirements)).not.toThrow()
256+
})
203257
})
204258
})

0 commit comments

Comments
 (0)